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 d6831edf0 feat(table): import bucket-partitioned Parquet files (#2395)
d6831edf0 is described below

commit d6831edf0f98c7554d00fb2957ac2b51ba897822
Author: Minh Vu <[email protected]>
AuthorDate: Wed Nov 4 19:11:40 2026 +0100

    feat(table): import bucket-partitioned Parquet files (#2395)
    
    * feat(table): import bucket-partitioned Parquet into v1 tables
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * fix(table): preserve file import API compatibility
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * fix(table): snapshot partition values before concurrent imports
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * fix(table): reject unknown partition transforms during file import
    
    * test(table): prevent unknown transform bypass via supplied partition tuple
    
    * fix(table): validate supplied partition values against Parquet bounds
    
    * style(table): sort arrow utils imports
    
    * fix(table): preserve addFilesOperation comparability
    
    ---------
    
    Signed-off-by: Minh Vu <[email protected]>
---
 table/arrow_utils.go                | 249 ++++++++++++++++++++++++---
 table/file_partition_values_test.go |  74 +++++++++
 table/internal/utils.go             |   6 +-
 table/table_test.go                 |   2 +-
 table/transaction.go                |  40 ++++-
 table/v1_partition_import_test.go   | 323 ++++++++++++++++++++++++++++++++++++
 6 files changed, 666 insertions(+), 28 deletions(-)

diff --git a/table/arrow_utils.go b/table/arrow_utils.go
index 1cc15095e..896909d4f 100644
--- a/table/arrow_utils.go
+++ b/table/arrow_utils.go
@@ -25,6 +25,7 @@ import (
        "fmt"
        "iter"
        "log/slog"
+       "reflect"
        "regexp"
        "slices"
        "strconv"
@@ -1853,7 +1854,7 @@ func computeStatsPlan(sc *iceberg.Schema, props 
iceberg.Properties) (result map[
        return result, nil
 }
 
-func filesToDataFiles(ctx context.Context, fileIO iceio.IO, meta 
*MetadataBuilder, filePaths []string, concurrency int) (_ []iceberg.DataFile, 
err error) {
+func filesToDataFiles(ctx context.Context, fileIO iceio.IO, meta 
*MetadataBuilder, filePaths []string, concurrency int, partitionValuesByPath 
map[string]map[int]any) (_ []iceberg.DataFile, err error) {
        partitionSpec, err := meta.CurrentSpec()
        if err != nil || partitionSpec == nil {
                return nil, fmt.Errorf("%w: cannot add files without a current 
spec", err)
@@ -1884,7 +1885,7 @@ func filesToDataFiles(ctx context.Context, fileIO 
iceio.IO, meta *MetadataBuilde
                        // Pass 0 so AddDataFilesByPath does not claim 
sort_order_id on files
                        // it did not write (#1184). Callers that know the 
file's
                        // sort layout use FileToDataFile with an explicit id.
-                       dataFiles[i] = fileToDataFile(ctx, fileIO, filePath, 
currentSchema, currentSpec, 0, meta.props)
+                       dataFiles[i] = fileToDataFile(ctx, fileIO, filePath, 
currentSchema, currentSpec, 0, meta.props, partitionValuesByPath[filePath])
 
                        return nil
                })
@@ -1897,6 +1898,160 @@ func filesToDataFiles(ctx context.Context, fileIO 
iceio.IO, meta *MetadataBuilde
        return dataFiles, nil
 }
 
+// FileToDataFileOption configures the conversion of an existing Parquet file.
+type FileToDataFileOption func(*fileToDataFileConfig)
+
+type fileToDataFileConfig struct {
+       partitionValues map[int]any
+}
+
+// WithFilePartitionValues supplies the partition tuple of an existing data 
file,
+// keyed by partition field ID (not source column ID). This is required for
+// transforms such as bucket[N], which cannot be inferred from Parquet 
statistics.
+// For a spec containing a bucket transform, all non-void fields must be 
supplied.
+// Each supplied value must match the transform's result type. A nil value
+// explicitly represents a null partition. Values for order-preserving
+// transforms are checked against Parquet bounds when available. Callers remain
+// responsible for ensuring that all rows agree with the partition values.
+func WithFilePartitionValues(values map[int]any) FileToDataFileOption {
+       partitionValues := cloneFilePartitionTuple(values)
+
+       return func(cfg *fileToDataFileConfig) {
+               cfg.partitionValues = partitionValues
+       }
+}
+
+// cloneFilePartitionTuple prevents the caller from changing partition values
+// while concurrently imported Parquet files are being inspected.
+func cloneFilePartitionTuple(values map[int]any) map[int]any {
+       if values == nil {
+               return nil
+       }
+       cloned := make(map[int]any, len(values))
+       for id, value := range values {
+               if raw, ok := value.([]byte); ok {
+                       value = bytes.Clone(raw)
+               }
+               cloned[id] = value
+       }
+
+       return cloned
+}
+
+func validateFileBucketValue(field iceberg.PartitionField, value any, 
numBuckets int) error {
+       if value == nil {
+               return nil
+       }
+
+       bucket, ok := value.(int32)
+       if !ok || bucket < 0 || int64(bucket) >= int64(numBuckets) {
+               return fmt.Errorf("%w: partition field %q (id %d) requires an 
int32 bucket in [0, %d), got %v (%T)",
+                       iceberg.ErrInvalidArgument, field.Name, field.FieldID, 
numBuckets, value, value)
+       }
+
+       return nil
+}
+
+func validFilePartitionValueType(typ iceberg.Type, value any) bool {
+       switch t := typ.(type) {
+       case iceberg.BooleanType:
+               _, ok := value.(bool)
+
+               return ok
+       case iceberg.Int32Type:
+               _, ok := value.(int32)
+
+               return ok
+       case iceberg.Int64Type:
+               _, ok := value.(int64)
+
+               return ok
+       case iceberg.Float32Type:
+               _, ok := value.(float32)
+
+               return ok
+       case iceberg.Float64Type:
+               _, ok := value.(float64)
+
+               return ok
+       case iceberg.StringType:
+               _, ok := value.(string)
+
+               return ok
+       case iceberg.BinaryType:
+               _, ok := value.([]byte)
+
+               return ok
+       case iceberg.FixedType:
+               v, ok := value.([]byte)
+
+               return ok && len(v) == t.Len()
+       case iceberg.DateType:
+               _, ok := value.(iceberg.Date)
+
+               return ok
+       case iceberg.TimeType:
+               _, ok := value.(iceberg.Time)
+
+               return ok
+       case iceberg.TimestampType, iceberg.TimestampTzType:
+               _, ok := value.(iceberg.Timestamp)
+
+               return ok
+       case iceberg.TimestampNsType, iceberg.TimestampTzNsType:
+               _, ok := value.(iceberg.TimestampNano)
+
+               return ok
+       case iceberg.UUIDType:
+               _, ok := value.(uuid.UUID)
+
+               return ok
+       case iceberg.DecimalType:
+               v, ok := value.(iceberg.Decimal)
+
+               return ok && v.Scale == t.Scale() && 
v.Val.FitsInPrecision(int32(t.Precision()))
+       default:
+               return false
+       }
+}
+
+func validateFilePartitionValues(schema *iceberg.Schema, spec 
iceberg.PartitionSpec, values map[int]any) error {
+       if err := validatePartitionValues(spec, values); err != nil {
+               return fmt.Errorf("%w: %v", iceberg.ErrInvalidArgument, err)
+       }
+
+       for _, field := range spec.Fields() {
+               value, supplied := values[field.FieldID]
+               if !supplied {
+                       continue
+               }
+
+               switch transform := field.Transform.(type) {
+               case iceberg.BucketTransform:
+                       if err := validateFileBucketValue(field, value, 
transform.NumBuckets); err != nil {
+                               return err
+                       }
+               case iceberg.VoidTransform:
+                       if value != nil {
+                               return fmt.Errorf("%w: void partition field %q 
(id %d) must be null",
+                                       iceberg.ErrInvalidArgument, field.Name, 
field.FieldID)
+                       }
+               }
+               if value != nil {
+                       source, ok := 
schema.FindFieldByID(field.FirstSourceID())
+                       if !ok {
+                               return fmt.Errorf("%w: source field for 
partition %q not found", iceberg.ErrInvalidSchema, field.Name)
+                       }
+                       resultType := field.Transform.ResultType(source.Type)
+                       if !validFilePartitionValueType(resultType, value) {
+                               return fmt.Errorf("%w: partition field %q 
expects %s, got %T", iceberg.ErrInvalidArgument, field.Name, resultType, value)
+                       }
+               }
+       }
+
+       return nil
+}
+
 // FileToDataFile builds a DataFile for an existing parquet file at
 // filePath, reading its footer to populate record count, file size,
 // column sizes, value/null counts and lower/upper bounds, and to infer
@@ -1905,7 +2060,13 @@ func filesToDataFiles(ctx context.Context, fileIO 
iceio.IO, meta *MetadataBuilde
 // footers can convey the file's sort layout (spec data-file field
 // sort_order_id); pass 0 to make no claim. Panics from the unexported
 // conversion are recovered into an error.
-func FileToDataFile(ctx context.Context, fileIO iceio.IO, filePath string, 
currentSchema *iceberg.Schema, currentSpec iceberg.PartitionSpec, sortOrderID 
int, props iceberg.Properties) (df iceberg.DataFile, err error) {
+func FileToDataFile(ctx context.Context, fileIO iceio.IO, filePath string, 
currentSchema *iceberg.Schema, currentSpec iceberg.PartitionSpec, sortOrderID 
int, props iceberg.Properties) (iceberg.DataFile, error) {
+       return FileToDataFileWithOptions(ctx, fileIO, filePath, currentSchema, 
currentSpec, sortOrderID, props)
+}
+
+// FileToDataFileWithOptions builds a DataFile like FileToDataFile, with 
options
+// to supply partition values that cannot be inferred from Parquet statistics.
+func FileToDataFileWithOptions(ctx context.Context, fileIO iceio.IO, filePath 
string, currentSchema *iceberg.Schema, currentSpec iceberg.PartitionSpec, 
sortOrderID int, props iceberg.Properties, opts ...FileToDataFileOption) (df 
iceberg.DataFile, err error) {
        defer func() {
                if r := recover(); r != nil {
                        switch e := r.(type) {
@@ -1917,10 +2078,24 @@ func FileToDataFile(ctx context.Context, fileIO 
iceio.IO, filePath string, curre
                }
        }()
 
-       return fileToDataFile(ctx, fileIO, filePath, currentSchema, 
currentSpec, sortOrderID, props), nil
+       cfg := fileToDataFileConfig{}
+       for _, opt := range opts {
+               opt(&cfg)
+       }
+
+       return fileToDataFile(ctx, fileIO, filePath, currentSchema, 
currentSpec, sortOrderID, props, cfg.partitionValues), nil
 }
 
-func fileToDataFile(ctx context.Context, fileIO iceio.IO, filePath string, 
currentSchema *iceberg.Schema, currentSpec iceberg.PartitionSpec, sortOrderID 
int, props iceberg.Properties) iceberg.DataFile {
+func fileToDataFile(ctx context.Context, fileIO iceio.IO, filePath string, 
currentSchema *iceberg.Schema, currentSpec iceberg.PartitionSpec, sortOrderID 
int, props iceberg.Properties, supplied map[int]any) iceberg.DataFile {
+       // Explicit partition tuples must not bypass the Iceberg requirement 
that
+       // writers reject unknown partition transforms.
+       if err := checkNoUnknownTransform(&currentSpec); err != nil {
+               panic(err)
+       }
+       if err := validateFilePartitionValues(currentSchema, currentSpec, 
supplied); err != nil {
+               panic(err)
+       }
+
        format := tblutils.FormatFromFileName(filePath)
        rdr := must(format.Open(ctx, fileIO, filePath))
        defer rdr.Close()
@@ -1945,29 +2120,59 @@ func fileToDataFile(ctx context.Context, fileIO 
iceio.IO, filePath string, curre
                arrSchema,
        )
 
-       partitionValues := make(map[int]any)
-       if !currentSpec.Equals(*iceberg.UnpartitionedSpec) {
-               for _, field := range currentSpec.Fields() {
-                       if !field.Transform.PreservesOrder() {
-                               panic(fmt.Errorf("cannot infer partition value 
from parquet metadata for a non-linear partition field: %s with transform %s", 
field.Name, field.Transform))
+       // For bucket specs, require the complete tuple instead of combining 
caller
+       // values with footer inference, which cannot prove null partition 
values.
+       hasBucket := false
+       for _, field := range currentSpec.Fields() {
+               switch field.Transform.(type) {
+               case iceberg.BucketTransform:
+                       hasBucket = true
+               }
+       }
+       partitionValues := make(map[int]any, currentSpec.NumFields())
+       for _, field := range currentSpec.Fields() {
+               if value, ok := supplied[field.FieldID]; ok {
+                       if field.Transform.PreservesOrder() {
+                               // Keep the same lower/upper bound checks as 
the inference path.
+                               inferred := statistics.PartitionValue(field, 
currentSchema)
+                               if inferred != nil && !reflect.DeepEqual(value, 
inferred) {
+                                       panic(fmt.Errorf("%w: supplied 
partition value for field %q (id %d) does not match Parquet bounds: got %v, 
expected %v",
+                                               iceberg.ErrInvalidArgument, 
field.Name, field.FieldID, value, inferred))
+                               }
                        }
+                       partitionValues[field.FieldID] = value
 
-                       partitionVal := statistics.PartitionValue(field, 
currentSchema)
-                       if partitionVal != nil {
-                               partitionValues[field.FieldID] = partitionVal
-                       }
+                       continue
+               }
+               switch field.Transform.(type) {
+               case iceberg.VoidTransform:
+                       // Iceberg v1 uses void transforms to drop a partition 
field
+                       // without changing its field ID or position.
+                       partitionValues[field.FieldID] = nil
+
+                       continue
+               }
+               if hasBucket || !field.Transform.PreservesOrder() {
+                       panic(fmt.Errorf("%w: cannot infer partition field %q 
(id %d) with transform %s; supply WithFilePartitionValues",
+                               iceberg.ErrInvalidArgument, field.Name, 
field.FieldID, field.Transform))
+               }
+
+               partitionVal := statistics.PartitionValue(field, currentSchema)
+               if partitionVal != nil {
+                       partitionValues[field.FieldID] = partitionVal
                }
        }
 
        return statistics.ToDataFile(tblutils.DataFileOpts{
-               Schema:          currentSchema,
-               Spec:            currentSpec,
-               Path:            filePath,
-               Format:          iceberg.ParquetFile,
-               Content:         iceberg.EntryContentData,
-               FileSize:        rdr.SourceFileSize(),
-               PartitionValues: partitionValues,
-               SortOrderID:     sortOrderID,
+               Schema:                      currentSchema,
+               Spec:                        currentSpec,
+               Path:                        filePath,
+               Format:                      iceberg.ParquetFile,
+               Content:                     iceberg.EntryContentData,
+               FileSize:                    rdr.SourceFileSize(),
+               PartitionValues:             partitionValues,
+               PreserveNullPartitionValues: true,
+               SortOrderID:                 sortOrderID,
        })
 }
 
diff --git a/table/file_partition_values_test.go 
b/table/file_partition_values_test.go
new file mode 100644
index 000000000..a6134c686
--- /dev/null
+++ b/table/file_partition_values_test.go
@@ -0,0 +1,74 @@
+// 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 table
+
+import (
+       "context"
+       "testing"
+
+       "github.com/apache/arrow-go/v18/arrow/decimal128"
+       "github.com/apache/iceberg-go"
+       "github.com/google/uuid"
+       "github.com/stretchr/testify/require"
+)
+
+func TestValidFilePartitionValueType(t *testing.T) {
+       fixed, err := iceberg.FixedTypeOf(2)
+       require.NoError(t, err)
+       dec, err := iceberg.DecimalTypeOf(3, 1)
+       require.NoError(t, err)
+       tests := []struct {
+               typ            iceberg.Type
+               valid, invalid any
+       }{
+               {iceberg.PrimitiveTypes.Bool, true, "true"},
+               {iceberg.PrimitiveTypes.Int32, int32(1), int64(1)},
+               {iceberg.PrimitiveTypes.Int64, int64(1), int32(1)},
+               {iceberg.PrimitiveTypes.Float32, float32(1), float64(1)},
+               {iceberg.PrimitiveTypes.Float64, float64(1), float32(1)},
+               {iceberg.PrimitiveTypes.String, "x", []byte("x")},
+               {iceberg.PrimitiveTypes.Binary, []byte("x"), "x"},
+               {fixed, []byte("ab"), []byte("a")},
+               {iceberg.PrimitiveTypes.Date, iceberg.Date(1), int32(1)},
+               {iceberg.PrimitiveTypes.Time, iceberg.Time(1), int64(1)},
+               {iceberg.PrimitiveTypes.Timestamp, iceberg.Timestamp(1), 
int64(1)},
+               {iceberg.PrimitiveTypes.TimestampTz, iceberg.Timestamp(1), 
int64(1)},
+               {iceberg.PrimitiveTypes.UUID, uuid.UUID{}, []byte("x")},
+               {dec, iceberg.Decimal{Val: decimal128.FromI64(123), Scale: 1}, 
iceberg.Decimal{Val: decimal128.FromI64(1000), Scale: 1}},
+       }
+       for _, tc := range tests {
+               t.Run(tc.typ.String(), func(t *testing.T) {
+                       require.True(t, validFilePartitionValueType(tc.typ, 
tc.valid))
+                       require.False(t, validFilePartitionValueType(tc.typ, 
tc.invalid))
+               })
+       }
+}
+
+func TestFileToDataFileRejectsUnknownTransformWithExplicitPartition(t 
*testing.T) {
+       unknown, err := iceberg.ParseTransform("opaque-user-transform")
+       require.NoError(t, err)
+       spec := iceberg.NewPartitionSpecID(1, iceberg.PartitionField{
+               SourceIDs: []int{1}, FieldID: 1000, Name: "opaque", Transform: 
unknown,
+       })
+
+       // No storage is needed: the invalid write is rejected before opening 
the file.
+       _, err = FileToDataFileWithOptions(context.Background(), nil, 
"unused.parquet",
+               simpleSchema(), spec, 0, nil,
+               WithFilePartitionValues(map[int]any{1000: "opaque-value"}))
+       require.ErrorIs(t, err, iceberg.ErrInvalidTransform)
+}
diff --git a/table/internal/utils.go b/table/internal/utils.go
index ca52adf4e..2bbbc2c4c 100644
--- a/table/internal/utils.go
+++ b/table/internal/utils.go
@@ -292,6 +292,8 @@ type DataFileOpts struct {
        Content         iceberg.ManifestEntryContent
        FileSize        int64
        PartitionValues map[int]any
+       // PreserveNullPartitionValues distinguishes an explicit null from an 
omitted value.
+       PreserveNullPartitionValues bool
        // SortOrderID claims the file's rows are fully sorted by that order; 
zero
        // makes no claim and leaves the field absent.
        SortOrderID int
@@ -313,9 +315,9 @@ func (d *DataFileStatistics) ToDataFile(opts DataFileOpts) 
iceberg.DataFile {
                for _, field := range opts.Spec.Fields() {
                        sourceField, sourceFieldFound := 
opts.Schema.FindFieldByIDRef(
                                field.FirstSourceID(), 
iceberginternal.SchemaRef{})
-                       partitionVal := opts.PartitionValues[field.FieldID]
+                       partitionVal, supplied := 
opts.PartitionValues[field.FieldID]
                        switch {
-                       case partitionVal != nil:
+                       case partitionVal != nil || (supplied && 
opts.PreserveNullPartitionValues):
                                // prioritizing caller-supplied value.
                                fieldIDToPartitionData[field.FieldID] = 
partitionVal
                        case field.Transform.PreservesOrder() && 
sourceFieldFound:
diff --git a/table/table_test.go b/table/table_test.go
index a20272dfd..aa99a39ad 100644
--- a/table/table_test.go
+++ b/table/table_test.go
@@ -834,7 +834,7 @@ func (t *TableWritingTestSuite) 
TestAddFilesToBucketPartitionedTableFails() {
        tx := tbl.NewTransaction()
        err := tx.AddDataFilesByPath(t.ctx, files, nil, false)
        t.Error(err)
-       t.ErrorContains(err, "cannot infer partition value from parquet 
metadata for a non-linear partition field: baz_bucket_3 with transform 
bucket[3]")
+       t.ErrorContains(err, "cannot infer partition field \"baz_bucket_3\"")
 }
 
 func (t *TableWritingTestSuite) 
TestAddFilesToPartitionedTableFailsLowerAndUpperMismatch() {
diff --git a/table/transaction.go b/table/transaction.go
index 67ffbb08c..f3b182952 100644
--- a/table/transaction.go
+++ b/table/transaction.go
@@ -1082,7 +1082,7 @@ func (t *Transaction) ReplaceDataFilesByPath(ctx 
context.Context, filesToDelete,
        if err != nil {
                return err
        }
-       dataFiles, err := filesToDataFiles(ctx, fs, meta, filesToAdd, 1)
+       dataFiles, err := filesToDataFiles(ctx, fs, meta, filesToAdd, 1, nil)
        if err != nil {
                return err
        }
@@ -2246,7 +2246,12 @@ func (t *Transaction) replaceFiles(ctx context.Context, 
dataFilesToDelete, dataF
 type AddDataFilesByPathOption func(addFilesOp *addFilesOperation)
 
 type addFilesOperation struct {
-       concurrency int
+       concurrency     int
+       partitionValues *addFilesPartitionValues
+}
+
+type addFilesPartitionValues struct {
+       byPath map[string]map[int]any
 }
 
 // WithAddDataFilesByPathConcurrency overrides the concurrency used by 
AddDataFilesByPath.
@@ -2259,8 +2264,28 @@ func WithAddDataFilesByPathConcurrency(concurrency int) 
AddDataFilesByPathOption
        }
 }
 
+// WithAddDataFilesByPathPartitionValues supplies the partition tuple for each
+// existing data file, indexed first by file path and then by partition field 
ID.
+// Bucket partitions cannot be inferred from Parquet min/max statistics, so the
+// caller must provide all non-void partition fields for bucket specs. For 
other
+// specs, unlisted fields are inferred when their transforms preserve order.
+// Void fields are set to null.
+// All supplied values must be correct for every row of their file.
+func WithAddDataFilesByPathPartitionValues(values map[string]map[int]any) 
AddDataFilesByPathOption {
+       partitionValues := make(map[string]map[int]any, len(values))
+       for path, tuple := range values {
+               partitionValues[path] = cloneFilePartitionTuple(tuple)
+       }
+
+       return func(op *addFilesOperation) {
+               op.partitionValues = &addFilesPartitionValues{byPath: 
partitionValues}
+       }
+}
+
 // AddDataFilesByPath adds data files at the provided paths to the table.
 // Unlike AddDataFiles, it opens each file to read its schema and statistics.
+// Use WithAddDataFilesByPathPartitionValues for partition transforms whose
+// values cannot be inferred from column statistics (for example, bucket[N]).
 func (t *Transaction) AddDataFilesByPath(ctx context.Context, filePaths 
[]string, snapshotProps iceberg.Properties, ignoreDuplicates bool, opts 
...AddDataFilesByPathOption) error {
        meta, err := t.txnMeta()
        if err != nil {
@@ -2280,6 +2305,15 @@ func (t *Transaction) AddDataFilesByPath(ctx 
context.Context, filePaths []string
                }
                set[filePath] = struct{}{}
        }
+       var partitionValues map[string]map[int]any
+       if addFilesOp.partitionValues != nil {
+               partitionValues = addFilesOp.partitionValues.byPath
+       }
+       for filePath := range partitionValues {
+               if _, ok := set[filePath]; !ok {
+                       return fmt.Errorf("%w: partition values supplied for 
path %q not present in AddDataFilesByPath", iceberg.ErrInvalidArgument, 
filePath)
+               }
+       }
 
        fs, err := t.tbl.fsF(ctx)
        if err != nil {
@@ -2329,7 +2363,7 @@ func (t *Transaction) AddDataFilesByPath(ctx 
context.Context, filePaths []string
        }
        updater := t.appendSnapshotProducer(meta, wfs, snapshotProps)
 
-       dataFiles, err := filesToDataFiles(ctx, fs, meta, filePaths, 
addFilesOp.concurrency)
+       dataFiles, err := filesToDataFiles(ctx, fs, meta, filePaths, 
addFilesOp.concurrency, partitionValues)
        if err != nil {
                return err
        }
diff --git a/table/v1_partition_import_test.go 
b/table/v1_partition_import_test.go
new file mode 100644
index 000000000..f18d4d5f7
--- /dev/null
+++ b/table/v1_partition_import_test.go
@@ -0,0 +1,323 @@
+// 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 table_test
+
+import (
+       "context"
+       "os"
+       "path/filepath"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/apache/iceberg-go/table"
+       "github.com/stretchr/testify/require"
+)
+
+// A v1 table with an externally written, bucket-partitioned Parquet file.
+// Its rows share one bucket, which is required by the Iceberg specification.
+func makeV1BucketImportFixture(t *testing.T) (*table.Table, iceio.LocalFS, 
string, int, int32) {
+       t.Helper()
+
+       dir := t.TempDir()
+       schema := iceberg.NewSchema(1,
+               iceberg.NestedField{ID: 1, Name: "id", Type: 
iceberg.PrimitiveTypes.Int32, Required: true},
+       )
+       bucket := iceberg.BucketTransform{NumBuckets: 8}
+       spec := iceberg.NewPartitionSpecID(0, iceberg.PartitionField{
+               SourceIDs: []int{1}, FieldID: 1000, Name: "id_bucket", 
Transform: bucket,
+       })
+       meta, err := table.NewMetadata(schema, &spec, table.UnsortedSortOrder, 
dir, iceberg.Properties{
+               table.PropertyFormatVersion: "1",
+       })
+       require.NoError(t, err)
+       require.Equal(t, 1, meta.FormatVersion())
+
+       parquetBytes, _ := parquetMetaFromSchema(t, schema, 
"[{\"id\":7},{\"id\":7}]")
+       path := filepath.Join(dir, "data.parquet")
+       require.NoError(t, os.WriteFile(path, parquetBytes, 0o600))
+
+       result := bucket.Apply(iceberg.Optional[iceberg.Literal]{
+               Valid: true,
+               Val:   iceberg.Int32Literal(7),
+       })
+       require.True(t, result.Valid)
+       bucketID := result.Val.(iceberg.Int32Literal).Value()
+       currentSpec := meta.PartitionSpec()
+       fieldID := currentSpec.Field(0).FieldID
+
+       fs := iceio.LocalFS{}
+       cat := &mergeCatalog{meta: meta}
+       tbl := table.New(table.Identifier{"default", "bucket_import"}, meta, 
filepath.Join(dir, "metadata", "00000.json"),
+               func(context.Context) (iceio.IO, error) { return fs, nil }, cat)
+
+       return tbl, fs, path, fieldID, bucketID
+}
+
+func TestV1FileToDataFileExplicitBucketPartition(t *testing.T) {
+       tbl, fs, path, fieldID, bucketID := makeV1BucketImportFixture(t)
+       spec := tbl.Metadata().PartitionSpec()
+
+       _, err := table.FileToDataFileWithOptions(t.Context(), fs, path, 
tbl.Metadata().CurrentSchema(), spec, 0, nil)
+       require.ErrorContains(t, err, "cannot infer partition field")
+
+       file, err := table.FileToDataFileWithOptions(t.Context(), fs, path, 
tbl.Metadata().CurrentSchema(), spec, 0, nil,
+               table.WithFilePartitionValues(map[int]any{fieldID: bucketID}))
+       require.NoError(t, err)
+       require.Equal(t, bucketID, file.Partition()[fieldID])
+       require.EqualValues(t, 2, file.Count())
+       require.Positive(t, file.FileSizeBytes())
+
+       t.Run("invalid bucket type", func(t *testing.T) {
+               _, err := table.FileToDataFileWithOptions(t.Context(), fs, 
path, tbl.Metadata().CurrentSchema(), spec, 0, nil,
+                       table.WithFilePartitionValues(map[int]any{fieldID: 
int(1)}))
+               require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+       })
+
+       t.Run("out of range bucket", func(t *testing.T) {
+               _, err := table.FileToDataFileWithOptions(t.Context(), fs, 
path, tbl.Metadata().CurrentSchema(), spec, 0, nil,
+                       table.WithFilePartitionValues(map[int]any{fieldID: 
int32(8)}))
+               require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+       })
+
+       t.Run("negative bucket", func(t *testing.T) {
+               _, err := table.FileToDataFileWithOptions(t.Context(), fs, 
path, tbl.Metadata().CurrentSchema(), spec, 0, nil,
+                       table.WithFilePartitionValues(map[int]any{fieldID: 
int32(-1)}))
+               require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+       })
+
+       t.Run("unknown partition field", func(t *testing.T) {
+               _, err := table.FileToDataFileWithOptions(t.Context(), fs, 
path, tbl.Metadata().CurrentSchema(), spec, 0, nil,
+                       table.WithFilePartitionValues(map[int]any{fieldID + 
1000: bucketID}))
+               require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+       })
+
+       t.Run("null bucket", func(t *testing.T) {
+               file, err := table.FileToDataFileWithOptions(t.Context(), fs, 
path, tbl.Metadata().CurrentSchema(), spec, 0, nil,
+                       table.WithFilePartitionValues(map[int]any{fieldID: 
nil}))
+               require.NoError(t, err)
+               value, exists := file.Partition()[fieldID]
+               require.True(t, exists)
+               require.Nil(t, value)
+       })
+}
+
+func TestV1ImportVoidPartitionWithoutExplicitValue(t *testing.T) {
+       tbl, fs, path, _, _ := makeV1BucketImportFixture(t)
+       spec := iceberg.NewPartitionSpecID(1, iceberg.PartitionField{
+               SourceIDs: []int{1}, FieldID: 1000, Name: "old_id_bucket", 
Transform: iceberg.VoidTransform{},
+       })
+
+       file, err := table.FileToDataFileWithOptions(t.Context(), fs, path, 
tbl.Metadata().CurrentSchema(), spec, 0, nil)
+       require.NoError(t, err)
+       value, ok := file.Partition()[1000]
+       require.True(t, ok)
+       require.Nil(t, value)
+
+       _, err = table.FileToDataFileWithOptions(t.Context(), fs, path, 
tbl.Metadata().CurrentSchema(), spec, 0, nil,
+               table.WithFilePartitionValues(map[int]any{1000: int32(0)}))
+       require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+}
+
+func TestV1AddDataFilesByPathBucketPartitionCommit(t *testing.T) {
+       tbl, fs, path, fieldID, bucketID := makeV1BucketImportFixture(t)
+       tx := tbl.NewTransaction()
+
+       err := tx.AddDataFilesByPath(t.Context(), []string{path}, nil, false,
+               
table.WithAddDataFilesByPathPartitionValues(map[string]map[int]any{
+                       path: {fieldID: bucketID},
+               }))
+       require.NoError(t, err)
+
+       committed, err := tx.Commit(t.Context())
+       require.NoError(t, err)
+       require.Equal(t, 1, committed.Metadata().FormatVersion())
+
+       snapshot := committed.CurrentSnapshot()
+       require.NotNil(t, snapshot)
+       manifests, err := snapshot.Manifests(fs)
+       require.NoError(t, err)
+       require.NotEmpty(t, manifests)
+
+       var imported int
+       for _, manifest := range manifests {
+               for entry, err := range manifest.Entries(fs, true) {
+                       require.NoError(t, err)
+                       if entry.DataFile().FilePath() == path {
+                               imported++
+                               require.Equal(t, bucketID, 
entry.DataFile().Partition()[fieldID])
+                       }
+               }
+       }
+       require.Equal(t, 1, imported)
+}
+
+func TestV1AddDataFilesByPathValidatesPartitionPaths(t *testing.T) {
+       tbl, _, path, fieldID, bucketID := makeV1BucketImportFixture(t)
+       tx := tbl.NewTransaction()
+
+       err := tx.AddDataFilesByPath(t.Context(), []string{path}, nil, false,
+               
table.WithAddDataFilesByPathPartitionValues(map[string]map[int]any{
+                       "other.parquet": {fieldID: bucketID},
+               }))
+       require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+
+       // A rejected option must not poison the transaction. Import without the
+       // bucket value still fails rather than writing a mispartitioned file.
+       err = tx.AddDataFilesByPath(t.Context(), []string{path}, nil, false)
+       require.ErrorContains(t, err, "cannot infer partition field")
+}
+
+func TestV1FileToDataFileExplicitPartitionValues(t *testing.T) {
+       tbl, fs, path, _, bucketID := makeV1BucketImportFixture(t)
+       spec := iceberg.NewPartitionSpecID(1,
+               iceberg.PartitionField{SourceIDs: []int{1}, FieldID: 1000, 
Name: "bucket", Transform: iceberg.BucketTransform{NumBuckets: 8}},
+               iceberg.PartitionField{SourceIDs: []int{1}, FieldID: 1001, 
Name: "id", Transform: iceberg.IdentityTransform{}},
+               iceberg.PartitionField{SourceIDs: []int{1}, FieldID: 1002, 
Name: "old", Transform: iceberg.VoidTransform{}},
+       )
+       convert := func(values map[int]any) (iceberg.DataFile, error) {
+               return table.FileToDataFileWithOptions(t.Context(), fs, path, 
tbl.Metadata().CurrentSchema(), spec, 0, nil,
+                       table.WithFilePartitionValues(values))
+       }
+
+       _, err := convert(map[int]any{1000: bucketID})
+       require.ErrorContains(t, err, "cannot infer partition field")
+       _, err = convert(map[int]any{1000: bucketID, 1001: "7"})
+       require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+       file, err := convert(map[int]any{1000: bucketID, 1001: int32(7)})
+       require.NoError(t, err)
+       require.Equal(t, int32(7), file.Partition()[1001])
+       require.Nil(t, file.Partition()[1002])
+
+       // A supplied value cannot override the footer for identity partitions.
+       _, err = convert(map[int]any{1000: bucketID, 1001: int32(99)})
+       require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+       _, err = convert(map[int]any{1000: bucketID, 1001: nil})
+       require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+}
+
+func TestV1FileToDataFileChecksSuppliedOrderPreservingBounds(t *testing.T) {
+       tbl, fs, path, _, _ := makeV1BucketImportFixture(t)
+       spec := iceberg.NewPartitionSpecID(1,
+               iceberg.PartitionField{SourceIDs: []int{1}, FieldID: 1000, 
Name: "id", Transform: iceberg.IdentityTransform{}},
+               iceberg.PartitionField{SourceIDs: []int{1}, FieldID: 1001, 
Name: "id_truncated", Transform: iceberg.TruncateTransform{Width: 10}},
+       )
+       convert := func(values map[int]any) (iceberg.DataFile, error) {
+               return table.FileToDataFileWithOptions(t.Context(), fs, path, 
tbl.Metadata().CurrentSchema(), spec, 0, nil,
+                       table.WithFilePartitionValues(values))
+       }
+
+       file, err := convert(map[int]any{1000: int32(7), 1001: int32(0)})
+       require.NoError(t, err)
+       require.Equal(t, int32(7), file.Partition()[1000])
+       require.Equal(t, int32(0), file.Partition()[1001])
+
+       inferred, err := convert(nil)
+       require.NoError(t, err)
+       require.Equal(t, file.Partition(), inferred.Partition())
+
+       for _, tc := range []struct {
+               name   string
+               values map[int]any
+       }{
+               {"incorrect identity", map[int]any{1000: int32(99), 1001: 
int32(0)}},
+               {"incorrect truncate", map[int]any{1000: int32(7), 1001: 
int32(10)}},
+               {"null identity over non-null rows", map[int]any{1000: nil, 
1001: int32(0)}},
+               {"null truncate over non-null rows", map[int]any{1000: 
int32(7), 1001: nil}},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       _, err := convert(tc.values)
+                       require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+                       require.ErrorContains(t, err, "does not match Parquet 
bounds")
+               })
+       }
+}
+
+func TestV1AddDataFilesByPathRejectsSuppliedIdentityMismatch(t *testing.T) {
+       _, fs, path, _, _ := makeV1BucketImportFixture(t)
+       dir := filepath.Dir(path)
+       schema := iceberg.NewSchema(1,
+               iceberg.NestedField{ID: 1, Name: "id", Type: 
iceberg.PrimitiveTypes.Int32, Required: true},
+       )
+       spec := iceberg.NewPartitionSpecID(0, iceberg.PartitionField{
+               SourceIDs: []int{1}, FieldID: 1000, Name: "id", Transform: 
iceberg.IdentityTransform{},
+       })
+       meta, err := table.NewMetadata(schema, &spec, table.UnsortedSortOrder, 
dir, iceberg.Properties{
+               table.PropertyFormatVersion: "1",
+       })
+       require.NoError(t, err)
+       tbl := table.New(table.Identifier{"default", "identity_import"}, meta, 
filepath.Join(dir, "metadata", "00000.json"),
+               func(context.Context) (iceio.IO, error) { return fs, nil }, 
&mergeCatalog{meta: meta})
+
+       for _, tc := range []struct {
+               name  string
+               value any
+       }{
+               {"incorrect identity", int32(99)},
+               {"null identity", nil},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       tx := tbl.NewTransaction()
+                       err := tx.AddDataFilesByPath(t.Context(), 
[]string{path}, nil, false,
+                               
table.WithAddDataFilesByPathPartitionValues(map[string]map[int]any{
+                                       path: {1000: tc.value},
+                               }))
+                       require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+                       require.ErrorContains(t, err, "does not match Parquet 
bounds")
+               })
+       }
+
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.AddDataFilesByPath(t.Context(), []string{path}, 
nil, false,
+               
table.WithAddDataFilesByPathPartitionValues(map[string]map[int]any{
+                       path: {1000: int32(7)},
+               })))
+}
+
+func TestV1ImportPartitionOptionsSnapshotCallerValues(t *testing.T) {
+       tbl, fs, path, fieldID, bucketID := makeV1BucketImportFixture(t)
+       values := map[int]any{fieldID: bucketID}
+       option := table.WithFilePartitionValues(values)
+       values[fieldID] = int32(-1)
+       file, err := table.FileToDataFileWithOptions(t.Context(), fs, path,
+               tbl.Metadata().CurrentSchema(), tbl.Metadata().PartitionSpec(), 
0, nil, option)
+       require.NoError(t, err)
+       require.Equal(t, bucketID, file.Partition()[fieldID])
+
+       byPath := map[string]map[int]any{path: {fieldID: bucketID}}
+       addOption := table.WithAddDataFilesByPathPartitionValues(byPath)
+       byPath[path][fieldID] = int32(-1)
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.AddDataFilesByPath(t.Context(), []string{path}, 
nil, false, addOption))
+       committed, err := tx.Commit(t.Context())
+       require.NoError(t, err)
+       manifests, err := committed.CurrentSnapshot().Manifests(fs)
+       require.NoError(t, err)
+       require.NotEmpty(t, manifests)
+       var found bool
+       for _, manifest := range manifests {
+               for entry, entryErr := range manifest.Entries(fs, true) {
+                       require.NoError(t, entryErr)
+                       if entry.DataFile().FilePath() == path {
+                               found = true
+                               require.Equal(t, bucketID, 
entry.DataFile().Partition()[fieldID])
+                       }
+               }
+       }
+       require.True(t, found)
+}

Reply via email to