Skip to content

[CALCITE-7722] RexSimplify IS [NOT] NULL on a safe operator with Strong policy ANY and unsafe operands can be further simplified - #5184

Open
rubenada wants to merge 3 commits into
apache:mainfrom
rubenada:CALCITE-7722
Open

[CALCITE-7722] RexSimplify IS [NOT] NULL on a safe operator with Strong policy ANY and unsafe operands can be further simplified#5184
rubenada wants to merge 3 commits into
apache:mainfrom
rubenada:CALCITE-7722

Conversation

@rubenada

Copy link
Copy Markdown
Contributor

Jira Link

CALCITE-7722

Changes Proposed

RexSimplify.simplifyIsNotNull / simplifyIsNull currently bail out of the whole simplification when the input RexCall is not fully safe (i.e. isSafeExpression(a) == false). This is stricter than necessary for operators with Strong.Policy.ANY, where IS [NOT] NULL(f(x, y, ...)) is semantically equivalent to IS [NOT] NULL(x) OR/AND IS [NOT] NULL(y) OR/AND ... — the operator itself does not need to be evaluated to compute the result.

Example (regression for downstream projects such as Hive):

Before (≤ 1.34):
IS NOT NULL(CAST(key AS DOUBLE) + 1.0) → IS NOT NULL(CAST(key AS DOUBLE))

After (≥ 1.35):
IS NOT NULL(CAST(key AS DOUBLE) + 1.0) → (unchanged)

The rewrite is dropped because CAST(key AS DOUBLE) + 1.0 is a non-lossless cast wrapped in a +, so isSafeExpression returns false — even though + is Strong.ANY and the distribution is a valid rewrite regardless of the outer call's safety.

Proposed fix:

In the Strong.Policy.ANY branch, replace the full-tree safety requirement with a shallow safety check on the outer call. Because the branch rewraps the input as IS [NOT] NULL(operand_i) and joins the results with OR/AND, add a per-operand guard to prevent RexCall.isAlwaysTrue()/isAlwaysFalse() from silently collapsing a rewrapped IS [NOT] NULL(op) whose operand is typed non-nullable but not fully safe (which would otherwise erase a throwing subexpression such as 1 / 0).

Strong.Policy.NOT_NULL and CUSTOM continue to require full-tree safety, since those branches drop the subtree entirely.

Comment thread core/src/main/java/org/apache/calcite/rex/RexSimplify.java Outdated
Comment thread core/src/main/java/org/apache/calcite/rex/RexSimplify.java Outdated
Comment thread core/src/main/java/org/apache/calcite/rex/RexSimplify.java Outdated
Comment thread core/src/test/java/org/apache/calcite/rex/RexProgramTest.java Outdated
Comment thread core/src/test/java/org/apache/calcite/rex/RexProgramTest.java

@julianhyde julianhyde 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.

-1

This change should use Strong.

It is frustrating when you log an issue and provide a PR at the same time. It removes the chance to have design discussions.

@rubenada rubenada changed the title [CALCITE-7722] Simplify IS NULL / IS NOT NULL expressions by removing operations if they do not affect the nullability [CALCITE-7722] RexSimplify IS [NOT] NULL over a Strong.ANY call is no longer simplified when the call itself is unsafe Aug 17, 2026
@rubenada

Copy link
Copy Markdown
Contributor Author

@julianhyde the patch is actually using Strong. I have update the Jira to make it more accurate with the actual fix proposal

@rubenada rubenada changed the title [CALCITE-7722] RexSimplify IS [NOT] NULL over a Strong.ANY call is no longer simplified when the call itself is unsafe [CALCITE-7722] RexSimplify IS [NOT] NULL on a safe operator with Strong policy ANY and unsafe operands can be further simplified Aug 18, 2026

@thomasrebele thomasrebele 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.

I find the idea of "shallow-safety" interesting. It would be nice if we could support simplifying ((1/0)+1) IS NOT NULL.

