Skip to content

fix(spark): derive pmod's decimal result type from the declared arguments - #24409

Merged
Jefffrey merged 7 commits into
apache:mainfrom
amitvijapur:fix/spark-pmod-decimal-result-type
Sep 6, 2026
Merged

fix(spark): derive pmod's decimal result type from the declared arguments#24409
Jefffrey merged 7 commits into
apache:mainfrom
amitvijapur:fix/spark-pmod-decimal-result-type

Conversation

@amitvijapur

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

pmod reports a wider decimal type than Spark does. Spark derives the result
type of pmod with Pmod.resultDecimalType, which applies the Remainder
rule to the declared argument types:

scale     = max(s1, s2)
precision = min(p1 - s1, p2 - s2) + scale

For pmod(decimal(3,1), decimal(2,1)) Spark reports decimal(2,1), but
DataFusion reported Decimal128(3, 1).

The cause is coercion. SparkPmod used Signature::numeric, which collapses
both arguments to a common decimal before return_type runs, so the two
precisions return_type saw were already equal and the rule degenerated to the
input precision.

What changes are included in this PR?

  • SparkPmod moves to Signature::user_defined with a coerce_types that
    leaves a decimal/decimal argument pair intact, following the precedent set by
    try_sum. Every other argument combination keeps the coercion
    Signature::numeric performed — including its null handling, where a null
    argument is skipped and an all-null call falls back to Float64 — so only the
    decimal pair changes behaviour.
  • return_type applies Spark's Pmod.resultDecimalType rule for decimal
    arguments and is unchanged for everything else.
  • Because Spark's result type is narrower than the dividend, the operands
    cannot be cast to it before the remainder is taken without overflowing the
    dividend — pmod(99.9::decimal(3,1), 2.5::decimal(2,1)) returns
    decimal(2,1), which cannot hold 99.9. spark_pmod therefore widens the
    operands to a common computation type, takes the remainder there, and narrows
    the result afterwards.

Overflow semantics

The remainder is bounded by the divisor, but the result type only carries
min(p1 - s1, p2 - s2) integer digits, so the narrowing step can overflow when
the divisor is wider than the dividend:

-- result type decimal(3,1), true value 9999.8
SELECT pmod(-0.1::decimal(3,1), 9999.9::decimal(5,1));

Spark wraps decimal arithmetic in CheckOverflow(nullOnOverflow = !ansiEnabled),
so the narrowing cast returns NULL in legacy mode and raises under ANSI. The
widening cast uses safe: false in both modes, since the computation type is
chosen to fit both operands and a silent NULL there would hide a real bug.

Scope

Deliberately limited to pmod over two Decimal128 arguments, which is what
#23895 reports. Three adjacent gaps are left alone and are happy to be follow-ups
if you would rather see them here:

  • SparkMod has the same bug, since Remainder.resultDecimalType is the
    same rule. It is the easier of the two: arrow's Op::Rem already computes
    min(p1-s1, p2-s2) + max(s1, s2), so mod needs only the coerce_types
    pass-through and the matching return_type, with no widen/narrow step.
  • Decimal mixed with integer still diverges: pmod(2.5::decimal(3,1), 3)
    reports Decimal128(21, 1) where Spark casts INT to decimal(10,0) and
    reports decimal(3,1).
  • Decimal256, Decimal64 and Decimal32 pairs fall through to the
    previous behaviour. Spark has no equivalent of the wider types.

Are these changes tested?

Yes.

datafusion/sqllogictest/test_files/spark/math/pmod.slt gains:

  • four arrow_typeof assertions covering equal scales, differing precisions,
    differing scales, and the narrowing case;
  • a value test for pmod(99.9::decimal(3,1), 2.5::decimal(2,1)), the case that
    would regress if the operands were narrowed before the remainder;
  • the overflow case above, asserted as NULL in legacy mode and as an error in
    the ANSI block; and
  • null-argument cases pinning the coercion parity described above.

modulus.rs gains a unit test for pmod_decimal_result_type covering the rule
directly, independent of the planner.

The existing pmod and mod value tests are unchanged and still pass. Verified
locally: cargo test -p datafusion-spark --all-features (279 passed), all 244
spark/ sqllogictest files, cargo clippy --all-targets --all-features -D warnings, and cargo fmt --all --check.

Are there any user-facing changes?

Yes, and it is the point of the fix: pmod over two decimals now reports the
same result type Spark does. Values that fit the Spark result type are
unchanged. Values that do not fit were previously returned at the wider type and
are now NULL (legacy) or an error (ANSI), matching Spark. No public API changes.

…ents

