Skip to content

#3952 fix: Cypher -> EXISTS { MATCH (p)-[:WORKS_WITH]->() } returns false when relationship type embeds a Cypher keyword after underscore - #3962

Merged
robfrank merged 3 commits into
mainfrom
fix/3952-exists-match-subquery-relationship-type
Apr 22, 2026
Merged

#3952 fix: Cypher -> EXISTS { MATCH (p)-[:WORKS_WITH]->() } returns false when relationship type embeds a Cypher keyword after underscore#3962
robfrank merged 3 commits into
mainfrom
fix/3952-exists-match-subquery-relationship-type

Conversation

@robfrank

Copy link
Copy Markdown
Collaborator

Summary

Fixes #3952.

EXISTS { MATCH (p)-[:WORKS_WITH]->(:Person) } returned false for every row even when the pattern clearly matched, while the equivalent WHERE (p)-[:WORKS_WITH]->() predicate worked correctly.

Root cause: ExistsExpression.matchesKeywordAt() used Character.isLetterOrDigit() for word-boundary detection. Because underscore _ is not a letter or digit in Java, "WITH" inside "WORKS_WITH" was falsely recognised as the Cypher WITH clause keyword. This caused injectWhereConditions() to split the relationship type name mid-token, producing an invalid query such as:

MATCH (p), (p)-[:WORKS_WHERE id(p) = $__exists_p WITH]->(:Person)

The resulting parse error was silently caught in evaluate() and returned false. Any relationship type whose name ends with a Cypher keyword after an underscore (_WITH, _WHERE, _RETURN, _ORDER, _SKIP, _LIMIT, _UNION) was affected.

Fix: Replaced the Character.isLetterOrDigit(c) boundary check in matchesKeywordAt() with a new isCypherIdentifierChar(c) helper that also returns true for _, consistent with Cypher identifier rules.

Changes

  • engine/src/main/java/com/arcadedb/query/opencypher/ast/ExistsExpression.java - fix in matchesKeywordAt() + new isCypherIdentifierChar() helper
  • engine/src/test/java/com/arcadedb/query/opencypher/CypherExistsUnderscoreRelationshipTypeTest.java - regression test (3 cases: WORKS_WITH, KNOWS_WHERE, and control KNOWS)

Test plan

🤖 Generated with Claude Code

…hen relationship type name embeds a Cypher keyword after underscore

Closes #3952

`matchesKeywordAt()` in ExistsExpression used `Character.isLetterOrDigit()`
for word-boundary detection. Because underscore is not a letter or digit in
Java, "WITH" inside "WORKS_WITH" was falsely matched as the Cypher WITH
clause keyword, corrupting the injected subquery and causing a
silently-caught parse exception that returned false.

Fix: replace the boundary check with `isCypherIdentifierChar()` which also
treats `_` as part of an identifier, consistent with Cypher identifier rules.
Affects any relationship type whose name ends with a Cypher keyword after an
underscore (e.g. _WITH, _WHERE, _RETURN).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@codacy-production

codacy-production Bot commented Apr 22, 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

🟢 Coverage 90.91% diff coverage · -7.48% coverage variation

Metric Results
Coverage variation -7.48% coverage variation
Diff coverage 90.91% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (627d421) 119294 86800 72.76%
Head commit (b0dad27) 150544 (+31250) 98270 (+11470) 65.28% (-7.48%)

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 (#3962) 11 10 90.91%

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. Give us feedback

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses issue #3952, where EXISTS { MATCH } subqueries incorrectly returned false for relationship types containing Cypher keyword fragments (e.g., WORKS_WITH). The fix introduces a helper method to include underscores in word boundary checks. Feedback suggests that the boundary logic should be further hardened to include characters like ':', '.', and '$' to prevent false matches in labels or properties. Additionally, it is recommended to apply the new identifier check to variable detection to ensure variables containing underscores are handled correctly.

Comment on lines 212 to 217
if (pos > 0 && isCypherIdentifierChar(upper.charAt(pos - 1)))
return false;
// Check word boundary after
final int end = pos + keyword.length();
if (end < upper.length() && Character.isLetterOrDigit(upper.charAt(end)))
if (end < upper.length() && isCypherIdentifierChar(upper.charAt(end)))
return false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The word boundary check for Cypher keywords is still incomplete. While adding the underscore fixes the reported issue for relationship types like WORKS_WITH, the logic will still incorrectly match keywords when they are part of a label (e.g., :WITH), a property access (e.g., .WITH), or a parameter (e.g., $WITH).

To be more robust, the boundary check should also consider characters like :, ., and $ as part of a token, which should prevent a keyword match.

    if (pos > 0) {
      final char c = upper.charAt(pos - 1);
      if (isCypherIdentifierChar(c) || c == ':' || c == '.' || c == '$')
        return false;
    }
    // Check word boundary after
    final int end = pos + keyword.length();
    if (end < upper.length()) {
      final char c = upper.charAt(end);
      if (isCypherIdentifierChar(c) || c == ':' || c == '.' || c == '$')
        return false;
    }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit b0dad27. Added ':' '.' '$' to the boundary checks in matchesKeywordAt alongside the existing isCypherIdentifierChar check, so [:WITH], n.with, and $with are no longer misidentified as standalone clause keywords.

Comment thread