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}