Skip to main content

graphforge_ir/
lib.rs

1//! GraphForge Graph IR — semantic, graph-native, semver-versioned.
2//!
3//! The Graph IR is the stable contract between the parser/binder and the
4//! execution engine.
5//!
6//! # Milestone status
7//!
8//! - M11 #565 — Newtype IDs and core IR primitives
9//! - M11 #566 — Expression arena
10//! - M11 #567 — GraphOp enum and GraphPlan envelope
11//! - M11 #568 — Binder (AST → Graph IR) ← **this issue**
12//! - M11 #681 — RuntimeCatalog
13#![forbid(unsafe_code)]
14
15pub mod binder;
16pub use binder::{BindError, BindErrorKind, Binder};
17
18pub mod catalog;
19pub use catalog::{RuntimeCatalog, RuntimePropId, RuntimeTypeId, runtime_relation_type_id};
20
21pub mod expr;
22pub use expr::{BinaryOpKind, CaseArm, ExprArena, IrExpr, IrLiteral, UnaryOpKind};
23pub use graphforge_ast::QuantifierKind;
24
25pub mod plan;
26pub use plan::{GraphOp, GraphPlan, GraphPlanBuilder, OntologyMode, SortKey};
27
28pub mod procedure;
29pub use procedure::{ProcedureDefinition, ProcedureField, ProcedureRegistry, ProcedureYield};
30
31use std::fmt;
32use std::str::FromStr;
33
34use serde::{Deserialize, Serialize};
35
36pub use graphforge_core::{GfError, PropId, TypeId};
37
38// ---------------------------------------------------------------------------
39// Core newtypes
40// ---------------------------------------------------------------------------
41
42/// Identifies a bound variable within a query plan (e.g. the `n` in `(n:Person)`).
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
44pub struct VarId(pub u32);
45
46/// Identifies an expression node within an [`ExprArena`](crate::ExprArena).
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
48pub struct ExprId(pub u32);
49
50// ---------------------------------------------------------------------------
51// IrVersion
52// ---------------------------------------------------------------------------
53
54/// Semver-like version for the Graph IR wire format.
55///
56/// Breaking changes bump `major`; new operators bump `minor`; bug fixes bump
57/// `patch`.  Serialised as `"major.minor.patch"`.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
59pub struct IrVersion {
60    /// Major version — incremented on breaking IR changes.
61    pub major: u16,
62    /// Minor version — incremented when new operators are added.
63    pub minor: u16,
64    /// Patch version — incremented for bug fixes.
65    pub patch: u16,
66}
67
68impl IrVersion {
69    /// The current IR version shipped with this build.
70    pub const CURRENT: Self = Self {
71        major: 0,
72        minor: 3,
73        patch: 0,
74    };
75}
76
77impl fmt::Display for IrVersion {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
80    }
81}
82
83impl From<&str> for IrVersion {
84    /// Parse `"major.minor.patch"`.  Returns `0.0.0` on any parse failure.
85    fn from(s: &str) -> Self {
86        let parts: Vec<&str> = s.splitn(3, '.').collect();
87        let parse = |p: &&str| p.parse::<u16>().unwrap_or(0);
88        Self {
89            major: parts.first().map_or(0, parse),
90            minor: parts.get(1).map_or(0, parse),
91            patch: parts.get(2).map_or(0, parse),
92        }
93    }
94}
95
96impl FromStr for IrVersion {
97    type Err = std::convert::Infallible;
98    fn from_str(s: &str) -> Result<Self, Self::Err> {
99        Ok(Self::from(s))
100    }
101}
102
103// ---------------------------------------------------------------------------
104// OntologyVersion
105// ---------------------------------------------------------------------------
106
107/// Wraps an ontology checksum or semver string for embedding in a `GraphPlan`.
108#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
109pub struct OntologyVersion(pub String);
110
111impl fmt::Display for OntologyVersion {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        write!(f, "{}", self.0)
114    }
115}
116
117impl<S: Into<String>> From<S> for OntologyVersion {
118    fn from(s: S) -> Self {
119        Self(s.into())
120    }
121}
122
123// ---------------------------------------------------------------------------
124// Direction and SortOrder
125// ---------------------------------------------------------------------------
126
127/// Edge traversal direction in a pattern or expand operator.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
129pub enum Direction {
130    /// Outgoing edge: `(a)-[:R]->(b)`.
131    Out,
132    /// Incoming edge: `(a)<-[:R]-(b)`.
133    In,
134    /// Either direction: `(a)-[:R]-(b)`.
135    Undirected,
136}
137
138/// Sort direction for ORDER BY keys.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
140pub enum SortOrder {
141    /// Ascending (smallest first).
142    Asc,
143    /// Descending (largest first).
144    Desc,
145}
146
147// ---------------------------------------------------------------------------
148// Projection and aggregation primitives
149// ---------------------------------------------------------------------------
150
151/// A single item in a PROJECT or WITH operator: an expression plus an optional alias.
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
153pub struct ProjectItem {
154    /// The expression to project.
155    pub expr: ExprId,
156    /// Optional output column name.
157    pub alias: Option<String>,
158    /// For a `WITH` item only: the variable this item introduces into the
159    /// downstream scope (#814). The lowerer maps it to the projected column so a
160    /// later clause referencing the alias resolves. `None` for terminal `RETURN`
161    /// items, which introduce no scope.
162    #[serde(default)]
163    pub out_var: Option<VarId>,
164}
165
166/// An aggregation expression inside an AGGREGATE operator.
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168pub struct AggExpr {
169    /// The aggregation function.
170    pub func: AggFunc,
171    /// The argument expression (`None` for `COUNT(*)`).
172    pub arg: Option<ExprId>,
173    /// The percentile argument for `percentileDisc(expr, percentile)` /
174    /// `percentileCont(expr, percentile)`.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub percentile: Option<ExprId>,
177    /// Output column name.
178    pub alias: String,
179    /// When this aggregate is computed as a sub-expression of a larger RETURN
180    /// item (`count(*) + 1`), the synthetic variable bound to its output column
181    /// so a following `Project` can reference it. `None` for a terminal
182    /// top-level aggregate (no `Project` follows). (#599 nested aggregates.)
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub out_var: Option<VarId>,
185}
186
187/// Aggregation function kind.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
189pub enum AggFunc {
190    /// `count(expr)` or `count(*)`.
191    Count,
192    /// `count(DISTINCT expr)`.
193    CountDistinct,
194    /// `sum(expr)`.
195    Sum,
196    /// `sum(DISTINCT expr)`.
197    SumDistinct,
198    /// `avg(expr)`.
199    Avg,
200    /// `avg(DISTINCT expr)`.
201    AvgDistinct,
202    /// `min(expr)`.
203    Min,
204    /// `max(expr)`.
205    Max,
206    /// `collect(expr)` — gathers values into a list.
207    Collect,
208    /// `collect(DISTINCT expr)` — gathers distinct non-null values into a list.
209    CollectDistinct,
210    /// `percentileDisc(expr, percentile)` — discrete percentile.
211    PercentileDisc,
212    /// `percentileCont(expr, percentile)` — continuous percentile.
213    PercentileCont,
214}
215
216// ---------------------------------------------------------------------------
217// CreatePattern
218// ---------------------------------------------------------------------------
219
220/// A node to create, as bound from a `CREATE` clause.
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222pub struct CreateNodeSpec {
223    /// The pattern variable bound to this node.
224    pub var: VarId,
225    /// The node's complete label set, resolved to [`TypeId`] values.
226    pub labels: Vec<TypeId>,
227    /// Property map expression (an [`IrExpr::MapLiteral`](crate::IrExpr)), or
228    /// `None` when no `{…}` was given.
229    pub properties: Option<ExprId>,
230    /// `true` when this variable was already bound by a preceding clause
231    /// (`MATCH`/`WITH`) — the node is **referenced** per matched row, not minted
232    /// (#703). `false` (the default) for a node the `CREATE` introduces.
233    #[serde(default)]
234    pub is_reference: bool,
235}
236
237/// An edge to create, as bound from a `CREATE` clause.
238#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
239pub struct CreateEdgeSpec {
240    /// The pattern variable bound to this edge.
241    pub var: VarId,
242    /// Source node variable.
243    pub src: VarId,
244    /// Destination node variable.
245    pub dst: VarId,
246    /// The relation type, resolved to a [`TypeId`] (or `None` if untyped).
247    pub rel_type: Option<TypeId>,
248    /// Edge direction.
249    pub direction: Direction,
250    /// Property map expression, or `None`.
251    pub properties: Option<ExprId>,
252}
253
254/// The nodes and edges described by a `CREATE` (or `MERGE`) clause.
255///
256/// Populated by the binder from the AST pattern; consumed by the relational
257/// lowering layer, which resolves property expressions and type names before
258/// handing a self-contained write spec to the execution layer.
259#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
260pub struct CreatePattern {
261    /// Nodes to create, in pattern order.
262    #[serde(default)]
263    pub nodes: Vec<CreateNodeSpec>,
264    /// Edges to create, in pattern order.
265    #[serde(default)]
266    pub edges: Vec<CreateEdgeSpec>,
267}
268
269// ---------------------------------------------------------------------------
270// SET / REMOVE
271// ---------------------------------------------------------------------------
272
273/// A single `SET n.prop = <expr>` assignment, as bound from a `SET` clause
274/// (#791).
275///
276/// The write **target** is always a bound variable's property; bulk forms
277/// (`n += {…}`, `n = {…}`) and label writes (`n:Label`) are rejected at bind.
278/// `value` is a full runtime expression (not coerced to a literal) — the
279/// lowering layer turns it into a DataFusion expression evaluated per matched
280/// row at execution time.
281#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
282pub struct SetPropItem {
283    /// The bound variable whose property is written.
284    pub target: VarId,
285    /// The property, resolved to a [`PropId`].
286    pub prop: PropId,
287    /// The property name (carried so the lowering layer needs no `PropId →
288    /// name` catalog round-trip — mirrors how [`CreateNodeSpec`] carries
289    /// resolved names alongside ids).
290    pub prop_name: String,
291    /// The value expression, evaluated per matched row.
292    pub value: ExprId,
293}
294
295/// A bulk map assignment from `SET n += map` or `SET n = map` (#800).
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
297pub struct SetMapItem {
298    /// The bound node or relationship variable whose properties are written.
299    pub target: VarId,
300    /// The map expression, evaluated per matched row.
301    pub map: ExprId,
302    /// `true` for replacement (`=`), `false` for merge (`+=`).
303    pub replace: bool,
304}
305
306/// Labels added to or removed from a bound node variable.
307#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
308pub struct LabelItem {
309    /// Bound node variable.
310    pub target: VarId,
311    /// Labels resolved through the runtime/ontology catalog.
312    pub labels: Vec<TypeId>,
313}
314
315/// One branch-specific action attached to a `MERGE` clause (#959).
316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
317pub enum MergeSetItem {
318    /// Assign one property from a runtime expression.
319    Property(SetPropItem),
320    /// Merge or replace a complete property map.
321    Map(SetMapItem),
322    /// Add labels to the matched or newly-created node.
323    AddLabels {
324        /// Bound node variable.
325        target: VarId,
326        /// Labels resolved through the runtime/ontology catalog.
327        labels: Vec<TypeId>,
328    },
329}
330
331/// A single `REMOVE n.prop` deletion, as bound from a `REMOVE` clause (#791).
332///
333/// Label removals are represented separately by [`LabelItem`].
334#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
335pub struct RemovePropItem {
336    /// The bound variable whose property is removed.
337    pub target: VarId,
338    /// The property, resolved to a [`PropId`].
339    pub prop: PropId,
340    /// The property name (see [`SetPropItem::prop_name`]).
341    pub prop_name: String,
342}
343
344// ---------------------------------------------------------------------------
345// Tests
346// ---------------------------------------------------------------------------
347
348#[cfg(test)]
349mod tests {
350    use std::collections::HashMap;
351
352    use super::*;
353
354    #[test]
355    fn ir_version_display() {
356        let v = IrVersion {
357            major: 1,
358            minor: 2,
359            patch: 3,
360        };
361        assert_eq!(v.to_string(), "1.2.3");
362    }
363
364    #[test]
365    fn ir_version_from_str() {
366        let v = IrVersion::from("2.0.1");
367        assert_eq!(v.major, 2);
368        assert_eq!(v.minor, 0);
369        assert_eq!(v.patch, 1);
370    }
371
372    #[test]
373    fn ir_version_from_str_partial() {
374        let v = IrVersion::from("3.1");
375        assert_eq!(v.major, 3);
376        assert_eq!(v.minor, 1);
377        assert_eq!(v.patch, 0);
378    }
379
380    #[test]
381    fn ir_version_from_str_invalid_falls_back() {
382        let v = IrVersion::from("not.a.version");
383        assert_eq!(
384            v,
385            IrVersion {
386                major: 0,
387                minor: 0,
388                patch: 0
389            }
390        );
391    }
392
393    #[test]
394    fn newtype_hash_keys() {
395        let mut map: HashMap<VarId, &str> = HashMap::new();
396        map.insert(VarId(0), "a");
397        map.insert(VarId(1), "b");
398        assert_eq!(map[&VarId(0)], "a");
399
400        let mut pmap: HashMap<PropId, u32> = HashMap::new();
401        pmap.insert(PropId(5), 42);
402        assert_eq!(pmap[&PropId(5)], 42);
403
404        let mut emap: HashMap<ExprId, bool> = HashMap::new();
405        emap.insert(ExprId(10), true);
406        assert!(emap[&ExprId(10)]);
407    }
408
409    #[test]
410    fn serde_roundtrip_ir_version() {
411        let v = IrVersion {
412            major: 1,
413            minor: 2,
414            patch: 3,
415        };
416        let json = serde_json::to_string(&v).unwrap();
417        let back: IrVersion = serde_json::from_str(&json).unwrap();
418        assert_eq!(v, back);
419    }
420
421    #[test]
422    fn serde_roundtrip_ontology_version() {
423        let v = OntologyVersion::from("abc123checksum");
424        let json = serde_json::to_string(&v).unwrap();
425        let back: OntologyVersion = serde_json::from_str(&json).unwrap();
426        assert_eq!(v, back);
427    }
428
429    #[test]
430    fn serde_roundtrip_direction() {
431        for d in [Direction::Out, Direction::In, Direction::Undirected] {
432            let json = serde_json::to_string(&d).unwrap();
433            let back: Direction = serde_json::from_str(&json).unwrap();
434            assert_eq!(d, back);
435        }
436    }
437
438    #[test]
439    fn serde_roundtrip_sort_order() {
440        for s in [SortOrder::Asc, SortOrder::Desc] {
441            let json = serde_json::to_string(&s).unwrap();
442            let back: SortOrder = serde_json::from_str(&json).unwrap();
443            assert_eq!(s, back);
444        }
445    }
446
447    #[test]
448    fn type_id_reexported() {
449        // TypeId must come from graphforge-core, not a local definition
450        let id = TypeId(42);
451        assert_eq!(id.0, 42);
452    }
453
454    #[test]
455    fn create_pattern_roundtrip() {
456        let p = CreatePattern {
457            nodes: vec![CreateNodeSpec {
458                var: VarId(0),
459                labels: vec![TypeId(3)],
460                properties: Some(ExprId(1)),
461                is_reference: false,
462            }],
463            edges: vec![CreateEdgeSpec {
464                var: VarId(1),
465                src: VarId(0),
466                dst: VarId(2),
467                rel_type: Some(TypeId(4)),
468                direction: Direction::Out,
469                properties: None,
470            }],
471        };
472        let json = serde_json::to_string(&p).unwrap();
473        let back: CreatePattern = serde_json::from_str(&json).unwrap();
474        assert_eq!(p, back);
475    }
476
477    #[test]
478    fn create_pattern_default_is_empty() {
479        let p = CreatePattern::default();
480        assert!(p.nodes.is_empty() && p.edges.is_empty());
481    }
482}