Skip to content

fix(engine) #5626: walk the subquery bodies the parse-time checks claimed were validated elsewhere - #5642

Merged
lvca merged 11 commits into
mainfrom
fix/issue-5626-subquery-parse-validation
Jul 31, 2026
Merged

fix(engine) #5626: walk the subquery bodies the parse-time checks claimed were validated elsewhere#5642
lvca merged 11 commits into
mainfrom
fix/issue-5626-subquery-parse-validation

Conversation

@lvca

@lvca lvca commented Jul 31, 2026

Copy link
Copy Markdown
Member

Closes #5626.

CypherExpressionWalker stopped at a subquery held as text, and its class Javadoc explained why: "those are parsed on their own and validated then, and there is no AST here to walk." The issue is right that the second half is false. It turns out a fourth body was escaping too, for a different reason, and two whole predicates were escaping for a third.

What actually escaped

shape why the walk did not reach it
EXISTS { } / COUNT { } / COLLECT { } body kept as text, no AST to descend into
CALL { } walkClause's default arm skipped it, on the same "validated when it is parsed" belief
WHERE EXISTS { ... } the builder wrapped it in an anonymous BooleanExpression, a leaf to the walk
WHERE isEmpty(x) - a function call as a bare predicate same: a second anonymous adapter

The last two are the trap this class's own Javadoc warns about ("the default arms treat an unrecognised type as a leaf ... with nothing failing to say so"), and they meant the reproducer in the issue was under-stating the reach: the first of the three forms was invisible even before the body question.

The fix

The three expressions now carry their body as a CypherStatement alongside the text, and the walk descends into it - as it now does into a CALL { } body.

The issue's second option, taken, with one correction to its rationale: this does not remove the re-parse. What executes is not the body as written but the body after CorrelatedSubqueryRewriter has pinned the outer row into it, which differs row by row, so the text has to stay and the runtime parse with it. What the AST costs is therefore not free - but it is not a second lex either: it is built from the parse tree ANTLR already produced for the body (ctx.queryWithLocalDefinitions(), or the patternList/whereClause of a bare-pattern body), so it is one tree walk at parse time, once per distinct query text.

The two anonymous adapters are gone. Both were byte-for-byte BooleanCoercionExpression - same evaluate, same evaluateTernary, same getText - so they are now that named node, which the walk already knew. The function-call one turned out to be wholly redundant with the generic arm three lines below it (both call parseExpressionFromText(expr6) and wrap the result), and its removal also fixes a latent NPE: it wrapped a possibly-null expression, where the generic arm falls back properly.

A body is a scope of its own. Reusing the enclosing varTypes map for it would have been wrong in both directions, so the visitor re-binds itself at the boundary (Visitor.forNestedStatement): the kinds the body declares, over the ones it inherits, with any name the body binds by any means dropped from what it inherits. That is what keeps

MATCH p = (a:P)-[:KNOWS]->(b:P) CALL { MATCH (p:P) RETURN p.name AS n } RETURN n

legal - an implicit CALL { } imports nothing, so the body's p is its own node and p.name is a plain property read, not property access on the outer path.

Two more expression positions the same traversal now reaches: procedure CALL arguments and the LOAD CSV FROM url.

Behaviour change

Same shape as #5602's: a bad call inside a subquery body is now rejected before the query starts instead of failing at runtime - or, where the subquery matched no row, instead of quietly answering false / 0 / []. No check is new; only its reach.

One existing test changed. Issue5228CallMatchEdgeLabelEntityTest probed CALL () { MATCH (b:EdgeLabel) RETURN labels(b), type(b) } and asserted zero rows; type(b) on a node is the type error it already was outside a subquery, so that half of the probe now asserts the rejection - and asserts it identically inside and outside, which is the point of the issue.

Verified against Neo4j

Neo4j runs semantic analysis over the whole query including subquery bodies: abs('x') is Type mismatch: expected Float or Integer but was String at compile time wherever it is written, and type(node) is Type mismatch: expected Relationship but was Node. Neither is a Cypher usage mistake on the reporter's side.

Tests

CypherSubqueryParseTimeValidationIssue5626Test - 22 tests, all under EXPLAIN so the query is parsed and planned but never executed:

  • the reproducer for each of the four body kinds, each asserting the same class and message as the identical call in the outer WHERE
  • every check of the phase reaching inside, not only the type one: unknown name, wrong arity, property access on a path variable
  • the positions a body can be written in: bare pattern, negated, inside a conjunction, in a WITH ... WHERE, nested one subquery inside another, a UNION branch of a CALL body
  • procedure CALL arguments and LOAD CSV FROM
  • no false rejection: a correlated body, variable kinds read from the body that declares them, the unimported-shadowing case above, and the converse (an outer path variable is still a path in a body that does not re-declare it)
  • EXISTS / COUNT / COLLECT still evaluate to the same answers after the adapter swap

Green: engine query.** + function.** 11311/0, query.opencypher.** 7808/0, bolt 93/0, the server Cypher HTTP ITs 20/0.

Follow-up worth filing separately

The per-row re-parse is still there, and is a much larger cost than anything here: EXISTS { } lexes, parses, validates and plans its body once for every outer row. Now that the body exists as an AST, correlating it as an AST - binding the outer row into parameters rather than splicing text - would remove that, and would also retire CorrelatedSubqueryRewriter's text scanning, which is where #4995, #5165, #5461 and #5464 all came from.

…imed were validated elsewhere

CypherExpressionWalker stopped at EXISTS { }, COUNT { } and COLLECT { }, and its Javadoc said those bodies
were "parsed on their own and validated then". They are not: each keeps its body as text and re-parses it
once per outer row, absorbing a failure into the expression's neutral value. A CALL { } body was never
handed to this phase at all.

So abs('x') was rejected before the query ran when written in a WHERE and accepted one level in, which is
the clause-dependent asymmetry #5602 set out to remove.

The three expressions now carry their body as an AST built from the parse tree ANTLR already produced, and
the walk descends into it and into a CALL { } body. A body is a scope of its own, so a check that reads
variable kinds re-binds itself to what the body declares over what it inherits.

Two anonymous BooleanExpression adapters - one for an EXISTS written as a bare WHERE predicate, one for a
function call used as one - were leaves for the same reason and are now the equivalent
BooleanCoercionExpression. Procedure CALL arguments and the LOAD CSV url are walked too.
@mergify

mergify Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

…ry-parse-validation

# Conflicts:
#	docs/release-26.8.1.md
@codacy-production

codacy-production Bot commented Jul 31, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 54 complexity

Metric Results
Complexity 54

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review: PR #5642 - walk subquery bodies at parse time

Read the full diff and surrounding source (CypherExpressionWalker, CypherSemanticValidator, BooleanCoercionExpression, CallClause, SimpleCypherStatement). This is a clean, well-scoped fix. The problem statement is precise, the AST-alongside-text approach is the right one, and the test suite is unusually complete: it covers each body kind, each check (type / arity / unknown-fn / path-access), every syntactic position a body can appear in, plus explicit no-false-rejection cases. Nice work.

What I verified

  • BooleanCoercionExpression really is byte-for-byte equivalent to the two removed anonymous adapters. evaluate (Boolean.TRUE.equals(value) == value instanceof Boolean && (Boolean) value), evaluateTernary (null->null, Boolean->value, else->TRUE), and getText all match. The EXISTS adapter had no evaluateTernary override, but since EXISTS always yields a Boolean, the added ternary path is never reached with a non-boolean, so the swap is behavior-preserving.
  • The latent NPE claim holds. The old function-call adapter wrapped a possibly-null parseExpressionFromText(expr6) and would NPE in evaluate; the generic arm three lines down guards with if (parsedExpr != null) and falls back. Genuine improvement.
  • No cross-module fallout from the Consumer -> Visitor signature change: CypherExpressionWalker.walk has no callers outside CypherSemanticValidator.
  • getClausesInOrder() is never null (SimpleCypherStatement defaults it to new ArrayList<>()), so collectNestedDeclarations iterating it without a guard is safe, though slightly inconsistent with walk(), which does null-check. Cosmetic.

I could not execute the test suite in this review environment (test runs required an approval that was not available here), so I am relying on the reported green runs for the dynamic behavior.

One substantive question - scope inheritance into an implicit CALL { }

nestedVarTypes inherits every non-shadowed outer variable kind into the body scope, for all nested statements. For the three correlated expressions (EXISTS / COUNT / COLLECT) that is exactly right. For an implicit CALL { } - which the PR description itself notes "imports nothing" - the body arguably should not see outer variables at all, yet it inherits their kinds anyway (minus shadowed names).

