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


##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/CastWithAnsiOnSuite.scala:
##########
@@ -481,6 +481,17 @@ 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)")

Review Comment:
   This checks only the message substring, so the `DataTypeErrors` change 
(rendering a negative-scale value like `1E+2147483647` with `toString`) is not 
asserted directly. If that branch were changed later, e.g. to print a truncated 
plain string or the wrong value, this test would still pass. Could we add a 
`checkError` on `NUMERIC_VALUE_OUT_OF_RANGE.WITH_SUGGESTION` with the expected 
`value` parameter for at least one of these inputs?



##########
sql/api/src/main/scala/org/apache/spark/sql/types/Decimal.scala:
##########
@@ -458,6 +458,19 @@ 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) {
+        // setScale takes time proportional to the scale change and fails once 
the change
+        // exceeds the Int range, e.g. for 1e-2147483647. Skip it when the 
result is known.
+        val numIntegralDigits = dv.precision.toLong - dv.scale
+        if (dv.signum != 0 && numIntegralDigits > precision.toLong - scale) {
+          return false
+        }
+        if (dv.signum == 0 || numIntegralDigits < -scale.toLong - 1) {
+          // |dv| is below 0.01 ulp of the new scale, so the result depends 
only on the sign
+          // and the rounding mode. Round a value of the same sign and 0.01 
ulp instead.
+          dv = BigDecimal(dv.signum, scale + 2)
+        }
+      }
       dv = dv.setScale(scale, roundMode)

Review Comment:
   This shortcut protects only `changePrecision`. `Decimal.set(decimal: 
BigDecimal, precision: Int, scale: Int)` still calls `decimal.setScale(scale, 
ROUND_HALF_UP)` without a bound, and it is used by the JSON, CSV and XML 
parsers (`JacksonParser.scala:441/444`, `UnivocityParser.scala:226`, 
`StaxXmlParser.scala:788`). For example, `from_json('{"a": 1e-2147483647}', 'a 
DECIMAL(10,2)')`, or a CSV/XML column `1e-2147483647` read with a 
`DECIMAL(10,2)` schema, still fails with `BigInteger would overflow supported 
range`, and `1e-100000000` still takes ~40s per value. `set(decimal)`'s 
`decimal.setScale(0)` has the same issue.
   
   Would it make sense to move this magnitude check into a small shared rescale 
helper used by `changePrecision` and both `set` overloads, so every entry point 
is covered? If you prefer to keep this PR focused on casts, a separate JIRA 
would be fine too.



##########
sql/api/src/main/scala/org/apache/spark/sql/types/Decimal.scala:
##########
@@ -458,6 +458,19 @@ 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 uses `Long` arithmetic for the scale difference and the integral 
digits, but the existing fast-fail in `Decimal.fromString`/`fromStringANSI` 
still uses `numDigitsInIntegralPart`, which computes `bigDecimal.precision - 
bigDecimal.scale` in `Int`. For `1e2147483647` (precision 1, scale 
-2147483647), the result overflows to `-2147483648`, so the `> 
DecimalType.MAX_PRECISION` fast-fail is skipped. With the default 
`spark.sql.legacy.allowNegativeScaleOfDecimal=false`, `Decimal(bigDecimal)` 
then goes to `set(decimal)`, which calls `decimal.setScale(0)`:
   
   ```
   jshell> var b = new java.math.BigDecimal("1e2147483647");
   jshell> b.precision() - b.scale()
   $2 ==> -2147483648
   jshell> b.setScale(0)
   |  Exception java.lang.ArithmeticException: BigInteger would overflow 
supported range
   ```
   
   So `SELECT try_cast('1e2147483647' AS DECIMAL(10,2))` (and the non-ANSI 
cast) throws instead of returning NULL, and the ANSI cast throws a raw 
`ArithmeticException` instead of `NUMERIC_VALUE_OUT_OF_RANGE`. 
`'12e2147483646'` hits the same overflow. The new tests cover these huge 
exponents only with the legacy config on. Could you compute 
`numDigitsInIntegralPart` in `Long` as well and add the non-legacy cases to 
`CastSuiteBase`?



##########
sql/api/src/main/scala/org/apache/spark/sql/errors/DataTypeErrors.scala:
##########
@@ -210,7 +210,9 @@ private[sql] object DataTypeErrors extends 
DataTypeErrorsBase {
     new SparkArithmeticException(
       errorClass = "NUMERIC_VALUE_OUT_OF_RANGE.WITH_SUGGESTION",
       messageParameters = Map(
-        "value" -> value.toPlainString,
+        // A negative scale (legacy mode only) can make the plain string 
arbitrarily long,
+        // e.g. 1E+2147483647.
+        "value" -> (if (value.scale < 0) value.toString else 
value.toPlainString),

Review Comment:
   This changes the message for every negative-scale value, not only for the 
huge ones. With `spark.sql.legacy.allowNegativeScaleOfDecimal=true`, an 
overflow of a value stored as unscaled `1` with scale `-2` used to show `100` 
and now shows `1E+2`, and the same applies to ordinary values like `1E+40`. 
Since only very large `-scale` values make the plain string too long, would it 
be better to switch to `toString` only above some threshold (e.g. when 
`-value.scale` exceeds a reasonable number of digits), so the existing message 
stays the same for normal values?



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/types/DecimalSuite.scala:
##########
@@ -492,4 +492,57 @@ class DecimalSuite extends SparkFunSuite with 
PrivateMethodTester with SQLHelper
         s"unscaled: $unscaled, scaleFrom: $scaleFrom, scaleTo: $scaleTo, mode: 
$roundMode")
     }
   }
+
+  test("SPARK-60119: changePrecision with a source scale far from the target 
scale") {
+    // A value below 0.1 ulp of the target scale rounds to 0 or +/-1 ulp 
depending only on its

Review Comment:
   nit. This comment says `below 0.1 ulp`, while the shortcut in 
`changePrecision` applies below `0.01 ulp` (`numIntegralDigits < -scale.toLong 
- 1`), and its comment says `0.01 ulp`. The statement is mathematically true, 
but it can make readers think values in `[0.01, 0.1)` ulp take the shortcut. 
Could you align it with the main code?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala:
##########
@@ -108,15 +108,8 @@ private[sql] object QueryExecutionErrors extends 
QueryErrorsBase with ExecutionE
       decimalPrecision: Int,
       decimalScale: Int,
       context: QueryContext = null): ArithmeticException = {
-    new SparkArithmeticException(
-      errorClass = "NUMERIC_VALUE_OUT_OF_RANGE.WITH_SUGGESTION",
-      messageParameters = Map(
-        "value" -> value.toPlainString,
-        "precision" -> decimalPrecision.toString,
-        "scale" -> decimalScale.toString,
-        "config" -> toSQLConf(SQLConf.ANSI_ENABLED.key)),
-      context = getQueryContext(context),
-      summary = getSummary(context))
+    DataTypeErrors.cannotChangeDecimalPrecisionError(

Review Comment:
   nit. Now this method only forwards to 
`DataTypeErrors.cannotChangeDecimalPrecisionError` with the same signature. 
Since its only main-code caller is `CastUtils.java:110`, we can call 
`DataTypeErrors` there directly and remove this wrapper, so that the two copies 
cannot drift apart again.



-- 
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