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

laskoviymishka 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 63ced822c fix(manifest): support nanosecond timestamp partitions 
(#2034)
63ced822c is described below

commit 63ced822ca9c7d6534306e58dda3ed6daad8e115
Author: Jeremy Schoemaker <[email protected]>
AuthorDate: Tue Sep 22 16:45:07 2026 -0500

    fix(manifest): support nanosecond timestamp partitions (#2034)
    
    * fix(manifest): support nanosecond timestamp partition types in Avro schema
    
    partitionTypeToAvroSchema had no arm for TimestampNsType or
    TimestampTzNsType, so any v3 table partitioned on a timestamp_ns or
    timestamptz_ns column fell through to "unsupported partition type" and
    could not have a manifest written at all. NewManifestWriter calls
    partitionTypeToAvroSchema before anything else, so the failure is
    unconditional.
    
    The read side already handles these types: the manifest decoder switches
    on atype.TimestampNanos, and catalog/rest/scan_task_decoder.go maps both
    ns types to atype.TimestampNanos. The library could read partitions it
    could not write.
    
    Add TimestampNsNode and TimestampTzNsNode to the internal Avro schema
    nodes, using atype.TimestampNanos with adjust-to-utc false and true
    respectively, mirroring the existing micros nodes, and wire both ns
    types into the partition type switch.
    
    Signed-off-by: Jeremy Schoemaker <[email protected]>
    
    * fix(manifest): finalize nanosecond partition statistics
    
    Signed-off-by: Jeremy Schoemaker <[email protected]>
    
    ---------
    
    Signed-off-by: Jeremy Schoemaker <[email protected]>
---
 internal/avro_schemas.go   |   6 ++-
 manifest.go                |   8 ++++
 schema_conversions.go      |   4 ++
 schema_conversions_test.go | 108 +++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 125 insertions(+), 1 deletion(-)

diff --git a/internal/avro_schemas.go b/internal/avro_schemas.go
index fe6c87dd0..d67d8191b 100644
--- a/internal/avro_schemas.go
+++ b/internal/avro_schemas.go
@@ -101,7 +101,11 @@ var (
        TimeNode        = avro.SchemaNode{Type: atype.Long, LogicalType: 
atype.TimeMicros}
        TimestampNode   = avro.SchemaNode{Type: atype.Long, LogicalType: 
atype.TimestampMicros, Props: map[string]any{"adjust-to-utc": false}}
        TimestampTzNode = avro.SchemaNode{Type: atype.Long, LogicalType: 
atype.TimestampMicros, Props: map[string]any{"adjust-to-utc": true}}
-       UUIDNode        = avro.SchemaNode{Type: atype.Fixed, Name: 
"uuid_fixed", Size: 16, LogicalType: atype.UUID}
+
+       TimestampNsNode   = avro.SchemaNode{Type: atype.Long, LogicalType: 
atype.TimestampNanos, Props: map[string]any{"adjust-to-utc": false}}
+       TimestampTzNsNode = avro.SchemaNode{Type: atype.Long, LogicalType: 
atype.TimestampNanos, Props: map[string]any{"adjust-to-utc": true}}
+
+       UUIDNode = avro.SchemaNode{Type: atype.Fixed, Name: "uuid_fixed", Size: 
16, LogicalType: atype.UUID}
 )
 
 // Compiled schemas for direct encoding/decoding use.
diff --git a/manifest.go b/manifest.go
index 67705f498..259c96457 100644
--- a/manifest.go
+++ b/manifest.go
@@ -1572,6 +1572,12 @@ func convertPartitionTimestamp(value any) (Timestamp, 
bool) {
        return Timestamp(converted), ok
 }
 
+func convertPartitionTimestampNano(value any) (TimestampNano, bool) {
+       converted, ok := convertPartitionInt64(value)
+
+       return TimestampNano(converted), ok
+}
+
 func newPartitionFieldStat(typ PrimitiveType) (fieldStats, error) {
        switch typ.(type) {
        case UnknownType:
@@ -1596,6 +1602,8 @@ func newPartitionFieldStat(typ PrimitiveType) 
(fieldStats, error) {
                return 
makePartitionFieldStats[Timestamp](convertPartitionTimestamp), nil
        case TimestampTzType:
                return 
makePartitionFieldStats[Timestamp](convertPartitionTimestamp), nil
+       case TimestampNsType, TimestampTzNsType:
+               return 
makePartitionFieldStats[TimestampNano](convertPartitionTimestampNano), nil
        case UUIDType:
                return 
makePartitionFieldStats[uuid.UUID](convertPartitionUUID), nil
        case BinaryType:
diff --git a/schema_conversions.go b/schema_conversions.go
index d7a7ab2cc..e23df9961 100644
--- a/schema_conversions.go
+++ b/schema_conversions.go
@@ -48,6 +48,10 @@ func partitionTypeToAvroSchema(t *StructType) (*avro.Schema, 
error) {
                        node = internal.NullableNode(internal.TimestampNode)
                case TimestampTzType:
                        node = internal.NullableNode(internal.TimestampTzNode)
+               case TimestampNsType:
+                       node = internal.NullableNode(internal.TimestampNsNode)
+               case TimestampTzNsType:
+                       node = internal.NullableNode(internal.TimestampTzNsNode)
                case UUIDType:
                        node = internal.NullableNode(internal.UUIDNode)
                case BooleanType:
diff --git a/schema_conversions_test.go b/schema_conversions_test.go
index a57cab2c1..516a8d51e 100644
--- a/schema_conversions_test.go
+++ b/schema_conversions_test.go
@@ -18,7 +18,9 @@
 package iceberg
 
 import (
+       "encoding/binary"
        "fmt"
+       "io"
        "testing"
        "time"
 
@@ -51,6 +53,10 @@ func partitionTypeToAvroSchemaNonNullable(t *StructType) 
(*avro.Schema, error) {
                        node = internal.TimestampNode
                case TimestampTzType:
                        node = internal.TimestampTzNode
+               case TimestampNsType:
+                       node = internal.TimestampNsNode
+               case TimestampTzNsType:
+                       node = internal.TimestampTzNsNode
                case UUIDType:
                        node = internal.UUIDNode
                case BooleanType:
@@ -282,3 +288,105 @@ func TestNonDayInt32PartitionAvroIntEncoding(t 
*testing.T) {
        assert.IsType(t, int32(0), got, "plain Int32 partition must decode as 
int32, not time.Time")
        assert.Equal(t, int32(42), got)
 }
+
+// TestNanosecondPartitionAvroSchema verifies that v3 nanosecond timestamp
+// types are accepted as partition types and round trip through Avro at
+// nanosecond precision. The read path in manifest.go already decodes
+// atype.TimestampNanos, so the write path has to emit it.
+func TestNanosecondPartitionAvroSchema(t *testing.T) {
+       cases := []struct {
+               name string
+               typ  Type
+       }{
+               {"timestamp_ns", TimestampNsType{}},
+               {"timestamptz_ns", TimestampTzNsType{}},
+       }
+
+       for _, tc := range cases {
+               t.Run(tc.name, func(t *testing.T) {
+                       partitionType := &StructType{FieldList: []NestedField{
+                               {ID: 1000, Name: "ts_ns", Type: tc.typ, 
Required: false},
+                       }}
+
+                       avroSchema, err := 
partitionTypeToAvroSchema(partitionType)
+                       require.NoError(t, err)
+                       require.NotNil(t, avroSchema)
+                       nonNullableSchema, err := 
partitionTypeToAvroSchemaNonNullable(partitionType)
+                       require.NoError(t, err)
+
+                       // A value that is not a whole number of microseconds, 
so a
+                       // micros-based logical type would lose the trailing 
123 ns.
+                       want := time.Date(2024, time.March, 1, 12, 0, 0, 
456789123, time.UTC)
+
+                       nonNullableEncoded, err := 
nonNullableSchema.Encode(map[string]any{"ts_ns": want})
+                       require.NoError(t, err)
+                       var nonNullableDecoded map[string]any
+                       _, err = nonNullableSchema.Decode(nonNullableEncoded, 
&nonNullableDecoded)
+                       require.NoError(t, err)
+                       assert.Equal(t, want, nonNullableDecoded["ts_ns"])
+
+                       encoded, err := 
avroSchema.Encode(map[string]any{"ts_ns": want})
+                       require.NoError(t, err)
+
+                       var decoded map[string]any
+                       _, err = avroSchema.Decode(encoded, &decoded)
+                       require.NoError(t, err)
+
+                       got, ok := decoded["ts_ns"]
+                       require.True(t, ok)
+                       gotTime, isTime := got.(time.Time)
+                       require.True(t, isTime, "nanosecond partition field 
must decode as time.Time, got %T", got)
+                       assert.Equal(t, want.UnixNano(), 
gotTime.UTC().UnixNano(),
+                               "nanosecond partition value must round trip 
without precision loss")
+               })
+       }
+}
+
+// TestNewManifestWriterV3NanosecondPartition verifies that a v3 manifest can
+// be written for a table with an identity partition on a nanosecond timestamp
+// column. Before the nanosecond arms existed in partitionTypeToAvroSchema,
+// this failed with "unsupported partition type: timestamp_ns".
+func TestNewManifestWriterV3NanosecondPartition(t *testing.T) {
+       cases := []struct {
+               name string
+               typ  Type
+       }{
+               {"timestamp_ns", TimestampNsType{}},
+               {"timestamptz_ns", TimestampTzNsType{}},
+       }
+
+       for _, tc := range cases {
+               t.Run(tc.name, func(t *testing.T) {
+                       sc := NewSchema(0,
+                               NestedField{ID: 1, Name: "ts", Type: tc.typ, 
Required: true},
+                       )
+                       spec := NewPartitionSpecID(0,
+                               PartitionField{FieldID: 1000, SourceIDs: 
[]int{1}, Name: "ts_identity", Transform: IdentityTransform{}},
+                       )
+
+                       writer, err := NewManifestWriter(3, io.Discard, spec, 
sc, 1)
+                       require.NoError(t, err)
+                       require.NotNil(t, writer)
+
+                       values := []TimestampNano{1709294400456789123, 
-123456789}
+                       for i, value := range values {
+                               builder, err := NewDataFileBuilder(spec, 
EntryContentData,
+                                       fmt.Sprintf("data-%d.parquet", i), 
ParquetFile,
+                                       map[int]any{1000: value}, nil, nil, 1, 
1)
+                               require.NoError(t, err)
+                               require.NoError(t, writer.Add(NewManifestEntry(
+                                       EntryStatusADDED, nil, nil, nil, 
builder.Build())))
+                       }
+
+                       manifest, err := writer.ToManifestFile("manifest.avro", 
0)
+                       require.NoError(t, err)
+                       summaries := manifest.Partitions()
+                       require.Len(t, summaries, 1)
+                       assert.False(t, summaries[0].ContainsNull)
+                       require.NotNil(t, summaries[0].LowerBound)
+                       require.NotNil(t, summaries[0].UpperBound)
+                       assert.Equal(t, binary.LittleEndian.AppendUint64(nil, 
uint64(values[1])), *summaries[0].LowerBound)
+                       assert.Equal(t, binary.LittleEndian.AppendUint64(nil, 
uint64(values[0])), *summaries[0].UpperBound)
+               })
+       }
+}

Reply via email to