The shadowing test (anUnimportedOuterNameDoesNotDecideTheBodysCheck) passes because the body re-declares p. But consider a body that references an outer variable it neither imports nor re-declares, e.g. MATCH p = (a:P)-[:KNOWS]->(b:P) CALL { RETURN p.name } RETURN * : here p is not imported and not re-bound, so it inherits VarType.PATH, and this phase would raise "InvalidArgumentType: Property access on a path variable" - whereas the accurate diagnostic (and Neo4j) is that p is simply not defined in the body. This is not a false rejection of a valid query, and this validator is not the unbound-variable checker, so the practical impact is just a mismatched error message on an already-invalid query. Worth a confirming test either way; if the intent is that an implicit CALL { } body starts from an empty scope, the inherited map should be gated on whether the CALL actually imports.

Minor nits (non-blocking)

  • In collectNestedDeclarations, the procedure-CALL arm does bound.add(item.getOutputName()) without the != null guard the WITH and SUBQUERY arms use. YieldItem.getOutputName() returns alias != null ? alias : fieldName, so a null is unlikely in a valid parse, but the inconsistency reads as an oversight.
  • The WITH double-walk (a WITH present in both getClausesInOrder() and getWithClauses() gets walked twice, so a subquery body under WITH ... WHERE recomputes nestedVarTypes and re-validates) is idempotent and parse-time only - the comments call it out - so it is fine, just noting it is real.

Behavior change

The Issue5228CallMatchEdgeLabelEntityTest change is justified: type(b) on a node was always a type error, and rejecting it identically inside and outside a subquery is the whole point of the issue. The release note flags the potentially-breaking nature clearly.

Overall: solid, focused fix with excellent test and documentation discipline. Only the implicit-CALL scope-inheritance question is worth a second look before merge.

…es in order

Review follow-up. The nested scope was built as "declarations, minus every name the body binds by any
means", which dropped the kind of a variable a body merely passes through: an importing WITH p re-projects
the outer path under the same name, and the drop made p stop being a path, so
CALL { WITH p RETURN p.name } was accepted where the equivalent CALL (p) { RETURN p.name } was rejected.

The body's clauses are now walked in order, the way validateVariableTypes walks a statement's: a pattern
declares a kind, a WITH resets the scope to what it projects and carries a kind through only for a plain
variable reference (so WITH p and WITH p AS q keep the path and WITH 1 AS p does not), WITH * passes the
scope through, and an UNWIND / YIELD / LOAD CSV binding drops whatever the name held. Simpler than the
two-collection version it replaces, and it drops the null-guard inconsistency the review noted.

On the review's question: an implicit CALL { } does inherit kinds for names its body cannot reference, and
that is harmless because validateVariableScope runs first - an unimported reference is reported as the
undefined variable it is, never as a kind error. Asserted rather than argued.
@lvca

lvca commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Thanks - the scope question was the right thing to pull on. Probing it turned up a genuine bug next door to it, so the whole nestedVarTypes construction is rewritten in 0429851.

Your case: confirmed harmless, and now asserted. MATCH p = (a:P)-[:KNOWS]->(b:P) CALL { RETURN p.name } RETURN * answers UndefinedVariable: Variable 'p' not defined, not the path-access error. validateVariableScope is phase 3 and validateFunctionArgumentTypes is phase 12, so the accurate diagnostic is raised long before this phase reads a kind for a name the body cannot see. Locked down by anUnimportedReferenceIsReportedAsUndefinedNotAsAKindError, and the reasoning is now in the method Javadoc rather than only in a PR description.

What the probe found instead. The old rule was "declarations, minus every name the body binds by any means". An importing WITH p re-projects the outer variable under the same name - it binds it, so the kind was dropped, so p stopped being a path:

MATCH p = (a:P)-[:KNOWS]->(b:P) CALL { WITH p RETURN p.name AS n } RETURN n   -- accepted (wrong)
MATCH p = (a:P)-[:KNOWS]->(b:P) CALL (p) { RETURN p.name AS n } RETURN n      -- rejected (right)

Two spellings of the same import, two answers - the asymmetry this PR exists to remove, reintroduced one level down.

The rewrite. The body's clauses are walked in order, the way validateVariableTypes walks a statement's: a pattern declares a kind, a WITH resets the scope to what it projects and carries a kind through only where the item is a plain variable reference, WITH * passes the incoming scope through, and a binding that says nothing about the kind (UNWIND variable, YIELD output, LOAD CSV variable) drops whatever the name held. So WITH p and WITH p AS q keep the path, WITH 1 AS p stops being one. One pass replaces collectNestedDeclarations + collectPatternVarTypes + declareNested, which also retires both nits: the getClausesInOrder() guard is back in line with walk(), and the YIELD arm no longer needs the missing null check.

Six cases added across three tests: the import spellings (explicit scope, leading WITH, renamed), the computed re-bind, the renamed pass-through for labels()/type(), WITH *, and your unimported case.

On the WITH double-walk - agreed it is real and idempotent. Leaving it: collapsing it needs identity tracking across two collections that some builder paths populate and others do not, which is more moving parts than the duplicate work it saves at parse time.

Green after the change: engine query.** + function.** 11314/0, query.opencypher.** 7811/0.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review: #5642 - walk the subquery bodies the parse-time checks claimed were validated elsewhere

Thorough, well-scoped fix with an unusually complete test suite. The core move - carrying each subquery body as a CypherStatement AST built off the parse tree ANTLR already produced (no re-lex) and descending into it - is the right one, and folding the two anonymous BooleanExpression adapters into the existing named BooleanCoercionExpression is a genuine simplification (net -176 lines of duplicated logic, plus it retires a latent NPE in the function-call adapter). Nice work.

Code quality

  • Extracting FunctionArgumentChecks as a per-scope Visitor and lifting the walkClause/walkWith/walkSet/walkOrderBy machinery out of CypherSemanticValidator into a reusable CypherExpressionWalker.walk(CypherStatement, Visitor) reads cleanly and removes duplication. The forNestedStatement boundary hook is a tidy way to let a scope-sensitive check re-bind itself while a scope-insensitive one keeps the same instance.
  • Threading Map<String, VarType> scope through the check methods instead of reading the field varTypes is the correct plumbing for the nested-scope requirement.
  • Javadoc is excellent, and the second commit (walk clauses in order for kind propagation) fixes a real asymmetry the first commit introduced (WITH p vs CALL (p)). Good catch during self-review.

One substantive limitation worth noting (not blocking)
nestedVarTypes collapses a UNION body to the intersection of the branches variable kinds (removeIf(value != otherBranch.get(key))), and walk(UnionStatement) then validates every branch with that single merged scope. Consequence: a kind-dependent check on a variable declared in only one branch is silently skipped. For example, inside CALL { MATCH (a:P) RETURN a.x AS x UNION MATCH q=(m:P)-->() RETURN q.name AS x }, the variable q exists only in branch 2, so it is dropped from the merged scope and the q.name-on-a-path check never fires at parse time.

This is a missed rejection, never a false rejection (merged keeps a name only where branches agree, so nothing spurious is ever flagged), and it is strictly better than the pre-PR behaviour where nothing in the body was walked at all - the runtime guard still catches it when the branch executes. The literal-argument checks (arity, unknown function, bad literal type) are scope-independent and do fire per branch, which is why unionBranchOfACallBodyIsValidated passes. To reach kind-dependent checks in union branches, each branch would need to be walked with its own nestedVarTypes(outer, branch) rather than the merged map. Fine to defer, but a one-line comment on nestedVarTypes documenting the intersection tradeoff would help the next reader.

Minor

  • walk(statement, ...) walks getClausesInOrder() and then getWithClauses() again, relying on the visitor being pure/idempotent. True for FunctionArgumentChecks, but the class is now a general-purpose public utility - a future stateful visitor (e.g. a counter) would double-count WITH expressions. The Javadoc calls this out, so it is a documented footgun rather than a bug; consider deduplicating the WITH set if a second caller ever appears.
  • Scope inheritance for a scoped CALL () { ... } (imports nothing) is the same as for the implicit CALL { ... }: nestedVarTypes inherits all outer kinds regardless of the import list, and correctness leans on validateVariableScope running first to report an unimported reference as undefined. That ordering is asserted for the implicit case but not for the explicit CALL () { RETURN outerVar.name } form - a cheap extra assertion would lock it down.

Performance
No hot-path concern. parseSubqueryBody allocates a fresh CypherASTBuilder and does one extra tree walk per subquery at parse time, once per distinct query text (cached), off the sub-tree ANTLR already built - no second lex/parse. The per-row re-parse called out as follow-up is the real cost and is correctly left out of scope.

Tests
Strong. 22 EXPLAIN-only tests covering each of the four body shapes asserting the same class+message as the identical outer-WHERE call, all three checks reaching inside (type, arity, path-property), the syntactic positions (bare pattern, negated, conjunction, WITH...WHERE, nested subquery, UNION branch, CALL args, LOAD CSV), the no-false-rejection cases (correlated body, kind read from the declaring body, unimported shadowing, imported-path kind preservation), and the EXISTS/COUNT/COLLECT evaluation-still-works regressions. The Issue5228 change is justified: type(b) on a node is a genuine type error, and asserting it identically inside and outside the subquery is exactly the point. Parse-time tests are fast, so the slow tag correctly does not apply.

