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)