From 5bff726cf0f211bf700dc95e060b0822eae22001 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 5 Oct 2026 11:42:19 +0200 Subject: [PATCH 1/7] structaccess: reject lossy numeric conversions in Set convertValue fell back to reflect.Convert for numeric kinds, which truncates floats and wraps out-of-range integers. Check for precision loss and overflow, matching the rules in libs/dyn/convert. Co-authored-by: Isaac --- libs/structs/structaccess/set.go | 100 +++++++++++++++++ libs/structs/structaccess/set_numeric_test.go | 103 ++++++++++++++++++ libs/structs/structvar/structvar_test.go | 9 ++ 3 files changed, 212 insertions(+) create mode 100644 libs/structs/structaccess/set_numeric_test.go diff --git a/libs/structs/structaccess/set.go b/libs/structs/structaccess/set.go index 02752e456ea..96c428f5444 100644 --- a/libs/structs/structaccess/set.go +++ b/libs/structs/structaccess/set.go @@ -3,6 +3,7 @@ package structaccess import ( "errors" "fmt" + "math" "reflect" "slices" "strconv" @@ -280,6 +281,11 @@ func convertValue(valueVal reflect.Value, targetType reflect.Type) (reflect.Valu return valueVal, nil } + // Numeric conversions must not silently truncate, wrap or round. + if isNumericKind(valueType.Kind()) && isNumericKind(targetType.Kind()) { + return convertNumeric(valueVal, targetType) + } + // Convertibility check (handles typedefed types) if valueType.ConvertibleTo(targetType) { return valueVal.Convert(targetType), nil @@ -294,6 +300,15 @@ func convertValue(valueVal reflect.Value, targetType reflect.Type) (reflect.Valu ptr.Elem().Set(valueVal) return ptr, nil } + if isNumericKind(valueType.Kind()) && isNumericKind(elemType.Kind()) { + converted, err := convertNumeric(valueVal, elemType) + if err != nil { + return reflect.Value{}, err + } + ptr := reflect.New(elemType) + ptr.Elem().Set(converted) + return ptr, nil + } if valueType.ConvertibleTo(elemType) { ptr := reflect.New(elemType) ptr.Elem().Set(valueVal.Convert(elemType)) @@ -310,6 +325,91 @@ func convertValue(valueVal reflect.Value, targetType reflect.Type) (reflect.Valu return reflect.Value{}, fmt.Errorf("cannot convert %s to %s", valueType, targetType) } +func isNumericKind(k reflect.Kind) bool { + switch k { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return true + } + return false +} + +func isIntKind(k reflect.Kind) bool { + return k >= reflect.Int && k <= reflect.Int64 +} + +func isUintKind(k reflect.Kind) bool { + return k >= reflect.Uint && k <= reflect.Uint64 +} + +// convertNumeric converts between numeric kinds and fails instead of losing data, +// following the rules of libs/dyn/convert (normalizeInt, normalizeFloat): +// - float to int requires an integral value that fits the destination; +// - int to int (or uint) requires the value to fit the destination; +// - int to float requires the value to be exactly representable; +// - float64 to float32 must not overflow to infinity. +func convertNumeric(valueVal reflect.Value, targetType reflect.Type) (reflect.Value, error) { + srcKind := valueVal.Kind() + dstKind := targetType.Kind() + zero := reflect.New(targetType).Elem() + + switch { + case isIntKind(srcKind) && isIntKind(dstKind): + if v := valueVal.Int(); zero.OverflowInt(v) { + return reflect.Value{}, fmt.Errorf("value %d overflows %s", v, dstKind) + } + case isIntKind(srcKind) && isUintKind(dstKind): + if v := valueVal.Int(); v < 0 || zero.OverflowUint(uint64(v)) { + return reflect.Value{}, fmt.Errorf("value %d overflows %s", v, dstKind) + } + case isUintKind(srcKind) && isIntKind(dstKind): + if v := valueVal.Uint(); v > math.MaxInt64 || zero.OverflowInt(int64(v)) { + return reflect.Value{}, fmt.Errorf("value %d overflows %s", v, dstKind) + } + case isUintKind(srcKind) && isUintKind(dstKind): + if v := valueVal.Uint(); zero.OverflowUint(v) { + return reflect.Value{}, fmt.Errorf("value %d overflows %s", v, dstKind) + } + case isIntKind(srcKind) && !isIntKind(dstKind) && !isUintKind(dstKind): + v := valueVal.Int() + f := reflect.ValueOf(v).Convert(targetType).Float() + // float64(2^63) is out of int64 range, so it cannot be converted back to compare. + if f >= math.MaxInt64 || int64(f) != v { + return reflect.Value{}, fmt.Errorf("cannot set %d to %s: precision loss", v, dstKind) + } + case isUintKind(srcKind) && !isIntKind(dstKind) && !isUintKind(dstKind): + v := valueVal.Uint() + f := reflect.ValueOf(v).Convert(targetType).Float() + if f >= math.MaxUint64 || uint64(f) != v { + return reflect.Value{}, fmt.Errorf("cannot set %d to %s: precision loss", v, dstKind) + } + case isIntKind(dstKind) || isUintKind(dstKind): + // float source + f := valueVal.Float() + if math.IsNaN(f) || f != math.Trunc(f) { + return reflect.Value{}, fmt.Errorf("cannot set %v to %s: precision loss", f, dstKind) + } + // Bounds are powers of two, so they are exact in float64. + bits := float64(targetType.Bits()) + lo, hi := 0.0, math.Exp2(bits) + if isIntKind(dstKind) { + lo, hi = -math.Exp2(bits-1), math.Exp2(bits-1) + } + if f < lo || f >= hi { + return reflect.Value{}, fmt.Errorf("value %v overflows %s", f, dstKind) + } + default: + // float to float + f := valueVal.Float() + if zero.OverflowFloat(f) { + return reflect.Value{}, fmt.Errorf("value %v overflows %s", f, dstKind) + } + } + + return valueVal.Convert(targetType), nil +} + // updateForceSendFields handles ForceSendFields when setting values: // - If setting nil: remove field from ForceSendFields // - If setting empty value: add field to ForceSendFields (if not already present) diff --git a/libs/structs/structaccess/set_numeric_test.go b/libs/structs/structaccess/set_numeric_test.go new file mode 100644 index 00000000000..d5b85654710 --- /dev/null +++ b/libs/structs/structaccess/set_numeric_test.go @@ -0,0 +1,103 @@ +package structaccess_test + +import ( + "math" + "testing" + + "github.com/databricks/cli/libs/structs/structaccess" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type numericTarget struct { + I int `json:"i,omitempty"` + I8 int8 `json:"i8,omitempty"` + I32 int32 `json:"i32,omitempty"` + I64 int64 `json:"i64,omitempty"` + U uint `json:"u,omitempty"` + U8 uint8 `json:"u8,omitempty"` + U64 uint64 `json:"u64,omitempty"` + F32 float32 `json:"f32,omitempty"` + F64 float64 `json:"f64,omitempty"` + PI *int `json:"pi,omitempty"` + PI32 *int32 `json:"pi32,omitempty"` + PU *uint `json:"pu,omitempty"` + PF32 *float32 `json:"pf32,omitempty"` + PF64 *float64 `json:"pf64,omitempty"` +} + +func TestSetNumericConversion(t *testing.T) { + tests := []struct { + name string + path string + value any + err string // empty means success + check func(t *testing.T, v *numericTarget) + }{ + // int <- float + {name: "int from integral float", path: "i", value: 2.0, check: func(t *testing.T, v *numericTarget) { assert.Equal(t, 2, v.I) }}, + {name: "int from negative integral float", path: "i", value: -3.0, check: func(t *testing.T, v *numericTarget) { assert.Equal(t, -3, v.I) }}, + {name: "int from fractional float", path: "i", value: 1.9, err: "cannot set 1.9 to int: precision loss"}, + {name: "int from NaN", path: "i", value: math.NaN(), err: "cannot set NaN to int: precision loss"}, + {name: "int from +Inf", path: "i", value: math.Inf(1), err: "value +Inf overflows int"}, + {name: "int8 from out of range float", path: "i8", value: 200.0, err: "value 200 overflows int8"}, + {name: "int64 from float 2^63", path: "i64", value: math.Exp2(63), err: "overflows int64"}, + {name: "int64 from float -2^63", path: "i64", value: -math.Exp2(63), check: func(t *testing.T, v *numericTarget) { assert.Equal(t, int64(math.MinInt64), v.I64) }}, + {name: "uint from negative float", path: "u", value: -1.0, err: "value -1 overflows uint"}, + {name: "uint8 from out of range float", path: "u8", value: 256.0, err: "value 256 overflows uint8"}, + {name: "uint64 from float 2^64", path: "u64", value: math.Exp2(64), err: "overflows uint64"}, + {name: "uint from float32 integral", path: "u", value: float32(7), check: func(t *testing.T, v *numericTarget) { assert.Equal(t, uint(7), v.U) }}, + + // int <- int/uint + {name: "int32 from fitting int64", path: "i32", value: int64(5), check: func(t *testing.T, v *numericTarget) { assert.Equal(t, int32(5), v.I32) }}, + {name: "int32 from overflowing int64", path: "i32", value: int64(1 << 40), err: "value 1099511627776 overflows int32"}, + {name: "int8 from negative overflowing int", path: "i8", value: -129, err: "value -129 overflows int8"}, + {name: "int64 from uint64 above MaxInt64", path: "i64", value: uint64(math.MaxUint64), err: "value 18446744073709551615 overflows int64"}, + {name: "int64 from uint64 MaxInt64", path: "i64", value: uint64(math.MaxInt64), check: func(t *testing.T, v *numericTarget) { assert.Equal(t, int64(math.MaxInt64), v.I64) }}, + {name: "int8 from overflowing uint", path: "i8", value: uint(128), err: "value 128 overflows int8"}, + + // uint <- int/uint + {name: "uint from negative int", path: "u", value: -1, err: "value -1 overflows uint"}, + {name: "uint8 from overflowing int", path: "u8", value: 256, err: "value 256 overflows uint8"}, + {name: "uint8 from fitting int", path: "u8", value: 255, check: func(t *testing.T, v *numericTarget) { assert.Equal(t, uint8(255), v.U8) }}, + {name: "uint8 from overflowing uint64", path: "u8", value: uint64(300), err: "value 300 overflows uint8"}, + {name: "uint64 from large uint64", path: "u64", value: uint64(math.MaxUint64), check: func(t *testing.T, v *numericTarget) { assert.Equal(t, uint64(math.MaxUint64), v.U64) }}, + + // float <- int/uint + {name: "float64 from small int", path: "f64", value: 1 << 53, check: func(t *testing.T, v *numericTarget) { assert.InDelta(t, float64(1<<53), v.F64, 0) }}, + {name: "float64 from int64 above 2^53", path: "f64", value: int64(1<<53 + 1), err: "cannot set 9007199254740993 to float64: precision loss"}, + {name: "float64 from MaxInt64", path: "f64", value: int64(math.MaxInt64), err: "cannot set 9223372036854775807 to float64: precision loss"}, + {name: "float64 from uint64 above 2^53", path: "f64", value: uint64(1<<53 + 1), err: "cannot set 9007199254740993 to float64: precision loss"}, + {name: "float64 from MaxUint64", path: "f64", value: uint64(math.MaxUint64), err: "precision loss"}, + {name: "float32 from int above 2^24", path: "f32", value: 1<<24 + 1, err: "cannot set 16777217 to float32: precision loss"}, + {name: "float32 from int 2^24", path: "f32", value: 1 << 24, check: func(t *testing.T, v *numericTarget) { assert.InDelta(t, float32(1<<24), v.F32, 0) }}, + + // float <- float + {name: "float32 from float64", path: "f32", value: 1.5, check: func(t *testing.T, v *numericTarget) { assert.InDelta(t, float32(1.5), v.F32, 0) }}, + {name: "float32 from overflowing float64", path: "f32", value: 1e300, err: "value 1e+300 overflows float32"}, + {name: "float64 from float32", path: "f64", value: float32(0.5), check: func(t *testing.T, v *numericTarget) { assert.InDelta(t, 0.5, v.F64, 0) }}, + + // pointer targets + {name: "ptr int from integral float", path: "pi", value: 2.0, check: func(t *testing.T, v *numericTarget) { require.NotNil(t, v.PI); assert.Equal(t, 2, *v.PI) }}, + {name: "ptr int from fractional float", path: "pi", value: 1.9, err: "cannot set 1.9 to int: precision loss"}, + {name: "ptr int32 from overflowing int64", path: "pi32", value: int64(1 << 40), err: "value 1099511627776 overflows int32"}, + {name: "ptr uint from negative int", path: "pu", value: -1, err: "value -1 overflows uint"}, + {name: "ptr float32 from overflowing float64", path: "pf32", value: 1e300, err: "overflows float32"}, + {name: "ptr float64 from large int64", path: "pf64", value: int64(1<<53 + 1), err: "precision loss"}, + {name: "ptr float64 from small int", path: "pf64", value: 3, check: func(t *testing.T, v *numericTarget) { require.NotNil(t, v.PF64); assert.InDelta(t, 3.0, *v.PF64, 0) }}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + v := &numericTarget{} + err := structaccess.SetByString(v, tc.path, tc.value) + if tc.err != "" { + require.ErrorContains(t, err, tc.err) + assert.Equal(t, &numericTarget{}, v, "failed set must leave the struct untouched") + return + } + require.NoError(t, err) + tc.check(t, v) + }) + } +} diff --git a/libs/structs/structvar/structvar_test.go b/libs/structs/structvar/structvar_test.go index 9d4c2532ad6..81009829c70 100644 --- a/libs/structs/structvar/structvar_test.go +++ b/libs/structs/structvar/structvar_test.go @@ -290,3 +290,12 @@ func TestToStructVarRequiresPointerType(t *testing.T) { require.NoError(t, err) assert.Equal(t, "test", sv.Value.(*TestObj).Name) } + +func TestResolveRefLossyNumericConversion(t *testing.T) { + sv := structvar.NewStructVar(&TestObj{Age: 25}, map[string]string{"age": "${var.age}"}) + + err := sv.ResolveRef("${var.age}", 1.9) + require.ErrorContains(t, err, "cannot set 1.9 to int: precision loss") + assert.Equal(t, 25, sv.Value.(*TestObj).Age) + assert.Contains(t, sv.Refs, "age") +} From 789b4df8a62cac583a784030e9a35926017c8ca2 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 5 Oct 2026 11:49:24 +0200 Subject: [PATCH 2/7] Fix exhaustive lint in isNumericKind Co-authored-by: Isaac --- libs/structs/structaccess/set.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/structs/structaccess/set.go b/libs/structs/structaccess/set.go index 96c428f5444..59d01069b99 100644 --- a/libs/structs/structaccess/set.go +++ b/libs/structs/structaccess/set.go @@ -331,8 +331,9 @@ func isNumericKind(k reflect.Kind) bool { reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Float32, reflect.Float64: return true + default: + return false } - return false } func isIntKind(k reflect.Kind) bool { From 95d7810a0c66f2296a0005724a314ce6fdec4ffd Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 5 Oct 2026 12:57:27 +0200 Subject: [PATCH 3/7] Add acceptance test for lossy float ref; use expected values in table test Co-authored-by: Isaac --- .../bad_ref_float_to_int/databricks.yml | 15 ++++++++ .../bad_ref_float_to_int/out.plan.txt | 6 +++ .../bad_ref_float_to_int/out.test.toml | 2 + .../bad_ref_float_to_int/output.txt | 0 .../resource_deps/bad_ref_float_to_int/script | 1 + .../bad_ref_float_to_int/test.toml | 7 ++++ libs/structs/structaccess/set_numeric_test.go | 37 ++++++++++--------- 7 files changed, 51 insertions(+), 17 deletions(-) create mode 100644 acceptance/bundle/resource_deps/bad_ref_float_to_int/databricks.yml create mode 100644 acceptance/bundle/resource_deps/bad_ref_float_to_int/out.plan.txt create mode 100644 acceptance/bundle/resource_deps/bad_ref_float_to_int/out.test.toml create mode 100644 acceptance/bundle/resource_deps/bad_ref_float_to_int/output.txt create mode 100644 acceptance/bundle/resource_deps/bad_ref_float_to_int/script create mode 100644 acceptance/bundle/resource_deps/bad_ref_float_to_int/test.toml diff --git a/acceptance/bundle/resource_deps/bad_ref_float_to_int/databricks.yml b/acceptance/bundle/resource_deps/bad_ref_float_to_int/databricks.yml new file mode 100644 index 00000000000..ded0667ebe4 --- /dev/null +++ b/acceptance/bundle/resource_deps/bad_ref_float_to_int/databricks.yml @@ -0,0 +1,15 @@ +bundle: + name: test-bundle + +resources: + jobs: + bar: + name: job bar + job_clusters: + - job_cluster_key: main + new_cluster: + azure_attributes: + spot_bid_max_price: 1.9 + foo: + name: job foo + max_concurrent_runs: ${resources.jobs.bar.job_clusters[0].new_cluster.azure_attributes.spot_bid_max_price} diff --git a/acceptance/bundle/resource_deps/bad_ref_float_to_int/out.plan.txt b/acceptance/bundle/resource_deps/bad_ref_float_to_int/out.plan.txt new file mode 100644 index 00000000000..73bc1960b6b --- /dev/null +++ b/acceptance/bundle/resource_deps/bad_ref_float_to_int/out.plan.txt @@ -0,0 +1,6 @@ + +>>> musterr [CLI] bundle plan +Error: cannot plan resources.jobs.foo: cannot update max_concurrent_runs with value of "${resources.jobs.bar.job_clusters[0].new_cluster.azure_attributes.spot_bid_max_price}": cannot set (*jobs.JobSettings).max_concurrent_runs to float64 (1.9): cannot set 1.9 to int: precision loss + +Error: planning failed + diff --git a/acceptance/bundle/resource_deps/bad_ref_float_to_int/out.test.toml b/acceptance/bundle/resource_deps/bad_ref_float_to_int/out.test.toml new file mode 100644 index 00000000000..a927a5fbc06 --- /dev/null +++ b/acceptance/bundle/resource_deps/bad_ref_float_to_int/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resource_deps/bad_ref_float_to_int/output.txt b/acceptance/bundle/resource_deps/bad_ref_float_to_int/output.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/acceptance/bundle/resource_deps/bad_ref_float_to_int/script b/acceptance/bundle/resource_deps/bad_ref_float_to_int/script new file mode 100644 index 00000000000..f486f04ba0b --- /dev/null +++ b/acceptance/bundle/resource_deps/bad_ref_float_to_int/script @@ -0,0 +1 @@ +trace musterr $CLI bundle plan &> out.plan.txt diff --git a/acceptance/bundle/resource_deps/bad_ref_float_to_int/test.toml b/acceptance/bundle/resource_deps/bad_ref_float_to_int/test.toml new file mode 100644 index 00000000000..e66a9af3b05 --- /dev/null +++ b/acceptance/bundle/resource_deps/bad_ref_float_to_int/test.toml @@ -0,0 +1,7 @@ +RecordRequests = false + +Env.DATABRICKS_CACHE_ENABLED = 'false' + +[[Repls]] +Old = 'terraform.tfstate' +New = 'resources.json' diff --git a/libs/structs/structaccess/set_numeric_test.go b/libs/structs/structaccess/set_numeric_test.go index d5b85654710..07fcb2aa7ae 100644 --- a/libs/structs/structaccess/set_numeric_test.go +++ b/libs/structs/structaccess/set_numeric_test.go @@ -31,60 +31,61 @@ func TestSetNumericConversion(t *testing.T) { name string path string value any - err string // empty means success - check func(t *testing.T, v *numericTarget) + // Exactly one of expected and err is set; expected has the type of the target field. + expected any + err string }{ // int <- float - {name: "int from integral float", path: "i", value: 2.0, check: func(t *testing.T, v *numericTarget) { assert.Equal(t, 2, v.I) }}, - {name: "int from negative integral float", path: "i", value: -3.0, check: func(t *testing.T, v *numericTarget) { assert.Equal(t, -3, v.I) }}, + {name: "int from integral float", path: "i", value: 2.0, expected: 2}, + {name: "int from negative integral float", path: "i", value: -3.0, expected: -3}, {name: "int from fractional float", path: "i", value: 1.9, err: "cannot set 1.9 to int: precision loss"}, {name: "int from NaN", path: "i", value: math.NaN(), err: "cannot set NaN to int: precision loss"}, {name: "int from +Inf", path: "i", value: math.Inf(1), err: "value +Inf overflows int"}, {name: "int8 from out of range float", path: "i8", value: 200.0, err: "value 200 overflows int8"}, {name: "int64 from float 2^63", path: "i64", value: math.Exp2(63), err: "overflows int64"}, - {name: "int64 from float -2^63", path: "i64", value: -math.Exp2(63), check: func(t *testing.T, v *numericTarget) { assert.Equal(t, int64(math.MinInt64), v.I64) }}, + {name: "int64 from float -2^63", path: "i64", value: -math.Exp2(63), expected: int64(math.MinInt64)}, {name: "uint from negative float", path: "u", value: -1.0, err: "value -1 overflows uint"}, {name: "uint8 from out of range float", path: "u8", value: 256.0, err: "value 256 overflows uint8"}, {name: "uint64 from float 2^64", path: "u64", value: math.Exp2(64), err: "overflows uint64"}, - {name: "uint from float32 integral", path: "u", value: float32(7), check: func(t *testing.T, v *numericTarget) { assert.Equal(t, uint(7), v.U) }}, + {name: "uint from float32 integral", path: "u", value: float32(7), expected: uint(7)}, // int <- int/uint - {name: "int32 from fitting int64", path: "i32", value: int64(5), check: func(t *testing.T, v *numericTarget) { assert.Equal(t, int32(5), v.I32) }}, + {name: "int32 from fitting int64", path: "i32", value: int64(5), expected: int32(5)}, {name: "int32 from overflowing int64", path: "i32", value: int64(1 << 40), err: "value 1099511627776 overflows int32"}, {name: "int8 from negative overflowing int", path: "i8", value: -129, err: "value -129 overflows int8"}, {name: "int64 from uint64 above MaxInt64", path: "i64", value: uint64(math.MaxUint64), err: "value 18446744073709551615 overflows int64"}, - {name: "int64 from uint64 MaxInt64", path: "i64", value: uint64(math.MaxInt64), check: func(t *testing.T, v *numericTarget) { assert.Equal(t, int64(math.MaxInt64), v.I64) }}, + {name: "int64 from uint64 MaxInt64", path: "i64", value: uint64(math.MaxInt64), expected: int64(math.MaxInt64)}, {name: "int8 from overflowing uint", path: "i8", value: uint(128), err: "value 128 overflows int8"}, // uint <- int/uint {name: "uint from negative int", path: "u", value: -1, err: "value -1 overflows uint"}, {name: "uint8 from overflowing int", path: "u8", value: 256, err: "value 256 overflows uint8"}, - {name: "uint8 from fitting int", path: "u8", value: 255, check: func(t *testing.T, v *numericTarget) { assert.Equal(t, uint8(255), v.U8) }}, + {name: "uint8 from fitting int", path: "u8", value: 255, expected: uint8(255)}, {name: "uint8 from overflowing uint64", path: "u8", value: uint64(300), err: "value 300 overflows uint8"}, - {name: "uint64 from large uint64", path: "u64", value: uint64(math.MaxUint64), check: func(t *testing.T, v *numericTarget) { assert.Equal(t, uint64(math.MaxUint64), v.U64) }}, + {name: "uint64 from large uint64", path: "u64", value: uint64(math.MaxUint64), expected: uint64(math.MaxUint64)}, // float <- int/uint - {name: "float64 from small int", path: "f64", value: 1 << 53, check: func(t *testing.T, v *numericTarget) { assert.InDelta(t, float64(1<<53), v.F64, 0) }}, + {name: "float64 from small int", path: "f64", value: 1 << 53, expected: float64(1 << 53)}, {name: "float64 from int64 above 2^53", path: "f64", value: int64(1<<53 + 1), err: "cannot set 9007199254740993 to float64: precision loss"}, {name: "float64 from MaxInt64", path: "f64", value: int64(math.MaxInt64), err: "cannot set 9223372036854775807 to float64: precision loss"}, {name: "float64 from uint64 above 2^53", path: "f64", value: uint64(1<<53 + 1), err: "cannot set 9007199254740993 to float64: precision loss"}, {name: "float64 from MaxUint64", path: "f64", value: uint64(math.MaxUint64), err: "precision loss"}, {name: "float32 from int above 2^24", path: "f32", value: 1<<24 + 1, err: "cannot set 16777217 to float32: precision loss"}, - {name: "float32 from int 2^24", path: "f32", value: 1 << 24, check: func(t *testing.T, v *numericTarget) { assert.InDelta(t, float32(1<<24), v.F32, 0) }}, + {name: "float32 from int 2^24", path: "f32", value: 1 << 24, expected: float32(1 << 24)}, // float <- float - {name: "float32 from float64", path: "f32", value: 1.5, check: func(t *testing.T, v *numericTarget) { assert.InDelta(t, float32(1.5), v.F32, 0) }}, + {name: "float32 from float64", path: "f32", value: 1.5, expected: float32(1.5)}, {name: "float32 from overflowing float64", path: "f32", value: 1e300, err: "value 1e+300 overflows float32"}, - {name: "float64 from float32", path: "f64", value: float32(0.5), check: func(t *testing.T, v *numericTarget) { assert.InDelta(t, 0.5, v.F64, 0) }}, + {name: "float64 from float32", path: "f64", value: float32(0.5), expected: 0.5}, // pointer targets - {name: "ptr int from integral float", path: "pi", value: 2.0, check: func(t *testing.T, v *numericTarget) { require.NotNil(t, v.PI); assert.Equal(t, 2, *v.PI) }}, + {name: "ptr int from integral float", path: "pi", value: 2.0, expected: 2}, {name: "ptr int from fractional float", path: "pi", value: 1.9, err: "cannot set 1.9 to int: precision loss"}, {name: "ptr int32 from overflowing int64", path: "pi32", value: int64(1 << 40), err: "value 1099511627776 overflows int32"}, {name: "ptr uint from negative int", path: "pu", value: -1, err: "value -1 overflows uint"}, {name: "ptr float32 from overflowing float64", path: "pf32", value: 1e300, err: "overflows float32"}, {name: "ptr float64 from large int64", path: "pf64", value: int64(1<<53 + 1), err: "precision loss"}, - {name: "ptr float64 from small int", path: "pf64", value: 3, check: func(t *testing.T, v *numericTarget) { require.NotNil(t, v.PF64); assert.InDelta(t, 3.0, *v.PF64, 0) }}, + {name: "ptr float64 from small int", path: "pf64", value: 3, expected: 3.0}, } for _, tc := range tests { @@ -97,7 +98,9 @@ func TestSetNumericConversion(t *testing.T) { return } require.NoError(t, err) - tc.check(t, v) + got, err := structaccess.GetByString(v, tc.path) + require.NoError(t, err) + assert.Equal(t, tc.expected, got) }) } } From 680645671768f0f1cbff7f01091b692a45043bfb Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 5 Oct 2026 13:24:20 +0200 Subject: [PATCH 4/7] structaccess: nest numeric conversion switch by source/target, allow lossy float to float Co-authored-by: Isaac --- libs/structs/structaccess/set.go | 89 +++++++++---------- libs/structs/structaccess/set_numeric_test.go | 5 +- 2 files changed, 44 insertions(+), 50 deletions(-) diff --git a/libs/structs/structaccess/set.go b/libs/structs/structaccess/set.go index 59d01069b99..6c91a4e5c56 100644 --- a/libs/structs/structaccess/set.go +++ b/libs/structs/structaccess/set.go @@ -336,76 +336,69 @@ func isNumericKind(k reflect.Kind) bool { } } -func isIntKind(k reflect.Kind) bool { - return k >= reflect.Int && k <= reflect.Int64 -} - -func isUintKind(k reflect.Kind) bool { - return k >= reflect.Uint && k <= reflect.Uint64 -} - // convertNumeric converts between numeric kinds and fails instead of losing data, -// following the rules of libs/dyn/convert (normalizeInt, normalizeFloat): -// - float to int requires an integral value that fits the destination; -// - int to int (or uint) requires the value to fit the destination; -// - int to float requires the value to be exactly representable; -// - float64 to float32 must not overflow to infinity. +// following the rules of libs/dyn/convert (normalizeInt, normalizeFloat). The one +// exception is float to float, which is allowed even when it rounds or overflows. func convertNumeric(valueVal reflect.Value, targetType reflect.Type) (reflect.Value, error) { - srcKind := valueVal.Kind() dstKind := targetType.Kind() zero := reflect.New(targetType).Elem() switch { - case isIntKind(srcKind) && isIntKind(dstKind): - if v := valueVal.Int(); zero.OverflowInt(v) { - return reflect.Value{}, fmt.Errorf("value %d overflows %s", v, dstKind) - } - case isIntKind(srcKind) && isUintKind(dstKind): - if v := valueVal.Int(); v < 0 || zero.OverflowUint(uint64(v)) { - return reflect.Value{}, fmt.Errorf("value %d overflows %s", v, dstKind) - } - case isUintKind(srcKind) && isIntKind(dstKind): - if v := valueVal.Uint(); v > math.MaxInt64 || zero.OverflowInt(int64(v)) { - return reflect.Value{}, fmt.Errorf("value %d overflows %s", v, dstKind) - } - case isUintKind(srcKind) && isUintKind(dstKind): - if v := valueVal.Uint(); zero.OverflowUint(v) { - return reflect.Value{}, fmt.Errorf("value %d overflows %s", v, dstKind) - } - case isIntKind(srcKind) && !isIntKind(dstKind) && !isUintKind(dstKind): + case valueVal.CanInt(): v := valueVal.Int() - f := reflect.ValueOf(v).Convert(targetType).Float() - // float64(2^63) is out of int64 range, so it cannot be converted back to compare. - if f >= math.MaxInt64 || int64(f) != v { - return reflect.Value{}, fmt.Errorf("cannot set %d to %s: precision loss", v, dstKind) + switch { + case zero.CanInt(): + if zero.OverflowInt(v) { + return reflect.Value{}, fmt.Errorf("value %d overflows %s", v, dstKind) + } + case zero.CanUint(): + if v < 0 || zero.OverflowUint(uint64(v)) { + return reflect.Value{}, fmt.Errorf("value %d overflows %s", v, dstKind) + } + default: + f := reflect.ValueOf(v).Convert(targetType).Float() + // float64(2^63) is out of int64 range, so it cannot be converted back to compare. + if f >= math.MaxInt64 || int64(f) != v { + return reflect.Value{}, fmt.Errorf("cannot set %d to %s: precision loss", v, dstKind) + } } - case isUintKind(srcKind) && !isIntKind(dstKind) && !isUintKind(dstKind): + + case valueVal.CanUint(): v := valueVal.Uint() - f := reflect.ValueOf(v).Convert(targetType).Float() - if f >= math.MaxUint64 || uint64(f) != v { - return reflect.Value{}, fmt.Errorf("cannot set %d to %s: precision loss", v, dstKind) + switch { + case zero.CanInt(): + if v > math.MaxInt64 || zero.OverflowInt(int64(v)) { + return reflect.Value{}, fmt.Errorf("value %d overflows %s", v, dstKind) + } + case zero.CanUint(): + if zero.OverflowUint(v) { + return reflect.Value{}, fmt.Errorf("value %d overflows %s", v, dstKind) + } + default: + f := reflect.ValueOf(v).Convert(targetType).Float() + if f >= math.MaxUint64 || uint64(f) != v { + return reflect.Value{}, fmt.Errorf("cannot set %d to %s: precision loss", v, dstKind) + } } - case isIntKind(dstKind) || isUintKind(dstKind): - // float source + + default: f := valueVal.Float() + if !zero.CanInt() && !zero.CanUint() { + // Float to float: allowed even with a loss. + break + } if math.IsNaN(f) || f != math.Trunc(f) { return reflect.Value{}, fmt.Errorf("cannot set %v to %s: precision loss", f, dstKind) } // Bounds are powers of two, so they are exact in float64. bits := float64(targetType.Bits()) lo, hi := 0.0, math.Exp2(bits) - if isIntKind(dstKind) { + if zero.CanInt() { lo, hi = -math.Exp2(bits-1), math.Exp2(bits-1) } if f < lo || f >= hi { return reflect.Value{}, fmt.Errorf("value %v overflows %s", f, dstKind) } - default: - // float to float - f := valueVal.Float() - if zero.OverflowFloat(f) { - return reflect.Value{}, fmt.Errorf("value %v overflows %s", f, dstKind) - } } return valueVal.Convert(targetType), nil diff --git a/libs/structs/structaccess/set_numeric_test.go b/libs/structs/structaccess/set_numeric_test.go index 07fcb2aa7ae..ef077e56814 100644 --- a/libs/structs/structaccess/set_numeric_test.go +++ b/libs/structs/structaccess/set_numeric_test.go @@ -75,7 +75,8 @@ func TestSetNumericConversion(t *testing.T) { // float <- float {name: "float32 from float64", path: "f32", value: 1.5, expected: float32(1.5)}, - {name: "float32 from overflowing float64", path: "f32", value: 1e300, err: "value 1e+300 overflows float32"}, + {name: "float32 from rounding float64", path: "f32", value: 1.1, expected: float32(1.1)}, + {name: "float32 from overflowing float64", path: "f32", value: 1e300, expected: float32(math.Inf(1))}, {name: "float64 from float32", path: "f64", value: float32(0.5), expected: 0.5}, // pointer targets @@ -83,7 +84,7 @@ func TestSetNumericConversion(t *testing.T) { {name: "ptr int from fractional float", path: "pi", value: 1.9, err: "cannot set 1.9 to int: precision loss"}, {name: "ptr int32 from overflowing int64", path: "pi32", value: int64(1 << 40), err: "value 1099511627776 overflows int32"}, {name: "ptr uint from negative int", path: "pu", value: -1, err: "value -1 overflows uint"}, - {name: "ptr float32 from overflowing float64", path: "pf32", value: 1e300, err: "overflows float32"}, + {name: "ptr float32 from overflowing float64", path: "pf32", value: 1e300, expected: float32(math.Inf(1))}, {name: "ptr float64 from large int64", path: "pf64", value: int64(1<<53 + 1), err: "precision loss"}, {name: "ptr float64 from small int", path: "pf64", value: 3, expected: 3.0}, } From 101eca4af54ff205535f31b2775a68f179bd90ff Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 5 Oct 2026 14:05:07 +0200 Subject: [PATCH 5/7] structaccess: allow lossy int to float conversions Co-authored-by: Isaac --- libs/structs/structaccess/set.go | 15 ++++----------- libs/structs/structaccess/set_numeric_test.go | 12 ++++++------ 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/libs/structs/structaccess/set.go b/libs/structs/structaccess/set.go index 6c91a4e5c56..ef1d055077d 100644 --- a/libs/structs/structaccess/set.go +++ b/libs/structs/structaccess/set.go @@ -337,8 +337,8 @@ func isNumericKind(k reflect.Kind) bool { } // convertNumeric converts between numeric kinds and fails instead of losing data, -// following the rules of libs/dyn/convert (normalizeInt, normalizeFloat). The one -// exception is float to float, which is allowed even when it rounds or overflows. +// following the rules of libs/dyn/convert (normalizeInt). Unlike normalizeFloat, +// any conversion to a float kind is allowed, even when it rounds or overflows. func convertNumeric(valueVal reflect.Value, targetType reflect.Type) (reflect.Value, error) { dstKind := targetType.Kind() zero := reflect.New(targetType).Elem() @@ -356,11 +356,7 @@ func convertNumeric(valueVal reflect.Value, targetType reflect.Type) (reflect.Va return reflect.Value{}, fmt.Errorf("value %d overflows %s", v, dstKind) } default: - f := reflect.ValueOf(v).Convert(targetType).Float() - // float64(2^63) is out of int64 range, so it cannot be converted back to compare. - if f >= math.MaxInt64 || int64(f) != v { - return reflect.Value{}, fmt.Errorf("cannot set %d to %s: precision loss", v, dstKind) - } + // Int to float: allowed even with a loss. } case valueVal.CanUint(): @@ -375,10 +371,7 @@ func convertNumeric(valueVal reflect.Value, targetType reflect.Type) (reflect.Va return reflect.Value{}, fmt.Errorf("value %d overflows %s", v, dstKind) } default: - f := reflect.ValueOf(v).Convert(targetType).Float() - if f >= math.MaxUint64 || uint64(f) != v { - return reflect.Value{}, fmt.Errorf("cannot set %d to %s: precision loss", v, dstKind) - } + // Uint to float: allowed even with a loss. } default: diff --git a/libs/structs/structaccess/set_numeric_test.go b/libs/structs/structaccess/set_numeric_test.go index ef077e56814..b633da0ff8a 100644 --- a/libs/structs/structaccess/set_numeric_test.go +++ b/libs/structs/structaccess/set_numeric_test.go @@ -66,11 +66,11 @@ func TestSetNumericConversion(t *testing.T) { // float <- int/uint {name: "float64 from small int", path: "f64", value: 1 << 53, expected: float64(1 << 53)}, - {name: "float64 from int64 above 2^53", path: "f64", value: int64(1<<53 + 1), err: "cannot set 9007199254740993 to float64: precision loss"}, - {name: "float64 from MaxInt64", path: "f64", value: int64(math.MaxInt64), err: "cannot set 9223372036854775807 to float64: precision loss"}, - {name: "float64 from uint64 above 2^53", path: "f64", value: uint64(1<<53 + 1), err: "cannot set 9007199254740993 to float64: precision loss"}, - {name: "float64 from MaxUint64", path: "f64", value: uint64(math.MaxUint64), err: "precision loss"}, - {name: "float32 from int above 2^24", path: "f32", value: 1<<24 + 1, err: "cannot set 16777217 to float32: precision loss"}, + {name: "float64 from int64 above 2^53 rounds", path: "f64", value: int64(1<<53 + 1), expected: float64(1 << 53)}, + {name: "float64 from MaxInt64 rounds", path: "f64", value: int64(math.MaxInt64), expected: float64(math.MaxInt64)}, + {name: "float64 from uint64 above 2^53 rounds", path: "f64", value: uint64(1<<53 + 1), expected: float64(1 << 53)}, + {name: "float64 from MaxUint64 rounds", path: "f64", value: uint64(math.MaxUint64), expected: float64(math.MaxUint64)}, + {name: "float32 from int above 2^24 rounds", path: "f32", value: 1<<24 + 1, expected: float32(1 << 24)}, {name: "float32 from int 2^24", path: "f32", value: 1 << 24, expected: float32(1 << 24)}, // float <- float @@ -85,7 +85,7 @@ func TestSetNumericConversion(t *testing.T) { {name: "ptr int32 from overflowing int64", path: "pi32", value: int64(1 << 40), err: "value 1099511627776 overflows int32"}, {name: "ptr uint from negative int", path: "pu", value: -1, err: "value -1 overflows uint"}, {name: "ptr float32 from overflowing float64", path: "pf32", value: 1e300, expected: float32(math.Inf(1))}, - {name: "ptr float64 from large int64", path: "pf64", value: int64(1<<53 + 1), err: "precision loss"}, + {name: "ptr float64 from large int64 rounds", path: "pf64", value: int64(1<<53 + 1), expected: float64(1 << 53)}, {name: "ptr float64 from small int", path: "pf64", value: 3, expected: 3.0}, } From 052b3b7c40e0fb7a32461b94d221553fc73ae65c Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 5 Oct 2026 14:23:59 +0200 Subject: [PATCH 6/7] Format numeric table test one field per line Co-authored-by: Isaac --- libs/structs/structaccess/set_numeric_test.go | 287 +++++++++++++++--- 1 file changed, 246 insertions(+), 41 deletions(-) diff --git a/libs/structs/structaccess/set_numeric_test.go b/libs/structs/structaccess/set_numeric_test.go index b633da0ff8a..48d3905a86a 100644 --- a/libs/structs/structaccess/set_numeric_test.go +++ b/libs/structs/structaccess/set_numeric_test.go @@ -36,57 +36,262 @@ func TestSetNumericConversion(t *testing.T) { err string }{ // int <- float - {name: "int from integral float", path: "i", value: 2.0, expected: 2}, - {name: "int from negative integral float", path: "i", value: -3.0, expected: -3}, - {name: "int from fractional float", path: "i", value: 1.9, err: "cannot set 1.9 to int: precision loss"}, - {name: "int from NaN", path: "i", value: math.NaN(), err: "cannot set NaN to int: precision loss"}, - {name: "int from +Inf", path: "i", value: math.Inf(1), err: "value +Inf overflows int"}, - {name: "int8 from out of range float", path: "i8", value: 200.0, err: "value 200 overflows int8"}, - {name: "int64 from float 2^63", path: "i64", value: math.Exp2(63), err: "overflows int64"}, - {name: "int64 from float -2^63", path: "i64", value: -math.Exp2(63), expected: int64(math.MinInt64)}, - {name: "uint from negative float", path: "u", value: -1.0, err: "value -1 overflows uint"}, - {name: "uint8 from out of range float", path: "u8", value: 256.0, err: "value 256 overflows uint8"}, - {name: "uint64 from float 2^64", path: "u64", value: math.Exp2(64), err: "overflows uint64"}, - {name: "uint from float32 integral", path: "u", value: float32(7), expected: uint(7)}, + { + name: "int from integral float", + path: "i", + value: 2.0, + expected: 2, + }, + { + name: "int from negative integral float", + path: "i", + value: -3.0, + expected: -3, + }, + { + name: "int from fractional float", + path: "i", + value: 1.9, + err: "cannot set 1.9 to int: precision loss", + }, + { + name: "int from NaN", + path: "i", + value: math.NaN(), + err: "cannot set NaN to int: precision loss", + }, + { + name: "int from +Inf", + path: "i", + value: math.Inf(1), + err: "value +Inf overflows int", + }, + { + name: "int8 from out of range float", + path: "i8", + value: 200.0, + err: "value 200 overflows int8", + }, + { + name: "int64 from float 2^63", + path: "i64", + value: math.Exp2(63), + err: "overflows int64", + }, + { + name: "int64 from float -2^63", + path: "i64", + value: -math.Exp2(63), + expected: int64(math.MinInt64), + }, + { + name: "uint from negative float", + path: "u", + value: -1.0, + err: "value -1 overflows uint", + }, + { + name: "uint8 from out of range float", + path: "u8", + value: 256.0, + err: "value 256 overflows uint8", + }, + { + name: "uint64 from float 2^64", + path: "u64", + value: math.Exp2(64), + err: "overflows uint64", + }, + { + name: "uint from float32 integral", + path: "u", + value: float32(7), + expected: uint(7), + }, // int <- int/uint - {name: "int32 from fitting int64", path: "i32", value: int64(5), expected: int32(5)}, - {name: "int32 from overflowing int64", path: "i32", value: int64(1 << 40), err: "value 1099511627776 overflows int32"}, - {name: "int8 from negative overflowing int", path: "i8", value: -129, err: "value -129 overflows int8"}, - {name: "int64 from uint64 above MaxInt64", path: "i64", value: uint64(math.MaxUint64), err: "value 18446744073709551615 overflows int64"}, - {name: "int64 from uint64 MaxInt64", path: "i64", value: uint64(math.MaxInt64), expected: int64(math.MaxInt64)}, - {name: "int8 from overflowing uint", path: "i8", value: uint(128), err: "value 128 overflows int8"}, + { + name: "int32 from fitting int64", + path: "i32", + value: int64(5), + expected: int32(5), + }, + { + name: "int32 from overflowing int64", + path: "i32", + value: int64(1 << 40), + err: "value 1099511627776 overflows int32", + }, + { + name: "int8 from negative overflowing int", + path: "i8", + value: -129, + err: "value -129 overflows int8", + }, + { + name: "int64 from uint64 above MaxInt64", + path: "i64", + value: uint64(math.MaxUint64), + err: "value 18446744073709551615 overflows int64", + }, + { + name: "int64 from uint64 MaxInt64", + path: "i64", + value: uint64(math.MaxInt64), + expected: int64(math.MaxInt64), + }, + { + name: "int8 from overflowing uint", + path: "i8", + value: uint(128), + err: "value 128 overflows int8", + }, // uint <- int/uint - {name: "uint from negative int", path: "u", value: -1, err: "value -1 overflows uint"}, - {name: "uint8 from overflowing int", path: "u8", value: 256, err: "value 256 overflows uint8"}, - {name: "uint8 from fitting int", path: "u8", value: 255, expected: uint8(255)}, - {name: "uint8 from overflowing uint64", path: "u8", value: uint64(300), err: "value 300 overflows uint8"}, - {name: "uint64 from large uint64", path: "u64", value: uint64(math.MaxUint64), expected: uint64(math.MaxUint64)}, + { + name: "uint from negative int", + path: "u", + value: -1, + err: "value -1 overflows uint", + }, + { + name: "uint8 from overflowing int", + path: "u8", + value: 256, + err: "value 256 overflows uint8", + }, + { + name: "uint8 from fitting int", + path: "u8", + value: 255, + expected: uint8(255), + }, + { + name: "uint8 from overflowing uint64", + path: "u8", + value: uint64(300), + err: "value 300 overflows uint8", + }, + { + name: "uint64 from large uint64", + path: "u64", + value: uint64(math.MaxUint64), + expected: uint64(math.MaxUint64), + }, // float <- int/uint - {name: "float64 from small int", path: "f64", value: 1 << 53, expected: float64(1 << 53)}, - {name: "float64 from int64 above 2^53 rounds", path: "f64", value: int64(1<<53 + 1), expected: float64(1 << 53)}, - {name: "float64 from MaxInt64 rounds", path: "f64", value: int64(math.MaxInt64), expected: float64(math.MaxInt64)}, - {name: "float64 from uint64 above 2^53 rounds", path: "f64", value: uint64(1<<53 + 1), expected: float64(1 << 53)}, - {name: "float64 from MaxUint64 rounds", path: "f64", value: uint64(math.MaxUint64), expected: float64(math.MaxUint64)}, - {name: "float32 from int above 2^24 rounds", path: "f32", value: 1<<24 + 1, expected: float32(1 << 24)}, - {name: "float32 from int 2^24", path: "f32", value: 1 << 24, expected: float32(1 << 24)}, + { + name: "float64 from small int", + path: "f64", + value: 1 << 53, + expected: float64(1 << 53), + }, + { + name: "float64 from int64 above 2^53 rounds", + path: "f64", + value: int64(1<<53 + 1), + expected: float64(1 << 53), + }, + { + name: "float64 from MaxInt64 rounds", + path: "f64", + value: int64(math.MaxInt64), + expected: float64(math.MaxInt64), + }, + { + name: "float64 from uint64 above 2^53 rounds", + path: "f64", + value: uint64(1<<53 + 1), + expected: float64(1 << 53), + }, + { + name: "float64 from MaxUint64 rounds", + path: "f64", + value: uint64(math.MaxUint64), + expected: float64(math.MaxUint64), + }, + { + name: "float32 from int above 2^24 rounds", + path: "f32", + value: 1<<24 + 1, + expected: float32(1 << 24), + }, + { + name: "float32 from int 2^24", + path: "f32", + value: 1 << 24, + expected: float32(1 << 24), + }, // float <- float - {name: "float32 from float64", path: "f32", value: 1.5, expected: float32(1.5)}, - {name: "float32 from rounding float64", path: "f32", value: 1.1, expected: float32(1.1)}, - {name: "float32 from overflowing float64", path: "f32", value: 1e300, expected: float32(math.Inf(1))}, - {name: "float64 from float32", path: "f64", value: float32(0.5), expected: 0.5}, + { + name: "float32 from float64", + path: "f32", + value: 1.5, + expected: float32(1.5), + }, + { + name: "float32 from rounding float64", + path: "f32", + value: 1.1, + expected: float32(1.1), + }, + { + name: "float32 from overflowing float64", + path: "f32", + value: 1e300, + expected: float32(math.Inf(1)), + }, + { + name: "float64 from float32", + path: "f64", + value: float32(0.5), + expected: 0.5, + }, // pointer targets - {name: "ptr int from integral float", path: "pi", value: 2.0, expected: 2}, - {name: "ptr int from fractional float", path: "pi", value: 1.9, err: "cannot set 1.9 to int: precision loss"}, - {name: "ptr int32 from overflowing int64", path: "pi32", value: int64(1 << 40), err: "value 1099511627776 overflows int32"}, - {name: "ptr uint from negative int", path: "pu", value: -1, err: "value -1 overflows uint"}, - {name: "ptr float32 from overflowing float64", path: "pf32", value: 1e300, expected: float32(math.Inf(1))}, - {name: "ptr float64 from large int64 rounds", path: "pf64", value: int64(1<<53 + 1), expected: float64(1 << 53)}, - {name: "ptr float64 from small int", path: "pf64", value: 3, expected: 3.0}, + { + name: "ptr int from integral float", + path: "pi", + value: 2.0, + expected: 2, + }, + { + name: "ptr int from fractional float", + path: "pi", + value: 1.9, + err: "cannot set 1.9 to int: precision loss", + }, + { + name: "ptr int32 from overflowing int64", + path: "pi32", + value: int64(1 << 40), + err: "value 1099511627776 overflows int32", + }, + { + name: "ptr uint from negative int", + path: "pu", + value: -1, + err: "value -1 overflows uint", + }, + { + name: "ptr float32 from overflowing float64", + path: "pf32", + value: 1e300, + expected: float32(math.Inf(1)), + }, + { + name: "ptr float64 from large int64 rounds", + path: "pf64", + value: int64(1<<53 + 1), + expected: float64(1 << 53), + }, + { + name: "ptr float64 from small int", + path: "pf64", + value: 3, + expected: 3.0, + }, } for _, tc := range tests { From 32b96824c189832acfc8e60376400e4dda3203b6 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 5 Oct 2026 15:36:32 +0200 Subject: [PATCH 7/7] Add acceptance test recording float variable referenced from int field Co-authored-by: Isaac --- .../float_to_int_field/databricks.yml | 13 +++++++++ .../float_to_int_field/out.test.toml | 2 ++ .../variables/float_to_int_field/output.txt | 28 +++++++++++++++++++ .../variables/float_to_int_field/script | 2 ++ 4 files changed, 45 insertions(+) create mode 100644 acceptance/bundle/variables/float_to_int_field/databricks.yml create mode 100644 acceptance/bundle/variables/float_to_int_field/out.test.toml create mode 100644 acceptance/bundle/variables/float_to_int_field/output.txt create mode 100644 acceptance/bundle/variables/float_to_int_field/script diff --git a/acceptance/bundle/variables/float_to_int_field/databricks.yml b/acceptance/bundle/variables/float_to_int_field/databricks.yml new file mode 100644 index 00000000000..d356d4e787f --- /dev/null +++ b/acceptance/bundle/variables/float_to_int_field/databricks.yml @@ -0,0 +1,13 @@ +bundle: + name: test-bundle + +variables: + x: + default: 5.9 + +resources: + jobs: + foo: + name: job foo + # Records current behaviour: a warning, the value is dropped and the default (1) applies. + max_concurrent_runs: ${var.x} diff --git a/acceptance/bundle/variables/float_to_int_field/out.test.toml b/acceptance/bundle/variables/float_to_int_field/out.test.toml new file mode 100644 index 00000000000..a927a5fbc06 --- /dev/null +++ b/acceptance/bundle/variables/float_to_int_field/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/variables/float_to_int_field/output.txt b/acceptance/bundle/variables/float_to_int_field/output.txt new file mode 100644 index 00000000000..2adb343ab98 --- /dev/null +++ b/acceptance/bundle/variables/float_to_int_field/output.txt @@ -0,0 +1,28 @@ + +>>> [CLI] bundle validate +Warning: cannot accurately represent "5.9" as integer due to precision loss + at resources.jobs.foo.max_concurrent_runs + in databricks.yml:13:28 + +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default + +Found 1 warning + +>>> [CLI] bundle validate -o json +Warning: cannot accurately represent "5.9" as integer due to precision loss + at resources.jobs.foo.max_concurrent_runs + in databricks.yml:13:28 + +{ + "variables": { + "x": { + "default": 5.9, + "value": 5.9 + } + }, + "max_concurrent_runs": 1 +} diff --git a/acceptance/bundle/variables/float_to_int_field/script b/acceptance/bundle/variables/float_to_int_field/script new file mode 100644 index 00000000000..dee8a94c629 --- /dev/null +++ b/acceptance/bundle/variables/float_to_int_field/script @@ -0,0 +1,2 @@ +trace $CLI bundle validate +trace $CLI bundle validate -o json | jq '{variables, max_concurrent_runs: .resources.jobs.foo.max_concurrent_runs}'