Note: I could not run Maven in the review environment (permission-gated), so the suite was not independently executed here; the review is static. I did confirm every AST accessor the new code calls (hasPathPatterns, CreateClause.isEmpty, hasPathVariable, getVariableName, StarExpression, getOutputName) exists.

Conventions
Follows CLAUDE.md: final throughout, imported types (no FQNs), the preferred assertThat style, no System.out, no Claude attribution. Release notes and the breaking-change callout are clear.

Overall: LGTM. The UNION-branch scope intersection is the only thing I would flag for a follow-up (or a documenting comment); everything else is polish.

…t each WITH once

Review follow-up, both points raised as non-blocking.

A UNION body was collapsed to the intersection of its branches' variable kinds and every branch was then
walked with that one map, so a kind-dependent check on a variable only one branch declares was silently
skipped - CALL { MATCH (a:P) RETURN a.name AS x UNION MATCH q=(m:P)-->() RETURN q.name AS x } let the
path-property read through. Each branch is now entered as a nested statement in its own right, so it builds
its own scope over what it inherits and the intersection is gone. Asserted from both branch positions, so a
scope accidentally taken from one branch cannot pass.

The walk also visited a WITH twice when it was registered both on the ordered clause list and on the
statement. Harmless for this phase's checks, which are pure, but the walker is a general utility now and a
visitor that keeps state would have double-counted. Which WITHs the ordered walk reached is tracked by
identity, so the second pass adds only what it missed.
@lvca

lvca commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Both points fixed rather than documented, in e9c26cb - each turned out to be a few lines.

The UNION intersection is gone. Your example is exactly right, and it was reachable: CALL { MATCH (a:P) RETURN a.name AS x UNION MATCH q=(m:P)-->() RETURN q.name AS x } let the path-property read through. Rather than document the tradeoff, each branch is now entered as a nested statement in its own right - walkNested(query, visitor) instead of walk(query, visitor) - so forNestedStatement fires per branch and each builds its own scope over what it inherits. nestedVarTypes then has nothing to merge and the intersection code is deleted.

One trap worth recording, since it is why the union arm could not simply fall through to the generic path: UnionStatement.getClausesInOrder() answers with its first branch's clauses. A nestedVarTypes that walked them would have handed every branch a scope built from branch 1 - which happens to produce the right answer for the example above, so the test asserts it from both branch positions.

The double WITH walk is gone too. You are right that "documented footgun" is the wrong resting place for a class that is now a general-purpose utility - a visitor that keeps state is a reasonable thing for the next caller to write, and it would have silently double-counted. Which WITHs the ordered walk reached is tracked by identity, so the second pass adds only what it genuinely missed. The WITH ... WHERE subquery test confirms the dedup did not cost coverage.

The explicit CALL () { } assertion is added, alongside the implicit one - both answer UndefinedVariable.

Four cases added (both union orderings, a clean union that must still pass, the explicit empty scope). Green: engine query.** + function.** 11315/0, query.opencypher.** 7812/0.

On the per-row re-parse: agreed it is the real cost and out of scope here. I will file it as a follow-up - now that the body exists as an AST, correlating it as an AST instead of splicing text would also retire CorrelatedSubqueryRewriter's text scanning, which is where #4995, #5165, #5461 and #5464 all came from.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review: #5626 - walking subquery bodies at parse time

Reviewed the full diff plus the surrounding source. This is a high-quality, well-reasoned change: it closes a real asymmetry (a bad call was rejected in an outer WHERE but silently absorbed one level in), the PR description is exact about what escaped and why, and the test suite is genuinely comprehensive - reproducer per body kind, every check (type/arity/unknown-name/path-property), every body position, the no-false-rejection cases, and the evaluate-still-works cases.

Correctness - verified, holds up

  • Validation ordering: validate() runs validateVariableScope before validateFunctionArgumentTypes, so the "an implicit CALL {} inherits kinds for names its body cannot reference, but that is harmless because the scope check fires first" argument is sound. It is also pinned by anUnimportedReferenceIsReportedAsUndefinedNotAsAKindError. Good that this was asserted rather than argued.
  • Adapter swap is behaviour-preserving: BooleanCoercionExpression.evaluate/evaluateTernary are byte-for-byte the old function-call adapter, and the EXISTS adapter only ever sees a Boolean from evaluate(), so three-valued logic is unchanged. The removal of the redundant function-call arm also legitimately drops the null-wrap NPE, since the generic arm null-guards (if (parsedExpr != null)).
  • No NPE surface added: getWithClauses() defaults to List.of(), getClausesInOrder() is null-guarded, and the walker has no callers outside CypherSemanticValidator, so the Consumer -> Visitor signature change is contained.
  • UNION handling (patch 3): entering each branch as its own nested scope rather than intersecting is the right call, and testing from both branch positions is exactly what guards against a scope accidentally taken from one branch.

Observations (non-blocking)

  1. Scope is an end-state, applied uniformly. nestedVarTypes walks the body clauses to a single final map, then every expression in the body is checked against it regardless of position - so in CALL { MATCH (m:P) WHERE type(m) ... WITH 1 AS m ... } the type(m) in the MATCH would be checked against the post-WITH scope where m is gone. This is imprecise, but it is the same approximation the top-level statement already uses (varTypes is one flat instance-field map), and the failure mode is a missed error (false negative), never a false rejection - so it is consistent and safe. Worth a one-line note in nestedVarTypes that the scope is deliberately the end-state, matching top-level behaviour.

  2. "Visited exactly once" is only partially guaranteed. Patch 3 IdentityHashMap dedup fixes the WITH-registered-twice case, and the class Javadoc now sells the walker as a general utility where visiting once "is what lets a visitor keep state". That contract holds for the WITH case but is not proven for every expression position. Today the only visitor is pure, so it does not bite - but if the "keep state" promise is advertised, consider narrowing the Javadoc claim or noting it is guaranteed specifically for the clause-list/statement-WITH overlap.

  3. FOREACH inner bindings are not collected in nestedVarTypes (only the FOREACH variable is removed). Correct here because FOREACH-inner variables are not visible in the body outer scope anyway - just flagging it as an intentional gap in case a future reader expects symmetry with walkClause FOREACH recursion.

Performance

Negligible and well-argued: the body AST is built off the parse tree ANTLR already produced (ctx.queryWithLocalDefinitions() / patternList+whereClause), so it is one extra tree walk at parse time, not a re-lex. nestedVarTypes allocates a few small HashMaps per subquery boundary, bounded by nesting depth. The real cost - the per-row re-parse - is correctly scoped out as follow-up.

Style / conventions

Matches the codebase: final throughout, imported types over FQNs, tests use assertThat(...).isInstanceOf(...), EXPLAIN-only so nothing executes, release notes updated. No System.out, no new dependency, no Claude attribution.

Nice work - the reasoning quality in both the code comments and the PR body is unusually high. The three observations above are all optional polish; nothing blocks merge.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review: #5626 - walk the subquery bodies the parse-time checks skipped

Reviewed all four commits, the walker/validator changes, and the tests against the surrounding code. This is a high-quality, well-scoped fix. Summary below with a few non-blocking notes.

What works well

  • Root cause, not symptom. The class Javadoc that asserted subquery bodies were "parsed on their own and validated then" was the actual bug, and the fix targets exactly that: EXISTS/COUNT/COLLECT now carry the body as a CypherStatement and CypherExpressionWalker descends into it, plus CALL { }, procedure CALL args, and LOAD CSV FROM.
  • Removing the two anonymous BooleanExpression adapters is a genuine cleanup. Collapsing them to BooleanCoercionExpression both closes the walk blind spot and removes the latent NPE in the function-call arm (the old adapter wrapped a possibly-null expression; the generic arm three lines down already null-checks). Byte-for-byte equivalent behavior, verified by the retained evaluation tests.
  • Scope handling is the subtle part and it is done carefully. The forNestedStatement boundary, the in-order clause walk in nestedVarTypes/applyWithProjection (so CALL (p) {...} and a leading WITH p agree), and the per-branch UNION scoping are each correct. I confirmed validateVariableScope runs before validateFunctionArgumentTypes, so the "implicit CALL { } inherits kinds for names it cannot reference" case is genuinely harmless: an unimported reference is reported as undefined first.
  • Test coverage is excellent. 22 tests covering each body kind, each check (type, arity, unknown-fn, path-property), each syntactic position, UNION branches from both sides, shadowing/import, and the no-false-rejection cases. Running everything under EXPLAIN to assert rejection happens before execution is the right technique. The iterative follow-up commits (2 and 3) each add regression tests for the exact gap they close: the WITH p pass-through and the UNION-branch intersection bug.
  • Style, final usage, Javadoc density, and the release-note behavior-change callout all match the repo conventions.

