Skip to content

feat(tests): add decimal arithmetic cases - #1213

Open
alexandrefimov wants to merge 8 commits into
substrait-io:mainfrom
alexandrefimov:tests/decimal-arithmetic-cases
Open

alexandrefimov wants to merge 8 commits into
substrait-io:mainfrom
alexandrefimov:tests/decimal-arithmetic-cases

Conversation

@alexandrefimov

@alexandrefimov alexandrefimov commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

functions_arithmetic_decimal defines add, subtract, multiply and divide over decimals, and the cases under those names cover only the integer and floating-point overloads. The precision and scale formula had no case behind it.

add, subtract and multiply each get ordinary operands, where the result type follows from the formula and the arithmetic is exact, and a pair at scale 10, where the formula gives up scale to keep precision within 38. Each also gets an operand pair whose exact result is outside the returned type under any treatment of scale, with overflow:ERROR, and null cases for one nullable operand and for both.

divide is left out. Its precision writes P2 where Gandiva, which #151 names as the reference these expressions were fixed against, and Hive, Spark and Trino all write S2, so dec(10,2) / dec(5,1) is dec(21,8) here and dec(17,8) in those three. Cases would settle that by assertion; #1216 is open on it instead.

Coverage moves from 282 to 285 of 531 function variants.


This change is Reviewable

`functions_arithmetic_decimal` defines add, subtract, multiply and divide over decimals, and the cases under those names cover only the integer and floating-point overloads. The precision and scale formula had no case behind it.

Each function gets ordinary operands, where the result type follows from the formula and the arithmetic is exact, and a pair at scale 10, where the formula gives up scale to keep precision within 38. Add and multiply also get an operand pair whose exact result is outside the returned type under any treatment of scale, with `overflow:ERROR`.

Coverage moves from 278 to 282 of 531 function variants.
Null operands exercise the MIRROR nullability the four implementations
declare, which is the one property of a declared result this repository
checks. Subtract and divide gain the overflow case add and multiply
already had, so all four state what happens when the exact result is
outside the type the formula returns.
@alexandrefimov

Copy link
Copy Markdown
Contributor Author

The divide cases here assert dec<21, 8>, which is what the extension file derives. The worked example in the documentation derives dec<17, 8> for the same call, and I opened #1216 for that. Merging these settles the question by default, so it is worth a glance first.

# basic: Sums of small values, with the result type given by the precision and scale formula
add(1.00::dec<10, 2>, 2.0::dec<5, 1>) = 3.00::dec<11, 2>
add(-1.00::dec<10, 2>, 2.0::dec<5, 1>) = 1.00::dec<11, 2>
add(0.00::dec<10, 2>, 0.0::dec<5, 1>) = 0.00::dec<11, 2>

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.

Before we encode these semantics... we should figure out what engine(s) this is intended to model.

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.

I explored around and the best I could find was spark. I executed the following with docker:

   SELECT a + b AS result, typeof(a + b) AS result_type
   FROM VALUES
     (CAST(1.00 AS DECIMAL(10,2)), CAST(2.0 AS DECIMAL(5,1))),
     (CAST(-1.00 AS DECIMAL(10,2)), CAST(2.0 AS DECIMAL(5,1))),
     (CAST(0.00 AS DECIMAL(10,2)), CAST(0.0 AS DECIMAL(5,1)))
   AS t(a, b);

and got:

   3.00   decimal(11,2)
   1.00   decimal(11,2)
   0.00   decimal(11,2)

which is a good sign.

I executed the equivalent query in datafusion:

   SELECT a + b AS result, arrow_typeof(a + b) AS result_type
   FROM (VALUES
     (CAST(1.00 AS DECIMAL(10,2)), CAST(2.0 AS DECIMAL(5,1))),
     (CAST(-1.00 AS DECIMAL(10,2)), CAST(2.0 AS DECIMAL(5,1))),
     (CAST(0.00 AS DECIMAL(10,2)), CAST(0.0 AS DECIMAL(5,1)))
   ) AS t(a, b);

