Skip to content

[SPARK-60118][SQL] Fix Int overflow in the fast-fail check of casting string to decimal - #59334

Closed
viirya wants to merge 6 commits into
apache:masterfrom
viirya:SPARK-60118
Closed

viirya wants to merge 6 commits into
apache:masterfrom
viirya:SPARK-60118

Conversation

@viirya

@viirya viirya commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

What changes were proposed in this pull request?

Decimal.fromString and Decimal.fromStringANSI fast-fail on strings whose number of integral digits exceeds DecimalType.MAX_PRECISION, computed as precision - scale of the parsed java.math.BigDecimal. This PR:

  • computes that value as a Long instead of an Int;
  • skips the check for zero, which fits any decimal type whatever its exponent, and drops a negative scale of zero first so that the result has precision 1 and scale 0 instead of a precision derived from the exponent;
  • moves the check into one helper shared by fromString and fromStringANSI.

Why are the changes needed?

For strings with an exponent near Int.MaxValue, e.g. '1e2147483647' (precision 1, scale -2147483647), precision - scale overflows Int to a negative number and the fast-fail check is skipped. The subsequent conversion then fails with a raw java.lang.ArithmeticException (BigInteger would overflow supported range, or Underflow on JDK 21+ where '1e2147483648' parses), so try_cast, legacy CAST and ANSI CAST all fail the task instead of returning NULL or raising NUMERIC_OUT_OF_SUPPORTED_RANGE.

The check also counts integral digits for zero, so e.g. '0e39' is rejected as out of range although it fits any decimal type, while '0e37' returns 0.00.

Strings far below the target scale (e.g. '1e-2147483647') and huge exponents under spark.sql.legacy.allowNegativeScaleOfDecimal=true fail later, in Decimal.changePrecision; they are fixed separately in SPARK-60119 (#59335). Other BigDecimal entry points of Decimal are tracked in SPARK-60132.

Does this PR introduce any user-facing change?

Yes. Casting such strings to decimal now returns NULL (try_cast / non-ANSI CAST) or raises NUMERIC_OUT_OF_SUPPORTED_RANGE (ANSI CAST), the same as other out-of-range strings such as '6E+38', instead of failing with java.lang.ArithmeticException. Zero with a large exponent, e.g. '0e39', now returns 0 (precision 1, scale 0) instead of NULL / NUMERIC_OUT_OF_SUPPORTED_RANGE.

How was this patch tested?

New test cases in DecimalSuite, CastWithAnsiOnSuite, CastWithAnsiOffSuite and TryCastSuite (inherited), covering interpreted and codegen evaluation. They fail without this fix.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Claude Opus 5.5)

This pull request and its description were written by Isaac.

… string to decimal

Compute the number of integral digits in `Decimal.fromString`/`fromStringANSI` as a Long,
so that strings with an exponent near Int.MaxValue (e.g. '1e2147483647') are rejected by
the fast-fail check instead of failing with a raw java.lang.ArithmeticException.

Co-authored-by: Isaac <[email protected]>

@dongjoon-hyun dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the fix, @viirya. Computing the number of integral digits in Long looks right to me. I left 6 inline comments. In summary:

  1. Large negative exponents still fail with a raw ArithmeticException

    '1e-2147483647' skips the fast-fail, and changePrecision throws in setScale for try_cast, non-ANSI CAST and ANSI CAST.

  2. The legacy spark.sql.legacy.allowNegativeScaleOfDecimal=true path is not covered

    The fast-fail is skipped under the legacy conf, so '1e2147483646' still fails with ArithmeticException: Underflow.

  3. '0e2147483647' changes from 0.00 to NULL or an error

    Zero fits any decimal type. Skipping the fast-fail for zero would keep 0.00 and also fix '0e39'.

  4. No fast-fail for large negative exponents

    try_cast('1e-10000000' AS DECIMAL(10,2)) takes about 1.9 seconds per row. This was already the case before this PR.

  5. The same precision - scale Int expression in Decimal.set

    Other BigDecimal entry points, such as Decimal(String) and the CSV/JSON readers, don't go through this bound. This can be a separate JIRA.

  6. Test coverage

    The tests don't cover the negative-exponent case or the legacy conf.

I checked these behaviors with spark-shell on a current master build. The code paths for 1, 2 and 4 are the same with this PR.

bigDecimal.precision - bigDecimal.scale
// Computed in Long since `precision - scale` can overflow Int, e.g. for "1e2147483647".
private def numDigitsInIntegralPart(bigDecimal: JavaBigDecimal): Long =
bigDecimal.precision.toLong - bigDecimal.scale

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This covers large positive exponents, but the mirror case with a large negative exponent still fails with a raw ArithmeticException. For '1e-2147483647', precision - scale is 1 - 2147483647, so the fast-fail is not taken. Decimal.set then sets _precision = _scale = 2147483647, and changePrecision calls dv.setScale(2, ...), which throws.

On the current master build (the path is the same with this PR):

try_cast('1e-2147483647' AS DECIMAL(10,2))                    => ArithmeticException: BigInteger would overflow supported range
CAST('1e-2147483647' AS DECIMAL(10,2)), ansi.enabled=false     => same
CAST('1e-2147483647' AS DECIMAL(10,2)), ansi.enabled=true      => same

This value rounds to 0.00, the same as '1e-100'. Since it is the same failure as the one in the PR description, could you handle this case in this PR too?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fails in changePrecision rather than in the fast-fail, so it's handled in #59335 (SPARK-60119), which bounds the small side there.

private def numDigitsInIntegralPart(bigDecimal: JavaBigDecimal): Int =
bigDecimal.precision - bigDecimal.scale
// Computed in Long since `precision - scale` can overflow Int, e.g. for "1e2147483647".
private def numDigitsInIntegralPart(bigDecimal: JavaBigDecimal): Long =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fast-fail is skipped when spark.sql.legacy.allowNegativeScaleOfDecimal is true, so this fix does not apply under the legacy conf. Decimal.set keeps the negative scale, and changePrecision fails in setScale:

spark.sql.legacy.allowNegativeScaleOfDecimal=true
try_cast('1e2147483646' AS DECIMAL(10,2))  => ArithmeticException: Underflow

This was already the case before this PR. Should it be covered here, or should the PR description say that the legacy conf is out of scope?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Covered in #59335 (SPARK-60119), with a cast-level test under the legacy conf. I noted it in the PR description.

