Skip to content

fix(engine) #5629: an explicit null in an optional argument propagates - #5699

Merged
robfrank merged 5 commits into
mainfrom
fix/5629-cypher-null-optional-arg-propagation
Aug 1, 2026
Merged

fix(engine) #5629: an explicit null in an optional argument propagates#5699
robfrank merged 5 commits into
mainfrom
fix/5629-cypher-null-optional-arg-propagation

Conversation

@robfrank

@robfrank robfrank commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Closes #5629

The decision

The issue asked to settle whether an explicit null in an optional argument position propagates or means "use the default", and explicitly warned against settling it per-function. It also asked for Neo4j's behaviour to be checked first, which the reporter could not verify.

Settled in favour of propagation. Three independent lines of evidence:

  1. Neo4j propagates. From the Cypher manual source (neo4j/docs-cypher): round() "returns null if any of its input parameters are null"; replace(original, search, replace [, limit]) "If any argument is null, null will be returned"; btrim("hello", null) and ltrim("hello", null) both "return null". round() is the direct counterpart of the case in the issue. (Neo4j is not uniformly propagate - substring and left/right raise on a null length - but in no documented case does it read an explicit null as "argument omitted". That is the invariant adopted here.)
  2. ArcadeDB had already decided this once. CypherSubstringFunction carries Issue #5193: an explicitly supplied null length propagates null (as Neo4j does), it must not be treated as an omitted argument. Same reasoning, same citation - just never written where the next function would find it.
  3. It was already the majority. Auditing all 22 stateless functions whose getMinArgs() differs from getMaxArgs(): 9 already propagated (the five temporal constructors, point(), ltrim(), rtrim(), vector_create()), 11 silently defaulted, 1 throws by design (range(), [OpenCypher] size() returns null for unsupported argument types instead of raising a type error #5477). Option 2 would have meant changing the ones that were already right.

The rule

An explicit null in an optional argument position is never "argument omitted". Omitting the argument selects the function's default; writing null there is subject to the usual null-in/null-out rule.

Stated once, on CypherFunctionHelper.isExplicitNull(args, position), and called rather than re-decided. That is the mechanism the issue asked for - settling it one function at a time is how the arity declarations drifted in #5484 and how normalize() and isNormalized() came to disagree in #5602.

Changes

Eleven functions adopt it - the five named in the issue plus six the audit found:

function optional argument before after
normalize / isNormalized normal form NFC null
format pattern toString() null
round rounding mode HALF_UP null
vector.distance metric EUCLIDEAN null
date/datetime/localdatetime/time/localtime`` .truncate adjustment map map ignored null
SubstringFunction length to end of string null

Omitting the argument still selects the default in every one of them; only the explicitly-written null changes.

Two of the five named functions were not where the issue pointed

Both are dual-path cases:

  • substring() - Cypher resolves it to CypherSubstringFunction, which already propagated (Explicit null length in substring() is treated as an omitted argument #5193). com.arcadedb.function.text.SubstringFunction is a second implementation that no factory registers and that still defaulted. Aligned here so the divergence does not surface the day it is wired up; covered by a direct unit test since no query can reach it.
  • vector_distance() - in Cypher this is a grammar rule whose metric is a keyword, so vector_distance(a, b, null) does not parse (the same situation as Neo4j's normalize()). VectorDistanceFunction is reached from Cypher as vector.distance(), where the metric is an ordinary expression. That is the path fixed and tested.

Ordering is preserved

Propagation is placed after the existing argument checks, not before, so a null in the optional position cannot mask a client error in an earlier one: round(null, 2, 'SIDEWAYS') still reports the unusable mode, and a bad truncate unit or non-temporal value is still reported. Both are pinned by tests.

Test plan

  • CypherOptionalArgumentNullIssue5629Test (new, 17 tests) - for each of the eleven functions: the explicit null answers null, and omitting the argument still selects the documented default. Confirmed failing before the fix (11 failures), passing after.
  • Pins the nine functions that already propagated, so the convention cannot drift back.
  • Pins the validation-before-propagation ordering for round() and the truncate family.
  • mvn -pl engine test -Dtest='Cypher*Test,OpenCypher*Test,TextStatelessFunctionsTest,*TemporalFunction*Test,*VectorFunction*Test,SQLFunctionPhase*Test,*StatelessFunction*Test' - 3060 tests, 0 failures.
  • Full mvn -pl engine test run as a backstop.

Two existing assertions changed

Both encoded the behaviour this issue decided to change; both were found by running the suite, and each leaves what the test was written to prove intact.

  1. CypherNumericFunctionArgumentIssue5484Test.theRoundingModeOfRoundIsNotANumericArgument asserted round(3.14159, 2, null) == 3.14, now expects null. Its point - that the mode position is not rejected as non-numeric - is still pinned by the 'FLOOR' and 'CEILING' cases.
  2. TextStatelessFunctionsTest.formatFunctionNullPattern asserted a null pattern answers toString(), now expects null, and gained an assertion that omitting the pattern still answers the ISO string.

Follow-ups (not in this change, deliberately)

  1. The *.truncate family raises raw JDK exceptions for client mistakes: NullPointerException on a null unit (args[0].toString() is unguarded; TrimFunction has the same exposure on its mode), and IllegalArgumentException: Unknown truncation unit from TemporalUtil. Both surface as HTTP 500 for the caller's error - same class as [OpenCypher] abs() returns HTTP 500 for invalid argument instead of client-facing error #5484. The new test asserts only the message for the unknown-unit case, so it does not encode the wrong exception class as correct.
  2. trim(mode, trimChar, source) defaults a null trimChar to whitespace, disagreeing with ltrim/rtrim beside it and with Neo4j's btrim("hello", null). Its 1-or-3 arity means the argument is not trailing, so it is a different shape.
  3. range() throws on a null step rather than propagating, by explicit decision in [OpenCypher] size() returns null for unsupported argument types instead of raising a type error #5477 - worth confirming that is still intended now the convention is written down.
  4. substring() diverges from Neo4j here: Neo4j raises for a null start or length, ArcadeDB propagates for both. Propagating is internally consistent (its null start already answered null, and Explicit null length in substring() is treated as an omitted argument #5193 chose this for the Cypher-facing implementation), but the divergence is worth a decision.
  5. com.arcadedb.function.text.SubstringFunction is registered by no factory - either wire it up or delete it, rather than keeping two implementations of one function.

Full reasoning and the complete audit table are in docs/5629-cypher-explicit-null-optional-argument.md.

🤖 Generated with Claude Code

Cypher functions with an optional trailing argument read an explicit null
in that position as "argument omitted, use the default", while the same
null in the first position propagated. normalize(null) answered null but
normalize('x', null) normalized as NFC, so the same absent value meant two
different things depending on which position it landed in.

Settled in favour of propagation, on three grounds:

- Neo4j documents that reading for every optional argument it defines one
  for: round() "returns null if any of its input parameters are null", and
  the same is said of replace()'s limit and btrim()'s trim character.
- ArcadeDB had already decided it this way once, for CypherSubstringFunction
  in #5193, citing Neo4j - it just was not written anywhere the next
  function would find it.
- 9 of the 22 optional-argument functions already propagated, so defaulting
  was the drift, not the house rule.

The rule is stated once, on CypherFunctionHelper.isExplicitNull(), so the
next function follows it instead of re-deciding - which is how the arity
declarations drifted in #5484 and how normalize() and isNormalized() came to
disagree in #5602.

Eleven functions adopt it: the five named in the issue plus the five
*.truncate functions and SubstringFunction, all found by auditing every
function whose getMinArgs() differs from getMaxArgs(). Omitting the argument
still selects the default in every one of them.

Propagation is placed after the existing argument checks, so round(null, 2,
'SIDEWAYS') still reports the unusable mode and a bad truncate unit is still
reported rather than masked by the null.
@mergify

mergify Bot commented Aug 1, 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

@codacy-production

codacy-production Bot commented Aug 1, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 4 complexity

Metric Results
Complexity 4

View in Codacy

🟢 Coverage 100.00% diff coverage · -7.54% coverage variation

Metric Results
Coverage variation -7.54% coverage variation
Diff coverage 100.00% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (83448ea) 150078 114508 76.30%
Head commit (f1b2f25) 182261 (+32183) 125314 (+10806) 68.76% (-7.54%)

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 (#5699) 20 20 100.00%

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 Aug 1, 2026

Copy link
Copy Markdown

Review: issue #5629 - explicit null in an optional argument propagates

Reviewed the full diff. This is a high-quality, tightly-scoped change. The strongest part is that the decision is made ONCE on CypherFunctionHelper.isExplicitNull(args, position) and called rather than re-litigated per function, which is exactly the anti-drift mechanism the issue asked for. The Neo4j sourcing, the audit of all 22 optional-argument functions, and the ordering guarantee (propagation placed after argument validation) are all convincing and, importantly, pinned by tests.

WHAT IS DONE WELL

  • Ordering is correct and tested. In RoundFunction and every *.truncate function the isExplicitNull check sits after the value/mode/unit validation, so round(null, 2, SIDEWAYS) still reports the unusable mode and date.truncate(fortnight, ..., null) still reports the bad unit. anUnusableModeIsStillReportedWhenAnotherArgumentIsNull and aBadTruncateUnitIsStillReportedWhenTheAdjustmentMapIsNull lock this in.
  • Test coverage is excellent. Each of the 11 functions is asserted twice (explicit null gives null, AND omitted gives the default), which keeps the fix from over-applying into "the default stopped working". Pinning the 9 already-correct functions is the right call to stop back-drift.
  • isExplicitNull handles the args == null case (a getMinArgs() == 0 function called with no args), so it cannot NPE.
  • The unregistered second com.arcadedb.function.text.SubstringFunction is aligned and covered by a direct unit test since no query can reach it, good for preventing a latent divergence.
  • The follow-ups section is honest and correctly scoped.

MINOR OBSERVATIONS (non-blocking)

  1. Import placement. In the five .truncate files, the new import com.arcadedb.function.cypher.CypherFunctionHelper is placed above the other com.arcadedb. imports and separated by a blank line, breaking the grouped import order used elsewhere in the file. Cosmetic, but worth a tidy for consistency.

  2. Redundant post-check null guards. After isExplicitNull(args, 2) returns false, the later args[2] instanceof Map / args[2] != null guards can no longer see a null at position 2. They are harmless (and arguably defensive against a future refactor), so no change needed, just noting the redundancy is intentional.

  3. Follow-up 1 is a real 500-on-client-error gap (args[0].toString() NPE on a null unit, e.g. date.truncate(null, date(...)), and the same on TrimFunctions mode). Same class as issue [OpenCypher] abs() returns HTTP 500 for invalid argument instead of client-facing error #5484 and correctly deferred, but since it produces an ungraceful HTTP 500 for a caller mistake, a fast-follow so the truncate family reports it as a 400 like round() now does would be worthwhile.

VERDICT

No correctness or performance concerns. The change is internally consistent, well-reasoned, and the regression suite (CypherOptionalArgumentNullIssue5629Test, plus the two updated assertions) captures both the new behaviour and the ordering invariant. LGTM.

… block

The five *.truncate files had it above the other com.arcadedb.* imports and
separated by a blank line. Pre-existing rather than introduced here - this
commit's parent changed no import lines in those files - but they are being
touched anyway, so tidy it rather than leave it for the next reader.
@robfrank

robfrank commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks - all three observations checked. One applied, two noted with a correction to each.

1. Import placement - applied (28f6464), with one correction: the misplaced import was not introduced by this PR. The parent commit's diff for those five files is 5 files changed, 25 insertions(+) and touches no import lines - CypherFunctionHelper was already imported there, which is why the isExplicitNull calls compiled without an import change. The misplacement is pre-existing. Tidied anyway since the files are being touched.

2. Redundant post-check guards - no change, but the redundancy is narrower than described. After isExplicitNull(args, 2) returns false, both surviving guards are still load-bearing:

  • args.length >= 3 still distinguishes omitted from present, which is the whole distinction this PR turns on.
  • instanceof Map still rejects a present, non-null, non-map value - date.truncate('year', d, 'nonsense') reaches it.

What isExplicitNull removed is only the case of instanceof doubling as an accidental null filter, which was the bug. So nothing here is dead code.

3. Truncate family 500-on-client-error - agreed, and it is worse than the null-unit NPE alone. There are two distinct paths, both recorded as follow-up 1 in the tracking doc:

  • args[0].toString() on a null unit gives a raw NullPointerException.
  • An unknown unit reaches TemporalUtil.truncateDate, which throws IllegalArgumentException: Unknown truncation unit: fortnight rather than a CommandSemanticException.

The second is why aBadTruncateUnitIsStillReportedWhenTheAdjustmentMapIsNull asserts only hasMessageContaining("fortnight") and deliberately not an exception class - asserting CommandExecutionException there would have encoded the wrong class as correct and made the eventual 400 fix look like a regression. Worth a fast-follow, and out of scope here since it is an error-classification defect rather than a null-semantics one.

@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

Code Review

Reviewed the full diff. This is an unusually strong PR: it treats a class of behavioral inconsistency rather than the five functions the issue named, states the rule in exactly one place (CypherFunctionHelper.isExplicitNull), and backs the decision with three independent lines of evidence (Neo4j docs, the prior #5193 precedent, and an audit of all 22 optional-argument functions). The write-up and the docs file make the reasoning auditable. Nice work.

What I verified

  • The rule is applied consistently across all 11 functions, and isExplicitNull(args, position) is correctly null-safe (args != null && args.length > position && args[position] == null), which matches Function.checkArity's contract that a getMinArgs() == 0 executor may be handed a null array.
  • Ordering is preserved in every case I traced. round(null, 2, 'SIDEWAYS') throws on the mode before the null check (mode parsed at line 66, propagation at line 71); the truncate family validates the unit and temporal value (TemporalUtil.truncate*) before isExplicitNull at the bottom. Both are pinned by tests.
  • round(3.14, null) and round(3.14, null, 'FLOOR') still propagate through the existing precisionArg == null path, so nothing regressed there.
  • Test coverage is excellent: every function is covered twice (explicit-null propagates AND omitted-argument still defaults), the nine already-propagating functions are pinned against drift, the two dual-path cases (substring/SubstringFunction, vector_distance/vector.distance) are each tested on the path a query can actually reach, and the two changed existing assertions each keep their original point intact.

Observations (all minor, none blocking)

  1. Behavioral / release-note surface. This changes observable output for 11 functions - most visibly format(x, null) now returns null instead of the ISO string. It is the correct Cypher semantics and clearly intentional, but it is the kind of change worth a line in the changelog/release notes so a user relying on the old defaulting behavior is not surprised.

  2. Weak assertions in the truncate "omitted-argument" cases. datetimeTruncate..., localdatetimeTruncate..., timeTruncate... and localtimeTruncate... assert only .isNotNull() for the default path, whereas dateTruncatePropagatesAnExplicitNullAdjustmentMap asserts the actual value (hasToString("1984-01-01")). Since the whole point of the second assertion is that the default path still does the right thing, matching the concrete truncated value in the other four would make them harder to pass by accident.

  3. Drive-by (pre-existing, not introduced here): the @SuppressWarnings("unchecked") in DateTruncateFunction/DateTimeTruncateFunction sits on getMinArgs() rather than on execute(), where the unchecked Map cast actually lives. Harmless, but if you are touching these files anyway it would be a trivial cleanup.

The follow-ups you deliberately deferred (raw NullPointerException on a null unit, TrimFunction's mode exposure, the trim/ltrim null trim-char disagreement, and deciding whether to wire up or delete the second SubstringFunction) are all well-scoped and correctly kept out of this change. Item 5 in particular - two implementations of one function where only one is registered - is worth its own issue so the divergence does not silently reappear.

LGTM.

 placement

The four truncate omitted-argument assertions checked only isNotNull(), which
the default path could satisfy while doing the wrong thing. They now assert the
concrete truncated value, as dateTruncate already did.

@SuppressWarnings("unchecked") sat on getMinArgs() rather than on execute(),
where the unchecked Map cast lives. Pre-existing, and in all five files rather
than the two reported.

Also records the PR and review history in the tracking doc.
@robfrank

robfrank commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Cycle 2 addressed in aa83404. Both code points applied, with a correction on the third.

2. Weak assertions - applied, and this was the right catch. The four truncate default-path assertions checked only .isNotNull(), which the default path could satisfy while doing entirely the wrong thing - the exact failure mode the second assertion exists to rule out. They now assert the concrete value, matching what dateTruncate... already did:

  • datetime.truncate('year', ...) -> 1984-01-01T00:00Z
  • localdatetime.truncate('year', ...) -> 1984-01-01T00:00
  • time.truncate('hour', ...) -> 12:00Z
  • localtime.truncate('hour', ...) -> 12:00

3. @SuppressWarnings("unchecked") - applied, and it is broader than reported. It sits on getMinArgs() in all five truncate files, not only DateTruncateFunction and DateTimeTruncateFunction. Moved to execute() in all five, where the (Map<String, Object>) args[2] cast actually lives.

1. Changelog - agreed on substance, but there is nowhere to put it. The repository has no CHANGELOG file. You are right that this is a user-visible change for 11 functions and that format(x, null) returning null instead of the ISO string is the one most likely to surprise, so I have recorded it in the tracking doc under "PR and review history" for whoever cuts the release. Happy to add it to a specific file if release notes live somewhere I have not found.

On follow-up 5 (two implementations of substring(), only one registered) - agreed it deserves its own issue rather than a doc bullet. Leaving that for @robfrank to file so it is not created under the wrong milestone.

Verification after these changes: 234 tests green across CypherOptionalArgumentNullIssue5629Test, OpenCypherTemporalFunctionsComprehensiveTest, CypherNumericFunctionArgumentIssue5484Test and TextStatelessFunctionsTest. CI on the previous head was fully green (build-and-package, CodeQL all languages, Codacy 0 new issues, Meterian).

@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review: #5629 - explicit null in an optional argument propagates

Reviewed the full diff, the new helper, all eleven touched functions, the parse-time path in CypherSemanticValidator, and the tests. This is a high-quality, well-scoped change. The decision is defensible, evidenced, and - crucially - encoded once (CypherFunctionHelper.isExplicitNull) rather than re-litigated per function, which is exactly what the issue asked for. Feedback below is mostly minor.

What's good

  • The mechanism, not a verdict. Putting the rule on one helper with the reasoning + citations in its Javadoc is the right call, and it directly addresses the drift history ([OpenCypher] abs() returns HTTP 500 for invalid argument instead of client-facing error #5484, [Misc] OpenCypher follow-ups from #5484: arity-guard blind spot, locale-dependent name folding, unimplemented registered functions #5602) the issue named.
  • Ordering is preserved and pinned. Placing the propagation check after argument validation so round(null, 2, 'SIDEWAYS') and a bad truncate unit still surface as client errors is the subtle part, and it is covered by dedicated tests (anUnusableModeIsStillReportedWhenAnotherArgumentIsNull, aBadTruncateUnitIsStillReportedWhenTheAdjustmentMapIsNull).
  • Test coverage. Each function is asserted twice (explicit-null propagates and omitted-still-defaults), the nine already-correct functions are pinned against regression, and the two flipped legacy assertions are updated with a note rather than deleted. The audit-driven approach (every function where getMinArgs() != getMaxArgs()) is the reason the truncate family was caught.
  • Scope is safe. These are all Cypher-only functions (registered solely in CypherFunctionFactory); no SQL path shares them, so there is no silent behavior change to SQL callers.
  • Confirmed the parse-time guard is consistent: a null literal is skipped in checkStaticallyKnownNumericArgs (line 2167, literal == null -> continue), so round(x, 2, null) is not wrongly rejected at parse time before runtime propagation can answer null.

Minor points (non-blocking)

  1. RoundFunction.parseRoundingMode Javadoc is now slightly contradictory. The doc says "A null here means the argument was omitted... A mode written as an explicit null never reaches this method." But at runtime it is still called with an explicit null: args.length == 3 ? parseRoundingMode(args[2]) : HALF_UP runs before the isExplicitNull check (line 66), so parseRoundingMode(null) executes and its HALF_UP result is then discarded by the propagation check. Harmless, but the if (mode == null) return HALF_UP branch (line 104) is effectively dead for the runtime path and the doc no longer matches. Consider short-circuiting before parsing (args.length == 3 && !isExplicitNull(args,2)) or softening the comment.

  2. Truncate functions compute before they short-circuit. In each *.truncate, TemporalUtil.truncate... runs (e.g. line 71 in DateTruncateFunction) and its result is then thrown away when isExplicitNull(args, 2) is true. Negligible cost, and it is deliberate so a bad unit still throws first - just noting the wasted computation. Not worth changing unless you want to add && !isExplicitNull to the earlier line.

  3. Follow-up build support #1 is a real latent 500 worth prioritizing. args[0].toString() on the unit selector (e.g. DateTruncateFunction line 57) NPEs on a null unit, and TrimFunction has the same exposure on its mode - both surface as HTTP 500 for a caller error, the same class as [OpenCypher] abs() returns HTTP 500 for invalid argument instead of client-facing error #5484. The PR correctly documents this as out of scope and the test asserts only the message (not the exception class) for the unknown-unit case, which is the right restraint. Just flagging it should not linger.

Verdict

Correct, well-tested, and the follow-ups are honestly catalogued. The minor Javadoc/dead-branch nit in parseRoundingMode is the only thing I would suggest tidying before merge; everything else is optional. Nice work on the audit and on writing the convention down where the next function will find it.

Reviewed with Claude Code.

parseRoundingMode(args[2]) ran before the propagation check, so an explicitly
written null mode was parsed to HALF_UP and the result then discarded. Harmless,
but it made the method's own Javadoc untrue: it claimed an explicit null never
reaches it.

execute() now skips the parse for an explicit null, which propagates immediately
after. A mode that is present and not null is still parsed before propagation, so
round(null, 2, 'SIDEWAYS') keeps reporting the unusable mode.
@robfrank

robfrank commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Cycle 3 addressed in d28f759.

1. parseRoundingMode Javadoc contradiction - correct catch, fixed. You are right: args.length == 3 ? parseRoundingMode(args[2]) : HALF_UP ran before the propagation check, so an explicit null was parsed to HALF_UP and the result then discarded. The behaviour was harmless but the Javadoc's claim that an explicit null "never reaches this method" was simply false.

Fixed by short-circuiting rather than by softening the comment, so the contract becomes true instead of merely accurate:

final RoundingMode mode =
    args.length == 3 && !CypherFunctionHelper.isExplicitNull(args, 2) ? parseRoundingMode(args[2]) : RoundingMode.HALF_UP;

The ordering guarantee is unaffected - a mode that is present and non-null is still parsed before propagation, so round(null, 2, 'SIDEWAYS') keeps reporting the unusable mode. anUnusableModeIsStillReportedWhenAnotherArgumentIsNull still passes. The Javadoc now also records that the null branch serves callers passing the omitted-argument sentinel directly, which is what keeps it from being dead.

2. Truncate computes before short-circuiting - declining, and not only on cost grounds. I tried the suggested && !isExplicitNull on the earlier line to be sure, and it breaks the ordering guarantee:

[ERROR] CypherOptionalArgumentNullIssue5629Test.aBadTruncateUnitIsStillReportedWhenTheAdjustmentMapIsNull:191

Skipping TemporalUtil.truncate* means date.truncate('fortnight', d, null) answers null instead of reporting the bad unit - exactly the masking this PR was careful to avoid. The wasted computation is the price of validating first, and it is one truncation on a path that is about to return null anyway. Reverted; the probe is not in the branch.

This is the difference between the two cases: in round() the parse is a pure function of the argument being discarded, so skipping it loses nothing. In truncate() the computation is what performs the validation, so skipping it loses the error.

3. Follow-up #1 priority - agreed. Noted for a fast-follow; it is the raw NullPointerException on a null unit plus the IllegalArgumentException on an unknown one, both HTTP 500 for a caller mistake.

@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review: #5629 - explicit null in an optional argument propagates

Reviewed the diff statically (code, tests, and the design doc). This is a high-quality change: it settles the question once on CypherFunctionHelper.isExplicitNull() rather than per-function, exactly as the issue asked, and backs the decision with three independent lines of evidence (Neo4j docs, the prior #5193 precedent, and the audit showing propagation was already the majority). The PR description is exemplary.

What I verified

  • Blast radius is Cypher-only. All eleven touched functions register exclusively through CypherFunctionRegistry / CypherFunctionFactory; none are wired into the SQL function registry, so the semantic change does not leak into SQL. Good, and it matches the stated scope.
  • isExplicitNull is null-safe and arity-safe (args != null && args.length > position && args[position] == null), so functions declaring getMinArgs() == 0 will not NPE.
  • Ordering is correct. In RoundFunction and the five *.truncate functions the propagation check sits after argument validation, so round(null, 2, 'SIDEWAYS') still reports the unusable mode and a bad truncate unit is still surfaced. Both are pinned by tests.
  • Imports present in all touched files.
  • Tests are thorough - each function is covered twice (explicit null -> null, omitted -> default), the nine already-propagating functions are pinned against regression, and the validation-before-propagation ordering is pinned. The two changed existing assertions each keep what they were originally proving.

Minor observations (non-blocking)

  1. RoundFunction evaluates isExplicitNull(args, 2) twice (once to pick the mode, once in the return guard). Negligible cost, but a final boolean nullMode = ... would remove the duplication and make the two branches obviously consistent.
  2. Truncate functions still silently ignore a non-null, non-Map third argument - e.g. date.truncate('year', date, 42) returns the truncated value, ignoring 42. Pre-existing (the args[2] instanceof Map guard), not introduced here, and adjacent to follow-up build support #1 about raw JDK exceptions from these functions. Worth folding into the same cleanup.
  3. The follow-ups section is honest and useful - especially the unguarded args[0].toString() NPE on a null truncate unit and the dead com.arcadedb.function.text.SubstringFunction (aligned here but registered by no factory). Agree these are correctly deferred.

Test coverage

Adequate and well-targeted. One gap worth closing eventually: no test pins the non-null, non-Map third-argument behavior for the truncate family, so observation #2 could silently change. Not required for this PR.

Nice work - the "state the rule once and call it" approach is exactly right, and the doc will save the next contributor from re-deciding it.

isExplicitNull(args, 2) was evaluated twice in round(), once to decide whether
to parse the mode and once in the propagation guard. One local makes the two
branches obviously consistent.

Also records the truncate family's silent acceptance of a present, non-null,
non-map third argument as follow-up 6, with a note on why it is deliberately
left untested rather than pinned.
@robfrank

robfrank commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Cycle 4 addressed in f1b2f25. This is the last automated cycle - the branch is handed back to @robfrank from here.

1. Double evaluation of isExplicitNull(args, 2) - applied. Hoisted into final boolean nullMode, so the mode-parsing branch and the propagation guard read off one value and cannot drift apart. Also merged the two adjacent comments, which had started to repeat each other after the last cycle.

2. Truncate accepts a present, non-null, non-Map third argument - agreed it is a defect, recorded as follow-up 6. date.truncate('year', d, 42) answers the truncated date and drops the 42. It is the same shape as what this PR repaired for null, one step further out, and it should be a type error.

On the suggested test: deliberately not adding one. A test asserting that date.truncate('year', d, 42) returns the truncated value would encode the defect as the expected behaviour, so the eventual fix would present as a regression and the next person would have to decide whether to trust the test or the type system. That is the same trap avoided earlier by asserting only the message for the unknown-unit case rather than its (wrong) exception class. The behaviour is documented in the tracking doc under follow-up 6 instead, where it reads as "known defect" rather than "pinned contract". Happy to add a characterization test if you would rather have regression detection than a clean signal - it is a real trade-off, just one I would not make silently.

3. Agreed on the deferred items.


Cycle summary

cycle head verdict applied
1 5ae1ba3b7 LGTM Misplaced import (pre-existing) moved into the grouped block
2 28f6464a5 LGTM Four .isNotNull() default-path assertions strengthened to concrete values; @SuppressWarnings moved to execute() in all five files
3 aa8340426 LGTM parseRoundingMode no longer called for a mode about to propagate, making its Javadoc true
4 d28f7590f LGTM Duplicate isExplicitNull evaluation hoisted; follow-up 6 recorded

CI green throughout: build-and-package SUCCESS on 5ae1ba3b7, 28f6464a5 and d28f7590f, CodeQL all six languages, Codacy 0 new issues, Meterian. Local verification after each cycle: 234 tests across the four affected classes, 3060 across the full Cypher/function surface.

Two items left for the maintainer:

  • Release notes. The repository has no CHANGELOG, so the user-visible surface (most notably format(x, null) now answering null instead of the ISO string) is recorded only in the PR body and the tracking doc.
  • Follow-ups 1-6 in docs/5629-cypher-explicit-null-optional-argument.md, of which Bump jedis from 3.6.1 to 3.6.3 #5 (two substring() implementations, one unregistered) and build support #1 (raw NullPointerException / IllegalArgumentException from the truncate family, both HTTP 500 for caller mistakes) are the ones worth their own issues.

@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review: fix(engine) #5629 - explicit null in an optional argument propagates

Reviewed the helper, all eleven modified functions, and the new test. This is a well-scoped, unusually well-reasoned change: it settles the question once (CypherFunctionHelper.isExplicitNull) rather than per-function, the audit-driven decision (propagate, matching Neo4j and the existing #5193 precedent) is sound, and the ordering guarantee - propagation placed after argument validation so a null can't mask a client error - is both correct and pinned by tests. Verified the specific claims: substring resolves to CypherSubstringFunction, and com.arcadedb.function.text.SubstringFunction is registered by no factory, so exercising it directly in the test is the right call. 👍

Correctness (verified by reading)

  • RoundFunction: round(3.14,2,null) -> null; round(null,2,'SIDEWAYS') still throws on the bad mode; round(3.14,null,'FLOOR') still propagates via the precisionArg == null branch. Ordering holds.
  • Truncate family: TemporalUtil.truncate* and the temporal-type validation run before the isExplicitNull check, so a bad unit / non-temporal value is reported rather than masked. Consistent across all five.
  • isExplicitNull correctly guards args == null and args.length > position, so the zero-arg / omitted cases are safe.

Minor (non-blocking)

  1. Now-dead null guards. After the early isExplicitNull(args, 2) return, two later checks can never see a null there:

    • SubstringFunction line 56: if (args.length == 3 && args[2] != null) - the args[2] != null is unreachable-false.
    • VectorDistanceFunction line 64: args.length > 2 && args[2] != null ? ... - same.
      Harmless defensive redundancy, but since the invariant now lives in isExplicitNull, these could be simplified for clarity (or left as belt-and-suspenders - your call).
  2. Mandatory-argument null asymmetry (out of scope, worth a note). The truncate functions throw CommandExecutionException when the temporal value (args[1]) is null, rather than propagating - e.g. date.truncate('year', null). That sits uneasily beside the null-in/null-out philosophy this PR champions, and Neo4j propagates there. The PR is deliberately scoped to the optional trailing argument, so this is not a regression, but it is arguably the same shape as the follow-ups already listed and might deserve a line in that section.

Test coverage

Excellent. The each-function-covered-twice structure (explicit null propagates and omitted still defaults) is exactly what prevents the rule from being over-applied into "the default stopped working", the already-propagating nine are pinned against drift, and the validation-before-propagation ordering is explicitly asserted for both round() and the truncate family. The unregistered SubstringFunction is tested directly since no query can reach it.

Style / conventions

Consistent with the codebase: final on locals/params, imports (no FQNs), Javadoc citing the governing issues, and the tests use the assertThat(...) style CLAUDE.md asks for. No new dependencies. No stray System.out.

Nice work - the documentation of why (the audit table, the two dual-path caveats, the deliberate follow-ups) makes this easy to trust.

Automated review by Claude Code.

@robfrank
robfrank merged commit d63e690 into main Aug 1, 2026
26 of 29 checks passed
@codecov

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.76%. Comparing base (83448ea) to head (f1b2f25).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
...arcadedb/function/cypher/CypherFunctionHelper.java 0.00% 0 Missing and 1 partial ⚠️
.../com/arcadedb/function/text/SubstringFunction.java 0.00% 0 Missing and 1 partial ⚠️
...cadedb/function/vector/VectorDistanceFunction.java 0.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #5699      +/-   ##
============================================
- Coverage     67.49%   66.76%   -0.73%     
- Complexity        0     1114    +1114     
============================================
  Files          1770     1771       +1     
  Lines        150078   150269     +191     
  Branches      31809    31873      +64     
============================================
- Hits         101290   100323     -967     
- Misses        35607    36887    +1280     
+ Partials      13181    13059     -122     

☔ 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] Decide whether an explicit null in an optional argument propagates or means "use the default"

1 participant