1use serde::{Deserialize, Deserializer, Serialize};
2
3use crate::traversal::{AstNode, MutationMode, ReadOnly, Traversal, TraversalState};
4#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum BatchCondition {
8 VarNotEmpty(String),
10 VarEmpty(String),
12 VarMinSize(String, usize),
14 PrevNotEmpty,
16}
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct NamedQuery {
21 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub name: Option<String>,
24 pub root: AstNode,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub condition: Option<BatchCondition>,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum BatchEntry {
35 Query(Box<NamedQuery>),
37 ForEach {
39 param: String,
41 body: Vec<BatchEntry>,
43 },
44}
45
46#[derive(Debug, Clone, PartialEq, Default, Serialize)]
48pub struct ReadBatch {
49 entries: Vec<BatchEntry>,
51 #[serde(default)]
53 returns: Vec<String>,
54}
55
56#[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 pub fn new() -> Self {
90 Self::default()
91 }
92
93 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 #[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 pub fn entries(&self) -> &[BatchEntry] {
116 &self.entries
117 }
118
119 pub fn returns(&self) -> &[String] {
121 &self.returns
122 }
123
124 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 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 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 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#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
198pub struct WriteBatch {
199 pub entries: Vec<BatchEntry>,
201 #[serde(default)]
203 pub returns: Vec<String>,
204}
205
206impl WriteBatch {
207 pub fn new() -> Self {
209 Self::default()
210 }
211
212 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 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 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263#[serde(rename_all = "snake_case")]
264pub enum BatchQuery {
265 Read(ReadBatch),
267 Write(WriteBatch),
269}
270pub fn read_batch() -> ReadBatch {
272 ReadBatch::new()
273}
274
275pub 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}