Non-blocking notes

  1. The AST build costs more than "one tree walk". parseSubqueryBody calls new CypherASTBuilder().visitQueryWithLocalDefinitions(...), and that path runs the full rewriter pipeline (InlineNodeWhereHoister, LabelPredicateHoister, ProjectedOrderByNormalizer, AST_REWRITER), not just a bare tree walk. It is still bounded and paid once per distinct query text (plan cache), and it happens unconditionally at parse time even when semantic validation is later skipped. Worth tightening the PR/Javadoc wording from "one tree walk at parse time" to reflect that a full statement is built, so the cost is not understated for a future reader.

  2. COLLECT passes null for the pattern/where args (parseSubqueryBody(ctx.queryWithLocalDefinitions(), null, null)) while EXISTS/COUNT pass ctx.patternList()/ctx.whereClause(). This is correct (a COLLECT body must RETURN, so it has no bare-pattern form) but the asymmetry reads as an oversight at the call site. A one-line comment there would save the next reader the grammar check.

  3. The double-WITH identity dedup assumes both lists hold the same instances. The IdentityHashMap guard in walk(CypherStatement) is correct for the current builder, but if any builder path ever registers a different WithClause instance on the statement vs. the ordered list, a stateful visitor would double-count. Harmless for today's pure checks (and the commit message flags this); just calling it out as the one latent assumption now that the walker is a general utility.

  4. Whole-body scope for kind checks. Every expression in a body is checked against the end-of-body scope rather than the scope at that expression's position. This mirrors how validateVariableTypes already treats a statement, so it is consistent rather than a regression, but a body that rebinds a variable's kind across a WITH boundary is a theoretical edge where the position-vs-final distinction could matter. Not worth changing given it matches existing behavior; noting for completeness.

None of these block merge. Nice work: the fix is correct, the cleanup is real, and the test suite is thorough.

…and record the scope approximation

The IdentityHashMap dedup made "each expression is visited once" true; nothing proved it. A counting visitor
over a query whose WITH is registered on both collections now does, and the test asserts that precondition
first, so it cannot pass for the trivial reason that there was nothing to double-visit. Dropping the dedup
makes it fail on eight expressions, two of them inside the CALL body.

nestedVarTypes returns the body's end state and applies it to every expression in the body wherever it sits.
That is the same approximation the top-level statement runs on, and matching it is the point: answering a body
more precisely than the query around it would put back a clause-dependent asymmetry. Recorded in the Javadoc,
along with why it can only miss a check and never invent one - the only rebinding Cypher allows on a bound name
is a WITH projection, which drops a kind but never turns a name into a path.

FOREACH collects nothing but the loop variable's removal, because what its inner clauses bind is not in scope
after the loop; the walk still checks those clauses' expressions. Noted where the arm is.
@lvca

lvca commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

All three observations addressed in 0e51705 - the first two turned into something enforced rather than documented.

1. The end-state scope. Right, and deliberately so: matching the top-level approximation is the point. varTypes is one map for the whole statement, and answering a body more precisely than the query around it would put back a clause-dependent asymmetry of exactly the kind #5602 and this issue exist to remove - the same query, checked differently depending on where it was written.

Worth adding to your direction analysis, because it makes the approximation safe rather than merely conservative: it can only err one way, and not by luck. A false rejection would need a name to be read at a position where its kind is K, then rebound to PATH later in the body. Cypher does not let a bound name be rebound by a pattern (Variable 'x' already declared), and the one rebinding it does allow - a WITH projection - can drop a kind but never turn a name into a path. So the end state is never more path-ish than any position preceding it. Missed check, never invented one, structurally. That reasoning is now in the nestedVarTypes Javadoc rather than only in a review thread.

2. "Visited exactly once." Fair to call out an advertised guarantee nothing proved. It is proved now: everyExpressionIsVisitedExactlyOnce walks a statement with a counting visitor and asserts no expression identity is reached twice. It asserts its own precondition first - that the query's WITH really is registered on both getClausesInOrder() and getWithClauses() - so it cannot pass because there was nothing to double-visit. Removing the IdentityHashMap dedup fails it on eight expressions:

Expecting empty but was: ["0", "n.age", "abs(n.age)", "0", "m", "n", "a", "m.age"]

m and m.age are from the CALL { } body, so the double-registration reaches into nested scopes too.

The contract stays narrow in the wording: guaranteed for the two registration paths this traversal reconciles; elsewhere once-only falls out of each arm walking a part of the tree no other arm does - a property to preserve when adding an arm, and now called out as such at the arm list rather than implied by the interface.

3. FOREACH. Intentional, and the asymmetry with walkClause is the correct one: what the inner CREATE/MERGE bind lives and dies inside the loop and is not in scope after it, so declaring those kinds in the body scope would be declaring them where they cannot be referenced. The walk does still descend into the inner clauses and check their expressions against the scope the loop was entered with. Noted at the arm.

Green after the change: engine query.opencypher.** 7813/0 (98 skipped), and the earlier full query.** + function.** runs at 10243/0 and 1072/0.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review of issue 5626 fix - walking subquery bodies through the parse-time checks

Reviewed the full diff (walker, validator, the three subquery expression nodes, the two builder changes, and both test files). This is a high-quality, carefully reasoned change. Summary and a few notes below.

What is good

  • The core fix is sound and minimal in behavior. CypherExpressionWalker now descends into CALL { } and the EXISTS/COUNT/COLLECT bodies via walkNested + Visitor.forNestedStatement, and the three expression nodes carry their body as a CypherStatement built off the parse tree ANTLR already produced (no re-lex). No new check is introduced; only its reach widens, which is exactly the clause-independence that 5602 set out for.
  • The two anonymous-adapter removals are verified equivalent. BooleanCoercionExpression.evaluate (Boolean.TRUE.equals(value)) and evaluateTernary (null gives null, Boolean gives value, else TRUE) match the deleted EXISTS/function adapters byte-for-byte. Since ExistsExpression.evaluate returns a non-null Boolean, folding the EXISTS adapter default evaluateTernary into the coercion explicit one is behavior-preserving. Collapsing the function-call arm into the generic arm three lines below also removes the latent NPE (the old arm wrapped a possibly-null expression; the generic arm guards parsedExpr != null). Good catch.
  • Constructor design is consistent with callers. Only ExistsExpression keeps the 2-arg constructor (used by the runtime-synthesized PatternPredicateExpression path, which correctly passes a null AST); Count/Collect have the single parser callsite. walkNested/walk tolerate a null parsedSubquery, so the runtime-synthesized path is safe.
  • Scope re-binding in nestedVarTypes is careful and well-documented - UNION branches entered individually, WITH projection dropping/carrying kinds by plain-variable-reference, FOREACH/UNWIND/YIELD/LOAD CSV kindless bindings dropped, and the "err toward missing a check, never inventing one" argument holds. The unimported-CALL-shadowing case leaning on validateVariableScope running first is a nice invariant to spell out.
  • The once-only guarantee (IdentityHashMap dedup of a WITH registered on both the ordered list and the statement) is now enforced by everyExpressionIsVisitedExactlyOnce, and that test asserts its own precondition first so it cannot pass vacuously. That upgrade lets a visitor accumulate rather than only assert.
  • Test coverage is excellent - 22 focused tests covering each body kind, each check reaching inside (unknown name, arity, path-property), every body position (bare pattern, negated, conjunction, WITH...WHERE, nested subquery, UNION branch), procedure CALL args and LOAD CSV FROM, plus a solid set of no-false-rejection cases. The Issue5228 change is a legitimate adaptation (the type(b) probe now asserts the same rejection inside and outside), not a weakening.

Minor notes / questions (non-blocking)

  1. parseSubqueryBody runs a fresh CypherASTBuilder (and thus the rewriters) over the body sub-tree at parse time. This is once per distinct query text, so cost is negligible - but it does mean a subquery body is now AST-built at parse time where before it was only text-re-parsed at runtime. If the AST builder has any construct it cannot handle that the runtime text path tolerated, a previously-accepted body could now fail at parse. The body is always a subset of top-level grammar and the suite is broad, so risk is low; just flagging it as the one behavioral surface beyond "rejected earlier."

  2. COLLECT passes parseSubqueryBody(ctx.queryWithLocalDefinitions(), null, null) - no bare-pattern arm, unlike EXISTS/COUNT. That is correct if the grammar guarantees a COLLECT { } body is always a full query with a RETURN (it should be). Worth a one-line confirmation, since if a bare-pattern COLLECT body were ever reachable, parsedSubquery would be null and its body would silently skip validation - the exact class of blind spot this PR closes.

  3. The follow-up you filed (correlating the body as an AST to retire the per-row re-parse and CorrelatedSubqueryRewriter text scanning) is the right next step - the AST now existing makes it reachable.

