Repository navigation
[CALCITE-5907] RexSimplify replaces a comparison with a BOOLEAN literal by a non-BOOLEAN operand - #5295
Conversation
| RexCall call = (RexCall) term; | ||
| if (call.getOperands().get(0).isAlwaysTrue()) { | ||
| if (call.getOperands().get(0).isAlwaysTrue() | ||
| && call.getOperands().get(1).getType().getSqlTypeName() == SqlTypeName.BOOLEAN) { |
There was a problem hiding this comment.
I hope you have checked that this works for any nullability
There was a problem hiding this comment.
This is bullshit.
CALCITE-5907 is invalid, because you should never have "varchar-column = bool-literal" in a Rex node. If we see non-boolean terms in a boolean expression, we should throw.
There was a problem hiding this comment.
Let me share my understanding of this part—though I might be mistaken: I agree that a well-typed Rex tree should never contain =(varchar, bool), and that SqlValidator is the right place to enforce comparability. However, that invariant is not enforced at the Rex layer itself: RexBuilder.makeCall(EQUALS, ...) performs no operand-family check (EQUALS' return-type inference is unconditionally BOOLEAN), so downstream systems that construct expressions programmatically (e.g. Flink, the reporter of https://issues.apache.org/jira/browse/FLINK-27402) can and do produce such nodes.
The bug here is not the input's existence, but that the simplifier turned a valid BOOLEAN condition into a non-BOOLEAN one. A rewrite rule must never produce output less valid than its input; leaving the equality untouched is the minimal, safe behavior. Throwing from a simplification hot path would turn a silent robustness gap into planner crashes for rels that currently plan and execute fine, and conflates rewriting with validation, Calcite keeps those separate (cf. RexChecker/RexUtil.verify).
There was a problem hiding this comment.
Ah, it's a malformed =, not a malformed boolean term in AND or OR. That's not OK, but it's understandable. I think it would break a lot of clients it Calcite started to reject such = calls.
So I think you should make isAlwaysTrue more defensive - push the 'type is boolean' check into that method. Thus for the string literal 'true', isAlwaysTrue will return false.
Ditto isAlwaysFalse and any analogous methods.
There was a problem hiding this comment.
Thanks for the suggestion @julianhyde . isAlwaysTrue / isAlwaysFalse are already defensive: both RexLiteral and RexCall return false unless the expression has BOOLEAN type, so the string literal 'true' already returns false.The call-site check guards the other operand (the one that would replace the =` term), so it can't be pushed intoisAlwaysTrue. Without it,AND(=(x, TRUE), ...)with VARCHAR x would simplify toAND(x, ...) which is the CALCITE-5907 bug. Please correct me if I am wrong~
There was a problem hiding this comment.
I hope you have checked that this works for any nullability
Good point @mihaibudiu . The check only looks at the type name, not nullability, and it is safe for both cases: =(x, TRUE) → x with nullable x (if x is NULL, =(NULL, TRUE) and bare NULL are both UNKNOWN, i.e. FALSE under UNKNOWN_AS_FALSE) and with NOT NULL x (trivially equivalent).
I had added NOT NULL variants of VARCHAR, INTEGER and BOOLEAN to testSimplifyAndEqualityTrue to cover this.
There was a problem hiding this comment.
Maybe just check that the operands have the same type? Your change is making RexSimplify more complicated, because we are accommodating a client that is sending us garbage. That is tech debt right there.
There was a problem hiding this comment.
Or call a 'isWellFormed' method before entering the loop. It sends a strong message that "this code doesn't run on garbage".
There was a problem hiding this comment.
Good idea, done @julianhyde . The loop now starts with a well-formedness guard: unless both operands of = have the same type name (e.g.=(x, TRUE) with VARCHAR or INTEGER x is malformed), it breaks without simplifying. This is equivalent to the previous check, sinceisAlwaysTrue() can only be true for a BOOLEAN expression, but it states the precondition up front. I compare getSqlTypeName() rather than RelDataType.equals, because TRUE is NOT NULL and equals would compare nullability, blocking the valid simplification of=(nullable Bool X, TRUE).
|
Since this pr is approved and should had addressed comments, if no other objections I would merge it in few days. cc @julianhyde @mihaibudiu |
vlsi
left a comment
There was a problem hiding this comment.
The same defect remains when the operands are swapped. simplifyComparison checks only that o0 is BOOLEAN (RexSimplify.java#L751), and Comparison.of then swaps the operands so that cmp.ref is the VARCHAR one. With this PR applied, in every RexUnknownAs mode:
| Input | Result |
|---|---|
=(true, ?0.varchar0) |
?0.varchar0 |
AND(=(true, ?0.varchar0), =(true, ?0.varchar1)) |
AND(?0.varchar0, ?0.varchar1), of type VARCHAR |
AND(=(false, ?0.varchar0), =(false, ?0.varchar1)) |
AND(NOT(?0.varchar0), NOT(?0.varchar1)) |
AND(<>(false, ?0.varchar0), <>(false, ?0.varchar1)) |
AND(?0.varchar0, ?0.varchar1) |
A front end that builds =(TRUE, c) without a cast, as Flink does in the JIRA, still gets the plan CALCITE-5907 reports for WHERE TRUE = c AND TRUE = d. testSimplifyAndEqualityTrue needs these four inputs too.
The JIRA summary, and with it the PR title and the squashed commit, does not name the symptom, and the bug is wider than AND. Something like RexSimplify replaces a comparison with a BOOLEAN literal by a non-BOOLEAN operand covers the swapped and FALSE forms as well.
| if (call.getOperands().get(0).getType().getSqlTypeName() | ||
| != call.getOperands().get(1).getType().getSqlTypeName()) { | ||
| break; | ||
| } |
There was a problem hiding this comment.
Julian suggested an isWellFormed check (comment). A helper of that shape, called here and from simplifyComparison before L751, would also stop the swapped-operand case from the review body, and its name would say why the term is left alone. As written, the guard exists only in this loop and carries no comment.
This also changes plans for ANY: under UNKNOWN_AS_FALSE, AND(=($0, true), ?0.bool1) with $0 of type ANY used to become AND($0, ?0.bool1) and now stays unchanged. It needs a mention in the JIRA and a test, since ITEM on a MAP<VARCHAR, ANY> column, such as MongoDB's _MAP, produces ANY.
| // "=(x, true)" can be simplified to "x" only if x has BOOLEAN type; | ||
| // there is no implicit cast from VARCHAR or INTEGER, so the equality | ||
| // must be retained |
There was a problem hiding this comment.
The stated reason is not accurate: Calcite does coerce VARCHAR and numeric types to BOOLEAN (SqlTypeCoercionRule). The equality has to stay because replacing it with x would put a non-BOOLEAN operand into the AND. Suggest: "=(x, true)" simplifies to "x" only if x is BOOLEAN; otherwise the AND would get a non-BOOLEAN operand.
| checkSimplifyUnchanged( | ||
| and(eq(vVarchar(0), trueLiteral), | ||
| eq(vVarchar(1), trueLiteral))); |
There was a problem hiding this comment.
With paranoid mode on (RexProgramBuilderBase turns it on for every test), a regression here throws ClassCastException from RexInterpreter inside RexSimplify.verify and never shows the simplified expression. Turning paranoid mode off for the non-BOOLEAN inputs, as testSimplifyItemRangeTerms does with simplify = simplify.withParanoid(false), makes checkSimplifyUnchanged print the expression it got instead.
Nit: the second argument of and( is indented at the same level as and( itself, so it reads like a second argument of checkSimplifyUnchanged; the same applies at L2487, L2494, L2497, and L2500.
Good catch, thank you @vlsi ! Regarding the summary for the Jira issue and PR, I agree that your suggestion is reasonable. I have updated the wording to "RexSimplify replaces a comparison with a BOOLEAN literal by a non-BOOLEAN operand," and this change will be reflected during the subsequent merge.(I look forward to your review~). |
|
@vlsi Are you happy with current changes? if there no other objections I would merge it in few days. |
| /** Returns whether a comparison between {@code left} and {@code right} | ||
| * is well-formed, that is, its operands have the same type. | ||
| * | ||
| * <p>Comparisons whose operands have different types (e.g. a VARCHAR | ||
| * column compared with the BOOLEAN literal TRUE) never result from | ||
| * Calcite's own SQL translation, but clients that build Rex trees | ||
| * programmatically sometimes create them; such comparisons are left | ||
| * unsimplified. */ | ||
| private static boolean isWellFormed(RexNode left, RexNode right) { |
There was a problem hiding this comment.
The javadoc says that comparisons whose operands have different types "never result from Calcite's own SQL translation". Nothing in RexSimplify or its tests backs this, and no change to the converter would update it. Both call sites need only "both operands are BOOLEAN": in simplifyComparison o0 is already BOOLEAN, and in the AND loop only a BOOLEAN node can be isAlwaysTrue(). A helper named for that check, such as isBooleanComparison(left, right) with the javadoc Returns whether both operands of a comparison have type BOOLEAN., behaves the same at both sites and needs no claim about where such trees come from. isWellFormed names a general property, so a later caller could use it to skip valid simplifications such as INTEGER compared with BIGINT. The first sentence also says "the same type" where the check compares SqlTypeName.
There was a problem hiding this comment.
Thanks for your suggestion: I renamed the helper to isBooleanComparison(left, right) with your javadoc (the unverified provenance claim is gone; at simplifyComparison the call collapsed to a single condition, behavior unchanged).
| /** Test case for | ||
| * <a href="https://issues.apache.org/jira/browse/CALCITE-5907">[CALCITE-5907] | ||
| * Unexpected boolean expression simplification for And expression</a>. */ |
There was a problem hiding this comment.
The link text should match the JIRA summary, which is now RexSimplify replaces a comparison with a BOOLEAN literal by a non-BOOLEAN operand.
| // Paranoid mode throws from RexSimplify.verify for these non-BOOLEAN | ||
| // inputs, so turn it off to let checkSimplifyUnchanged report the | ||
| // simplified expression instead |
There was a problem hiding this comment.
Paranoid mode throws only when the simplified expression evaluates differently from the original, not for these inputs as such. Suggest: // With paranoid mode on, a wrong simplification of these non-BOOLEAN inputs fails inside RexSimplify.verify with an exception that hides the result; turn it off so that checkSimplifyUnchanged prints the simplified expression.
| // "=$0, true" where $0 has type ANY is no longer simplified to "$0"; | ||
| // ITEM on a MAP<VARCHAR, ANY> column (e.g. MongoDB's _MAP) produces ANY, | ||
| // which may be BOOLEAN at runtime |
There was a problem hiding this comment.
"=$0, true" is missing its parentheses. "is no longer simplified" describes the change rather than the rule, and "which may be BOOLEAN at runtime" gives the opposite reason: the term stays because a value of type ANY need not be BOOLEAN. Suggest: // "=($0, true)" stays when $0 has type ANY, since its value need not be BOOLEAN; ITEM on a MAP<VARCHAR, ANY> column, such as MongoDB's _MAP, has type ANY.
|
|
@xuzifu666 I squashed the branch into one commit, 2ff3fb9 and updated the PR description. The code is the same as in 939e7c1. I guess we can merge this once CI succeeds. Thank you. |
…al by a non-BOOLEAN operand



Jira Link
CALCITE-5907
Changes Proposed
RexSimplify replaced a comparison of an expression
xwith a BOOLEAN literal byxorNOT(x)without checking thatxwas BOOLEAN, soAND(=(?0.varchar0, true), =(?0.varchar1, true))becameAND(?0.varchar0, ?0.varchar1), an AND of VARCHAR operands. Clients that build Rex trees directly produce such comparisons: Flink hit it withWHERE b = true AND c = trueon a VARCHAR columnc(FLINK-27402).Two places did the rewrite. The
EQUALSloop insimplifyAnd2ForUnknownAsFalsereplaced=(x, TRUE)and=(TRUE, x)inside an AND, under unknown-as-FALSE only.simplifyComparisonchecked only that the first operand was BOOLEAN, so it rewrote the forms with the literal first in everyRexUnknownAsmode:=(TRUE, x)tox,=(FALSE, x)toNOT(x), and<>(FALSE, x)tox, becauseComparison.ofswapped the operands.Both places now rewrite only when both operands have type BOOLEAN (
isBooleanComparison). The check comparesSqlTypeName, so=(x, TRUE)with a nullable BOOLEANxis still simplified. It cannot move intoisAlwaysTrue, which already returns false for a non-BOOLEAN literal, because it guards the other operand, the one that replaces the comparison. Throwing on a VARCHAR compared with a BOOLEAN would break clients that build such calls today.One result changes for an input that is not malformed: under unknown-as-FALSE,
AND(=($0, true), ?0.bool1)with$0of typeANYused to becomeAND($0, ?0.bool1)and is now left unchanged, since a value of typeANYneed not be BOOLEAN.ITEMon aMAP<VARCHAR, ANY>column, such as MongoDB's_MAP, has typeANY.RexProgramTest.testSimplifyAndEqualityTrue(new) fails without the change. It covers VARCHAR and INTEGER operands, nullable and NOT NULL, inside an AND with the literal second, the=with TRUE and FALSE and<>with FALSE forms with the literal first, and theANYcase, and checks that BOOLEAN operands are still simplified. Paranoid mode is off for the non-BOOLEAN inputs so that a regression prints the simplified expression instead of failing insideRexSimplify.verify.🤖 Generated with Claude Code