Spark derives pmod's decimal result with `Pmod.resultDecimalType`, applying
the `Remainder` rule to the declared argument types:

    scale     = max(s1, s2)
    precision = min(p1 - s1, p2 - s2) + scale

`SparkPmod` used `Signature::numeric`, which collapses both arguments to a
common decimal before `return_type` runs. The two precisions it saw were
already equal, so the rule degenerated to the input precision and
`pmod(decimal(3,1), decimal(2,1))` reported `Decimal128(3, 1)` where Spark
reports `decimal(2,1)`.

Leave decimal arguments intact through coercion, as `try_sum` already does,
and apply the rule in `return_type`. Every other argument combination keeps
the coercion `Signature::numeric` performed, null handling included, so only
the decimal pair changes behaviour.

The result type is narrower than the dividend, so the operands cannot be cast
to it up front without overflowing it; `spark_pmod` widens them to a common
computation type instead and narrows the result afterwards. The remainder is
bounded by the divisor rather than by the result type, so that narrowing can
overflow when the divisor is wider than the dividend. Spark wraps decimal
arithmetic in `CheckOverflow(nullOnOverflow = !ansiEnabled)`, so the narrowing
cast returns NULL in legacy mode and raises under ANSI, and the widening cast
raises in either mode because the computation type always fits both operands.

Values that fit the Spark result type are unchanged; values that do not were
previously returned at the wider type and are now NULL or an error, which is
the reported-type bug itself rather than a separate behaviour change.