Nice work - the reasoning in the Javadoc and commit messages made this easy to verify. LGTM pending the CI green you cited.

…e second pass was never needed

The walk covered WITH from two collections and deduplicated by identity to keep from visiting one twice. Both
halves were unnecessary: StatementBuilder.addWith is the only place a WITH is registered and it puts the same
object on getWithClauses() and on getClausesInOrder(), and StatementBuilder.build() is the only construction
site, so the second pass could never reach a clause the first missed. Instrumenting it to raise on a
statement-only WITH and running query.opencypher.** confirmed it: 7813 tests, not one hit.

So the second pass and the IdentityHashMap guarding it are gone. Visiting once is structural again - each arm
covers a part of the tree no other arm does - rather than a property restored by a set allocated per statement,
and the latent question of what happens if the two collections ever hold different instances goes with it.

everyExpressionIsVisitedExactlyOnce now pins the invariant the single pass rests on: every WITH on the statement
is the same instance on the ordered list. A builder path that broke that would make the walk skip the clause,
and this is what would say so.

Also: the subquery-body AST is a full statement build over an existing sub-tree, rewriters included, not the
"one tree walk" the Javadoc claimed - corrected, since a reader sizing the cost should not be told less than it
is. And a note at the COLLECT call site on why it passes no pattern/where context: a COLLECT body has no
bare-pattern form in the grammar.
@lvca

lvca commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

All four notes addressed in 5b37491. The third one turned out to be pointing at code that should not exist.

3. The double-WITH identity dedup - deleted, along with the pass it was guarding. You framed it as a latent assumption; checking the assumption showed the second pass was unreachable. StatementBuilder.addWith is the only place a WITH is registered, and it puts the same object on both getWithClauses() and getClausesInOrder(); StatementBuilder.build() is the only construction site. So the second loop could never reach a clause the first missed - it existed only to be deduplicated against.

Confirmed rather than argued: I replaced the second pass with a throw on any statement-only WITH and ran query.opencypher.**. 7813 tests, zero hits.

So the pass and the IdentityHashMap are both gone, and the walk is the ordered clause list alone. Visiting once is structural again - each arm covers a part of the tree no other arm does - rather than a property restored by a set allocated per statement walk, and the question of what happens if the two collections ever hold different instances goes away instead of being documented. The remaining direction is the safe one: a builder path that registered a WITH on the statement alone would make the walk skip it, a missed check, never a double-count.

everyExpressionIsVisitedExactlyOnce now pins the invariant the single pass rests on - every WITH on the statement is the same instance on the ordered list - so a future builder path that breaks it fails a test instead of quietly costing coverage.

1. "One tree walk" understated it. Correct, and fixed: visitQueryWithLocalDefinitions runs the full CypherASTBuilder pipeline, rewriters included. The Javadoc now says a full statement is built over an existing sub-tree, and that a reader sizing it should read it as "one more statement built", not "one traversal". What the sentence was defending - that there is no second lex or parse - is kept, since that was the alternative it was chosen over.

2. The COLLECT null/null asymmetry. One line at the call site: a COLLECT body has no bare-pattern form in the grammar, it has to RETURN the value being collected, so it is always a queryWithLocalDefinitions and there is no pattern/where context to pass.

4. Whole-body scope. Agreed, and it stays - matching the top-level approximation is the point, since answering a body more precisely than the query around it would put back a clause-dependent asymmetry. Worth adding that it is safe by construction, not by luck: a false rejection would need a name to be read at a position where its kind is K and rebound to PATH later in the body, and Cypher does not let a bound name be rebound by a pattern, while the one rebinding it does allow - a WITH projection - can drop a kind but never turn a name into a path. The end state is therefore never more path-ish than any position before it. That reasoning is in the nestedVarTypes Javadoc as of 0e51705.

Green: query.opencypher.** 7813/0 (98 skipped), query.sql.** 2315/0 (8 skipped), and the earlier query.** + function.** at 10243/0 and 1072/0.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review: #5626 walk subquery bodies at parse time

Reviewed the full diff plus the surrounding validator/builder/walker code. This is a careful, well-scoped fix that closes a real asymmetry: abs('x') was rejected in an outer WHERE but slipped through inside EXISTS {}/COUNT {}/COLLECT {}/CALL {}. The refactor of CypherExpressionWalker from Consumer<Expression> into a Visitor with a forNestedStatement scope hook is clean, and folding the two anonymous BooleanExpression adapters back into BooleanCoercionExpression (with the latent NPE removal on the function-call path) is a nice simplification that also removes the blind spot. Test coverage is excellent - the reproducer per body form, the "rejected identically inside and outside", the scope/shadowing/UNION-branch cases, and the negative cases (correlated bodies, imported-vs-unimported names) are all there.

A few observations, mostly minor:

1. Duplicated scope-building logic (maintainability). nestedVarTypes + applyWithProjection re-implement "build variable kinds by walking clauses in order", which validateVariableTypes (lines ~120-200) already does for the top-level statement. The two diverge: the existing one tracks VarType.SCALAR for literal/map projections and uses registerVar (which throws VariableTypeConflict on a kind clash), while the new nested one drops such names and uses a plain scope.put (intentional, for implicit-CALL shadowing). Functionally equivalent for the PATH/NODE/RELATIONSHIP checks that matter, but two independent copies of scope construction will drift over time. Worth a // keep in sync with validateVariableTypes note, or a follow-up to unify them.

2. Memory / eager build. Each Exists/Count/CollectExpression now retains a full CypherStatement AST (parsedSubquery) for the lifetime of the cached plan, in addition to the body text it still executes from. parseSubqueryBody also runs the entire CypherASTBuilder rewriter pipeline eagerly at parse time even though the AST is consumed only by the semantic validator. Both are acceptable and the Javadoc is admirably honest about the cost ("one more statement built, not one traversal"), but since the AST is only needed during validation, nulling the field after validation (or building it lazily from the validator) would drop the steady-state footprint. Not blocking - just flagging the retained duplication.

3. Stricter walk contract. The old validator defensively walked getWithClauses() a second time ("walking one twice is harmless - the checks are pure"). The new single-pass walk drops that and relies on the invariant that StatementBuilder.addWith registers the same WithClause instance on both getWithClauses() and getClausesInOrder(). I verified that invariant holds today (StatementBuilder.java:83-86, single registration point) and it is pinned by everyExpressionIsVisitedExactlyOnce. Good - but note the walker is now a general utility whose visitors may accumulate (the visit-count test does), so the once-only guarantee is now load-bearing rather than incidental. The class Javadoc calls this out well.

4. EXISTS in a WHERE behavior preserved. Nice that existsInWhereStillFiltersCorrectly and countAndCollectSubqueriesStillEvaluate guard the adapter swap - that was the riskiest part of the change (the coercion path must still evaluate identically), and it is covered.

Correctness spot-checks that passed: the null-body path (ExistsExpression synthesized at runtime -> getParsedSubquery() null -> walkNested no-ops); the UNION-branch-per-scope handling; applyWithProjection for WITH * / WITH *, 1 AS z; and no stale call sites left on the widened checkFunctionArgTypes/getExpressionType/checkPropertyAccessOnPath signatures.

I did not run the build myself; relying on the stated green results (query.**+function.** 11311/0, query.opencypher.** 7808/0, bolt 93/0, server Cypher HTTP ITs 20/0). Recommend a quick mvn -pl engine verify confirmation before merge.

Overall: solid, well-tested, well-documented. None of the above blocks merge; items 1 and 2 are the ones worth a follow-up.

…st not fail the query

Building the body AST put the statement builder on the parse path of something that until now was carried as
text and only re-parsed at execution. A construct ANTLR accepts but the builder cannot assemble would therefore
have turned a query that used to run into a parse failure, which is a regression this issue has no business
causing. The build is now best-effort: on failure the body stays text only, the walk treats the expression as a
leaf exactly as before #5626, and execution re-parses the text as it always has. Logged at FINE, so a body with
no AST - a body the parse-time checks do not see - stays discoverable instead of becoming permanent.

aSubqueryExpressionWithNoBodyIsALeafRatherThanAFailure pins what that relies on. Its visitor dereferences the
statement it is handed, the way the validator's does when it builds the inner scope, because the guarantee is
not "the walk survives a null body" but "the boundary hook is never called with one" - and only a visitor that
reads the statement tells those apart. Removing the guard in walkNested fails it with an NPE; a visitor that
ignores its argument would have passed either way.

Also cites the grammar at the COLLECT call site rather than asserting it: collectExpression has the single
queryWithLocalDefinitions alternative where existsExpression and countExpression have two, so a COLLECT body
can never arrive without an AST and quietly skip the checks.
@lvca

lvca commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Both notes acted on in 2ec34f2. The first one was the more important of the two.

