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

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new f53314e4f feat(arrow): read manifest entries into Arrow with 
arrow-avro (#3388)
f53314e4f is described below

commit f53314e4fab2e2708bd6b94041b701463653fcc2
Author: Matt Butrovich <[email protected]>
AuthorDate: Wed Nov 4 17:31:07 2026 +0000

    feat(arrow): read manifest entries into Arrow with arrow-avro (#3388)
    
    * feat(arrow): convert manifest entries to an Arrow RecordBatch
    
    * test(arrow): cover manifest versions, count overflow, and a PyIceberg 
manifest
    
    * refactor(arrow): assert the shape of the generated manifest_entry schema
    
    * feat(arrow): read manifest entries into Arrow with arrow-avro
    
    * address PR feedback
    
    * Return an error if arrow-avro splits a manifest into more than one batch
    
    * fix v2 discrepancy
    
    * Update manifest reader tests for Arrow Binary mapping
    
    Co-authored-by: Copilot App <[email protected]>
    
    * Export read_manifest_entries by name
    
    Co-authored-by: Copilot App <[email protected]>
    
    ---------
    
    Co-authored-by: Kevin Liu <[email protected]>
    Co-authored-by: Copilot App <[email protected]>
---
 Cargo.lock                                |   27 +-
 Cargo.toml                                |    5 +
 crates/iceberg/Cargo.toml                 |    1 +
 crates/iceberg/public-api.txt             |    1 +
 crates/iceberg/src/arrow/manifest.rs      | 1422 +++++++++++++++++++++++++++++
 crates/iceberg/src/arrow/mod.rs           |    2 +
 crates/iceberg/src/spec/manifest/entry.rs |   18 +
 7 files changed, 1474 insertions(+), 2 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 1feac7435..e5084cd69 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -289,6 +289,28 @@ dependencies = [
  "num-traits",
 ]
 
+[[package]]
+name = "arrow-avro"
+version = "59.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "9145b685d586482102e4fe3d40349fe00086c0b219d51cae9daaead4698849ea"
+dependencies = [
+ "arrow-array",
+ "arrow-buffer",
+ "arrow-schema",
+ "bytes",
+ "crc",
+ "flate2",
+ "indexmap 2.14.0",
+ "rand 0.9.5",
+ "serde",
+ "serde_json",
+ "snap",
+ "strum_macros 0.28.0",
+ "uuid",
+ "zstd",
+]
+
 [[package]]
 name = "arrow-buffer"
 version = "59.3.2"
@@ -1455,7 +1477,7 @@ version = "3.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
 dependencies = [
- "windows-sys 0.48.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -2873,6 +2895,7 @@ dependencies = [
  "array-init",
  "arrow-arith",
  "arrow-array",
+ "arrow-avro",
  "arrow-buffer",
  "arrow-cast",
  "arrow-ord",
@@ -7403,7 +7426,7 @@ version = "0.1.11"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
 dependencies = [
- "windows-sys 0.48.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 1a63d85be..5410bdba6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -51,6 +51,11 @@ array-init = "2"
 arrow = "59.2"
 arrow-arith = "59.2"
 arrow-array = "59.2"
+arrow-avro = { version = "59.2", default-features = false, features = [
+  "deflate",
+  "snappy",
+  "zstd",
+] }
 arrow-buffer = "59.2"
 arrow-cast = "59.2"
 arrow-ord = "59.2"
diff --git a/crates/iceberg/Cargo.toml b/crates/iceberg/Cargo.toml
index fa5e6cce7..1ca8d59d6 100644
--- a/crates/iceberg/Cargo.toml
+++ b/crates/iceberg/Cargo.toml
@@ -39,6 +39,7 @@ apache-avro = { workspace = true }
 array-init = { workspace = true }
 arrow-arith = { workspace = true }
 arrow-array = { workspace = true }
+arrow-avro = { workspace = true }
 arrow-buffer = { workspace = true }
 arrow-cast = { workspace = true }
 arrow-ord = { workspace = true }
diff --git a/crates/iceberg/public-api.txt b/crates/iceberg/public-api.txt
index 92267f0a9..29acb011c 100644
--- a/crates/iceberg/public-api.txt
+++ b/crates/iceberg/public-api.txt
@@ -131,6 +131,7 @@ pub fn 
iceberg::arrow::arrow_schema_to_schema_auto_assign_ids(schema: &arrow_sch
 pub fn iceberg::arrow::arrow_struct_to_literal(struct_array: 
&arrow_array::array::ArrayRef, ty: &iceberg::spec::StructType) -> 
iceberg::Result<alloc::vec::Vec<core::option::Option<iceberg::spec::Literal>>>
 pub fn iceberg::arrow::arrow_type_to_type(ty: 
&arrow_schema::datatype::DataType) -> iceberg::Result<iceberg::spec::Type>
 pub fn iceberg::arrow::datum_to_arrow_type_with_ree(datum: 
&iceberg::spec::Datum) -> arrow_schema::datatype::DataType
+pub fn iceberg::arrow::read_manifest_entries(bytes: &[u8], manifest_file: 
&iceberg::spec::ManifestFile) -> 
iceberg::Result<arrow_array::record_batch::RecordBatch>
 pub fn iceberg::arrow::schema_to_arrow_schema(schema: &iceberg::spec::Schema) 
-> iceberg::Result<arrow_schema::schema::Schema>
 pub fn iceberg::arrow::strip_metadata_from_schema(schema: 
&arrow_schema::schema::Schema) -> iceberg::Result<arrow_schema::schema::Schema>
 pub fn iceberg::arrow::type_to_arrow_type(ty: &iceberg::spec::Type) -> 
iceberg::Result<arrow_schema::datatype::DataType>
diff --git a/crates/iceberg/src/arrow/manifest.rs 
b/crates/iceberg/src/arrow/manifest.rs
new file mode 100644
index 000000000..57bb2402a
--- /dev/null
+++ b/crates/iceberg/src/arrow/manifest.rs
@@ -0,0 +1,1422 @@
+// 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.
+
+//! Reading manifest entries into Arrow.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use arrow_arith::boolean::{and, is_null};
+use arrow_array::builder::Int64Builder;
+use arrow_array::cast::AsArray;
+use arrow_array::types::{TimestampMicrosecondType, TimestampNanosecondType};
+use arrow_array::{
+    Array, ArrayRef, BooleanArray, Int32Array, Int64Array, ListArray, 
MapArray, RecordBatch,
+    StructArray, new_null_array,
+};
+use arrow_avro::reader::ReaderBuilder;
+use arrow_avro::schema::AvroSchema;
+use arrow_cast::{CastOptions, cast_with_options};
+use arrow_ord::cmp::eq;
+use arrow_schema::{ArrowError, DataType, Field, Fields, TimeUnit};
+use arrow_select::zip::zip;
+use parquet::arrow::PARQUET_FIELD_ID_META_KEY;
+use serde_json::Value as JsonValue;
+
+use crate::arrow::schema_to_arrow_schema;
+use crate::error::invalid_data;
+use crate::spec::{
+    FormatVersion, ManifestContentType, ManifestFile, ManifestMetadata, 
ManifestStatus, Schema,
+    manifest_entry_fields,
+};
+use crate::{Error, Result};
+
+/// Field ID of `data_file`.
+const DATA_FILE_FIELD_ID: i64 = 2;
+/// Field ID of `data_file.partition`.
+const PARTITION_FIELD_ID: i64 = 102;
+/// Field ID of `data_file.content`.
+const CONTENT_FIELD_ID: i32 = 134;
+/// Field ID of `sequence_number`.
+const SEQUENCE_NUMBER_FIELD_ID: i32 = 3;
+/// Field ID of `file_sequence_number`.
+const FILE_SEQUENCE_NUMBER_FIELD_ID: i32 = 4;
+
+/// Reads the entries of a manifest file into a [`RecordBatch`] with one row
+/// per entry.
+///
+/// `bytes` is the manifest file and `manifest_file` is its entry in the
+/// manifest list. The schema is the spec's `manifest_entry` struct for the
+/// latest format version, with field IDs in the Arrow field metadata, so
+/// manifests of every format version read into the same schema apart from
+/// `data_file.partition`, which follows the manifest's partition spec.
+///
+/// Fields are matched to the manifest's Avro schema by field ID. A v1
+/// manifest reads with the spec's v2+ defaults, and null snapshot IDs,
+/// sequence numbers, and first row IDs are inherited from `manifest_file`.
+/// Lower and upper bounds keep the binary single-value serialization they were
+/// written with.
+///
+/// # Example
+///
+/// ```
+/// use iceberg::arrow::read_manifest_entries;
+/// use iceberg::spec::{ManifestContentType, ManifestFile};
+///
+/// let bytes = std::fs::read(concat!(
+///     env!("CARGO_MANIFEST_DIR"),
+///     "/testdata/manifests/pyiceberg-v2-data.avro"
+/// ))?;
+/// let manifest_file = ManifestFile {
+///     manifest_path: "s3://bucket/metadata/manifest.avro".to_string(),
+///     manifest_length: bytes.len() as i64,
+///     partition_spec_id: 0,
+///     content: ManifestContentType::Data,
+///     sequence_number: 1,
+///     min_sequence_number: 1,
+///     added_snapshot_id: 1,
+///     added_files_count: None,
+///     existing_files_count: None,
+///     deleted_files_count: None,
+///     added_rows_count: None,
+///     existing_rows_count: None,
+///     deleted_rows_count: None,
+///     partitions: None,
+///     key_metadata: None,
+///     first_row_id: None,
+/// };
+///
+/// let batch = read_manifest_entries(&bytes, &manifest_file)?;
+/// assert_eq!(batch.num_rows(), 2);
+/// # Ok::<(), Box<dyn std::error::Error>>(())
+/// ```
+pub fn read_manifest_entries(bytes: &[u8], manifest_file: &ManifestFile) -> 
Result<RecordBatch> {
+    // usize::MAX decodes the whole manifest into one batch. First row ID
+    // inheritance is a running sum over every entry in the file, and an empty
+    // manifest still returns its schema. arrow-avro sizes its buffers
+    // independently of the batch size, so this doesn't preallocate.
+    let builder = || ReaderBuilder::new().with_batch_size(usize::MAX);
+    let mut reader = builder().build(bytes).map_err(invalid_manifest)?;
+    let header = reader.avro_header();
+    let metadata: HashMap<String, Vec<u8>> = header
+        .metadata()
+        .map(|(key, value)| (String::from_utf8_lossy(key).into_owned(), 
value.to_vec()))
+        .collect();
+    let metadata = ManifestMetadata::parse(&metadata)?;
+    let mut avro_schema: JsonValue = serde_json::from_slice(
+        header
+            .get("avro.schema")
+            .ok_or_else(|| invalid_data!("manifest has no avro.schema"))?,
+    )?;
+    let partition_type = metadata
+        .partition_spec()
+        .partition_type(metadata.schema())?;
+    let schema = Arc::new(schema_to_arrow_schema(
+        &Schema::builder()
+            .with_fields(manifest_entry_fields(&partition_type))
+            .build()?,
+    )?);
+
+    // TODO(#3257): arrow-avro 59 can't decode a record with no fields, which
+    // is the partition of every unpartitioned manifest, so project it out and
+    // build it in `missing_field`. arrow-avro 60 fixes this
+    // (apache/arrow-rs#10771).
+    if remove_empty_partition(&mut avro_schema) {
+        reader = builder()
+            .with_reader_schema(AvroSchema::new(avro_schema.to_string()))
+            .build(bytes)
+            .map_err(invalid_manifest)?;
+    }
+
+    let Some(batch) = reader.next() else {
+        return Ok(RecordBatch::new_empty(schema));
+    };
+    let batch = batch.map_err(invalid_manifest)?;
+    // Taking only the first batch relies on arrow-avro filling it to the batch
+    // size, so fail instead of dropping entries if that changes.
+    if reader.next().is_some() {
+        return Err(invalid_data!(
+            "arrow-avro split the manifest into more than one batch"
+        ));
+    }
+    let entries = project_struct(
+        &StructArray::from(batch),
+        &avro_schema,
+        schema.fields(),
+        *metadata.format_version(),
+    )?;
+    Ok(RecordBatch::from(inherit(entries, manifest_file)?))
+}
+
+/// Removes `data_file.partition` from a manifest's Avro schema if it has no
+/// fields, and returns whether it did.
+fn remove_empty_partition(avro_schema: &mut JsonValue) -> bool {
+    let has_field_id = |field: &JsonValue, id: i64| field["field-id"].as_i64() 
== Some(id);
+    let Some(data_file_fields) = avro_schema["fields"]
+        .as_array_mut()
+        .and_then(|fields| {
+            fields
+                .iter_mut()
+                .find(|field| has_field_id(field, DATA_FILE_FIELD_ID))
+        })
+        .and_then(|data_file| data_file["type"]["fields"].as_array_mut())
+    else {
+        return false;
+    };
+    let len = data_file_fields.len();
+    data_file_fields.retain(|field| {
+        !(has_field_id(field, PARTITION_FIELD_ID)
+            && field["type"]["fields"]
+                .as_array()
+                .is_some_and(Vec::is_empty))
+    });
+    data_file_fields.len() != len
+}
+
+fn invalid_manifest(err: ArrowError) -> Error {
+    invalid_data!("failed to read manifest").with_source(err)
+}
+
+/// Returns the field ID of a field in the schema built from
+/// `manifest_entry_fields`, which gives every field one.
+fn field_id(field: &Field) -> i32 {
+    field.metadata()[PARQUET_FIELD_ID_META_KEY].parse().unwrap()
+}
+
+/// Returns the type of a nullable Avro field, `["null", T]`, as `T`.
+fn non_null_type(avro_type: &JsonValue) -> &JsonValue {
+    match avro_type.as_array().map(Vec::as_slice) {
+        Some([JsonValue::String(null), branch] | [branch, 
JsonValue::String(null)])
+            if null == "null" =>
+        {
+            branch
+        }
+        _ => avro_type,
+    }
+}
+
+/// Builds each of `fields` from the column of `array` that the Avro record
+/// `record` gives the same field ID.
+fn project_struct(
+    array: &StructArray,
+    record: &JsonValue,
+    fields: &Fields,
+    format_version: FormatVersion,
+) -> Result<StructArray> {
+    let avro_fields = record["fields"]
+        .as_array()
+        .ok_or_else(|| invalid_data!("manifest Avro type {record} is not a 
record"))?;
+    let mut columns = Vec::with_capacity(fields.len());
+    for field in fields {
+        let id = field_id(field);
+        columns.push(
+            match avro_fields
+                .iter()
+                .position(|avro_field| avro_field["field-id"].as_i64() == 
Some(id.into()))
+            {
+                Some(index) => project(
+                    array.column(index),
+                    non_null_type(&avro_fields[index]["type"]),
+                    field,
+                    format_version,
+                )?,
+                None => missing_field(field, array.len(), format_version)?,
+            },
+        );
+    }
+    StructArray::try_new_with_length(fields.clone(), columns, 
array.nulls().cloned(), array.len())
+        .map_err(invalid_manifest)
+}
+
+/// Builds the column for a field that the manifest doesn't have.
+fn missing_field(field: &Field, len: usize, format_version: FormatVersion) -> 
Result<ArrayRef> {
+    // v1 manifests have no content or sequence number columns, which v2+
+    // readers read as 0. A v2+ manifest may lack file_sequence_number (Java
+    // wrote none before 1.1.0), which reads as null and is inherited.
+    match field_id(field) {
+        CONTENT_FIELD_ID => Ok(Arc::new(Int32Array::from_value(0, len))),
+        SEQUENCE_NUMBER_FIELD_ID | FILE_SEQUENCE_NUMBER_FIELD_ID
+            if format_version == FormatVersion::V1 =>
+        {
+            Ok(Arc::new(Int64Array::from_value(0, len)))
+        }
+        _ if field.is_nullable() => Ok(new_null_array(field.data_type(), len)),
+        _ if matches!(field.data_type(), DataType::Struct(fields) if 
fields.is_empty()) => {
+            Ok(Arc::new(StructArray::new_empty_fields(len, None)))
+        }
+        id => Err(invalid_data!(
+            "manifest has no required field {} with field ID {id}",
+            field.name()
+        )),
+    }
+}
+
+/// Converts the decoded `array` of Avro type `avro_type` to `field`, the
+/// spec's Arrow field for it.
+fn project(
+    array: &ArrayRef,
+    avro_type: &JsonValue,
+    field: &Field,
+    format_version: FormatVersion,
+) -> Result<ArrayRef> {
+    let type_mismatch = || {
+        invalid_data!(
+            "manifest field {} has Avro type {avro_type}, which doesn't match 
{}",
+            field.name(),
+            field.data_type()
+        )
+    };
+    Ok(match field.data_type() {
+        DataType::Struct(fields) => Arc::new(project_struct(
+            array.as_struct_opt().ok_or_else(type_mismatch)?,
+            avro_type,
+            fields,
+            format_version,
+        )?),
+        DataType::List(element) => {
+            let list = array.as_list_opt::<i32>().ok_or_else(type_mismatch)?;
+            let values = project(
+                list.values(),
+                non_null_type(&avro_type["items"]),
+                element,
+                format_version,
+            )?;
+            Arc::new(
+                ListArray::try_new(
+                    element.clone(),
+                    list.offsets().clone(),
+                    values,
+                    list.nulls().cloned(),
+                )
+                .map_err(invalid_manifest)?,
+            )
+        }
+        // Avro stores maps with int keys as arrays of key-value records.
+        DataType::Map(entries, _) => {
+            let list = array.as_list_opt::<i32>().ok_or_else(type_mismatch)?;
+            let DataType::Struct(key_value) = entries.data_type() else {
+                unreachable!()
+            };
+            let entries_array = project_struct(
+                list.values().as_struct_opt().ok_or_else(type_mismatch)?,
+                &avro_type["items"],
+                key_value,
+                format_version,
+            )?;
+            Arc::new(
+                MapArray::try_new(
+                    entries.clone(),
+                    list.offsets().clone(),
+                    entries_array,
+                    list.nulls().cloned(),
+                    false,
+                )
+                .map_err(invalid_manifest)?,
+            )
+        }
+        // arrow-avro gives every timestamp a UTC time zone, so only the type
+        // changes.
+        DataType::Timestamp(TimeUnit::Microsecond, time_zone) => Arc::new(
+            array
+                .as_primitive_opt::<TimestampMicrosecondType>()
+                .ok_or_else(type_mismatch)?
+                .clone()
+                .with_timezone_opt(time_zone.clone()),
+        ),
+        DataType::Timestamp(TimeUnit::Nanosecond, time_zone) => Arc::new(
+            array
+                .as_primitive_opt::<TimestampNanosecondType>()
+                .ok_or_else(type_mismatch)?
+                .clone()
+                .with_timezone_opt(time_zone.clone()),
+        ),
+        data_type if array.data_type() == data_type => array.clone(),
+        // Without `safe: false`, a value that doesn't fit the spec's type 
would
+        // become null instead of an error.
+        data_type => cast_with_options(array, data_type, &CastOptions {
+            safe: false,
+            ..Default::default()
+        })
+        .map_err(invalid_manifest)?,
+    })
+}
+
+/// Fills in the values that the spec says entries inherit from the manifest
+/// list.
+fn inherit(entries: StructArray, manifest_file: &ManifestFile) -> 
Result<StructArray> {
+    let (fields, mut columns, nulls) = entries.into_parts();
+    let index = |name: &str| fields.find(name).unwrap().0;
+    let (status, snapshot_id, sequence_number, file_sequence_number, 
data_file) = (
+        index("status"),
+        index("snapshot_id"),
+        index("sequence_number"),
+        index("file_sequence_number"),
+        index("data_file"),
+    );
+
+    columns[snapshot_id] =
+        fill_nulls(&columns[snapshot_id], None, 
manifest_file.added_snapshot_id)?;
+    if columns[sequence_number].null_count() > 0 || 
columns[file_sequence_number].null_count() > 0 {
+        let added = eq(
+            &columns[status],
+            &Int32Array::new_scalar(ManifestStatus::Added as i32),
+        )?;
+        for column in [sequence_number, file_sequence_number] {
+            columns[column] = fill_nulls(
+                &columns[column],
+                Some(&added),
+                manifest_file.sequence_number,
+            )?;
+        }
+    }
+    if manifest_file.content == ManifestContentType::Data {
+        let (data_file_fields, mut data_file_columns, data_file_nulls) =
+            columns[data_file].as_struct().clone().into_parts();
+        let index = |name: &str| data_file_fields.find(name).unwrap().0;
+        let first_row_id = index("first_row_id");
+        data_file_columns[first_row_id] = inherit_first_row_ids(
+            data_file_columns[first_row_id].as_primitive(),
+            columns[status].as_primitive(),
+            data_file_columns[index("record_count")].as_primitive(),
+            manifest_file,
+        )?;
+        columns[data_file] = Arc::new(StructArray::new(
+            data_file_fields,
+            data_file_columns,
+            data_file_nulls,
+        ));
+    }
+    Ok(StructArray::new(fields, columns, nulls))
+}
+
+/// Replaces the nulls of `array` with `value`, only in the rows that `rows`
+/// selects when given.
+fn fill_nulls(array: &ArrayRef, rows: Option<&BooleanArray>, value: i64) -> 
Result<ArrayRef> {
+    if array.null_count() == 0 {
+        return Ok(array.clone());
+    }
+    let mask = match rows {
+        Some(rows) => and(&is_null(array)?, rows)?,
+        None => is_null(array)?,
+    };
+    Ok(zip(&mask, &Int64Array::new_scalar(value), array)?)
+}
+
+/// Assigns first row IDs to the data files that lack one, following first row
+/// ID inheritance. Deleted entries take no row IDs, because the spec's
+/// suggested manifest `first_row_id` increment, `added_rows_count` plus
+/// `existing_rows_count`, leaves no room for them. A manifest without a first
+/// row ID belongs to a snapshot without row lineage, so all its row IDs are
+/// null.
+fn inherit_first_row_ids(
+    first_row_ids: &Int64Array,
+    status: &Int32Array,
+    record_counts: &Int64Array,
+    manifest_file: &ManifestFile,
+) -> Result<ArrayRef> {
+    let Some(manifest_first_row_id) = manifest_file.first_row_id else {
+        return Ok(new_null_array(&DataType::Int64, first_row_ids.len()));
+    };
+    if first_row_ids.null_count() == 0 {
+        return Ok(Arc::new(first_row_ids.clone()));
+    }
+    let mut next_row_id = i64::try_from(manifest_first_row_id)?;
+    let mut builder = Int64Builder::with_capacity(first_row_ids.len());
+    for ((first_row_id, &status), &record_count) in first_row_ids
+        .iter()
+        .zip(status.values())
+        .zip(record_counts.values())
+    {
+        match first_row_id {
+            None if status != ManifestStatus::Deleted as i32 => {
+                if record_count < 0 {
+                    return Err(invalid_data!(
+                        "negative record_count {record_count} assigning first 
row IDs in {}",
+                        manifest_file.manifest_path
+                    ));
+                }
+                builder.append_value(next_row_id);
+                next_row_id = 
next_row_id.checked_add(record_count).ok_or_else(|| {
+                    invalid_data!(
+                        "row ID overflow assigning first row IDs in {}",
+                        manifest_file.manifest_path
+                    )
+                })?;
+            }
+            first_row_id => builder.append_option(first_row_id),
+        }
+    }
+    Ok(Arc::new(builder.finish()))
+}
+
+#[cfg(test)]
+mod tests {
+    use std::collections::HashMap;
+    use std::sync::Arc;
+
+    use apache_avro::types::Value as AvroValue;
+    use apache_avro::{Codec, DeflateSettings, ZstandardSettings};
+    use arrow_array::cast::AsArray;
+    use arrow_array::types::{Int32Type, Int64Type};
+    use arrow_array::{
+        Array, ArrayRef, BinaryArray, BooleanArray, Date32Array, 
Decimal128Array,
+        FixedSizeBinaryArray, Float32Array, Float64Array, Int32Array, 
Int64Array, StringArray,
+        Time64MicrosecondArray, TimestampMicrosecondArray, 
TimestampNanosecondArray,
+    };
+
+    use super::*;
+    use crate::ErrorKind;
+    use crate::arrow::arrow_schema_to_schema;
+    use crate::io::FileIO;
+    use crate::spec::{
+        DataContentType, DataFile, DataFileBuilder, DataFileFormat, Datum, 
ListType, Literal,
+        Manifest, ManifestEntry, ManifestStatus, ManifestWriter, 
ManifestWriterBuilder, MapType,
+        NestedField, PartitionSpec, PrimitiveLiteral, PrimitiveType, Schema, 
Struct, StructType,
+        Transform, Type,
+    };
+
+    fn table_schema() -> Schema {
+        let columns = [
+            (1, "b", PrimitiveType::Boolean),
+            (2, "i", PrimitiveType::Int),
+            (3, "l", PrimitiveType::Long),
+            (4, "f", PrimitiveType::Float),
+            (5, "d", PrimitiveType::Double),
+            (6, "dec", PrimitiveType::Decimal {
+                precision: 38,
+                scale: 10,
+            }),
+            (7, "date", PrimitiveType::Date),
+            (8, "time", PrimitiveType::Time),
+            (9, "ts", PrimitiveType::Timestamp),
+            (10, "tstz", PrimitiveType::Timestamptz),
+            (11, "ts_ns", PrimitiveType::TimestampNs),
+            (12, "tstz_ns", PrimitiveType::TimestamptzNs),
+            (13, "s", PrimitiveType::String),
+            (14, "fx", PrimitiveType::Fixed(3)),
+            (15, "bin", PrimitiveType::Binary),
+        ];
+        Schema::builder()
+            .with_fields(
+                columns.into_iter().map(|(id, name, ty)| {
+                    NestedField::optional(id, name, Type::Primitive(ty)).into()
+                }),
+            )
+            .build()
+            .unwrap()
+    }
+
+    fn partition_spec(fields: &[(&str, Transform)]) -> PartitionSpec {
+        fields
+            .iter()
+            .fold(
+                PartitionSpec::builder(table_schema()).with_spec_id(0),
+                |builder, (source, transform)| {
+                    builder
+                        .add_partition_field(*source, 
format!("{source}_{transform}"), *transform)
+                        .unwrap()
+                },
+            )
+            .build()
+            .unwrap()
+    }
+
+    fn data_file(content: DataContentType, path: &str) -> DataFileBuilder {
+        let mut builder = DataFileBuilder::default();
+        builder
+            .content(content)
+            .file_path(path.to_string())
+            .file_format(DataFileFormat::Parquet)
+            .record_count(1)
+            .file_size_in_bytes(10);
+        builder
+    }
+
+    /// Writes a manifest with `ManifestWriter` and returns its bytes and its
+    /// manifest list entry.
+    async fn write_manifest(
+        spec: PartitionSpec,
+        snapshot_id: Option<i64>,
+        build: impl FnOnce(ManifestWriterBuilder) -> ManifestWriter,
+        add: impl FnOnce(&mut ManifestWriter),
+    ) -> (Vec<u8>, ManifestFile) {
+        let io = FileIO::new_with_memory();
+        let output = io.new_output("memory:///manifest.avro").unwrap();
+        let mut writer = build(ManifestWriterBuilder::new(
+            output,
+            snapshot_id,
+            Arc::new(table_schema()),
+            spec,
+        ));
+        add(&mut writer);
+        let manifest_file = writer.write_manifest_file().await.unwrap();
+        let bytes = io
+            .new_input("memory:///manifest.avro")
+            .unwrap()
+            .read()
+            .await
+            .unwrap()
+            .to_vec();
+        (bytes, manifest_file)
+    }
+
+    fn column(batch: &RecordBatch, name: &str) -> ArrayRef {
+        batch.column_by_name(name).unwrap().clone()
+    }
+
+    fn data_file_column(batch: &RecordBatch, name: &str) -> ArrayRef {
+        column(batch, "data_file")
+            .as_struct()
+            .column_by_name(name)
+            .unwrap()
+            .clone()
+    }
+
+    /// Entries of the map in `row` of a `map<int, long>` column, sorted by 
key.
+    fn long_map(column: &ArrayRef, row: usize) -> Vec<(i32, i64)> {
+        let entries = column.as_map().value(row);
+        let keys = entries.column(0).as_primitive::<Int32Type>();
+        let values = entries.column(1).as_primitive::<Int64Type>();
+        let mut pairs: Vec<_> = keys
+            .values()
+            .iter()
+            .copied()
+            .zip(values.values().iter().copied())
+            .collect();
+        pairs.sort_unstable();
+        pairs
+    }
+
+    /// Entries of the map in `row` of a `map<int, binary>` column, sorted by 
key.
+    fn binary_map(column: &ArrayRef, row: usize) -> Vec<(i32, Vec<u8>)> {
+        let entries = column.as_map().value(row);
+        let keys = entries.column(0).as_primitive::<Int32Type>();
+        let values = entries.column(1).as_binary::<i32>();
+        let mut pairs: Vec<_> = keys
+            .values()
+            .iter()
+            .copied()
+            .zip(values.iter().map(|v| v.unwrap().to_vec()))
+            .collect();
+        pairs.sort_unstable();
+        pairs
+    }
+
+    fn int_map_type(key_id: i32, value_id: i32, value: PrimitiveType) -> Type {
+        Type::Map(MapType::new(
+            NestedField::map_key_element(key_id, 
Type::Primitive(PrimitiveType::Int)).into(),
+            NestedField::map_value_element(value_id, Type::Primitive(value), 
true).into(),
+        ))
+    }
+
+    fn list_type(element_id: i32, element: PrimitiveType) -> Type {
+        Type::List(ListType::new(
+            NestedField::list_element(element_id, Type::Primitive(element), 
true).into(),
+        ))
+    }
+
+    /// Rewrites a manifest with `codec`, `edit_schema` applied to its Avro
+    /// schema, and `edit_record` applied to each record.
+    fn rewrite_manifest(
+        bytes: &[u8],
+        codec: Codec,
+        edit_schema: impl Fn(&mut serde_json::Value),
+        edit_record: impl Fn(&mut AvroValue),
+    ) -> Vec<u8> {
+        let reader = apache_avro::Reader::new(bytes).unwrap();
+        let mut schema = serde_json::to_value(reader.writer_schema()).unwrap();
+        edit_schema(&mut schema);
+        let schema = apache_avro::Schema::parse(&schema).unwrap();
+        let metadata = reader.user_metadata().clone();
+        let mut writer = apache_avro::Writer::with_codec(&schema, Vec::new(), 
codec).unwrap();
+        for (key, value) in metadata {
+            writer.add_user_metadata(key, value).unwrap();
+        }
+        for record in reader {
+            let mut record = record.unwrap();
+            edit_record(&mut record);
+            writer.append_value(record).unwrap();
+        }
+        writer.into_inner().unwrap()
+    }
+
+    /// Calls `edit` on every record field object in an Avro schema.
+    fn for_each_avro_field(schema: &mut serde_json::Value, edit: &impl Fn(&mut 
serde_json::Value)) {
+        match schema {
+            serde_json::Value::Object(object) => {
+                if let Some(serde_json::Value::Array(fields)) = 
object.get_mut("fields") {
+                    for field in fields {
+                        edit(field);
+                        for_each_avro_field(field.get_mut("type").unwrap(), 
edit);
+                    }
+                }
+                if let Some(items) = object.get_mut("items") {
+                    for_each_avro_field(items, edit);
+                }
+            }
+            serde_json::Value::Array(branches) => {
+                for branch in branches {
+                    for_each_avro_field(branch, edit);
+                }
+            }
+            _ => {}
+        }
+    }
+
+    /// Renames the record field `from` to `to` in every Avro record value.
+    fn rename_avro_field(value: &mut AvroValue, from: &str, to: &str) {
+        match value {
+            AvroValue::Record(fields) => {
+                for (name, field) in fields {
+                    if name == from {
+                        *name = to.to_string();
+                    }
+                    rename_avro_field(field, from, to);
+                }
+            }
+            AvroValue::Union(_, value) => rename_avro_field(value, from, to),
+            AvroValue::Array(items) => {
+                for item in items {
+                    rename_avro_field(item, from, to);
+                }
+            }
+            _ => {}
+        }
+    }
+
+    #[tokio::test]
+    async fn test_schema_is_spec_manifest_entry() {
+        let spec = partition_spec(&[("i", Transform::Identity), ("s", 
Transform::Truncate(2))]);
+        let (bytes, manifest_file) =
+            write_manifest(spec, Some(1), |b| b.build_v2_data(), |_| {}).await;
+        let batch = read_manifest_entries(&bytes, &manifest_file).unwrap();
+        assert_eq!(batch.num_rows(), 0);
+
+        let long = || Type::Primitive(PrimitiveType::Long);
+        let int = || Type::Primitive(PrimitiveType::Int);
+        let string = || Type::Primitive(PrimitiveType::String);
+        let data_file = StructType::new(vec![
+            NestedField::required(134, "content", int()).into(),
+            NestedField::required(100, "file_path", string()).into(),
+            NestedField::required(101, "file_format", string()).into(),
+            NestedField::required(
+                102,
+                "partition",
+                Type::Struct(StructType::new(vec![
+                    NestedField::optional(1000, "i_identity", int()).into(),
+                    NestedField::optional(1001, "s_truncate[2]", 
string()).into(),
+                ])),
+            )
+            .into(),
+            NestedField::required(103, "record_count", long()).into(),
+            NestedField::required(104, "file_size_in_bytes", long()).into(),
+            NestedField::optional(
+                108,
+                "column_sizes",
+                int_map_type(117, 118, PrimitiveType::Long),
+            )
+            .into(),
+            NestedField::optional(
+                109,
+                "value_counts",
+                int_map_type(119, 120, PrimitiveType::Long),
+            )
+            .into(),
+            NestedField::optional(
+                110,
+                "null_value_counts",
+                int_map_type(121, 122, PrimitiveType::Long),
+            )
+            .into(),
+            NestedField::optional(
+                137,
+                "nan_value_counts",
+                int_map_type(138, 139, PrimitiveType::Long),
+            )
+            .into(),
+            NestedField::optional(
+                125,
+                "lower_bounds",
+                int_map_type(126, 127, PrimitiveType::Binary),
+            )
+            .into(),
+            NestedField::optional(
+                128,
+                "upper_bounds",
+                int_map_type(129, 130, PrimitiveType::Binary),
+            )
+            .into(),
+            NestedField::optional(131, "key_metadata", 
Type::Primitive(PrimitiveType::Binary))
+                .into(),
+            NestedField::optional(132, "split_offsets", list_type(133, 
PrimitiveType::Long)).into(),
+            NestedField::optional(135, "equality_ids", list_type(136, 
PrimitiveType::Int)).into(),
+            NestedField::optional(140, "sort_order_id", int()).into(),
+            NestedField::optional(142, "first_row_id", long()).into(),
+            NestedField::optional(143, "referenced_data_file", 
string()).into(),
+            NestedField::optional(144, "content_offset", long()).into(),
+            NestedField::optional(145, "content_size_in_bytes", long()).into(),
+        ]);
+        let expected = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(0, "status", int()).into(),
+                NestedField::optional(1, "snapshot_id", long()).into(),
+                NestedField::optional(3, "sequence_number", long()).into(),
+                NestedField::optional(4, "file_sequence_number", 
long()).into(),
+                NestedField::required(2, "data_file", 
Type::Struct(data_file)).into(),
+            ])
+            .build()
+            .unwrap();
+        assert_eq!(
+            arrow_schema_to_schema(&batch.schema()).unwrap().as_struct(),
+            expected.as_struct()
+        );
+    }
+
+    #[tokio::test]
+    async fn test_data_file_columns() {
+        let added = DataFile {
+            column_sizes: HashMap::from([(1, 11)]),
+            value_counts: HashMap::from([(1, 10), (3, 9)]),
+            null_value_counts: HashMap::from([(1, 1)]),
+            nan_value_counts: HashMap::from([(4, 0)]),
+            lower_bounds: HashMap::from([(2, Datum::int(-1)), (13, 
Datum::string("abc"))]),
+            upper_bounds: HashMap::from([(2, Datum::int(7))]),
+            key_metadata: Some(vec![1, 2]),
+            split_offsets: Some(vec![4, 100]),
+            sort_order_id: Some(0),
+            ..data_file(DataContentType::Data, "s3://b/data/0.parquet")
+                .record_count(10)
+                .file_size_in_bytes(100)
+                .build()
+                .unwrap()
+        };
+        let existing = data_file(DataContentType::Data, 
"s3://b/data/1.parquet")
+            .build()
+            .unwrap();
+        let (bytes, manifest_file) = write_manifest(
+            partition_spec(&[]),
+            Some(1),
+            |b| b.build_v2_data(),
+            |writer| {
+                writer.add_file(added, 2).unwrap();
+                writer.add_existing_file(existing, 5, 3, Some(3)).unwrap();
+            },
+        )
+        .await;
+        let batch = read_manifest_entries(&bytes, &manifest_file).unwrap();
+        assert_eq!(batch.num_rows(), 2);
+
+        assert_eq!(
+            column(&batch, "status").as_ref(),
+            &Int32Array::from(vec![1, 0]) as &dyn Array
+        );
+        assert_eq!(
+            column(&batch, "snapshot_id").as_ref(),
+            &Int64Array::from(vec![1, 5]) as &dyn Array
+        );
+        assert_eq!(
+            column(&batch, "sequence_number").as_ref(),
+            &Int64Array::from(vec![2, 3]) as &dyn Array
+        );
+
+        let column = |name: &str| data_file_column(&batch, name);
+        assert_eq!(
+            column("content").as_ref(),
+            &Int32Array::from(vec![0, 0]) as &dyn Array
+        );
+        assert_eq!(
+            column("file_path").as_ref(),
+            &StringArray::from(vec!["s3://b/data/0.parquet", 
"s3://b/data/1.parquet"])
+                as &dyn Array
+        );
+        assert_eq!(
+            column("file_format").as_ref(),
+            &StringArray::from(vec!["PARQUET", "PARQUET"]) as &dyn Array
+        );
+        assert_eq!(
+            column("record_count").as_ref(),
+            &Int64Array::from(vec![10, 1]) as &dyn Array
+        );
+        assert_eq!(
+            column("file_size_in_bytes").as_ref(),
+            &Int64Array::from(vec![100, 10]) as &dyn Array
+        );
+        assert_eq!(long_map(&column("column_sizes"), 0), vec![(1, 11)]);
+        assert_eq!(long_map(&column("value_counts"), 0), vec![(1, 10), (3, 
9)]);
+        assert_eq!(long_map(&column("null_value_counts"), 0), vec![(1, 1)]);
+        assert_eq!(long_map(&column("nan_value_counts"), 0), vec![(4, 0)]);
+        assert_eq!(long_map(&column("value_counts"), 1), vec![]);
+        assert_eq!(binary_map(&column("lower_bounds"), 0), vec![
+            (2, (-1i32).to_le_bytes().to_vec()),
+            (13, b"abc".to_vec()),
+        ]);
+        assert_eq!(binary_map(&column("upper_bounds"), 0), vec![(
+            2,
+            7i32.to_le_bytes().to_vec()
+        )]);
+        assert_eq!(
+            column("key_metadata").as_ref(),
+            &BinaryArray::from(vec![Some(&[1u8, 2][..]), None]) as &dyn Array
+        );
+        let split_offsets = column("split_offsets");
+        assert_eq!(
+            split_offsets.as_list::<i32>().value(0).as_ref(),
+            &Int64Array::from(vec![4, 100]) as &dyn Array
+        );
+        assert_eq!(
+            column("sort_order_id").as_ref(),
+            &Int32Array::from(vec![Some(0), None]) as &dyn Array
+        );
+    }
+
+    #[tokio::test]
+    async fn test_delete_file_columns() {
+        let position_deletes = DataFile {
+            file_format: DataFileFormat::Puffin,
+            referenced_data_file: Some("s3://b/data/0.parquet".to_string()),
+            content_offset: Some(4),
+            content_size_in_bytes: Some(40),
+            ..data_file(DataContentType::PositionDeletes, 
"s3://b/data/1.puffin")
+                .build()
+                .unwrap()
+        };
+        let equality_deletes = DataFile {
+            equality_ids: Some(vec![1, 3]),
+            ..data_file(DataContentType::EqualityDeletes, 
"s3://b/data/2.parquet")
+                .build()
+                .unwrap()
+        };
+        let (bytes, manifest_file) = write_manifest(
+            partition_spec(&[]),
+            Some(1),
+            |b| b.build_v3_deletes(),
+            |writer| {
+                writer.add_file(position_deletes, 2).unwrap();
+                writer.add_file(equality_deletes, 2).unwrap();
+            },
+        )
+        .await;
+        let batch = read_manifest_entries(&bytes, &manifest_file).unwrap();
+
+        let column = |name: &str| data_file_column(&batch, name);
+        assert_eq!(
+            column("content").as_ref(),
+            &Int32Array::from(vec![1, 2]) as &dyn Array
+        );
+        assert_eq!(
+            column("file_format").as_ref(),
+            &StringArray::from(vec!["PUFFIN", "PARQUET"]) as &dyn Array
+        );
+        assert_eq!(
+            column("referenced_data_file").as_ref(),
+            &StringArray::from(vec![Some("s3://b/data/0.parquet"), None]) as 
&dyn Array
+        );
+        assert_eq!(
+            column("content_offset").as_ref(),
+            &Int64Array::from(vec![Some(4), None]) as &dyn Array
+        );
+        assert_eq!(
+            column("content_size_in_bytes").as_ref(),
+            &Int64Array::from(vec![Some(40), None]) as &dyn Array
+        );
+        let equality_ids = column("equality_ids");
+        let equality_ids = equality_ids.as_list::<i32>();
+        assert!(equality_ids.is_null(0));
+        assert_eq!(
+            equality_ids.value(1).as_ref(),
+            &Int32Array::from(vec![1, 3]) as &dyn Array
+        );
+    }
+
+    #[tokio::test]
+    async fn test_partition_values() {
+        let identity_columns = [
+            "b", "i", "l", "f", "d", "dec", "date", "time", "ts", "tstz", 
"ts_ns", "tstz_ns", "s",
+            "fx", "bin",
+        ];
+        let mut fields: Vec<_> = identity_columns
+            .iter()
+            .map(|column| (*column, Transform::Identity))
+            .collect();
+        fields.extend([
+            ("i", Transform::Bucket(4)),
+            ("ts", Transform::Day),
+            ("s", Transform::Truncate(2)),
+            ("l", Transform::Void),
+        ]);
+        let spec = partition_spec(&fields);
+
+        let values = Struct::from_iter([
+            Some(Literal::bool(true)),
+            Some(Literal::int(-7)),
+            Some(Literal::long(1i64 << 40)),
+            Some(Literal::float(1.5f32)),
+            Some(Literal::double(-2.25)),
+            Some(Literal::decimal(-12_345)),
+            Some(Literal::date(19_000)),
+            Some(Literal::time(3_600_000_000)),
+            Some(Literal::timestamp(1_700_000_000_000_000)),
+            Some(Literal::timestamptz(1_700_000_000_000_001)),
+            Some(Literal::timestamp_nano(1_700_000_000_000_000_002)),
+            Some(Literal::timestamptz_nano(1_700_000_000_000_000_003)),
+            Some(Literal::string("abc")),
+            Some(Literal::fixed([1u8, 2, 3])),
+            Some(Literal::binary([9u8])),
+            Some(Literal::int(3)),
+            Some(Literal::date(19_675)),
+            Some(Literal::string("ab")),
+            None,
+        ]);
+        let nulls = Struct::from_iter(fields.iter().map(|_| None));
+        let (bytes, manifest_file) = write_manifest(
+            spec,
+            Some(1),
+            |b| b.build_v2_data(),
+            |writer| {
+                for (i, partition) in [values, nulls].into_iter().enumerate() {
+                    let file = DataFile {
+                        partition,
+                        ..data_file(DataContentType::Data, 
&format!("s3://b/data/{i}.parquet"))
+                            .build()
+                            .unwrap()
+                    };
+                    writer.add_file(file, 1).unwrap();
+                }
+            },
+        )
+        .await;
+        let batch = read_manifest_entries(&bytes, &manifest_file).unwrap();
+
+        let partition = data_file_column(&batch, "partition");
+        let partition = partition.as_struct();
+        let expected: Vec<ArrayRef> = vec![
+            Arc::new(BooleanArray::from(vec![Some(true), None])),
+            Arc::new(Int32Array::from(vec![Some(-7), None])),
+            Arc::new(Int64Array::from(vec![Some(1i64 << 40), None])),
+            Arc::new(Float32Array::from(vec![Some(1.5), None])),
+            Arc::new(Float64Array::from(vec![Some(-2.25), None])),
+            Arc::new(
+                Decimal128Array::from(vec![Some(-12_345), None])
+                    .with_precision_and_scale(38, 10)
+                    .unwrap(),
+            ),
+            Arc::new(Date32Array::from(vec![Some(19_000), None])),
+            Arc::new(Time64MicrosecondArray::from(vec![
+                Some(3_600_000_000),
+                None,
+            ])),
+            Arc::new(TimestampMicrosecondArray::from(vec![
+                Some(1_700_000_000_000_000),
+                None,
+            ])),
+            Arc::new(
+                
TimestampMicrosecondArray::from(vec![Some(1_700_000_000_000_001), None])
+                    .with_timezone("+00:00"),
+            ),
+            Arc::new(TimestampNanosecondArray::from(vec![
+                Some(1_700_000_000_000_000_002),
+                None,
+            ])),
+            Arc::new(
+                
TimestampNanosecondArray::from(vec![Some(1_700_000_000_000_000_003), None])
+                    .with_timezone("+00:00"),
+            ),
+            Arc::new(StringArray::from(vec![Some("abc"), None])),
+            Arc::new(
+                FixedSizeBinaryArray::try_from_sparse_iter_with_size(
+                    [Some(vec![1u8, 2, 3]), None].into_iter(),
+                    3,
+                )
+                .unwrap(),
+            ),
+            Arc::new(BinaryArray::from(vec![Some(&[9u8][..]), None])),
+            Arc::new(Int32Array::from(vec![Some(3), None])),
+            Arc::new(Date32Array::from(vec![Some(19_675), None])),
+            Arc::new(StringArray::from(vec![Some("ab"), None])),
+            Arc::new(Int64Array::from(vec![None, None])),
+        ];
+        assert_eq!(partition.num_columns(), expected.len());
+        for (index, expected) in expected.iter().enumerate() {
+            assert_eq!(
+                partition.column(index).as_ref(),
+                expected.as_ref(),
+                "partition field {}",
+                partition.fields()[index].name()
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn test_v1_manifest_reads_with_v2_defaults() {
+        let file = data_file(DataContentType::Data, "s3://b/data/0.parquet")
+            .build()
+            .unwrap();
+        let (bytes, mut manifest_file) = write_manifest(
+            partition_spec(&[]),
+            Some(1),
+            |b| b.build_v1(),
+            |writer| writer.add_file(file, -1).unwrap(),
+        )
+        .await;
+        // The v1 default must win over inheritance, so give the manifest list
+        // entry a sequence number that inheritance would use.
+        manifest_file.sequence_number = 9;
+        let batch = read_manifest_entries(&bytes, &manifest_file).unwrap();
+
+        assert_eq!(
+            data_file_column(&batch, "content").as_ref(),
+            &Int32Array::from(vec![0]) as &dyn Array
+        );
+        for name in ["sequence_number", "file_sequence_number"] {
+            assert_eq!(
+                column(&batch, name).as_ref(),
+                &Int64Array::from(vec![0]) as &dyn Array,
+                "{name}"
+            );
+        }
+        assert_eq!(
+            column(&batch, "snapshot_id").as_ref(),
+            &Int64Array::from(vec![1]) as &dyn Array
+        );
+    }
+
+    #[tokio::test]
+    async fn test_snapshot_id_and_sequence_number_inheritance() {
+        let file = |i: usize| {
+            data_file(DataContentType::Data, 
&format!("s3://b/data/{i}.parquet"))
+                .build()
+                .unwrap()
+        };
+        let (bytes, mut manifest_file) = write_manifest(
+            partition_spec(&[]),
+            None,
+            |b| b.build_v2_data(),
+            |writer| {
+                writer.add_file(file(0), -1).unwrap();
+                writer.add_delete_file(file(1), 4, Some(4)).unwrap();
+                writer.add_existing_file(file(2), 3, 5, Some(5)).unwrap();
+            },
+        )
+        .await;
+        // ManifestWriter rejects an existing entry without sequence numbers.
+        let bytes = rewrite_manifest(
+            &bytes,
+            Codec::Null,
+            |_| {},
+            |record| {
+                let AvroValue::Record(fields) = record else {
+                    panic!("manifest entry is not a record");
+                };
+                if fields.contains(&("status".to_string(), AvroValue::Int(0))) 
{
+                    for (name, value) in fields {
+                        if name.ends_with("sequence_number") {
+                            *value = AvroValue::Union(0, 
Box::new(AvroValue::Null));
+                        }
+                    }
+                }
+            },
+        );
+        manifest_file.added_snapshot_id = 42;
+        manifest_file.sequence_number = 7;
+        let batch = read_manifest_entries(&bytes, &manifest_file).unwrap();
+
+        assert_eq!(
+            column(&batch, "snapshot_id").as_ref(),
+            &Int64Array::from(vec![42, 42, 3]) as &dyn Array
+        );
+        for name in ["sequence_number", "file_sequence_number"] {
+            assert_eq!(
+                column(&batch, name).as_ref(),
+                &Int64Array::from(vec![Some(7), Some(4), None]) as &dyn Array,
+                "{name}"
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn test_v2_manifest_without_file_sequence_number_inherits() {
+        let file = |i: usize| {
+            data_file(DataContentType::Data, 
&format!("s3://b/data/{i}.parquet"))
+                .build()
+                .unwrap()
+        };
+        let (bytes, mut manifest_file) = write_manifest(
+            partition_spec(&[]),
+            Some(1),
+            |b| b.build_v2_data(),
+            |writer| {
+                writer.add_file(file(0), -1).unwrap();
+                writer.add_existing_file(file(1), 3, 5, Some(5)).unwrap();
+            },
+        )
+        .await;
+        // Java wrote v2 manifests without file_sequence_number before 1.1.0.
+        let bytes = rewrite_manifest(
+            &bytes,
+            Codec::Null,
+            |schema| {
+                schema["fields"]
+                    .as_array_mut()
+                    .unwrap()
+                    .retain(|field| field["name"] != "file_sequence_number")
+            },
+            |record| {
+                let AvroValue::Record(fields) = record else {
+                    panic!("manifest entry is not a record");
+                };
+                fields.retain(|(name, _)| name != "file_sequence_number");
+            },
+        );
+        manifest_file.sequence_number = 7;
+        let batch = read_manifest_entries(&bytes, &manifest_file).unwrap();
+
+        assert_eq!(
+            column(&batch, "file_sequence_number").as_ref(),
+            &Int64Array::from(vec![Some(7), None]) as &dyn Array
+        );
+    }
+
+    async fn first_row_id_manifest() -> (Vec<u8>, ManifestFile) {
+        let file = |i: usize, record_count: u64, first_row_id: Option<i64>| 
DataFile {
+            first_row_id,
+            ..data_file(DataContentType::Data, 
&format!("s3://b/data/{i}.parquet"))
+                .record_count(record_count)
+                .build()
+                .unwrap()
+        };
+        let entry = |status, data_file| ManifestEntry {
+            status,
+            snapshot_id: Some(1),
+            sequence_number: Some(1),
+            file_sequence_number: Some(1),
+            data_file,
+        };
+        write_manifest(
+            partition_spec(&[]),
+            Some(1),
+            |b| b.build_v3_data(),
+            |writer| {
+                writer.add_file(file(0, 10, None), 1).unwrap();
+                writer
+                    .add_existing_entry(entry(ManifestStatus::Existing, 
file(1, 3, Some(5))))
+                    .unwrap();
+                writer
+                    .add_delete_entry(entry(ManifestStatus::Deleted, file(2, 
4, None)))
+                    .unwrap();
+                writer
+                    .add_existing_entry(entry(ManifestStatus::Existing, 
file(3, 6, None)))
+                    .unwrap();
+                writer.add_file(file(4, 2, None), 1).unwrap();
+            },
+        )
+        .await
+    }
+
+    #[tokio::test]
+    async fn test_first_row_id_inheritance() {
+        let (bytes, mut manifest_file) = first_row_id_manifest().await;
+        manifest_file.first_row_id = Some(100);
+        let batch = read_manifest_entries(&bytes, &manifest_file).unwrap();
+        // Deleted entries don't take row IDs, and entries with a first_row_id 
keep it.
+        assert_eq!(
+            data_file_column(&batch, "first_row_id").as_ref(),
+            &Int64Array::from(vec![Some(100), Some(5), None, Some(110), 
Some(116)]) as &dyn Array
+        );
+
+        manifest_file.first_row_id = None;
+        let batch = read_manifest_entries(&bytes, &manifest_file).unwrap();
+        assert_eq!(data_file_column(&batch, "first_row_id").null_count(), 5);
+
+        manifest_file.first_row_id = Some(i64::MAX as u64 - 5);
+        let err = read_manifest_entries(&bytes, &manifest_file).unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+
+        // A negative record_count would assign overlapping row IDs.
+        let bytes = rewrite_manifest(
+            &bytes,
+            Codec::Null,
+            |_| {},
+            |record| {
+                let AvroValue::Record(fields) = record else {
+                    panic!("manifest entry is not a record");
+                };
+                let Some((_, AvroValue::Record(data_file))) =
+                    fields.iter_mut().find(|(name, _)| name == "data_file")
+                else {
+                    panic!("manifest entry has no data_file record");
+                };
+                for (name, value) in data_file {
+                    if name == "record_count" && *value == AvroValue::Long(10) 
{
+                        *value = AvroValue::Long(-10);
+                    }
+                }
+            },
+        );
+        manifest_file.first_row_id = Some(100);
+        let err = read_manifest_entries(&bytes, &manifest_file).unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+    }
+
+    #[tokio::test]
+    async fn test_compressed_manifests() {
+        let (bytes, manifest_file) = first_row_id_manifest().await;
+        let expected = read_manifest_entries(&bytes, &manifest_file).unwrap();
+        for codec in [
+            Codec::Deflate(DeflateSettings::default()),
+            Codec::Snappy,
+            Codec::Zstandard(ZstandardSettings::default()),
+        ] {
+            let bytes = rewrite_manifest(&bytes, codec, |_| {}, |_| {});
+            assert_eq!(
+                read_manifest_entries(&bytes, &manifest_file).unwrap(),
+                expected,
+                "{codec:?}"
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn test_fields_are_matched_by_field_id() {
+        let file = DataFile {
+            partition: Struct::from_iter([Some(Literal::int(7))]),
+            ..data_file(DataContentType::Data, "s3://b/data/0.parquet")
+                .build()
+                .unwrap()
+        };
+        let (bytes, manifest_file) = write_manifest(
+            partition_spec(&[("i", Transform::Identity)]),
+            Some(1),
+            |b| b.build_v2_data(),
+            |writer| writer.add_file(file, 1).unwrap(),
+        )
+        .await;
+        let renames = [("file_path", "path"), ("i_identity", 
"renamed_partition")];
+        let bytes = rewrite_manifest(
+            &bytes,
+            Codec::Null,
+            |schema| {
+                for_each_avro_field(schema, &|field| {
+                    for (from, to) in renames {
+                        if field["name"] == from {
+                            field["name"] = to.into();
+                        }
+                    }
+                })
+            },
+            |record| {
+                for (from, to) in renames {
+                    rename_avro_field(record, from, to);
+                }
+            },
+        );
+        let batch = read_manifest_entries(&bytes, &manifest_file).unwrap();
+
+        assert_eq!(
+            data_file_column(&batch, "file_path").as_ref(),
+            &StringArray::from(vec!["s3://b/data/0.parquet"]) as &dyn Array
+        );
+        let partition = data_file_column(&batch, "partition");
+        assert_eq!(
+            partition.as_struct().column(0).as_ref(),
+            &Int32Array::from(vec![7]) as &dyn Array
+        );
+    }
+
+    #[tokio::test]
+    async fn test_missing_required_field_is_an_error() {
+        let file = data_file(DataContentType::Data, "s3://b/data/0.parquet")
+            .build()
+            .unwrap();
+        let (bytes, manifest_file) = write_manifest(
+            partition_spec(&[]),
+            Some(1),
+            |b| b.build_v2_data(),
+            |writer| writer.add_file(file, 1).unwrap(),
+        )
+        .await;
+        // A file_path under another field ID leaves the required file_path 
missing.
+        let bytes = rewrite_manifest(
+            &bytes,
+            Codec::Null,
+            |schema| {
+                for_each_avro_field(schema, &|field| {
+                    if field["name"] == "file_path" {
+                        field["field-id"] = 999.into();
+                    }
+                })
+            },
+            |_| {},
+        );
+        let err = read_manifest_entries(&bytes, &manifest_file).unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+    }
+
+    #[test]
+    fn test_manifest_written_by_pyiceberg() {
+        let bytes = std::fs::read(format!(
+            "{}/testdata/manifests/pyiceberg-v2-data.avro",
+            env!("CARGO_MANIFEST_DIR")
+        ))
+        .unwrap();
+        let manifest = Manifest::parse_avro(&bytes).unwrap();
+        let manifest_file = ManifestFile {
+            manifest_path: "s3://bucket/metadata/manifest.avro".to_string(),
+            manifest_length: bytes.len() as i64,
+            partition_spec_id: 0,
+            content: ManifestContentType::Data,
+            sequence_number: 1,
+            min_sequence_number: 1,
+            added_snapshot_id: 1,
+            added_files_count: None,
+            existing_files_count: None,
+            deleted_files_count: None,
+            added_rows_count: None,
+            existing_rows_count: None,
+            deleted_rows_count: None,
+            partitions: None,
+            key_metadata: None,
+            first_row_id: None,
+        };
+        let batch = read_manifest_entries(&bytes, &manifest_file).unwrap();
+
+        assert_eq!(batch.num_rows(), manifest.entries().len());
+        let paths = data_file_column(&batch, "file_path");
+        let partition = data_file_column(&batch, "partition");
+        let category = 
partition.as_struct().column(0).as_string::<i32>().clone();
+        for (row, entry) in manifest.entries().iter().enumerate() {
+            let data_file = entry.data_file();
+            assert_eq!(paths.as_string::<i32>().value(row), 
data_file.file_path());
+            let Some(Literal::Primitive(PrimitiveLiteral::String(expected))) =
+                &data_file.partition()[0]
+            else {
+                panic!("partition value is not a string");
+            };
+            assert_eq!(category.value(row), expected);
+            let mut lower_bounds: Vec<_> = data_file
+                .lower_bounds()
+                .iter()
+                .map(|(id, bound)| (*id, bound.to_bytes().unwrap().to_vec()))
+                .collect();
+            lower_bounds.sort_unstable();
+            assert!(!lower_bounds.is_empty());
+            assert_eq!(
+                binary_map(&data_file_column(&batch, "lower_bounds"), row),
+                lower_bounds
+            );
+        }
+    }
+}
diff --git a/crates/iceberg/src/arrow/mod.rs b/crates/iceberg/src/arrow/mod.rs
index 28cf6e94a..c74b20d7c 100644
--- a/crates/iceberg/src/arrow/mod.rs
+++ b/crates/iceberg/src/arrow/mod.rs
@@ -28,6 +28,8 @@ pub mod delete_file_loader;
 pub(crate) mod delete_filter;
 
 mod int96;
+mod manifest;
+pub use manifest::read_manifest_entries;
 mod reader;
 /// RecordBatch projection utilities
 pub mod record_batch_projector;
diff --git a/crates/iceberg/src/spec/manifest/entry.rs 
b/crates/iceberg/src/spec/manifest/entry.rs
index 555d86a25..bbd2965d5 100644
--- a/crates/iceberg/src/spec/manifest/entry.rs
+++ b/crates/iceberg/src/spec/manifest/entry.rs
@@ -535,6 +535,24 @@ fn data_file_fields_v3(partition_type: &StructType) -> 
Vec<NestedFieldRef> {
     ]
 }
 
+/// Fields of the `manifest_entry` struct in the latest format version. A
+/// [`ManifestEntry`] of any version fits them: earlier versions lack only
+/// optional fields, apart from the deprecated v1 `block_size_in_bytes`, which
+/// [`DataFile`] doesn't keep.
+pub(crate) fn manifest_entry_fields(partition_type: &StructType) -> 
Vec<NestedFieldRef> {
+    vec![
+        STATUS.clone(),
+        SNAPSHOT_ID_V2.clone(),
+        SEQUENCE_NUMBER.clone(),
+        FILE_SEQUENCE_NUMBER.clone(),
+        Arc::new(NestedField::required(
+            2,
+            "data_file",
+            Type::Struct(StructType::new(data_file_fields_v3(partition_type))),
+        )),
+    ]
+}
+
 pub(super) fn data_file_schema_v3(partition_type: &StructType) -> 
Result<AvroSchema> {
     let schema = Schema::builder()
         .with_fields(data_file_fields_v3(partition_type))

Reply via email to