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/arrow-go.git


The following commit(s) were added to refs/heads/main by this push:
     new b3dacd2a fix(parquet/pqarrow): reject decimal overflow when writing 
integers (#1161)
b3dacd2a is described below

commit b3dacd2a2ce0bf709a29a63a233f1626b5eee817
Author: Minh Vu <[email protected]>
AuthorDate: Fri Aug 21 21:15:40 2026 +0200

    fix(parquet/pqarrow): reject decimal overflow when writing integers (#1161)
    
    ### Rationale for this change
    
    When decimals are stored as Parquet integers, values outside the
    declared or physical precision can be narrowed to int32 or int64 without
    a runtime error. That can silently write a different value.
    
    ### What changes are included in this PR?
    
    Validate non-null Decimal128 and Decimal256 values against both their
    declared precision and the target integer precision before conversion,
    and return arrow.ErrInvalid when a value does not fit.
    
    ### Are these changes tested?
    
    Yes. The test covers Decimal128 and Decimal256 with both int32 and int64
    Parquet storage. The full parquet/pqarrow package suite passes.
    
    ### Are there any user-facing changes?
    
    Invalid decimal arrays now return an error instead of producing
    corrupted integer values.
---
 parquet/pqarrow/encode_arrow.go      |  95 +++++++++++++----
 parquet/pqarrow/encode_arrow_test.go | 198 +++++++++++++++++++++++++++++++++++
 2 files changed, 275 insertions(+), 18 deletions(-)

diff --git a/parquet/pqarrow/encode_arrow.go b/parquet/pqarrow/encode_arrow.go
index b71452f9..8b49fb95 100644
--- a/parquet/pqarrow/encode_arrow.go
+++ b/parquet/pqarrow/encode_arrow.go
@@ -121,11 +121,58 @@ func targetDecimalPrecision(cw file.ColumnChunkWriter) 
(int32, bool) {
 }
 
 func decimal128FitsTargetPrecision(val decimal128.Num, precision int32) bool {
-       return precision > 0 && precision <= decimal128.MaxPrecision && 
val.FitsInPrecision(precision)
+       if precision <= 0 || precision > decimal128.MaxPrecision {
+               return false
+       }
+       // Abs overflows for the minimum two's-complement value.
+       if val.Sign() < 0 && val.Negate() == val {
+               return false
+       }
+       return val.FitsInPrecision(precision)
 }
 
 func decimal256FitsTargetPrecision(val decimal256.Num, precision int32) bool {
-       return precision > 0 && precision <= decimal256.MaxPrecision && 
val.FitsInPrecision(precision)
+       if precision <= 0 || precision > decimal256.MaxPrecision {
+               return false
+       }
+       // Abs overflows for the minimum two's-complement value.
+       if val.Sign() < 0 && val.Negate() == val {
+               return false
+       }
+       return val.FitsInPrecision(precision)
+}
+
+// validatePresentDecimalValues visits only values represented by a definition
+// level. Values below the repeated ancestor definition level are list
+// placeholders and do not have a corresponding entry in arr.
+func validatePresentDecimalValues(arr arrow.Array, defLevels []int16, 
levelInfo file.LevelInfo, validate func(int) error) error {
+       if len(defLevels) == 0 {
+               for idx := 0; idx < arr.Len(); idx++ {
+                       if arr.IsValid(idx) {
+                               if err := validate(idx); err != nil {
+                                       return err
+                               }
+                       }
+               }
+               return nil
+       }
+
+       valueIdx := 0
+       for _, defLevel := range defLevels {
+               if defLevel < levelInfo.RepeatedAncestorDefLevel {
+                       continue
+               }
+               if valueIdx >= arr.Len() {
+                       break
+               }
+               if defLevel == levelInfo.DefLevel && arr.IsValid(valueIdx) {
+                       if err := validate(valueIdx); err != nil {
+                               return err
+                       }
+               }
+               valueIdx++
+       }
+       return nil
 }
 
 // arrowColumnWriter is a convenience object for easily writing arrow data to 
a specific
@@ -414,31 +461,37 @@ func writeDenseArrow(ctx *arrowWriteContext, cw 
file.ColumnChunkWriter, leafArr
                        case arrow.DECIMAL128:
                                arr := leafArr.(*array.Decimal128)
                                precision, hasPrecision := 
targetDecimalPrecision(cw)
-                               for idx, val := range arr.Values() {
-                                       if arr.IsNull(idx) {
-                                               continue
-                                       }
+                               if err := validatePresentDecimalValues(arr, 
defLevels, cw.LevelInfo(), func(idx int) error {
+                                       val := arr.Value(idx)
                                        if !decimal128FitsInt32(val) {
                                                return fmt.Errorf("%w: 
Decimal128 value at index %d does not fit in Parquet INT32", arrow.ErrInvalid, 
idx)
                                        }
                                        if hasPrecision && 
!decimal128FitsTargetPrecision(val, precision) {
                                                return fmt.Errorf("%w: 
Decimal128 value at index %d does not fit Parquet DECIMAL precision %d", 
arrow.ErrInvalid, idx, precision)
                                        }
+                                       return nil
+                               }); err != nil {
+                                       return err
+                               }
+                               for idx, val := range arr.Values() {
                                        data[idx] = int32(val.LowBits())
                                }
                        case arrow.DECIMAL256:
                                arr := leafArr.(*array.Decimal256)
                                precision, hasPrecision := 
targetDecimalPrecision(cw)
-                               for idx, val := range arr.Values() {
-                                       if arr.IsNull(idx) {
-                                               continue
-                                       }
+                               if err := validatePresentDecimalValues(arr, 
defLevels, cw.LevelInfo(), func(idx int) error {
+                                       val := arr.Value(idx)
                                        if !decimal256FitsInt32(val) {
                                                return fmt.Errorf("%w: 
Decimal256 value at index %d does not fit in Parquet INT32", arrow.ErrInvalid, 
idx)
                                        }
                                        if hasPrecision && 
!decimal256FitsTargetPrecision(val, precision) {
                                                return fmt.Errorf("%w: 
Decimal256 value at index %d does not fit Parquet DECIMAL precision %d", 
arrow.ErrInvalid, idx, precision)
                                        }
+                                       return nil
+                               }); err != nil {
+                                       return err
+                               }
+                               for idx, val := range arr.Values() {
                                        data[idx] = int32(val.LowBits())
                                }
                        default:
@@ -515,16 +568,19 @@ func writeDenseArrow(ctx *arrowWriteContext, cw 
file.ColumnChunkWriter, leafArr
                        data = 
arrow.Int64Traits.CastFromBytes(ctx.dataBuffer.Bytes())
                        arr := leafArr.(*array.Decimal128)
                        precision, hasPrecision := targetDecimalPrecision(cw)
-                       for idx, val := range arr.Values() {
-                               if arr.IsNull(idx) {
-                                       continue
-                               }
+                       if err := validatePresentDecimalValues(arr, defLevels, 
cw.LevelInfo(), func(idx int) error {
+                               val := arr.Value(idx)
                                if !decimal128FitsInt64(val) {
                                        return fmt.Errorf("%w: Decimal128 value 
at index %d does not fit in Parquet INT64", arrow.ErrInvalid, idx)
                                }
                                if hasPrecision && 
!decimal128FitsTargetPrecision(val, precision) {
                                        return fmt.Errorf("%w: Decimal128 value 
at index %d does not fit Parquet DECIMAL precision %d", arrow.ErrInvalid, idx, 
precision)
                                }
+                               return nil
+                       }); err != nil {
+                               return err
+                       }
+                       for idx, val := range arr.Values() {
                                data[idx] = int64(val.LowBits())
                        }
                case arrow.DECIMAL256:
@@ -532,16 +588,19 @@ func writeDenseArrow(ctx *arrowWriteContext, cw 
file.ColumnChunkWriter, leafArr
                        data = 
arrow.Int64Traits.CastFromBytes(ctx.dataBuffer.Bytes())
                        arr := leafArr.(*array.Decimal256)
                        precision, hasPrecision := targetDecimalPrecision(cw)
-                       for idx, val := range arr.Values() {
-                               if arr.IsNull(idx) {
-                                       continue
-                               }
+                       if err := validatePresentDecimalValues(arr, defLevels, 
cw.LevelInfo(), func(idx int) error {
+                               val := arr.Value(idx)
                                if !decimal256FitsInt64(val) {
                                        return fmt.Errorf("%w: Decimal256 value 
at index %d does not fit in Parquet INT64", arrow.ErrInvalid, idx)
                                }
                                if hasPrecision && 
!decimal256FitsTargetPrecision(val, precision) {
                                        return fmt.Errorf("%w: Decimal256 value 
at index %d does not fit Parquet DECIMAL precision %d", arrow.ErrInvalid, idx, 
precision)
                                }
+                               return nil
+                       }); err != nil {
+                               return err
+                       }
+                       for idx, val := range arr.Values() {
                                data[idx] = int64(val.LowBits())
                        }
                default:
diff --git a/parquet/pqarrow/encode_arrow_test.go 
b/parquet/pqarrow/encode_arrow_test.go
index 7a4fdd01..7f49aeff 100644
--- a/parquet/pqarrow/encode_arrow_test.go
+++ b/parquet/pqarrow/encode_arrow_test.go
@@ -309,6 +309,204 @@ func TestWriteArrowCols(t *testing.T) {
        }
 }
 
+func TestWriteDecimalRejectsValuesOutsidePrecision(t *testing.T) {
+       tests := []struct {
+               name      string
+               dtype     arrow.DecimalType
+               value     string
+               valuePrec int32
+       }{
+               {"decimal128_int32", &arrow.Decimal128Type{Precision: 9}, 
"3000000000", 10},
+               {"decimal128_int64", &arrow.Decimal128Type{Precision: 18}, 
"10000000000000000000", 20},
+               {"decimal256_int32", &arrow.Decimal256Type{Precision: 9}, 
"3000000000", 10},
+               {"decimal256_int64", &arrow.Decimal256Type{Precision: 18}, 
"10000000000000000000", 20},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       mem := 
memory.NewCheckedAllocator(memory.DefaultAllocator)
+                       defer mem.AssertSize(t, 0)
+
+                       var values arrow.Array
+                       switch dtype := tt.dtype.(type) {
+                       case *arrow.Decimal128Type:
+                               value, err := decimal128.FromString(tt.value, 
tt.valuePrec, 0)
+                               require.NoError(t, err)
+                               builder := array.NewDecimal128Builder(mem, 
dtype)
+                               builder.Append(value)
+                               values = builder.NewDecimal128Array()
+                               builder.Release()
+                       case *arrow.Decimal256Type:
+                               value, err := decimal256.FromString(tt.value, 
tt.valuePrec, 0)
+                               require.NoError(t, err)
+                               builder := array.NewDecimal256Builder(mem, 
dtype)
+                               builder.Append(value)
+                               values = builder.NewDecimal256Array()
+                               builder.Release()
+                       }
+                       defer values.Release()
+
+                       sc := arrow.NewSchema([]arrow.Field{{Name: "decimal", 
Type: tt.dtype}}, nil)
+                       rec := array.NewRecordBatch(sc, []arrow.Array{values}, 
int64(values.Len()))
+                       defer rec.Release()
+
+                       var sink bytes.Buffer
+                       writer, err := pqarrow.NewFileWriter(sc, &sink,
+                               
parquet.NewWriterProperties(parquet.WithStoreDecimalAsInteger(true)),
+                               
pqarrow.NewArrowWriterProperties(pqarrow.WithAllocator(mem)))
+                       require.NoError(t, err)
+                       err = writer.Write(rec)
+                       require.ErrorIs(t, err, arrow.ErrInvalid)
+                       assert.ErrorContains(t, err, "does not fit")
+                       require.NoError(t, writer.Close())
+               })
+       }
+}
+
+func TestWriteDecimalRejectsTwoComplementMinimum(t *testing.T) {
+       tests := []struct {
+               name  string
+               dtype arrow.DecimalType
+               value any
+       }{
+               {"decimal128_int32", &arrow.Decimal128Type{Precision: 9}, 
decimal128.New(-1<<63, 0)},
+               {"decimal128_int64", &arrow.Decimal128Type{Precision: 18}, 
decimal128.New(-1<<63, 0)},
+               {"decimal256_int32", &arrow.Decimal256Type{Precision: 9}, 
decimal256.New(1<<63, 0, 0, 0)},
+               {"decimal256_int64", &arrow.Decimal256Type{Precision: 18}, 
decimal256.New(1<<63, 0, 0, 0)},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       mem := 
memory.NewCheckedAllocator(memory.DefaultAllocator)
+                       defer mem.AssertSize(t, 0)
+
+                       var values arrow.Array
+                       switch dtype := tt.dtype.(type) {
+                       case *arrow.Decimal128Type:
+                               builder := array.NewDecimal128Builder(mem, 
dtype)
+                               builder.Append(tt.value.(decimal128.Num))
+                               values = builder.NewDecimal128Array()
+                               builder.Release()
+                       case *arrow.Decimal256Type:
+                               builder := array.NewDecimal256Builder(mem, 
dtype)
+                               builder.Append(tt.value.(decimal256.Num))
+                               values = builder.NewDecimal256Array()
+                               builder.Release()
+                       }
+                       defer values.Release()
+
+                       sc := arrow.NewSchema([]arrow.Field{{Name: "decimal", 
Type: tt.dtype}}, nil)
+                       rec := array.NewRecordBatch(sc, []arrow.Array{values}, 
int64(values.Len()))
+                       defer rec.Release()
+
+                       var sink bytes.Buffer
+                       writer, err := pqarrow.NewFileWriter(sc, &sink,
+                               
parquet.NewWriterProperties(parquet.WithStoreDecimalAsInteger(true)),
+                               
pqarrow.NewArrowWriterProperties(pqarrow.WithAllocator(mem)))
+                       require.NoError(t, err)
+                       err = writer.Write(rec)
+                       require.ErrorIs(t, err, arrow.ErrInvalid)
+                       require.NoError(t, writer.Close())
+               })
+       }
+}
+
+func TestWriteDecimalIgnoresValuesUnderNullParent(t *testing.T) {
+       tests := []struct {
+               name      string
+               dtype     arrow.DecimalType
+               value     string
+               valuePrec int32
+       }{
+               {"decimal128_int32", &arrow.Decimal128Type{Precision: 9}, 
"3000000000", 10},
+               {"decimal128_int64", &arrow.Decimal128Type{Precision: 18}, 
"10000000000000000000", 20},
+               {"decimal256_int32", &arrow.Decimal256Type{Precision: 9}, 
"3000000000", 10},
+               {"decimal256_int64", &arrow.Decimal256Type{Precision: 18}, 
"10000000000000000000", 20},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       mem := 
memory.NewCheckedAllocator(memory.DefaultAllocator)
+                       defer mem.AssertSize(t, 0)
+
+                       var values arrow.Array
+                       switch dtype := tt.dtype.(type) {
+                       case *arrow.Decimal128Type:
+                               value, err := decimal128.FromString(tt.value, 
tt.valuePrec, 0)
+                               require.NoError(t, err)
+                               builder := array.NewDecimal128Builder(mem, 
dtype)
+                               builder.Append(value)
+                               builder.Append(decimal128.FromI64(1))
+                               values = builder.NewDecimal128Array()
+                               builder.Release()
+                       case *arrow.Decimal256Type:
+                               value, err := decimal256.FromString(tt.value, 
tt.valuePrec, 0)
+                               require.NoError(t, err)
+                               builder := array.NewDecimal256Builder(mem, 
dtype)
+                               builder.Append(value)
+                               builder.Append(decimal256.FromI64(1))
+                               values = builder.NewDecimal256Array()
+                               builder.Release()
+                       }
+                       defer values.Release()
+
+                       childField := arrow.Field{Name: "decimal", Type: 
tt.dtype}
+                       structField := arrow.Field{Name: "parent", Type: 
arrow.StructOf(childField), Nullable: true}
+                       validity := memory.NewBufferBytes([]byte{0x02})
+                       defer validity.Release()
+                       structData := array.NewData(structField.Type, 2, 
[]*memory.Buffer{validity}, []arrow.ArrayData{values.Data()}, 1, 0)
+                       defer structData.Release()
+                       structArray := array.NewStructData(structData)
+                       defer structArray.Release()
+
+                       sc := arrow.NewSchema([]arrow.Field{structField}, nil)
+                       rec := array.NewRecordBatch(sc, 
[]arrow.Array{structArray}, 2)
+                       defer rec.Release()
+
+                       var sink bytes.Buffer
+                       writer, err := pqarrow.NewFileWriter(sc, &sink,
+                               
parquet.NewWriterProperties(parquet.WithStoreDecimalAsInteger(true)),
+                               
pqarrow.NewArrowWriterProperties(pqarrow.WithAllocator(mem)))
+                       require.NoError(t, err)
+                       require.NoError(t, writer.Write(rec))
+                       require.NoError(t, writer.Close())
+               })
+       }
+}
+
+func TestWriteDecimalRejectsOverflowAfterEmptyList(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       dtype := &arrow.Decimal128Type{Precision: 4, Scale: 0}
+       listBuilder := array.NewListBuilder(mem, dtype)
+       valueBuilder := listBuilder.ValueBuilder().(*array.Decimal128Builder)
+
+       listBuilder.Append(true)
+       listBuilder.Append(true)
+       valueBuilder.Append(decimal128.FromI64(99999))
+       listBuilder.Append(true)
+       valueBuilder.Append(decimal128.FromI64(1))
+       lists := listBuilder.NewListArray()
+       listBuilder.Release()
+       defer lists.Release()
+
+       sc := arrow.NewSchema([]arrow.Field{{Name: "decimal", Type: 
lists.DataType()}}, nil)
+       rec := array.NewRecordBatch(sc, []arrow.Array{lists}, 
int64(lists.Len()))
+       defer rec.Release()
+
+       var sink bytes.Buffer
+       writer, err := pqarrow.NewFileWriter(sc, &sink,
+               
parquet.NewWriterProperties(parquet.WithStoreDecimalAsInteger(true)),
+               pqarrow.NewArrowWriterProperties(pqarrow.WithAllocator(mem)))
+       require.NoError(t, err)
+
+       err = writer.Write(rec)
+       require.ErrorIs(t, err, arrow.ErrInvalid)
+       assert.ErrorContains(t, err, "does not fit")
+       require.NoError(t, writer.Close())
+}
+
 func TestWriteArrowInt96(t *testing.T) {
        mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
        defer mem.AssertSize(t, 0)

Reply via email to