checkExceptionInExpression[ArithmeticException](
cast("6E+37", DecimalType(38, 1)),
"cannot be represented as Decimal(38, 1)")
Seq("1e2147483647", "0e2147483647", "1.5e2147483647", "1e2147483648").foreach { s =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'0e2147483647' is exactly zero, and it fits DECIMAL(10,2). On master it returns 0.00. With this PR it becomes NULL (non-ANSI) or NUMERIC_OUT_OF_SUPPORTED_RANGE (ANSI), and these tests lock that in.

It is consistent with '0e39' and '0e100', as the PR description says, but those look like the same false positive: the integral-digit check counts precision - scale even for zero.

spark.sql.ansi.enabled=false, master
CAST(s AS DECIMAL(10,2)) for '0e2147483647', '0e39', '0e37'  => 0.00, null, 0.00

How about skipping the fast-fail for zero, e.g. bigDecimal.signum != 0 && numDigitsInIntegralPart(bigDecimal) > DecimalType.MAX_PRECISION? Then '0e2147483647' keeps returning 0.00, and '0e39' is fixed too.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Done: the fast-fail skips zero now, and the tests check that '0e39', '0e2147483647' and '0.0e2147483648' cast to 0.00.


private def numDigitsInIntegralPart(bigDecimal: JavaBigDecimal): Int =
bigDecimal.precision - bigDecimal.scale
// Computed in Long since `precision - scale` can overflow Int, e.g. for "1e2147483647".

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fast-fail exists because converting a huge value is slow (see the Decimal("6.0790316E+25569151") comment below), but it only bounds large positive exponents. A large negative exponent goes through the same slow path in setScale:

try_cast('1e-10000000' AS DECIMAL(10,2)), 1 row   => 0.00  (1867 ms)
try_cast('1e-10000000' AS DECIMAL(10,2)), 5 rows  => 0.00  (8779 ms)

This was already the case before this PR, so it can be a separate JIRA. A bound on the negative side could fix this and the '1e-2147483647' exception together. For example, a value below 10^-(MAX_SCALE + 1) rounds to zero for any target scale.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix as above in #59335; '1e-100000000' returns 0.00 without the setScale cost.

private def numDigitsInIntegralPart(bigDecimal: JavaBigDecimal): Int =
bigDecimal.precision - bigDecimal.scale
// Computed in Long since `precision - scale` can overflow Int, e.g. for "1e2147483647".
private def numDigitsInIntegralPart(bigDecimal: JavaBigDecimal): Long =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decimal.set(BigDecimal) has the same Int expression, this._precision = decimal.precision - decimal.scale (L152). Other entry points that build a Decimal from a BigDecimal don't go through this bound either. For example, Decimal(String), and the CSV/JSON readers with a decimal schema (UnivocityParser L226, JacksonParser L441) call Decimal(bd, precision, scale), which goes straight to setScale.

Would it make sense to move the bound (in Long) into Decimal.set or a shared helper, so these paths don't depend on the string-cast fast path? This can be a separate JIRA.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed SPARK-60132 for this.

}
}

test("UTF8String to Decimal with exponent near Int.MaxValue") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Depending on how you handle the comments in Decimal.scala, could you add these cases?

  • The negative-exponent case, '1e-2147483647'.
  • A cast case with spark.sql.legacy.allowNegativeScaleOfDecimal=true, like the withSQLConf(SQLConf.LEGACY_ALLOW_NEGATIVE_SCALE_OF_DECIMAL_ENABLED.key -> "true") block in the SPARK-37451 test above. Under the legacy conf, fromString itself succeeds and the exception comes from changePrecision, so it needs a cast-level check.

Both still fail with a raw ArithmeticException, and the current tests don't catch them.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The negative-exponent and legacy-conf cases are tested in #59335. I added zero cases here.

Zero fits any decimal type whatever its exponent, so strings like '0e39' should not be
rejected as out of range.

Co-authored-by: Isaac <[email protected]>
@viirya

viirya commented Oct 10, 2026

Copy link
Copy Markdown
Member Author

Thanks for the thorough review, @dongjoon-hyun.

  • Large negative exponents ('1e-2147483647', and the slow '1e-10000000') and the legacy negative-scale conf: these fail in changePrecision (setScale with a huge scale delta), not in the fast-fail check this PR fixes. They are handled in [SPARK-60119][SQL] Skip rescaling in Decimal.changePrecision when the result is known from the magnitude #59335 (SPARK-60119), together with cast-level tests for both cases, so I kept this PR scoped to the fast-fail.
  • Zero: agreed, '0e39' returning NULL is the same false positive. The fast-fail now skips zero (bigDecimal.signum != 0), so '0e2147483647' keeps returning 0.00 and '0e39' is fixed. I updated the tests and the PR description.
  • Decimal.set L152 and the other BigDecimal entry points: filed SPARK-60132. For CSV/JSON the exception currently becomes a bad record rather than a task failure, but it should still be handled consistently.

@HyukjinKwon HyukjinKwon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

