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

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


The following commit(s) were added to refs/heads/main by this push:
     new 2fc51d108 fix(manifest): normalize file_format casing when reading 
manifest entries (#1984)
2fc51d108 is described below

commit 2fc51d108dd845ce79867b94c73acc7caa3a0e60
Author: Will Roever <[email protected]>
AuthorDate: Wed Oct 14 06:21:26 2026 -0700

    fix(manifest): normalize file_format casing when reading manifest entries 
(#1984)
    
    * fix(manifest): normalize file_format casing when reading entries
    
    Route the decoded file_format value through FileFormatFromString in
    ReadEntry so spec-conformant lowercase spellings match the FileFormat
    constants, and reject unknown formats there with the offending file path.
    
    Signed-off-by: Will Roever <[email protected]>
    
    * fix(manifest): normalize file_format in the data file codec
    
    Signed-off-by: Will Roever <[email protected]>
    
    * fix(manifest): drop redundant wrapping in the file format error
    
    Signed-off-by: Will Roever <[email protected]>
    
    * test(manifest): pin the file format error message and widen the codec 
table
    
    Signed-off-by: Will Roever <[email protected]>
    
    ---------
    
    Signed-off-by: Will Roever <[email protected]>
---
 data_file_codec.go      |  3 ++
 data_file_codec_test.go | 56 ++++++++++++++++++++++++++++++++
 manifest.go             | 20 +++++++++++-
 manifest_test.go        | 86 +++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 164 insertions(+), 1 deletion(-)

diff --git a/data_file_codec.go b/data_file_codec.go
index 15c6c84ba..ea0b96ba6 100644
--- a/data_file_codec.go
+++ b/data_file_codec.go
@@ -146,6 +146,9 @@ func unmarshalAvroDataFileEntry(data []byte, spec 
PartitionSpec, schema *Schema,
        if _, err := s.Decode(data, entry); err != nil {
                return nil, fmt.Errorf("iceberg: unmarshalAvroDataFileEntry: 
%w", err)
        }
+       if err := df.normalizeFormat(); err != nil {
+               return nil, fmt.Errorf("iceberg: unmarshalAvroDataFileEntry: 
%w", err)
+       }
        df.specID = int32(spec.ID())
        df.fieldNameToID = maps.nameToID
        df.fieldIDToLogicalType = maps.idToType
diff --git a/data_file_codec_test.go b/data_file_codec_test.go
index 6126e6975..0e35bcce7 100644
--- a/data_file_codec_test.go
+++ b/data_file_codec_test.go
@@ -18,6 +18,7 @@
 package iceberg
 
 import (
+       "fmt"
        "reflect"
        "strconv"
        "sync"
@@ -124,6 +125,61 @@ func TestMarshalAvroEntryOmitsDistinctCounts(t *testing.T) 
{
        }
 }
 
+func TestUnmarshalAvroDataFileEntryNormalizesFileFormat(t *testing.T) {
+       spec := NewPartitionSpec()
+       schema := NewSchema(0)
+
+       const path = "s3://bucket/table/data.parquet"
+
+       tests := []struct {
+               name      string
+               content   ManifestEntryContent
+               written   FileFormat
+               expected  FileFormat
+               expectErr bool
+       }{
+               {name: "spec lowercase", written: "parquet", expected: 
ParquetFile},
+               {name: "mixed case", written: "Parquet", expected: ParquetFile},
+               {name: "uppercase", written: ParquetFile, expected: 
ParquetFile},
+               {name: "lowercase avro", written: "avro", expected: AvroFile},
+               {name: "lowercase orc", written: "orc", expected: OrcFile},
+               {
+                       name: "lowercase puffin in delete entry", content: 
EntryContentPosDeletes,
+                       written: "puffin", expected: PuffinFile,
+               },
+               {name: "unknown format", written: "csv", expectErr: true},
+               {name: "empty format", written: "", expectErr: true},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       content := tt.content
+                       if content == 0 {
+                               content = EntryContentData
+                       }
+                       builder, err := NewDataFileBuilder(spec, content,
+                               path, ParquetFile, nil, nil, nil, 1, 1024)
+                       require.NoError(t, err)
+                       source := builder.Build().(*dataFile)
+                       source.Format = tt.written
+
+                       encoded, err := source.MarshalAvroEntry(spec, schema, 2)
+                       require.NoError(t, err)
+
+                       decoded, err := unmarshalAvroDataFileEntry(encoded, 
spec, schema, 2)
+                       if tt.expectErr {
+                               require.EqualError(t, err, fmt.Sprintf(
+                                       "iceberg: unmarshalAvroDataFileEntry: 
data file %q: unknown file format: %s",
+                                       path, tt.written))
+
+                               return
+                       }
+                       require.NoError(t, err)
+                       require.Equal(t, tt.expected, decoded.FileFormat())
+               })
+       }
+}
+
 // TestManifestEntrySchemaForDistinguishesSameSpecIDDifferentPartitionTypes
 // guards against the cache-key collision that would occur if the cache
 // were keyed only by spec.ID(). Two tables can both use
diff --git a/manifest.go b/manifest.go
index e344b14a9..08cb21011 100644
--- a/manifest.go
+++ b/manifest.go
@@ -918,7 +918,11 @@ func (c *ManifestReader) ReadEntry() (ManifestEntry, 
error) {
        if c.isFallback {
                tmp = tmp.(*fallbackManifestEntry).toEntry()
        }
-       tmp.DataFile().(*dataFile).projected = c.projected
+       df := tmp.DataFile().(*dataFile)
+       df.projected = c.projected
+       if err := df.normalizeFormat(); err != nil {
+               return nil, err
+       }
        switch tmp.Status() {
        case EntryStatusEXISTING, EntryStatusADDED, EntryStatusDELETED:
        default:
@@ -2797,6 +2801,20 @@ func (d *dataFile) setFieldIDToDecimalScaleMap(m 
map[int]int) {
        d.fieldIDToDecimalScale = m
 }
 
+// normalizeFormat sets d.Format to the FileFormat constant matching its
+// decoded spelling, or returns an error if it names no known format.
+// Every Avro decode path must call it before the value is compared or
+// exposed.
+func (d *dataFile) normalizeFormat() error {
+       format, err := FileFormatFromString(string(d.Format))
+       if err != nil {
+               return fmt.Errorf("data file %q: %w", d.FilePath(), err)
+       }
+       d.Format = format
+
+       return nil
+}
+
 func (d *dataFile) ContentType() ManifestEntryContent { return d.Content }
 func (d *dataFile) FilePath() string                  { return d.Path }
 func (d *dataFile) FileFormat() FileFormat            { return d.Format }
diff --git a/manifest_test.go b/manifest_test.go
index 12b4ad495..e139e5ef4 100644
--- a/manifest_test.go
+++ b/manifest_test.go
@@ -2427,6 +2427,92 @@ func (m *ManifestTestSuite) 
TestManifestReaderRejectsInvalidEntries() {
        }
 }
 
+func (m *ManifestTestSuite) TestManifestReaderNormalizesFileFormat() {
+       partitionSpec := NewPartitionSpecID(1,
+               PartitionField{FieldID: 1000, SourceIDs: []int{1}, Name: 
"VendorID", Transform: IdentityTransform{}},
+               PartitionField{FieldID: 1001, SourceIDs: []int{2}, Name: 
"tpep_pickup_datetime", Transform: IdentityTransform{}})
+       partitionSchema, err := 
partitionTypeToAvroSchema(partitionSpec.PartitionType(testSchema))
+       m.Require().NoError(err)
+
+       tests := []struct {
+               name         string
+               version      int
+               content      ManifestContent
+               entryContent ManifestEntryContent
+               written      FileFormat
+               expected     FileFormat
+               expectErr    bool
+       }{
+               {name: "spec lowercase", written: "parquet", expected: 
ParquetFile},
+               {name: "mixed case", written: "Parquet", expected: ParquetFile},
+               {name: "uppercase", written: ParquetFile, expected: 
ParquetFile},
+               {name: "lowercase avro", written: "avro", expected: AvroFile},
+               {name: "lowercase orc", written: "orc", expected: OrcFile},
+               {
+                       name: "lowercase puffin in delete manifest", content: 
ManifestContentDeletes,
+                       entryContent: EntryContentPosDeletes, written: 
"puffin", expected: PuffinFile,
+               },
+               {name: "v1 fallback entry", version: 1, written: "parquet", 
expected: ParquetFile},
+               {name: "unknown format", written: "csv", expectErr: true},
+               {name: "empty format", written: "", expectErr: true},
+       }
+
+       for _, tt := range tests {
+               m.Run(tt.name, func() {
+                       version := tt.version
+                       if version == 0 {
+                               version = 2
+                       }
+                       content := tt.content
+                       if content == 0 {
+                               content = ManifestContentData
+                       }
+                       entrySchema, err := 
internal.NewManifestEntrySchema(partitionSchema, version)
+                       m.Require().NoError(err)
+
+                       records := manifestEntryV2Records
+                       if version == 1 {
+                               records = manifestEntryV1Records
+                       }
+                       entry := *records[0]
+                       file := cloneDataFileAvroFields(entry.Data.(*dataFile))
+                       file.Format = tt.written
+                       file.Content = tt.entryContent
+                       entry.Data = file
+
+                       mw := ManifestWriter{version: version, spec: 
partitionSpec, schema: testSchema, content: content}
+                       metadata, err := mw.meta()
+                       m.Require().NoError(err)
+
+                       var buf bytes.Buffer
+                       writer, err := ocf.NewWriter(&buf, entrySchema,
+                               ocf.WithSchema(entrySchema.String()), 
ocf.WithMetadata(metadata))
+                       m.Require().NoError(err)
+                       var toEncode any = &entry
+                       if version == 1 {
+                               toEncode = 
&fallbackManifestEntry{manifestEntry: entry}
+                       }
+                       m.Require().NoError(writer.Encode(toEncode))
+                       m.Require().NoError(writer.Close())
+
+                       manifest := &manifestFile{version: version, SpecID: 1, 
Content: content}
+                       reader, err := NewManifestReader(manifest, 
bytes.NewReader(buf.Bytes()))
+                       m.Require().NoError(err)
+                       defer reader.Close()
+
+                       got, err := reader.ReadEntry()
+                       if tt.expectErr {
+                               m.Require().EqualError(err,
+                                       fmt.Sprintf("data file %q: unknown file 
format: %s", file.Path, tt.written))
+
+                               return
+                       }
+                       m.Require().NoError(err)
+                       m.Equal(tt.expected, got.DataFile().FileFormat())
+               })
+       }
+}
+
 func (m *ManifestTestSuite) TestManifestEntryBuilder() {
        dataFileBuilder, err := NewDataFileBuilder(
                NewPartitionSpec(),

Reply via email to