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 96be131bd fix(literals): reject string-to-decimal casts that would 
round (#2458)
96be131bd is described below

commit 96be131bdfd6b3d7704982410039b409248eeb55
Author: Shivam Sharma <[email protected]>
AuthorDate: Mon Nov 9 23:00:43 2026 +0530

    fix(literals): reject string-to-decimal casts that would round (#2458)
    
    StringLiteral.To(DecimalType) used decimal128.FromString, which rounds
    to the target scale. A predicate bound with more fractional digits than
    the column's scale was silently rounded, so price > "12.345" on a
    decimal(9, 2) column bound as price > 12.35 and dropped rows equal to
    12.35.
    
    Return ErrBadCast when the string needs more fractional digits than the
    scale. Values the scale represents exactly still convert as before.
    
    Closes #2457
    
    Signed-off-by: Shivam Sharma 
<[email protected]>
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 literals.go      | 33 +++++++++++++++++++++++++++++++++
 literals_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 75 insertions(+)

diff --git a/literals.go b/literals.go
index 1b65bdb81..44546fe77 100644
--- a/literals.go
+++ b/literals.go
@@ -29,6 +29,7 @@ import (
        "reflect"
        "slices"
        "strconv"
+       "strings"
        "time"
        "unsafe"
 
@@ -1049,6 +1050,14 @@ func (s StringLiteral) To(typ Type) (Literal, error) {
                                ErrBadCast, s, typ, err.Error())
                }
 
+               // FromString rounds to the scale. A rounded bound changes 
which rows
+               // a predicate matches (price > 12.345 is not price > 12.35), so
+               // reject values with more fractional digits than the scale.
+               if decimalStringScale(string(s)) > int64(t.scale) {
+                       return nil, fmt.Errorf("%w: casting '%s' to %s - value 
has more fractional digits than the scale",
+                               ErrBadCast, s, typ)
+               }
+
                return DecimalLiteral{Val: n, Scale: t.scale}, nil
        case BooleanType:
                val, err := strconv.ParseBool(string(s))
@@ -1438,6 +1447,30 @@ func pow10Big(n int) *big.Int {
        return new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(n)), nil)
 }
 
+// decimalStringScale returns the smallest scale that represents the decimal
+// string s exactly: its fractional digits less its exponent, not counting
+// trailing zeros. s must already parse as a number. An exponent outside the
+// int32 range returns math.MaxInt64, so the value is rejected.
+func decimalStringScale(s string) int64 {
+       mantissa, exp := s, int64(0)
+       if i := strings.IndexAny(s, "eE"); i >= 0 {
+               var err error
+               if exp, err = strconv.ParseInt(s[i+1:], 10, 32); err != nil {
+                       return math.MaxInt64
+               }
+               mantissa = s[:i]
+       }
+
+       whole, frac, _ := strings.Cut(mantissa, ".")
+       digits := strings.TrimLeft(whole, "+-") + frac
+       significant := strings.TrimRight(digits, "0")
+       if significant == "" {
+               return 0
+       }
+
+       return int64(len(frac)) - exp - int64(len(digits)-len(significant))
+}
+
 func (d DecimalLiteral) Equals(other Literal) bool {
        rhs, ok := other.(DecimalLiteral)
        if !ok {
diff --git a/literals_test.go b/literals_test.go
index 7b52350fd..5875402b5 100644
--- a/literals_test.go
+++ b/literals_test.go
@@ -1429,6 +1429,48 @@ func TestBadStringLiteralCasts(t *testing.T) {
        }
 }
 
+func TestStringLiteralToDecimalScale(t *testing.T) {
+       dec92 := iceberg.MustDecimalTypeOf(9, 2)
+
+       // Values the type's scale represents exactly convert, trailing zeros 
included.
+       exact := []struct {
+               str      string
+               unscaled int64
+       }{
+               {"12.34", 1234},
+               {"-12.34", -1234},
+               {"12.3", 1230},
+               {"12", 1200},
+               {"12.340", 1234},
+               {"1234e-2", 1234},
+               {"1.2e1", 1200},
+               {"0.000", 0},
+               {"0e-5", 0},
+       }
+       for _, tt := range exact {
+               t.Run(tt.str, func(t *testing.T) {
+                       got, err := iceberg.NewLiteral(tt.str).To(dec92)
+                       require.NoError(t, err)
+                       assert.Equal(t, iceberg.DecimalLiteral{Val: 
decimal128.FromI64(tt.unscaled), Scale: 2}, got)
+               })
+       }
+
+       // Digits beyond the scale would be rounded away, so these must not 
convert.
+       for _, str := range []string{"12.345", "-12.345", "12.341", "0.001", 
"1.2345e1", "1e-3"} {
+               t.Run(str, func(t *testing.T) {
+                       _, err := iceberg.NewLiteral(str).To(dec92)
+                       assert.ErrorIs(t, err, iceberg.ErrBadCast)
+               })
+       }
+
+       // Rounding the bound would change the predicate: price > 12.345 must 
not
+       // bind as price > 12.35, which wrongly excludes 12.35.
+       sc := iceberg.NewSchema(0,
+               iceberg.NestedField{ID: 1, Name: "price", Type: dec92, 
Required: true})
+       _, err := iceberg.BindExpr(sc, 
iceberg.GreaterThan(iceberg.Reference("price"), "12.345"), true)
+       assert.ErrorIs(t, err, iceberg.ErrBadCast)
+}
+
 func TestStringLiteralToIntMaxMinValue(t *testing.T) {
        above, err := iceberg.NewLiteral(strconv.FormatInt(math.MaxInt32+1, 
10)).
                To(iceberg.PrimitiveTypes.Int32)

Reply via email to