This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/dev/pr-11523-02b156e4573bae30cd17982adb0965f07d3b0657 in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit a0561d4045281c8e31ca96c6812b5024534db481 Author: Jast <[email protected]> AuthorDate: Thu Sep 10 17:15:17 2026 +0000 [Fix][Transform-V2] Preserve exact binary value when CASTing floats to DECIMAL (#11523) Co-authored-by: zhangshenghang <[email protected]> Co-authored-by: zhangshenghang <[email protected]> --- .../sql/zeta/functions/SystemFunction.java | 30 ++++- .../sql/zeta/functions/SystemFunctionTest.java | 137 +++++++++++++++++++++ 2 files changed, 165 insertions(+), 2 deletions(-) diff --git a/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/functions/SystemFunction.java b/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/functions/SystemFunction.java index 7917613b2a..c482045052 100644 --- a/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/functions/SystemFunction.java +++ b/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/functions/SystemFunction.java @@ -233,9 +233,35 @@ public class SystemFunction { CommonErrorCodeDeprecated.UNSUPPORTED_OPERATION, String.format("Unsupported CAST AS type: %s", v2)); case "DECIMAL": - BigDecimal bigDecimal = new BigDecimal(v1.toString()); + BigDecimal bigDecimal; + RoundingMode roundingMode; + if (v1 instanceof BigDecimal) { + bigDecimal = (BigDecimal) v1; + roundingMode = RoundingMode.CEILING; + } else if (v1 instanceof Float) { + // Translate the exact binary value, mirroring CAST semantics in + // databases (e.g. MySQL). Float.toString() returns the shortest + // round-trip representation and drops the hidden binary digits + // (126.752251f -> "126.75225"), which would turn CAST(... AS + // DECIMAL(20,10)) into 126.7522500000 instead of the exact + // 126.7522506714 (issue #10198). Round half away from zero to + // match MySQL CAST semantics; CEILING would otherwise push the + // hidden binary tail of 0.1f (0.10000000149...) up to 0.11 + // at scale 2 and silently corrupt ordinary DECIMAL casts. + bigDecimal = new BigDecimal((Float) v1); + roundingMode = RoundingMode.HALF_UP; + } else if (v1 instanceof Double) { + // Same reasoning as for Float above; the exact binary tail of + // 0.1d (0.10000000000000000555...) plus CEILING at scale 2 + // would also become 0.11 instead of 0.10. + bigDecimal = new BigDecimal((Double) v1); + roundingMode = RoundingMode.HALF_UP; + } else { + bigDecimal = new BigDecimal(v1.toString()); + roundingMode = RoundingMode.CEILING; + } Integer scale = (Integer) args.get(3); - return bigDecimal.setScale(scale, RoundingMode.CEILING); + return bigDecimal.setScale(scale, roundingMode); case "BOOLEAN": if (v1 instanceof Number) { if (Arrays.asList(1, 0).contains(((Number) v1).intValue())) { diff --git a/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/zeta/functions/SystemFunctionTest.java b/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/zeta/functions/SystemFunctionTest.java index b980df25d3..6f87410205 100644 --- a/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/zeta/functions/SystemFunctionTest.java +++ b/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/zeta/functions/SystemFunctionTest.java @@ -212,6 +212,143 @@ public class SystemFunctionTest { Assertions.assertEquals(new BigDecimal("1.24"), result); } + @Test + public void testCastAsDecimalKeepsExactBinaryValueOfFloats() { + // The binary value of 126.752251f is 126.75225067138671875. Going through + // Float.toString() first would lose the hidden binary digits ("126.75225") + // and yield 126.7522500000, while MySQL's CAST(float AS DECIMAL(20,10)) + // converts the exact binary value and yields 126.7522506714 (issue #10198). + List<Object> args = new ArrayList<>(); + args.add(126.752251f); + args.add("DECIMAL"); + args.add(20); + args.add(10); + Assertions.assertEquals(new BigDecimal("126.7522506714"), SystemFunction.castAs(args)); + + // Scaling to the 6 digits of a numeric(10,6) sink column must round the + // exact binary value to 126.752251 instead of 126.752250. + args.clear(); + args.add(126.752251f); + args.add("DECIMAL"); + args.add(10); + args.add(6); + Assertions.assertEquals(new BigDecimal("126.752251"), SystemFunction.castAs(args)); + + // Double inputs must not be stringified either. + args.clear(); + args.add(44.916103d); + args.add("DECIMAL"); + args.add(20); + args.add(10); + Assertions.assertEquals(new BigDecimal("44.9161030000"), SystemFunction.castAs(args)); + + // BigDecimal inputs are passed through without re-parsing. + args.clear(); + args.add(new BigDecimal("126.7522506714")); + args.add("DECIMAL"); + args.add(20); + args.add(10); + Assertions.assertEquals(new BigDecimal("126.7522506714"), SystemFunction.castAs(args)); + } + + @Test + public void testCastAsDecimalRoundsCommonFloatsHalfAwayFromZero() { + // The exact binary value of 0.1f is 0.10000000149011612..., and the exact + // binary value of 0.1d is 0.10000000000000000555.... Combined with the + // legacy CEILING rounding, these would both round up to 0.11 at scale 2 + // and silently corrupt ordinary DECIMAL casts. Round half away from + // zero (the contract used by MySQL's CAST(float AS DECIMAL(...))) so the + // common positive tail is preserved. + List<Object> args = new ArrayList<>(); + args.add(0.1f); + args.add("DECIMAL"); + args.add(10); + args.add(2); + Assertions.assertEquals(new BigDecimal("0.10"), SystemFunction.castAs(args)); + + args.clear(); + args.add(0.1d); + args.add("DECIMAL"); + args.add(10); + args.add(2); + Assertions.assertEquals(new BigDecimal("0.10"), SystemFunction.castAs(args)); + + // Negative tails round symmetrically: -0.1f is -0.10000000149... which + // rounds to -0.10 at scale 2 because the absolute value's tail is below + // the next half-step at the second decimal digit. + args.clear(); + args.add(-0.1f); + args.add("DECIMAL"); + args.add(10); + args.add(2); + Assertions.assertEquals(new BigDecimal("-0.10"), SystemFunction.castAs(args)); + + args.clear(); + args.add(-0.1d); + args.add("DECIMAL"); + args.add(10); + args.add(2); + Assertions.assertEquals(new BigDecimal("-0.10"), SystemFunction.castAs(args)); + + // Half-rounding boundary: 0.5f is exactly representable as 0.5 and + // rounds up to 1 at scale 0; -0.5d is exactly -0.5 and HALF_UP pushes + // it away from zero to -1 (CEILING would have stayed at 0). + args.clear(); + args.add(0.5f); + args.add("DECIMAL"); + args.add(2); + args.add(0); + Assertions.assertEquals(new BigDecimal("1"), SystemFunction.castAs(args)); + + args.clear(); + args.add(-0.5d); + args.add("DECIMAL"); + args.add(2); + args.add(0); + Assertions.assertEquals(new BigDecimal("-1"), SystemFunction.castAs(args)); + + // Below-half rounding: 0.49f rounds down to 0 at scale 0 (CEILING + // would have wrongly raised it to 1). + args.clear(); + args.add(0.49f); + args.add("DECIMAL"); + args.add(2); + args.add(0); + Assertions.assertEquals(new BigDecimal("0"), SystemFunction.castAs(args)); + + // Above-half rounding: 0.51f rounds up to 1 at scale 0. + args.clear(); + args.add(0.51f); + args.add("DECIMAL"); + args.add(2); + args.add(0); + Assertions.assertEquals(new BigDecimal("1"), SystemFunction.castAs(args)); + } + + @Test + public void testCastAsDecimalThroughParsedSqlCast() { + // Exercise the parsed SQL CAST(... AS DECIMAL(...)) path end-to-end so + // the SystemFunction.castAs helper is reached through ZetaSQLEngine, + // not only through the direct unit-test helper call. + SeaTunnelRowType rowType = + new SeaTunnelRowType( + new String[] {"value"}, new SeaTunnelDataType[] {BasicType.FLOAT_TYPE}); + + // 0.1f is the regression case: it must stay 0.10 instead of becoming + // 0.11 after going through Float -> BigDecimal -> setScale(2). + SeaTunnelRow floatRow = + runSql("SELECT CAST(value AS DECIMAL(10,2)) AS out_value FROM dual", rowType, 0.1f); + Assertions.assertEquals(new BigDecimal("0.10"), floatRow.getField(0)); + + // 126.752251f -> DECIMAL(10,6) must round to 126.752251 (issue #10198). + SeaTunnelRow issueRow = + runSql( + "SELECT CAST(value AS DECIMAL(10,6)) AS out_value FROM dual", + rowType, + 126.752251f); + Assertions.assertEquals(new BigDecimal("126.752251"), issueRow.getField(0)); + } + @Test public void testCoalesceRespectsTargetType() { SeaTunnelDataType<?> targetType = BasicType.INT_TYPE;