1. The parse-time AST build as a new failure surface. You are right that this is the one behavioural surface beyond "rejected earlier", and it is the wrong direction to leave to luck: a construct ANTLR accepts but CypherASTBuilder cannot assemble would turn a query that used to run into a parse failure. Widening what gets rejected is what this issue is for; breaking what used to run is not.

So the build is now best-effort. On failure the body stays text only, the expression keeps a null AST, the walk treats it as a leaf exactly as before #5626, and execution re-parses the text as it always has - the pre-issue behaviour, restored precisely for the case that would otherwise regress. Logged at FINE, because a body with no AST is a body the parse-time checks do not see, and that should stay discoverable rather than become permanent.

aSubqueryExpressionWithNoBodyIsALeafRatherThanAFailure pins what the fallback rests on, and it took two attempts to make it mean anything. The first version used a plain lambda visitor and passed even with the null guard in walkNested deleted - three layers of independent null-tolerance made it vacuous. The guarantee is not "the walk survives a null body" but "the boundary hook is never called with one", and only a visitor that dereferences the statement can tell those apart, which is what the validator's own forNestedStatement does when it builds the inner scope. With the visitor reading getClausesInOrder(), removing the guard fails it:

java.lang.NullPointerException: Cannot invoke "CypherStatement.getClausesInOrder()" because "nested" is null

2. COLLECT and the bare-pattern arm. Confirmed from the grammar rather than assumed, and the rule is now quoted at the call site so the next reader does not have to go looking:

collectExpression : COLLECT LCURLY queryWithLocalDefinitions RCURLY
existsExpression  : EXISTS  LCURLY (queryWithLocalDefinitions | matchMode? patternList whereClause?) RCURLY
countExpression   : COUNT   LCURLY (queryWithLocalDefinitions | matchMode? patternList whereClause?) RCURLY

One alternative where the other two have two, because a COLLECT body has to RETURN the value being collected. So queryWithLocalDefinitions() is never null there and the blind spot you describe is unreachable - which, as you say, is exactly the class of thing this PR closes, so it is worth having it stated rather than inferred.

3. The follow-up. Agreed, and it is the reason the AST is attached to the expression rather than built and discarded inside the validator: correlating the body as an AST retires both the per-row re-parse and the text scanning in CorrelatedSubqueryRewriter. Out of scope here, tracked separately.

Green: query.opencypher.** 7814/0 (98 skipped). No conflict with main.

@codacy-production

codacy-production Bot commented Jul 31, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 42 complexity

Metric Results
Complexity 42

View in Codacy

🟢 Coverage 92.92% diff coverage · -6.68% coverage variation

Metric Results
Coverage variation -6.68% coverage variation
Diff coverage 92.92% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (7081057) 149675 112546 75.19%
Head commit (9284669) 181821 (+32146) 124580 (+12034) 68.52% (-6.68%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#5642) 240 223 92.92%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review: #5626 - walk the subquery bodies the parse-time checks claimed were validated elsewhere

I read the full diff (all 7 commits), the surrounding code in CypherExpressionWalker, CypherSemanticValidator, CypherExpressionBuilder, and confirmed the key API assumptions the change rests on. This is a high-quality change: the problem statement is precise, the fix is well-scoped, and the test suite is unusually thorough. Below is a mix of what I verified and a few smaller observations.

What holds up under scrutiny

  • The single-pass walk over getClausesInOrder() is sound. I confirmed StatementBuilder.addWith() registers the same WithClause instance on both withClauses and clausesInOrder, and both implicit and explicit WITHs go through it. So dropping the second getWithClauses() loop (commit 6) genuinely cannot miss a clause, and everyExpressionIsVisitedExactlyOnce pins that invariant for the future. Good call replacing the IdentityHashMap dedup with a structural guarantee plus a test, rather than leaving the guard in place.
  • The removed funcCtx anonymous adapter is a real net win. The generic arm three lines below (if (parsedExpr != null) return new BooleanCoercionExpression(parsedExpr); return createFallbackComparison(ctx);) does null-check where the deleted adapter wrapped a possibly-null funcExpr unguarded, so removing it does fix a latent NPE, not just tidy the code. findFunctionInvocationRecursive is still used elsewhere, so no dead code left behind.
  • The per-branch UNION scoping fix (commit 3) is correct and the "read from the branch that declares it" test asserts from both branch positions, which is the right way to catch a scope accidentally taken from one branch.
  • The nestedVarTypes end-state approximation is documented honestly (commit 5): it can miss a check but not invent one, and the reasoning (the only rebinding Cypher allows on a bound name is a WITH projection, which drops a kind but never manufactures a PATH) matches what valid Cypher can express. Re-declaring an outer variable to a different kind within a body is itself an error caught elsewhere, so the "never a false rejection" claim holds for queries that reach this phase.
  • Test coverage is excellent - 22 EXPLAIN-based tests covering each body kind, each check (type / arity / unknown fn / path-property), each syntactic position, the no-false-rejection cases, and the evaluate-as-before cases. The Issue5228 change correctly reclassifies type(node) as the pre-existing type error it always was.

Observations worth a look (none blocking)

  1. catch (Exception e) in parseSubqueryBody is broad. The best-effort fallback (commit 7) is the right design decision - a body ANTLR accepts but the builder cannot assemble must not turn a previously-running query into a parse failure. But catching Exception also swallows genuine builder defects (NPEs, IllegalState) into a FINE log, where a regression in validation coverage would be invisible in normal runs. Since the pre-[Cypher] EXISTS/COUNT/COLLECT subquery bodies escape parse-time argument validation #5626 contract is "fails at runtime when the text is re-parsed," the query still fails eventually, so correctness is preserved - but consider whether an unexpected RuntimeException here deserves a louder log level (or a metric) than the expected CommandParsingException, so a silently-degraded body is discoverable in practice, not just in theory.

  2. Parse-time cost is a full statement build, not a tree walk. Commit 6 already corrected the Javadoc to say this honestly. Worth restating for reviewers: each EXISTS/COUNT/COLLECT/CALL body now runs the entire CypherASTBuilder pipeline including rewriters at parse time, and nested bodies do so recursively. It is bounded by query size and paid once per distinct query text (plan cached), so it is fine - but it is a real addition to cold-parse latency for subquery-heavy queries, and the follow-up you flagged (correlating the body as an AST to retire the per-row re-parse) is the thing that would actually pay this back. Filing that separately as you suggest is the right move.

  3. Minor style: import com.arcadedb.log.LogManager; in CypherExpressionBuilder is appended out of alphabetical order relative to the surrounding imports.

Verification

I did not run the suite here, but the author reports green across query.**/function.** (11311), query.opencypher.** (7808/7813), bolt (93), and the server Cypher HTTP ITs (20), and every API the diff depends on (StarExpression, the MatchClause(List, boolean, WhereClause) ctor, getWithClauses/getClausesInOrder, addWith dual-registration) checks out against the tree.

Overall: clear problem, minimal-surface fix, strong tests, and the review-follow-up commits each tighten a real edge (WITH pass-through kinds, per-branch UNION scope, best-effort fallback). Nice work.

lvca added 2 commits July 31, 2026 17:04
…tatement and by a subquery body

Phase 2a and the nested scope builder were two copies of "walk the clauses in order and work out what each name
is". Two copies drift, and these already had: the statement's tracked SCALAR for a literal or map projection,
the body's dropped the name; the statement's raised VariableTypeConflict on a kind clash, the body's overwrote.
Writing that walk twice is what let the two spellings of the same import disagree in the first place, so it is
now one method with the two differences as parameters - the scope it starts from, and whether a kind clash
raises - rather than two methods that resemble each other.

Folding them together closed an asymmetry older than this issue. The statement's copy dropped every kind at a
WITH * because it kept only what the projection names, while the body's passed the incoming scope through. So
MATCH p = (a:P)-[:KNOWS]->(b:P) WITH * RETURN p.name was accepted at the top level and the same access rejected
inside a body - the shape #5602 and #5626 exist to remove, in the phase that decides what a name is. Both now
reject it, pinned from both positions by aKindSurvivesWithStarInsideAndOutsideASubqueryBody; making the star
arm unconditional again fails it.

The statement side also picks up the kindless-binding arms the body side had - UNWIND, LOAD CSV, YIELD and a
nested subquery's output drop whatever the name held - which can only lose a kind, so it narrows what is
checked rather than widening what is rejected.
…der that broke

The best-effort fallback caught Exception and logged at FINE, which put two different things in the same quiet
place. A CommandParsingException is the case the fallback exists for - the builder saying it does not support
this body - and it is not news. Anything else is a defect, and its cost is invisible: the query still runs, the
checks just stop seeing inside that body. The second arm now logs at WARNING, which is the difference between a
degraded body being discoverable and being theoretical.

Also puts the LogManager import back in alphabetical order, and records in the release notes the second
potentially-breaking shape the shared scope construction produces: a kind now survives WITH *, so a path
property read after one is rejected where the top-level scope used to have forgotten what the name was.
@lvca

lvca commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Two rounds of feedback landed together here, so this covers both: the maintainability point from the earlier review and the three observations in the latest. Pushed as 669f469 and 0b108de.

Duplicated scope construction - unified, and it was hiding something. You were right that two copies of "walk the clauses in order and work out what each name is" would drift, and right that they already had. Rather than a // keep in sync note, they are now one buildVarTypes with the two genuine differences as parameters: the scope it starts from (empty for a statement, the enclosing one for a body), and whether a kind clash raises VariableTypeConflict (it must at the top level; it must not inside a body, where a name the body binds for itself legitimately shadows an outer one).

Folding them together closed an asymmetry older than this issue. The statement's copy dropped every kind at a WITH *, because it kept only what the projection names; the body's copy passed the incoming scope through. So:

MATCH p = (a:P)-[:KNOWS]->(b:P) WITH * RETURN p.name                        -- accepted (wrong)
MATCH (n:P) RETURN COLLECT { MATCH p = (a:P)-->(b:P) WITH * RETURN p.name } -- rejected (right)

The same access, two answers, decided by whether it was written inside a body - the shape #5602 and #5626 exist to remove, sitting in the phase that decides what a name is. Both reject it now. aKindSurvivesWithStarInsideAndOutsideASubqueryBody pins it from both positions; making the star arm unconditional again fails it. WITH 1 AS p still stops p being a path, so a projection that names what it keeps is unaffected. Added to the release notes as a second potentially-breaking shape.

The statement side also picks up the kindless-binding arms the body side had - UNWIND, LOAD CSV, YIELD, a nested subquery's output drop whatever the name held. That direction can only lose a kind, so it narrows what is checked rather than widening what is rejected.

1. The broad catch (Exception) - split. Good point: the fallback was putting two different things in the same quiet place. A CommandParsingException is the case the fallback exists for, the builder saying it does not support this body, and it is not news - still FINE. Anything else is a defect whose cost is invisible, since the query still runs and only the checks stop seeing inside that body - now WARNING. That is the difference between a degraded body being discoverable in an ordinary log and being discoverable in theory.

2. Parse-time cost. Agreed, and no further change: the Javadoc says a full statement build including rewriters, and the follow-up that pays it back (correlating the body as an AST to retire the per-row re-parse) is exactly why the AST is attached to the expression rather than built and discarded inside the validator.

3. Import order. Fixed - com.arcadedb.log.LogManager now sits after com.arcadedb.exception.*.

One suggestion I am not taking, from the earlier review: nulling parsedSubquery after validation to drop the steady-state footprint. Cached plans are shared across threads, so nulling a field on one is a write to a structure other threads are reading concurrently - a data race in exchange for a bounded saving. It also works directly against the follow-up above, which needs that AST at execution time. The retained duplication is real; this is not the way to remove it.

Green: query.opencypher.** 7815/0 (98 skipped), query.** + function.** 11318/0 (106 skipped). The full mvn -pl engine verify you asked for is running now and I will post the result here when it finishes. No conflict with main.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review: #5626 - walk subquery bodies in parse-time validation

Reviewed all 9 commits end to end. This is a high-quality, carefully reasoned change: the traversal is generalized cleanly, two byte-for-byte anonymous adapters are collapsed into the named BooleanCoercionExpression the walk already understood, the kind-scope logic duplicated between a statement and a subquery body is folded into a single buildVarTypes, and every step comes with a regression test and a Neo4j reference point. Test coverage is excellent (reproducer per body kind, every check reaching inside, body positions, UNION branches, no-false-rejection cases, visit-once invariant, the body-less fallback). Nice work.

A few observations, mostly minor / for consideration:

1. Intra-body variable-type conflicts are no longer detected (minor consistency gap)

nestedVarTypes calls buildVarTypes(nested, outer, false), so raiseOnKindClash is off for all subquery bodies, including EXISTS/COUNT/COLLECT. The Javadoc justifies this as "a name the body binds for itself may legitimately shadow an outer one" - true for an implicit CALL { } that imports nothing. But turning the flag fully off also suppresses a clash between two declarations inside the same body, e.g.

MATCH (n:P) WHERE EXISTS { MATCH (x:P) MATCH ()-[x]->() RETURN x } RETURN n

The identical MATCH (x:P) MATCH ()-[x]->() in the outer query raises VariableTypeConflict; inside the body it now silently overwrites. This runs slightly counter to the PR's own goal ("validate a body like the query around it"). It is not a regression (bodies weren't validated at all before), so fine as a follow-up rather than a blocker - but worth a note, since separating "shadow an inherited name" (allowed) from "clash within the body" (an error) needs more than a single boolean.

