Skip to content

feat(parser): write clauses + full wire-up — CREATE, MERGE, SET, REMOVE, DELETE, CALL#658

Merged
DecisionNerd merged 2 commits into
mainfrom
feature/653-write-clauses
May 30, 2026
Merged

feat(parser): write clauses + full wire-up — CREATE, MERGE, SET, REMOVE, DELETE, CALL#658
DecisionNerd merged 2 commits into
mainfrom
feature/653-write-clauses

Conversation

@DecisionNerd
Copy link
Copy Markdown
Owner

@DecisionNerd DecisionNerd commented May 30, 2026

Closes #653

Part of #554 (chunk D of 4, final parser chunk).

Summary

  • parse_query now dispatches all 13 clause types (7 read-only from parser: read-only clauses — MATCH, OPTIONAL MATCH, WHERE, WITH, RETURN, UNWIND, UNION (src/parser/clauses.rs) #652 + 6 write clauses here)
  • CREATE — pattern list
  • MERGE — single pattern + optional ON CREATE SET / ON MATCH SET
  • SET — property assignment (n.x = v), full replace (n = {}), property merge (n += {}), label addition (n:Label)
  • REMOVE — property removal (n.x), label removal (n:Label)
  • DELETE / DETACH DELETE — comma-separated expression list
  • CALL — subquery form CALL { ... } [YIELD ...] and named procedure CALL f.name(args) [YIELD ...]
  • grammar.lalrpop and build.rs were already gone (cleaned up in earlier PRs)

Test plan

  • CREATE (n:Person {name: 'Alice'}) parses correctly
  • CREATE (a)-[:KNOWS]->(b) — relationship creation
  • MERGE (n:Person {email: $e}) ON CREATE SET n.created = 1 ON MATCH SET n.seen = 2
  • SET n.age = 30, n.name = 'Bob' — multiple set items
  • SET n = {age: 30} — full replacement
  • SET n:Employee — label addition
  • REMOVE n.age, n:TempLabel — mixed remove
  • DELETE n, r
  • DETACH DELETE n
  • CALL { MATCH (n) RETURN n } YIELD n — subquery form
  • CALL db.labels() YIELD label — procedure form
  • All 13 clause types reachable from parse_query without panic
  • cargo test -p gf-cypher — 102 tests pass
  • cargo test --workspace — full workspace green

🤖 Generated with Claude Code

Note

Add write clause parsing (CREATE, MERGE, SET, REMOVE, DELETE, CALL) to Cypher parser

  • Extends parse_query in clauses.rs to dispatch all major write clauses instead of returning errors for them.
  • Adds parsers for CREATE (pattern list), MERGE (single pattern with optional ON CREATE SET / ON MATCH SET blocks), SET (all item forms: property assign, full replace, merge, label add), REMOVE (property and label forms), DELETE/DETACH DELETE (expression list), and CALL (subquery block or named procedure with optional YIELD).
  • Adds helper functions: parse_set_items, parse_remove_items, parse_opt_yield, parse_procedure_name, eat_label_names, and eat_ident_with_span.
  • Expands the test suite to cover all 13 clause types, replacing prior assertions that write clauses return errors.

Macroscope summarized 85e01be.

Summary by CodeRabbit

  • New Features
    • Cypher query parser now supports write operations: CREATE, MERGE (including ON CREATE/ON MATCH variants), SET (property assign/replace/merge and label additions), REMOVE (property and label removals), DELETE (with optional DETACH), and CALL (subquery and procedure forms with optional YIELD).
  • Tests
    • Unit tests expanded to cover all new write-clause parsing scenarios and updated clause-coverage checks.

Review Change Stack

…VE, DELETE, CALL (#653)

- `parse_query` dispatches all 13 clause types (was 7)
- CREATE: pattern list
- MERGE: single pattern + optional ON CREATE/ON MATCH SET
- SET: property assignment, full replace, property merge, label addition
- REMOVE: property removal, label removal
- DELETE / DETACH DELETE: comma-separated expression list
- CALL: subquery form `CALL { ... }` and named procedure `CALL f(args)`
  with optional YIELD
- 13 tests for write clauses; `all_clause_types_dispatch_without_panic`
  confirms all 13 dispatch arms reachable

Closes #653

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 30, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c944324c-b066-458f-a7fe-cbedda5929c5

📥 Commits

Reviewing files that changed from the base of the PR and between f397c87 and 85e01be.

📒 Files selected for processing (1)
  • crates/gf-cypher/src/parser/clauses.rs

Walkthrough

Parser adds complete write-clause support to clauses.rs: CREATE for node/relationship patterns, MERGE with conditional SET actions, SET with property/label operations, REMOVE for property/label removal, DELETE with optional DETACH, and CALL supporting both subquery and procedure forms. All write clauses integrate into parse_query dispatcher with comprehensive test coverage validating each clause type.

Changes

Write-clause parsing

Layer / File(s) Summary
Type imports and dependencies
crates/gf-cypher/src/parser/clauses.rs
Import list extended to include AST types for all write clauses: Create, Merge, Set, Remove, Delete, Call and their associated item types.
Clause dispatcher wiring
crates/gf-cypher/src/parser/clauses.rs
parse_query dispatcher now recognizes CREATE, MERGE, SET, REMOVE, DELETE, DETACH DELETE, and CALL tokens and dispatches to corresponding parsers, replacing prior error returns for write clauses.
Write clause parsers and helpers
crates/gf-cypher/src/parser/clauses.rs
Adds parse_create_clause, parse_merge_clause (with optional ON CREATE SET / ON MATCH SET), parse_set_clause with property assignment/replacement/merge and label addition, parse_remove_clause for property/label removal, parse_delete_clause with DETACH support, parse_call_clause supporting subquery form (brace-depth bounded) and procedure form with optional YIELD, plus helper eat_ident_with_span and shared label/procedure parsing helpers.
Test infrastructure and coverage
crates/gf-cypher/src/parser/clauses.rs
Test-module imports updated for new AST types; test suite expanded with dedicated tests for each write clause (CREATE node/relationship, MERGE with conditional sets, SET property/replacement/label, REMOVE property/label, DELETE/DETACH DELETE forms, CALL subquery/procedure); final all-clauses test now includes all 13 clause types without panic.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

Suggested labels

enhancement, parser

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and clearly summarizes the main feature: implementation of write clauses (CREATE, MERGE, SET, REMOVE, DELETE, CALL) in the Cypher parser.
Linked Issues check ✅ Passed All primary objectives from issue #653 are met: CREATE, MERGE, SET, REMOVE, DELETE/DETACH DELETE, and CALL clauses are implemented; parse_query dispatches all 13 clause types; all exit criteria examples parse correctly; 102 tests pass.
Out of Scope Changes check ✅ Passed All changes are scoped to write-clause parsing in clauses.rs as required; no unrelated modifications detected. The work is focused on the final parser chunk (#653) of the #554 initiative.
Docstring Coverage ✅ Passed Docstring coverage is 96.88% which is sufficient. The required threshold is 80.00%.
Description check ✅ Passed PR description is comprehensive, includes all major template sections with proper detail, test plan fully checked, and provides clear context of changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/653-write-clauses

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 30, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.09%. Comparing base (8546a88) to head (85e01be).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #658   +/-   ##
=======================================
  Coverage   84.09%   84.09%           
=======================================
  Files          49       49           
  Lines       12921    12921           
  Branches     3628     3628           
=======================================
  Hits        10866    10866           
  Misses       1270     1270           
  Partials      785      785           
Flag Coverage Δ
full-coverage 84.09% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
parser 92.93% <ø> (ø)
planner 79.90% <ø> (ø)
executor 75.46% <ø> (ø)
storage 98.68% <ø> (ø)
ast 97.51% <ø> (ø)
types 90.66% <ø> (ø)

Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 8546a88...85e01be. Read the comment docs.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread crates/gf-cypher/src/parser/clauses.rs
Comment thread crates/gf-cypher/src/parser/clauses.rs Outdated
Comment thread crates/gf-cypher/src/parser/clauses.rs
- CALL subquery: error on unterminated brace block (depth != 0 after loop)
- MERGE: accumulate ON CREATE/MATCH SET items with extend instead of overwriting
- SET/REMOVE: capture exact VarRef span from token triple, not start+1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
crates/gf-cypher/src/parser/clauses.rs (3)

1048-1056: ⚡ Quick win

Test doesn't verify subquery content is preserved.

The test confirms procedure.is_empty() and yield_items.len() == 1, but doesn't verify that the inner query MATCH (n) RETURN n was actually parsed. Once the subquery parsing issue is fixed, add assertions on c.subquery to ensure the inner query's clauses are present.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gf-cypher/src/parser/clauses.rs` around lines 1048 - 1056, The test
call_subquery currently only checks procedure.is_empty() and yield_items length;
update it to assert that the subquery was parsed by inspecting c.subquery (the
inner query AST) — e.g., ensure c.subquery is Some(_) and that its clauses
contain the MATCH and RETURN clause nodes (check the number of clauses and/or
inspect the first clause is a MATCH and the last is a RETURN) so the inner query
"MATCH (n) RETURN n" is preserved in the AstClause::Call(c) structure.

249-268: 💤 Low value

Duplicate ON CREATE SET / ON MATCH SET silently overwrites.

The loop allows multiple ON CREATE SET or ON MATCH SET clauses but replaces the previous items instead of extending them or erroring. If a query contains MERGE (n) ON CREATE SET n.x = 1 ON CREATE SET n.y = 2, only the second action's items are retained.

Consider either extending the vectors or detecting and rejecting duplicates:

Option: Error on duplicate
 loop {
     if ts.at(&Tok::On) {
         ts.advance(); // consume ON
         match ts.peek() {
             Some(Tok::Create) => {
+                if !on_create.is_empty() {
+                    return Err(ts.err("duplicate ON CREATE SET"));
+                }
                 ts.advance(); // consume CREATE
                 ts.eat(&Tok::Set)?;
                 on_create = parse_set_items(ts)?;
             }
             Some(Tok::Match) => {
+                if !on_match.is_empty() {
+                    return Err(ts.err("duplicate ON MATCH SET"));
+                }
                 ts.advance(); // consume MATCH
                 ts.eat(&Tok::Set)?;
                 on_match = parse_set_items(ts)?;
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gf-cypher/src/parser/clauses.rs` around lines 249 - 268, The loop
handling ON clauses currently overwrites previous ON CREATE SET and ON MATCH SET
data (variables on_create and on_match) because each parse_set_items(ts)
replaces the vector; update the handling in the loop in parse of clauses.rs so
duplicates are either rejected or merged: either (a) check if on_create/on_match
is already Some and return an error via ts.err("duplicate ON CREATE/MATCH SET")
when ts.at(&Tok::On) -> Tok::Create/Tok::Match is seen a second time, or (b)
when on_create/on_match is Some, append/extend the existing Vec with the result
of parse_set_items(ts) instead of replacing it (use Vec::extend). Locate the
logic around the loop that inspects ts.at(&Tok::On), ts.advance(), match on
Tok::Create/Match, and variables on_create/on_match to implement the chosen fix.

318-329: 💤 Low value

VarRef span is hardcoded to 1 character.

Span::new(start, start + 1) assumes the variable name is exactly one character. For multi-character names like person.name = 'x', the VarRef span will be incorrect. This affects error reporting and source mapping but not parsing correctness.

Consider capturing the position before and after eat_ident to derive the accurate span, or have eat_ident return (String, Span).

Also applies to: 398-408

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gf-cypher/src/parser/clauses.rs` around lines 318 - 329, The VarRef
span is being hardcoded with Span::new(start, start + 1) inside the
SetItem::Property construction which assumes single-character variable names;
update the code to compute the correct span by capturing the identifier bounds
(either have eat_ident return (String, Span) or capture position before/after
eat_ident) and use that span when constructing VarRef (and similarly fix the
other occurrence around lines 398-408); look for PropertyAccess/Expr::Var/VarRef
usages and replace the hardcoded Span::new(...) with the precise span returned
or computed from eat_ident.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/gf-cypher/src/parser/clauses.rs`:
- Around line 454-483: The CALL { ... } branch currently discards the inner
query by simply consuming tokens and returning an empty CallClause; update
parsing to capture and parse the subquery into the CallClause (add a subquery:
Option<AstQuery> field in the AST) instead of leaving procedure/args empty: when
ts.at(&Tok::LBrace) advance past `{`, collect or slice the token range for the
brace-balanced body and invoke the existing query parser (or appropriate
parse_query function) to produce an AstQuery and store it in
CallClause.subquery; if the closing `}` is never found, return a parse error
instead of silently exiting; preserve existing parse_opt_yield(ts)? handling
after parsing the subquery and set span via ts.span_from(start).

---

Nitpick comments:
In `@crates/gf-cypher/src/parser/clauses.rs`:
- Around line 1048-1056: The test call_subquery currently only checks
procedure.is_empty() and yield_items length; update it to assert that the
subquery was parsed by inspecting c.subquery (the inner query AST) — e.g.,
ensure c.subquery is Some(_) and that its clauses contain the MATCH and RETURN
clause nodes (check the number of clauses and/or inspect the first clause is a
MATCH and the last is a RETURN) so the inner query "MATCH (n) RETURN n" is
preserved in the AstClause::Call(c) structure.
- Around line 249-268: The loop handling ON clauses currently overwrites
previous ON CREATE SET and ON MATCH SET data (variables on_create and on_match)
because each parse_set_items(ts) replaces the vector; update the handling in the
loop in parse of clauses.rs so duplicates are either rejected or merged: either
(a) check if on_create/on_match is already Some and return an error via
ts.err("duplicate ON CREATE/MATCH SET") when ts.at(&Tok::On) ->
Tok::Create/Tok::Match is seen a second time, or (b) when on_create/on_match is
Some, append/extend the existing Vec with the result of parse_set_items(ts)
instead of replacing it (use Vec::extend). Locate the logic around the loop that
inspects ts.at(&Tok::On), ts.advance(), match on Tok::Create/Match, and
variables on_create/on_match to implement the chosen fix.
- Around line 318-329: The VarRef span is being hardcoded with Span::new(start,
start + 1) inside the SetItem::Property construction which assumes
single-character variable names; update the code to compute the correct span by
capturing the identifier bounds (either have eat_ident return (String, Span) or
capture position before/after eat_ident) and use that span when constructing
VarRef (and similarly fix the other occurrence around lines 398-408); look for
PropertyAccess/Expr::Var/VarRef usages and replace the hardcoded Span::new(...)
with the precise span returned or computed from eat_ident.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 69e1d900-6fb7-49ac-a39c-48be6acac66f

📥 Commits

Reviewing files that changed from the base of the PR and between 8546a88 and f397c87.

📒 Files selected for processing (1)
  • crates/gf-cypher/src/parser/clauses.rs

Comment on lines +454 to +483
if ts.at(&Tok::LBrace) {
// CALL { ... } subquery form
ts.advance(); // consume {
// Consume tokens until matching } (depth-aware)
let mut depth = 1usize;
while !ts.is_empty() {
match ts.peek() {
Some(Tok::LBrace) => {
depth += 1;
ts.advance();
}
Some(Tok::RBrace) => {
depth -= 1;
ts.advance();
if depth == 0 {
break;
}
}
_ => {
ts.advance();
}
}
}
let yield_items = parse_opt_yield(ts)?;
Ok(CallClause {
procedure: vec![],
args: vec![],
yield_items,
span: ts.span_from(start),
})
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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

CALL subquery body is discarded — the inner query is never parsed or stored.

The subquery form consumes all tokens between { and } using depth tracking, but the tokens are simply advanced past without being parsed. The resulting CallClause has no representation of the subquery content (procedure: vec![], args: vec![]).

For CALL { MATCH (n) RETURN n } YIELD n, the inner MATCH (n) RETURN n is lost entirely. This makes the AST incomplete and downstream processing (IR, execution) impossible.

Additionally, if the closing } is never found, the loop exits silently when ts.is_empty() without producing an error, leading to confusing downstream failures.

Suggested approach
 if ts.at(&Tok::LBrace) {
     // CALL { ... } subquery form
     ts.advance(); // consume {
-    // Consume tokens until matching } (depth-aware)
-    let mut depth = 1usize;
-    while !ts.is_empty() {
-        match ts.peek() {
-            Some(Tok::LBrace) => {
-                depth += 1;
-                ts.advance();
-            }
-            Some(Tok::RBrace) => {
-                depth -= 1;
-                ts.advance();
-                if depth == 0 {
-                    break;
-                }
-            }
-            _ => {
-                ts.advance();
-            }
-        }
-    }
+    // Recursively parse the inner query
+    let subquery = parse_query(ts)?;
+    if !ts.eat_if(&Tok::RBrace) {
+        return Err(ts.err("expected `}` to close CALL subquery"));
+    }
     let yield_items = parse_opt_yield(ts)?;
     Ok(CallClause {
         procedure: vec![],
         args: vec![],
+        subquery: Some(subquery),
         yield_items,
         span: ts.span_from(start),
     })

This requires CallClause in gf-ast to have a subquery: Option<AstQuery> field.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gf-cypher/src/parser/clauses.rs` around lines 454 - 483, The CALL {
... } branch currently discards the inner query by simply consuming tokens and
returning an empty CallClause; update parsing to capture and parse the subquery
into the CallClause (add a subquery: Option<AstQuery> field in the AST) instead
of leaving procedure/args empty: when ts.at(&Tok::LBrace) advance past `{`,
collect or slice the token range for the brace-balanced body and invoke the
existing query parser (or appropriate parse_query function) to produce an
AstQuery and store it in CallClause.subquery; if the closing `}` is never found,
return a parse error instead of silently exiting; preserve existing
parse_opt_yield(ts)? handling after parsing the subquery and set span via
ts.span_from(start).

@DecisionNerd DecisionNerd merged commit c54a740 into main May 30, 2026
41 of 42 checks passed
@DecisionNerd DecisionNerd deleted the feature/653-write-clauses branch May 30, 2026 00:09
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.

parser: write clauses, full wire-up, delete grammar.lalrpop (src/parser/clauses.rs)

1 participant