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 82f0fb19b fix: decimal to int literal conversion ignores scale (#2096)
82f0fb19b is described below

commit 82f0fb19bc638dc954d942e5cd998b14670fa4c9
Author: Revanth <[email protected]>
AuthorDate: Fri Oct 2 13:03:00 2026 -0500

    fix: decimal to int literal conversion ignores scale (#2096)
---
 exprs_test.go    |  31 ++++++++++++
 literals.go      |  51 ++++++++++++++++++-
 literals_test.go | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++++--
 3 files changed, 227 insertions(+), 6 deletions(-)

diff --git a/exprs_test.go b/exprs_test.go
index d0c4a292c..ee313acd6 100644
--- a/exprs_test.go
+++ b/exprs_test.go
@@ -23,6 +23,7 @@ import (
        "strconv"
        "testing"
 
+       "github.com/apache/arrow-go/v18/arrow/decimal128"
        "github.com/apache/arrow-go/v18/parquet/variant"
        "github.com/apache/iceberg-go"
        "github.com/google/uuid"
@@ -1083,6 +1084,36 @@ func TestBindOutOfRangeDate(t *testing.T) {
        }
 }
 
+func TestBindDecimalToIntRespectsScale(t *testing.T) {
+       sc := iceberg.NewSchema(1,
+               iceberg.NestedField{ID: 1, Name: "qty", Type: 
iceberg.PrimitiveTypes.Int32},
+               iceberg.NestedField{ID: 2, Name: "total", Type: 
iceberg.PrimitiveTypes.Int64},
+       )
+
+       fractional := iceberg.Decimal{Val: decimal128.FromI64(1234), Scale: 2} 
// 12.34
+       whole := iceberg.Decimal{Val: decimal128.FromI64(1200), Scale: 2}      
// 12.00
+
+       for _, name := range []string{"qty", "total"} {
+               t.Run(name, func(t *testing.T) {
+                       ref := iceberg.Reference(name)
+
+                       // Binding 12.34 must not produce qty == 1234, and 
rounding would
+                       // turn qty < 12.34 into qty < 12, so both are rejected.
+                       _, err := iceberg.BindExpr(sc, iceberg.EqualTo(ref, 
fractional), true)
+                       require.ErrorIs(t, err, iceberg.ErrBadCast)
+
+                       _, err = iceberg.BindExpr(sc, iceberg.LessThan(ref, 
fractional), true)
+                       require.ErrorIs(t, err, iceberg.ErrBadCast)
+
+                       bound, err := iceberg.BindExpr(sc, iceberg.EqualTo(ref, 
whole), true)
+                       require.NoError(t, err)
+                       want, err := iceberg.BindExpr(sc, iceberg.EqualTo(ref, 
int64(12)), true)
+                       require.NoError(t, err)
+                       assert.True(t, want.Equals(bound), "expected %s, got 
%s", want, bound)
+               })
+       }
+}
+
 func TestVariantBoundLiteralRejectionMessage(t *testing.T) {
        sc := iceberg.NewSchema(0,
                iceberg.NestedField{ID: 1, Name: "payload", Type: 
iceberg.VariantType{}, Required: false},
diff --git a/literals.go b/literals.go
index 0a89adb40..c1f0b933b 100644
--- a/literals.go
+++ b/literals.go
@@ -1343,7 +1343,10 @@ func (d DecimalLiteral) To(t Type) (Literal, error) {
                return nil, fmt.Errorf("%w: could not convert %v to %s",
                        ErrBadCast, d, t)
        case Int32Type:
-               v := d.Val.BigInt()
+               v, err := d.integral(t)
+               if err != nil {
+                       return nil, err
+               }
                if !v.IsInt64() {
                        if v.Sign() > 0 {
                                return Int32AboveMaxLiteral(), nil
@@ -1360,7 +1363,10 @@ func (d DecimalLiteral) To(t Type) (Literal, error) {
 
                return Int32Literal(int32(i)), nil
        case Int64Type:
-               v := d.Val.BigInt()
+               v, err := d.integral(t)
+               if err != nil {
+                       return nil, err
+               }
                if !v.IsInt64() {
                        if v.Sign() > 0 {
                                return Int64AboveMaxLiteral(), nil
@@ -1386,6 +1392,47 @@ func (d DecimalLiteral) To(t Type) (Literal, error) {
        return nil, fmt.Errorf("%w: DecimalLiteral to %s", ErrBadCast, t)
 }
 
+// integral returns the decimal's value as a whole number, or ErrBadCast if
+// it has a nonzero fractional part. Rounding would be unsafe for range
+// predicates: qty < 12.34 must not bind as qty < 12.
+//
+// It uses big.Int rather than decimal128.Rescale, which panics on scales
+// outside [-38, 38] or when scaling up overflows 128 bits; DecimalLiteral
+// can be constructed directly without scale validation.
+func (d DecimalLiteral) integral(t Type) (*big.Int, error) {
+       v := d.Val.BigInt()
+       switch {
+       case d.Scale == 0 || v.Sign() == 0:
+               return v, nil
+       case d.Scale > 38:
+               // |v| < 10^39, so any scale above 38 leaves a fractional part. 
The
+               // literal is not formatted: String cannot render this scale.
+               return nil, fmt.Errorf("%w: decimal scale %d is out of range 
for conversion to %s",
+                       ErrBadCast, d.Scale, t)
+       case d.Scale > 0:
+               q, r := v.QuoRem(v, pow10Big(d.Scale), new(big.Int))
+               if r.Sign() != 0 {
+                       return nil, fmt.Errorf("%w: could not convert %v to %s 
without losing its fractional part",
+                               ErrBadCast, d, t)
+               }
+
+               return q, nil
+       default:
+               // A nonzero value times 10^19 is already outside int64, and 
callers
+               // clamp that to above max or below min, so larger exponents 
add nothing.
+               shift := 19
+               if d.Scale > -shift {
+                       shift = -d.Scale
+               }
+
+               return v.Mul(v, pow10Big(shift)), nil
+       }
+}
+
+func pow10Big(n int) *big.Int {
+       return new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(n)), nil)
+}
+
 func (d DecimalLiteral) Equals(other Literal) bool {
        rhs, ok := other.(DecimalLiteral)
        if !ok {
diff --git a/literals_test.go b/literals_test.go
index 6823033ae..c3bcb941a 100644
--- a/literals_test.go
+++ b/literals_test.go
@@ -19,7 +19,9 @@ package iceberg_test
 
 import (
        "encoding/binary"
+       "fmt"
        "math"
+       "math/big"
        "strconv"
        "testing"
        "time"
@@ -480,17 +482,158 @@ func TestDecimalLiteralTypeDoesNotPanicForLargeScale(t 
*testing.T) {
        })
 }
 
+func TestDecimalLiteralToIntegerRespectsScale(t *testing.T) {
+       dec := func(unscaled int64, scale int) iceberg.DecimalLiteral {
+               return iceberg.DecimalLiteral(iceberg.Decimal{Val: 
decimal128.FromI64(unscaled), Scale: scale})
+       }
+
+       for _, typ := range []iceberg.Type{iceberg.PrimitiveTypes.Int32, 
iceberg.PrimitiveTypes.Int64} {
+               t.Run(typ.String(), func(t *testing.T) {
+                       // 12.34 has a fractional part, so it cannot be cast 
without changing
+                       // the predicate's meaning.
+                       _, err := dec(1234, 2).To(typ)
+                       require.ErrorIs(t, err, iceberg.ErrBadCast)
+
+                       _, err = dec(-1, 3).To(typ)
+                       require.ErrorIs(t, err, iceberg.ErrBadCast)
+
+                       // 12.00 is a whole number and converts to 12.
+                       got, err := dec(1200, 2).To(typ)
+                       require.NoError(t, err)
+                       want, err := iceberg.NewLiteral(int32(12)).To(typ)
+                       require.NoError(t, err)
+                       assert.Truef(t, want.Equals(got), "expected: %s, got: 
%s", want, got)
+
+                       got, err = dec(-500, 2).To(typ)
+                       require.NoError(t, err)
+                       want, err = iceberg.NewLiteral(int32(-5)).To(typ)
+                       require.NoError(t, err)
+                       assert.Truef(t, want.Equals(got), "expected: %s, got: 
%s", want, got)
+               })
+       }
+
+       // Range checks apply to the scaled value, not the unscaled one.
+       above, err := dec((math.MaxInt32+1)*100, 
2).To(iceberg.PrimitiveTypes.Int32)
+       require.NoError(t, err)
+       assert.Equal(t, iceberg.Int32AboveMaxLiteral(), above)
+
+       got, err := dec((math.MaxInt32+1)*100, 
2).To(iceberg.PrimitiveTypes.Int64)
+       require.NoError(t, err)
+       assert.True(t, iceberg.NewLiteral(int64(math.MaxInt32+1)).Equals(got))
+
+       // The unscaled value is above the int max, but MaxInt32.000 itself 
fits.
+       got, err = dec(int64(math.MaxInt32)*1000, 
3).To(iceberg.PrimitiveTypes.Int32)
+       require.NoError(t, err)
+       assert.True(t, iceberg.NewLiteral(int32(math.MaxInt32)).Equals(got))
+}
+
+func TestDecimalLiteralToIntegerScaleEdgeCases(t *testing.T) {
+       dec := func(unscaled *big.Int, scale int) iceberg.DecimalLiteral {
+               return iceberg.DecimalLiteral(iceberg.Decimal{Val: 
decimal128.FromBigInt(unscaled), Scale: scale})
+       }
+       scaled := func(v int64, by int64) *big.Int {
+               return new(big.Int).Mul(big.NewInt(v), big.NewInt(by))
+       }
+       pow10 := func(n int64) *big.Int {
+               return new(big.Int).Exp(big.NewInt(10), big.NewInt(n), nil)
+       }
+       one, maxI64, minI64 := big.NewInt(1), big.NewInt(math.MaxInt64), 
big.NewInt(math.MinInt64)
+
+       // DecimalLiteral can be built with scales Iceberg schemas would reject;
+       // integer conversion must return an error or clamp, never panic.
+       t.Run("out of range scales", func(t *testing.T) {
+               for _, typ := range 
[]iceberg.Type{iceberg.PrimitiveTypes.Int32, iceberg.PrimitiveTypes.Int64} {
+                       for _, scale := range []int{39, 100, math.MaxInt} {
+                               _, err := dec(one, scale).To(typ)
+                               require.ErrorIs(t, err, iceberg.ErrBadCast, 
"scale %d to %s", scale, typ)
+                               assert.ErrorContains(t, err, 
fmt.Sprintf("decimal scale %d is out of range", scale))
+                               assert.NotContains(t, err.Error(), "PANIC")
+                       }
+
+                       got, err := dec(big.NewInt(0), 39).To(typ)
+                       require.NoError(t, err)
+                       want, err := iceberg.NewLiteral(int32(0)).To(typ)
+                       require.NoError(t, err)
+                       assert.True(t, want.Equals(got), "got %s", got)
+               }
+       })
+
+       t.Run("negative scales", func(t *testing.T) {
+               got, err := dec(big.NewInt(12), 
-1).To(iceberg.PrimitiveTypes.Int32)
+               require.NoError(t, err)
+               assert.True(t, iceberg.NewLiteral(int32(120)).Equals(got), "got 
%s", got)
+
+               got, err = dec(big.NewInt(-12), 
-2).To(iceberg.PrimitiveTypes.Int64)
+               require.NoError(t, err)
+               assert.True(t, iceberg.NewLiteral(int64(-1200)).Equals(got), 
"got %s", got)
+
+               // Around the 10^19 cap: ±9e18 fits in int64, ±1e19 does not.
+               nineE18 := new(big.Int).Mul(big.NewInt(9), pow10(18))
+               for _, sign := range []int64{1, -1} {
+                       got, err := dec(big.NewInt(9*sign), 
-18).To(iceberg.PrimitiveTypes.Int64)
+                       require.NoError(t, err)
+                       want := new(big.Int).Mul(nineE18, 
big.NewInt(sign)).Int64()
+                       assert.True(t, iceberg.NewLiteral(want).Equals(got), 
"got %s", got)
+               }
+
+               for _, tc := range []struct {
+                       unscaled int64
+                       scale    int
+               }{{10, -18}, {1, -19}} {
+                       above, err := dec(big.NewInt(tc.unscaled), 
tc.scale).To(iceberg.PrimitiveTypes.Int64)
+                       require.NoError(t, err)
+                       assert.Equal(t, iceberg.Int64AboveMaxLiteral(), above, 
"%d at scale %d", tc.unscaled, tc.scale)
+
+                       below, err := dec(big.NewInt(-tc.unscaled), 
tc.scale).To(iceberg.PrimitiveTypes.Int64)
+                       require.NoError(t, err)
+                       assert.Equal(t, iceberg.Int64BelowMinLiteral(), below, 
"%d at scale %d", -tc.unscaled, tc.scale)
+               }
+
+               for _, scale := range []int{-1, -40, math.MinInt} {
+                       above, err := dec(pow10(38), 
scale).To(iceberg.PrimitiveTypes.Int64)
+                       require.NoError(t, err)
+                       assert.Equal(t, iceberg.Int64AboveMaxLiteral(), above, 
"scale %d", scale)
+
+                       below, err := dec(new(big.Int).Neg(pow10(38)), 
scale).To(iceberg.PrimitiveTypes.Int32)
+                       require.NoError(t, err)
+                       assert.Equal(t, iceberg.Int32BelowMinLiteral(), below, 
"scale %d", scale)
+               }
+       })
+
+       t.Run("scaled int64 bounds", func(t *testing.T) {
+               got, err := dec(scaled(math.MaxInt64, 100), 
2).To(iceberg.PrimitiveTypes.Int64)
+               require.NoError(t, err)
+               assert.True(t, 
iceberg.NewLiteral(int64(math.MaxInt64)).Equals(got), "got %s", got)
+
+               got, err = dec(scaled(math.MinInt64, 100), 
2).To(iceberg.PrimitiveTypes.Int64)
+               require.NoError(t, err)
+               assert.True(t, 
iceberg.NewLiteral(int64(math.MinInt64)).Equals(got), "got %s", got)
+
+               above, err := dec(new(big.Int).Mul(new(big.Int).Add(maxI64, 
one), big.NewInt(100)), 2).
+                       To(iceberg.PrimitiveTypes.Int64)
+               require.NoError(t, err)
+               assert.Equal(t, iceberg.Int64AboveMaxLiteral(), above)
+
+               below, err := dec(new(big.Int).Mul(new(big.Int).Sub(minI64, 
one), big.NewInt(100)), 2).
+                       To(iceberg.PrimitiveTypes.Int64)
+               require.NoError(t, err)
+               assert.Equal(t, iceberg.Int64BelowMinLiteral(), below)
+
+               // MaxInt64.01 is fractional, so it errors rather than clamping.
+               _, err = dec(new(big.Int).Add(scaled(math.MaxInt64, 100), one), 
2).To(iceberg.PrimitiveTypes.Int64)
+               require.ErrorIs(t, err, iceberg.ErrBadCast)
+       })
+}
+
 func TestDecimalLiteralConversions(t *testing.T) {
        n1 := iceberg.Decimal{Val: decimal128.FromI64(1234), Scale: 2}
        n2 := iceberg.Decimal{Val: decimal128.FromI64(math.MaxInt32 + 1), 
Scale: 0}
-       n3 := iceberg.Decimal{Val: decimal128.FromI64(math.MinInt32 - 1), 
Scale: 10}
+       n3 := iceberg.Decimal{Val: decimal128.FromI64(math.MinInt32 - 1), 
Scale: 0}
 
        tests := []struct {
                from iceberg.DecimalLiteral
                to   iceberg.Literal
        }{
-               {iceberg.DecimalLiteral(n1), iceberg.NewLiteral(int32(1234))},
-               {iceberg.DecimalLiteral(n1), iceberg.NewLiteral(int64(1234))},
                {iceberg.DecimalLiteral(n2), 
iceberg.NewLiteral(int64(math.MaxInt32 + 1))},
                {iceberg.DecimalLiteral(n1), 
iceberg.NewLiteral(float32(12.34))},
                {iceberg.DecimalLiteral(n1), 
iceberg.NewLiteral(float64(12.34))},
@@ -516,7 +659,7 @@ func TestDecimalLiteralConversions(t *testing.T) {
        assert.Equal(t, iceberg.PrimitiveTypes.Int32, below.Type())
 
        n4 := iceberg.Decimal{Val: decimal128.FromU64(math.MaxInt64 + 1), 
Scale: 0}
-       n5 := iceberg.Decimal{Val: decimal128.FromU64(math.MaxUint64).Negate(), 
Scale: 20}
+       n5 := iceberg.Decimal{Val: decimal128.FromU64(math.MaxUint64).Negate(), 
Scale: 0}
 
        above, err = iceberg.DecimalLiteral(n4).To(iceberg.PrimitiveTypes.Int64)
        require.NoError(t, err)

Reply via email to