This is an automated email from the ASF dual-hosted git repository.
davidzollo pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git
The following commit(s) were added to refs/heads/dev by this push:
new 5d2e89108d [Fix][Transform-V2] Fix silent value corruption in Zeta SQL
numeric functions (#11697)
5d2e89108d is described below
commit 5d2e89108dc18ccd18da2f5fd26eb274cd2d4a55
Author: Sepuri Sai Krishna <[email protected]>
AuthorDate: Fri Aug 14 09:43:47 2026 +0530
[Fix][Transform-V2] Fix silent value corruption in Zeta SQL numeric
functions (#11697)
---
.../introduction/concepts/incompatible-changes.md | 19 +++++
.../introduction/concepts/incompatible-changes.md | 18 +++++
.../test/resources/sql_transform/func_numeric.conf | 8 +-
.../seatunnel/transform/sql/zeta/ZetaSQLType.java | 13 +--
.../sql/zeta/functions/NumericFunction.java | 52 +++++++++---
.../transform/sql/SQLNumericFunctionsTest.java | 5 +-
.../transform/sql/zeta/NumericFunctionTest.java | 92 ++++++++++++++++++++++
.../sql/zeta/functions/NumericFunctionTest.java | 12 ++-
8 files changed, 195 insertions(+), 24 deletions(-)
diff --git a/docs/en/introduction/concepts/incompatible-changes.md
b/docs/en/introduction/concepts/incompatible-changes.md
index d1d3159d42..7ad6a2af1b 100644
--- a/docs/en/introduction/concepts/incompatible-changes.md
+++ b/docs/en/introduction/concepts/incompatible-changes.md
@@ -145,6 +145,25 @@ You need to check this document before you upgrade to
related version.
- Adjusted SQL Transform date & time functions:
- `DATEDIFF(<start>, <end>, 'MONTH')` now returns the total number of months
between the two dates across years (for example, from `2023-01-01` to
`2024-03-01` returns `14` instead of `15`).
- `WEEK(<datetime>)` now returns the ISO week number directly (previous
behavior added an extra `+1` to the ISO week value).
+- **[BREAKING]** SQL Transform `CEIL` / `CEILING`, `FLOOR` and `TRUNC` /
`TRUNCATE` now return the data type of their
+ argument, as their documentation has always specified. Previously `CEIL` and
`FLOOR` declared `INT` and `TRUNC`
+ declared `DOUBLE` regardless of the input type, which silently produced
wrong values:
+
+ | Expression | Input | Previous result | Current result |
+ |------------|-------|-----------------|----------------|
+ | `CEIL(bigint_col)` | `9007199254740993` | `1` | `9007199254740993` |
+ | `FLOOR(double_col)` | `1.0E18` | `2147483647` | `1.0E18` |
+ | `TRUNC(bigint_col)` | `9007199254740993` | declared `DOUBLE`, returned a
`Long` | `9007199254740993` |
+
+ **Migration Guide**: If a downstream sink column was created against the old
`INT` / `DOUBLE` output type, widen it to
+ match the source column type (for example `BIGINT` for `CEIL(bigint_col)`),
or wrap the expression in an explicit
+ `CAST(... AS INT)` to keep the previous schema. Expressions over `INT`
columns are unaffected.
+- **[BREAKING]** SQL Transform `ROUND`, `TRUNC` / `TRUNCATE` and `MOD` no
longer round-trip their arguments through
+ `double`, so `DECIMAL` and large `BIGINT` values keep full precision. For
example
+ `ROUND(CAST('12345678901234567890.987654321' AS DECIMAL(38,9)), 2)`
previously returned
+ `12345678901234567000.00` and now returns `12345678901234567890.99`, and
`MOD(9007199254740993, 2)` previously
+ returned `0` and now returns `1`. Jobs that (intentionally or not) depended
on the old lossy values will see
+ different — now correct — output.
### Engine Behavior Changes
diff --git a/docs/zh/introduction/concepts/incompatible-changes.md
b/docs/zh/introduction/concepts/incompatible-changes.md
index a9b52589db..a44dc4e8e0 100644
--- a/docs/zh/introduction/concepts/incompatible-changes.md
+++ b/docs/zh/introduction/concepts/incompatible-changes.md
@@ -138,6 +138,24 @@
| `DynamicCompile` | `compile_pattern = ABSOLUTE_PATH` 但 `absolute_path` 为空
| 运行时文件读取失败 | 使用 `ABSOLUTE_PATH` 模式时提供 `absolute_path` |
**迁移指南**:升级前请对照上表检查您的转换配置。如果现有配置匹配了"新增拒绝的配置"中的情况,请在升级前修改。提交时的错误消息会清楚标明哪个选项无效及原因。
+- **[BREAKING]** SQL 转换的 `CEIL` / `CEILING`、`FLOOR` 与 `TRUNC` / `TRUNCATE`
现在返回与参数相同的数据类型,
+ 这与文档中一直声明的行为一致。此前无论输入类型如何,`CEIL` 和 `FLOOR` 都声明为 `INT`、`TRUNC` 声明为 `DOUBLE`,
+ 从而静默产生错误的值:
+
+ | 表达式 | 输入 | 以前的结果 | 现在的结果 |
+ |-------|------|-----------|-----------|
+ | `CEIL(bigint_col)` | `9007199254740993` | `1` | `9007199254740993` |
+ | `FLOOR(double_col)` | `1.0E18` | `2147483647` | `1.0E18` |
+ | `TRUNC(bigint_col)` | `9007199254740993` | 声明为 `DOUBLE`,实际返回 `Long` |
`9007199254740993` |
+
+ **迁移指南**:如果下游 Sink 的列是按旧的 `INT` / `DOUBLE` 输出类型创建的,请将其放宽为与源列一致的类型
+ (例如 `CEIL(bigint_col)` 对应 `BIGINT`),或使用显式的 `CAST(... AS INT)` 保持原有 schema。
+ 对 `INT` 列的表达式不受影响。
+- **[BREAKING]** SQL 转换的 `ROUND`、`TRUNC` / `TRUNCATE` 和 `MOD` 不再将参数经由 `double`
中转,
+ 因此 `DECIMAL` 和大 `BIGINT` 值可以保留完整精度。例如
+ `ROUND(CAST('12345678901234567890.987654321' AS DECIMAL(38,9)), 2)` 以前返回
+ `12345678901234567000.00`,现在返回
`12345678901234567890.99`;`MOD(9007199254740993, 2)` 以前返回 `0`,
+ 现在返回 `1`。依赖旧的精度丢失结果的作业,其输出会发生变化(现在是正确的)。
### 引擎行为变更
diff --git
a/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-2/src/test/resources/sql_transform/func_numeric.conf
b/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-2/src/test/resources/sql_transform/func_numeric.conf
index 046a1cf995..56b4e37e65 100644
---
a/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-2/src/test/resources/sql_transform/func_numeric.conf
+++
b/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-2/src/test/resources/sql_transform/func_numeric.conf
@@ -157,9 +157,9 @@ sink {
},
{
field_name = "c5_1"
- field_type = "int"
+ field_type = "double"
field_value = [
- {equals_to = 14}
+ {equals_to = 14.0}
]
},
{
@@ -171,9 +171,9 @@ sink {
},
{
field_name = "c5_2"
- field_type = "int"
+ field_type = "double"
field_value = [
- {equals_to = 13}
+ {equals_to = 13.0}
]
},
{
diff --git
a/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/ZetaSQLType.java
b/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/ZetaSQLType.java
index 7da359c8f5..5fbf4c44d6 100644
---
a/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/ZetaSQLType.java
+++
b/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/ZetaSQLType.java
@@ -362,9 +362,6 @@ public class ZetaSQLType {
case ZetaSQLFunction.LOCATE:
case ZetaSQLFunction.INSTR:
case ZetaSQLFunction.POSITION:
- case ZetaSQLFunction.CEIL:
- case ZetaSQLFunction.CEILING:
- case ZetaSQLFunction.FLOOR:
case ZetaSQLFunction.DAY_OF_MONTH:
case ZetaSQLFunction.DAY_OF_WEEK:
case ZetaSQLFunction.DAY_OF_YEAR:
@@ -410,8 +407,6 @@ public class ZetaSQLType {
case ZetaSQLFunction.POWER:
case ZetaSQLFunction.RAND:
case ZetaSQLFunction.RANDOM:
- case ZetaSQLFunction.TRUNC:
- case ZetaSQLFunction.TRUNCATE:
case ZetaSQLFunction.COSINE_DISTANCE:
case ZetaSQLFunction.L1_DISTANCE:
case ZetaSQLFunction.L2_DISTANCE:
@@ -462,10 +457,18 @@ public class ZetaSQLType {
"unsupported datetime format type");
}
}
+ // These functions all return the type of their first
argument. CEIL/FLOOR/TRUNC are
+ // documented that way as well; declaring INT/DOUBLE for them
would truncate BIGINT
+ // and DECIMAL results.
case ZetaSQLFunction.ABS:
case ZetaSQLFunction.DATEADD:
case ZetaSQLFunction.TIMESTAMPADD:
case ZetaSQLFunction.ROUND:
+ case ZetaSQLFunction.CEIL:
+ case ZetaSQLFunction.CEILING:
+ case ZetaSQLFunction.FLOOR:
+ case ZetaSQLFunction.TRUNC:
+ case ZetaSQLFunction.TRUNCATE:
case ZetaSQLFunction.NULLIF:
return
getExpressionType(function.getParameters().getExpressions().get(0));
case ZetaSQLFunction.IFNULL:
diff --git
a/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/functions/NumericFunction.java
b/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/functions/NumericFunction.java
index 1be6a44ef3..fb9b46ba66 100644
---
a/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/functions/NumericFunction.java
+++
b/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/functions/NumericFunction.java
@@ -152,6 +152,26 @@ public class NumericFunction {
return Math.atan2(arg.doubleValue(), arg2.doubleValue());
}
+ /**
+ * Converts a numeric value to {@link BigDecimal} without losing precision.
+ *
+ * <p>{@link BigDecimal} values are used as-is and integral types are
widened through {@code
+ * longValue()}. Only the floating point types are read through {@code
doubleValue()}, where the
+ * value carries no more precision than a {@code double} to begin with.
+ */
+ private static BigDecimal toBigDecimal(Number value) {
+ if (value instanceof BigDecimal) {
+ return (BigDecimal) value;
+ }
+ if (value instanceof Byte
+ || value instanceof Short
+ || value instanceof Integer
+ || value instanceof Long) {
+ return BigDecimal.valueOf(value.longValue());
+ }
+ return BigDecimal.valueOf(value.doubleValue());
+ }
+
public static Number mod(List<Object> args) {
Number leftValue = (Number) args.get(0);
if (leftValue == null) {
@@ -161,12 +181,12 @@ public class NumericFunction {
if (rightValue == null) {
return null;
}
- if (rightValue.doubleValue() == 0) {
+ BigDecimal leftBD = toBigDecimal(leftValue);
+ BigDecimal rightBD = toBigDecimal(rightValue);
+ if (rightBD.signum() == 0) {
throw new TransformException(
CommonErrorCodeDeprecated.UNSUPPORTED_OPERATION, "Mod by
zero");
}
- BigDecimal leftBD = BigDecimal.valueOf(leftValue.doubleValue());
- BigDecimal rightBD = BigDecimal.valueOf(rightValue.doubleValue());
BigDecimal[] res = leftBD.divideAndRemainder(rightBD);
if (rightValue instanceof Integer) {
return res[1].intValue();
@@ -190,7 +210,14 @@ public class NumericFunction {
rightValue.getClass().getName(), ZetaSQLFunction.MOD));
}
- public static Integer ceil(List<Object> args) {
+ /**
+ * Returns the smallest value greater than or equal to the argument.
+ *
+ * <p>As documented for {@code CEIL}, the result keeps the data type of
the argument. Narrowing
+ * the result to {@code int} would silently corrupt {@code BIGINT}, {@code
DOUBLE} and {@code
+ * DECIMAL} inputs whose value does not fit into 32 bits.
+ */
+ public static Number ceil(List<Object> args) {
Number v1 = (Number) args.get(0);
if (v1 == null) {
return null;
@@ -199,7 +226,7 @@ public class NumericFunction {
if (args.size() >= 2) {
v2 = (Number) args.get(1);
}
- return round(v1, v2, RoundingMode.CEILING).intValue();
+ return round(v1, v2, RoundingMode.CEILING);
}
private static Number round(Number v1, Number v2, RoundingMode
roundingMode) {
@@ -225,8 +252,9 @@ public class NumericFunction {
}
case "BIGDECIMAL":
{
- BigDecimal bd = BigDecimal.valueOf(v1.doubleValue());
- v1 = bd.setScale(scale, roundingMode);
+ // Must not round-trip through double: a DECIMAL carries
more digits than a
+ // double can hold, so doubleValue() would silently drop
the low-order ones.
+ v1 = ((BigDecimal) v1).setScale(scale, roundingMode);
break;
}
case "DOUBLE":
@@ -282,7 +310,13 @@ public class NumericFunction {
return Math.exp(v1.doubleValue());
}
- public static Integer floor(List<Object> args) {
+ /**
+ * Returns the largest value less than or equal to the argument.
+ *
+ * <p>As documented for {@code FLOOR}, the result keeps the data type of
the argument. See
+ * {@link #ceil(List)} for why the result must not be narrowed to {@code
int}.
+ */
+ public static Number floor(List<Object> args) {
Number v1 = (Number) args.get(0);
if (v1 == null) {
return null;
@@ -291,7 +325,7 @@ public class NumericFunction {
if (args.size() >= 2) {
v2 = (Number) args.get(1);
}
- return round(v1, v2, RoundingMode.FLOOR).intValue();
+ return round(v1, v2, RoundingMode.FLOOR);
}
public static Double ln(List<Object> args) {
diff --git
a/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/SQLNumericFunctionsTest.java
b/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/SQLNumericFunctionsTest.java
index 8c71f4a1be..223ba4ecb0 100644
---
a/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/SQLNumericFunctionsTest.java
+++
b/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/SQLNumericFunctionsTest.java
@@ -65,8 +65,9 @@ public class SQLNumericFunctionsTest {
Assertions.assertEquals(3, outRow.getField(0));
Assertions.assertEquals(-1, outRow.getField(1));
- Assertions.assertEquals(2, outRow.getField(2));
- Assertions.assertEquals(1, outRow.getField(3));
+ // CEIL/FLOOR keep the type of their argument, so a DOUBLE column
yields a DOUBLE
+ Assertions.assertEquals(2d, outRow.getField(2));
+ Assertions.assertEquals(1d, outRow.getField(3));
}
@Test
diff --git
a/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/zeta/NumericFunctionTest.java
b/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/zeta/NumericFunctionTest.java
index 07a194feea..66e26f4d69 100644
---
a/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/zeta/NumericFunctionTest.java
+++
b/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/zeta/NumericFunctionTest.java
@@ -93,4 +93,96 @@ public class NumericFunctionTest {
public void testSignNullReturnsNull() {
Assertions.assertNull(NumericFunction.sign(Collections.singletonList(null)));
}
+
+ /** BIGINT values outside the {@code int} range must survive CEIL/FLOOR
unchanged. */
+ @Test
+ public void testCeilAndFloorKeepBigIntValue() {
+ long value = 9007199254740993L;
+ SeaTunnelRowType rowType =
+ new SeaTunnelRowType(
+ new String[] {"long_v"}, new SeaTunnelDataType[]
{BasicType.LONG_TYPE});
+ SeaTunnelRow inputRow = new SeaTunnelRow(new Object[] {value});
+
+ SQLEngine sqlEngine =
SQLEngineFactory.getSQLEngine(SQLEngineFactory.EngineType.ZETA);
+ sqlEngine.init(
+ "test",
+ null,
+ rowType,
+ "select CEIL(long_v) as ceil_v, FLOOR(long_v) as floor_v,
TRUNC(long_v) as trunc_v from test");
+
+ SeaTunnelRowType outRowType = sqlEngine.typeMapping(null);
+ Assertions.assertEquals(BasicType.LONG_TYPE,
outRowType.getFieldType(0));
+ Assertions.assertEquals(BasicType.LONG_TYPE,
outRowType.getFieldType(1));
+ Assertions.assertEquals(BasicType.LONG_TYPE,
outRowType.getFieldType(2));
+
+ SeaTunnelRow outRow = sqlEngine.transformBySQL(inputRow,
rowType).get(0);
+ Assertions.assertEquals(value, outRow.getField(0));
+ Assertions.assertEquals(value, outRow.getField(1));
+ Assertions.assertEquals(value, outRow.getField(2));
+ }
+
+ /** DOUBLE inputs keep their type through CEIL/FLOOR instead of being
narrowed to int. */
+ @Test
+ public void testCeilAndFloorKeepDoubleType() {
+ SeaTunnelRowType rowType =
+ new SeaTunnelRowType(
+ new String[] {"double_v"}, new SeaTunnelDataType[]
{BasicType.DOUBLE_TYPE});
+ SeaTunnelRow inputRow = new SeaTunnelRow(new Object[] {1.0e18d});
+
+ SQLEngine sqlEngine =
SQLEngineFactory.getSQLEngine(SQLEngineFactory.EngineType.ZETA);
+ sqlEngine.init(
+ "test",
+ null,
+ rowType,
+ "select CEIL(double_v) as ceil_v, FLOOR(double_v) as floor_v
from test");
+
+ SeaTunnelRowType outRowType = sqlEngine.typeMapping(null);
+ Assertions.assertEquals(BasicType.DOUBLE_TYPE,
outRowType.getFieldType(0));
+ Assertions.assertEquals(BasicType.DOUBLE_TYPE,
outRowType.getFieldType(1));
+
+ SeaTunnelRow outRow = sqlEngine.transformBySQL(inputRow,
rowType).get(0);
+ Assertions.assertEquals(1.0e18d, outRow.getField(0));
+ Assertions.assertEquals(1.0e18d, outRow.getField(1));
+ }
+
+ /** DECIMAL values carry more digits than a double, so ROUND/TRUNC must
stay exact. */
+ @Test
+ public void testRoundAndTruncKeepDecimalPrecision() {
+ DecimalType decimalType = new DecimalType(38, 9);
+ BigDecimal value = new BigDecimal("12345678901234567890.987654321");
+ SeaTunnelRowType rowType =
+ new SeaTunnelRowType(
+ new String[] {"decimal_v"}, new SeaTunnelDataType[]
{decimalType});
+ SeaTunnelRow inputRow = new SeaTunnelRow(new Object[] {value});
+
+ SQLEngine sqlEngine =
SQLEngineFactory.getSQLEngine(SQLEngineFactory.EngineType.ZETA);
+ sqlEngine.init(
+ "test",
+ null,
+ rowType,
+ "select ROUND(decimal_v, 2) as round_v, TRUNC(decimal_v, 2) as
trunc_v, CEIL(decimal_v) as ceil_v from test");
+
+ SeaTunnelRowType outRowType = sqlEngine.typeMapping(null);
+ Assertions.assertEquals(decimalType, outRowType.getFieldType(0));
+ Assertions.assertEquals(decimalType, outRowType.getFieldType(1));
+ Assertions.assertEquals(decimalType, outRowType.getFieldType(2));
+
+ SeaTunnelRow outRow = sqlEngine.transformBySQL(inputRow,
rowType).get(0);
+ Assertions.assertEquals(new BigDecimal("12345678901234567890.99"),
outRow.getField(0));
+ Assertions.assertEquals(new BigDecimal("12345678901234567890.98"),
outRow.getField(1));
+ Assertions.assertEquals(new BigDecimal("12345678901234567891"),
outRow.getField(2));
+ }
+
+ /** MOD must not lose the low-order bits of a BIGINT dividend. */
+ @Test
+ public void testModKeepsBigIntPrecision() {
+ Assertions.assertEquals(1,
NumericFunction.mod(Arrays.asList(9007199254740993L, 2)));
+ Assertions.assertEquals(1L,
NumericFunction.mod(Arrays.asList(9007199254740993L, 2L)));
+ Assertions.assertEquals(
+ new BigDecimal("0.987654321"),
+ NumericFunction.mod(
+ Arrays.asList(
+ new
BigDecimal("12345678901234567890.987654321"),
+ new BigDecimal("1"))));
+ }
}
diff --git
a/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/zeta/functions/NumericFunctionTest.java
b/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/zeta/functions/NumericFunctionTest.java
index a14f0a0421..cfed353864 100644
---
a/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/zeta/functions/NumericFunctionTest.java
+++
b/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/zeta/functions/NumericFunctionTest.java
@@ -158,11 +158,15 @@ public class NumericFunctionTest {
@Test
public void testCeilFloorRoundAndTrunc() {
- Assertions.assertEquals(2, NumericFunction.ceil(Arrays.asList(1.2d)));
- Assertions.assertEquals(-1,
NumericFunction.ceil(Arrays.asList(-1.8d)));
+ // CEIL/FLOOR return the type of their argument, so a DOUBLE argument
yields a DOUBLE
+ Assertions.assertEquals(2d, NumericFunction.ceil(Arrays.asList(1.2d)));
+ Assertions.assertEquals(-1d,
NumericFunction.ceil(Arrays.asList(-1.8d)));
- Assertions.assertEquals(1, NumericFunction.floor(Arrays.asList(1.8d)));
- Assertions.assertEquals(-2,
NumericFunction.floor(Arrays.asList(-1.2d)));
+ Assertions.assertEquals(1d,
NumericFunction.floor(Arrays.asList(1.8d)));
+ Assertions.assertEquals(-2d,
NumericFunction.floor(Arrays.asList(-1.2d)));
+
+ Assertions.assertEquals(2, NumericFunction.ceil(Arrays.asList(2)));
+ Assertions.assertEquals(2L, NumericFunction.floor(Arrays.asList(2L)));
Assertions.assertEquals(3L,
NumericFunction.round(Arrays.asList(2.6d)).longValue());
Assertions.assertEquals(2L,
NumericFunction.round(Arrays.asList(2.4d)).longValue());