Skip to main content

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}