2. Single-collection WITH walk rests on a builder invariant

Commit 6 drops the second getWithClauses() pass and relies on every WITH being the same instance on both getWithClauses() and getClausesInOrder(). This was validated empirically (instrumentation over 7813 tests) and is pinned by everyExpressionIsVisitedExactlyOnce. Reasonable basis, but the guarantee now lives in StatementBuilder.addWith/build by convention rather than in the type system - a future builder path that registers a WITH only on the statement would make the walk (and every check through it) silently skip it. The test guards current builder output; it can't guard a not-yet-written path. Consider a lightweight assertion in StatementBuilder itself, or keeping that comment prominent (it currently is).

3. Performance: full statement build per subquery body at parse time

parseSubqueryBody runs the entire CypherASTBuilder pipeline (rewriters included) for each EXISTS/COUNT/COLLECT/bare-pattern body, purely to give the checks something to walk - the body still executes from text. Correctly bounded (body size), paid once per distinct query text (plan cache), and honestly documented after commit 6's correction. No action needed; flagging only because the cost is real for deeply-nested subquery-heavy queries, and the follow-up you describe (correlating as an AST) would retire it.

4. Best-effort AST build defers some errors to runtime (deliberate)

The catch (CommandParsingException) fallback in parseSubqueryBody means a body the builder can't assemble keeps its text and its errors surface at execution instead of parse time. Right call to avoid regressing queries ANTLR accepts but the builder doesn't fully support, and the FINE/WARNING split in commit 9 is a nice touch for discoverability. Just noting it's a small hole in the "rejected before it starts" guarantee for those specific constructs.

Nits

  • CypherASTBuilder.getOriginalText is package-private and reached from CypherExpressionBuilder in the same package - fine, but slightly surprising as a cross-class static; a short doc wouldn't hurt.
  • Release notes are unusually thorough (a plus); the two "potentially breaking" callouts (type()-on-node and the WITH * kind survival) are the ones users will actually hit - good that both are explicit.

Overall: solid, well-tested, and the reasoning in the commit messages made this easy to follow. The only item I'd suggest tracking is #1 (intra-body kind clash) as a small follow-up.

Note: I could not execute mvn in this environment to independently confirm the green results you reported; the review above is static.

…wing an outer name

Folding the two scope builders together left one flag deciding whether a kind clash raises, off for every
subquery body. That allowed the thing it was meant to allow - a body binding a name the enclosing query uses for
something else, which an implicit CALL { } may do because it imports nothing - but it also suppressed a clash
between two declarations inside the same body. MATCH (x:P) MATCH ()-[x]->() raised VariableTypeConflict written
in the query and passed written in a body, which is the asymmetry this issue exists to remove, one level down
from where it was found.

The two are different questions, so the flag is gone rather than refined: the build now tracks which names the
scope declared itself, and a clash is a clash only between two of those. A statement inherits nothing, so every
clash there is one and the top-level behaviour is unchanged; a body raises on its own second declaration and
stays silent on its first re-declaration of an inherited name. A WITH's projection counts as declaring what it
projects, so past WITH p the body has restated p and a later clash with it is real.

aKindClashInsideABodyIsAConflictWhileShadowingAnOuterNameIsNot asserts both directions, since a check that got
only one right would either miss the conflict or reject the shadow. Both mutations fail it: never raising loses
the conflict, always raising rejects the shadow.
@lvca

lvca commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Point 1 was a real gap and it is fixed rather than tracked - 9284669. Points 2 to 4 and the nits below.

1. Intra-body kind clashes - fixed, by removing the flag rather than refining it. You put your finger on exactly the wrong shape: one boolean was answering two different questions. MATCH (x:P) MATCH ()-[x]->() raised VariableTypeConflict written in the query and passed written in a body - the asymmetry this issue exists to remove, one level down from where it was found, introduced by the unification two commits earlier.

Shadowing an inherited name and clashing with one declared here are different questions, so the build now tracks which names the scope declared itself, and a clash is a clash only between two of those:

MATCH (n:P) WHERE EXISTS { MATCH (x:P) MATCH ()-[x]->() RETURN x } RETURN n   -- VariableTypeConflict, as outside
MATCH p = (a:P)-[:KNOWS]->(b:P) CALL { MATCH (p:P) RETURN p.name AS n } RETURN n  -- shadowing, still fine