Github-Issue:apache#23895
@github-actions github-actions Bot added sqllogictest SQL Logic Tests (.slt) spark labels Aug 16, 2026
pub fn new() -> Self {
Self {
signature: Signature::numeric(2, Volatility::Immutable),
signature: Signature::user_defined(Volatility::Immutable),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

would it be valid to do this instead:

            signature: Signature::one_of(
                vec![
                    TypeSignature::Coercible(vec![
                        Coercion::new_exact(TypeSignatureClass::Decimal),
                        Coercion::new_exact(TypeSignatureClass::Decimal),
                    ]),
                    TypeSignature::Numeric(2),
                ],
                Volatility::Immutable,
            ),

then in return_type() calculate the widened precision/scale according to spark rules

and then in invoke_with_args() we can retrieve this calculated type via ScalarFunctionArgs::return_type

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks — I've taken the second half of this. spark_pmod now receives the type
via ScalarFunctionArgs::return_type instead of re-deriving it from the argument
arrays, so the rule is applied in exactly one place (e363e12).

The one_of signature I could not get to work, and I think the reason is
structural rather than something I can order around. NativeType::Null matches
every TypeSignatureClass:

https://github.com/apache/datafusion/blob/main/datafusion/expr-common/src/signature.rs#L460-L462

and is then left at its origin type rather than being cast to the desired one:

https://github.com/apache/datafusion/blob/main/datafusion/expr-common/src/signature.rs#L520

So Coercible([Decimal, Decimal]) accepts a null argument, passes it through as
Null, and Numeric(2) is never reached. Swapping the order does not help,
since Numeric would then match the decimal pair first and unify the two
precisions, which is the bug this PR is fixing.

With the one_of version applied, three cases regress:

SELECT arrow_typeof(pmod(NULL, NULL));
  This feature is not implemented: Can't create a zero scalar from data_type "Null"

SELECT pmod(NULL, NULL);
  This feature is not implemented: Can't create a zero scalar from data_type "Null"

SELECT arrow_typeof(pmod(2.5::decimal(3,1), NULL));
  Execution error: pmod does not support (Decimal128(3, 1), Null)

mod returns Float64 and Decimal128(3, 1) for those, and pmod did too
before this PR, so they looked worth keeping. That is what the coerce_types
version is doing: decimal pairs pass through untouched, and everything else —
nulls included — reuses the same fold TypeSignature::Numeric performs, so the
existing behaviour is preserved rather than reimplemented.

Happy to switch if there is a way to make Coercible decline nulls that I've
missed, or if you'd rather the null cases be handled explicitly in return_type
instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

or if you'd rather the null cases be handled explicitly in return_type
instead.

i think this approach would be good. we do have an issue regarding how null types interact with this coercion api:

so until that is resolved this is probably a good interim fix

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in ff84872. one_of with Coercible([Decimal, Decimal]) then Numeric(2), and pmod_numeric_coercion is gone with it.

The null result types are decided in return_type now. I checked these against mod rather than guessing: arrow_typeof(mod(NULL, NULL)) is Float64 and arrow_typeof(mod(2.5::decimal(3,1), NULL)) is Decimal128(3, 1), and pmod now returns the same.

One knock-on: since the null is no longer coerced away, spark_pmod can receive DataType::Null arrays, so it short-circuits to a null array of the result type instead of failing on ScalarValue::new_zero.

pmod(NULL, 3::int) does not plan under Numeric(2). mod rejects it identically, and pmod did too before this PR when it used Signature::numeric(2), so I've covered it with a statement error test rather than treating it as a regression to fix here.

@amitvijapur

Copy link
Copy Markdown
Contributor Author

Noting the overlap for reviewers: #23898 touches the same two files.

That PR corrects pmod's runtime behaviour — Java-style integer overflow on
(r + n) % n, ANSI-mode zero divisors for floating point, and -0.0 handling.
This PR corrects its declared result type, and is the only one of the two to
touch Signature, coerce_types or return_type.

So they are complementary rather than competing, but they will conflict
textually in both modulus.rs and pmod.slt. Happy to rebase this one on top
of #23898 if that lands first, or to hold if you would rather they were
sequenced the other way.

`spark_pmod` re-derived the Spark decimal result type from the argument
arrays, duplicating the rule `return_type` had already applied. Pass the
computed type in instead, so it is derived in exactly one place.

Per review feedback on apache#24409.
@codecov-commenter

codecov-commenter commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.06931% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.67%. Comparing base (c1b39bd) to head (ce97a59).
⚠️ Report is 261 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/spark/src/function/math/modulus.rs 93.06% 4 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #24409      +/-   ##
==========================================
+ Coverage   81.22%   81.67%   +0.44%     
==========================================
  Files        1111     1126      +15     
  Lines      389991   414605   +24614     
  Branches   389991   414605   +24614     
==========================================
+ Hits       316783   338625   +21842     
- Misses      54590    56061    +1471     
- Partials    18618    19919    +1301     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…explicitly

Replaces the hand-written `coerce_types` with the `one_of` signature suggested
in review:

    Coercible([Decimal, Decimal])
    Numeric(2)

`Coercible` matches a null argument and passes it through uncoerced (apache#19458),
so a decimal pair still reaches `return_type` with its declared precision and
scale, while everything else falls to `Numeric(2)`. That drops
`pmod_numeric_coercion`, which existed only to reproduce `Numeric`'s fold.

The null cases the coercion API cannot express are handled in `return_type`
instead: two untyped nulls answer `Float64`, and one untyped null keeps the
other side's type. Both match what `mod` returns, verified directly:

    SELECT arrow_typeof(mod(NULL, NULL));                   -- Float64
    SELECT arrow_typeof(mod(2.5::decimal(3,1), NULL));      -- Decimal128(3, 1)

Because the null is no longer coerced away, `spark_pmod` can now receive
`DataType::Null` arrays, so it short-circuits to a null array of the result
type rather than failing to build a zero scalar.

`pmod(NULL, 3::int)` does not plan under `Numeric(2)`. That matches both `mod`
today and `pmod` before this PR, which used `Signature::numeric(2)`, so it is
covered by a `statement error` test rather than treated as a regression.
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

Thank you for opening this pull request!

Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).

Details
     Cloning apache/main
    Building datafusion-spark v55.0.0 (current)
       Built [  49.218s] (current)
     Parsing datafusion-spark v55.0.0 (current)
      Parsed [   0.064s] (current)
    Building datafusion-spark v55.0.0 (baseline)
       Built [  48.193s] (baseline)
     Parsing datafusion-spark v55.0.0 (baseline)
      Parsed [   0.066s] (baseline)
    Checking datafusion-spark v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.379s] 223 checks: 222 pass, 1 fail, 0 warn, 31 skip

--- failure function_parameter_count_changed: pub fn parameter count changed ---

Description:
A publicly-visible function now takes a different number of parameters.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#fn-change-arity
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/function_parameter_count_changed.ron

Failed in:
  datafusion_spark::function::math::modulus::spark_pmod now takes 3 parameters instead of 2, in /home/runner/work/datafusion/datafusion/datafusion/spark/src/function/math/modulus.rs:128

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  99.673s] datafusion-spark
    Building datafusion-sqllogictest v55.0.0 (current)
       Built [ 108.161s] (current)
     Parsing datafusion-sqllogictest v55.0.0 (current)
      Parsed [   0.022s] (current)
    Building datafusion-sqllogictest v55.0.0 (baseline)
       Built [ 108.013s] (baseline)
     Parsing datafusion-sqllogictest v55.0.0 (baseline)
      Parsed [   0.023s] (baseline)
    Checking datafusion-sqllogictest v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.093s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 219.700s] datafusion-sqllogictest

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Aug 22, 2026

@Jefffrey Jefffrey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

im assuming theres heavy LLM usage at play here; please ensure you disclose this and carefully review its output as im seeing unnecessary comments

Comment thread datafusion/spark/src/function/math/modulus.rs Outdated
Comment thread datafusion/spark/src/function/math/modulus.rs Outdated
Comment thread datafusion/spark/src/function/math/modulus.rs
Comment thread datafusion/spark/src/function/math/modulus.rs Outdated
Comment thread datafusion/spark/src/function/math/modulus.rs Outdated
Comment thread datafusion/spark/src/function/math/modulus.rs Outdated
# An untyped NULL beside a typed non-decimal argument takes the Numeric path,
# which cannot coerce the pair. `mod` rejects it the same way.
statement error DataFusion error: Error during planning: Internal error: Function 'pmod' failed to match any signature
SELECT pmod(NULL, 3::int);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this seems quite odd and is worth looking into further in a followup

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Comment thread datafusion/sqllogictest/test_files/spark/math/pmod.slt Outdated
Comment thread datafusion/sqllogictest/test_files/spark/math/pmod.slt Outdated
Comment on lines +135 to +143
# An untyped NULL matches the decimal signature and is passed through
# uncoerced (apache/datafusion#19458), so these types are decided explicitly
# rather than by coercion. `mod` answers Float64 for two untyped nulls and
# keeps the other side's type when only one is null; pmod matches it.
query T
SELECT arrow_typeof(pmod(NULL, NULL));
----
Float64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
# An untyped NULL matches the decimal signature and is passed through
# uncoerced (apache/datafusion#19458), so these types are decided explicitly
# rather than by coercion. `mod` answers Float64 for two untyped nulls and
# keeps the other side's type when only one is null; pmod matches it.
query T
SELECT arrow_typeof(pmod(NULL, NULL));
----
Float64
query T
SELECT arrow_typeof(pmod(NULL, NULL));
----
Float64

we dont have to keep repeating this

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

bumping to remove this unnecessary comment

Uses `decimal_coercion` directly at the call site and drops
`pmod_computation_type`: only a decimal pair can reach that branch, since
`Numeric(2)` already gives every other combination a common type.

Returns a null scalar rather than building a null array for the null
short-circuit.

Removes comments that were wrong or unnecessary: a doc paragraph referencing
`SparkPmod::coerce_types`, which this PR deletes; a claim that the computation
type is wide enough by construction alongside a justification for overflow
handling; and a pointer to an ANSI assertion "further down" that sits above it.

Drops three sqllogictest cases that were duplicated in two places, and the
value assertions that only restated the type assertions beside them.
@amitvijapur

Copy link
Copy Markdown
Contributor Author

Yes, LLM-assisted, and I should have said so upfront rather than when asked. I use Claude Code for implementation and review its output before pushing.

That review wasn't good enough here. You caught a doc paragraph pointing at SparkPmod::coerce_types, which this same PR deletes; a comment claiming the computation type is wide enough by construction while also justifying overflow handling, which can't both be true; a pointer to an ANSI assertion "further down" that is actually above it; and three test cases I'd added twice in two places. Those are the unnecessary comments you're describing and none should have survived.

All eleven points are applied in d336e0e, and the diff is 43 lines smaller for it. decimal_coercion inlined as you suggested, since only a decimal pair can reach that branch now.

On the understanding bar in the contributor guide: what I can justify is the null and coercion interaction. When you suggested one_of I built it, found it regressed pmod(NULL, NULL) and pmod(2.5::decimal(3,1), NULL), and posted the failing queries rather than just accepting or rejecting the suggestion. The null result types I checked against mod directly rather than reasoning about them.

What I'd flag as not fully mine: I can't tell you why Numeric(2) refuses (Null, Int32). I established that mod behaves identically and that it predates this PR, but not the underlying cause. Happy to leave that for the followup you mentioned.

I kept the statement error case for pmod(NULL, 3::int) for now since it covers real current behaviour, but happy to drop it into the followup instead if you'd rather it not sit in this PR.

Going forward I'll keep comments to what I'd write myself.

Removes the comment above the decimal coercion branch and the block above the
null type assertions in pmod.slt, per review.
Comment thread datafusion/spark/src/function/math/modulus.rs Outdated
@Jefffrey Jefffrey removed the auto detected api change Auto detected API change label Sep 6, 2026
@Jefffrey
Jefffrey enabled auto-merge September 6, 2026 16:26
@Jefffrey

Jefffrey commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

thanks @amitvijapur

@Jefffrey
Jefffrey added this pull request to the merge queue Sep 6, 2026
Merged via the queue into apache:main with commit 262936e Sep 6, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

spark sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] pmod returns a wider decimal type than Spark

3 participants