fix(sql): fix crash on incomplete expression after CASE ... END - #7290
Conversation
A binary operator with a missing right operand placed directly after a CASE expression's 'end' -- for example "sum(case when true then 1 else 0 end &)" -- crashed with a NullPointerException instead of reporting a syntax error. The expression parser's 'end' handler flushes the final CASE branch's value through the operator stack, leaving argStackDepth at 1, and then added the restored outer depth plus paramCount on top. That stale 1 leaked through, so after every CASE the depth counter ran one higher than the listener's real operand stack. The surplus is harmless for valid queries, but it let a trailing binary operator pass the arity guard in onNode while missing an operand. The tree builder then polled only the single real operand and assigned a null left-hand side, which later NPE'd during column-alias generation or function construction. Reset argStackDepth to 0 after the flush loop so the counter stays in step with the operand stack, since a CASE expression yields exactly one value. The arity guard now fires and the query reports "too few arguments for '&'" with the position pointing at the operator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
Reviewed at level 2. (Level 3 exists for a full 11-agent mission-critical pass; this diff is one production line plus three error-path tests, so level 2 is proportionate.) Verdict: Approve. The fix is correct, minimal, and well-targeted. The diagnosis in the PR body matches the code exactly, the depth arithmetic is sound, and there are no regressions for valid queries. What the change does (verified)
I confirmed this is unique to the CASE path: the sibling closing constructs — parenthesis/function (1335-1359) and array/bracket (1232-1266) — feed the flush-loop's An instrumented parser run (one of the review agents) recorded 136 CASE- CriticalNone. Moderate1. (Out-of-diff, pre-existing — not a regression) An adjacent malformed form still NPEs: a no-whitespace dot immediately after } else {
// attach dot to existing literal or constant
ExpressionNode en = opStack.peek(); // returns null after a top-level CASE
((GenericLexer.FloatingSequence) en.token)...; // NPE
}After a top-level I'm flagging it because it is the same user-facing symptom the PR sets out to eliminate ("crash on incomplete expression after CASE END") and the repo convention explicitly favors bundling related fixes into one PR rather than splitting. Note the dot handler already hardens one sibling case — lines 2028-2034 throw MinorNone. Member ordering is correct in both test files ( PR metadataClean. Title follows Conventional Commits and repeats the verb ( Summary
|
A dot placed directly after a CASE expression's 'end' -- for example "case when true then 1 else 0 end.foo" -- crashed the parser instead of reporting a syntax error. At the top level it threw a NullPointerException; inside a larger expression such as "1 + case ... end.foo" it threw a ClassCastException. The expression parser's dot handler peeks the operator stack and casts the top token to FloatingSequence to glue the dot onto a preceding literal. A CASE result is not a gluable literal token: it sits on the listener's operand stack, leaving either an empty operator stack (null peek -> NPE) or an unrelated pending operator whose String token fails the cast (-> ClassCastException). Guard both cases: extend the token only when the stack top is a FloatingSequence literal, otherwise raise "'.' is unexpected here" with the position on the dot. This mirrors the existing guard on the dot dereference path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
[PR Coverage check]😍 pass : 4 / 4 (100.00%) file detail
|
|
/azp run macwin |
|
Azure Pipelines successfully started running 1 pipeline(s). |
bluestreak01
left a comment
There was a problem hiding this comment.
Approve.
Reviewed at level 3 (full pass: change-surface map, fresh-context adversarial agents, and empirical verification — compiled the branch, ran the new tests, ran 20 adversarial probes, ran 424 regression tests).
Both fixes are minimal and correct, converting former NPE/CCE internal errors into precise syntax errors at the right positions, with no behavioral change for valid queries.
- Fix 1 (argStackDepth = 0 after the CASE end flush): the flush loop leaves exactly 1 (the single final-branch value), which is re-added via + paramCount; the old code carried that stale 1, inflating depth by +1 after every CASE ... END and defeating the arity guard. Verified across simple/searched/nested/embedded CASE shapes; the end-of-parseExpr flush has no absolute-value assertion, so the prior inflation was harmless for valid queries.
- Fix 2 (instanceof FloatingSequence guard in the dot handler): immutableOf returns a pooled FloatingSequence for lexer tokens, so valid a.b keeps a gluable token on top and the guard passes; quoted prefixes take a different arm. The old unconditional cast means any previously-valid input already satisfied the type, so the guard cannot regress a valid parse.
Empirical: new tests 10/10; regression ExpressionParserTest (300) + CaseFunctionFactoryTest (64) + SwitchFunctionFactoryTest (60) = 424/424; 20-input adversarial sweep of dangling tokens after END produced zero crashes.
0 blocking findings. The out-of-diff end.foo NPE flagged in the earlier review is now fixed in-diff by Fix 2. Metadata clean (Conventional Commits title, end-user-facing body with Scope and tradeoffs, Bug/SQL labels).
CriticalNone. ModerateNone. Minor1. Code comment violates the declarative-present rule (CHANGE B,
This justifies the line by describing the bug that would occur without it (a counterfactual against the old behavior) rather than stating a present property. The rest of the comment is fine. Suggested reframe, keeping the rationale but stating it declaratively: // A CASE expression yields exactly one value. The flush loop above already drained
// the final branch, and line 1938 re-adds that value via paramCount; resetting to 0
// here keeps argStackDepth equal to the listener's operand stack, so onNode's arity
// guard sees the true operand count for whatever token follows END.(The phrasing "left behind" / passive "is re-added below" in the same comment are milder instances of the same register.) This is the only finding that touches your own documented preference, so flagging it — but it's a comment nit, not a behavior issue. 2. Test-intent comments use "not an NPE…" contrast framing ( 3. End-to-end coverage asymmetry for the dot fix. The assertSyntaxError("select 1 + case when true then 1 else 0 end.foo", 43, "'.' is unexpected here");Not blocking — the path is covered at the parser level; this just adds full-compiler symmetry. 4. Idiom inconsistency (trivial). The new code at line 1112 uses the Java 17 pattern variable ( Observations (out of scope — not findings against this PR)
SummaryVerdict: Approve. Both fixes are correct, minimal, and well-targeted. CHANGE B's depth arithmetic is sound by static analysis (the +1 double-count is real and the reset is exactly right), and CHANGE A's guard provably cannot regress a valid query (it narrows |
Summary
Two malformed expressions involving
CASE ... ENDcrashed the query with aninternal error instead of returning a syntax error. Over HTTP/PGWire this
surfaced to the user as an internal error rather than a clear message about the
malformed SQL. Both stem from the expression parser mishandling a token that
immediately follows
END. The two fixes are independent and each has its ownsection below.
Fix 1: dangling binary operator after
ENDProblem
A binary operator with a missing right operand directly after a
CASE ... ENDexpression crashed with a
NullPointerException. For example:A simple operand in the same position (e.g.
1 &) already reported a propererror, so only the
CASE ... ENDform was affected.Root cause
The expression parser's
end-keyword handler flushes the final CASE branch'svalue through the operator stack, which leaves the local
argStackDepthcounterat 1. The code right after assumes that counter was already cleared (its own
comment says so) and adds the restored outer depth plus
paramCounton top. Thestale 1 leaked through, so after every
CASE ... ENDthe parser's depth counterran one higher than the listener's real operand stack.
That surplus is harmless for valid queries, but it defeated the arity guard
argStackDepth < node.paramCountinonNode: a trailing binary operator (whichneeds two operands) saw an inflated depth and passed the guard. The tree builder
then polled only the single real operand and assigned a null left-hand side,
which later dereferenced to an NPE during column-alias generation or function
construction.
Fix
Reset
argStackDepthto 0 after the flush loop so the counter stays in stepwith the operand stack, since a
CASEexpression yields exactly one value. Thearity guard now fires and the query reports:
with the position pointing at the offending operator.
Fix 2: dangling dot after
ENDProblem
A dot directly after
CASE ... ENDcrashed the parser. At the top level itthrew a
NullPointerException:and inside a larger expression it threw a
ClassCastException:Root cause
The parser's dot handler peeks the operator stack and casts the top token to
FloatingSequenceto glue the dot onto a preceding literal (thea.bqualified-name case). A
CASEresult is not a gluable literal token: it sits onthe listener's operand stack, leaving either an empty operator stack (
peek()returns null -> NPE) or an unrelated pending operator whose
Stringtoken failsthe cast (->
ClassCastException).Fix
Extend the token only when the stack top is a
FloatingSequenceliteral;otherwise raise a syntax error with the position on the dot:
This mirrors the existing guard on the dot-dereference path.
Scope and tradeoffs
CASEexpression. Theadjustment only removes a previously-harmless over-count, so valid queries
produce the same parse as before; the only behavioral change is that the
malformed form now returns a syntax error instead of an NPE.
table.columnreference. Itadds a null/type check on a path that previously assumed a
FloatingSequencewas present. Valid qualified references are unaffected; only the
previously-crashing inputs change behavior.
defensive null-check downstream; each prevents the malformed expression node
from being built in the first place.
Test plan
ExpressionParserTest: addedtestCaseDanglingOperatorAfterEndandtestCaseDanglingOperatorAfterEndNested(Fix 1), plustestCaseDanglingDotAfterEndand
testCaseDanglingDotAfterEndInExpression(Fix 2), alongside the existingtestCaseDanglingOperatorAfter{Case,Else,Then,When}family.SqlParserTest: addedtestCaseDanglingOperatorAfterEnd(Fix 1) andtestCaseDanglingDotAfterEnd(Fix 2), covering the reportedselectscenariosend to end and asserting a clean error rather than a crash.
post-fix all pass.
SqlParserTest(1083),WhereClauseParserTest(846),FunctionParserTest(120),CaseFunctionFactoryTest(64),SwitchFunctionFactoryTest(60): 2,173 tests, all passing, confirming validtable.columnandCASEparsing is unaffected.🤖 Generated with Claude Code