and also got:

   +--------+-------------------+
   | result | result_type       |
   +--------+-------------------+
   | 3.00   | Decimal128(11, 2) |
   | 1.00   | Decimal128(11, 2) |
   | 0.00   | Decimal128(11, 2) |
   +--------+-------------------+

So they both agree there which is good.

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.

The reference is Gandiva's DecimalTypeUtil — #151 names it, and that PR is the only change to these return expressions since the file was written. Hive implements the same rules, and the comment on Spark's adjustPrecisionScale says it is based on Hive's.

I transcribed Gandiva, Hive 4.0.1 and Spark 4.0.1 beside the YAML and compared them over every operand type pair type_classes.md allows (779 × 779 = 606841): add, subtract and multiply derive the same type on every one. divide does not; more on that in the thread below.

add(0.00::dec<10, 2>, 0.0::dec<5, 1>) = 0.00::dec<11, 2>

# scale_reduction: Two operands at scale 10 need 39 digits, so the formula keeps precision 38 and drops the scale to 9
add(1.0000000000::dec<38, 10>, 2.0000000000::dec<38, 10>) = 3.000000000::dec<38, 9>

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.

However in this case, they dont' agree.

Spark

Query

  SELECT
    CAST('1.0000000000' AS DECIMAL(38,10))
      + CAST('2.0000000000' AS DECIMAL(38,10)) AS result,
    typeof(
      CAST('1.0000000000' AS DECIMAL(38,10))
        + CAST('2.0000000000' AS DECIMAL(38,10))
    ) AS result_type;

Result

  3.000000000  decimal(38,9)

Datafusion

Query

  SELECT
    CAST('1.0000000000' AS DECIMAL(38,10))
      + CAST('2.0000000000' AS DECIMAL(38,10)) AS result,
    arrow_typeof(
      CAST('1.0000000000' AS DECIMAL(38,10))
        + CAST('2.0000000000' AS DECIMAL(38,10))
    ) AS result_type;

Result

  3.0000000000  Decimal128(38, 10)

Outcome

Notice that the scale is different in both.

IMO, a function could belong inside of the substrait core functions provided it is supported by >1 reasonably supported engines. If we can't find a few engines where they agree on the semantics, I would almost opt to remove the functions rather than codify a very engine-specific implementation.

If we are going to update these tests to lay claim to the intended semantics, can we also update the function's documentation to clarify why they have these semantics in the first place?

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.

I asked codex to investigate via docker and see if there are any others that support these semantics, and it found that both hive and spark look good, but Trino, DataFusion, MySQL, and ClickHouse all differ.

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.

Trino splits from Spark and Hive on this line the same way DataFusion does, and MySQL and ClickHouse are not on either side of it. Result types for dec(10,2) and dec(5,1) as operands, and for the pair on this line:

add multiply divide this line
the YAML dec(11,2) dec(16,3) dec(21,8) dec(38,9)
Spark 4.0.1, Hive 4.0.1 dec(11,2) dec(16,3) dec(17,8) dec(38,9)
Trino 483 dec(11,2) dec(15,3) dec(17,8) dec(38,10)
MySQL 8.4.11 dec(11,2) dec(15,3) dec(15,6) dec(39,10)
ClickHouse 26.8.6.5 Decimal(18,2) Decimal(18,3) Decimal(18,2) Decimal(38,10)

MySQL does not cap precision at 38, and ClickHouse widens a result to the next Decimal size — its Decimal(18,2) is Decimal64 — so those two rows are a different decimal type, not a different rule. Trino's decimal type is Substrait's, and it splits from Spark and Hive where DataFusion does: above 38 it keeps the scale instead of taking the surplus out of it, and it drops the +1 on multiply, as MySQL does. That rule above 38 comes from Gandiva's DecimalTypeUtil, which #151 names as the reference for these expressions.