The Long arithmetic and the zero exemption in Decimal.fromString / fromStringANSI look right. Both the interpreted and codegen cast paths rescale the result through changePrecision, so zeros with large exponents come out as 0.00 of the target type. The remaining raw-ArithmeticException paths behave the same as on master and are tracked in SPARK-60119 (#59335) and SPARK-60132, as already discussed: tiny-magnitude exponents, the legacy negative-scale conf, and Decimal.set with the other BigDecimal entry points.

The one blocking issue is in the new tests. The exponent-2147483648 inputs are only parsed by newer JDKs. JDK 17, the minimum and default CI JDK, rejects them with Exponent overflow., and the PR's CI fails in DecimalSuite, CastWithAnsiOnSuite, CastWithAnsiOffSuite and TryCastSuite on exactly those cases.

Findings

1 total: 0 P0, 1 P1, 0 P2, 0 P3.

Blocking (P1)

  • New tests use exponent 2147483648, which JDK 17 cannot parse — sql/catalyst/src/test/scala/org/apache/spark/sql/types/DecimalSuite.scala:452 — see inline.

Generated by Omnigent on Databricks.

test("UTF8String to Decimal with exponent near Int.MaxValue") {
// The number of integral digits (precision - scale) of these values overflows Int.
Seq("1e2147483647", "-1E+2147483647", "12e2147483647", "1.5e2147483647",
"1e2147483648").foreach { string =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (P1): "1e2147483648" (and "0.0e2147483648" at line 461) only parse on newer JDKs. On JDK 17, which is Spark's java.version and the default CI JDK, new java.math.BigDecimal(s) throws NumberFormatException("Exponent overflow.") for an exponent outside Int range. That happens in stringToJavaBigDecimal, before the fast-fail runs. So on JDK 17:

  • fromStringANSI("1e2147483648") throws SparkNumberFormatException (CAST_INVALID_INPUT), not SparkArithmeticException / NUMERIC_OUT_OF_SUPPORTED_RANGE.
  • "0.0e2147483648" gives null / CAST_INVALID_INPUT instead of zero.

JDK 25 parses both strings (scale -2147483648 and -2147483647). I checked both JDKs with a standalone BigDecimal probe.

This is what fails CI on this commit:

  • DecimalSuite: this test
  • CastWithAnsiOnSuite: lines 478 and 483, and TryCastSuite, which inherits them
  • CastWithAnsiOffSuite: line 565

The "1e2147483648" -> null checks still pass on JDK 17, but only through the NumberFormatException path, not the Long fast-fail they are meant to cover.

Could you drop these two inputs, or replace them with strings that parse on every supported JDK and still overflow Int? For example, "10e2147483647" (precision 2, scale -2147483647) for the non-zero side and "0.0e2147483647" for the zero side.

Verification:

  • Regression: On JDK 17, the new Decimal string-conversion test and the cast-string-to-decimal fast-fail tests (ANSI on, ANSI off, and try_cast, interpreted and codegen) pass, where they currently fail on the exponent-2147483648 inputs.
  • Behavior: Each non-zero test input parses with java.math.BigDecimal on JDK 17 and yields precision - scale greater than Int.MaxValue, and the same assertions fail if numDigitsInIntegralPart is reverted to Int arithmetic.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. My local runs used JDK 21, where new BigDecimal("1e2147483648") parses with scale Int.MinValue; JDK 17 rejects it with NumberFormatException before the fast-fail runs. I replaced '1e2147483648' with '12e2147483647' (precision 2, scale -2147483647) and dropped '0.0e2147483648'; '0e2147483647' still covers the zero case with an Int-overflowing digit count, so '0.0e2147483647' (whose count, 2147483647, fits in Int) wouldn't add coverage. On JDK 17, the updated tests pass, and all four fail again if numDigitsInIntegralPart is reverted to Int arithmetic. I also corrected the PR description, which mentioned the JDK 21-only Underflow failure.

JDK 17 rejects an exponent outside the Int range, e.g. '1e2147483648', with
NumberFormatException before the fast-fail runs. Use '12e2147483647' instead, which
parses on every supported JDK and still overflows Int.

Co-authored-by: Isaac <[email protected]>

@dongjoon-hyun dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for addressing the previous comments, @viirya. The zero exemption and the JDK 17-safe inputs look good to me, and I don't re-raise the points tracked in #59335 (SPARK-60119) and SPARK-60132. I left 5 inline comments on the latest commit. In summary:

  1. Zeros with a large exponent now come back with an invalid precision

    With the zero exemption, '0e39' reaches Decimal.set, whose precision - scale (L152) is the same Int expression. fromString("0e39") returns a Decimal with precision 40 (it was null before), and fromString("0e2147483647") returns one with precision -2147483648. Cast hides this because changePrecision rescales the value.

  2. The zero assertions in DecimalSuite compare only the value

    === Decimal(0) passes no matter what precision and scale the result has, so the test can't catch 1.

  3. The fast-fail condition and its comment are duplicated

    The same three-line condition is in both fromString and fromStringANSI.

  4. The zero cast checks are identical in CastWithAnsiOnSuite and CastWithAnsiOffSuite

    The expected result doesn't depend on ANSI mode, so the check could live in CastSuiteBase.

  5. The new test name has no JIRA prefix

    The neighboring regression tests use the SPARK-xxxxx: form.

I checked 1 with java.math.BigDecimal on JDK 21, mirroring the Decimal.set arithmetic:

0e39:         bd.precision=1 bd.scale=-39         -> Decimal._precision=40
-0E+100:      bd.precision=1 bd.scale=-100        -> Decimal._precision=101
0e2147483647: bd.precision=1 bd.scale=-2147483647 -> Decimal._precision=-2147483648

if (numDigitsInIntegralPart(bigDecimal) > DecimalType.MAX_PRECISION &&
// For example: Decimal("6.0790316E+25569151"). Zero fits any decimal type whatever its
// exponent, e.g. "0e39", so it is not checked.
if (bigDecimal.signum != 0 &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the zero exemption, a zero with a large exponent goes to Decimal(bigDecimal). Decimal.set then computes this._precision = decimal.precision - decimal.scale (L152), which is the same Int expression this PR moves to Long in numDigitsInIntegralPart:

0e39:         bd.precision=1 bd.scale=-39         -> Decimal._precision=40
-0E+100:      bd.precision=1 bd.scale=-100        -> Decimal._precision=101
0e2147483647: bd.precision=1 bd.scale=-2147483647 -> Decimal._precision=-2147483648

So Decimal.fromString("0e39") used to return null and now returns a Decimal whose precision is 40, above MAX_PRECISION. The '0e2147483647' case was already like this on master. Cast is not affected because changePrecision rescales the value, but fromString / fromStringANSI are public, and a caller that reads .precision or builds DecimalType(d.precision, d.scale) gets an invalid value.

SPARK-60132 tracks Decimal.set in general. Since this PR is what newly sends these zeros there, how about normalizing zero here? For example, Decimal(bigDecimal.setScale(math.max(bigDecimal.scale, 0))) for signum == 0, which keeps a non-negative scale such as '0.00' and gives precision 1 for '0e39'.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. When negative scales are not allowed, a zero's negative scale is dropped before Decimal.set, so the result has precision 1 and scale 0; '0.00' keeps scale 2.

}
// Zero fits any decimal type whatever its exponent.
Seq("0e39", "-0E+100", "0e2147483647").foreach { string =>
assert(Decimal.fromString(UTF8String.fromString(string)) === Decimal(0))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decimal.equals compares only the value (through compare), so === Decimal(0) passes for any precision and scale. It passes today although fromString("0e39") has precision 40 and fromString("0e2147483647") has precision -2147483648 (see the comment in Decimal.scala).

Could you also assert the precision and scale, e.g. precision within [1, DecimalType.MAX_PRECISION] and scale == 0?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The test now also asserts precision 1 and scale 0 for these zeros, and fails with "40 did not equal 1" without the normalization.

if (numDigitsInIntegralPart(bigDecimal) > DecimalType.MAX_PRECISION &&
// For example: Decimal("6.0790316E+25569151"). Zero fits any decimal type whatever its
// exponent, e.g. "0e39", so it is not checked.
if (bigDecimal.signum != 0 &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This three-line condition and its three-line comment are a copy of the ones in fromString (L651-656). This PR already had to change both copies the same way for the zero exemption.

How about a shared private predicate, e.g. exceedsMaxPrecision(bigDecimal)? Another option is to fold the zero rule into numDigitsInIntegralPart, since zero has one integral digit. Either way the rule would be defined in one place.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Moved into one private helper, stringBigDecimalToDecimal, which also holds the zero rule.

cast(s, DecimalType(10, 2)),
"NUMERIC_OUT_OF_SUPPORTED_RANGE")
}
Seq("0e39", "0e2147483647").foreach { s =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These zero checks are identical to the ones added in CastWithAnsiOffSuite (L565-567), because the expected 0.00 doesn't depend on ANSI mode.

Could they move to CastSuiteBase? Then they run once for ANSI on, ANSI off and try_cast, and there is only one copy to maintain.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Moved to CastSuiteBase, so they run for ANSI on, ANSI off and try_cast.

}
}

test("UTF8String to Decimal with exponent near Int.MaxValue") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The neighboring regression tests use the JIRA prefix (SPARK-37451: ..., SPARK-41554: ...). How about test("SPARK-60118: UTF8String to Decimal with exponent near Int.MaxValue")?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

…ingANSI

A zero with a large exponent such as '0e39' now reaches Decimal.set, which derives the
precision from the negative scale (40 for '0e39', Int.MinValue for '0e2147483647'). Drop
the negative scale of a zero first so the result has precision 1 and scale 0.

Move the fast-fail into one helper used by both fromString and fromStringANSI, check the
precision and scale of zero results in DecimalSuite, move the zero cast checks to
CastSuiteBase, and add the JIRA prefix to the new test name.

Co-authored-by: Isaac <[email protected]>
@viirya

viirya commented Oct 12, 2026

Copy link
Copy Markdown
Member Author

Thanks again, @dongjoon-hyun. All five are addressed in e44b77f:

  • Zero is normalized before Decimal.set: when negative scales are not allowed, a zero's negative scale is dropped first (setScale(max(scale, 0))), so '0e39' and '0e2147483647' now give precision 1 and scale 0, and '0.00' keeps scale 2. Under the legacy negative-scale conf zero is left as is, since a negative scale is valid there.
  • The fast-fail is now one private helper used by both fromString and fromStringANSI.
  • DecimalSuite checks the precision and scale of the zero results (the precision assertion fails without the normalization), the zero cast checks moved to CastSuiteBase, and the test name has the JIRA prefix.

@dongjoon-hyun dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the update, @viirya. All 5 points from my previous review are handled. Zeros now come back with precision 1 and scale 0, the test checks the precision and scale, the fast-fail lives in one helper, the zero cast checks moved to CastSuiteBase, and the test name has the JIRA prefix. I left 6 inline comments on the latest commit. Only the first one is a real concern, a per-row cost added by the new helper. The rest are minor. In summary:

  1. Decimal.scala L654: The new helper reads the legacy conf on every call

    Before, the && order read allowNegativeScaleOfDecimalEnabled only for out-of-range strings. Now every string-to-decimal cast reads it. On executors that allocates a new ReadOnlySQLConf per row, and on the driver it takes the SparkContext lock.

  2. Decimal.scala L656: The zero rule could also live in Decimal.set(BigDecimal)

    The other BigDecimal entry points still give precision 40 for 0E+39, so Literal(new java.math.BigDecimal("0E+39")) fails. This overlaps SPARK-60132 and doesn't need to block this PR.

  3. Decimal.scala L658: nit: A zero with a large negative exponent still gets a growing precision

    '0e-2147483647' returns precision and scale 2147483647. Cast still returns 0.00, so only the comment is off.

  4. DecimalSuite.scala L461: The legacy-conf behavior of the zero branch is not tested

    Under allowNegativeScaleOfDecimal=true, '0e39' keeps scale -39, and no test pins that.

  5. Decimal.scala L655: nit: The legacy branch repeats the last branch

    The conf matters only for two rare cases, so the four branches could be two conditions.

  6. Decimal.scala L649: nit: The Javadoc overstates when null is returned

    A value whose scale no decimal type can hold, such as '1e-50', comes back as a non-null Decimal.

* very slow. For example: Decimal("6.0790316E+25569151")
*/
private def stringBigDecimalToDecimal(bigDecimal: JavaBigDecimal): Decimal = {
if (SqlApiConf.get.allowNegativeScaleOfDecimalEnabled) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads SqlApiConf.get.allowNegativeScaleOfDecimalEnabled before anything else, so every call pays for a conf lookup. On master and at the previous commit, the && order read the conf only when numDigitsInIntegralPart(bigDecimal) > DecimalType.MAX_PRECISION, that is, only for out-of-range strings.

This is a per-row path: Cast calls the helper per row (L1380/L1385 interpreted, L1900/L1912 codegen), and so does the protobuf uint64-to-decimal converter. The lookup isn't cheap there:

  • On executors, a normal task has no existingConf (only SQLExecutionRDD sets one), so SQLConf.get returns new ReadOnlySQLConf(TaskContext.get()). That builds two ConfigReaders with their maps and providers, then looks the key up in the task's local properties, once per row.
  • On the driver (for example ConvertToLocalRelation or constant folding), SQLConf.get calls SparkContext.getActive, which takes SPARK_CONTEXT_CONSTRUCTOR_LOCK on every call.

I traced this in the code and didn't benchmark it. This path has been tuned for speed before, though (the s.toString.trim note in stringToJavaBigDecimal above, and the fast-fail itself).

How about reading the conf only on the two rare paths that need it? For example:

  private def stringBigDecimalToDecimal(bigDecimal: JavaBigDecimal): Decimal = {
    if (bigDecimal.signum == 0) {
      // Zero fits any decimal type whatever its exponent, e.g. "0e39". Drop a negative scale so
      // that the precision does not grow with the exponent.
      if (bigDecimal.scale < 0 && !SqlApiConf.get.allowNegativeScaleOfDecimalEnabled) {
        Decimal(bigDecimal.setScale(0))
      } else {
        Decimal(bigDecimal)
      }
    } else if (numDigitsInIntegralPart(bigDecimal) > DecimalType.MAX_PRECISION &&
        !SqlApiConf.get.allowNegativeScaleOfDecimalEnabled) {
      null
    } else {
      Decimal(bigDecimal)
    }
  }

This gives the same results as the current helper for every input, and an in-range string never touches the conf.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, that was a regression from the previous commit. Done with your suggested structure; an in-range string no longer reads the conf.

private def stringBigDecimalToDecimal(bigDecimal: JavaBigDecimal): Decimal = {
if (SqlApiConf.get.allowNegativeScaleOfDecimalEnabled) {
Decimal(bigDecimal)
} else if (bigDecimal.signum == 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The precision that grows with the exponent comes from Decimal.set(BigDecimal) (L150-152, this._precision = decimal.precision - decimal.scale), so the other BigDecimal entry points still have it. From the code, Literal(new java.math.BigDecimal("0E+39")) (literals.scala L83-85) builds Decimal(d) with precision 40. DecimalType.fromDecimal then throws DECIMAL_PRECISION_EXCEEDS_MAX_PRECISION, while Literal(java.math.BigDecimal.ZERO) works. With 0E+2147483647, the precision is -2147483648, and DecimalType fails with DECIMAL_SCALE_EXCEEDS_PRECISION.

I know SPARK-60132 tracks the other entry points, so this doesn't need to block this PR. If set(BigDecimal) handled zero itself (precision 1 and scale 0 for a zero with a negative scale when negative scales are not allowed), every entry point would be covered, and this branch would only need to skip the fast-fail. Could you add the zero case to SPARK-60132 so it isn't lost?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the zero case and this Literal example to SPARK-60132.

Decimal(bigDecimal)
} else if (bigDecimal.signum == 0) {
// Zero fits any decimal type whatever its exponent, e.g. "0e39". Drop a negative scale so
// that the precision does not grow with the exponent.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This keeps a non-negative scale as it is, so a zero with a large negative exponent still gets a precision that grows with the exponent:

0e-50:         bd.precision=1 bd.scale=50         -> Decimal._precision=50         _scale=50
0e-2147483647: bd.precision=1 bd.scale=2147483647 -> Decimal._precision=2147483647 _scale=2147483647

(I checked this with java.math.BigDecimal on JDK 21, mirroring this branch and Decimal.set.) Cast still returns 0.00 because BigDecimal.setScale returns a zero right away, so nothing fails. But this comment says the precision no longer grows with the exponent, and the test comment says "whatever its exponent", while both only cover positive exponents.

Could you either cap the scale for zero, e.g. math.min(math.max(bigDecimal.scale, 0), DecimalType.MAX_SCALE), or narrow the comment to positive exponents?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Narrowed the comment to positive exponents. I'd rather not cap the scale only for zero, since a nonzero value like '1e-50' gets precision 50 the same way and changePrecision handles both.

parameters = Map("value" -> string))
}
// Zero fits any decimal type whatever its exponent.
Seq("0e39", "-0E+100", "0e2147483647").foreach { string =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The helper checks the legacy conf before the zero branch. So under spark.sql.legacy.allowNegativeScaleOfDecimal=true, a zero such as '0e39' keeps its negative scale (precision 1, scale -39) and is not normalized. I think that's intended, but no test covers it, so reordering the branches (as in the suggestion at Decimal.scala L654) could change it silently.

How about a legacy block like the one in the SPARK-37451 test above? For example:

    withSQLConf(SQLConf.LEGACY_ALLOW_NEGATIVE_SCALE_OF_DECIMAL_ENABLED.key -> "true") {
      Seq(Decimal.fromString(UTF8String.fromString("0e39")),
        Decimal.fromStringANSI(UTF8String.fromString("0e39"))).foreach { d =>
        assert(d === Decimal(0))
        assert(d.scale === -39)
      }
    }

Also, the '0.00' check at L469 runs only through fromString. It could cover fromStringANSI too, like the loop above.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, added the legacy block and the fromStringANSI check for '0.00'.

*/
private def stringBigDecimalToDecimal(bigDecimal: JavaBigDecimal): Decimal = {
if (SqlApiConf.get.allowNegativeScaleOfDecimalEnabled) {
Decimal(bigDecimal)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This branch is the same as the last else branch. The conf matters only for a zero with a negative scale and for an out-of-range value. So the four branches could be the two conditions in the suggestion at L654, which also keeps the conf off the common path.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done as part of the restructuring above.

}

/**
* Converts a `JavaBigDecimal` parsed from a string to `Decimal`, or returns null if no decimal

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The helper returns null only when the integral part has more than MAX_PRECISION digits and negative scales are not allowed. A value whose scale no decimal type can hold, such as '1e-50' (precision 50, scale 50), comes back as a non-null Decimal, and changePrecision deals with it later.

How about something like "or returns null if its integral part has more than DecimalType.MAX_PRECISION digits"? Then a new caller that skips changePrecision won't take a non-null result for a valid decimal.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

The helper read spark.sql.legacy.allowNegativeScaleOfDecimal before anything else, so every
string-to-decimal cast paid for a conf lookup per row (a new ReadOnlySQLConf on executors).
Read it only for a zero with a negative scale and for a value with too many integral digits,
as before. Also narrow the comments, and test zero under the legacy conf and '0.00' through
fromStringANSI.

Co-authored-by: Isaac <[email protected]>
@viirya

viirya commented Oct 12, 2026

Copy link
Copy Markdown
Member Author

Thanks, @dongjoon-hyun. Addressed in e7dd3a4:

  • The helper now reads the legacy conf only for a zero with a negative scale and for a value with too many integral digits, as before this PR, so an in-range string doesn't touch the conf. I used your suggested structure, which also removes the duplicate branch.
  • The comment now says the precision doesn't grow with a positive exponent. I didn't cap the scale of zero: a nonzero value like '1e-50' gets precision 50 the same way, and changePrecision handles both.
  • The Javadoc now says exactly when null is returned.
  • DecimalSuite now also checks zero under the legacy conf (scale -39 is kept) and '0.00' through fromStringANSI.
  • I added the zero case and the Literal example to SPARK-60132.

@HyukjinKwon HyukjinKwon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

The Long digit count and the zero handling look right. Non-zero strings whose integral-digit count overflows Int now hit the fast-fail and give NULL or NUMERIC_OUT_OF_SUPPORTED_RANGE. Zeros with a large exponent come back with precision 1 and scale 0, and the helper keeps the legacy-conf lookup off the common path. The cases left to SPARK-60119 (#59335) and SPARK-60132 behave the same as on master, as already discussed.

The JDK 17 issue from the previous review is resolved. The tests no longer use exponent-2147483648 inputs, and each remaining overflow input parses on JDK 17 with an Int-overflowing digit count.

One minor point remains: the new Javadoc on stringBigDecimalToDecimal says the conf is read only on the two rare paths, but Decimal.set still reads it for in-range values with a negative scale.

Findings

1 total: 0 P0, 0 P1, 0 P2, 1 P3.

Nit (P3)

  • New Javadoc overstates where the legacy conf is read — sql/api/src/main/scala/org/apache/spark/sql/types/Decimal.scala:654 — see inline.

Re-review status

Prior AI findings: 1 addressed, 0 still present; additional unresolved findings in this review: 1.

New attribution: 1 newly introduced, 0 late catch, 0 previously raised, 0 unattributed.

Remaining prior AI findings

No prior AI findings remain.

Generated by Omnigent on Databricks.

* fast fail because constructing a very large JavaBigDecimal to Decimal is very slow. For
* example: Decimal("6.0790316E+25569151")
*
* This runs for every row of a cast, so the conf is read only for a zero with a negative scale

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (P3): The fall-through Decimal(bigDecimal) goes through Decimal.set(BigDecimal), which still evaluates decimal.scale < 0 && !SqlApiConf.get.allowNegativeScaleOfDecimalEnabled (L151). So an in-range string with a negative scale, such as '1e5' or '1.2E+3', still reads the conf on every row. That matches master, so it's not a regression, but this sentence says the conversion reads the conf only for the two rare cases.

Could you scope it to this helper's own checks? For example, "so this helper reads the conf only for a zero with a negative scale and for a value with too many integral digits".

Verification:

  • Inspection: The revised comment is consistent with the code: the helper's guards read the conf only on its two rare branches, and any statement about the conversion as a whole accounts for Decimal.set reading the conf for negative-scale values.
  • Compile: The sql/api module still compiles and scalastyle passes on the edited comment (ASCII only, lines within 100 characters).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. Scoped the sentence to this helper's own checks, as you suggested.

Decimal(bigDecimal)
}
} else if (numDigitsInIntegralPart(bigDecimal) > DecimalType.MAX_PRECISION &&
!SqlApiConf.get.allowNegativeScaleOfDecimalEnabled) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line fails the scalafmt check in CI. At e7dd3a4, the Run / Linters, licenses, and dependencies job fails in the Scala linter step with Scalafmt: Unformatted files found on spark-sql-api. The previous commit passed this job. For sql/api, scalafmt wants a 2-space continuation indent here, as in the original code on master, not 4. Sorry, the 4-space indent came from the snippet I suggested in my previous review.

I ran the formatter on this commit, and it changed only this line and the Javadoc at L649-652 (see the other comment):

./build/mvn scalafmt:format -Dscalafmt.skip=false -Dscalafmt.validateOnly=false -Dscalafmt.changedOnly=false -pl sql/api
Suggested change
!SqlApiConf.get.allowNegativeScaleOfDecimalEnabled) {
!SqlApiConf.get.allowNegativeScaleOfDecimalEnabled) {

Because this step failed, the later lint steps (the structured logging check, the Java linter, the Connect JVM client MiMa check, and the Python, JS and R linters) were skipped, so they haven't run on this commit yet.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, thanks. No worries about the snippet; I should have run scalafmt on sql/api myself.

Comment on lines +649 to +652
* Converts a `JavaBigDecimal` parsed from a string to `Decimal`, or returns null if its integral
* part has more than `DecimalType.MAX_PRECISION` digits and negative scales are not allowed. We
* fast fail because constructing a very large JavaBigDecimal to Decimal is very slow. For
* example: Decimal("6.0790316E+25569151")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L649 is 99 characters, over the maxColumn = 98 in dev/.scalafmt.conf that applies to sql/api. It is the other spot behind the scalafmt failure. Scalastyle allows 100 characters, so it doesn't catch this. This is how scalafmt wraps the paragraph:

Suggested change
* Converts a `JavaBigDecimal` parsed from a string to `Decimal`, or returns null if its integral
* part has more than `DecimalType.MAX_PRECISION` digits and negative scales are not allowed. We
* fast fail because constructing a very large JavaBigDecimal to Decimal is very slow. For
* example: Decimal("6.0790316E+25569151")
* Converts a `JavaBigDecimal` parsed from a string to `Decimal`, or returns null if its
* integral part has more than `DecimalType.MAX_PRECISION` digits and negative scales are not
* allowed. We fast fail because constructing a very large JavaBigDecimal to Decimal is very
* slow. For example: Decimal("6.0790316E+25569151")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed with scalafmt's wrapping.

parameters = Map("value" -> string))
}
// Zero fits any decimal type whatever its exponent.
Seq("0e39", "-0E+100", "0e2147483647").foreach { string =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The precision and scale asserts below only cover exponents above 38, which returned null before. This PR also changes two nearby cases that no test covers. '0e38' is the boundary, the first positive exponent that master rejected (1 - (-38) = 39 > 38). An in-range zero with a negative scale, such as '0e37' or '0e5', now gets precision 1 instead of a precision derived from the exponent. I checked this with java.math.BigDecimal on JDK 21, mirroring master and this PR followed by Decimal.set:

0e5:  master precision 6,  scale 0 -> this PR precision 1, scale 0
0e37: master precision 38, scale 0 -> this PR precision 1, scale 0
0e38: master null                  -> this PR precision 1, scale 0

Suppose a later change normalizes zero only when the fast-fail would have fired, or moves the rule into Decimal.set for SPARK-60132 and misses this case. Then '0e37' could go back to precision 38, and every current test would still pass. How about adding "0e38" and "0e37" to this list, and "0e38" to the CastSuiteBase list (L334)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Added '0e37' and '0e38' here (both now assert precision 1 and scale 0) and '0e38' to the CastSuiteBase list.

Use the 2-space continuation indent and wrap the Javadoc within maxColumn = 98, as scalafmt
requires for sql/api. Scope the Javadoc's note on the conf to this helper, since Decimal.set
still reads it for an in-range value with a negative scale. Also test '0e38', the smallest
exponent master rejected, and '0e37', which used to get precision 38.

Co-authored-by: Isaac <[email protected]>
@viirya

viirya commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

Thanks, @dongjoon-hyun. Fixed in 2e4ddb4: the continuation indent and the Javadoc wrap now pass scalafmt on sql/api (I verified with the same scalafmt:format validate command, which fails on e7dd3a4), and the tests now cover '0e37' and '0e38' in DecimalSuite and '0e38' in CastSuiteBase.

@dongjoon-hyun dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, LGTM.

@viirya viirya closed this in 79ab768 Oct 14, 2026
viirya added a commit that referenced this pull request Oct 14, 2026
… string to decimal

### What changes were proposed in this pull request?

`Decimal.fromString` and `Decimal.fromStringANSI` fast-fail on strings whose number of integral digits exceeds `DecimalType.MAX_PRECISION`, computed as `precision - scale` of the parsed `java.math.BigDecimal`. This PR:
- computes that value as a `Long` instead of an `Int`;
- skips the check for zero, which fits any decimal type whatever its exponent, and drops a negative scale of zero first so that the result has precision 1 and scale 0 instead of a precision derived from the exponent;
- moves the check into one helper shared by `fromString` and `fromStringANSI`.

### Why are the changes needed?

For strings with an exponent near `Int.MaxValue`, e.g. `'1e2147483647'` (precision 1, scale -2147483647), `precision - scale` overflows `Int` to a negative number and the fast-fail check is skipped. The subsequent conversion then fails with a raw `java.lang.ArithmeticException` (`BigInteger would overflow supported range`, or `Underflow` on JDK 21+ where `'1e2147483648'` parses), so `try_cast`, legacy `CAST` and ANSI `CAST` all fail the task instead of returning NULL or raising `NUMERIC_OUT_OF_SUPPORTED_RANGE`.

The check also counts integral digits for zero, so e.g. `'0e39'` is rejected as out of range although it fits any decimal type, while `'0e37'` returns `0.00`.

Strings far below the target scale (e.g. `'1e-2147483647'`) and huge exponents under `spark.sql.legacy.allowNegativeScaleOfDecimal=true` fail later, in `Decimal.changePrecision`; they are fixed separately in SPARK-60119 (#59335). Other `BigDecimal` entry points of `Decimal` are tracked in SPARK-60132.

### Does this PR introduce _any_ user-facing change?

Yes. Casting such strings to decimal now returns NULL (`try_cast` / non-ANSI `CAST`) or raises `NUMERIC_OUT_OF_SUPPORTED_RANGE` (ANSI `CAST`), the same as other out-of-range strings such as `'6E+38'`, instead of failing with `java.lang.ArithmeticException`. Zero with a large exponent, e.g. `'0e39'`, now returns `0` (precision 1, scale 0) instead of NULL / `NUMERIC_OUT_OF_SUPPORTED_RANGE`.

### How was this patch tested?

New test cases in `DecimalSuite`, `CastWithAnsiOnSuite`, `CastWithAnsiOffSuite` and `TryCastSuite` (inherited), covering interpreted and codegen evaluation. They fail without this fix.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Claude Opus 5.5)

This pull request and its description were written by Isaac.

Closes #59334 from viirya/SPARK-60118.

Authored-by: Liang-Chi Hsieh <[email protected]>
Signed-off-by: Liang-Chi Hsieh <[email protected]>
(cherry picked from commit 79ab768)
Signed-off-by: Liang-Chi Hsieh <[email protected]>
viirya added a commit that referenced this pull request Oct 14, 2026
… string to decimal

### What changes were proposed in this pull request?

`Decimal.fromString` and `Decimal.fromStringANSI` fast-fail on strings whose number of integral digits exceeds `DecimalType.MAX_PRECISION`, computed as `precision - scale` of the parsed `java.math.BigDecimal`. This PR:
- computes that value as a `Long` instead of an `Int`;
- skips the check for zero, which fits any decimal type whatever its exponent, and drops a negative scale of zero first so that the result has precision 1 and scale 0 instead of a precision derived from the exponent;
- moves the check into one helper shared by `fromString` and `fromStringANSI`.

### Why are the changes needed?

For strings with an exponent near `Int.MaxValue`, e.g. `'1e2147483647'` (precision 1, scale -2147483647), `precision - scale` overflows `Int` to a negative number and the fast-fail check is skipped. The subsequent conversion then fails with a raw `java.lang.ArithmeticException` (`BigInteger would overflow supported range`, or `Underflow` on JDK 21+ where `'1e2147483648'` parses), so `try_cast`, legacy `CAST` and ANSI `CAST` all fail the task instead of returning NULL or raising `NUMERIC_OUT_OF_SUPPORTED_RANGE`.

The check also counts integral digits for zero, so e.g. `'0e39'` is rejected as out of range although it fits any decimal type, while `'0e37'` returns `0.00`.

Strings far below the target scale (e.g. `'1e-2147483647'`) and huge exponents under `spark.sql.legacy.allowNegativeScaleOfDecimal=true` fail later, in `Decimal.changePrecision`; they are fixed separately in SPARK-60119 (#59335). Other `BigDecimal` entry points of `Decimal` are tracked in SPARK-60132.

### Does this PR introduce _any_ user-facing change?

Yes. Casting such strings to decimal now returns NULL (`try_cast` / non-ANSI `CAST`) or raises `NUMERIC_OUT_OF_SUPPORTED_RANGE` (ANSI `CAST`), the same as other out-of-range strings such as `'6E+38'`, instead of failing with `java.lang.ArithmeticException`. Zero with a large exponent, e.g. `'0e39'`, now returns `0` (precision 1, scale 0) instead of NULL / `NUMERIC_OUT_OF_SUPPORTED_RANGE`.

### How was this patch tested?

New test cases in `DecimalSuite`, `CastWithAnsiOnSuite`, `CastWithAnsiOffSuite` and `TryCastSuite` (inherited), covering interpreted and codegen evaluation. They fail without this fix.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Claude Opus 5.5)

This pull request and its description were written by Isaac.

Closes #59334 from viirya/SPARK-60118.

Authored-by: Liang-Chi Hsieh <[email protected]>
Signed-off-by: Liang-Chi Hsieh <[email protected]>
(cherry picked from commit 79ab768)
Signed-off-by: Liang-Chi Hsieh <[email protected]>
viirya added a commit that referenced this pull request Oct 14, 2026
… string to decimal

### What changes were proposed in this pull request?

`Decimal.fromString` and `Decimal.fromStringANSI` fast-fail on strings whose number of integral digits exceeds `DecimalType.MAX_PRECISION`, computed as `precision - scale` of the parsed `java.math.BigDecimal`. This PR:
- computes that value as a `Long` instead of an `Int`;
- skips the check for zero, which fits any decimal type whatever its exponent, and drops a negative scale of zero first so that the result has precision 1 and scale 0 instead of a precision derived from the exponent;
- moves the check into one helper shared by `fromString` and `fromStringANSI`.

### Why are the changes needed?

For strings with an exponent near `Int.MaxValue`, e.g. `'1e2147483647'` (precision 1, scale -2147483647), `precision - scale` overflows `Int` to a negative number and the fast-fail check is skipped. The subsequent conversion then fails with a raw `java.lang.ArithmeticException` (`BigInteger would overflow supported range`, or `Underflow` on JDK 21+ where `'1e2147483648'` parses), so `try_cast`, legacy `CAST` and ANSI `CAST` all fail the task instead of returning NULL or raising `NUMERIC_OUT_OF_SUPPORTED_RANGE`.

The check also counts integral digits for zero, so e.g. `'0e39'` is rejected as out of range although it fits any decimal type, while `'0e37'` returns `0.00`.

Strings far below the target scale (e.g. `'1e-2147483647'`) and huge exponents under `spark.sql.legacy.allowNegativeScaleOfDecimal=true` fail later, in `Decimal.changePrecision`; they are fixed separately in SPARK-60119 (#59335). Other `BigDecimal` entry points of `Decimal` are tracked in SPARK-60132.

### Does this PR introduce _any_ user-facing change?

Yes. Casting such strings to decimal now returns NULL (`try_cast` / non-ANSI `CAST`) or raises `NUMERIC_OUT_OF_SUPPORTED_RANGE` (ANSI `CAST`), the same as other out-of-range strings such as `'6E+38'`, instead of failing with `java.lang.ArithmeticException`. Zero with a large exponent, e.g. `'0e39'`, now returns `0` (precision 1, scale 0) instead of NULL / `NUMERIC_OUT_OF_SUPPORTED_RANGE`.

### How was this patch tested?

New test cases in `DecimalSuite`, `CastWithAnsiOnSuite`, `CastWithAnsiOffSuite` and `TryCastSuite` (inherited), covering interpreted and codegen evaluation. They fail without this fix.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Claude Opus 5.5)

This pull request and its description were written by Isaac.

Closes #59334 from viirya/SPARK-60118.

Authored-by: Liang-Chi Hsieh <[email protected]>
Signed-off-by: Liang-Chi Hsieh <[email protected]>
(cherry picked from commit 79ab768)
Signed-off-by: Liang-Chi Hsieh <[email protected]>
viirya added a commit that referenced this pull request Oct 14, 2026
… string to decimal

### What changes were proposed in this pull request?

`Decimal.fromString` and `Decimal.fromStringANSI` fast-fail on strings whose number of integral digits exceeds `DecimalType.MAX_PRECISION`, computed as `precision - scale` of the parsed `java.math.BigDecimal`. This PR:
- computes that value as a `Long` instead of an `Int`;
- skips the check for zero, which fits any decimal type whatever its exponent, and drops a negative scale of zero first so that the result has precision 1 and scale 0 instead of a precision derived from the exponent;
- moves the check into one helper shared by `fromString` and `fromStringANSI`.

### Why are the changes needed?

For strings with an exponent near `Int.MaxValue`, e.g. `'1e2147483647'` (precision 1, scale -2147483647), `precision - scale` overflows `Int` to a negative number and the fast-fail check is skipped. The subsequent conversion then fails with a raw `java.lang.ArithmeticException` (`BigInteger would overflow supported range`, or `Underflow` on JDK 21+ where `'1e2147483648'` parses), so `try_cast`, legacy `CAST` and ANSI `CAST` all fail the task instead of returning NULL or raising `NUMERIC_OUT_OF_SUPPORTED_RANGE`.

The check also counts integral digits for zero, so e.g. `'0e39'` is rejected as out of range although it fits any decimal type, while `'0e37'` returns `0.00`.

Strings far below the target scale (e.g. `'1e-2147483647'`) and huge exponents under `spark.sql.legacy.allowNegativeScaleOfDecimal=true` fail later, in `Decimal.changePrecision`; they are fixed separately in SPARK-60119 (#59335). Other `BigDecimal` entry points of `Decimal` are tracked in SPARK-60132.

### Does this PR introduce _any_ user-facing change?

Yes. Casting such strings to decimal now returns NULL (`try_cast` / non-ANSI `CAST`) or raises `NUMERIC_OUT_OF_SUPPORTED_RANGE` (ANSI `CAST`), the same as other out-of-range strings such as `'6E+38'`, instead of failing with `java.lang.ArithmeticException`. Zero with a large exponent, e.g. `'0e39'`, now returns `0` (precision 1, scale 0) instead of NULL / `NUMERIC_OUT_OF_SUPPORTED_RANGE`.

### How was this patch tested?

New test cases in `DecimalSuite`, `CastWithAnsiOnSuite`, `CastWithAnsiOffSuite` and `TryCastSuite` (inherited), covering interpreted and codegen evaluation. They fail without this fix.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Claude Opus 5.5)

This pull request and its description were written by Isaac.

Closes #59334 from viirya/SPARK-60118.

Authored-by: Liang-Chi Hsieh <[email protected]>
Signed-off-by: Liang-Chi Hsieh <[email protected]>
(cherry picked from commit 79ab768)
Signed-off-by: Liang-Chi Hsieh <[email protected]>
viirya added a commit that referenced this pull request Oct 14, 2026
… string to decimal

### What changes were proposed in this pull request?

`Decimal.fromString` and `Decimal.fromStringANSI` fast-fail on strings whose number of integral digits exceeds `DecimalType.MAX_PRECISION`, computed as `precision - scale` of the parsed `java.math.BigDecimal`. This PR:
- computes that value as a `Long` instead of an `Int`;
- skips the check for zero, which fits any decimal type whatever its exponent, and drops a negative scale of zero first so that the result has precision 1 and scale 0 instead of a precision derived from the exponent;
- moves the check into one helper shared by `fromString` and `fromStringANSI`.

### Why are the changes needed?

For strings with an exponent near `Int.MaxValue`, e.g. `'1e2147483647'` (precision 1, scale -2147483647), `precision - scale` overflows `Int` to a negative number and the fast-fail check is skipped. The subsequent conversion then fails with a raw `java.lang.ArithmeticException` (`BigInteger would overflow supported range`, or `Underflow` on JDK 21+ where `'1e2147483648'` parses), so `try_cast`, legacy `CAST` and ANSI `CAST` all fail the task instead of returning NULL or raising `NUMERIC_OUT_OF_SUPPORTED_RANGE`.

The check also counts integral digits for zero, so e.g. `'0e39'` is rejected as out of range although it fits any decimal type, while `'0e37'` returns `0.00`.

Strings far below the target scale (e.g. `'1e-2147483647'`) and huge exponents under `spark.sql.legacy.allowNegativeScaleOfDecimal=true` fail later, in `Decimal.changePrecision`; they are fixed separately in SPARK-60119 (#59335). Other `BigDecimal` entry points of `Decimal` are tracked in SPARK-60132.

### Does this PR introduce _any_ user-facing change?

Yes. Casting such strings to decimal now returns NULL (`try_cast` / non-ANSI `CAST`) or raises `NUMERIC_OUT_OF_SUPPORTED_RANGE` (ANSI `CAST`), the same as other out-of-range strings such as `'6E+38'`, instead of failing with `java.lang.ArithmeticException`. Zero with a large exponent, e.g. `'0e39'`, now returns `0` (precision 1, scale 0) instead of NULL / `NUMERIC_OUT_OF_SUPPORTED_RANGE`.

### How was this patch tested?

New test cases in `DecimalSuite`, `CastWithAnsiOnSuite`, `CastWithAnsiOffSuite` and `TryCastSuite` (inherited), covering interpreted and codegen evaluation. They fail without this fix.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Claude Opus 5.5)

This pull request and its description were written by Isaac.

Closes #59334 from viirya/SPARK-60118.

Authored-by: Liang-Chi Hsieh <[email protected]>
Signed-off-by: Liang-Chi Hsieh <[email protected]>
(cherry picked from commit 79ab768)
Signed-off-by: Liang-Chi Hsieh <[email protected]>
@viirya

viirya commented Oct 14, 2026

Copy link
Copy Markdown
Member Author

Merge Summary:

Posted by merge_spark_pr.py

@viirya
viirya deleted the SPARK-60118 branch October 14, 2026 01:23
@viirya

viirya commented Oct 14, 2026

Copy link
Copy Markdown
Member Author

Thanks @HyukjinKwon @dongjoon-hyun !

JacksonKjar pushed a commit to JacksonKjar/apache-spark that referenced this pull request Oct 14, 2026
… string to decimal

### What changes were proposed in this pull request?

`Decimal.fromString` and `Decimal.fromStringANSI` fast-fail on strings whose number of integral digits exceeds `DecimalType.MAX_PRECISION`, computed as `precision - scale` of the parsed `java.math.BigDecimal`. This PR:
- computes that value as a `Long` instead of an `Int`;
- skips the check for zero, which fits any decimal type whatever its exponent, and drops a negative scale of zero first so that the result has precision 1 and scale 0 instead of a precision derived from the exponent;
- moves the check into one helper shared by `fromString` and `fromStringANSI`.

### Why are the changes needed?

For strings with an exponent near `Int.MaxValue`, e.g. `'1e2147483647'` (precision 1, scale -2147483647), `precision - scale` overflows `Int` to a negative number and the fast-fail check is skipped. The subsequent conversion then fails with a raw `java.lang.ArithmeticException` (`BigInteger would overflow supported range`, or `Underflow` on JDK 21+ where `'1e2147483648'` parses), so `try_cast`, legacy `CAST` and ANSI `CAST` all fail the task instead of returning NULL or raising `NUMERIC_OUT_OF_SUPPORTED_RANGE`.

The check also counts integral digits for zero, so e.g. `'0e39'` is rejected as out of range although it fits any decimal type, while `'0e37'` returns `0.00`.

Strings far below the target scale (e.g. `'1e-2147483647'`) and huge exponents under `spark.sql.legacy.allowNegativeScaleOfDecimal=true` fail later, in `Decimal.changePrecision`; they are fixed separately in SPARK-60119 (apache#59335). Other `BigDecimal` entry points of `Decimal` are tracked in SPARK-60132.

### Does this PR introduce _any_ user-facing change?

Yes. Casting such strings to decimal now returns NULL (`try_cast` / non-ANSI `CAST`) or raises `NUMERIC_OUT_OF_SUPPORTED_RANGE` (ANSI `CAST`), the same as other out-of-range strings such as `'6E+38'`, instead of failing with `java.lang.ArithmeticException`. Zero with a large exponent, e.g. `'0e39'`, now returns `0` (precision 1, scale 0) instead of NULL / `NUMERIC_OUT_OF_SUPPORTED_RANGE`.

### How was this patch tested?

New test cases in `DecimalSuite`, `CastWithAnsiOnSuite`, `CastWithAnsiOffSuite` and `TryCastSuite` (inherited), covering interpreted and codegen evaluation. They fail without this fix.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Claude Opus 5.5)

This pull request and its description were written by Isaac.

Closes apache#59334 from viirya/SPARK-60118.

Authored-by: Liang-Chi Hsieh <[email protected]>
Signed-off-by: Liang-Chi Hsieh <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants