Skip to content

fix(engine) #5602: close the five OpenCypher follow-ups left by #5484 - #5612

Merged
lvca merged 13 commits into
mainfrom
issue-5602-opencypher-followups
Jul 31, 2026
Merged

fix(engine) #5602: close the five OpenCypher follow-ups left by #5484#5612
lvca merged 13 commits into
mainfrom
issue-5602-opencypher-followups

Conversation

@lvca

@lvca lvca commented Jul 31, 2026

Copy link
Copy Markdown
Member

Closes #5602.

The five follow-ups recorded while fixing #5484, done together because items 1 and 3 touch the same registry.

1. The arity guard now compares something

FunctionValidator's minArgs/maxArgs became a hard parse-time gate in #5484, which is how a declaration narrower than the real signature started rejecting valid queries. The drift guard added alongside it asserted it had compared zero of the 129 registered names: no executor overrode getMinArgs()/getMaxArgs(), and the seven that reach a SQL function through SQLFunctionBridge (count, distance, stdev, stdev_pop, stdev_samp, stdevp, sum) had nothing to delegate to. distance — the entry that was actually wrong — was in that group.

The issue proposed giving SQLFunction an argument-count contract. It already inherits one from Function; what was missing is that nothing used it, and adding bounds next to each executor's hand-written if (args.length != N) would just be a second hand-written copy free to drift from the first. So instead each executor declares its arity once and its runtime guard reads that declaration (Function.checkArity). The bridge passes the wrapped SQL function's bounds through. What the guard compares is therefore the parser's view against the code's, not two copies of the same number.