So this line does codify one side, and merging it turns a difference into a failing case for Trino and DataFusion. I would rather a test file not decide that: the return expression is already released and these cases only write down what it says, so moving to the other side is a change to the YAML. If you would rather this PR not assert it at all, say so and I will drop the scale_reduction cases too.

divide is not that — there the YAML matches none of them. Its precision uses P2 where Gandiva, Hive, Spark and Trino use S2: on dec(10,2) / dec(9,0) the formula gives dec(29,12) and Spark, Hive and Trino all give dec(20,12). #1216 is open on exactly that: a formula to fix rather than a function to drop. I will take divide.test out of this PR.

On the documentation: opened #1223 — description on add, subtract and multiply, since that is what the generated function page prints. divide waits on #1216 and modulus never reaches the reduction, so both are left out; the alternative of one paragraph in the prose docs is in that PR's description.

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.

Are you using these substrait functions with one of these engines? Just trying to understand context better 🙂 Thanks!

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.

Not me, no. But Spark (via substrait-java), Isthmus and DuckDB all write add:dec_dec from functions_arithmetic_decimal for decimal +, each declaring its own result type: here dec(38,9) from Spark, dec(38,10) from the other two. So removing the functions would break those plans, and of the three, this line matches only Spark. I'd drop the scale_reduction cases, if you agree. Run and versions: probe/decimal-rules.

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.

If substrait core spec is following SQL 99, the tests should be based on that. @vbarua

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.

SQL:1999 section 6.26 leaves result precision implementation-defined. The 38-digit cap and scale reduction are Substrait's choices. PR 1223 now makes this distinction explicit. These tests cover the existing return expressions.

The divide return expression writes P2 where Gandiva, which substrait-io#151 names as the
reference these rules were fixed against, and Hive, Spark and Trino all write
S2: dec(10,2) / dec(5,1) is dec(21,8) here and dec(17,8) in those three. Cases
would settle that by assertion; substrait-io#1216 is open on it instead.
@coderabbitai

coderabbitai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review.

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: substrait-io/substrait/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 454cd3d9-e2e3-4be1-84ea-a14a9c5e413c
📥 Commits

Reviewing files that changed from the base of the PR and between 82fa3f5 and 5d0135a.

📒 Files selected for processing: 1
  • tests/coverage/baseline.json
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.

Summary

Summary by CodeRabbit

  • Tests
    • Added coverage for decimal addition, subtraction, and multiplication, including negative and zero values, precision and scale behavior, overflow errors, and null propagation.
    • Updated the test coverage baseline.

Added scalar test cases for decimal addition, subtraction, and multiplication. Updated the coverage baseline to reflect the new test and covered-variant counts.

Changes: Decimal arithmetic tests

Layer / File(s) Summary
Decimal arithmetic coverage
tests/cases/arithmetic_decimal/add.test, tests/cases/arithmetic_decimal/subtract.test, tests/cases/arithmetic_decimal/multiply.test, tests/coverage/baseline.json
Added cases for signed and zero values, result typing, scale reduction, overflow, and null propagation. The baseline now reports 1,338 tests and 294 covered function variants. The function overload count remains 534.

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Other

Merge Risk: Minimal · previous review 5d013

This change adds decimal arithmetic test cases and updates coverage totals. The supplied evidence identifies no concrete failure or misleading baseline that should block merging.

Architecture risk: Low · up to 0c8f6

The change affects 1 system.

Changed systems: tests

Architecture concerns
No architecture-level concerns identified.

Review details

Systems and components

  • observed — tests (service) was modified; 4 changed files map to changed impact.

