dongjoon-hyun commented on code in PR #59335:
URL: https://github.com/apache/spark/pull/59335#discussion_r4247351758


##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/CastWithAnsiOnSuite.scala:
##########
@@ -481,6 +481,36 @@ class CastWithAnsiOnSuite extends CastSuiteBase with 
QueryErrorsBase {
       castErrMsg("abcd", DecimalType(38, 1)))
   }
 
+  test("SPARK-60119: cast string with a huge exponent to decimal with negative 
scale allowed") {
+    withSQLConf(SQLConf.LEGACY_ALLOW_NEGATIVE_SCALE_OF_DECIMAL_ENABLED.key -> 
"true") {
+      Seq("1e2147483647", "-1e2147483646", "12e2147483647", 
"1e100000000").foreach { str =>
+        checkExceptionInExpression[ArithmeticException](
+          cast(str, DecimalType(10, 2)),
+          "cannot be represented as Decimal(10, 2)")
+      }
+      checkEvaluation(cast("0e2147483647", DecimalType(10, 2)), 
Decimal("0.00"))
+
+      // The value is shown in plain notation unless its scale is hugely 
negative.
+      Seq("1e2147483647" -> "1E+2147483647", "1e40" -> ("1" + "0" * 
40)).foreach {
+        case (str, value) =>
+          if (!isTryCast) {
+            checkError(
+              exception = intercept[SparkArithmeticException](
+                cast(str, DecimalType(10, 2)).eval()),

Review Comment:
   nit. This `checkError` runs only the interpreted path (`.eval()`). The 
codegen path goes through `CastUtils.changePrecisionExact`, whose error helper 
this PR switches from `QueryExecutionErrors` to `DataTypeErrors`. The loop 
above checks that path only by a message substring, so the `value` parameter is 
not asserted there. `checkErrorInExpression[SparkArithmeticException](cast(str, 
DecimalType(10, 2)), "NUMERIC_VALUE_OUT_OF_RANGE.WITH_SUGGESTION", Map(...))` 
checks the parameters in the interpreted, mutable-projection, and 
unsafe-projection modes. `TryCastSuite` already overrides it to expect `null`, 
so the `isTryCast` branch would no longer be needed. It does not check 
`queryContext`, but that is the same empty context in every mode here.



##########
sql/api/src/main/scala/org/apache/spark/sql/types/Decimal.scala:
##########
@@ -458,6 +458,20 @@ final class Decimal extends Ordered[Decimal] with 
Serializable {
     if (dv.ne(null)) {
       // We get here if either we started with a BigDecimal, or we switched to 
one because we would
       // have overflowed our Long; in either case we must rescale dv to the 
new scale.
+      if (math.abs(dv.scale.toLong - scale) > DecimalType.MAX_PRECISION) {

Review Comment:
   This shortcut fixes `changePrecision`, but `RoundBase` still rescales 
without a bound. For a negative `_scale`, `nullSafeEval` calls 
`decimal.toBigDecimal.setScale(_scale, mode)` directly 
(`mathExpressions.scala:1782`). The byte/short/int/long branches 
(`mathExpressions.scala:1786-1807`) and the generated code 
(`mathExpressions.scala:1856-1857`) do the same. On the JDK:
   
   ```
   jshell> new BigDecimal("1.5").setScale(-2147483646, RoundingMode.HALF_UP)
   java.lang.ArithmeticException: BigInteger would overflow supported range
   jshell> new BigDecimal("1.5").setScale(-2147483647, RoundingMode.HALF_UP)
   java.lang.ArithmeticException: Underflow
   ```
   
   So `SELECT round(1.5BD, -2147483646)` fails with a raw `ArithmeticException` 
instead of returning `0`. Unlike the cast cases, this does not need 
`spark.sql.legacy.allowNegativeScaleOfDecimal`, because `round`/`bround` accept 
any foldable Int scale.
   
   This is pre-existing, so it does not need to block this PR. Since 
SPARK-60133 already covers `Decimal.set(decimal, precision, scale)`, could we 
add `round`/`bround` to its scope? A shared helper in `Decimal` built from the 
logic here could then serve all of these callers.



##########
sql/api/src/main/scala/org/apache/spark/sql/errors/DataTypeErrors.scala:
##########
@@ -207,10 +212,14 @@ private[sql] object DataTypeErrors extends 
DataTypeErrorsBase {
       decimalPrecision: Int,
       decimalScale: Int,
       context: QueryContext): ArithmeticException = {
+    // A huge negative scale (legacy mode only) makes the plain string too 
long to build,
+    // e.g. for 1E+2147483647.
+    val valueString =
+      if (value.scale < -MAX_PLAIN_STRING_TRAILING_ZEROS) value.toString else 
value.toPlainString

Review Comment:
   This bounds the plain string only in the `NUMERIC_VALUE_OUT_OF_RANGE` 
message. Other code still calls `Decimal.toPlainString` on the same values:
   - `Cast` to string uses plain notation under ANSI (`useDecimalPlainString = 
ansiEnabled`, `Cast.scala:829`, used at `ToStringBase.scala:200` and 
`ToStringBase.scala:353`).
   - `ToPrettyString`, which `Dataset.show` uses, always does 
(`ToPrettyString.scala:50`).
   
   `DecimalType` has no lower bound on a negative scale. With 
`spark.sql.legacy.allowNegativeScaleOfDecimal=true` and the default ANSI mode, 
both of these build the full plain string:
   
   ```scala
   lit("1e2147483647").cast(DecimalType(1, -2147483647)).cast("string")
   spark.range(1).select(lit(new java.math.BigDecimal("1e2147483647"))).show()
   ```
   
   The inner cast succeeds because the precision and scale already match, so 
`changePrecision` takes its fast path. On the JDK, `new 
BigDecimal("1e2147483647").toPlainString()` throws `OutOfMemoryError: too large 
to fit in a String`.
   
   This is pre-existing and legacy-only, so it does not need to block this PR. 
But it is the same problem this guard solves. Could we track it in a JIRA, or 
move the threshold into a `Decimal`-level helper that these callers can share?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to