Side note: This PR makes me think that there are some limitations in the current code for representing the properties of the operators (e.g., CALCITE-7264). I think there was a discussion somewhere whether the possibility that a RexNode may throw an exception could be included in the type system. The safety and shallow-safety of an operator would be attached to the operator itself instead of a visitor (see Julian's comment).

The concept of "safety" and "shallow-safety" could be collapsed, i.e., an operator is safe iff its evaluation does not throw an exception. A RexNode expression is safe iff all its operators are safe. Might be a bit easier to understand than "shallow-safety".


// The outer PLUS is shallow-safe, but the div(1, 0) is not, so the peel is therefore suppressed
checkSimplifyUnchanged(isNotNull(plus(div(literal(1), literal(0)), vIntNotNull())));
checkSimplifyUnchanged(isNull(plus(div(literal(1), literal(0)), vIntNotNull())));

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.

The previous version of the PR stated the reason for not simplifying this: otherwise it would be simplified to true because RexCall#isAlwaysTrue() returns true for IS NULL(1/0). This sounds like a bug in RexCall.isAlwaysTrue() to me. Could we make RexCall#isAlwaysTrue check for the safety of the IS [NOT] NULL operand? Potentially in a follow-up ticket if this turns out to be too complex.

@rubenada rubenada Aug 18, 2026

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.

Correct, the problem is that RexCall#isAlwaysTrue/isAlwaysFalse of an IS [NOT] NULL on an expression that is of a non-nullable type will always return a short-circuited true/false, without considering if the expression is safe, so we can hide a rutime issue; e.g. IS NOT NULL of a division by zero of type non-nullable would be collapsed to true. IMO this is a different improvement, I think this should be handled separately, as a follow-up ticket.

UPDATE: on a second thought, maybe if we include it in here we can simplify the current patch, checking....

UPDATE II: actually, fixing the RexCall isAlwaysTrue / isAlwaysFalse simplification for IS [NOT] NULL so that it considers isSafe makes the patch simpler, and enables more simplifications; so I've just applied it on the last commit. Thanks for pointing this out @thomasrebele !

Comment thread core/src/test/java/org/apache/calcite/rex/RexProgramTest.java Outdated
@julianhyde

Copy link
Copy Markdown
Contributor

+1

(This overrides my earlier '-1'. Thank you to @rubenada for explaining in Jira the purpose of this change.)

@julianhyde

Copy link
Copy Markdown
Contributor

@thomasrebele Yes, we could use better terminology. "Safe", "Strict" and "Strong" are parallel concepts, dealing with exceptions (non-termination), whether arguments get evaluated, and null values.

It's necessary to distinguish an operator's propagation characteristics (e.g. whether it returns null if and only if both its arguments are null) from the characteristics of an expression (e.g. whether it may return null, or may throw).

Even if you are able to find a good terminology that the community agrees on, implementing it is a challenge - you would have to modify the source code, potentially renaming classes, and deal with the fact that there are closed issues and commits that cannot be retrospectively changed.


// Operators with checked arithmetic: they cannot be peeled because they are not "safe"
// (they will throw at runtime in case of overflow)
checkSimplifyUnchanged(

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.

@thomasrebele @mihaibudiu I have rebased with the latest main branch (which includes the patch "CALCITE-7725 Review safety of checked arithmetic operators").
As expected, I had to adjust the tests here, since the checked arithmetic expressions are no longer simplified (because they are not considered safe, i.e. it is now working as expected).
When you have a bit of time, could you please take another look at the PR?

@sonarqubecloud

Copy link
Copy Markdown

}
switch (Strong.policy(a)) {
case NOT_NULL:
// Drops the subtree; require full-tree safety so we don't hide runtime errors

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.

should the comment be inside the braces?

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.

Could be. IMO it talks about the general NOT_NULL case behavior, so it doesn't seem out of place currently.

* <p>Non-{@link RexCall} nodes are always shallow-safe (they cannot
* throw at their own level).
*/
boolean isShallowSafe(RexNode node) {

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.

Can this be called isOperatorSafe? After all, it's a property of the operator.

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.

I'd prefer to leave the current name, since the method theoretically could accept any type of RexNode, and also to avoid confusion with SqlOperator#isSafeOperator

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.

An alternative might be isOuterSafe.

*/
@Test void testSimplifyIsNotNullDistributesAcrossStrongOpWithLossyCast() {
// "(CAST(?0.varchar0):INTEGER + 1) IS NOT NULL" ==> "IS NOT NULL(CAST(?0.varchar0):INTEGER)"
// The outer PLUS is strong AND shallow-safe; distribution keeps the

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.

what is "distribution"?

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.

It means the IS_NOT_NULL(expr) is "distributed" among the operands of the expression: IS_NOT_NULL(operand0) AND .... IS_NOT_NULL(operandN)

isNull(plus(cast(vVarchar(), tInt(true)), literal(1))),
"IS NULL(CAST(?0.varchar0):INTEGER)");

// Confirm this is consistent with same expression without CAST

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.

I don't know which cast this is talking about

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.

It means the CAST of the tests above (which contains similar IS_NOT_NULL on PLUS expression, but with CAST operand)

isNotNull(plus(cast(vVarchar(), tDate(true)), interval(10, TimeUnit.DAY))),
"IS NOT NULL(CAST(?0.varchar0):DATE)");
checkSimplify(
isNull(plus(cast(vVarchar(), tDate(true)), interval(1, TimeUnit.MONTH))),

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.

Isn't this wrong? Arithmetic on dates should be checked.
Or maybe this kind of code can never be generated?

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.

This seems to be consistent with how these operations are defined atm.
Is arithmetic on date/interval operands supposed to behave different compared to other operand types?

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.

Yes, arithmetic on dates and intervals should not wrap around. For integers you can argue that wrap around may make sense (e.g., that's the Java semantics), but for dates or intervals it produces no meaningful results. Perhaps this is another bug -- an omission in ConvertToChecked?

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.

Yes, in that case I'd consider that a separate issue, to be handled in a follow-up ticket

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.

I agree with handling this in a follow-up ticket.

@thomasrebele thomasrebele 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.

LGTM

* <p>Non-{@link RexCall} nodes are always shallow-safe (they cannot
* throw at their own level).
*/
boolean isShallowSafe(RexNode node) {

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.

An alternative might be isOuterSafe.

isNotNull(plus(cast(vVarchar(), tDate(true)), interval(10, TimeUnit.DAY))),
"IS NOT NULL(CAST(?0.varchar0):DATE)");
checkSimplify(
isNull(plus(cast(vVarchar(), tDate(true)), interval(1, TimeUnit.MONTH))),

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.

I agree with handling this in a follow-up ticket.

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.

4 participants