A statement inherits nothing, so every clash there is one and top-level behaviour is unchanged. A WITH's projection counts as declaring what it projects, so past WITH p the body has restated p and a later clash with it is real - which also keeps the top-level semantics after a projection exactly as they were.

aKindClashInsideABodyIsAConflictWhileShadowingAnOuterNameIsNot asserts both directions, because a check that got only one right would either miss the conflict or reject the shadow. Both mutations fail it: never raising loses the conflict, always raising rejects the shadow with Variable 'p' already defined as PATH, cannot redefine as NODE.

2. The single-collection WITH walk rests on a builder invariant. Agreed on the shape of the risk, and I am leaving it as is. A StatementBuilder assertion would fire only for a path someone is in the middle of writing, and the failure it guards is a silent loss of coverage, not a crash - so the place it has to be visible is where the walk decides to trust the invariant, which is where the comment is. The empirical check and the test cover what exists; the comment is what a future path has to read.

3. Parse-time cost / 4. best-effort fallback deferring some errors. Both correct as stated, both deliberate, no change. The fallback's hole in "rejected before it starts" is the price of not regressing queries ANTLR accepts and the builder does not fully support, and it is the smaller of the two harms.

Nit - getOriginalText. Left package-private; it is used across several classes in this package already, so a doc line rather than a visibility change is the right size, and it can ride along with the follow-up.

On the mvn -pl engine verify I promised. The first run reported BUILD FAILURE, and it was my own fault, not the branch's: I had mvn test runs going in the same module while it executed, so target/classes was rewritten under the running JVM. The failures are all NoClassDefFoundError on classes that exist (ContinuousAggregateRefresher, LSMTreeIndexBulkLoader$1) in com.arcadedb.schema.** - nothing this branch touches. Re-running it clean with nothing else against the module; I will post the real result here.

Green meanwhile: query.** + function.** 11319/0 (106 skipped). No conflict with main.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review: #5626 - walk the subquery bodies the parse-time checks claimed were validated elsewhere

Thorough, well-reasoned change. I read the whole diff and spot-checked the two invariants it rests on. Both hold, and the test suite is unusually complete for this kind of scope work. Summary: no blocking issues found; a few notes below.

What I verified

  • Phase ordering the fix depends on. nestedVarTypes inherits the full enclosing scope even for an implicit CALL { } that imports nothing, and the correctness argument is "the scope check reports an unimported reference as undefined before this phase reads a kind for it." Confirmed: validate() runs validateVariableScope (line 68) before validateFunctionArgumentTypes (line 77), and validateSubqueryScope/validateSubqueryBranchScope descend into bodies. The anUnimportedReferenceIsReportedAsUndefinedNotAsAKindError tests pin it from both the implicit and explicit-empty-scope directions. Good.
  • Dropping the getWithClauses() fallback. The old validateFunctionArgumentTypes walked WITH clauses twice (ordered list + getWithClauses()), justified by "some builder paths register it only on the statement." The new single walk relies on that not being true. Confirmed in StatementBuilder.addWith (lines 83-85): it is the one registration point and puts the same instance on both withClauses and clausesInOrder, so the ordered list is complete. everyExpressionIsVisitedExactlyOnce pins the instance-identity invariant that makes this universal, not just true for the tested query.

Code quality

  • The Consumer<Expression> -> Visitor interface with forNestedStatement is the right abstraction for "re-bind at a scope boundary," and folding the two copies of the var-kind walk into one buildVarTypes is a real de-duplication that also fixes the pre-existing WITH * asymmetry. Nicely done.
  • Removing the two anonymous BooleanExpression adapters in favor of BooleanCoercionExpression is a clear win: it kills the walker blind spot and, as the description notes, retires a latent NPE in the function-call adapter path. The redundancy with the generic arm three lines below was real.
  • Javadoc density is high but genuinely explanatory (the "shadowing vs clashing" and "end-state scope approximation" notes in particular earn their length).

Notes / things to keep an eye on (non-blocking)

  1. Broad catch (RuntimeException) in parseSubqueryBody. Logging at WARNING and returning null (best-effort) is the right call given the goal of never turning a running query into a parse failure. The tradeoff is that a genuine builder defect degrades to "body silently skips the parse-time checks" plus one log line. Since the coverage loss is invisible at runtime, a lightweight counter/metric might be worth it if you want the FINE/WARNING gaps observable in production rather than only greppable in logs. aSubqueryExpressionWithNoBodyIsALeafRatherThanAFailure covers the walk side of the fallback well.

  2. Compile-time cost. Each EXISTS/COUNT/COLLECT/CALL body now triggers a full statement build (rewriters included) at parse time. The description states this is "paid once per distinct query text since the plan is cached" and bounded by body size - correct as long as the OpenCypher plan cache keys on the raw query text (so the second execution of the same text skips the rebuild). Worth a one-line confirmation for the deeply-nested-subquery shape, since the cost compounds with nesting depth. It is compile-time only, not per-row, so the ceiling is fine.

  3. Behavior change is genuinely breaking-ish (a query that used to run can now be rejected at parse time, e.g. type(node) inside a subquery, and the new WITH * kind survival). This is correctly documented in release-26.8.1.md with the "Potentially breaking, in the same way [Misc] OpenCypher follow-ups from #5484: arity-guard blind spot, locale-dependent name folding, unimplemented registered functions #5602 was" callout, and the one existing test that changed (Issue5228...) is updated to assert the rejection identically inside and outside a subquery, which is exactly the point. No concern beyond making sure the release note ships.

Test coverage

Excellent. 22 new tests under EXPLAIN covering all four body forms, every check reaching inside (not just the type one), every body position, both no-false-rejection directions (correlated body, shadowing, imported-keeps-kind, WITH projection drop/carry), the walker once-only invariant, the null-body fallback, and the UNION-branch scoping from both branch positions. The "assert rejected identically inside and outside" pattern throughout is the right shape for an asymmetry fix.

Nice work.

@lvca

lvca commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Thanks - all three notes answered, one with a code change, two with reasons rather than changes.

2. The plan cache does key on raw query text - confirmed, and recorded where it is claimed. CypherStatementCache is a Map<String, ParsedQuery> keyed on the query string (normalised only by stripping a trailing semicolon), and the body ASTs hang off the statement inside ParsedQuery, so the whole build is reused on the second execution of the same text. You were right to ask for it rather than take it.

On the nesting shape specifically: it adds rather than multiplies. A body reached from parseSubqueryBody is built by its own visit, and the outer statement holds that subquery only as an expression - it never descends into the body a second time. So EXISTS { ... EXISTS { ... } } builds the inner body once, as part of its parent's build, not again for each level above it. Total work is linear in the text, not exponential in depth. That is now in the parseSubqueryBody Javadoc alongside the cost sentence, since a claim about a cache belongs next to the code that relies on it, not only in a review thread.

1. A metric for the fallback - not taking it, and the reason is structural. I agree the coverage loss is the invisible kind. But parseSubqueryBody is a static method in the parser: it has no database, no profiler, no Micrometer registry, and no path to one. Threading a handle through the parser purely to count an event that should never fire is a bigger change to a hot, stateless path than the observability is worth - and it would put a runtime dependency into a class whose whole job is turning text into an AST. The WARNING is the proportionate signal: it fires exactly when a builder defect degrades a body, and it names the body. If these ever turn out to happen in the field, the right move is to fix the builder gap, not to keep a counter of it.

3. Release note. Shipping in this PR - docs/release-26.8.1.md, both callouts (type(node) inside a subquery, and the WITH * kind survival) are in the "Potentially breaking" block.

Still owed: the clean mvn -pl engine verify. It is running now with nothing else against the module. I will post the result here before this is ready to merge - the first attempt's BUILD FAILURE was my own concurrent-build artifact, as described above, and I do not want that standing as the last word on it.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.58333% with 37 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.50%. Comparing base (7081057) to head (9284669).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
...uery/opencypher/parser/CypherExpressionWalker.java 85.56% 3 Missing and 11 partials ⚠️
...ery/opencypher/parser/CypherSemanticValidator.java 88.07% 5 Missing and 8 partials ⚠️
...ery/opencypher/parser/CypherExpressionBuilder.java 54.54% 9 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #5642      +/-   ##
============================================
+ Coverage     66.48%   66.50%   +0.02%     
+ Complexity     1114     1113       -1     
============================================
  Files          1771     1771              
  Lines        149675   149829     +154     
  Branches      31743    31775      +32     
============================================
+ Hits          99504    99650     +146     
- Misses        37173    37174       +1     
- Partials      12998    13005       +7     

☔ 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.

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.

[Cypher] EXISTS/COUNT/COLLECT subquery bodies escape parse-time argument validation

1 participant