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 019b76781 fix(table): reject ambiguous row-id columns in position 
deltas (#1616)
019b76781 is described below

commit 019b767816aeff557c71b49b1b08287f20b1c669
Author: Minh Vu <[email protected]>
AuthorDate: Tue Aug 4 12:17:47 2026 +0200

    fix(table): reject ambiguous row-id columns in position deltas (#1616)
    
    ## What changed
    
    Require position-delta batches to contain at most one `_row_id` column.
    The shared lookup is used by `Reinsert`, `Insert`, and the null-column
    append path.
    
    ## Why
    
    Arrow schemas can contain duplicate field names. Selecting the first
    `_row_id` silently made reserved row-lineage metadata ambiguous and
    could ignore a conflicting or malformed second column.
    
    ## Testing
    
    - Added duplicate `_row_id` coverage for both `Reinsert` and `Insert`
    - `go test ./table`
    
    ---------
    
    Signed-off-by: Minh Vu <[email protected]>
---
 table/position_delta_writer.go      | 36 ++++++++++++++++++++++++-----
 table/position_delta_writer_test.go | 46 +++++++++++++++++++++++++++++++++++++
 2 files changed, 76 insertions(+), 6 deletions(-)

diff --git a/table/position_delta_writer.go b/table/position_delta_writer.go
index e7c97541f..14dc00a2e 100644
--- a/table/position_delta_writer.go
+++ b/table/position_delta_writer.go
@@ -114,13 +114,16 @@ func (w *PositionDeltaWriter) Reinsert(batch 
arrow.RecordBatch) error {
                return fmt.Errorf("%w: writer is already closed", 
ErrInvalidOperation)
        }
 
-       indices := batch.Schema().FieldIndices(iceberg.RowIDColumnName)
-       if len(indices) == 0 {
+       rowIDIndex, found, err := rowIDColumnIndex(batch.Schema())
+       if err != nil {
+               return err
+       }
+       if !found {
                return fmt.Errorf("%w: Reinsert batch must contain %s column",
                        iceberg.ErrInvalidArgument, iceberg.RowIDColumnName)
        }
 
-       col := batch.Column(indices[0])
+       col := batch.Column(rowIDIndex)
        if col.NullN() > 0 {
                return fmt.Errorf("%w: Reinsert batch %s column must not 
contain null values",
                        iceberg.ErrInvalidArgument, iceberg.RowIDColumnName)
@@ -140,8 +143,12 @@ func (w *PositionDeltaWriter) Insert(batch 
arrow.RecordBatch) error {
                return fmt.Errorf("%w: writer is already closed", 
ErrInvalidOperation)
        }
 
-       if indices := batch.Schema().FieldIndices(iceberg.RowIDColumnName); 
len(indices) > 0 {
-               if batch.Column(indices[0]).NullN() != int(batch.NumRows()) {
+       rowIDIndex, found, err := rowIDColumnIndex(batch.Schema())
+       if err != nil {
+               return err
+       }
+       if found {
+               if batch.Column(rowIDIndex).NullN() != int(batch.NumRows()) {
                        return fmt.Errorf("%w: Insert batch %s column must be 
all null (use Reinsert for preserved IDs)",
                                iceberg.ErrInvalidArgument, 
iceberg.RowIDColumnName)
                }
@@ -245,7 +252,11 @@ func (w *PositionDeltaWriter) buildUnifiedIterator() 
iter.Seq2[arrow.RecordBatch
 // already has _row_id (validated to be all-null by Insert), it is returned
 // retained as-is.
 func appendNullRowIDColumn(alloc memory.Allocator, batch arrow.RecordBatch) 
(arrow.RecordBatch, error) {
-       if indices := batch.Schema().FieldIndices(iceberg.RowIDColumnName); 
len(indices) > 0 {
+       _, found, err := rowIDColumnIndex(batch.Schema())
+       if err != nil {
+               return nil, err
+       }
+       if found {
                batch.Retain()
 
                return batch, nil
@@ -278,3 +289,16 @@ func appendNullRowIDColumn(alloc memory.Allocator, batch 
arrow.RecordBatch) (arr
 
        return array.NewRecordBatch(newSchema, cols, nrows), nil
 }
+
+func rowIDColumnIndex(schema *arrow.Schema) (int, bool, error) {
+       indices := schema.FieldIndices(iceberg.RowIDColumnName)
+       switch len(indices) {
+       case 0:
+               return 0, false, nil
+       case 1:
+               return indices[0], true, nil
+       default:
+               return 0, false, fmt.Errorf("%w: batch contains multiple %s 
columns",
+                       iceberg.ErrInvalidArgument, iceberg.RowIDColumnName)
+       }
+}
diff --git a/table/position_delta_writer_test.go 
b/table/position_delta_writer_test.go
index 55eb524a0..629d3931a 100644
--- a/table/position_delta_writer_test.go
+++ b/table/position_delta_writer_test.go
@@ -280,3 +280,49 @@ func 
TestPositionDeltaWriter_ReinsertRejectsMissingColumn(t *testing.T) {
        require.Error(t, err)
        assert.Contains(t, err.Error(), "must contain _row_id")
 }
+
+func TestPositionDeltaWriter_RejectsDuplicateRowIDColumns(t *testing.T) {
+       for _, layout := range []struct {
+               name       string
+               appendRows func(first, second *array.Int64Builder)
+       }{
+               {"both null", func(first, second *array.Int64Builder) {
+                       first.AppendNull()
+                       second.AppendNull()
+               }},
+               {"both non-null", func(first, second *array.Int64Builder) {
+                       first.Append(10)
+                       second.Append(11)
+               }},
+       } {
+               t.Run(layout.name, func(t *testing.T) {
+                       schema := arrow.NewSchema([]arrow.Field{
+                               {Name: "id", Type: arrow.PrimitiveTypes.Int64},
+                               {Name: iceberg.RowIDColumnName, Type: 
arrow.PrimitiveTypes.Int64, Nullable: true},
+                               {Name: iceberg.RowIDColumnName, Type: 
arrow.PrimitiveTypes.Int64, Nullable: true},
+                       }, nil)
+                       builder := 
array.NewRecordBuilder(memory.DefaultAllocator, schema)
+                       defer builder.Release()
+                       builder.Field(0).(*array.Int64Builder).Append(1)
+                       
layout.appendRows(builder.Field(1).(*array.Int64Builder), 
builder.Field(2).(*array.Int64Builder))
+                       batch := builder.NewRecordBatch()
+                       defer batch.Release()
+
+                       for _, operation := range []struct {
+                               name string
+                               call func(*table.PositionDeltaWriter) error
+                       }{
+                               {"Reinsert", func(w *table.PositionDeltaWriter) 
error { return w.Reinsert(batch) }},
+                               {"Insert", func(w *table.PositionDeltaWriter) 
error { return w.Insert(batch) }},
+                       } {
+                               t.Run(operation.name, func(t *testing.T) {
+                                       writer, err := 
table.NewPositionDeltaWriter(newV3RowLineageTestTable(t))
+                                       require.NoError(t, err)
+                                       err = operation.call(writer)
+                                       require.ErrorIs(t, err, 
iceberg.ErrInvalidArgument)
+                                       require.ErrorContains(t, err, "multiple 
_row_id columns")
+                               })
+                       }
+               })
+       }
+}

Reply via email to