Support multi-statement mutations (insert + edge in one query)

Allow mutation queries to contain multiple sequential statements that
execute atomically within a single transactional run. This enables
patterns like inserting a node and its edges in one query:

    query add_and_link($name: String, $age: I32, $friend: String) {
        insert Person { name: $name, age: $age }
        insert Knows { from: $name, to: $friend }
    }

Changes span the full compiler-to-execution pipeline:
- Grammar: mutation_body = { mutation_stmt+ }
- AST: QueryDecl.mutations: Vec<Mutation>
- IR: MutationIR.ops: Vec<MutationOpIR>
- Execution: loop over ops, accumulate affected counts

Cross-statement visibility works because each statement's commit_updates
advances the manifest state, so subsequent statements see prior writes.
Atomicity comes from the existing run mechanism (begin_run/publish_run).

https://claude.ai/code/session_01E4VG2WXrZW8aeXFiqr8NwF
This commit is contained in:
Claude 2026-04-11 20:27:51 +00:00
parent a844e0ba68
commit d10f78530f
No known key found for this signature in database
9 changed files with 240 additions and 64 deletions

View file

@ -55,7 +55,7 @@ fn parse_query_decl(pair: pest::iterators::Pair<Rule>) -> Result<QueryDecl> {
let mut return_clause = Vec::new();
let mut order_clause = Vec::new();
let mut limit = None;
let mut mutation = None;
let mut mutations = Vec::new();
for item in inner {
match item.as_rule() {
@ -134,11 +134,18 @@ fn parse_query_decl(pair: pest::iterators::Pair<Rule>) -> Result<QueryDecl> {
}
}
}
Rule::mutation_stmt => {
let stmt = body.into_inner().next().ok_or_else(|| {
NanoError::Parse("mutation statement cannot be empty".to_string())
})?;
mutation = Some(parse_mutation_stmt(stmt)?);
Rule::mutation_body => {
for mutation_pair in body.into_inner() {
if let Rule::mutation_stmt = mutation_pair.as_rule() {
let stmt =
mutation_pair.into_inner().next().ok_or_else(|| {
NanoError::Parse(
"mutation statement cannot be empty".to_string(),
)
})?;
mutations.push(parse_mutation_stmt(stmt)?);
}
}
}
_ => {}
}
@ -156,7 +163,7 @@ fn parse_query_decl(pair: pest::iterators::Pair<Rule>) -> Result<QueryDecl> {
return_clause,
order_clause,
limit,
mutation,
mutations,
})
}
@ -1265,7 +1272,7 @@ query add_person($name: String, $age: I32) {
"#;
let qf = parse_query(input).unwrap();
let q = &qf.queries[0];
match q.mutation.as_ref().expect("expected mutation") {
match q.mutations.first().expect("expected mutation") {
Mutation::Insert(ins) => {
assert_eq!(ins.type_name, "Person");
assert_eq!(ins.assignments.len(), 2);
@ -1285,7 +1292,7 @@ query set_age($name: String, $age: I32) {
"#;
let qf = parse_query(input).unwrap();
let q = &qf.queries[0];
match q.mutation.as_ref().expect("expected mutation") {
match q.mutations.first().expect("expected mutation") {
Mutation::Update(upd) => {
assert_eq!(upd.type_name, "Person");
assert_eq!(upd.assignments.len(), 1);
@ -1305,7 +1312,7 @@ query drop_person($name: String) {
"#;
let qf = parse_query(input).unwrap();
let q = &qf.queries[0];
match q.mutation.as_ref().expect("expected mutation") {
match q.mutations.first().expect("expected mutation") {
Mutation::Delete(del) => {
assert_eq!(del.type_name, "Person");
assert_eq!(del.predicate.property, "name");
@ -1372,7 +1379,7 @@ query stamp() {
"#,
)
.unwrap();
match mutation.queries[0].mutation.as_ref().unwrap() {
match mutation.queries[0].mutations.first().unwrap() {
Mutation::Update(update) => {
assert!(matches!(update.assignments[0].value, MatchValue::Now));
assert!(matches!(update.predicate.value, MatchValue::Now));
@ -1381,6 +1388,47 @@ query stamp() {
}
}
#[test]
fn test_parse_multi_mutation() {
let input = r#"
query add_and_link($name: String, $age: I32, $friend: String) {
insert Person { name: $name, age: $age }
insert Knows { from: $name, to: $friend }
}
"#;
let qf = parse_query(input).unwrap();
let q = &qf.queries[0];
assert_eq!(q.mutations.len(), 2);
assert!(matches!(&q.mutations[0], Mutation::Insert(ins) if ins.type_name == "Person"));
assert!(matches!(&q.mutations[1], Mutation::Insert(ins) if ins.type_name == "Knows"));
}
#[test]
fn test_parse_multi_mutation_mixed_ops() {
let input = r#"
query create_and_clean($name: String, $age: I32, $old: String) {
insert Person { name: $name, age: $age }
delete Person where name = $old
}
"#;
let qf = parse_query(input).unwrap();
let q = &qf.queries[0];
assert_eq!(q.mutations.len(), 2);
assert!(matches!(&q.mutations[0], Mutation::Insert(_)));
assert!(matches!(&q.mutations[1], Mutation::Delete(_)));
}
#[test]
fn test_parse_single_mutation_backward_compat() {
let input = r#"
query add($name: String, $age: I32) {
insert Person { name: $name, age: $age }
}
"#;
let qf = parse_query(input).unwrap();
assert_eq!(qf.queries[0].mutations.len(), 1);
}
#[test]
fn test_parse_list_literal() {
let input = r#"