All 129 names are checked at build time. Two are pinned as deliberately narrower in Cypher than in SQL — count (whose star form is a separate parser construct) and sum (SQL's is variadic per row) — and each pin is asserted to still bite, so it cannot quietly become a hole.

Wire-contract change: the shared function layer's runtime arity guards answered CommandExecutionException (HTTP 500) while the parser answered CommandSemanticException (400) for the same mistake. Both now say Function 'x' expects N arguments but got M with the client-error class. This is the correction #5484 made at the parse-time gate, applied to the runtime side — but it is broader than the literal ask, so it is called out here.

2. Locale-dependent case folding

#5484 fixed function names, where a Turkish default made "ISNAN".toLowerCase() the dotless "ısnan". The issue listed seven remaining sites; there were more of the same defect — the EXPLAIN/PROFILE prefix scan, IS :: type names, temporal unit names, vector metric names, the graph functions' direction argument. All fold with Locale.ROOT, plus a test that reads the sources: the two forms behave identically under every locale CI runs in, so nothing else would notice a new one.

3. Registered-but-unimplemented functions

charLength → alias of the already-implemented char_length. isNormalized(input[, normalForm]) → implemented as the boolean counterpart of normalize(), sharing its form parsing so the two cannot accept different form names. charAt → unregistered; it names no function in Neo4j either, so it is now rejected up front with the ordinary unknown-function error instead of parsing and then failing at execution. KNOWN_WITHOUT_EXECUTOR is empty and stays pinned. An unknown-function error also echoes the spelling written rather than the folded one.

4. Parse-time validation beyond RETURN/WITH

MATCH (n:Nothing) WHERE abs('x') > 0 RETURN n ran to completion — and, matching no row, looked like a success — while the identical call in a RETURN was rejected before the query started. One CypherExpressionWalker replaces the three partial per-clause recursions, so the checks reach WHERE, UNWIND, SET, CREATE, MERGE, DELETE, FOREACH, ORDER BY, SKIP/LIMIT and inline pattern properties. No check is new — only its reach. A call that does execute still fails with the same message from the function's own guard.

5. Arithmetic errors

abs(-9223372036854775808), 9223372036854775807 + 1 and 1 / 0 have no representable answer, decided by the caller's values and not by anything wrong with the server; all answered HTTP 500. Per the design call on the issue, the whole category moves together: ArithmeticErrorException for 64-bit overflow and for division/modulo by zero — including duration(...) / 0, which escaped as a raw java.lang.ArithmeticException and reached the wire as an unrecognised throwable. HTTP answers 400, Bolt answers Neo.ClientError.Statement.ArithmeticError.

Tests

  • CypherFollowUpsIssue5602Test — 20 tests across items 2-5, including a Turkish-locale run and a source scan for new default-locale folding.
  • CypherFunctionArityRegistryTest — the isZero() pin replaced by full-coverage assertions; isNormalized, normalize and coalesce added to the per-arity sweep.
  • Issue5602ArithmeticErrorHttpStatusIT — 400 end to end, including the write path where the auto-commit wrapper re-wraps the failure.
  • BoltErrorClassificationTest — three cases for the new status code, including wrapped-as-cause and conflict-wins-over.
  • TextStatelessFunctionsTest — arity assertions updated to the client-error class.

Green: engine query.** + function.** 11273/0, server 777/0, bolt 280/0, the new IT 4/0.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBYJ4njo4cYiU9VqTTAr8o

1. The argument-count guard could not reach the seven functions that go through
   SQLFunctionBridge - distance among them, where the #5484 bug actually was -
   and asserted it had compared zero of the 129 registered names, because no
   executor declared getMinArgs()/getMaxArgs(). Every executor now declares its
   contract and enforces it from that declaration (Function.checkArity), so
   there is one number per function instead of a hand-written bound beside a
   hand-written if; the bridge passes the wrapped SQL function's through. count
   and sum are pinned as deliberately narrower in Cypher than in SQL, and the
   pin is asserted to still bite. A wrong argument count is now a client error
   (400) from the runtime guards too, matching the parse-time gate.

2. Case folding no longer depends on the server's default locale. #5484 fixed
   function names; the same pattern survived in procedure names, variable names,
   IS :: type names, the EXPLAIN/PROFILE prefix scan, temporal units, vector
   metrics and the graph functions' direction argument. A test reads the sources
   so a new one cannot slip in - the two forms differ only under a locale CI
   never runs in.

3. charLength() and isNormalized() work, charAt() is gone. All three parsed and
   then failed at execution with "Unknown function". charLength is an alias of
   char_length, isNormalized is the boolean counterpart of normalize() sharing
   its form parsing, and charAt - which names no Neo4j function either - is
   unregistered. An unknown-function error now echoes the spelling written.

4. Parse-time argument validation walked RETURN and WITH only, so the same bad
   call was rejected before the query ran or not depending on its clause. One
   CypherExpressionWalker replaces the three partial per-clause recursions and
   the checks now reach WHERE, UNWIND, SET, CREATE, MERGE, DELETE, FOREACH,
   ORDER BY, SKIP/LIMIT and inline pattern properties. No check is new.

5. An arithmetic error is the caller's, not the server's. Integer overflow and
   division by zero (including duration(...)/0, which escaped as a raw JDK
   ArithmeticException) raise ArithmeticErrorException: HTTP 400 and Bolt
   Neo.ClientError.Statement.ArithmeticError, as Neo4j classifies them. It
   extends CommandExecutionException, so code written against #5164/#5494 is
   unaffected, and float arithmetic keeps IEEE 754 semantics.
@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

…-followups

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

codacy-production Bot commented Jul 31, 2026

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 1 minor

Alerts:
⚠ 1 issue (≤ 0 issues of at least minor severity)

Results:
1 new issue

Category Results
CodeStyle 1 minor

View in Codacy

🟢 Metrics 74 complexity

Metric Results
Complexity 74

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.

…pin at 500

Issue5221CypherDivByZeroHttpStatusIT pinned `1 / 0` to HTTP 500, on the reasoning
that a zero divisor is data-dependent in the general case (n.a / n.b) and so a
runtime error rather than a client error. That argument does not survive contact
with the rest of the engine: abs(n.name) is data-dependent in exactly the same
way and has answered 400 since #5484. What decides the status is whose mistake it
is, and Neo4j classifies the whole arithmetic category - division by zero and
64-bit overflow alike - as Neo.ClientError.Statement.ArithmeticError.

What #5221 was actually about is unchanged and still asserted: the label stays
"Cannot execute command" rather than the misleading "Error on transaction
commit", and the detail still names the real cause. ArithmeticErrorException
extends CommandExecutionException, so the #5219 classification an embedded caller
sees is intact.
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Code Review: #5602 - close the five OpenCypher follow-ups left by #5484

Reviewed the full diff (~130 files). This is a high-quality, unusually well-documented change: the Javadoc and the release note both explain why each decision was made, the wire-contract change (HTTP 500 -> 400) is called out explicitly rather than smuggled in, and the test coverage (CypherFollowUpsIssue5602Test, CypherFunctionArityRegistryTest, Issue5602ArithmeticErrorHttpStatusIT, BoltErrorClassificationTest) is thorough - including a Turkish-locale run and a source scan that keeps a new default-locale fold from slipping back in. Nice work overall.

A few observations, none blocking:

Design

  • Function.checkArity centralizing the arity guard is the right call - one declaration per function (getMinArgs/getMaxArgs) that both the runtime guard and the build-time registry test read, instead of a hand-written if free to drift from the parser's view. The SQLFunctionBridge/DistinctAggregationWrapper/OrNullFunction delegation is consistent, and OrNullFunction correctly calls checkArity before its swallow-everything try/catch so a wrong arg count still reports instead of collapsing to null (good catch, and the comment says so).

  • Minor layering smell: the core com.arcadedb.function.Function interface now imports com.arcadedb.function.cypher.CypherFunctionHelper for the default checkArity. A generic abstraction reaching into a Cypher-specific helper is a slight inversion. It is contained within the engine module and harmless in practice (SQL functions reach arity via the bridge, not checkArity), but if Function is meant to stay query-language-neutral, the message-formatting helper (argumentCountDescription) might belong somewhere more neutral.

Nit

  • Unused import: engine/.../function/misc/IsEmptyFunction.java still imports com.arcadedb.exception.CommandExecutionException, but after the change the class only references it in a comment - the arity path now uses checkArity and the domain error uses CypherFunctionHelper.typeMismatch. Safe to drop the import.

Performance (informational, not a regression)

  • checkArity runs per execute() call, i.e. per row, reading two trivial constant-returning getters. This is equivalent to the previous inline if (args.length != N) and JIT will inline it, so no regression. Worth noting only that the argument count is fixed for the whole query (it is an AST property), so this check is technically redundant per-row - but that predates this PR and FunctionValidator already catches most cases at parse time. Not worth changing here.

Correctness spot-checks (all look right)

  • Arithmetic classification walks the whole cause chain with a depth cap of 32 in both BoltNetworkExecutor.isArithmeticError and AbstractServerHttpHandler.arithmeticError (guards a self-referential chain) - and the cap and comment match between the two. The isArithmeticError branch is correctly ordered before the generic CommandExecutionException -> 500 fallback in both the wrapped (TransactionException) and un-wrapped HTTP arms, and the Bolt classifier keeps a retryable conflict winning over an arithmetic error (tested).
  • ArithmeticErrorException extends CommandExecutionException preserves embedded catch-block behavior (64-bit integer arithmetic overflow silently wraps around instead of failing #5164/abs(Long.MIN_VALUE) silently returns a negative value instead of throwing an overflow error #5494) while letting the wire layers single it out - the right trade-off, and documented.
  • Float arithmetic left untouched (1.0/0.0 -> Infinity, 0.0/0.0 -> NaN); integer div/mod-by-zero and 64-bit overflow (incl. Long.MIN_VALUE / -1 via Math.divideExact, abs(Long.MIN_VALUE) via Math.absExact, and duration(...) / 0) now raise the client error.
  • CypherExpressionWalker looks comprehensive - single traversal covering the nested expression shapes, with a sensible stop at subqueries kept as unparsed text; charLength aliased to char_length, isNormalized sharing NormalizeFunction.parseNormalForm, charAt unregistered.
  • Locale-dependent folds moved to Locale.ROOT.

Note: I could not run the Maven build in this environment (build commands needed approval), so I relied on static inspection plus the green results reported in the PR description. Static checks found no import/usage mismatches beyond the one unused import above.

…of the Cypher helper

Code-review point: the query-language-neutral com.arcadedb.function.Function
interface reached into com.arcadedb.function.cypher.CypherFunctionHelper for the
message its own checkArity raises - an inversion, since nothing about counting
arguments is Cypher-specific.

The three helpers move to com.arcadedb.function.FunctionArity, in the same
package as Function, so the base interface needs no cross-package import at all.
The five call sites follow; nothing is left behind in CypherFunctionHelper, so
there is still one home for the wording.

Also drops two imports left unused by the earlier commits (IsEmptyFunction,
ArithmeticExpression); the one in Function is kept because its checkArity javadoc
links CommandSemanticException.
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review: #5602 - OpenCypher follow-ups

Reviewed the full diff (~130 files). This is a high-quality, unusually well-documented PR: the Javadoc and release notes explain not just what changed but why, and the test coverage (arity registry sweep, Turkish-locale run, source-scan for new default-locale folding, end-to-end HTTP IT, Bolt classification) is genuinely strong. The core design idea - each executor declares its arity once and both the runtime guard (Function.checkArity) and the parse-time registry test read that single declaration, so the two cannot drift - is the right shape and cleanly removes the hand-written if (args.length != N) duplication.

A few notes, mostly for awareness rather than blockers.

1. Widening parse-time validation is a behavior change worth flagging as potentially breaking

Item 4 moves argument/type validation from RETURN/WITH only to essentially every clause. That is correct and matches Neo4j, but it does reject queries that previously ran to completion - not only the MATCH (n:Nothing) WHERE abs('x') > 0 example, but also statically-bad calls sitting in branches that would never execute at runtime (e.g. RETURN CASE WHEN false THEN abs('x') ELSE 1 END). The PR frames this purely as "when the client is told, not what," which is accurate for reachable calls, but for a user relying on the old lenient behavior this is a compatibility change. Suggest the release note call it out explicitly as potentially breaking.

2. Two co-existing argument-count mechanisms with different error classes

checkArity(args) now throws CommandSemanticException (HTTP 400), but the older Function.validateArgs(args) (throws IllegalArgumentException) still exists and is still invoked for functions reached via the CALL path (CallStep.java:367). So the same wrong-arg-count mistake can surface as a 400 through normal expression evaluation but as an IllegalArgumentException (likely 500) through CALL. Not introduced by this PR, but since this PR is unifying arity handling it would be a natural place to either route validateArgs through checkArity semantics or leave a short comment on why they differ.

3. checkArity null-array folding - verified safe, worth a note

The Javadoc folds the old hand-written args == null guards into checkArity (treating null as 0 args). I checked the two executors that had those guards removed (CypherPointFunction min=1, CypherPointDistanceFunction min=2): both have getMinArgs() >= 1, so checkArity(null) throws before any args[0] dereference. Safe. The only latent trap would be a future getMinArgs()==0 executor that dereferences args when the array itself is null - checkArity would pass it through. Worth a one-line caution on checkArity that min=0 executors must still tolerate an empty/null array.

Things I specifically verified as correct

  • checkIntegerDivisorNotZero throws ArithmeticErrorException (extends CommandExecutionException, not java.lang.ArithmeticException), so the surrounding catch (ArithmeticException) in integerArithmetic does not swallow the "/ by zero" message and rewrap it as "long overflow" - a subtle trap that was avoided. Long.MIN_VALUE / -1 still correctly rewraps as "long overflow".
  • The HTTP arithmetic branch scans the whole cause chain (depth-capped at 32) and is positioned so it cannot be shadowed by the duplicate-key / parsing branches; the Bolt classification correctly keeps a retryable conflict winning over an arithmetic error.
  • Locale fixes all use Locale.ROOT; SQLFunctionDuanSSSP relies on its existing import java.util.* for Locale, so it compiles.
  • SQLFunctionBridge delegating getMinArgs/getMaxArgs to the wrapped SQL function is what finally lets the registry test cover the seven bridged functions incl. distance() (min=2,max=3), the original [OpenCypher] abs() returns HTTP 500 for invalid argument instead of client-facing error #5484 defect.

Note: I could not run mvn in this environment (sandbox-gated), so I did not independently reproduce the green test run the PR reports; the review above is static.

Nice work - the NARROWER_IN_CYPHER pin with the "must still bite" assertion is a particularly good touch for keeping a stricter-parser regression from hiding behind a stale pin.

…rity()

Code-review point: two argument-count mechanisms with different error classes
co-existed. checkArity() raises CommandSemanticException (HTTP 400), while the
older validateArgs() raised its own IllegalArgumentException with its own
wording - and validateArgs() is what the CALL path invokes
(CallStep.executeFunction). CallStep wraps what it catches, and the HTTP layer
unwraps exactly one cause level, so `CALL fn(<wrong count>)` surfaced as 500
while the identical mistake in an expression gave 400.

validateArgs() now delegates to checkArity(), so there is one check, one message
and one status however the function was reached. Procedure declares a separate
validateArgs() of its own; procedures are a different abstraction with their own
registry, their own CALL handling and around eighty implementations, so that one
is deliberately left alone and the reason is recorded on the method.

Also documents that checkArity()'s null-array tolerance only rejects the null for
a function requiring at least one argument - a getMinArgs()==0 executor is handed
it unchanged and must still tolerate it - and flags the widened parse-time
validation as potentially breaking in the release note.
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review: OpenCypher follow-ups (5602)

Reviewed the full diff. This is a high-quality, unusually well-documented PR: the arity contract is declared once per function and held to the parser's view by a build-time test guard, the locale fix ships with a source-scanning regression test, the three partial per-clause recursions collapse into one CypherExpressionWalker, and the new client-error classification is consistent across HTTP (400) and Bolt (Neo.ClientError.Statement.ArithmeticError), with the retryable-conflict-wins ordering explicitly tested. Nice work.

A few points, mostly minor:

1. The 'embedded catch blocks unaffected' guarantee does not hold for the arity change. Function.checkArity now throws CommandSemanticException, which extends CommandParsingException - a different branch from CommandExecutionException. The old hand-written guards threw CommandExecutionException. So embedded code that caught CommandExecutionException around a call to catch a wrong-argument-count now misses it. This is worth calling out because the PR takes deliberate care to preserve exactly this property for the arithmetic side (ArithmeticErrorException extends CommandExecutionException, 'existing catch blocks are unaffected'), while the arity change silently moves to another hierarchy branch. The runtime-side change is defensible (it now matches the parse-time gate, and arity really is a client error), but the two categories are treated asymmetrically and only one is documented as such. Consider a sentence in the release notes clarifying that the arity exception type, not just the HTTP status, changed.

2. normalize() and isNormalized() disagree on non-STRING input. IsNormalizedFunction rejects a non-CharSequence argument with typeMismatch(...) (isNormalized(123) -> TypeError), but NormalizeFunction.execute calls args[0].toString() on any type, so normalize(123) returns '123' normalized rather than a type error. Neo4j declares both as f(input :: STRING). The PR states the two cannot diverge on the form names (shared parseNormalForm), which is true, but they do diverge on input-type handling. Low priority, but since the pair is being aligned here it would be natural to make normalize() reject non-STRING the same way.

3. SQLFunctionBridge.execute() does not call checkArity(). The seven bridged functions (count, sum, distance, stdev*) get their bounds passed through for the parse-time gate and the registry test, but the bridge's execute() calls sqlFunction.execute(...) directly without a runtime checkArity, unlike every Cypher executor. In practice the parser gate covers known-function calls, so this is not a regression - just noting that the 'each executor's runtime guard reads its declaration' story has this one exception.

4. WITH clauses can be walked twice. validateFunctionArgumentTypes walks WITH via walkClause(WITH ...) and again via the statement.getWithClauses() loop. The code comments that this is harmless because the checks are pure and the first error throws - agreed, and it is parse-time only - so this is just an observation, not a request to change.

Test coverage. Strong. The noRegisteredSignatureIsNarrowerThanWhatItsExecutorDeclares guard with the uncovered count assertion is a nice touch - it prevents a future executor from silently dropping its declaration and reducing coverage. The Turkish-locale run, the wrapped-as-cause and conflict-wins Bolt cases, and the end-to-end 400 IT (including the auto-commit re-wrap path) cover the important edges.

Note: I could not compile/run the suite in this review environment (Maven blocked), so the above is a static review; the author reports the relevant modules green.

Overall: LGTM with the above as optional follow-ups. Point 1 is the only one I would suggest addressing before merge, and even that only as a documentation clarification.

…rces its own arity

Three code-review points.

normalize() and isNormalized() disagreed on their input domain. The pair shares
its normal-form parsing, so the form names could not diverge, but normalize()
called toString() on whatever arrived: normalize(123) quietly answered '123'
where its new counterpart raised a type error, and where Neo4j - which declares
both as f(input :: STRING) - raises one. It now rejects a non-STRING the same
way, which is the treatment size() and head() got in #5477 and #5476.

SQLFunctionBridge.execute() passed its arguments straight through without the
runtime arity guard every other executor runs, so the seven bridged functions
were the one exception to "each executor's guard reads its own declaration". It
now calls checkArity() from the wrapped SQL function's bounds, which can only
reject a call that function could not have served anyway.

The release note now also states that the arity exception *type* changed, not
only the HTTP status: CommandSemanticException extends CommandParsingException,
a different branch from the CommandExecutionException the old runtime guards
threw, so embedded code catching the latter for a bad argument count has to
catch the former. That is the opposite of the arithmetic change, which stayed
inside CommandExecutionException on purpose, and the note explains why the two
categories legitimately differ.
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Code review: #5602 OpenCypher follow-ups

I read through the arity infrastructure (Function, FunctionArity, SQLFunctionBridge, the ~90 executors), the arithmetic-error path (ArithmeticErrorException, ArithmeticExpression, CypherDuration), the new CypherExpressionWalker, FunctionValidator, the locale fixes, and the Bolt/HTTP wire mapping. This is a large but genuinely well-structured change, and the documentation (PR body plus the Javadoc/comments in the code) is excellent - it explains why each decision was taken, which will age well.

What is strong here

  • checkArity "declare once" design. Reading the runtime guard from the same getMinArgs()/getMaxArgs() declaration the registry test verifies removes the "second hand-written copy free to drift" problem cleanly. Wiring the seven SQLFunctionBridge functions through sqlFunction.getMinArgs() closes the exact hole (distance()) that motivated [OpenCypher] abs() returns HTTP 500 for invalid argument instead of client-facing error #5484.
  • Arithmetic classification. ArithmeticErrorException extends CommandExecutionException keeps embedded catch blocks working while letting the wire layers single it out. Nice that both BoltNetworkExecutor.isArithmeticError and the HTTP arithmeticError(...) walk the full cause chain with a depth cap - robust to the auto-commit TransactionException re-wrap, and directly tested (wrapped-as-cause, conflict-wins-over).
  • CypherExpressionWalker. Collapsing three partial per-clause recursions into one traversal is the right call, and "no check is new, only its reach" is accurate. Good that it stops at unparsed subquery text and documents it.
  • Test coverage is substantial (22 + 4 + 13 across the three new/updated suites, plus the registry full-coverage sweep and the Turkish-locale + source-scan guard against new default-locale folding).

One concrete concern worth verifying

The CALL path re-wraps a client-side arity error into a CommandExecutionException, and the HTTP handler only unwraps one level for the parsing family.

CallStep.executeFunction (engine .../executor/steps/CallStep.java:364-377):

try {
  function.validateArgs(args);       // now throws CommandSemanticException (a CommandParsingException)
  return function.execute(args, context);
} catch (final Exception e) {
  ...
  throw new CommandExecutionException("Error executing function: " + function.getName(), e);
}

A wrong-arity CALL f(...) therefore yields CommandExecutionException(cause = CommandSemanticException). The handler rescues that in the un-wrapped arm via realException instanceof CommandParsingException (AbstractServerHttpHandler.java:490) - one level of getCause() - so a direct request returns 400. But the TransactionException arm (lines 563-593) also inspects only one level (realException = e.getCause()). If the auto-commit wrapper re-wraps the failure, the chain becomes TransactionException -> CommandExecutionException -> CommandSemanticException; realException is the CommandExecutionException, the instanceof CommandParsingException check misses it, and it falls through to the CommandExecutionException arm -> HTTP 500.

Contrast the arithmetic branch, which walks the whole chain and is immune to the same double-wrap. The arity change is the mirror image of the arithmetic change but doesn't get the same chain-walk treatment.

Practical exposure is probably low - for functions known to FunctionValidator the parse-time gate rejects wrong arity before execution, so the runtime guard is defense-in-depth. But the stated goal is "a wrong argument count is a client error everywhere, from the runtime guards too," and that guarantee has a seam on the CALL + auto-commit path. Options:

  1. In CallStep.executeFunction (and the procedure/custom-function arms) rethrow CommandParsingException/CommandSemanticException as-is instead of wrapping in CommandExecutionException; or
  2. Make the handler's parsing check walk the cause chain the way arithmeticError(...) already does.

A targeted test - wrong-arity CALL over HTTP on a write/auto-commit path asserting 400 - would confirm whether this is real or already covered.

Minor

  • Function.checkArity documents that a null args array counts as zero and is passed through unchanged for getMinArgs() == 0 functions; worth a quick scan that no min == 0 executor does an unconditional args[0] after checkArity (I didn't spot one, but the contract now formally allows a null array to reach execute).
  • FunctionArity.describe treating both -1 and Integer.MAX_VALUE as "at least N" nicely reconciles the parser's -1 convention with the executors' Integer.MAX_VALUE default - just noting it's load-bearing for message consistency.

Note: I reviewed the merged tree statically and did not rebuild locally, so I'm relying on the PR's stated green runs for compilation/tests. Overall a high-quality PR; the one item I'd want closed before merge is confirming the CALL + auto-commit arity path returns 400.

…cution error

Code-review point, and a real seam. CallStep wrapped everything it caught in a
CommandExecutionException naming what was being called, which threw the
classification away: `CALL f(<wrong count>)` became CommandExecutionException
carrying the real CommandSemanticException as its cause. The HTTP layer unwraps
one level, so a plain request still answered 400 - but on the auto-commit path
the chain is TransactionException -> CommandExecutionException ->
CommandSemanticException, one level too deep, and the same mistake answered 500.

Fixed at the root rather than in the wire layer: a client error
(CommandParsingException and its subclasses) is rethrown untouched, so every
consumer benefits at once - HTTP, Bolt and embedded callers - instead of each
learning to dig further down the chain. Both call sites shared identical catch
arms, which collapse into one.

Issue5602ArithmeticErrorHttpStatusIT covers it end to end: a wrong-arity CALL on
a write statement, asserted at 400.

Also makes the five zero-minimum temporal constructors treat a null argument
array as empty. They dereferenced it before this branch too, so it is not a
regression, but checkArity()'s documented contract now formally lets a null
through for a getMinArgs()==0 function, so the contract and the code agree.
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review: #5602 OpenCypher follow-ups

Reviewed the full diff against CLAUDE.md conventions. This is a high-quality, well-scoped PR: the exceptionally detailed commit/PR narrative maps cleanly onto the code, and the design decisions are sound. Summary below, followed by the one concrete gap I found and a few minor notes.

What works well

  • Single-source-of-truth arity. Moving each executor's argument-count contract into getMinArgs()/getMaxArgs() and having both the runtime guard (Function.checkArity) and the registry test read that same declaration is the right fix. It eliminates the "hand-written if beside a hand-written bound" drift that #5484's distance() bug was an instance of. SQLFunctionBridge passing the wrapped SQL function's bounds through closes the one blind spot (count/distance/stdev*/sum).
  • FunctionArity placement. Hoisting the shared message/exception out of the cypher package so the language-neutral Function interface no longer reaches into a cypher helper is a correct dependency-direction cleanup.
  • ArithmeticErrorException extends CommandExecutionException. The right call - embedded catch blocks written against #5164/#5494 are unaffected, while the wire layers can single it out. Nice that checkIntegerDivisorNotZero throws it outside the catch (ArithmeticException) in integerArithmetic, so the precise "/ by zero" message survives instead of being re-wrapped as "long overflow".
  • Cause-chain classification. Both BoltNetworkExecutor.isArithmeticError and the HTTP handler's arithmeticError(...) walk the chain with a depth cap (32) against self-referential chains, and the Bolt path correctly keeps a retryable conflict winning over the (non-retryable) arithmetic error - covered by aRetryableConflictStillWinsOverAnArithmeticError. The HTTP handler adds the branch symmetrically in both the un-wrapped and auto-commit-wrapped arms.
  • CypherExpressionWalker. Collapsing three partial per-clause recursions into one traversal is a real maintainability win, and the exhaustive switch over expression types with an explicit default leaf comment is clear. Float IEEE-754 semantics are correctly left untouched.
  • Test coverage is strong: the registry test's noRegisteredSignatureIsNarrowerThanWhatItsExecutorDeclares with the uncovered.isEmpty() backstop (so a future executor silently dropping its declaration fails the build rather than reducing coverage), the NARROWER_IN_CYPHER pins that must "still bite", the Turkish-locale run, and the source-scan test are all thoughtful.

One concrete gap: top-level SKIP/LIMIT are not walked at parse time

CypherSemanticValidator.validateFunctionArgumentTypes walks the top-level RETURN items and walkOrderBy(statement.getOrderByClause(), ...), but not statement.getSkip() / statement.getLimit(). By contrast walkWith(...) does walk withClause.getSkip() / getLimit(). Both are real top-level expressions - CypherExecutionPlan evaluates statement.getSkip()/getLimit() for the final RETURN.

Effect: a bad call in a top-level SKIP/LIMIT (e.g. ... RETURN n LIMIT abs('x', 'y')) escapes the parse-time check, while the identical call in a WITH ... LIMIT ... is caught - the exact clause-dependent asymmetry item #4 set out to remove. Low severity (the runtime guard still catches it, and function calls in SKIP/LIMIT are uncommon), but adding

CypherExpressionWalker.walk(statement.getSkip(), checks);
CypherExpressionWalker.walk(statement.getLimit(), checks);

next to the walkOrderBy(statement.getOrderByClause(), checks) call would make the top-level path match walkWith and fully honor the stated goal.

Minor notes (non-blocking)

  • Function.checkArity now runs per-row for scalar/aggregation functions on the hot path. It's two int comparisons, so negligible, and no worse than the if (args.length != N) checks it replaces - just noting it's consistent with the performance mantra.
  • checkArity treats null args as zero, so a getMinArgs() == 0 executor is still handed the null array unchanged and must tolerate it. This is documented in the Javadoc; worth keeping in mind for any future min-0 function that does args[0] after checkArity.
  • In CypherFunctionArityRegistryTest.noRegisteredSignatureIsNarrowerThanWhatItsExecutorDeclares, getFunctionExecutor failures are silently continued, but the uncovered.isEmpty() assertion (combined with everyFunctionKnownToTheParserHasAnExecutor) backstops that, so it's fine.

Overall: well-designed, well-tested, and faithful to repo conventions (final params, Locale.ROOT folding, client-vs-server error classification). The only actionable item is the top-level SKIP/LIMIT walk for full consistency with the WITH path.

Code-review point. walkWith() walks a WITH's SKIP and LIMIT, but the top-level
ones hang off the statement rather than off a clause entry and were reached by
neither walk. So `RETURN n LIMIT abs('x')` was not rejected while
`WITH n LIMIT abs('x')` was - the clause-dependent asymmetry this widening
exists to remove, in the last place it survived.

The regression test asserts it through EXPLAIN, which parses and plans without
executing. The plain form proves nothing: SKIP/LIMIT are evaluated even when the
match yields no row, so the function's own runtime guard catches them either way,
and a test written that way passes with the fix reverted. Verified by reverting
it - under EXPLAIN the two top-level forms stop throwing while the WITH and WHERE
forms still do.
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

PR Review: Cypher follow-ups (#5602)

Reviewed the full diff (139 files vs. base). This is a careful, well-documented PR. The Javadoc-as-rationale style makes the intent of each change auditable, and the "declare arity once, guard reads the declaration" refactor is the right fix for the class of drift that produced the original distance() bug. Findings below, ordered by importance.

Correctness - looks solid

  • Arity unification (Function.checkArity + FunctionArity + SQLFunctionBridge pass-through): closes the real gap the drift guard could not previously check (the 7 bridged SQL functions). Delegation via DistinctAggregationWrapper/OrNullFunction/SQLFunctionBridge is consistent, and OrNullFunction correctly calls checkArity before the swallow-all try/catch so a wrong count is not masked as null.
  • CallStep.rethrowPreservingClientErrors: relies on CommandSemanticException extends CommandParsingException (verified), so arity/type client errors are rethrown untouched and the auto-commit double-wrap no longer degrades 400 to 500. Correct.
  • Arithmetic classification: ArithmeticErrorException extends CommandExecutionException keeps 64-bit integer arithmetic overflow silently wraps around instead of failing #5164/abs(Long.MIN_VALUE) silently returns a negative value instead of throwing an overflow error #5494 catch blocks working while letting the wire layers single it out. The HTTP arithmeticError()/Bolt isArithmeticError() walk the whole cause chain (depth-capped at 32) because the exception is wrapped differently per path - the duration(...) / 0 raw-java.lang.ArithmeticException leak is a good catch.
  • HTTP branch ordering: the new arithmetic arm sits before the generic else/500 and after the more-specific 409 arms, so the CommandExecutionException subtype is not shadowed. Bolt checks retryable-conflict first, preserving managed-transaction retry.
  • Locale folding: no new default-locale toLowerCase()/toUpperCase() in code (grep confirms only comments/test strings), and the source-scan regression test guards against reintroduction.

Minor / latent

  1. checkArity does not honor the -1 "unlimited" sentinel that FunctionArity.describe does. describe(min, max) treats both -1 and Integer.MAX_VALUE as "no limit", but Function.checkArity uses a raw actualArgs > getMaxArgs(). If a future Function implementation followed the FunctionValidator convention and returned -1 for a variadic getMaxArgs(), checkArity would reject every call (everything is > -1) while describe would still print "at least N". No current implementation returns -1 (SQL default is MAX_VALUE, all others explicit), so this is latent only - but the two helpers disagreeing on the sentinel is a trap worth closing, e.g. normalize -1 to MAX_VALUE inside checkArity (or document that the Function-side max must be MAX_VALUE, never -1).

  2. User-visible message change. Runtime arity errors move from abs() requires exactly 1 argument(s), got N to Function 'abs' expects 1 argument but got N, and status 500 -> 400. Intentional and documented; flagging as a wire-contract change for anyone asserting the old text (the PR updates TextStatelessFunctionsTest, but external consumers may notice).

  3. CypherExpressionWalker default branch is silent. New expression AST types that nest a function call fall into default -> {} and silently escape parse-time validation until someone adds a case. Reasonable trade-off, but a one-line Javadoc note ("add a case here when introducing an expression type that nests expressions") would help the next maintainer.

Tests

Coverage matches the change surface: full-coverage arity registry sweep (replacing the vacuous isZero() pin), Turkish-locale + source-scan folding tests, end-to-end 400 IT including the auto-commit write path, and Bolt classification incl. wrapped-as-cause and conflict-wins-over. The NARROWER_IN_CYPHER pins for count/sum asserted to still bite is a good touch.

Note

I could not run the build in this environment (Maven invocation blocked), so the review is static; the green results in the description are consistent with what I read.

Overall: high quality and well-reasoned. The only actionable item is the -1-sentinel inconsistency in checkArity (latent).

Code-review point, latent but a real trap. "No limit" has two spellings - the
Cypher registry writes -1, Function.getMaxArgs() defaults to Integer.MAX_VALUE -
and FunctionArity.describe() already accepted both while checkArity() compared
raw against getMaxArgs(). A Function written to the registry's convention would
therefore have rejected every call, since any count exceeds -1, while the message
went on describing it as taking "at least N".

Both now resolve the maximum through FunctionArity.effectiveMax(), so the check
and the message cannot disagree about what unbounded means. No implementation
returns -1 today; the test pins both spellings so none can start.

Also notes on CypherExpressionWalker that its default arm treats an unrecognised
type as a leaf, so an expression type added later without a case here hides
whatever it nests from every check that runs through the walker.
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Code Review

Reviewed the five follow-ups end to end (arity contract + registry guard, locale folding, missing functions, walker reach, arithmetic classification). This is high-quality work: the design is coherent, the Javadoc explains the why rather than the what, and the test coverage (build-time registry sweep, Turkish-locale run, source scan for new folding sites, 400 end-to-end IT including the auto-commit re-wrap path, Bolt classification incl. wrapped-cause and conflict-wins) is unusually good. The single-declaration-per-function approach (executor declares arity, checkArity/SQLFunctionBridge read it, registry test holds the parser to it) is the right fix for the class of bug distance() had, rather than a second hand-written copy free to drift.

A few observations, all minor:

1. OPTIONAL CALL now swallows arithmetic and arg-count client errors. CallStep.executeFunction returns null on any Exception when callClause.isOptional(), before rethrowPreservingClientErrors. With this PR, ArithmeticErrorException and the runtime CommandSemanticException from checkArity are Exceptions, so an OPTIONAL CALL with a wrong arg count or an overflow inside an optional call silently yields null rather than the 400 the same call gives outside OPTIONAL. This is pre-existing behavior, but the reclassification slightly widens what gets swallowed. Worth confirming that is intended (Neo4j treats OPTIONAL CALL as suppressing cardinality, not client errors).

2. CypherExpressionWalker treats PatternComprehensionExpression as a leaf. The default arm makes any unlisted composite type a leaf, and PatternComprehensionExpression nests a projection expression that can hold a function call. That inner expression will not reach parse-time validation. Consistent with the documented "no check is new, only its reach" scope and not a regression (the old partial recursions did not reach it either), but since the class Javadoc explicitly warns that a new composite type silently hides its contents, it would be good to either add the case or note in the code why pattern comprehensions are deliberately out of scope.

3. Wire-contract change is correctly called out. Runtime arity guards now throw CommandSemanticException (a CommandParsingException subtype) instead of CommandExecutionException. Embedded callers that previously caught CommandExecutionException for a wrong arg count during execution will no longer catch it there. You have flagged this prominently in the PR body and the validateArgs Javadoc, so this is just confirming it is a conscious break, not an oversight.

Nits:

  • FunctionValidator.FunctionSignature lowercases name in its constructor while registerFunction already lowercases the map key - harmless double-fold, could drop one.
  • The cause-chain walks in AbstractServerHttpHandler, BoltNetworkExecutor.isArithmeticError and isRetryableConflict all independently cap at depth 32 - fine, just noting the constant is duplicated four times.

Nothing here is blocking. Could not run the full Maven build in this environment (sandbox), so I am trusting the reported green suites; the changes I read are internally consistent and should compile.

Reviewed with Claude Code.

… and pattern comprehensions are walked

Two code-review points.

OPTIONAL CALL returned null on any exception, so a wrong argument count or a bad
argument type inside it answered an empty result instead of the 400 the same call
gives outside OPTIONAL - hiding the mistake behind something that looks like a
legitimately empty match. OPTIONAL suppresses cardinality, not malformed calls,
which is also how Neo4j reads it, so a client error now propagates from both the
function and the procedure arm. That makes the rethrow helper added a commit ago
redundant: the dedicated catch arm precedes the generic one, so the wrap collapses
back to a plain CommandExecutionException.

CypherExpressionWalker treated PatternComprehensionExpression as a leaf, so a
function call in its WHERE or its projection escaped parse-time validation - the
exact hazard the class Javadoc warns about. It had the parts but no accessors;
they are added and the walker descends into them.
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review: #5602 - close the five OpenCypher follow-ups

I reviewed the new core (FunctionArity, Function.checkArity, ArithmeticErrorException, SQLFunctionBridge, CypherExpressionWalker, ArithmeticExpression, CypherDuration.divide), the Bolt/HTTP error classification, a sample of the ~80 mechanical per-function edits, the validator widening in CypherSemanticValidator/FunctionValidator, and the tests. Overall this is a high-quality, carefully-scoped change - the "declare arity once, the runtime guard and the registry test both read that declaration" design is the right call, and the Javadoc explaining why each decision was made is excellent.

No blocking issues found. A few observations:

Strengths

  • checkArity as the single arity source eliminates the drift class of bug (distance() in [OpenCypher] abs() returns HTTP 500 for invalid argument instead of client-facing error #5484) structurally rather than adding a second hand-written copy. The SQLFunctionBridge pass-through closes the one group the registry test couldn't previously cover.
  • effectiveMax(-1) normalization in one place correctly fixes the "-1 means unbounded in the parser, MAX_VALUE in the code" mismatch - the comment on checkArity about not comparing against raw getMaxArgs() is spot on.
  • Static type checks only fire on literals/maps/lists (checkStaticallyKnownArgType / checkStaticallyKnownNumericArgs), so widening validateFunctionArgumentTypes to WHERE/SET/CREATE/etc. via one walker cannot produce false positives on variables or parameters. The widening is safe.
  • HTTP handler places isArithmeticError before the generic 500 fallback in both the un-wrapped and TransactionException-wrapped arms, and ArithmeticErrorException extends CommandExecutionException keeps embedded catch blocks working. The locale test restores the default in a finally, so no suite pollution.
  • SQL engine does not read getMinArgs()/getMaxArgs() anywhere, so adding those declarations to the SQL functions (count/sum/variance/geoDistance) has no effect on the SQL path - only the Cypher bridge and the registry test consume them. No SQL-side regression.

Minor observations (non-blocking)

  1. Duplicated cause-chain walk. isArithmeticError(...) (depth-32 loop over getCause()) now exists in both BoltNetworkExecutor and AbstractServerHttpHandler, mirroring the existing isRetryableConflict pattern. A shared helper (e.g. ExceptionUtils.findInCauseChain(Throwable, Class) in engine) would remove the copy. The bolt-on-provided-server module boundary makes engine the natural home. Purely a DRY nit.
  2. Wire-protocol coverage is HTTP + Bolt only. The other protocol modules (Postgres, MongoDB, Redis, GraphQL, Gremlin) still surface ArithmeticErrorException through their generic CommandExecutionException handling rather than as a distinct client error. Consistent with the issue's scope, but if a Cypher statement can reach the engine over another wire path, the 500-vs-400 distinction won't be made there. Worth a follow-up note.
  3. describe(0, 1) renders "0-1 arguments" for count (via the bridge). Correct, just reads slightly oddly; not worth special-casing.

Verification

Relied on the PR's stated green runs plus the registry test's build-time full-coverage assertion, which is the real guard against a narrower-than-real declaration slipping in. The NARROWER_IN_CYPHER pins for count/sum with the "still bites" assertion keep the two deliberate exceptions from becoming silent holes.

Nice work.

Code-review nit, raised twice. Deciding how to report a failure means looking
past the outermost throwable - it arrives wrapped differently depending on
whether the request came in directly, through the auto-commit TransactionException
wrapper, or carrying the JDK exception it came from - and each wire layer had
grown its own depth-capped loop to do it: two added by this branch plus the
pre-existing isRetryableConflict.

CauseChain.find/contains in com.arcadedb.exception replaces all three. Engine is
the natural home: bolt depends on server only in provided scope, so neither can
host something the other needs. The depth cap now lives in one place, with the
reason it exists (initCause can build a self-referential chain) written down once
instead of three times.

Also records in the release note that the arithmetic classification is HTTP and
Bolt only - the other wire protocols still report it through their generic
execution-error handling.
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review: #5602 OpenCypher follow-ups

I read through the whole diff (the 5 items, the new engine classes, the parser/validator rework, the Bolt/HTTP wiring, and the tests). Overall this is a high-quality, unusually well-documented change: the "one declaration per function, one message per mistake" direction is the right one, and the test story (build-time registry sweep, Turkish-locale runs, a source-scan guard, an end-to-end HTTP IT, and the Bolt classification cases) is genuinely strong. The Javadoc explaining why each decision was made is excellent.

A few observations, mostly minor.

Correctness / design (looks solid)

  • FunctionArity.effectiveMax correctly reconciles the two "unbounded" spellings (-1 in the registry vs Integer.MAX_VALUE from getMaxArgs()), and checkArity routes through it. Good catch that a raw count > -1 would reject every call.
  • checkArity(null) treating null as 0 args is a strict improvement over the old args.length NPE path, and the Javadoc correctly notes a min==0 executor must still tolerate the null array.
  • CauseChain centralizing the depth-capped walk (and the cyclic-chain rationale) is a nice cleanup; the Bolt "retryable conflict still wins over arithmetic" ordering is preserved and tested.
  • Keeping ArithmeticErrorException inside CommandExecutionException so embedded catch blocks are unaffected, while float Infinity/NaN semantics stay untouched, is the right call and well tested.

Minor: wire-contract / breaking-change surface

Intentional and documented in release-26.8.1.md, but worth restating for downstream awareness:

  • Wrong argument count via Function.validateArgs() now throws CommandSemanticException (a CommandParsingException) instead of IllegalArgumentException. Any embedded code catching IllegalArgumentException around a CALL/function invocation to detect a bad arg count will silently stop catching it. Make sure this lands in the user-facing changelog prominently.
  • Integer div-by-zero / overflow moving 500 -> 400 is a status change for any HTTP client asserting on 500 (the repo's own Issue5221...IT had to be updated). Fine, but it is a behavior change consumers may key off.

Minor: micro-nit on the HTTP handler

In AbstractServerHttpHandler, both new branches call arithmeticError(e) twice - once in the else if condition and once to bind reported - so the cause chain is walked twice on the error path. Given the project's "lightweight on GC / performance" mantra, consider hoisting to a local:
```java
final ArithmeticErrorException arith = arithmeticError(e);
if (arith != null) { ... use arith ... }
```
It is the error path, so impact is negligible, but it removes the double walk and the duplicated call.

Minor: redundant double check on the CALL path

CallStep.executeFunction calls function.validateArgs(args) (which calls checkArity) and then execute() also calls checkArity(args). Harmless and cheap, but the arity is now verified twice for CALL-invoked functions. Not worth changing unless it bothers you.

Suggestion: guard the CypherExpressionWalker default arm

The walker's default -> { /* leaf */ } arms are the one place a regression can hide silently: a newly introduced composite expression type that nests expressions would escape every check routed through the walker, with nothing failing to say so. The Javadoc calls this out clearly ("Add a case here..."), which is good, but consider a reflective test that enumerates Expression/BooleanExpression subclasses and asserts each is either handled explicitly or whitelisted as a leaf - in the same spirit as the noOpenCypherSourceFoldsCaseWithTheDefaultLocale source-scan guard already in this PR. That converts the "remember to add a case" comment into something the build enforces.

Nits

  • IsNormalizedFunction/NormalizeFunction sharing parseNormalForm is a good de-dup; the STRING-only tightening of normalize() is technically a behavior change (normalize(123) was '123', now a client error) - correctly documented and matched to size()/head() precedent.
  • CharLengthFunction.getName() still returns char_length while serving the charLength alias; consistent with how the other aliases resolve, just noting the name/alias asymmetry for future readers.

Nothing here is blocking. The core logic is correct, the parse-time reach is a real improvement, and the coverage is thorough. Nice work.

…walked or declared a leaf

Code-review suggestion, and the best one of the round. CypherExpressionWalker's
default arm is where a regression hides without a sound: a composite expression
type added later without a case falls into it and everything it nests escapes
every check routed through the walker. The class Javadoc says "add a case here",
but a comment is not a guard - PatternComprehensionExpression sat in that arm
until the previous commit, so a function call in its WHERE or projection was
never validated.

The test enumerates every Expression / BooleanExpression implementation from the
AST sources and requires each to be either handled by the walker or named in an
explicit leaf list. It also asserts the leaf list holds no stale entry, so a type
that grows children later cannot be excused by a whitelist nobody revisited.
Verified it bites: deleting the PatternComprehensionExpression case fails it by
name. Same move as the source scan that guards the locale fix.

Also resolves the arithmetic error once per catch block in the HTTP handler
rather than once in the condition and again to report it.
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review of #5602 - close the five OpenCypher follow-ups left by #5484

I read through the core mechanisms (Function.checkArity/FunctionArity, CauseChain, SQLFunctionBridge, CypherExpressionWalker, FunctionValidator, ArithmeticExpression, the Bolt/HTTP classification paths and the new tests) rather than skimming the 129 mechanical per-function edits. This is a genuinely high-quality change: each of the five items is scoped, the rationale is captured in the code where the next maintainer will actually see it, and every claim in the description is backed by a test.

What works well

  • Single source of arity truth. Moving the count check into Function.checkArity and phrasing the message through FunctionArity in both FunctionValidator and the runtime guard removes the hand-written-if-next-to-hand-written-bound drift. Routing SQLFunctionBridge through the wrapped SQL function getMinArgs/getMaxArgs closes the exact blind spot (distance) that caused [OpenCypher] abs() returns HTTP 500 for invalid argument instead of client-facing error #5484. The build-time registry sweep with the two deliberately-pinned exceptions (count, sum) asserted to still bite is the right shape.
  • FunctionArity.effectiveMax() correctly reconciles the two unbounded spellings (-1 in the parser registry vs Integer.MAX_VALUE from getMaxArgs), and checkArity uses it, so a -1 max cannot degenerate into at-most--1-arguments-reject-everything. Good catch to centralize that.
  • CauseChain consolidates the depth-capped cause-walk (previously duplicated) and the cap guards against a self-referential initCause chain. Both Bolt and HTTP now use it. ArithmeticErrorException extends CommandExecutionException, keeping embedded catch blocks working while letting the wire layers single it out; the Bolt ordering test (retryable conflict wins over arithmetic) is exactly the edge that matters for driver managed-retry.
  • CypherExpressionWalker replacing three partial per-clause recursions is a clean consolidation, and the Javadoc warning that the default arm silently swallows any new composite expression type is precisely the maintenance hazard worth flagging.
  • Locale folding to Locale.ROOT plus a source-scanning test to prevent regressions is a durable fix, not just a spot patch.

Minor points (non-blocking)

  1. parseNormalForm with an explicit null form argument. normalize(str, null) / isNormalized(str, null) pass args[1]==null into parseNormalForm, which treats null as omitted and defaults to NFC. Cypher null-propagation semantics would arguably return null for an explicit null second argument (as the functions already do for a null first argument). Low impact, but worth confirming against Neo4j behavior if you want strict parity.
  2. checkArity now runs on every execute() of aggregation functions (e.g. collect, avg), i.e. once per row rather than once per call. The cost is a couple of int comparisons so it is negligible, just noting it is a per-row call now.
  3. Test isolation: withDefaultLocale mutates the JVM-global Locale.setDefault. It correctly restores in finally, but a JVM-global mutation is fragile if this class is ever run in parallel with others in the same JVM. Fine as-is given the current forking config; flagging only so it is not copied into a parallel context later.
  4. Volume: the ~11-line getMinArgs/getMaxArgs/checkArity block repeated across 129 executors is inherent to the declare-arity-once-per-function decision and is the right tradeoff versus a central table that drifts from the executors - just a large diff to carry.

Adherence to project conventions

Consistent with CLAUDE.md: functions reuse the shared helpers rather than re-implementing checks, final is applied on new params/locals, tests use the assertThat(...) style, no new dependencies, and the changes are TDD-backed with regression tests across engine/server/bolt plus an end-to-end HTTP IT. No System.out debug left behind.

Nice work - the follow-ups are closed cleanly and the guards that failed to guard in #5484 now actually compare something.

Reviewed by Claude (automated PR review).

@codacy-production

codacy-production Bot commented Jul 31, 2026

Copy link
Copy Markdown

Not up to standards ⛔

🟢 Coverage 96.57% diff coverage · -6.83% coverage variation

Metric Results
Coverage variation -6.83% coverage variation
Diff coverage 96.57% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (0f7c013) 142188 108027 75.97%
Head commit (02922bc) 181556 (+39368) 125528 (+17501) 69.14% (-6.83%)

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 (#5612) 641 619 96.57%

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.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.82371% with 46 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.18%. Comparing base (0f7c013) to head (02922bc).
⚠️ Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
...uery/opencypher/parser/CypherExpressionWalker.java 89.68% 5 Missing and 8 partials ⚠️
...ery/opencypher/parser/CypherSemanticValidator.java 92.85% 1 Missing and 6 partials ⚠️
...server/http/handler/AbstractServerHttpHandler.java 61.53% 4 Missing and 1 partial ⚠️
...adedb/function/agg/DistinctAggregationWrapper.java 0.00% 2 Missing ⚠️
...dedb/query/opencypher/executor/steps/CallStep.java 60.00% 2 Missing ⚠️
.../query/opencypher/parser/VariableScopeTracker.java 0.00% 2 Missing ⚠️
...cadedb/query/opencypher/temporal/TemporalUtil.java 33.33% 0 Missing and 2 partials ⚠️
.../src/main/java/com/arcadedb/function/Function.java 80.00% 0 Missing and 1 partial ⚠️
...main/java/com/arcadedb/function/FunctionArity.java 88.88% 0 Missing and 1 partial ⚠️
...arcadedb/function/cypher/CypherFunctionHelper.java 0.00% 1 Missing ⚠️
... and 10 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5612      +/-   ##
==========================================
+ Coverage   67.03%   67.18%   +0.15%     
==========================================
  Files        1720     1771      +51     
  Lines      142188   149564    +7376     
  Branches    30536    31717    +1181     
==========================================
+ Hits        95313   100486    +5173     
- Misses      34199    35950    +1751     
- Partials    12676    13128     +452     

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

@lvca lvca self-assigned this Jul 31, 2026
@lvca lvca added this to the 26.8.1 milestone Jul 31, 2026
@lvca
lvca merged commit f9d2576 into main Jul 31, 2026
11 of 14 checks passed
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review: #5602 OpenCypher follow-ups

I read through the full diff (arity contract, arithmetic classification, locale folding, the new CypherExpressionWalker, the CauseChain/FunctionArity helpers, the wire-layer changes, and the tests). This is a large but coherent change and the quality is high: the write-once arity contract with a build-time drift guard, the single expression traversal replacing three partial recursions, and the CauseChain de-duplication are all genuine simplifications, and the test coverage (registry sweep, Turkish-locale run, source scan, end-to-end 400 IT, Bolt classification) is unusually complete. The reasoning is documented well enough to verify each decision. Nice work.

A few observations, mostly for downstream awareness rather than blockers:

1. Exception-type change is the most impactful thing here (documented, but worth emphasizing)

Arity errors move from IllegalArgumentException / CommandExecutionException to CommandSemanticException (a CommandParsingException). Embedded code that wraps a function/CALL invocation in catch (CommandExecutionException) or catch (IllegalArgumentException) specifically to catch a bad argument count will now miss it. The PR body and the Function.validateArgs Javadoc both call this out, and the deliberate contrast with the arithmetic change (which stays inside CommandExecutionException) is well argued - just flagging it as the one behavioral change most likely to surprise an embedded consumer.

2. CypherExpressionWalker default -> leaf is a silent-degradation risk

The default arms treating an unrecognized node as a leaf is right for literals/variables, but a newly-added composite expression type would silently escape every check routed through the walker, with nothing failing to signal it. The class Javadoc warns about this, which is good; the residual risk is that the warning lives only in a comment. I checked the current AST surface (NOT is a LogicalExpression with a null right, pattern comprehensions/shortest-path/coercion/wrappers are all handled) and coverage looks complete today - the concern is purely future maintenance. Worth considering whether a reflective test enumerating Expression/BooleanExpression implementors against the handled cases is feasible, so a new type fails the build rather than quietly narrowing validation reach.

3. Source-scan locale test is defense-in-depth, not a hard guarantee

allOpenCypherAndFunctionSourcesFoldWithExplicitLocale (relative src/main/java/... paths + literal .toLowerCase()/.toUpperCase() substring match) is a nice safety net, but it depends on the module being the working directory (a wrong CWD makes base absent and the walk skips), and it won't catch folding via a helper or a differently-spelled call. Given the real fix is the explicit Locale.ROOT at each site, this is fine as a tripwire - just don't lean on it as the primary defense.

4. Minor: double arity validation on the CALL path

CallStep.executeFunction calls validateArgs(args) (which calls checkArity) and then execute(args, ...) (which calls checkArity again). Harmless and cheap - the check is a couple of int comparisons with error construction only on the failure path, so no hot-path concern - but the second call is redundant now that every executor guards itself.

Things I specifically verified as correct

  • SQLFunctionBridge delegating getMinArgs/getMaxArgs to the wrapped SQL function, with SQLFunctionAbstract defaulting to 0..MAX_VALUE, so an SQL function that declares nothing is unaffected - the registry sweep's uncovered assertion confirms all 129 names are now reachable.
  • Bolt classifyExecutionError ordering: retryable conflict wins over arithmetic (the ArithmeticErrorException(cause=ConcurrentModificationException) test uses ArcadeDB's ConcurrentModificationException, which extends NeedRetryException, so the transient classification is preserved and a managed-transaction retry isn't lost).
  • checkArity builds no exception on the success path (getName/describe/mismatch are failure-only), so the change is GC-neutral on the hot path.
  • FunctionArity.effectiveMax correctly reconciles the -1 (registry) vs Integer.MAX_VALUE (interface) spellings of "unbounded", and AbstractServerHttpHandler resolves the arithmetic error once via CauseChain.find and reports ArcadeDB's own message.
  • No leftover references to the removed CypherFunctionHelper.arityMessage/arityMismatch.

Overall: solid, well-tested, and the breaking changes are surfaced honestly. The points above are refinements, not objections.

Reviewed with Claude Code.

robfrank pushed a commit that referenced this pull request Aug 14, 2026
…#5612)

* fix(engine) #5602: close the five OpenCypher follow-ups left by #5484

1. The argument-count guard could not reach the seven functions that go through
   SQLFunctionBridge - distance among them, where the #5484 bug actually was -
   and asserted it had compared zero of the 129 registered names, because no
   executor declared getMinArgs()/getMaxArgs(). Every executor now declares its
   contract and enforces it from that declaration (Function.checkArity), so
   there is one number per function instead of a hand-written bound beside a
   hand-written if; the bridge passes the wrapped SQL function's through. count
   and sum are pinned as deliberately narrower in Cypher than in SQL, and the
   pin is asserted to still bite. A wrong argument count is now a client error
   (400) from the runtime guards too, matching the parse-time gate.

2. Case folding no longer depends on the server's default locale. #5484 fixed
   function names; the same pattern survived in procedure names, variable names,
   IS :: type names, the EXPLAIN/PROFILE prefix scan, temporal units, vector
   metrics and the graph functions' direction argument. A test reads the sources
   so a new one cannot slip in - the two forms differ only under a locale CI
   never runs in.

3. charLength() and isNormalized() work, charAt() is gone. All three parsed and
   then failed at execution with "Unknown function". charLength is an alias of
   char_length, isNormalized is the boolean counterpart of normalize() sharing
   its form parsing, and charAt - which names no Neo4j function either - is
   unregistered. An unknown-function error now echoes the spelling written.

4. Parse-time argument validation walked RETURN and WITH only, so the same bad
   call was rejected before the query ran or not depending on its clause. One
   CypherExpressionWalker replaces the three partial per-clause recursions and
   the checks now reach WHERE, UNWIND, SET, CREATE, MERGE, DELETE, FOREACH,
   ORDER BY, SKIP/LIMIT and inline pattern properties. No check is new.

5. An arithmetic error is the caller's, not the server's. Integer overflow and
   division by zero (including duration(...)/0, which escaped as a raw JDK
   ArithmeticException) raise ArithmeticErrorException: HTTP 400 and Bolt
   Neo.ClientError.Statement.ArithmeticError, as Neo4j classifies them. It
   extends CommandExecutionException, so code written against #5164/#5494 is
   unaffected, and float arithmetic keeps IEEE 754 semantics.

* fix(server) #5602: division by zero reports 400, superseding the #5221 pin at 500

Issue5221CypherDivByZeroHttpStatusIT pinned `1 / 0` to HTTP 500, on the reasoning
that a zero divisor is data-dependent in the general case (n.a / n.b) and so a
runtime error rather than a client error. That argument does not survive contact
with the rest of the engine: abs(n.name) is data-dependent in exactly the same
way and has answered 400 since #5484. What decides the status is whose mistake it
is, and Neo4j classifies the whole arithmetic category - division by zero and
64-bit overflow alike - as Neo.ClientError.Statement.ArithmeticError.

What #5221 was actually about is unchanged and still asserted: the label stays
"Cannot execute command" rather than the misleading "Error on transaction
commit", and the detail still names the real cause. ArithmeticErrorException
extends CommandExecutionException, so the #5219 classification an embedded caller
sees is intact.

* refactor(engine) #5602: the arity wording moves beside Function, out of the Cypher helper

Code-review point: the query-language-neutral com.arcadedb.function.Function
interface reached into com.arcadedb.function.cypher.CypherFunctionHelper for the
message its own checkArity raises - an inversion, since nothing about counting
arguments is Cypher-specific.

The three helpers move to com.arcadedb.function.FunctionArity, in the same
package as Function, so the base interface needs no cross-package import at all.
The five call sites follow; nothing is left behind in CypherFunctionHelper, so
there is still one home for the wording.

Also drops two imports left unused by the earlier commits (IsEmptyFunction,
ArithmeticExpression); the one in Function is kept because its checkArity javadoc
links CommandSemanticException.

* fix(engine) #5602: validateArgs() runs the same arity check as checkArity()

Code-review point: two argument-count mechanisms with different error classes
co-existed. checkArity() raises CommandSemanticException (HTTP 400), while the
older validateArgs() raised its own IllegalArgumentException with its own
wording - and validateArgs() is what the CALL path invokes
(CallStep.executeFunction). CallStep wraps what it catches, and the HTTP layer
unwraps exactly one cause level, so `CALL fn(<wrong count>)` surfaced as 500
while the identical mistake in an expression gave 400.

validateArgs() now delegates to checkArity(), so there is one check, one message
and one status however the function was reached. Procedure declares a separate
validateArgs() of its own; procedures are a different abstraction with their own
registry, their own CALL handling and around eighty implementations, so that one
is deliberately left alone and the reason is recorded on the method.

Also documents that checkArity()'s null-array tolerance only rejects the null for
a function requiring at least one argument - a getMinArgs()==0 executor is handed
it unchanged and must still tolerate it - and flags the widened parse-time
validation as potentially breaking in the release note.

* fix(engine) #5602: normalize() is STRING-only and the SQL bridge enforces its own arity

Three code-review points.

normalize() and isNormalized() disagreed on their input domain. The pair shares
its normal-form parsing, so the form names could not diverge, but normalize()
called toString() on whatever arrived: normalize(123) quietly answered '123'
where its new counterpart raised a type error, and where Neo4j - which declares
both as f(input :: STRING) - raises one. It now rejects a non-STRING the same
way, which is the treatment size() and head() got in #5477 and #5476.

SQLFunctionBridge.execute() passed its arguments straight through without the
runtime arity guard every other executor runs, so the seven bridged functions
were the one exception to "each executor's guard reads its own declaration". It
now calls checkArity() from the wrapped SQL function's bounds, which can only
reject a call that function could not have served anyway.

The release note now also states that the arity exception *type* changed, not
only the HTTP status: CommandSemanticException extends CommandParsingException,
a different branch from the CommandExecutionException the old runtime guards
threw, so embedded code catching the latter for a bad argument count has to
catch the former. That is the opposite of the arithmetic change, which stayed
inside CommandExecutionException on purpose, and the note explains why the two
categories legitimately differ.

* fix(engine) #5602: CALL no longer flattens a client error into an execution error

Code-review point, and a real seam. CallStep wrapped everything it caught in a
CommandExecutionException naming what was being called, which threw the
classification away: `CALL f(<wrong count>)` became CommandExecutionException
carrying the real CommandSemanticException as its cause. The HTTP layer unwraps
one level, so a plain request still answered 400 - but on the auto-commit path
the chain is TransactionException -> CommandExecutionException ->
CommandSemanticException, one level too deep, and the same mistake answered 500.

Fixed at the root rather than in the wire layer: a client error
(CommandParsingException and its subclasses) is rethrown untouched, so every
consumer benefits at once - HTTP, Bolt and embedded callers - instead of each
learning to dig further down the chain. Both call sites shared identical catch
arms, which collapse into one.

Issue5602ArithmeticErrorHttpStatusIT covers it end to end: a wrong-arity CALL on
a write statement, asserted at 400.

Also makes the five zero-minimum temporal constructors treat a null argument
array as empty. They dereferenced it before this branch too, so it is not a
regression, but checkArity()'s documented contract now formally lets a null
through for a getMinArgs()==0 function, so the contract and the code agree.

* fix(engine) #5602: the top-level SKIP/LIMIT are walked at parse time too

Code-review point. walkWith() walks a WITH's SKIP and LIMIT, but the top-level
ones hang off the statement rather than off a clause entry and were reached by
neither walk. So `RETURN n LIMIT abs('x')` was not rejected while
`WITH n LIMIT abs('x')` was - the clause-dependent asymmetry this widening
exists to remove, in the last place it survived.

The regression test asserts it through EXPLAIN, which parses and plans without
executing. The plain form proves nothing: SKIP/LIMIT are evaluated even when the
match yields no row, so the function's own runtime guard catches them either way,
and a test written that way passes with the fix reverted. Verified by reverting
it - under EXPLAIN the two top-level forms stop throwing while the WITH and WHERE
forms still do.

* fix(engine) #5602: checkArity() honours the -1 spelling of "unbounded"

Code-review point, latent but a real trap. "No limit" has two spellings - the
Cypher registry writes -1, Function.getMaxArgs() defaults to Integer.MAX_VALUE -
and FunctionArity.describe() already accepted both while checkArity() compared
raw against getMaxArgs(). A Function written to the registry's convention would
therefore have rejected every call, since any count exceeds -1, while the message
went on describing it as taking "at least N".

Both now resolve the maximum through FunctionArity.effectiveMax(), so the check
and the message cannot disagree about what unbounded means. No implementation
returns -1 today; the test pins both spellings so none can start.

Also notes on CypherExpressionWalker that its default arm treats an unrecognised
type as a leaf, so an expression type added later without a case here hides
whatever it nests from every check that runs through the walker.

* fix(engine) #5602: OPTIONAL CALL no longer swallows a malformed call, and pattern comprehensions are walked

Two code-review points.

OPTIONAL CALL returned null on any exception, so a wrong argument count or a bad
argument type inside it answered an empty result instead of the 400 the same call
gives outside OPTIONAL - hiding the mistake behind something that looks like a
legitimately empty match. OPTIONAL suppresses cardinality, not malformed calls,
which is also how Neo4j reads it, so a client error now propagates from both the
function and the procedure arm. That makes the rethrow helper added a commit ago
redundant: the dedicated catch arm precedes the generic one, so the wrap collapses
back to a plain CommandExecutionException.

CypherExpressionWalker treated PatternComprehensionExpression as a leaf, so a
function call in its WHERE or its projection escaped parse-time validation - the
exact hazard the class Javadoc warns about. It had the parts but no accessors;
they are added and the walker descends into them.

* refactor(engine) #5602: one cause-chain walk instead of three copies

Code-review nit, raised twice. Deciding how to report a failure means looking
past the outermost throwable - it arrives wrapped differently depending on
whether the request came in directly, through the auto-commit TransactionException
wrapper, or carrying the JDK exception it came from - and each wire layer had
grown its own depth-capped loop to do it: two added by this branch plus the
pre-existing isRetryableConflict.

CauseChain.find/contains in com.arcadedb.exception replaces all three. Engine is
the natural home: bolt depends on server only in provided scope, so neither can
host something the other needs. The depth cap now lives in one place, with the
reason it exists (initCause can build a self-referential chain) written down once
instead of three times.

Also records in the release note that the arithmetic classification is HTTP and
Bolt only - the other wire protocols still report it through their generic
execution-error handling.

* test(engine) #5602: the build enforces that every expression type is walked or declared a leaf

Code-review suggestion, and the best one of the round. CypherExpressionWalker's
default arm is where a regression hides without a sound: a composite expression
type added later without a case falls into it and everything it nests escapes
every check routed through the walker. The class Javadoc says "add a case here",
but a comment is not a guard - PatternComprehensionExpression sat in that arm
until the previous commit, so a function call in its WHERE or projection was
never validated.

The test enumerates every Expression / BooleanExpression implementation from the
AST sources and requires each to be either handled by the walker or named in an
explicit leaf list. It also asserts the leaf list holds no stale entry, so a type
that grows children later cannot be excused by a whitelist nobody revisited.
Verified it bites: deleting the PatternComprehensionExpression case fails it by
name. Same move as the source scan that guards the locale fix.

Also resolves the arithmetic error once per catch block in the HTTP handler
rather than once in the condition and again to report it.

(cherry picked from commit f9d2576)
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.

[Misc] OpenCypher follow-ups from #5484: arity-guard blind spot, locale-dependent name folding, unimplemented registered functions

1 participant