graphforge_ir/plan.rs
1//! [`GraphPlan`] envelope and [`GraphOp`] operator enum.
2//!
3//! `GraphPlan` is the stable semantic contract between the compiler (binder)
4//! and the execution layer. It wraps a flat sequence of [`GraphOp`] operators
5//! together with an [`ExprArena`] holding all referenced expressions, plus
6//! version and ontology metadata.
7
8use serde::{Deserialize, Serialize};
9
10use crate::{
11 AggExpr, CreatePattern, Direction, ExprArena, ExprId, IrVersion, OntologyVersion,
12 ProcedureDefinition, ProcedureYield, ProjectItem, RemovePropItem, SetMapItem, SetPropItem,
13 SortOrder, TypeId, VarId,
14};
15
16/// Child projection column consumed by pattern-comprehension lowering.
17pub const PATTERN_COMPREHENSION_VALUE_ALIAS: &str = "__gf_pattern_comprehension_value";
18
19// ---------------------------------------------------------------------------
20// OntologyMode
21// ---------------------------------------------------------------------------
22
23/// The active ontology enforcement mode for this query plan.
24///
25/// Defined in [`graphforge_core`] (shared with the project manifest) and re-exported
26/// here so existing `graphforge_ir::OntologyMode` paths keep resolving.
27pub use graphforge_core::OntologyMode;
28
29// ---------------------------------------------------------------------------
30// SortKey
31// ---------------------------------------------------------------------------
32
33/// A single key in a [`GraphOp::Sort`] operator.
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct SortKey {
36 /// The expression to sort by.
37 pub expr: ExprId,
38 /// Sort direction.
39 pub order: SortOrder,
40 /// If `true`, `NULL` values appear before non-`NULL` values.
41 pub nulls_first: bool,
42}
43
44// ---------------------------------------------------------------------------
45// GraphOp
46// ---------------------------------------------------------------------------
47
48/// A single operator node in a [`GraphPlan`].
49///
50/// Plans are represented as a flat `Vec<GraphOp>` (operators in pipeline
51/// order). Recursive structure — e.g. optional matches, unions — is
52/// expressed by variants that embed a child [`GraphPlan`].
53///
54/// This enum is `#[non_exhaustive]` so that adding new operators in a minor
55/// version does not constitute a breaking change for downstream crates that
56/// match on it.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58#[non_exhaustive]
59pub enum GraphOp {
60 /// Scan all nodes, optionally filtered to a single label type.
61 ///
62 /// `ty: None` scans every node regardless of label.
63 NodeScan {
64 /// The variable that receives each scanned node.
65 var: VarId,
66 /// The label type to filter on, or `None` for a full scan.
67 ty: Option<TypeId>,
68 },
69
70 /// Wildcard edge scan across all relation types.
71 ///
72 /// Prefer [`TypedEdgeScan`](Self::TypedEdgeScan) when the relation type is
73 /// statically known; this variant exists for queries that match any edge.
74 EdgeScan {
75 /// The variable that receives each scanned edge.
76 var: VarId,
77 /// Optional relation type filter.
78 ty: Option<TypeId>,
79 },
80
81 /// Scan `topology/edges/TYPENAME.parquet` for a specific relation type.
82 ///
83 /// This is the primary edge-scan operator emitted by the binder whenever a
84 /// MATCH pattern specifies a concrete relation type. The relational
85 /// lowering layer maps `rel_ty → relation_name` via the ontology (or the
86 /// `RuntimeCatalog` in exploratory mode) to determine the Parquet file
87 /// path. In exploratory mode, if `rel_ty` is a `RuntimeTypeId`, the
88 /// storage layer routes the scan to `topology/edges/_exploratory.parquet`.
89 TypedEdgeScan {
90 /// The variable that receives each scanned edge.
91 var: VarId,
92 /// The concrete relation type to scan.
93 rel_ty: TypeId,
94 },
95
96 /// Expand from a source node along edges to destination nodes.
97 Expand {
98 /// Source node variable.
99 src: VarId,
100 /// Edge variable.
101 edge: VarId,
102 /// Destination node variable.
103 dst: VarId,
104 /// Relation type filter (`None` = any relation type).
105 rel_ty: Option<TypeId>,
106 /// Traversal direction.
107 dir: Direction,
108 /// Minimum number of hops (1 for a single hop).
109 min_hops: u16,
110 /// Maximum number of hops (`None` = unbounded).
111 max_hops: Option<u16>,
112 },
113
114 /// Enforce relationship isomorphism within one path pattern.
115 RelationshipUnique {
116 /// Newly bound relationship variable.
117 edge: VarId,
118 /// Relationships bound earlier in the same path pattern.
119 prior_edges: Vec<VarId>,
120 },
121
122 /// Retain only rows where `predicate` evaluates to `true`.
123 Filter {
124 /// The filter predicate expression.
125 predicate: ExprId,
126 },
127
128 /// Project a set of expressions into named output columns.
129 Project {
130 /// The output columns.
131 items: Vec<ProjectItem>,
132 /// If `true`, duplicate rows are eliminated.
133 distinct: bool,
134 },
135
136 /// Group rows and compute aggregates.
137 Aggregate {
138 /// Expressions to group by.
139 group_by: Vec<ExprId>,
140 /// Output column name for each group-by key (parallel to `group_by`):
141 /// `Some(name)` aliases that key's result column (openCypher names it by
142 /// the RETURN item's source text), `None` leaves the lowered expr's
143 /// default name. Empty or shorter-than-`group_by` ⇒ no aliasing (#599).
144 #[serde(default, skip_serializing_if = "Vec::is_empty")]
145 group_aliases: Vec<Option<String>>,
146 /// Synthetic output variable for each group-by key (parallel to
147 /// `group_by`), bound to that key's result column so a following `Project`
148 /// can reference it when a RETURN item nests an aggregate (`n.x +
149 /// count(*)`). Empty unless the aggregate was decomposed (#599).
150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
151 group_vars: Vec<Option<VarId>>,
152 /// Aggregate expressions.
153 aggs: Vec<AggExpr>,
154 },
155
156 /// Sort the row stream by one or more keys.
157 Sort {
158 /// Ordered list of sort keys.
159 keys: Vec<SortKey>,
160 },
161
162 /// Retain at most `count` rows.
163 Limit {
164 /// Maximum number of rows to return.
165 count: u64,
166 },
167
168 /// Retain at most the row count supplied by a query parameter.
169 LimitParam {
170 /// Parameter name without the leading `$`.
171 name: String,
172 },
173
174 /// Retain at most the row count produced by a variable-independent expression.
175 LimitExpr {
176 /// Expression evaluated once at query execution time.
177 expr: ExprId,
178 },
179
180 /// Skip the first `count` rows.
181 Skip {
182 /// Number of rows to skip.
183 count: u64,
184 },
185
186 /// Skip the row count supplied by a query parameter.
187 SkipParam {
188 /// Parameter name without the leading `$`.
189 name: String,
190 },
191
192 /// Skip the row count produced by a variable-independent expression.
193 SkipExpr {
194 /// Expression evaluated once at query execution time.
195 expr: ExprId,
196 },
197
198 /// Execute a child plan and emit its results, substituting `NULL` for
199 /// unmatched rows (LEFT OPTIONAL semantics).
200 Optional {
201 /// The optional sub-plan.
202 child: Box<GraphPlan>,
203 },
204
205 /// Retain or reject rows according to whether a correlated child plan has
206 /// at least one match (pattern-predicate semantics).
207 Exists {
208 /// The correlated sub-plan.
209 child: Box<GraphPlan>,
210 /// `true` for `NOT` existential predicates.
211 negated: bool,
212 },
213
214 /// Evaluate a correlated pattern and collect one projected value per match.
215 PatternComprehension {
216 /// The correlated match, optional filter, and terminal value projection.
217 child: Box<GraphPlan>,
218 /// Synthetic variable receiving the collected list in the outer plan.
219 output: VarId,
220 },
221
222 /// Evaluate a pattern comprehension once per graph-valued list element.
223 ListElementPatternComprehension {
224 /// Source list whose elements bind `loop_var` in lexical order.
225 list_expr: ExprId,
226 /// Lexical list-comprehension element variable.
227 loop_var: VarId,
228 /// Correlated pattern-comprehension child plan.
229 child: Box<GraphPlan>,
230 /// Synthetic variable receiving the child collection per element.
231 pattern_output: VarId,
232 /// Optional list-comprehension filter.
233 filter: Option<ExprId>,
234 /// Optional list-comprehension projection.
235 projection: Option<ExprId>,
236 /// Synthetic variable receiving the reassembled outer list.
237 output: VarId,
238 },
239
240 /// Combine results from multiple sub-plans.
241 Union {
242 /// If `false`, duplicates are eliminated (UNION).
243 /// If `true`, all rows are kept (UNION ALL).
244 all: bool,
245 /// The sub-plans whose results are combined.
246 inputs: Vec<GraphPlan>,
247 },
248
249 /// Iterate over a list expression, binding each element to `alias`.
250 Unwind {
251 /// The expression producing the list to iterate.
252 list_expr: ExprId,
253 /// The variable bound to each list element.
254 alias: VarId,
255 },
256
257 /// Invoke a registered procedure for every input row.
258 Call {
259 /// Frozen signature and deterministic fixture rows.
260 procedure: ProcedureDefinition,
261 /// Explicit expressions or implicit parameters, in signature order.
262 args: Vec<ExprId>,
263 /// Procedure outputs introduced into query scope.
264 yields: Vec<ProcedureYield>,
265 },
266
267 /// Create nodes and/or edges described by `pattern`.
268 ///
269 /// Write-path execution semantics are deferred to Milestone 13; this
270 /// variant serialises and deserialises but is not covered by execution
271 /// tests yet.
272 Create {
273 /// The pattern to create.
274 pattern: CreatePattern,
275 },
276
277 /// Match-or-create semantics for `pattern`.
278 ///
279 /// Same execution-deferral note as [`Create`](Self::Create).
280 Merge {
281 /// The pattern to match or create.
282 pattern: CreatePattern,
283 /// Actions applied only when the pattern is created.
284 #[serde(default, skip_serializing_if = "Vec::is_empty")]
285 on_create: Vec<crate::MergeSetItem>,
286 /// Actions applied only when the pattern already exists.
287 #[serde(default, skip_serializing_if = "Vec::is_empty")]
288 on_match: Vec<crate::MergeSetItem>,
289 },
290
291 /// Delete graph entities referenced directly or produced by expressions.
292 ///
293 /// Direct variables use identity columns; runtime expressions may produce
294 /// nodes, relationships, paths, or null through list/map access. When
295 /// `detach` is `false`, deleting a node that still has relationships is an
296 /// execution error; `detach = true` also removes incident edges.
297 Delete {
298 /// The bound variables to delete (nodes and/or edges).
299 vars: Vec<VarId>,
300 /// Runtime expressions yielding nodes, relationships, or paths.
301 #[serde(default, skip_serializing_if = "Vec::is_empty")]
302 exprs: Vec<ExprId>,
303 /// `true` for `DETACH DELETE` — remove incident edges with the node.
304 detach: bool,
305 },
306
307 /// Set properties on bound graph entities (#791).
308 ///
309 /// Each item assigns one property of a bound node or edge variable to a
310 /// value expression evaluated per matched row. Whether a target is a node
311 /// or an edge is resolved at lowering from the input row's identity columns
312 /// (`node_uuid` vs `edge_uuid`). Label and bulk-map writes execute through
313 /// the statement driver.
314 Set {
315 /// The property assignments, in clause order.
316 items: Vec<SetPropItem>,
317 /// Bulk map merge/replacement assignments, in clause order.
318 #[serde(default, skip_serializing_if = "Vec::is_empty")]
319 map_items: Vec<SetMapItem>,
320 /// Node-label additions, in clause order.
321 #[serde(default, skip_serializing_if = "Vec::is_empty")]
322 label_items: Vec<crate::LabelItem>,
323 },
324
325 /// Remove properties from bound graph entities (#791).
326 ///
327 /// The dual of [`Set`](Self::Set). Removing an absent property or label is
328 /// a no-op (openCypher).
329 Remove {
330 /// The properties to remove, in clause order.
331 items: Vec<RemovePropItem>,
332 /// Node-label removals, in clause order.
333 #[serde(default, skip_serializing_if = "Vec::is_empty")]
334 label_items: Vec<crate::LabelItem>,
335 },
336
337 /// Project a set of columns and optionally apply a WHERE predicate, then
338 /// pass results to subsequent operators (WITH clause semantics).
339 With {
340 /// The output columns.
341 items: Vec<ProjectItem>,
342 /// If `true`, duplicate projected rows are eliminated.
343 #[serde(default, skip_serializing_if = "is_false")]
344 distinct: bool,
345 /// Optional WHERE predicate applied after projection.
346 where_predicate: Option<ExprId>,
347 },
348}
349
350#[allow(clippy::trivially_copy_pass_by_ref)]
351fn is_false(value: &bool) -> bool {
352 !*value
353}
354
355// ---------------------------------------------------------------------------
356// GraphPlan
357// ---------------------------------------------------------------------------
358
359/// A versioned, serialisable graph query plan.
360///
361/// `GraphPlan` is the stable contract between the binder and the execution
362/// engine. It carries the full operator pipeline, all referenced expressions
363/// in a flat [`ExprArena`], and the ontology metadata needed by the relational
364/// lowering layer.
365///
366/// # Construction
367///
368/// Use [`GraphPlan::builder`] rather than constructing this directly:
369///
370/// ```
371/// use graphforge_ir::{GraphPlan, GraphOp, VarId};
372///
373/// let plan = GraphPlan::builder("openCypher")
374/// .push_op(GraphOp::NodeScan { var: VarId(0), ty: None })
375/// .build();
376///
377/// assert_eq!(plan.ops.len(), 1);
378/// assert_eq!(plan.dialect, "openCypher");
379/// ```
380#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
381pub struct GraphPlan {
382 /// IR format version for forward-compatibility checks.
383 pub ir_version: IrVersion,
384 /// Query dialect (e.g. `"openCypher"`).
385 pub dialect: String,
386 /// Ontology version used when the plan was compiled.
387 ///
388 /// `None` in `Exploratory` mode (no formal ontology present).
389 pub ontology_version: Option<OntologyVersion>,
390 /// Active ontology enforcement mode.
391 pub ontology_mode: OntologyMode,
392 /// Optional feature flags that affect plan behaviour.
393 pub feature_flags: Vec<String>,
394 /// The operator pipeline (in execution order).
395 pub ops: Vec<GraphOp>,
396 /// All expressions referenced by operators in this plan.
397 pub exprs: ExprArena,
398}
399
400impl GraphPlan {
401 /// Create a [`GraphPlanBuilder`] for constructing a new plan.
402 ///
403 /// `dialect` identifies the query language (e.g. `"openCypher"`).
404 /// The builder defaults to `ir_version = IrVersion::CURRENT` and
405 /// `ontology_mode = OntologyMode::Exploratory`.
406 #[must_use]
407 pub fn builder(dialect: impl Into<String>) -> GraphPlanBuilder {
408 GraphPlanBuilder {
409 ir_version: IrVersion::CURRENT,
410 dialect: dialect.into(),
411 ontology_version: None,
412 ontology_mode: OntologyMode::default(),
413 feature_flags: Vec::new(),
414 ops: Vec::new(),
415 exprs: ExprArena::new(),
416 }
417 }
418}
419
420// ---------------------------------------------------------------------------
421// GraphPlanBuilder
422// ---------------------------------------------------------------------------
423
424/// Builder for [`GraphPlan`].
425///
426/// Obtain via [`GraphPlan::builder`].
427#[derive(Debug)]
428pub struct GraphPlanBuilder {
429 ir_version: IrVersion,
430 dialect: String,
431 ontology_version: Option<OntologyVersion>,
432 ontology_mode: OntologyMode,
433 feature_flags: Vec<String>,
434 ops: Vec<GraphOp>,
435 exprs: ExprArena,
436}
437
438impl GraphPlanBuilder {
439 /// Override the IR version (default: [`IrVersion::CURRENT`]).
440 #[must_use]
441 pub fn ir_version(mut self, v: IrVersion) -> Self {
442 self.ir_version = v;
443 self
444 }
445
446 /// Set the ontology version.
447 #[must_use]
448 pub fn ontology_version(mut self, v: impl Into<OntologyVersion>) -> Self {
449 self.ontology_version = Some(v.into());
450 self
451 }
452
453 /// Set the ontology mode (default: [`OntologyMode::Exploratory`]).
454 #[must_use]
455 pub fn ontology_mode(mut self, m: OntologyMode) -> Self {
456 self.ontology_mode = m;
457 self
458 }
459
460 /// Add a feature flag string.
461 #[must_use]
462 pub fn feature_flag(mut self, flag: impl Into<String>) -> Self {
463 self.feature_flags.push(flag.into());
464 self
465 }
466
467 /// Append an operator to the pipeline (owned chaining form).
468 #[must_use]
469 pub fn push_op(mut self, op: GraphOp) -> Self {
470 self.ops.push(op);
471 self
472 }
473
474 /// Append an operator to the pipeline (mutable-reference form).
475 ///
476 /// Prefer this inside loops or helper functions where owning `self` is
477 /// inconvenient.
478 pub fn push_op_mut(&mut self, op: GraphOp) {
479 self.ops.push(op);
480 }
481
482 /// Push an expression into the arena and return its [`ExprId`].
483 pub fn push_expr(&mut self, expr: crate::IrExpr) -> ExprId {
484 self.exprs.push(expr)
485 }
486
487 /// Consume the builder and produce a [`GraphPlan`].
488 #[must_use]
489 pub fn build(self) -> GraphPlan {
490 GraphPlan {
491 ir_version: self.ir_version,
492 dialect: self.dialect,
493 ontology_version: self.ontology_version,
494 ontology_mode: self.ontology_mode,
495 feature_flags: self.feature_flags,
496 ops: self.ops,
497 exprs: self.exprs,
498 }
499 }
500}
501
502// ---------------------------------------------------------------------------
503// Tests
504// ---------------------------------------------------------------------------
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::{AggFunc, BinaryOpKind, ExprArena, IrExpr, IrLiteral, PropId};
510
511 fn make_type_id(n: u32) -> TypeId {
512 TypeId(n)
513 }
514
515 // NodeScan → TypedEdgeScan → Filter → Project
516 #[test]
517 fn plan_node_typed_edge_filter_project_roundtrip() {
518 let mut exprs = ExprArena::new();
519 let var_n = VarId(0);
520 let var_e = VarId(1);
521 let var_r = exprs.push(IrExpr::VarRef(VarId(0)));
522 let lit = exprs.push(IrExpr::Literal(IrLiteral::Bool(true)));
523 let pred = exprs.push(IrExpr::BinaryOp {
524 op: BinaryOpKind::Eq,
525 left: var_r,
526 right: lit,
527 });
528
529 let plan = GraphPlan {
530 ir_version: IrVersion::CURRENT,
531 dialect: "openCypher".into(),
532 ontology_version: Some(OntologyVersion::from("v1")),
533 ontology_mode: OntologyMode::Advisory,
534 feature_flags: vec![],
535 ops: vec![
536 GraphOp::NodeScan {
537 var: var_n,
538 ty: Some(make_type_id(1)),
539 },
540 GraphOp::TypedEdgeScan {
541 var: var_e,
542 rel_ty: make_type_id(10),
543 },
544 GraphOp::Filter { predicate: pred },
545 GraphOp::Project {
546 items: vec![ProjectItem {
547 expr: var_r,
548 alias: Some("n".into()),
549 out_var: None,
550 }],
551 distinct: false,
552 },
553 ],
554 exprs,
555 };
556
557 let json = serde_json::to_string(&plan).unwrap();
558 let restored: GraphPlan = serde_json::from_str(&json).unwrap();
559 assert_eq!(plan, restored);
560 }
561
562 // NodeScan → Expand → Filter → Project (architecture docs example)
563 #[test]
564 fn plan_node_expand_filter_project_roundtrip() {
565 let mut exprs = ExprArena::new();
566 let var_a = VarId(0);
567 let var_e = VarId(1);
568 let var_b = VarId(2);
569 let ref_b = exprs.push(IrExpr::VarRef(var_b));
570 let prop = exprs.push(IrExpr::PropertyAccess {
571 base: ref_b,
572 prop: PropId(5),
573 });
574 let lit = exprs.push(IrExpr::Literal(IrLiteral::Str("Bob".into())));
575 let pred = exprs.push(IrExpr::BinaryOp {
576 op: BinaryOpKind::Eq,
577 left: prop,
578 right: lit,
579 });
580
581 let plan = GraphPlan {
582 ir_version: IrVersion::CURRENT,
583 dialect: "openCypher".into(),
584 ontology_version: None,
585 ontology_mode: OntologyMode::Exploratory,
586 feature_flags: vec![],
587 ops: vec![
588 GraphOp::NodeScan {
589 var: var_a,
590 ty: None,
591 },
592 GraphOp::Expand {
593 src: var_a,
594 edge: var_e,
595 dst: var_b,
596 rel_ty: None,
597 dir: Direction::Out,
598 min_hops: 1,
599 max_hops: Some(1),
600 },
601 GraphOp::Filter { predicate: pred },
602 GraphOp::Project {
603 items: vec![ProjectItem {
604 expr: ref_b,
605 alias: Some("b".into()),
606 out_var: None,
607 }],
608 distinct: false,
609 },
610 ],
611 exprs,
612 };
613
614 let json = serde_json::to_string(&plan).unwrap();
615 let restored: GraphPlan = serde_json::from_str(&json).unwrap();
616 assert_eq!(plan, restored);
617 }
618
619 #[test]
620 fn typed_edge_scan_roundtrip() {
621 let op = GraphOp::TypedEdgeScan {
622 var: VarId(3),
623 rel_ty: make_type_id(42),
624 };
625 let json = serde_json::to_string(&op).unwrap();
626 let back: GraphOp = serde_json::from_str(&json).unwrap();
627 assert_eq!(op, back);
628 }
629
630 #[test]
631 fn exploratory_plan_no_ontology_version_roundtrip() {
632 let plan = GraphPlan::builder("openCypher")
633 .push_op(GraphOp::NodeScan {
634 var: VarId(0),
635 ty: None,
636 })
637 .build();
638
639 assert!(plan.ontology_version.is_none());
640 assert_eq!(plan.ontology_mode, OntologyMode::Exploratory);
641
642 let json = serde_json::to_string(&plan).unwrap();
643 let restored: GraphPlan = serde_json::from_str(&json).unwrap();
644 assert_eq!(plan, restored);
645 }
646
647 #[test]
648 fn ontology_mode_serialises_as_lowercase() {
649 assert_eq!(
650 serde_json::to_string(&OntologyMode::Exploratory).unwrap(),
651 "\"exploratory\""
652 );
653 assert_eq!(
654 serde_json::to_string(&OntologyMode::Advisory).unwrap(),
655 "\"advisory\""
656 );
657 assert_eq!(
658 serde_json::to_string(&OntologyMode::Strict).unwrap(),
659 "\"strict\""
660 );
661
662 // round-trip all three
663 for mode in [
664 OntologyMode::Exploratory,
665 OntologyMode::Advisory,
666 OntologyMode::Strict,
667 ] {
668 let json = serde_json::to_string(&mode).unwrap();
669 let back: OntologyMode = serde_json::from_str(&json).unwrap();
670 assert_eq!(mode, back);
671 }
672 }
673
674 #[test]
675 fn builder_produces_correct_plan() {
676 let plan = GraphPlan::builder("openCypher")
677 .ontology_mode(OntologyMode::Strict)
678 .ontology_version("sha256:abc")
679 .feature_flag("experimental_join")
680 .push_op(GraphOp::NodeScan {
681 var: VarId(0),
682 ty: Some(make_type_id(1)),
683 })
684 .push_op(GraphOp::Limit { count: 100 })
685 .build();
686
687 assert_eq!(plan.dialect, "openCypher");
688 assert_eq!(plan.ir_version, IrVersion::CURRENT);
689 assert_eq!(plan.ontology_mode, OntologyMode::Strict);
690 assert_eq!(
691 plan.ontology_version,
692 Some(OntologyVersion::from("sha256:abc"))
693 );
694 assert_eq!(plan.feature_flags, vec!["experimental_join"]);
695 assert_eq!(plan.ops.len(), 2);
696 }
697
698 #[test]
699 fn sort_key_roundtrip() {
700 let key = SortKey {
701 expr: ExprId(0),
702 order: SortOrder::Desc,
703 nulls_first: true,
704 };
705 let json = serde_json::to_string(&key).unwrap();
706 let back: SortKey = serde_json::from_str(&json).unwrap();
707 assert_eq!(key, back);
708 }
709
710 #[test]
711 fn all_graphop_variants_roundtrip() {
712 let mut exprs = ExprArena::new();
713 let e0 = exprs.push(IrExpr::Literal(IrLiteral::Null));
714 let v0 = VarId(0);
715
716 let ops: Vec<GraphOp> = vec![
717 GraphOp::NodeScan { var: v0, ty: None },
718 GraphOp::EdgeScan {
719 var: v0,
720 ty: Some(make_type_id(1)),
721 },
722 GraphOp::TypedEdgeScan {
723 var: v0,
724 rel_ty: make_type_id(2),
725 },
726 GraphOp::Expand {
727 src: v0,
728 edge: VarId(1),
729 dst: VarId(2),
730 rel_ty: None,
731 dir: Direction::Undirected,
732 min_hops: 1,
733 max_hops: None,
734 },
735 GraphOp::Filter { predicate: e0 },
736 GraphOp::Project {
737 items: vec![ProjectItem {
738 expr: e0,
739 alias: None,
740 out_var: None,
741 }],
742 distinct: true,
743 },
744 GraphOp::Aggregate {
745 group_by: vec![e0],
746 group_aliases: vec![Some("g".into())],
747 group_vars: vec![None],
748 aggs: vec![AggExpr {
749 func: AggFunc::Count,
750 arg: None,
751 percentile: None,
752 alias: "cnt".into(),
753 out_var: None,
754 }],
755 },
756 GraphOp::Sort {
757 keys: vec![SortKey {
758 expr: e0,
759 order: SortOrder::Asc,
760 nulls_first: false,
761 }],
762 },
763 GraphOp::Limit { count: 10 },
764 GraphOp::LimitParam { name: "l".into() },
765 GraphOp::LimitExpr { expr: e0 },
766 GraphOp::Skip { count: 5 },
767 GraphOp::SkipParam { name: "s".into() },
768 GraphOp::SkipExpr { expr: e0 },
769 GraphOp::Optional {
770 child: Box::new(
771 GraphPlan::builder("openCypher")
772 .push_op(GraphOp::NodeScan { var: v0, ty: None })
773 .build(),
774 ),
775 },
776 GraphOp::Exists {
777 child: Box::new(
778 GraphPlan::builder("openCypher")
779 .push_op(GraphOp::NodeScan { var: v0, ty: None })
780 .build(),
781 ),
782 negated: false,
783 },
784 GraphOp::PatternComprehension {
785 child: Box::new(
786 GraphPlan::builder("openCypher")
787 .push_op(GraphOp::NodeScan { var: v0, ty: None })
788 .build(),
789 ),
790 output: v0,
791 },
792 GraphOp::ListElementPatternComprehension {
793 list_expr: e0,
794 loop_var: v0,
795 child: Box::new(GraphPlan::builder("openCypher").build()),
796 pattern_output: v0,
797 filter: None,
798 projection: Some(e0),
799 output: v0,
800 },
801 GraphOp::Union {
802 all: true,
803 inputs: vec![GraphPlan::builder("openCypher").build()],
804 },
805 GraphOp::Unwind {
806 list_expr: e0,
807 alias: v0,
808 },
809 GraphOp::Create {
810 pattern: CreatePattern::default(),
811 },
812 GraphOp::Merge {
813 pattern: CreatePattern::default(),
814 on_create: vec![],
815 on_match: vec![],
816 },
817 GraphOp::Delete {
818 vars: vec![v0],
819 exprs: vec![],
820 detach: true,
821 },
822 GraphOp::Set {
823 items: vec![SetPropItem {
824 target: v0,
825 prop: PropId(7),
826 prop_name: "age".into(),
827 value: e0,
828 }],
829 map_items: vec![],
830 label_items: vec![],
831 },
832 GraphOp::Remove {
833 items: vec![RemovePropItem {
834 target: v0,
835 prop: PropId(7),
836 prop_name: "age".into(),
837 }],
838 label_items: vec![],
839 },
840 GraphOp::With {
841 items: vec![ProjectItem {
842 expr: e0,
843 alias: Some("x".into()),
844 out_var: None,
845 }],
846 distinct: true,
847 where_predicate: Some(e0),
848 },
849 ];
850
851 for op in &ops {
852 let json = serde_json::to_string(op).unwrap();
853 let back: GraphOp = serde_json::from_str(&json).unwrap();
854 assert_eq!(op, &back, "round-trip failed for {op:?}");
855 }
856 }
857}