use serde::{Deserialize, Serialize};
use crate::{
AggExpr, CreatePattern, Direction, ExprArena, ExprId, IrVersion, OntologyVersion,
ProcedureDefinition, ProcedureYield, ProjectItem, RemovePropItem, SetMapItem, SetPropItem,
SortOrder, TypeId, VarId,
};
pub const PATTERN_COMPREHENSION_VALUE_ALIAS: &str = "__gf_pattern_comprehension_value";
pub use graphforge_core::OntologyMode;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SortKey {
pub expr: ExprId,
pub order: SortOrder,
pub nulls_first: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum GraphOp {
NodeScan {
var: VarId,
ty: Option<TypeId>,
},
EdgeScan {
var: VarId,
ty: Option<TypeId>,
},
TypedEdgeScan {
var: VarId,
rel_ty: TypeId,
},
Expand {
src: VarId,
edge: VarId,
dst: VarId,
rel_ty: Option<TypeId>,
dir: Direction,
min_hops: u16,
max_hops: Option<u16>,
},
RelationshipUnique {
edge: VarId,
prior_edges: Vec<VarId>,
},
Filter {
predicate: ExprId,
},
Project {
items: Vec<ProjectItem>,
distinct: bool,
},
Aggregate {
group_by: Vec<ExprId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
group_aliases: Vec<Option<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
group_vars: Vec<Option<VarId>>,
aggs: Vec<AggExpr>,
},
Sort {
keys: Vec<SortKey>,
},
Limit {
count: u64,
},
LimitParam {
name: String,
},
LimitExpr {
expr: ExprId,
},
Skip {
count: u64,
},
SkipParam {
name: String,
},
SkipExpr {
expr: ExprId,
},
Optional {
child: Box<GraphPlan>,
},
Exists {
child: Box<GraphPlan>,
negated: bool,
},
PatternComprehension {
child: Box<GraphPlan>,
output: VarId,
},
ListElementPatternComprehension {
list_expr: ExprId,
loop_var: VarId,
child: Box<GraphPlan>,
pattern_output: VarId,
filter: Option<ExprId>,
projection: Option<ExprId>,
output: VarId,
},
Union {
all: bool,
inputs: Vec<GraphPlan>,
},
Unwind {
list_expr: ExprId,
alias: VarId,
},
Call {
procedure: ProcedureDefinition,
args: Vec<ExprId>,
yields: Vec<ProcedureYield>,
},
Create {
pattern: CreatePattern,
},
Merge {
pattern: CreatePattern,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
on_create: Vec<crate::MergeSetItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
on_match: Vec<crate::MergeSetItem>,
},
Delete {
vars: Vec<VarId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
exprs: Vec<ExprId>,
detach: bool,
},
Set {
items: Vec<SetPropItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
map_items: Vec<SetMapItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
label_items: Vec<crate::LabelItem>,
},
Remove {
items: Vec<RemovePropItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
label_items: Vec<crate::LabelItem>,
},
With {
items: Vec<ProjectItem>,
#[serde(default, skip_serializing_if = "is_false")]
distinct: bool,
where_predicate: Option<ExprId>,
},
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GraphPlan {
pub ir_version: IrVersion,
pub dialect: String,
pub ontology_version: Option<OntologyVersion>,
pub ontology_mode: OntologyMode,
pub feature_flags: Vec<String>,
pub ops: Vec<GraphOp>,
pub exprs: ExprArena,
}
impl GraphPlan {
#[must_use]
pub fn builder(dialect: impl Into<String>) -> GraphPlanBuilder {
GraphPlanBuilder {
ir_version: IrVersion::CURRENT,
dialect: dialect.into(),
ontology_version: None,
ontology_mode: OntologyMode::default(),
feature_flags: Vec::new(),
ops: Vec::new(),
exprs: ExprArena::new(),
}
}
}
#[derive(Debug)]
pub struct GraphPlanBuilder {
ir_version: IrVersion,
dialect: String,
ontology_version: Option<OntologyVersion>,
ontology_mode: OntologyMode,
feature_flags: Vec<String>,
ops: Vec<GraphOp>,
exprs: ExprArena,
}
impl GraphPlanBuilder {
#[must_use]
pub fn ir_version(mut self, v: IrVersion) -> Self {
self.ir_version = v;
self
}
#[must_use]
pub fn ontology_version(mut self, v: impl Into<OntologyVersion>) -> Self {
self.ontology_version = Some(v.into());
self
}
#[must_use]
pub fn ontology_mode(mut self, m: OntologyMode) -> Self {
self.ontology_mode = m;
self
}
#[must_use]
pub fn feature_flag(mut self, flag: impl Into<String>) -> Self {
self.feature_flags.push(flag.into());
self
}
#[must_use]
pub fn push_op(mut self, op: GraphOp) -> Self {
self.ops.push(op);
self
}
pub fn push_op_mut(&mut self, op: GraphOp) {
self.ops.push(op);
}
pub fn push_expr(&mut self, expr: crate::IrExpr) -> ExprId {
self.exprs.push(expr)
}
#[must_use]
pub fn build(self) -> GraphPlan {
GraphPlan {
ir_version: self.ir_version,
dialect: self.dialect,
ontology_version: self.ontology_version,
ontology_mode: self.ontology_mode,
feature_flags: self.feature_flags,
ops: self.ops,
exprs: self.exprs,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AggFunc, BinaryOpKind, ExprArena, IrExpr, IrLiteral, PropId};
fn make_type_id(n: u32) -> TypeId {
TypeId(n)
}
#[test]
fn plan_node_typed_edge_filter_project_roundtrip() {
let mut exprs = ExprArena::new();
let var_n = VarId(0);
let var_e = VarId(1);
let var_r = exprs.push(IrExpr::VarRef(VarId(0)));
let lit = exprs.push(IrExpr::Literal(IrLiteral::Bool(true)));
let pred = exprs.push(IrExpr::BinaryOp {
op: BinaryOpKind::Eq,
left: var_r,
right: lit,
});
let plan = GraphPlan {
ir_version: IrVersion::CURRENT,
dialect: "openCypher".into(),
ontology_version: Some(OntologyVersion::from("v1")),
ontology_mode: OntologyMode::Advisory,
feature_flags: vec![],
ops: vec![
GraphOp::NodeScan {
var: var_n,
ty: Some(make_type_id(1)),
},
GraphOp::TypedEdgeScan {
var: var_e,
rel_ty: make_type_id(10),
},
GraphOp::Filter { predicate: pred },
GraphOp::Project {
items: vec![ProjectItem {
expr: var_r,
alias: Some("n".into()),
out_var: None,
}],
distinct: false,
},
],
exprs,
};
let json = serde_json::to_string(&plan).unwrap();
let restored: GraphPlan = serde_json::from_str(&json).unwrap();
assert_eq!(plan, restored);
}
#[test]
fn plan_node_expand_filter_project_roundtrip() {
let mut exprs = ExprArena::new();
let var_a = VarId(0);
let var_e = VarId(1);
let var_b = VarId(2);
let ref_b = exprs.push(IrExpr::VarRef(var_b));
let prop = exprs.push(IrExpr::PropertyAccess {
base: ref_b,
prop: PropId(5),
});
let lit = exprs.push(IrExpr::Literal(IrLiteral::Str("Bob".into())));
let pred = exprs.push(IrExpr::BinaryOp {
op: BinaryOpKind::Eq,
left: prop,
right: lit,
});
let plan = GraphPlan {
ir_version: IrVersion::CURRENT,
dialect: "openCypher".into(),
ontology_version: None,
ontology_mode: OntologyMode::Exploratory,
feature_flags: vec![],
ops: vec![
GraphOp::NodeScan {
var: var_a,
ty: None,
},
GraphOp::Expand {
src: var_a,
edge: var_e,
dst: var_b,
rel_ty: None,
dir: Direction::Out,
min_hops: 1,
max_hops: Some(1),
},
GraphOp::Filter { predicate: pred },
GraphOp::Project {
items: vec![ProjectItem {
expr: ref_b,
alias: Some("b".into()),
out_var: None,
}],
distinct: false,
},
],
exprs,
};
let json = serde_json::to_string(&plan).unwrap();
let restored: GraphPlan = serde_json::from_str(&json).unwrap();
assert_eq!(plan, restored);
}
#[test]
fn typed_edge_scan_roundtrip() {
let op = GraphOp::TypedEdgeScan {
var: VarId(3),
rel_ty: make_type_id(42),
};
let json = serde_json::to_string(&op).unwrap();
let back: GraphOp = serde_json::from_str(&json).unwrap();
assert_eq!(op, back);
}
#[test]
fn exploratory_plan_no_ontology_version_roundtrip() {
let plan = GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.build();
assert!(plan.ontology_version.is_none());
assert_eq!(plan.ontology_mode, OntologyMode::Exploratory);
let json = serde_json::to_string(&plan).unwrap();
let restored: GraphPlan = serde_json::from_str(&json).unwrap();
assert_eq!(plan, restored);
}
#[test]
fn ontology_mode_serialises_as_lowercase() {
assert_eq!(
serde_json::to_string(&OntologyMode::Exploratory).unwrap(),
"\"exploratory\""
);
assert_eq!(
serde_json::to_string(&OntologyMode::Advisory).unwrap(),
"\"advisory\""
);
assert_eq!(
serde_json::to_string(&OntologyMode::Strict).unwrap(),
"\"strict\""
);
for mode in [
OntologyMode::Exploratory,
OntologyMode::Advisory,
OntologyMode::Strict,
] {
let json = serde_json::to_string(&mode).unwrap();
let back: OntologyMode = serde_json::from_str(&json).unwrap();
assert_eq!(mode, back);
}
}
#[test]
fn builder_produces_correct_plan() {
let plan = GraphPlan::builder("openCypher")
.ontology_mode(OntologyMode::Strict)
.ontology_version("sha256:abc")
.feature_flag("experimental_join")
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: Some(make_type_id(1)),
})
.push_op(GraphOp::Limit { count: 100 })
.build();
assert_eq!(plan.dialect, "openCypher");
assert_eq!(plan.ir_version, IrVersion::CURRENT);
assert_eq!(plan.ontology_mode, OntologyMode::Strict);
assert_eq!(
plan.ontology_version,
Some(OntologyVersion::from("sha256:abc"))
);
assert_eq!(plan.feature_flags, vec!["experimental_join"]);
assert_eq!(plan.ops.len(), 2);
}
#[test]
fn sort_key_roundtrip() {
let key = SortKey {
expr: ExprId(0),
order: SortOrder::Desc,
nulls_first: true,
};
let json = serde_json::to_string(&key).unwrap();
let back: SortKey = serde_json::from_str(&json).unwrap();
assert_eq!(key, back);
}
#[test]
fn all_graphop_variants_roundtrip() {
let mut exprs = ExprArena::new();
let e0 = exprs.push(IrExpr::Literal(IrLiteral::Null));
let v0 = VarId(0);
let ops: Vec<GraphOp> = vec![
GraphOp::NodeScan { var: v0, ty: None },
GraphOp::EdgeScan {
var: v0,
ty: Some(make_type_id(1)),
},
GraphOp::TypedEdgeScan {
var: v0,
rel_ty: make_type_id(2),
},
GraphOp::Expand {
src: v0,
edge: VarId(1),
dst: VarId(2),
rel_ty: None,
dir: Direction::Undirected,
min_hops: 1,
max_hops: None,
},
GraphOp::Filter { predicate: e0 },
GraphOp::Project {
items: vec![ProjectItem {
expr: e0,
alias: None,
out_var: None,
}],
distinct: true,
},
GraphOp::Aggregate {
group_by: vec![e0],
group_aliases: vec![Some("g".into())],
group_vars: vec![None],
aggs: vec![AggExpr {
func: AggFunc::Count,
arg: None,
percentile: None,
alias: "cnt".into(),
out_var: None,
}],
},
GraphOp::Sort {
keys: vec![SortKey {
expr: e0,
order: SortOrder::Asc,
nulls_first: false,
}],
},
GraphOp::Limit { count: 10 },
GraphOp::LimitParam { name: "l".into() },
GraphOp::LimitExpr { expr: e0 },
GraphOp::Skip { count: 5 },
GraphOp::SkipParam { name: "s".into() },
GraphOp::SkipExpr { expr: e0 },
GraphOp::Optional {
child: Box::new(
GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan { var: v0, ty: None })
.build(),
),
},
GraphOp::Exists {
child: Box::new(
GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan { var: v0, ty: None })
.build(),
),
negated: false,
},
GraphOp::PatternComprehension {
child: Box::new(
GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan { var: v0, ty: None })
.build(),
),
output: v0,
},
GraphOp::ListElementPatternComprehension {
list_expr: e0,
loop_var: v0,
child: Box::new(GraphPlan::builder("openCypher").build()),
pattern_output: v0,
filter: None,
projection: Some(e0),
output: v0,
},
GraphOp::Union {
all: true,
inputs: vec![GraphPlan::builder("openCypher").build()],
},
GraphOp::Unwind {
list_expr: e0,
alias: v0,
},
GraphOp::Create {
pattern: CreatePattern::default(),
},
GraphOp::Merge {
pattern: CreatePattern::default(),
on_create: vec![],
on_match: vec![],
},
GraphOp::Delete {
vars: vec![v0],
exprs: vec![],
detach: true,
},
GraphOp::Set {
items: vec![SetPropItem {
target: v0,
prop: PropId(7),
prop_name: "age".into(),
value: e0,
}],
map_items: vec![],
label_items: vec![],
},
GraphOp::Remove {
items: vec![RemovePropItem {
target: v0,
prop: PropId(7),
prop_name: "age".into(),
}],
label_items: vec![],
},
GraphOp::With {
items: vec![ProjectItem {
expr: e0,
alias: Some("x".into()),
out_var: None,
}],
distinct: true,
where_predicate: Some(e0),
},
];
for op in &ops {
let json = serde_json::to_string(op).unwrap();
let back: GraphOp = serde_json::from_str(&json).unwrap();
assert_eq!(op, &back, "round-trip failed for {op:?}");
}
}
}