Skip to main content

dr_strange_parser/
write.rs

1//! Compile and execute parsed write statements. Unlike reads (which compile to
2//! a `LogicalPlan` the surfaces run), writes are imperative — core mutates
3//! through `WriteTxn`, not a serializable plan — so the query-language runtime
4//! applies them here, in one transaction committed atomically.
5//!
6//! A `MATCH … SET/REMOVE/DELETE` is **find-then-mutate**: the `MATCH` compiles
7//! to a read plan whose terminal node is the bound variable; we run it to get
8//! the ids, then apply the ops to each. A standalone `CREATE` just builds nodes
9//! and edges.
10
11use ahash::{AHashMap, AHashSet};
12
13use dr_strange_core::{
14    Dir, LogicalPlan, NodeId, PlaneHandle, PropDesc, PropValue, Properties, WriteTxn,
15};
16
17use crate::ast::*;
18
19/// What a write statement changed. Returned so a surface can report
20/// `2 nodes, 1 edge created`, etc.
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct WriteSummary {
23    pub nodes_created: u64,
24    pub edges_created: u64,
25    pub props_set: u64,
26    pub labels_set: u64,
27    pub nodes_deleted: u64,
28    pub edges_deleted: u64,
29}
30
31/// A compiled, executable write statement: an optional bound `MATCH` (variable
32/// name + its read plan) and the mutation ops. Build with [`compile`].
33#[derive(Debug)]
34pub struct WriteStatement {
35    /// `Some((var, plan))` for `MATCH … SET/REMOVE/DELETE`; `None` for `CREATE`.
36    binding: Option<(String, LogicalPlan)>,
37    ops: Vec<WriteOp>,
38    /// Whether any op adds/removes a label (so we preload current label sets).
39    has_label_ops: bool,
40    /// Values for `$name` placeholders, resolved when props are written.
41    params: crate::Params,
42}
43
44impl WriteStatement {
45    /// Execute against `plane` in one committed transaction.
46    pub fn apply(&self, plane: &PlaneHandle<'_>) -> Result<WriteSummary, String> {
47        execute(plane, self)
48    }
49}
50
51/// The variable bound by a `MATCH` pattern — its terminal node's variable.
52fn terminal_var(p: &Pattern) -> Option<&str> {
53    match p.rest.last() {
54        Some((_, n)) => n.var.as_deref(),
55        None => p.first.var.as_deref(),
56    }
57}
58
59fn set_item_var(it: &SetItem) -> &str {
60    match it {
61        SetItem::Prop { var, .. } | SetItem::Label { var, .. } | SetItem::Merge { var, .. } => var,
62    }
63}
64
65fn remove_item_var(it: &RemoveItem) -> &str {
66    match it {
67        RemoveItem::Prop { var, .. } | RemoveItem::Label { var, .. } => var,
68    }
69}
70
71fn is_label_op(op: &WriteOp) -> bool {
72    match op {
73        WriteOp::Set(items) => items.iter().any(|i| matches!(i, SetItem::Label { .. })),
74        WriteOp::Remove(items) => items.iter().any(|i| matches!(i, RemoveItem::Label { .. })),
75        _ => false,
76    }
77}
78
79/// Validate a parsed write and compile its `MATCH` (if any) into a read plan.
80pub fn compile(mut ast: WriteAst, params: crate::Params) -> Result<WriteStatement, String> {
81    let has_label_ops = ast.ops.iter().any(is_label_op);
82    resolve_keys(&mut ast.ops, &params)?;
83
84    let binding = match ast.match_clause {
85        // Standalone: only CREATE / MERGE are allowed with no MATCH.
86        None => {
87            for op in &ast.ops {
88                match op {
89                    WriteOp::Create(_) => {}
90                    WriteOp::Merge(m) => validate_merge(m)?,
91                    _ => {
92                        return Err(
93                            "SET / REMOVE / DELETE require a MATCH to select nodes".to_string()
94                        );
95                    }
96                }
97            }
98            None
99        }
100        // MATCH …: only SET/REMOVE/DELETE, all referencing the terminal variable.
101        Some(m) => {
102            let var = terminal_var(&m.pattern)
103                .ok_or("MATCH … SET/REMOVE/DELETE needs the last node to have a variable")?
104                .to_string();
105            for op in &ast.ops {
106                match op {
107                    // CREATE after MATCH runs once per matched row: nodes that
108                    // reuse the matched variable anchor to it; other vars are
109                    // new. No var restriction (unlike SET/REMOVE/DELETE).
110                    WriteOp::Create(_) => {}
111                    WriteOp::Merge(m) => validate_merge_after_match(m, &var)?,
112                    WriteOp::Set(items) => check_var(items.iter().map(set_item_var), &var)?,
113                    WriteOp::Remove(items) => check_var(items.iter().map(remove_item_var), &var)?,
114                    WriteOp::Delete { vars, .. } => {
115                        check_var(vars.iter().map(String::as_str), &var)?
116                    }
117                }
118            }
119            // Compile the MATCH to a read plan returning the terminal node.
120            let query = Query {
121                source: QuerySource {
122                    kind: SourceKind::Match,
123                    first: m.pattern.first,
124                    rest: m.pattern.rest,
125                },
126                beams: Vec::new(),
127                where_clause: m.where_clause,
128                // One row per matched node, not per path: a pattern that
129                // reaches a node twice (`(a)-->(n)`, two `a`s) would otherwise
130                // delete it twice — the second time a missing node — and the
131                // ops can't tell the rows apart anyway, since only the
132                // terminal variable is theirs to mutate.
133                ret: Return {
134                    distinct: true,
135                    items: vec![ReturnItem::Star],
136                },
137                order_by: Vec::new(),
138                skip: None,
139                limit: None,
140                // A write never time-travels: it targets the current state.
141                as_of: None,
142            };
143            let plan = crate::compile::compile(query, None, &params)?;
144            Some((var, plan))
145        }
146    };
147
148    Ok(WriteStatement {
149        binding,
150        ops: ast.ops,
151        has_label_ops,
152        params,
153    })
154}
155
156/// Resolve every `{key: $param}` to its string now, so a missing or non-string
157/// key is the statement's error rather than one row's, and the run-time path
158/// reads plain literals ([`literal_key`]).
159fn resolve_keys(ops: &mut [WriteOp], params: &crate::Params) -> Result<(), String> {
160    fn resolve_node(cn: &mut CreateNode, params: &crate::Params) -> Result<(), String> {
161        if let Some(Val::Param(name)) = &cn.key {
162            match crate::resolve_param(params, name)? {
163                s @ PropValue::Str(_) => cn.key = Some(Val::Lit(s)),
164                other => {
165                    return Err(format!(
166                        "`key:` must be a string to serve as the external key; `${name}` is {other:?}"
167                    ));
168                }
169            }
170        }
171        Ok(())
172    }
173    for op in ops {
174        let paths: Vec<&mut CreatePath> = match op {
175            WriteOp::Create(paths) => paths.iter_mut().collect(),
176            WriteOp::Merge(m) => vec![&mut m.path],
177            _ => continue,
178        };
179        for path in paths {
180            resolve_node(&mut path.first, params)?;
181            for (_, node) in &mut path.rest {
182                resolve_node(node, params)?;
183            }
184        }
185    }
186    Ok(())
187}
188
189/// The node's external key, which [`resolve_keys`] has made a literal.
190fn literal_key(cn: &CreateNode) -> Option<&str> {
191    match &cn.key {
192        Some(Val::Lit(PropValue::Str(s))) => Some(s),
193        _ => None,
194    }
195}
196
197/// A MERGE upserts by external key, so every node needs a string `key:` —
198/// unless it re-names a node earlier in the path (`(a)-[:R]->(b)<-[:S]-(a)`),
199/// which is already resolved. ON CREATE/MATCH SET is only for a single-node
200/// MERGE and references its variable.
201fn validate_merge(m: &MergeClause) -> Result<(), String> {
202    let nodes = std::iter::once(&m.path.first).chain(m.path.rest.iter().map(|(_, n)| n));
203    let mut bound: AHashSet<&str> = AHashSet::new();
204    for n in nodes {
205        let rebound = n.var.as_deref().is_some_and(|v| !bound.insert(v));
206        if n.key.is_none() && !rebound {
207            return Err(
208                "MERGE needs a `key:` on every node to upsert on, e.g. MERGE (n:Label {key:\"…\"})"
209                    .to_string(),
210            );
211        }
212    }
213    if !m.path.rest.is_empty() && (!m.on_create.is_empty() || !m.on_match.is_empty()) {
214        return Err(
215            "ON CREATE / ON MATCH SET is only supported for a single-node MERGE".to_string(),
216        );
217    }
218    let var = m.path.first.var.as_deref();
219    for it in m.on_create.iter().chain(&m.on_match) {
220        match var {
221            Some(v) if set_item_var(it) == v => {}
222            _ => {
223                return Err(
224                    "ON CREATE / ON MATCH SET must reference the MERGE variable".to_string()
225                );
226            }
227        }
228    }
229    Ok(())
230}
231
232/// A MERGE after MATCH must extend the matched node with a relationship: the
233/// first node anchors to the matched variable, every later node needs a key,
234/// and ON CREATE/MATCH SET isn't supported here.
235fn validate_merge_after_match(m: &MergeClause, terminal: &str) -> Result<(), String> {
236    if m.path.first.var.as_deref() != Some(terminal) {
237        return Err(format!(
238            "MERGE after MATCH must start from the matched variable `{terminal}`, \
239             e.g. MATCH ({terminal}) MERGE ({terminal})-[:R]->(b {{key:\"…\"}})"
240        ));
241    }
242    if m.path.rest.is_empty() {
243        return Err(
244            "MERGE after MATCH must extend the matched node with a relationship".to_string(),
245        );
246    }
247    for (_, n) in &m.path.rest {
248        if n.key.is_none() {
249            return Err("MERGE needs a `key:` on every non-anchor node".to_string());
250        }
251    }
252    if !m.on_create.is_empty() || !m.on_match.is_empty() {
253        return Err("ON CREATE / ON MATCH SET isn't supported for a MERGE after MATCH".to_string());
254    }
255    Ok(())
256}
257
258fn check_var<'a>(vars: impl Iterator<Item = &'a str>, bound: &str) -> Result<(), String> {
259    for v in vars {
260        if v != bound {
261            return Err(format!(
262                "`{v}` is not the matched variable `{bound}`; a mutation may reference \
263                 only the pattern's terminal variable"
264            ));
265        }
266    }
267    Ok(())
268}
269
270fn resolve_val(v: &Val, params: &crate::Params) -> Result<PropValue, String> {
271    match v {
272        Val::Lit(p) => Ok(p.clone()),
273        Val::Param(name) => crate::resolve_param(params, name),
274    }
275}
276
277fn props_of(entries: &[(String, Val)], params: &crate::Params) -> Result<Properties, String> {
278    entries
279        .iter()
280        .map(|(k, v)| Ok((k.clone(), PropDesc::new(resolve_val(v, params)?))))
281        .collect()
282}
283
284fn execute(plane: &PlaneHandle<'_>, stmt: &WriteStatement) -> Result<WriteSummary, String> {
285    let mut staged = Staged::new(&stmt.params);
286
287    // Find-then-mutate: run the MATCH read plan to get the target ids.
288    let ids: Vec<NodeId> = match &stmt.binding {
289        Some((_, plan)) => plane
290            .query_from_plan(plan.clone())
291            .ids()
292            .map_err(|e| e.to_string())?,
293        None => Vec::new(),
294    };
295
296    // Preloaded rather than read per op: a label SET/REMOVE rewrites the whole
297    // set, so the ops need the one the store holds now.
298    if stmt.has_label_ops {
299        for id in &ids {
300            if let Some(n) = plane.node(*id).map_err(|e| e.to_string())? {
301                staged.labels.insert(id.0, n.labels);
302            }
303        }
304    }
305
306    let rows = Rows { stmt, ids: &ids };
307    let mut txn = plane.write().map_err(|e| e.to_string())?;
308    for op in &stmt.ops {
309        match op {
310            WriteOp::Create(paths) => {
311                run_create(&mut txn, paths, rows, &mut staged)?;
312            }
313            WriteOp::Merge(m) => {
314                run_merge(plane, &mut txn, m, rows, &mut staged)?;
315            }
316            WriteOp::Set(items) => {
317                for id in &ids {
318                    for it in items {
319                        apply_set(&mut txn, *id, it, &mut staged)?;
320                    }
321                }
322            }
323            WriteOp::Remove(items) => {
324                for id in &ids {
325                    for it in items {
326                        apply_remove(&mut txn, *id, it, &mut staged)?;
327                    }
328                }
329            }
330            WriteOp::Delete { detach, .. } => {
331                run_delete(plane, &mut txn, rows, *detach, &mut staged)?;
332            }
333        }
334    }
335
336    txn.commit().map_err(|e| e.to_string())?;
337    Ok(staged.summary)
338}
339
340/// `CREATE`, standalone or after a `MATCH`.
341///
342/// Standalone builds once, with variables scoped to the clause. After a match
343/// it runs once per matched row with the matched variable pre-bound, so `(a)`
344/// anchors to that row's node rather than creating a fresh one.
345fn run_create<'a>(
346    txn: &mut WriteTxn<'_>,
347    paths: &'a [CreatePath],
348    rows: Rows<'a>,
349    staged: &mut Staged<'a>,
350) -> Result<(), String> {
351    match &rows.stmt.binding {
352        None => {
353            let mut vars: AHashMap<&'a str, NodeId> = AHashMap::new();
354            for path in paths {
355                create_path(txn, path, &mut vars, staged)?;
356            }
357        }
358        Some((bound, _)) => {
359            for id in rows.ids {
360                let mut vars: AHashMap<&'a str, NodeId> = AHashMap::new();
361                vars.insert(bound.as_str(), *id);
362                for path in paths {
363                    create_path(txn, path, &mut vars, staged)?;
364                }
365            }
366        }
367    }
368    Ok(())
369}
370
371/// `MERGE`, standalone or once per matched row — the same anchoring rule as
372/// [`run_create`]. [`Staged::keyed`] outlives the rows on purpose: a keyed node
373/// two rows both name must resolve to one node, and `node_by_key` cannot see a
374/// create this transaction has not committed.
375fn run_merge<'a>(
376    plane: &PlaneHandle<'_>,
377    txn: &mut WriteTxn<'_>,
378    m: &'a MergeClause,
379    rows: Rows<'a>,
380    staged: &mut Staged<'a>,
381) -> Result<(), String> {
382    match &rows.stmt.binding {
383        None => {
384            let mut vars: AHashMap<&'a str, NodeId> = AHashMap::new();
385            merge_path(plane, txn, m, &mut vars, staged)?;
386        }
387        Some((bound, _)) => {
388            for id in rows.ids {
389                let mut vars: AHashMap<&'a str, NodeId> = AHashMap::new();
390                vars.insert(bound.as_str(), *id);
391                merge_path(plane, txn, m, &mut vars, staged)?;
392            }
393        }
394    }
395    Ok(())
396}
397
398/// `DELETE`, and `DETACH DELETE`.
399///
400/// Plain `DELETE` refuses a node that still has relationships, which is
401/// Cypher's own semantics; `DETACH DELETE` cascades, core deleting the
402/// incident edges along with the node.
403fn run_delete(
404    plane: &PlaneHandle<'_>,
405    txn: &mut WriteTxn<'_>,
406    rows: Rows<'_>,
407    detach: bool,
408    staged: &mut Staged<'_>,
409) -> Result<(), String> {
410    for id in rows.ids {
411        if !detach
412            && !plane
413                .neighbors(*id, Dir::Both, None)
414                .map_err(|e| e.to_string())?
415                .is_empty()
416        {
417            return Err(format!(
418                "cannot DELETE node {} — it still has relationships; use DETACH DELETE",
419                id.0
420            ));
421        }
422        txn.delete_node(*id).map_err(|e| e.to_string())?;
423        staged.summary.nodes_deleted += 1;
424        staged.labels.remove(&id.0);
425    }
426    Ok(())
427}
428
429fn apply_set(
430    txn: &mut WriteTxn<'_>,
431    id: NodeId,
432    it: &SetItem,
433    staged: &mut Staged<'_>,
434) -> Result<(), String> {
435    match it {
436        SetItem::Prop { key, value, .. } => {
437            txn.set_prop(id, key, PropDesc::new(resolve_val(value, staged.params)?))
438                .map_err(|e| e.to_string())?;
439            staged.summary.props_set += 1;
440        }
441        SetItem::Merge { props, .. } => {
442            for (k, v) in props {
443                txn.set_prop(id, k, PropDesc::new(resolve_val(v, staged.params)?))
444                    .map_err(|e| e.to_string())?;
445                staged.summary.props_set += 1;
446            }
447        }
448        SetItem::Label { label, .. } => {
449            let set = staged.labels.entry(id.0).or_default();
450            if !set.iter().any(|l| l == label) {
451                set.push(label.clone());
452                let refs: Vec<&str> = set.iter().map(String::as_str).collect();
453                txn.set_labels(id, &refs).map_err(|e| e.to_string())?;
454                staged.summary.labels_set += 1;
455            }
456        }
457    }
458    Ok(())
459}
460
461fn apply_remove(
462    txn: &mut WriteTxn<'_>,
463    id: NodeId,
464    it: &RemoveItem,
465    staged: &mut Staged<'_>,
466) -> Result<(), String> {
467    match it {
468        RemoveItem::Prop { key, .. } => {
469            txn.remove_prop(id, key).map_err(|e| e.to_string())?;
470            staged.summary.props_set += 1;
471        }
472        RemoveItem::Label { label, .. } => {
473            let set = staged.labels.entry(id.0).or_default();
474            let before = set.len();
475            set.retain(|l| l != label);
476            if set.len() != before {
477                let refs: Vec<&str> = set.iter().map(String::as_str).collect();
478                txn.set_labels(id, &refs).map_err(|e| e.to_string())?;
479                staged.summary.labels_set += 1;
480            }
481        }
482    }
483    Ok(())
484}
485
486// ---- MERGE ----------------------------------------------------------------
487
488/// Execute a MERGE (single node or path): upsert each keyed node by external
489/// key, apply ON CREATE/MATCH SET to a single node, and ensure each edge.
490fn merge_path<'a>(
491    plane: &PlaneHandle<'_>,
492    txn: &mut WriteTxn<'_>,
493    m: &'a MergeClause,
494    vars: &mut AHashMap<&'a str, NodeId>,
495    staged: &mut Staged<'a>,
496) -> Result<(), String> {
497    let (first_id, created) = upsert_merge_node(plane, txn, &m.path.first, vars, staged)?;
498    // ON CREATE / ON MATCH SET apply to a single-node MERGE's node.
499    let items = if created { &m.on_create } else { &m.on_match };
500    for it in items {
501        apply_set(txn, first_id, it, staged)?;
502    }
503    let mut prev = first_id;
504    for (rel, node) in &m.path.rest {
505        let (cur, _) = upsert_merge_node(plane, txn, node, vars, staged)?;
506        ensure_edge(plane, txn, (prev, cur), rel, staged)?;
507        prev = cur;
508    }
509    Ok(())
510}
511
512/// Find a node by external key (and bind it) or create it. Reuse order: an
513/// already-bound variable (a matched anchor / an earlier node in this path),
514/// then a node upserted earlier *in this statement* by the same key
515/// ([`Staged::keyed`] — needed because `node_by_key` can't see the uncommitted
516/// create, e.g. a shared MERGE target across matched rows), then the committed
517/// store. Returns `(id, created)`.
518fn upsert_merge_node<'a>(
519    plane: &PlaneHandle<'_>,
520    txn: &mut WriteTxn<'_>,
521    cn: &'a CreateNode,
522    vars: &mut AHashMap<&'a str, NodeId>,
523    staged: &mut Staged<'a>,
524) -> Result<(NodeId, bool), String> {
525    if let Some(v) = &cn.var
526        && let Some(&id) = vars.get(v.as_str())
527    {
528        return Ok((id, false));
529    }
530    let key = literal_key(cn).ok_or("MERGE node needs a `key:` to upsert on")?;
531
532    if let Some(&id) = staged.keyed.get(key) {
533        if let Some(v) = &cn.var {
534            vars.insert(v.as_str(), id);
535        }
536        return Ok((id, false));
537    }
538
539    let (id, created) = match plane.node_by_key(key).map_err(|e| e.to_string())? {
540        Some(found) => {
541            let id = found.id;
542            staged.labels.entry(id.0).or_insert(found.labels);
543            (id, false)
544        }
545        None => {
546            let label_refs: Vec<&str> = cn.label.as_deref().into_iter().collect();
547            let id = txn
548                .create_node_with_key(key, &label_refs, props_of(&cn.props, staged.params)?)
549                .map_err(|e| e.to_string())?;
550            staged.summary.nodes_created += 1;
551            staged
552                .labels
553                .entry(id.0)
554                .or_insert_with(|| cn.label.clone().into_iter().collect());
555            (id, true)
556        }
557    };
558    staged.keyed.insert(key, id);
559    if let Some(v) = &cn.var {
560        vars.insert(v.as_str(), id);
561    }
562    Ok((id, created))
563}
564
565/// A statement in flight: what it has written so far, and the params it
566/// resolves values from.
567///
568/// One transaction spans every op, and the committed store shows none of its
569/// work until commit — so each op reads here what the ones before it made.
570struct Staged<'a> {
571    /// `$name` values, for every op that resolves one.
572    params: &'a crate::Params,
573    /// Keyed nodes this statement upserted or created, so one key names one
574    /// node however many rows or clauses reach it.
575    keyed: AHashMap<&'a str, NodeId>,
576    /// The `(src, dst, type)` triples created, which `ensure_edge` must check
577    /// on top of the committed store.
578    edges: AHashSet<(NodeId, NodeId, String)>,
579    /// Labels per node id: a label SET/REMOVE is read-modify-write, since
580    /// core's `set_labels` replaces the whole set.
581    labels: AHashMap<u64, Vec<String>>,
582    /// The counts returned to the caller.
583    summary: WriteSummary,
584}
585
586impl<'a> Staged<'a> {
587    /// An empty statement, resolving values from `params`.
588    fn new(params: &'a crate::Params) -> Self {
589        Self {
590            params,
591            keyed: AHashMap::new(),
592            edges: AHashSet::new(),
593            labels: AHashMap::new(),
594            summary: WriteSummary::default(),
595        }
596    }
597}
598
599/// The nodes a `MATCH` bound, and the statement that named them.
600///
601/// The two travel together: `ids` is empty exactly when the statement has no
602/// binding, and every op that runs per row needs both — the ids to anchor on
603/// and the binding to say which variable they are.
604#[derive(Clone, Copy)]
605struct Rows<'a> {
606    stmt: &'a WriteStatement,
607    ids: &'a [NodeId],
608}
609
610/// Ensure a directed edge of `rel.ty` exists between the `(prev, cur)` pair —
611/// MERGE is idempotent, so a matching edge is not duplicated, whether it was
612/// committed earlier or created a clause ago in this same statement.
613fn ensure_edge(
614    plane: &PlaneHandle<'_>,
615    txn: &mut WriteTxn<'_>,
616    (prev, cur): (NodeId, NodeId),
617    rel: &CreateRel,
618    staged: &mut Staged<'_>,
619) -> Result<(), String> {
620    // `->` is prev→cur; `<-` is cur→prev.
621    let (src, dst) = match rel.dir {
622        Dir::In => (cur, prev),
623        _ => (prev, cur),
624    };
625    if staged.edges.contains(&(src, dst, rel.ty.clone())) {
626        return Ok(());
627    }
628    let existing = plane
629        .neighbors(src, Dir::Out, Some(&rel.ty))
630        .map_err(|e| e.to_string())?;
631    if existing.iter().any(|n| n.node == dst) {
632        return Ok(());
633    }
634    txn.create_edge(src, dst, &rel.ty, props_of(&rel.props, staged.params)?)
635        .map_err(|e| e.to_string())?;
636    staged.edges.insert((src, dst, rel.ty.clone()));
637    staged.summary.edges_created += 1;
638    Ok(())
639}
640
641// ---- CREATE ---------------------------------------------------------------
642
643fn get_or_create<'a>(
644    txn: &mut WriteTxn<'_>,
645    cn: &'a CreateNode,
646    vars: &mut AHashMap<&'a str, NodeId>,
647    staged: &mut Staged<'a>,
648) -> Result<NodeId, String> {
649    if let Some(v) = &cn.var
650        && let Some(&id) = vars.get(v.as_str())
651    {
652        return Ok(id); // same variable → the same node
653    }
654    let labels: Vec<&str> = cn.label.as_deref().into_iter().collect();
655    let props = props_of(&cn.props, staged.params)?;
656    let id = match literal_key(cn) {
657        Some(k) => {
658            let id = txn
659                .create_node_with_key(k, &labels, props)
660                .map_err(|e| e.to_string())?;
661            // A MERGE later in this statement upserts on the same key; the
662            // store won't show it the node until commit, so tell it here.
663            staged.keyed.insert(k, id);
664            id
665        }
666        None => txn.create_node(&labels, props).map_err(|e| e.to_string())?,
667    };
668    staged.summary.nodes_created += 1;
669    if let Some(v) = &cn.var {
670        vars.insert(v.as_str(), id);
671    }
672    Ok(id)
673}
674
675fn create_path<'a>(
676    txn: &mut WriteTxn<'_>,
677    path: &'a CreatePath,
678    vars: &mut AHashMap<&'a str, NodeId>,
679    staged: &mut Staged<'a>,
680) -> Result<(), String> {
681    let mut prev = get_or_create(txn, &path.first, vars, staged)?;
682    for (rel, node) in &path.rest {
683        let cur = get_or_create(txn, node, vars, staged)?;
684        // `->` is prev→cur; `<-` is cur→prev (the parser rejects undirected).
685        let (src, dst) = match rel.dir {
686            Dir::In => (cur, prev),
687            _ => (prev, cur),
688        };
689        txn.create_edge(src, dst, &rel.ty, props_of(&rel.props, staged.params)?)
690            .map_err(|e| e.to_string())?;
691        // CREATE always adds; it records the edge so a MERGE later in the
692        // same statement sees it.
693        staged.edges.insert((src, dst, rel.ty.clone()));
694        staged.summary.edges_created += 1;
695        prev = cur;
696    }
697    Ok(())
698}