Skip to main content

helix_ast/
batch.rs

1use serde::{Deserialize, Deserializer, Serialize};
2
3use crate::traversal::{AstNode, MutationMode, ReadOnly, Traversal, TraversalState};
4/// Condition for conditional batch entries.
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum BatchCondition {
8    /// Variable is not empty.
9    VarNotEmpty(String),
10    /// Variable is empty.
11    VarEmpty(String),
12    /// Variable has at least this size.
13    VarMinSize(String, usize),
14    /// Previous query result was not empty.
15    PrevNotEmpty,
16}
17
18/// A named batch query.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct NamedQuery {
21    /// Variable name.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub name: Option<String>,
24    /// Traversal root.
25    pub root: AstNode,
26    /// Optional condition.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub condition: Option<BatchCondition>,
29}
30
31/// Batch entry.
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum BatchEntry {
35    /// Single query.
36    Query(Box<NamedQuery>),
37    /// Execute body once per object in a parameter array.
38    ForEach {
39        /// Top-level parameter.
40        param: String,
41        /// Body entries.
42        body: Vec<BatchEntry>,
43    },
44}
45
46/// Read-only query batch.
47#[derive(Debug, Clone, PartialEq, Default, Serialize)]
48pub struct ReadBatch {
49    /// Batch entries in execution order.
50    entries: Vec<BatchEntry>,
51    /// Variables to return.
52    #[serde(default)]
53    returns: Vec<String>,
54}
55
56/// A read batch contained a persistent mutation.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ReadBatchError {
59    entry_path: String,
60}
61
62impl ReadBatchError {
63    fn mutation(entry_path: String) -> Self {
64        Self { entry_path }
65    }
66}
67
68impl std::fmt::Display for ReadBatchError {
69    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        write!(
71            formatter,
72            "read batch entry '{}' contains a persistent mutation",
73            self.entry_path
74        )
75    }
76}
77
78impl std::error::Error for ReadBatchError {}
79
80#[derive(Deserialize)]
81struct RawReadBatch {
82    entries: Vec<BatchEntry>,
83    #[serde(default)]
84    returns: Vec<String>,
85}
86
87impl ReadBatch {
88    /// Create an empty read batch.
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    /// Construct a read batch from raw wire-compatible parts.
94    ///
95    /// Every query root, including nested `for_each` bodies and branch
96    /// traversals, is checked before the batch becomes representable.
97    pub fn try_from_parts(
98        entries: Vec<BatchEntry>,
99        returns: Vec<String>,
100    ) -> Result<Self, ReadBatchError> {
101        validate_read_entries(&entries, "entries")?;
102        Ok(Self { entries, returns })
103    }
104
105    /// Construct an unchecked batch for downstream raw-AST contract tests.
106    ///
107    /// This escape hatch is unavailable in normal builds.
108    #[cfg(feature = "test-utils")]
109    #[doc(hidden)]
110    pub fn from_parts_unchecked_for_tests(entries: Vec<BatchEntry>, returns: Vec<String>) -> Self {
111        Self { entries, returns }
112    }
113
114    /// Batch entries in execution order.
115    pub fn entries(&self) -> &[BatchEntry] {
116        &self.entries
117    }
118
119    /// Variables returned by this batch.
120    pub fn returns(&self) -> &[String] {
121        &self.returns
122    }
123
124    /// Add a named read-only traversal.
125    pub fn var_as<S: TraversalState>(
126        mut self,
127        name: &str,
128        traversal: Traversal<S, ReadOnly>,
129    ) -> Self {
130        self.entries.push(BatchEntry::Query(Box::new(NamedQuery {
131            name: Some(name.to_string()),
132            root: traversal.into_ast(),
133            condition: None,
134        })));
135        self
136    }
137
138    /// Add a conditional named read-only traversal.
139    pub fn var_as_if<S: TraversalState>(
140        mut self,
141        name: &str,
142        condition: BatchCondition,
143        traversal: Traversal<S, ReadOnly>,
144    ) -> Self {
145        self.entries.push(BatchEntry::Query(Box::new(NamedQuery {
146            name: Some(name.to_string()),
147            root: traversal.into_ast(),
148            condition: Some(condition),
149        })));
150        self
151    }
152
153    /// Add a for-each body.
154    pub fn for_each_param(mut self, param: &str, body: ReadBatch) -> Self {
155        self.entries.push(BatchEntry::ForEach {
156            param: param.to_string(),
157            body: body.entries,
158        });
159        self
160    }
161
162    /// Set returned variables.
163    pub fn returning<I, S>(mut self, vars: I) -> Self
164    where
165        I: IntoIterator<Item = S>,
166        S: Into<String>,
167    {
168        self.returns = vars.into_iter().map(Into::into).collect();
169        self
170    }
171}
172
173impl<'de> Deserialize<'de> for ReadBatch {
174    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
175    where
176        D: Deserializer<'de>,
177    {
178        let raw = RawReadBatch::deserialize(deserializer)?;
179        Self::try_from_parts(raw.entries, raw.returns).map_err(serde::de::Error::custom)
180    }
181}
182
183fn validate_read_entries(entries: &[BatchEntry], path: &str) -> Result<(), ReadBatchError> {
184    entries
185        .iter()
186        .enumerate()
187        .try_for_each(|(index, entry)| match entry {
188            BatchEntry::Query(query) if query.root.is_read_only() => Ok(()),
189            BatchEntry::Query(_) => Err(ReadBatchError::mutation(format!("{path}[{index}]"))),
190            BatchEntry::ForEach { body, .. } => {
191                validate_read_entries(body, &format!("{path}[{index}].for_each"))
192            }
193        })
194}
195
196/// Write-capable query batch.
197#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
198pub struct WriteBatch {
199    /// Batch entries in execution order.
200    pub entries: Vec<BatchEntry>,
201    /// Variables to return.
202    #[serde(default)]
203    pub returns: Vec<String>,
204}
205
206impl WriteBatch {
207    /// Create an empty write batch.
208    pub fn new() -> Self {
209        Self::default()
210    }
211
212    /// Add a named traversal.
213    pub fn var_as<S: TraversalState, M: MutationMode>(
214        mut self,
215        name: &str,
216        traversal: Traversal<S, M>,
217    ) -> Self {
218        self.entries.push(BatchEntry::Query(Box::new(NamedQuery {
219            name: Some(name.to_string()),
220            root: traversal.into_ast(),
221            condition: None,
222        })));
223        self
224    }
225
226    /// Add a conditional named traversal.
227    pub fn var_as_if<S: TraversalState, M: MutationMode>(
228        mut self,
229        name: &str,
230        condition: BatchCondition,
231        traversal: Traversal<S, M>,
232    ) -> Self {
233        self.entries.push(BatchEntry::Query(Box::new(NamedQuery {
234            name: Some(name.to_string()),
235            root: traversal.into_ast(),
236            condition: Some(condition),
237        })));
238        self
239    }
240
241    /// Add a for-each body.
242    pub fn for_each_param(mut self, param: &str, body: WriteBatch) -> Self {
243        self.entries.push(BatchEntry::ForEach {
244            param: param.to_string(),
245            body: body.entries,
246        });
247        self
248    }
249
250    /// Set returned variables.
251    pub fn returning<I, S>(mut self, vars: I) -> Self
252    where
253        I: IntoIterator<Item = S>,
254        S: Into<String>,
255    {
256        self.returns = vars.into_iter().map(Into::into).collect();
257        self
258    }
259}
260
261/// Batch query payload.
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263#[serde(rename_all = "snake_case")]
264pub enum BatchQuery {
265    /// Read-only batch.
266    Read(ReadBatch),
267    /// Write-capable batch.
268    Write(WriteBatch),
269}
270/// Create a read batch.
271pub fn read_batch() -> ReadBatch {
272    ReadBatch::new()
273}
274
275/// Create a write batch.
276pub fn write_batch() -> WriteBatch {
277    WriteBatch::new()
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::expr::Predicate;
284    use crate::graph::{EdgeRef, NodeRef};
285    use crate::index::IndexSpec;
286    use crate::traversal::{EmitBehavior, RepeatConfig, SubTraversal};
287    use crate::value::PropertyInput;
288
289    fn query(root: AstNode) -> BatchEntry {
290        BatchEntry::Query(Box::new(NamedQuery {
291            name: Some("result".to_owned()),
292            root,
293            condition: None,
294        }))
295    }
296
297    fn nodes() -> AstNode {
298        AstNode::Nodes {
299            reference: NodeRef::All,
300        }
301    }
302
303    fn branch(root: AstNode) -> SubTraversal {
304        SubTraversal {
305            root: Box::new(root),
306        }
307    }
308
309    fn mutation_families() -> Vec<(&'static str, AstNode)> {
310        vec![
311            (
312                "create_index",
313                AstNode::CreateIndex {
314                    spec: IndexSpec::node_equality("User", "email"),
315                    if_not_exists: true,
316                },
317            ),
318            (
319                "drop_index",
320                AstNode::DropIndex {
321                    spec: IndexSpec::node_equality("User", "email"),
322                },
323            ),
324            (
325                "retry_index_operation",
326                AstNode::RetryIndexOperation {
327                    operation_id: "00000000-0000-0000-0000-000000000001".to_owned(),
328                },
329            ),
330            (
331                "abort_index_operation",
332                AstNode::AbortIndexOperation {
333                    operation_id: "00000000-0000-0000-0000-000000000001".to_owned(),
334                },
335            ),
336            (
337                "add_node",
338                AstNode::AddN {
339                    input: None,
340                    label: "User".to_owned(),
341                    properties: Vec::new(),
342                },
343            ),
344            (
345                "add_edge",
346                AstNode::AddE {
347                    input: Box::new(nodes()),
348                    label: "KNOWS".to_owned(),
349                    to: NodeRef::id(2),
350                    properties: Vec::new(),
351                },
352            ),
353            (
354                "set_property",
355                AstNode::SetProperty {
356                    input: Box::new(nodes()),
357                    name: "active".to_owned(),
358                    value: PropertyInput::from(true),
359                },
360            ),
361            (
362                "remove_property",
363                AstNode::RemoveProperty {
364                    input: Box::new(nodes()),
365                    name: "active".to_owned(),
366                },
367            ),
368            (
369                "drop_node",
370                AstNode::Drop {
371                    input: Box::new(nodes()),
372                },
373            ),
374            (
375                "drop_edge",
376                AstNode::DropEdge {
377                    input: Box::new(nodes()),
378                    to: NodeRef::id(2),
379                },
380            ),
381            (
382                "drop_labeled_edge",
383                AstNode::DropEdgeLabeled {
384                    input: Box::new(nodes()),
385                    to: NodeRef::id(2),
386                    label: "KNOWS".to_owned(),
387                },
388            ),
389            (
390                "drop_edge_by_id",
391                AstNode::DropEdgeById {
392                    input: None,
393                    edges: EdgeRef::id(1),
394                },
395            ),
396        ]
397    }
398
399    fn nested_positions(mutation: &AstNode) -> Vec<(&'static str, AstNode)> {
400        let condition = Predicate::eq("active", true);
401        vec![
402            (
403                "unary_input",
404                AstNode::Count {
405                    input: Box::new(mutation.clone()),
406                },
407            ),
408            (
409                "repeat_body",
410                AstNode::Repeat {
411                    input: Box::new(nodes()),
412                    config: RepeatConfig {
413                        traversal: branch(mutation.clone()),
414                        times: Some(1),
415                        until: None,
416                        emit: EmitBehavior::None,
417                        emit_predicate: None,
418                        max_depth: 1,
419                    },
420                },
421            ),
422            (
423                "union_branch",
424                AstNode::Union {
425                    input: Box::new(nodes()),
426                    traversals: vec![branch(AstNode::Context), branch(mutation.clone())],
427                },
428            ),
429            (
430                "coalesce_branch",
431                AstNode::Coalesce {
432                    input: Box::new(nodes()),
433                    traversals: vec![branch(AstNode::Context), branch(mutation.clone())],
434                },
435            ),
436            (
437                "choose_then",
438                AstNode::Choose {
439                    input: Box::new(nodes()),
440                    condition: condition.clone(),
441                    then_traversal: branch(mutation.clone()),
442                    else_traversal: Some(branch(AstNode::Context)),
443                },
444            ),
445            (
446                "choose_else",
447                AstNode::Choose {
448                    input: Box::new(nodes()),
449                    condition,
450                    then_traversal: branch(AstNode::Context),
451                    else_traversal: Some(branch(mutation.clone())),
452                },
453            ),
454            (
455                "optional_branch",
456                AstNode::Optional {
457                    input: Box::new(nodes()),
458                    traversal: branch(mutation.clone()),
459                },
460            ),
461        ]
462    }
463
464    #[test]
465    fn read_batch_rejects_every_mutation_family_at_root_and_nested_positions() {
466        for (mutation_name, mutation) in mutation_families() {
467            assert!(
468                ReadBatch::try_from_parts(vec![query(mutation.clone())], Vec::new()).is_err(),
469                "{mutation_name} must be rejected at the root"
470            );
471
472            for (position, nested) in nested_positions(&mutation) {
473                assert!(
474                    ReadBatch::try_from_parts(vec![query(nested)], Vec::new()).is_err(),
475                    "{mutation_name} must be rejected at {position}"
476                );
477            }
478
479            assert!(
480                ReadBatch::try_from_parts(
481                    vec![BatchEntry::ForEach {
482                        param: "items".to_owned(),
483                        body: vec![query(mutation)],
484                    }],
485                    Vec::new(),
486                )
487                .is_err(),
488                "{mutation_name} must be rejected in a for_each body"
489            );
490        }
491    }
492
493    #[test]
494    fn read_batch_accepts_recursive_read_only_positions() {
495        let safe_branch = || {
496            branch(AstNode::Count {
497                input: Box::new(AstNode::Context),
498            })
499        };
500        let entries = vec![
501            query(AstNode::Repeat {
502                input: Box::new(nodes()),
503                config: RepeatConfig::new(safe_branch()).times(1),
504            }),
505            query(AstNode::Union {
506                input: Box::new(nodes()),
507                traversals: vec![safe_branch()],
508            }),
509            query(AstNode::Coalesce {
510                input: Box::new(nodes()),
511                traversals: vec![safe_branch()],
512            }),
513            query(AstNode::Choose {
514                input: Box::new(nodes()),
515                condition: Predicate::eq("active", true),
516                then_traversal: safe_branch(),
517                else_traversal: Some(safe_branch()),
518            }),
519            query(AstNode::Optional {
520                input: Box::new(nodes()),
521                traversal: safe_branch(),
522            }),
523            BatchEntry::ForEach {
524                param: "items".to_owned(),
525                body: vec![query(nodes())],
526            },
527        ];
528
529        let batch = ReadBatch::try_from_parts(entries, vec!["result".to_owned()])
530            .expect("read-only recursive positions should be accepted");
531        assert_eq!(batch.entries().len(), 6);
532        assert_eq!(batch.returns(), ["result"]);
533    }
534}