Before / after behavior

  • observed — Modified behavior in tests/cases/arithmetic_decimal/add.test: Added decimal addition coverage for normal, negative, zero, scale-reduced, overflow, and nullable operands, including expected result types and overflow error behavior.
  • observed — Modified behavior in tests/cases/arithmetic_decimal/multiply.test: Added decimal multiplication coverage for basic values, negative and zero operands, scale reduction from requested precision 77/scale 20 to precision 38/scale 6, overflow failure, and nullable inputs with propagated result precision and scale.
  • observed — Modified behavior in tests/cases/arithmetic_decimal/subtract.test: Added decimal subtraction test cases for standard results, precision/scale calculation, scale reduction, overflow producing ERROR, and nullable operands producing typed null results.
  • observed — Modified behavior in tests/coverage/baseline.json: The baseline increases total_test_count from 1,309 to 1,330 and num_covered_function_variants from 283 to 286; num_function_variants remains 531.
Pre-merge checks: Passed 5
Passed Passed checks: 5 passed
Check name Status Explanation
Title check Passed The title uses conventional commit syntax and clearly describes the addition of decimal arithmetic test cases.
Description check Passed The description explains the tested operations, coverage, omitted divide cases, and the related discrepancy. No breaking change is indicated, so the BREAKING CHANGE section is not required.
Docstring Coverage Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

alexandrefimov added a commit to alexandrefimov/substrait-conformance-cases that referenced this pull request Sep 17, 2026
Review of substrait-io/substrait#1213 asks which engine the
functions_arithmetic_decimal return expressions model, whether more than one
agrees with them, and reports that Trino, MySQL and ClickHouse do not.
probe/decimal-rules answers all three: rules.py transcribes the Substrait,
Gandiva, Hive 4.0.1, Spark 4.0.1 and arrow-rs rules, each from its own source
so that agreement is computed rather than shared, and compares them over all
606841 decimal operand type pairs; engines.py runs that pull request's cases
on Spark, Hive, Trino, MySQL and ClickHouse, printing the type the formula
derives beside the one the engine derived.

add, subtract, multiply and modulus derive the same type as Gandiva, Hive and
default Spark on every pair. divide writes P2 where all three write S2, and
Trino answers S2 as well, so that row is the formula standing apart from its
own reference rather than an operation each engine defines its own way. It is
substrait-io/substrait#1216 seen from the other side.

MySQL and ClickHouse differ about the type rather than the rule: MySQL has no
bound at 38, and ClickHouse's precision is a storage width. Trino's decimal is
Substrait's, and it differs twice - p1 + p2 for multiply, and keeping the
scale above 38 where Spark and Hive borrow from it. That last one is the split
the review found between Spark and DataFusion, and it runs two against two
rather than one engine against a Spark setting.
alexandrefimov added a commit to alexandrefimov/substrait-conformance-cases that referenced this pull request Sep 17, 2026
Review of substrait-io/substrait#1213 asks which engine the
functions_arithmetic_decimal return expressions model, whether more than one
agrees with them, and reports that Trino, MySQL and ClickHouse do not.
probe/decimal-rules answers all three: rules.py transcribes the Substrait,
Gandiva, Hive 4.0.1, Spark 4.0.1 and arrow-rs rules, each from its own source
so that agreement is computed rather than shared, and compares them over all
606841 decimal operand type pairs; engines.py runs that pull request's cases
on Spark, Hive, Trino, MySQL and ClickHouse, printing the type the formula
derives beside the one the engine derived.

add, subtract, multiply and modulus derive the same type as Gandiva, Hive and
default Spark on every pair. divide writes P2 where all three write S2, and
Trino answers S2 as well, so that row is the formula standing apart from its
own reference rather than an operation each engine defines its own way. It is
substrait-io/substrait#1216 seen from the other side.

MySQL and ClickHouse differ about the type rather than the rule: MySQL has no
bound at 38, and ClickHouse's precision is a storage width. Trino's decimal is
Substrait's, and it differs twice - p1 + p2 for multiply, and keeping the
scale above 38 where Spark and Hive borrow from it. That last one is the split
the review found between Spark and DataFusion, and it runs two against two
rather than one engine against a Spark setting.
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