use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::Arc;
use datafusion::arrow::record_batch::RecordBatch;
use datafusion::functions_aggregate::count::count_all;
use datafusion::functions_aggregate::expr_fn::{
array_agg, avg, avg_distinct, count, count_distinct, max, min, sum, sum_distinct,
};
use datafusion::logical_expr::{
Expr as DfExpr, ExprFunctionExt, ExprSchemable, Extension, JoinType, LogicalPlanBuilder,
SortExpr, logical_plan::LogicalTableSource,
};
use graphforge_core::{GfError, OntologyMode, TypeId};
use graphforge_ir::plan::PATTERN_COMPREHENSION_VALUE_ALIAS;
use graphforge_ir::{
AggExpr, AggFunc, CreatePattern, Direction, ExprArena, ExprId, GraphOp, GraphPlan, IrExpr,
IrLiteral, ProjectItem, RemovePropItem, SetPropItem, SortOrder, VarId,
};
use graphforge_ontology::OntologyHandle;
use graphforge_plan::{
DeleteTarget, GraphCreateNode, GraphDeleteNode, GraphRemoveNode, GraphSetNode,
OptionalMatchNode, RemoveTarget, ResolvedEdgeSpec, ResolvedNodeSpec, SetTarget, UnwindNode,
VarLenExpandNode,
};
use graphforge_storage::{
EXPLORATORY_EDGE_SCHEMA, GraphCatalog, TOPOLOGY_NODES_SCHEMA, TYPED_EDGE_SCHEMA,
};
use crate::LogicalPlan;
use crate::expr::{ExprLowerer, LoweringError, VarMap, list_index_range};
const INPUT_ORDER_COLUMN_PREFIX: &str = "__gf_input_order_";
trait MapUnsupportedExpr<T> {
fn map_unsupported_expr(self) -> Result<T, LoweringError>;
}
impl<T, E: std::fmt::Display> MapUnsupportedExpr<T> for Result<T, E> {
fn map_unsupported_expr(self) -> Result<T, LoweringError> {
self.map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))
}
}
pub struct GraphPlanLowerer<'a> {
catalog: Option<&'a GraphCatalog>,
type_id_to_rel_name: HashMap<u32, String>,
type_id_to_entity_name: HashMap<u32, String>,
write_target: Option<(&'a Path, OntologyMode)>,
read_dir: Option<(&'a Path, OntologyMode)>,
node_shapes: std::sync::RwLock<HashMap<u32, crate::expr::NodeShape>>,
inference_rules: HashMap<u32, Vec<(String, String)>>,
relational_fixed_hop_reference: bool,
}
impl<'a> GraphPlanLowerer<'a> {
#[must_use]
pub fn new(catalog: Option<&'a GraphCatalog>, ontology: Option<&'a OntologyHandle>) -> Self {
Self::build(catalog, ontology, None, None)
}
#[must_use]
pub fn new_for_writes(
catalog: Option<&'a GraphCatalog>,
ontology: Option<&'a OntologyHandle>,
dir: &'a Path,
mode: OntologyMode,
) -> Self {
Self::build(catalog, ontology, Some((dir, mode)), Some((dir, mode)))
}
#[must_use]
pub fn new_with_dir(
catalog: Option<&'a GraphCatalog>,
ontology: Option<&'a OntologyHandle>,
dir: &'a Path,
mode: OntologyMode,
) -> Self {
Self::build(catalog, ontology, None, Some((dir, mode)))
}
fn build(
catalog: Option<&'a GraphCatalog>,
ontology: Option<&'a OntologyHandle>,
write_target: Option<(&'a Path, OntologyMode)>,
read_dir: Option<(&'a Path, OntologyMode)>,
) -> Self {
let mut type_id_to_rel_name = build_type_id_map(ontology);
if let Some(c) = catalog {
for (id, name) in c.rel_names() {
let plan_id =
graphforge_ir::runtime_relation_type_id(graphforge_ir::RuntimeTypeId(*id));
type_id_to_rel_name.insert(plan_id.0, name.clone());
}
}
Self {
catalog,
type_id_to_rel_name,
type_id_to_entity_name: build_entity_id_map(ontology),
write_target,
read_dir,
node_shapes: std::sync::RwLock::new(HashMap::new()),
inference_rules: build_inference_rules(ontology),
relational_fixed_hop_reference: false,
}
}
#[doc(hidden)]
#[must_use]
pub fn with_relational_fixed_hop_reference(mut self) -> Self {
self.relational_fixed_hop_reference = true;
self
}
fn read_dir(&self) -> Option<&'a Path> {
self.read_dir.map(|(d, _)| d)
}
fn read_mode(&self) -> OntologyMode {
self.read_dir.map_or(OntologyMode::Exploratory, |(_, m)| m)
}
fn prop_names(&self) -> HashMap<u32, String> {
match self.catalog {
Some(c) => c.prop_names().clone(),
None => HashMap::new(),
}
}
fn expr_lowerer<'b>(&self, arena: &'b ExprArena, var_map: &'b VarMap) -> ExprLowerer<'b> {
let mut node_label_names = self.type_id_to_entity_name.clone();
if let Some(c) = self.catalog {
for (id, name) in c.label_names() {
node_label_names.entry(*id).or_insert_with(|| name.clone());
}
}
let mut lowerer = ExprLowerer::with_prop_names_and_nodes(
arena,
var_map,
self.prop_names(),
self.node_shapes
.read()
.expect("node shapes lock poisoned")
.clone(),
node_label_names,
self.read_dir().is_some(),
);
if let Some(dir) = self.read_dir() {
lowerer = lowerer.with_read_target(dir.to_path_buf());
}
lowerer
}
fn build_node_shapes(&self, ops: &[GraphOp]) -> HashMap<u32, crate::expr::NodeShape> {
let mut shapes = HashMap::new();
self.collect_node_shapes(ops, &mut shapes);
shapes
}
fn collect_node_shapes(
&self,
ops: &[GraphOp],
shapes: &mut HashMap<u32, crate::expr::NodeShape>,
) {
for op in ops {
match op {
GraphOp::NodeScan { var, ty } => {
let prop_names = self.node_prop_cols(*ty);
shapes.insert(var.0, crate::expr::NodeShape { prop_names });
}
GraphOp::Optional { child }
| GraphOp::Exists { child, .. }
| GraphOp::PatternComprehension { child, .. }
| GraphOp::ListElementPatternComprehension { child, .. } => {
self.collect_node_shapes(&child.ops, shapes);
}
GraphOp::Union { inputs, .. } => {
for input in inputs {
self.collect_node_shapes(&input.ops, shapes);
}
}
_ => {}
}
}
}
fn prop_table_stem(&self, ty: Option<TypeId>) -> Option<String> {
match ty {
Some(type_id) => Some(
self.type_id_to_entity_name
.get(&type_id.0)
.cloned()
.unwrap_or_else(|| "_untyped".to_owned()),
),
None if matches!(self.read_mode(), OntologyMode::Exploratory) => {
Some("_untyped".to_owned())
}
None => None,
}
}
fn node_prop_cols(&self, ty: Option<TypeId>) -> Vec<String> {
let Some(dir) = self.read_dir() else {
return Vec::new();
};
let Some(stem) = self.prop_table_stem(ty) else {
return Vec::new();
};
let prop_table = graphforge_storage::PropertyTable::open_discovered(dir, &stem);
prop_table
.schema_ref()
.fields()
.iter()
.map(|f| f.name().clone())
.filter(|n| TOPOLOGY_NODES_SCHEMA.field_with_name(n).is_err())
.collect()
}
fn join_node_properties(
&self,
var: VarId,
ty: Option<TypeId>,
scan: LogicalPlan,
) -> Result<LogicalPlan, LoweringError> {
use datafusion::common::Column;
use datafusion::logical_expr::col;
let Some(dir) = self.read_dir() else {
return Ok(scan); };
let Some(stem) = self.prop_table_stem(ty) else {
return Ok(scan); };
let prop_table = graphforge_storage::PropertyTable::open_discovered(dir, &stem);
let prop_schema = prop_table.schema_ref();
let node_alias = var_alias(var);
let existing: std::collections::HashSet<String> = scan
.schema()
.iter()
.filter(|(q, _)| q.is_some_and(|t| t.table() == node_alias.as_str()))
.map(|(_, f)| f.name().clone())
.collect();
let prop_cols: Vec<String> = prop_schema
.fields()
.iter()
.map(|f| f.name().clone())
.filter(|n| TOPOLOGY_NODES_SCHEMA.field_with_name(n).is_err())
.filter(|n| !existing.contains(n))
.collect();
if prop_cols.is_empty() {
return Ok(scan);
}
let prop_alias = format!("{node_alias}__props");
let prop_src = datafusion::datasource::provider_as_source(Arc::new(prop_table));
let prop_scan = LogicalPlanBuilder::scan(prop_alias.clone(), prop_src, None)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
let input_cols: Vec<Column> = scan
.schema()
.iter()
.map(|(qualifier, field)| Column::new(qualifier.cloned(), field.name()))
.collect();
let join_pred =
col(format!("{node_alias}.node_uuid")).eq(col(format!("{prop_alias}.node_uuid")));
let joined = LogicalPlanBuilder::from(scan)
.join_on(prop_scan, JoinType::Left, vec![join_pred])
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
let mut projections: Vec<DfExpr> = input_cols.into_iter().map(DfExpr::Column).collect();
for name in &prop_cols {
projections.push(
crate::expr::qualified_col(&prop_alias, name)
.alias_qualified(Some(node_alias.as_str()), name.as_str()),
);
}
LogicalPlanBuilder::from(joined)
.project(projections)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()
}
pub fn lower_plan(&self, plan: &GraphPlan) -> Result<LogicalPlan, GfError> {
*self.node_shapes.write().expect("node shapes lock poisoned") =
self.build_node_shapes(&plan.ops);
let mut var_map = VarMap::new();
self.lower_pipeline(&plan.ops, &plan.exprs, &mut var_map)
.map_err(|e| GfError::Plan(e.to_string()))
}
pub fn lower_prefix(
&self,
ops: &[GraphOp],
exprs: &ExprArena,
var_map: &mut VarMap,
) -> Result<LogicalPlan, GfError> {
*self.node_shapes.write().expect("node shapes lock poisoned") = self.build_node_shapes(ops);
self.lower_pipeline(ops, exprs, var_map)
.map_err(|e| GfError::Plan(e.to_string()))
}
pub fn lower_terminal_suffix(
&self,
ops: &[GraphOp],
exprs: &ExprArena,
var_map: &mut VarMap,
input_schema: datafusion::common::DFSchemaRef,
) -> Result<LogicalPlan, GfError> {
let input = LogicalPlan::EmptyRelation(datafusion::logical_expr::EmptyRelation {
produce_one_row: true,
schema: input_schema,
});
self.lower_pipeline_from(ops, exprs, var_map, input, None)
.map_err(|e| GfError::Plan(e.to_string()))
}
pub fn lower_write_segment(
&self,
ops: &[GraphOp],
exprs: &ExprArena,
var_map: &mut VarMap,
input_schema: datafusion::common::DFSchemaRef,
pending_nodes: &RecordBatch,
) -> Result<LogicalPlan, GfError> {
let input = LogicalPlan::EmptyRelation(datafusion::logical_expr::EmptyRelation {
produce_one_row: true,
schema: input_schema,
});
self.lower_pipeline_from(ops, exprs, var_map, input, Some(pending_nodes))
.map_err(|e| GfError::Plan(e.to_string()))
}
pub fn resolve_create_pattern(
&self,
pattern: &CreatePattern,
exprs: &ExprArena,
var_map: &VarMap,
input_schema: &datafusion::common::DFSchemaRef,
) -> Result<(Vec<ResolvedNodeSpec>, Vec<ResolvedEdgeSpec>), GfError> {
self.create_specs(pattern, exprs, var_map, Some(input_schema))
.map_err(|e| GfError::Plan(e.to_string()))
}
pub fn register_created_node_shapes(&self, nodes: &[ResolvedNodeSpec]) {
let mut shapes = self.node_shapes.write().expect("node shapes lock poisoned");
for spec in nodes.iter().filter(|n| !n.is_reference) {
let prop_names = spec
.properties
.iter()
.map(|(k, _)| k.clone())
.chain(spec.computed_properties.iter().map(|(k, _)| k.clone()))
.collect();
shapes.insert(spec.var, crate::expr::NodeShape { prop_names });
}
}
pub fn register_node_property_shape(&self, var: VarId, name: &str) {
let mut shapes = self.node_shapes.write().expect("node shapes lock poisoned");
let shape = shapes
.entry(var.0)
.or_insert_with(|| crate::expr::NodeShape { prop_names: vec![] });
if !shape.prop_names.iter().any(|existing| existing == name) {
shape.prop_names.push(name.to_owned());
shape.prop_names.sort();
}
}
pub fn lower_value_expr(
&self,
exprs: &ExprArena,
var_map: &VarMap,
id: graphforge_ir::ExprId,
) -> Result<DfExpr, GfError> {
self.expr_lowerer(exprs, var_map)
.lower(id)
.map_err(|e| GfError::Plan(e.to_string()))
}
pub fn lower_value_expr_with_input(
&self,
exprs: &ExprArena,
var_map: &VarMap,
id: graphforge_ir::ExprId,
input_schema: datafusion::common::DFSchemaRef,
) -> Result<DfExpr, GfError> {
self.expr_lowerer(exprs, var_map)
.with_input_schema(input_schema)
.lower(id)
.map_err(|e| GfError::Plan(e.to_string()))
}
#[must_use]
pub fn entity_name_map(&self) -> HashMap<u32, String> {
self.type_id_to_entity_name.clone()
}
fn lower_pipeline(
&self,
ops: &[GraphOp],
exprs: &ExprArena,
var_map: &mut VarMap,
) -> Result<LogicalPlan, LoweringError> {
let ordered: Vec<&GraphOp> = ops.iter().collect();
let produce_one_row = !ordered.first().copied().is_some_and(is_source_op);
let mut current = LogicalPlanBuilder::empty(produce_one_row)
.build()
.map_unsupported_expr()?;
for (i, op) in ordered.iter().enumerate() {
let create_feeds_read = matches!(op, GraphOp::Create { .. }) && i + 1 < ordered.len();
current = self.lower_op_with_arena(
op,
current,
exprs,
var_map,
create_feeds_read,
false,
None,
)?;
}
Ok(current)
}
fn lower_pipeline_from(
&self,
ops: &[GraphOp],
exprs: &ExprArena,
var_map: &mut VarMap,
mut current: LogicalPlan,
pending_nodes: Option<&RecordBatch>,
) -> Result<LogicalPlan, LoweringError> {
let ordered: Vec<&GraphOp> = ops.iter().collect();
for (i, op) in ordered.iter().enumerate() {
let create_feeds_read = matches!(op, GraphOp::Create { .. }) && i + 1 < ordered.len();
current = self.lower_op_with_arena(
op,
current,
exprs,
var_map,
create_feeds_read,
true,
pending_nodes,
)?;
}
Ok(current)
}
pub fn lower_op(
&self,
op: &GraphOp,
input: LogicalPlan,
exprs: &ExprArena,
_var_map: &VarMap,
expr_lowerer: &ExprLowerer<'_>,
) -> Result<LogicalPlan, LoweringError> {
lower_relational_op(op, input, exprs, expr_lowerer)
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn lower_op_with_arena(
&self,
op: &GraphOp,
input: LogicalPlan,
exprs: &ExprArena,
var_map: &mut VarMap,
create_feeds_read: bool,
preserve_empty_input: bool,
pending_nodes: Option<&RecordBatch>,
) -> Result<LogicalPlan, LoweringError> {
match op {
GraphOp::NodeScan { var, ty } => {
if let Some(alias) = var_map.get(*var) {
let qualifier = datafusion::common::TableReference::bare(alias);
let input = if input
.schema()
.index_of_column_by_name(Some(&qualifier), "node_uuid")
.is_some()
&& input
.schema()
.index_of_column_by_name(Some(&qualifier), "node_id")
.is_none()
{
enrich_bound_node_identity(&input, alias, self.read_dir())?
} else {
input
};
return match ty {
Some(type_id) => {
let filtered = filter_node_by_type(input, alias, *type_id)?;
self.join_node_properties(*var, *ty, filtered)
}
None => self.join_node_properties(*var, None, input),
};
}
let scan = lower_node_scan(*var, *ty, var_map, self.read_dir(), pending_nodes)?;
let scan = self.join_node_properties(*var, *ty, scan)?;
if input.schema().fields().is_empty() && !preserve_empty_input {
return Ok(scan);
}
return LogicalPlanBuilder::from(input)
.cross_join(scan)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr();
}
GraphOp::TypedEdgeScan { var, rel_ty } => {
return lower_typed_edge_scan(
*var,
*rel_ty,
var_map,
self.catalog,
&self.type_id_to_rel_name,
self.read_dir(),
self.read_mode(),
);
}
GraphOp::EdgeScan { var, ty } => {
return lower_edge_scan(
*var,
*ty,
var_map,
&self.type_id_to_rel_name,
self.read_dir(),
self.read_mode(),
);
}
GraphOp::Expand {
src,
edge,
dst,
rel_ty,
dir,
min_hops,
max_hops,
} => {
return lower_expand(
*src,
*edge,
*dst,
*rel_ty,
*dir,
*min_hops,
*max_hops,
input,
var_map,
self.catalog,
&self.type_id_to_rel_name,
&self.inference_rules,
self.read_dir,
self.relational_fixed_hop_reference,
);
}
GraphOp::RelationshipUnique { edge, prior_edges } => {
use datafusion::logical_expr::col;
let edge_alias = var_map
.get(*edge)
.ok_or(LoweringError::UnboundVar(edge.0))?;
let mut predicates = prior_edges.iter().map(|prior| {
let prior_alias = var_map
.get(*prior)
.ok_or(LoweringError::UnboundVar(prior.0))?;
let value = |alias: &str| {
if alias.ends_with(graphforge_plan::VAR_LEN_EDGE_LIST_FIELD) {
col(alias)
} else {
col(format!("{alias}.edge_uuid"))
}
};
Ok(crate::expr::relationship_disjoint(
value(edge_alias),
value(prior_alias),
))
});
let Some(mut predicate) = predicates.next().transpose()? else {
return Ok(input);
};
for next in predicates {
predicate = predicate.and(next?);
}
return LogicalPlanBuilder::from(input)
.filter(predicate)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr();
}
GraphOp::Unwind { list_expr, alias } => {
let expr_lowerer = self
.expr_lowerer(exprs, var_map)
.with_input_schema(input.schema().clone());
let df_expr = expr_lowerer.lower(*list_expr)?;
let element_field =
unwind_element_field(&df_expr, input.schema(), self.prop_names().values());
let node =
UnwindNode::new(Arc::new(input), df_expr, var_alias(*alias), &element_field);
var_map.insert(*alias, var_alias(*alias));
return Ok(LogicalPlan::Extension(Extension {
node: Arc::new(node),
}));
}
GraphOp::Call {
procedure,
args,
yields,
} => {
return self.lower_call_op(procedure, args, yields, input, exprs, var_map);
}
GraphOp::Union { all, inputs } => {
return self.lower_union_op(*all, inputs);
}
GraphOp::Optional { child } => {
let mut child_vm = VarMap::new();
let child_plan = self.lower_pipeline(&child.ops, &child.exprs, &mut child_vm)?;
let (join_keys, inner_keep_idx) =
optional_join_keys(&input, &child_plan, var_map, &child_vm);
merge_optional_child_vars(&child_vm, var_map);
promote_optional_entity_vars(&input, &child_plan, &child_vm, var_map);
let node = OptionalMatchNode::new(
Arc::new(input),
Arc::new(child_plan),
join_keys,
inner_keep_idx,
);
return Ok(LogicalPlan::Extension(Extension {
node: Arc::new(node),
}));
}
GraphOp::Exists { child, negated } => {
return self.lower_exists_op(child, *negated, input, var_map);
}
GraphOp::PatternComprehension { child, output } => {
return self.lower_pattern_comprehension_op(child, *output, input, var_map);
}
GraphOp::ListElementPatternComprehension {
list_expr,
loop_var,
child,
pattern_output,
filter,
projection,
output,
} => {
return self.lower_list_element_pattern_comprehension_op(
*list_expr,
*loop_var,
child,
*pattern_output,
*filter,
*projection,
*output,
input,
exprs,
var_map,
);
}
GraphOp::Create { pattern } => {
return self.lower_create(pattern, exprs, input, var_map, create_feeds_read);
}
GraphOp::Delete { vars, detach, .. } => {
return self.lower_delete_op(vars, *detach, input);
}
GraphOp::Set {
items,
map_items,
label_items,
} => {
if !map_items.is_empty() || !label_items.is_empty() {
return Err(LoweringError::UnsupportedExpr(
"SET map and label assignments execute through the statement driver".into(),
));
}
return self.lower_set_op(items, input, exprs, var_map);
}
GraphOp::Remove { items, label_items } => {
if !label_items.is_empty() {
return Err(LoweringError::UnsupportedExpr(
"REMOVE label assignments execute through the statement driver".into(),
));
}
return self.lower_remove_op(items, input);
}
GraphOp::Project { items, distinct }
if items.iter().any(|item| item.out_var.is_some()) =>
{
let plan = {
let lowerer = self
.expr_lowerer(exprs, var_map)
.with_input_schema(input.schema().clone());
lower_project(items, *distinct, input, &lowerer)?
};
for item in items {
if let (Some(v), Some(alias)) = (item.out_var, item.alias.as_ref()) {
var_map.insert(v, alias.clone());
}
}
return Ok(plan);
}
GraphOp::With {
items,
distinct,
where_predicate,
} => {
return self.lower_with_op(
items,
*distinct,
*where_predicate,
input,
exprs,
var_map,
);
}
GraphOp::Aggregate {
group_by,
group_aliases,
group_vars,
aggs,
} => {
let plan = {
let lowerer = self
.expr_lowerer(exprs, var_map)
.with_input_schema(input.schema().clone());
lower_aggregate(
group_by,
group_aliases,
aggs,
input,
exprs,
&lowerer,
Some((group_vars, var_map)),
)?
};
let decomposed = !group_vars.is_empty() || aggs.iter().any(|a| a.out_var.is_some());
if decomposed {
let passthrough: Vec<(VarId, String)> = group_by
.iter()
.zip(group_vars)
.filter_map(|(&expr, group_var)| {
let group_var = (*group_var)?;
match exprs.get(expr) {
IrExpr::VarRef(source) if *source == group_var => var_map
.get(group_var)
.map(|alias| (group_var, alias.to_owned())),
_ => None,
}
})
.collect();
var_map.clear();
for (var, alias) in passthrough {
var_map.insert(var, alias);
}
for (i, gv) in group_vars.iter().enumerate() {
if let (Some(v), Some(Some(alias))) = (gv, group_aliases.get(i)) {
var_map.insert(*v, alias.clone());
}
}
for a in aggs {
if let Some(v) = a.out_var {
var_map.insert(v, a.alias.clone());
}
}
}
return Ok(plan);
}
_ => {}
}
let expr_lowerer = self
.expr_lowerer(exprs, var_map)
.with_input_schema(input.schema().clone());
lower_relational_op(op, input, exprs, &expr_lowerer)
}
fn lower_call_op(
&self,
procedure: &graphforge_ir::ProcedureDefinition,
args: &[ExprId],
yields: &[graphforge_ir::ProcedureYield],
input: LogicalPlan,
exprs: &ExprArena,
var_map: &mut VarMap,
) -> Result<LogicalPlan, LoweringError> {
use datafusion::arrow::datatypes::{DataType, Field, Schema};
use datafusion::common::{Column, DFSchema};
use datafusion::logical_expr::{EmptyRelation, lit};
let width = procedure.inputs.len() + procedure.outputs.len();
let names: Vec<String> = (0..width)
.map(|index| format!("column{}", index + 1))
.collect();
let fields = procedure
.inputs
.iter()
.chain(&procedure.outputs)
.zip(&names)
.map(|(field, name)| {
let data_type = match field.type_name.to_ascii_uppercase().as_str() {
"BOOLEAN" => DataType::Boolean,
"INTEGER" => DataType::Int64,
"FLOAT" | "NUMBER" => DataType::Float64,
_ => DataType::Utf8,
};
Field::new(name, data_type, field.nullable)
})
.collect::<Vec<_>>();
let schema = Arc::new(
DFSchema::try_from(Schema::new(fields))
.map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))?,
);
let fixture = if width == 0 {
LogicalPlan::EmptyRelation(EmptyRelation {
produce_one_row: !procedure.rows.is_empty(),
schema,
})
} else if procedure.rows.is_empty() {
LogicalPlan::EmptyRelation(EmptyRelation {
produce_one_row: false,
schema,
})
} else {
let rows = procedure
.rows
.iter()
.map(|row| {
row.iter()
.map(|value| lit(crate::expr::ir_literal_to_scalar(value)))
.collect()
})
.collect();
LogicalPlanBuilder::values_with_schema(rows, &schema)
.and_then(LogicalPlanBuilder::build)
.map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))?
};
let input_columns: Vec<Column> = input
.schema()
.iter()
.map(|(qualifier, field)| Column::new(qualifier.cloned(), field.name()))
.collect();
let lowerer = self
.expr_lowerer(exprs, var_map)
.with_input_schema(input.schema().clone());
let predicates = args
.iter()
.enumerate()
.map(|(index, arg)| {
Ok(DfExpr::BinaryExpr(
datafusion::logical_expr::BinaryExpr::new(
Box::new(lowerer.lower(*arg)?),
datafusion::logical_expr::Operator::IsNotDistinctFrom,
Box::new(DfExpr::Column(Column::from_name(names[index].clone()))),
),
))
})
.collect::<Result<Vec<_>, LoweringError>>()?;
let joined = LogicalPlanBuilder::from(input)
.join_on(fixture, JoinType::Inner, predicates)
.and_then(LogicalPlanBuilder::build)
.map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))?;
let mut projection: Vec<DfExpr> = input_columns.into_iter().map(DfExpr::Column).collect();
for yielded in yields {
let output_index = procedure
.outputs
.iter()
.position(|field| field.name == yielded.field)
.expect("binder only emits registered procedure outputs");
let alias = yielded.alias.clone();
projection.push(
DfExpr::Column(Column::from_name(
names[procedure.inputs.len() + output_index].clone(),
))
.alias(alias.clone()),
);
var_map.insert(yielded.var, alias);
}
LogicalPlanBuilder::from(joined)
.project(projection)
.and_then(LogicalPlanBuilder::build)
.map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))
}
fn lower_union_op(
&self,
all: bool,
inputs: &[GraphPlan],
) -> Result<LogicalPlan, LoweringError> {
if inputs.len() < 2 {
return Err(LoweringError::UnsupportedExpr(
"UNION requires at least two branch plans".into(),
));
}
let mut plans = inputs
.iter()
.map(|branch| {
let mut branch_vars = VarMap::new();
self.lower_pipeline(&branch.ops, &branch.exprs, &mut branch_vars)
})
.collect::<Result<Vec<_>, _>>()?
.into_iter();
let first = plans.next().expect("UNION branch count checked above");
let union = plans.try_fold(first, |left, right| {
LogicalPlanBuilder::from(left)
.union(right)
.and_then(LogicalPlanBuilder::build)
.map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))
})?;
if all {
Ok(union)
} else {
LogicalPlanBuilder::from(union)
.distinct()
.and_then(LogicalPlanBuilder::build)
.map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))
}
}
fn lower_exists_op(
&self,
child: &GraphPlan,
negated: bool,
input: LogicalPlan,
var_map: &VarMap,
) -> Result<LogicalPlan, LoweringError> {
if let [GraphOp::Union { inputs, .. }] = child.ops.as_slice() {
return self.lower_exists_alternatives(inputs, negated, input, var_map);
}
let (child_ops, is_full_subquery) = match child.ops.last() {
Some(GraphOp::Project { .. }) => (&child.ops[..child.ops.len() - 1], true),
_ => (child.ops.as_slice(), false),
};
let seed_outer_input = is_full_subquery && full_subquery_needs_outer_input(child, var_map);
let mut child_vm = if seed_outer_input {
var_map.clone()
} else {
VarMap::new()
};
let child_plan = if seed_outer_input {
self.lower_pipeline_from(child_ops, &child.exprs, &mut child_vm, input.clone(), None)?
} else {
self.lower_pipeline(child_ops, &child.exprs, &mut child_vm)?
};
let (join_keys, _) = optional_join_keys(&input, &child_plan, var_map, &child_vm);
if join_keys.is_empty() {
return Err(LoweringError::UnsupportedExpr(
"pattern predicate must share at least one bound variable".into(),
));
}
let left_keys = join_keys
.iter()
.map(|(outer_idx, _)| schema_join_column(input.schema(), *outer_idx))
.collect::<Vec<_>>();
let right_keys = join_keys
.iter()
.map(|(_, inner_idx)| schema_join_column(child_plan.schema(), *inner_idx))
.collect::<Vec<_>>();
let join_type = if negated {
JoinType::LeftAnti
} else {
JoinType::LeftSemi
};
LogicalPlanBuilder::from(input)
.join(child_plan, join_type, (left_keys, right_keys), None)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()
}
fn lower_exists_alternatives(
&self,
children: &[GraphPlan],
negated: bool,
input: LogicalPlan,
var_map: &VarMap,
) -> Result<LogicalPlan, LoweringError> {
use datafusion::common::Column;
let mut expected_outer_keys: Option<Vec<usize>> = None;
let mut key_union: Option<LogicalPlan> = None;
for child in children {
let mut child_vm = VarMap::new();
let child_plan = self.lower_pipeline(&child.ops, &child.exprs, &mut child_vm)?;
let (mut join_keys, _) = optional_join_keys(&input, &child_plan, var_map, &child_vm);
if join_keys.is_empty() {
return Err(LoweringError::UnsupportedExpr(
"pattern predicate must share at least one bound variable".into(),
));
}
join_keys.sort_unstable_by_key(|(outer_idx, _)| *outer_idx);
let outer_keys = join_keys
.iter()
.map(|(outer_idx, _)| *outer_idx)
.collect::<Vec<_>>();
if expected_outer_keys
.as_ref()
.is_some_and(|expected| expected != &outer_keys)
{
return Err(LoweringError::UnsupportedExpr(
"OR pattern predicates must correlate on the same bound variables".into(),
));
}
expected_outer_keys.get_or_insert(outer_keys);
let key_projection = join_keys
.iter()
.enumerate()
.map(|(key_idx, (_, inner_idx))| {
DfExpr::Column(schema_join_column(child_plan.schema(), *inner_idx))
.alias(format!("__exists_key_{key_idx}"))
})
.collect::<Vec<_>>();
let key_plan = LogicalPlanBuilder::from(child_plan)
.project(key_projection)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
key_union = Some(match key_union {
None => key_plan,
Some(union) => LogicalPlanBuilder::from(union)
.union(key_plan)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?,
});
}
let outer_keys = expected_outer_keys.ok_or_else(|| {
LoweringError::UnsupportedExpr("pattern predicate has no alternatives".into())
})?;
let key_union = key_union.expect("outer keys imply a key union");
let left_keys = outer_keys
.iter()
.map(|idx| schema_join_column(input.schema(), *idx))
.collect::<Vec<_>>();
let right_keys = (0..left_keys.len())
.map(|idx| Column::from_name(format!("__exists_key_{idx}")))
.collect::<Vec<_>>();
let join_type = if negated {
JoinType::LeftAnti
} else {
JoinType::LeftSemi
};
LogicalPlanBuilder::from(input)
.join(key_union, join_type, (left_keys, right_keys), None)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()
}
fn lower_pattern_comprehension_op(
&self,
child: &GraphPlan,
output: VarId,
input: LogicalPlan,
var_map: &mut VarMap,
) -> Result<LogicalPlan, LoweringError> {
use datafusion::common::Column;
use datafusion::functions::core::expr_fn::coalesce;
use datafusion::scalar::ScalarValue;
let (terminal, match_ops) = child.ops.split_last().ok_or_else(|| {
LoweringError::UnsupportedExpr("pattern comprehension child is empty".into())
})?;
let GraphOp::Project { items, distinct } = terminal else {
return Err(LoweringError::UnsupportedExpr(
"pattern comprehension child must end in a value projection".into(),
));
};
if *distinct || items.len() != 1 {
return Err(LoweringError::UnsupportedExpr(
"pattern comprehension child must project exactly one non-distinct value".into(),
));
}
let item = &items[0];
if item.alias.as_deref() != Some(PATTERN_COMPREHENSION_VALUE_ALIAS) {
return Err(LoweringError::UnsupportedExpr(
"pattern comprehension child has an invalid value projection".into(),
));
}
let outer_columns = input
.schema()
.iter()
.map(|(qualifier, field)| {
DfExpr::Column(Column::new(qualifier.cloned(), field.name().to_owned()))
})
.collect::<Vec<_>>();
let mut child_vm = VarMap::new();
let child_plan = self.lower_pipeline(match_ops, &child.exprs, &mut child_vm)?;
let (mut join_keys, _) = optional_join_keys(&input, &child_plan, var_map, &child_vm);
if join_keys.is_empty() {
return Err(LoweringError::UnsupportedExpr(
"pattern comprehension must share at least one bound node variable".into(),
));
}
join_keys.sort_unstable_by_key(|(outer_idx, _)| *outer_idx);
let value = self
.expr_lowerer(&child.exprs, &child_vm)
.with_input_schema(child_plan.schema().clone())
.lower(item.expr)?;
let element_type = value.get_type(child_plan.schema()).map_unsupported_expr()?;
let key_aliases = (0..join_keys.len())
.map(|idx| format!("__gf_pattern_key_{idx}"))
.collect::<Vec<_>>();
let group_exprs = join_keys
.iter()
.zip(&key_aliases)
.map(|((_, inner_idx), alias)| {
DfExpr::Column(schema_join_column(child_plan.schema(), *inner_idx)).alias(alias)
})
.collect::<Vec<_>>();
let output_alias = format!("{PATTERN_COMPREHENSION_VALUE_ALIAS}_{}", output.0);
let aggregate_exprs = vec![array_agg(value).alias(&output_alias)];
let collected = LogicalPlanBuilder::from(child_plan)
.aggregate(group_exprs, aggregate_exprs)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
let left_keys = join_keys
.iter()
.map(|(outer_idx, _)| schema_join_column(input.schema(), *outer_idx))
.collect::<Vec<_>>();
let right_keys = key_aliases
.iter()
.map(|alias| Column::from_name(alias.clone()))
.collect::<Vec<_>>();
let joined = LogicalPlanBuilder::from(input)
.join(collected, JoinType::Left, (left_keys, right_keys), None)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
let empty = ScalarValue::List(ScalarValue::new_list(&[], &element_type, true));
let mut projection = outer_columns;
projection.push(
coalesce(vec![
DfExpr::Column(Column::from_name(output_alias.clone())),
DfExpr::Literal(empty, None),
])
.alias(&output_alias),
);
let result = LogicalPlanBuilder::from(joined)
.project(projection)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
var_map.insert(output, output_alias);
Ok(result)
}
#[allow(
clippy::too_many_arguments,
clippy::too_many_lines,
reason = "ordinal unwind, child correlation, and ordered regroup form one lowering operation"
)]
fn lower_list_element_pattern_comprehension_op(
&self,
list_expr: ExprId,
loop_var: VarId,
child: &GraphPlan,
pattern_output: VarId,
filter: Option<ExprId>,
projection: Option<ExprId>,
output: VarId,
input: LogicalPlan,
exprs: &ExprArena,
var_map: &mut VarMap,
) -> Result<LogicalPlan, LoweringError> {
use datafusion::arrow::datatypes::{DataType, Field};
use datafusion::common::Column;
use datafusion::functions::core::expr_fn::{coalesce, get_field};
use datafusion::functions_nested::expr_fn::array_element;
use datafusion::scalar::ScalarValue;
const LIST: &str = "__gf_list_source";
const INDICES: &str = "__gf_list_indices";
const INDEX: &str = "__gf_list_index";
let outer_columns = plan_columns(&input);
let row_keys = input
.schema()
.iter()
.filter(|(_, field)| matches!(field.name().as_str(), "node_id" | "edge_id"))
.map(|(qualifier, field)| Column::new(qualifier.cloned(), field.name().to_owned()))
.collect::<Vec<_>>();
if row_keys.is_empty() {
return Err(LoweringError::UnsupportedExpr(
"graph-valued list comprehension requires an outer entity identity".into(),
));
}
let row_key_aliases = (0..row_keys.len())
.map(|index| format!("__gf_list_row_key_{index}"))
.collect::<Vec<_>>();
let list = self
.expr_lowerer(exprs, var_map)
.with_input_schema(input.schema().clone())
.lower(list_expr)?;
let DataType::List(item) = list.get_type(input.schema()).map_unsupported_expr()? else {
return Err(LoweringError::InvalidType(
"nested pattern comprehension source must be a list".into(),
));
};
let DataType::Struct(node_fields) = item.data_type() else {
return Err(LoweringError::InvalidType(
"nested pattern comprehension elements must be node values".into(),
));
};
let mut indexed_projection = outer_columns.clone();
indexed_projection.push(list.alias(LIST));
let indexed = LogicalPlanBuilder::from(input)
.project(indexed_projection)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
let mut index_projection = plan_columns(&indexed);
index_projection
.push(list_index_range(DfExpr::Column(Column::from_name(LIST))).alias(INDICES));
let with_indices = LogicalPlanBuilder::from(indexed.clone())
.project(index_projection)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
let index_field = Field::new(INDEX, DataType::Int64, true);
let expanded = LogicalPlan::Extension(Extension {
node: Arc::new(UnwindNode::new(
Arc::new(with_indices),
DfExpr::Column(Column::from_name(INDICES)),
INDEX,
&index_field,
)),
});
let element = array_element(
DfExpr::Column(Column::from_name(LIST)),
DfExpr::Column(Column::from_name(INDEX)) + datafusion::logical_expr::lit(1_i64),
);
let loop_alias = var_alias(loop_var);
let mut element_projection = plan_columns(&expanded);
element_projection.extend(node_fields.iter().map(|field| {
get_field(element.clone(), field.name())
.alias_qualified(Some(loop_alias.as_str()), field.name())
}));
let expanded = LogicalPlanBuilder::from(expanded)
.project(element_projection)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
var_map.insert(loop_var, loop_alias.clone());
let matched =
self.lower_pattern_comprehension_op(child, pattern_output, expanded, var_map)?;
let clause_lowerer = self
.expr_lowerer(exprs, var_map)
.with_input_schema(matched.schema().clone());
let filtered = if let Some(predicate) = filter {
lower_filter(predicate, matched, &clause_lowerer)?
} else {
matched
};
let value = match projection {
Some(expr) => self
.expr_lowerer(exprs, var_map)
.with_input_schema(filtered.schema().clone())
.lower(expr)?,
None => DfExpr::Column(Column::new(Some(loop_alias.as_str()), "node_uuid")),
};
let element_type = value.get_type(filtered.schema()).map_unsupported_expr()?;
let output_alias = format!("__gf_list_pattern_{}", output.0);
let ordered = array_agg(value)
.order_by(vec![
DfExpr::Column(Column::from_name(INDEX)).sort(true, true),
])
.build()
.map_unsupported_expr()?
.alias(&output_alias);
let collected = LogicalPlanBuilder::from(filtered)
.aggregate(
row_keys
.iter()
.zip(&row_key_aliases)
.map(|(column, alias)| DfExpr::Column(column.clone()).alias(alias))
.collect::<Vec<_>>(),
vec![ordered],
)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
let joined = LogicalPlanBuilder::from(indexed)
.join(
collected,
JoinType::Left,
(
row_keys,
row_key_aliases
.iter()
.map(|alias| Column::from_name(alias.clone()))
.collect::<Vec<_>>(),
),
None,
)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
let empty = ScalarValue::List(ScalarValue::new_list(&[], &element_type, true));
let null = ScalarValue::new_null_list(element_type, true, 1);
let result_list = datafusion::logical_expr::when(
DfExpr::Column(Column::from_name(LIST)).is_null(),
DfExpr::Literal(null, None),
)
.otherwise(coalesce(vec![
DfExpr::Column(Column::from_name(output_alias.clone())),
DfExpr::Literal(empty, None),
]))
.map_unsupported_expr()?
.alias(&output_alias);
let mut final_projection = outer_columns;
final_projection.push(result_list);
let result = LogicalPlanBuilder::from(joined)
.project(final_projection)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
var_map.insert(output, output_alias);
Ok(result)
}
#[allow(clippy::too_many_lines)]
fn lower_with_op(
&self,
items: &[ProjectItem],
distinct: bool,
where_predicate: Option<ExprId>,
input: LogicalPlan,
exprs: &ExprArena,
var_map: &mut VarMap,
) -> Result<LogicalPlan, LoweringError> {
let input_order = input_sort_exprs(&input);
let keep_input_for_where = where_predicate.is_some();
let incoming_scope = var_map.clone();
let node_shapes = self
.node_shapes
.read()
.expect("node shapes lock poisoned")
.clone();
let forwarded_prefixes: HashSet<String> = items
.iter()
.filter_map(|item| {
let IrExpr::VarRef(v) = exprs.get(item.expr) else {
return None;
};
let prefix = var_map.get(*v)?;
let is_relationship = input.schema().iter().any(|(qualifier, field)| {
qualifier.is_some_and(|q| q.table() == prefix) && field.name() == "edge_uuid"
});
(node_shapes.contains_key(&v.0) || is_relationship).then(|| prefix.to_owned())
})
.collect();
let physical_names: HashMap<VarId, String> = items
.iter()
.filter_map(|item| {
let out_var = item.out_var?;
let name = item.alias.as_ref()?;
let conflicts_with_forwarded_property =
input.schema().iter().any(|(qualifier, field)| {
qualifier.as_ref().is_some_and(|q| {
forwarded_prefixes.contains(q.table()) && field.name() == name
})
});
let shadows_input = input.schema().iter().any(|(_, field)| field.name() == name);
(conflicts_with_forwarded_property || shadows_input)
.then(|| (out_var, format!("__gf_with_{}", out_var.0)))
})
.collect();
let mut select: Vec<DfExpr> = Vec::new();
let mut new_scope: Vec<(VarId, String)> = Vec::new();
let mut predicate_scope: Vec<(VarId, String)> = Vec::new();
let mut forwarded_node_shapes = Vec::new();
if keep_input_for_where {
for (qualifier, field) in input.schema().iter() {
select.push(DfExpr::Column(datafusion::common::Column::new(
qualifier.cloned(),
field.name(),
)));
}
}
{
let lowerer = self
.expr_lowerer(exprs, var_map)
.with_input_schema(input.schema().clone());
for item in items {
let name = item.alias.as_deref().ok_or_else(|| {
LoweringError::UnsupportedExpr(
"WITH item without an alias (binder should reject)".into(),
)
})?;
if let IrExpr::VarRef(v) = exprs.get(item.expr) {
let prefix = var_map
.get(*v)
.ok_or(LoweringError::UnboundVar(v.0))?
.to_string();
let is_relationship = input.schema().iter().any(|(qualifier, field)| {
qualifier.is_some_and(|q| q.table() == prefix)
&& field.name() == "edge_uuid"
});
if node_shapes.contains_key(&v.0) || is_relationship {
let output_var = item.out_var.unwrap_or(*v);
let output_prefix = if output_var == *v {
prefix.clone()
} else {
var_alias(output_var)
};
if output_var != *v
&& let Some(shape) = node_shapes.get(&v.0).cloned()
{
forwarded_node_shapes.push((output_var.0, shape));
}
if !keep_input_for_where || output_prefix != prefix {
for (qualifier, field) in input.schema().iter() {
if qualifier.is_some_and(|q| q.table() == prefix) {
let column = DfExpr::Column(datafusion::common::Column::new(
qualifier.cloned(),
field.name(),
));
select.push(if output_prefix == prefix {
column
} else {
column.alias_qualified(
Some(output_prefix.as_str()),
field.name(),
)
});
}
}
}
new_scope.push((output_var, output_prefix.clone()));
predicate_scope.push((output_var, output_prefix));
} else {
let projected_name = if keep_input_for_where {
item.out_var.map_or_else(
|| format!("__gf_with_{}", predicate_scope.len()),
|v| format!("__gf_with_{}", v.0),
)
} else {
item.out_var
.and_then(|v| physical_names.get(&v).cloned())
.unwrap_or_else(|| name.to_string())
};
select.push(lowerer.lower(item.expr)?.alias(projected_name.as_str()));
if let Some(v) = item.out_var {
let output_name = physical_names
.get(&v)
.cloned()
.unwrap_or_else(|| name.to_string());
new_scope.push((v, output_name));
predicate_scope.push((v, projected_name));
}
}
} else {
use datafusion::logical_expr::ExprSchemable;
let projected_name = if keep_input_for_where {
item.out_var.map_or_else(
|| format!("__gf_with_{}", predicate_scope.len()),
|v| format!("__gf_with_{}", v.0),
)
} else {
item.out_var
.and_then(|v| physical_names.get(&v).cloned())
.unwrap_or_else(|| name.to_string())
};
let value_expr = lowerer.lower(item.expr)?;
if let Some(output_var) = item.out_var
&& let Ok(datafusion::arrow::datatypes::DataType::Struct(fields)) =
value_expr.get_type(input.schema().as_ref())
&& fields.iter().any(|field| field.name() == "node_uuid")
{
let output_prefix = var_alias(output_var);
for field in &fields {
select.push(
datafusion::functions::core::expr_fn::get_field(
value_expr.clone(),
field.name(),
)
.alias_qualified(
Some(output_prefix.as_str()),
field.name().as_str(),
),
);
}
new_scope.push((output_var, output_prefix.clone()));
predicate_scope.push((output_var, output_prefix));
continue;
}
select.push(value_expr.alias(projected_name.as_str()));
if let Some(v) = item.out_var {
let output_name = physical_names
.get(&v)
.cloned()
.unwrap_or_else(|| name.to_string());
new_scope.push((v, output_name));
predicate_scope.push((v, projected_name));
}
}
}
}
if !distinct {
select.extend(input_order.iter().enumerate().map(|(index, sort)| {
sort.expr
.clone()
.alias(format!("{INPUT_ORDER_COLUMN_PREFIX}{index}"))
}));
}
let projected = LogicalPlanBuilder::from(input)
.project(select)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
drop(node_shapes);
if !forwarded_node_shapes.is_empty() {
self.node_shapes
.write()
.expect("node shapes lock poisoned")
.extend(forwarded_node_shapes);
}
let filtered = match where_predicate {
Some(pred) => {
let mut filter_scope = incoming_scope;
for (v, col) in &predicate_scope {
filter_scope.insert(*v, col.clone());
}
let lowerer = self
.expr_lowerer(exprs, &filter_scope)
.with_input_schema(projected.schema().clone());
let df_pred = lowerer.lower(pred)?;
LogicalPlanBuilder::from(projected)
.filter(df_pred)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?
}
None => projected,
};
let mut output = if keep_input_for_where {
let node_shapes = self
.node_shapes
.read()
.expect("node shapes lock poisoned")
.clone();
let lowerer = self
.expr_lowerer(exprs, var_map)
.with_input_schema(filtered.schema().clone());
let mut final_select: Vec<DfExpr> = Vec::new();
for item in items {
let name = item.alias.as_deref().ok_or_else(|| {
LoweringError::UnsupportedExpr(
"WITH item without an alias (binder should reject)".into(),
)
})?;
if let IrExpr::VarRef(v) = exprs.get(item.expr) {
let prefix = var_map.get(*v).ok_or(LoweringError::UnboundVar(v.0))?;
let is_relationship = filtered.schema().iter().any(|(qualifier, field)| {
qualifier.is_some_and(|q| q.table() == prefix)
&& field.name() == "edge_uuid"
});
if node_shapes.contains_key(&v.0) || is_relationship {
let output_var = item.out_var.unwrap_or(*v);
let output_prefix = if output_var == *v {
prefix.to_owned()
} else {
var_alias(output_var)
};
for (qualifier, field) in filtered.schema().iter() {
if qualifier.is_some_and(|q| q.table() == output_prefix) {
final_select.push(DfExpr::Column(datafusion::common::Column::new(
qualifier.cloned(),
field.name(),
)));
}
}
} else {
let projected_name = item
.out_var
.and_then(|v| physical_names.get(&v))
.map_or(name, String::as_str);
final_select.push(lowerer.lower(item.expr)?.alias(projected_name));
}
} else {
let projected_name = item
.out_var
.and_then(|v| physical_names.get(&v))
.map_or(name, String::as_str);
final_select.push(lowerer.lower(item.expr)?.alias(projected_name));
}
}
if !distinct {
final_select.extend(input_order.iter().enumerate().map(|(index, _)| {
DfExpr::Column(datafusion::common::Column::from_name(format!(
"{INPUT_ORDER_COLUMN_PREFIX}{index}"
)))
}));
}
LogicalPlanBuilder::from(filtered)
.project(final_select)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?
} else {
filtered
};
if distinct {
output = LogicalPlanBuilder::from(output)
.distinct()
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
}
var_map.clear();
for (v, col) in new_scope {
var_map.insert(v, col);
}
Ok(output)
}
fn lower_create(
&self,
pattern: &CreatePattern,
exprs: &ExprArena,
input: LogicalPlan,
var_map: &mut VarMap,
feeds_read: bool,
) -> Result<LogicalPlan, LoweringError> {
let (dir, mode) = self.write_target.ok_or_else(|| {
LoweringError::UnsupportedExpr(
"CREATE requires a write target; lower via new_for_writes".into(),
)
})?;
let (nodes, edges) = self.create_specs(pattern, exprs, var_map, Some(input.schema()))?;
if feeds_read {
let out_schema = Self::created_rows_schema(&nodes, input.schema())?;
self.register_created_node_shapes(&nodes);
for spec in &nodes {
if spec.is_reference {
continue;
}
let v = VarId(spec.var);
var_map.insert(v, var_alias(v));
}
let node = GraphCreateNode::new_emitting(
Arc::new(input),
nodes,
edges,
dir.to_path_buf(),
mode,
out_schema,
);
return Ok(LogicalPlan::Extension(Extension {
node: Arc::new(node),
}));
}
let node = GraphCreateNode::new(Arc::new(input), nodes, edges, dir.to_path_buf(), mode);
Ok(LogicalPlan::Extension(Extension {
node: Arc::new(node),
}))
}
fn created_rows_schema(
nodes: &[ResolvedNodeSpec],
input_schema: &datafusion::common::DFSchemaRef,
) -> Result<datafusion::common::DFSchemaRef, LoweringError> {
use std::collections::HashMap;
use datafusion::arrow::datatypes::{DataType, Field};
use datafusion::common::{DFSchema, TableReference};
use datafusion::logical_expr::ExprSchemable;
let mut qualified: Vec<(Option<TableReference>, Arc<Field>)> = input_schema
.iter()
.map(|(q, f)| (q.cloned(), Arc::clone(f)))
.collect();
for spec in nodes {
if spec.is_reference {
continue;
}
let qual = TableReference::bare(var_alias(VarId(spec.var)));
let mut push = |name: &str, ty: DataType, nullable: bool| {
qualified.push((Some(qual.clone()), Arc::new(Field::new(name, ty, nullable))));
};
push("node_uuid", DataType::FixedSizeBinary(16), false);
push("node_id", DataType::UInt64, false);
push("type_id", DataType::UInt32, false);
push(
"type_ids",
DataType::List(Arc::new(datafusion::arrow::datatypes::Field::new(
"item",
DataType::UInt32,
false,
))),
false,
);
for (name, lit) in &spec.properties {
Self::ensure_created_node_emit_property_name(name)?;
let scalar = crate::expr::ir_literal_to_scalar(lit);
push(name, scalar.data_type(), true);
}
for (name, expr) in &spec.computed_properties {
Self::ensure_created_node_emit_property_name(name)?;
let ty = expr.get_type(input_schema).map_unsupported_expr()?;
push(name, ty, true);
}
}
let schema =
DFSchema::new_with_metadata(qualified, HashMap::new()).map_unsupported_expr()?;
Ok(Arc::new(schema))
}
fn ensure_created_node_emit_property_name(name: &str) -> Result<(), LoweringError> {
if matches!(name, "node_uuid" | "node_id" | "type_id" | "type_ids") {
return Err(LoweringError::UnsupportedExpr(format!(
"CREATE property `{name}` collides with a reserved node topology field"
)));
}
Ok(())
}
fn create_specs(
&self,
pattern: &CreatePattern,
exprs: &ExprArena,
var_map: &VarMap,
input_schema: Option<&datafusion::common::DFSchemaRef>,
) -> Result<(Vec<ResolvedNodeSpec>, Vec<ResolvedEdgeSpec>), LoweringError> {
let nodes: Vec<ResolvedNodeSpec> = pattern
.nodes
.iter()
.map(|n| {
let (properties, computed_properties) =
eval_map_literal(self, n.properties, exprs, var_map, input_schema)?;
Ok(ResolvedNodeSpec {
var: n.var.0,
label_ids: n.labels.iter().map(|t| t.0).collect(),
label_names: n
.labels
.iter()
.filter_map(|t| self.type_id_to_entity_name.get(&t.0).cloned())
.collect(),
properties,
computed_properties,
is_reference: n.is_reference,
})
})
.collect::<Result<_, LoweringError>>()?;
let edges: Vec<ResolvedEdgeSpec> = pattern
.edges
.iter()
.map(|e| {
let (properties, computed_properties) =
eval_map_literal(self, e.properties, exprs, var_map, input_schema)?;
Ok(ResolvedEdgeSpec {
var: e.var.0,
src: e.src.0,
dst: e.dst.0,
rel_type_id: e.rel_type.map(|t| t.0),
rel_type_name: e
.rel_type
.and_then(|t| self.type_id_to_rel_name.get(&t.0).cloned()),
direction: e.direction,
properties,
computed_properties,
})
})
.collect::<Result<_, LoweringError>>()?;
Ok((nodes, edges))
}
fn lower_delete_op(
&self,
vars: &[VarId],
detach: bool,
input: LogicalPlan,
) -> Result<LogicalPlan, LoweringError> {
use datafusion::common::TableReference;
let (dir, mode) = self.write_target.ok_or_else(|| {
LoweringError::UnsupportedExpr(
"DELETE requires a write target; lower via new_for_writes".into(),
)
})?;
let schema = input.schema();
let targets: Vec<DeleteTarget> = vars
.iter()
.map(|var| {
let qual = TableReference::bare(var_alias(*var));
let is_node = schema
.index_of_column_by_name(Some(&qual), "node_uuid")
.is_some();
let is_edge = schema
.index_of_column_by_name(Some(&qual), "edge_uuid")
.is_some();
match (is_node, is_edge) {
(true, _) => Ok(DeleteTarget {
var: var.0,
is_edge: false,
}),
(false, true) => Ok(DeleteTarget {
var: var.0,
is_edge: true,
}),
(false, false) => Err(LoweringError::UnsupportedExpr(format!(
"DELETE target var_{} has no node_uuid/edge_uuid column in the \
input — it must be bound by a preceding MATCH",
var.0
))),
}
})
.collect::<Result<_, LoweringError>>()?;
let node = GraphDeleteNode::new(Arc::new(input), targets, detach, dir.to_path_buf(), mode);
Ok(LogicalPlan::Extension(Extension {
node: Arc::new(node),
}))
}
fn resolve_write_kind(
schema: &datafusion::common::DFSchemaRef,
var: VarId,
clause: &str,
) -> Result<bool, LoweringError> {
use datafusion::common::TableReference;
let qual = TableReference::bare(var_alias(var));
let is_node = schema
.index_of_column_by_name(Some(&qual), "node_uuid")
.is_some();
let is_edge = schema
.index_of_column_by_name(Some(&qual), "edge_uuid")
.is_some();
match (is_node, is_edge) {
(true, _) => Ok(false),
(false, true) => {
if schema
.index_of_column_by_name(Some(&qual), "rel_type_name")
.is_some()
{
Ok(true)
} else {
Err(LoweringError::UnsupportedExpr(format!(
"{clause} on an edge requires a known relation type \
(e.g. `-[r:KNOWS]->`); an untyped edge write is not yet \
supported (follow-up to #791)"
)))
}
}
(false, false) => Err(LoweringError::UnsupportedExpr(format!(
"{clause} target var_{} has no node_uuid/edge_uuid column in the \
input — it must be bound by a preceding MATCH",
var.0
))),
}
}
fn lower_set_op(
&self,
items: &[SetPropItem],
input: LogicalPlan,
exprs: &ExprArena,
var_map: &VarMap,
) -> Result<LogicalPlan, LoweringError> {
let (dir, mode) = self.write_target.ok_or_else(|| {
LoweringError::UnsupportedExpr(
"SET requires a write target; lower via new_for_writes".into(),
)
})?;
let expr_lowerer = self.expr_lowerer(exprs, var_map);
let schema = input.schema();
let targets: Vec<SetTarget> = items
.iter()
.map(|item| {
let is_edge = Self::resolve_write_kind(schema, item.target, "SET")?;
let value = expr_lowerer.lower(item.value)?;
Ok(SetTarget {
var: item.target.0,
is_edge,
prop_name: item.prop_name.clone(),
value,
})
})
.collect::<Result<_, LoweringError>>()?;
let node = GraphSetNode::new(
Arc::new(input),
targets,
self.type_id_to_entity_name.clone(),
dir.to_path_buf(),
mode,
);
Ok(LogicalPlan::Extension(Extension {
node: Arc::new(node),
}))
}
fn lower_remove_op(
&self,
items: &[RemovePropItem],
input: LogicalPlan,
) -> Result<LogicalPlan, LoweringError> {
let (dir, mode) = self.write_target.ok_or_else(|| {
LoweringError::UnsupportedExpr(
"REMOVE requires a write target; lower via new_for_writes".into(),
)
})?;
let schema = input.schema();
let targets: Vec<RemoveTarget> = items
.iter()
.map(|item| {
let is_edge = Self::resolve_write_kind(schema, item.target, "REMOVE")?;
Ok(RemoveTarget {
var: item.target.0,
is_edge,
prop_name: item.prop_name.clone(),
})
})
.collect::<Result<_, LoweringError>>()?;
let node = GraphRemoveNode::new(
Arc::new(input),
targets,
self.type_id_to_entity_name.clone(),
dir.to_path_buf(),
mode,
);
Ok(LogicalPlan::Extension(Extension {
node: Arc::new(node),
}))
}
}
fn lower_relational_op(
op: &GraphOp,
input: LogicalPlan,
exprs: &ExprArena,
expr_lowerer: &ExprLowerer<'_>,
) -> Result<LogicalPlan, LoweringError> {
match op {
GraphOp::Filter { predicate } => lower_filter(*predicate, input, expr_lowerer),
GraphOp::Project { items, distinct } => {
lower_project(items, *distinct, input, expr_lowerer)
}
GraphOp::Aggregate {
group_by,
group_aliases,
aggs,
..
} => lower_aggregate(
group_by,
group_aliases,
aggs,
input,
exprs,
expr_lowerer,
None,
),
GraphOp::Sort { keys } => lower_sort(keys, input, expr_lowerer),
GraphOp::Limit { count } => lower_limit(*count, input),
GraphOp::Skip { count } => lower_skip(*count, input),
other => Err(LoweringError::UnsupportedExpr(format!(
"operator not yet lowered (deferred to #577+): {other:?}"
))),
}
}
fn lower_filter(
predicate: ExprId,
input: LogicalPlan,
lowerer: &ExprLowerer<'_>,
) -> Result<LogicalPlan, LoweringError> {
let df_pred = lowerer.lower(predicate)?;
LogicalPlanBuilder::from(input)
.filter(df_pred)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()
}
fn lower_project(
items: &[ProjectItem],
distinct: bool,
input: LogicalPlan,
lowerer: &ExprLowerer<'_>,
) -> Result<LogicalPlan, LoweringError> {
let select_exprs: Vec<DfExpr> = items
.iter()
.map(|item| {
let e = lowerer.lower(item.expr)?;
Ok::<_, LoweringError>(match &item.alias {
Some(alias) => e.alias(alias),
None => e,
})
})
.collect::<Result<_, _>>()?;
let plan = LogicalPlanBuilder::from(input)
.project(select_exprs)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
if distinct {
LogicalPlanBuilder::from(plan)
.distinct()
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()
} else {
Ok(plan)
}
}
fn input_sort_exprs(input: &LogicalPlan) -> Vec<SortExpr> {
match input {
LogicalPlan::Sort(sort) => sort.expr.clone(),
LogicalPlan::Projection(projection) => input_sort_exprs(&projection.input)
.into_iter()
.enumerate()
.filter_map(|(index, sort)| {
let name = format!("{INPUT_ORDER_COLUMN_PREFIX}{index}");
projection
.schema
.fields()
.iter()
.any(|field| field.name() == &name)
.then(|| {
SortExpr::new(
DfExpr::Column(datafusion::common::Column::from_name(name)),
sort.asc,
sort.nulls_first,
)
})
})
.collect(),
LogicalPlan::Filter(filter) => input_sort_exprs(&filter.input),
_ => Vec::new(),
}
}
fn preserve_collect_order(
func: AggFunc,
aggregate: DfExpr,
input_order: &[SortExpr],
) -> Result<DfExpr, LoweringError> {
if func != AggFunc::Collect || input_order.is_empty() {
return Ok(aggregate);
}
aggregate
.order_by(input_order.to_vec())
.build()
.map_unsupported_expr()
}
#[allow(
clippy::too_many_lines,
reason = "group shaping, row markers, aggregate lowering, and aliases form one operation"
)]
fn lower_aggregate(
group_by: &[ExprId],
group_aliases: &[Option<String>],
aggs: &[AggExpr],
input: LogicalPlan,
exprs: &ExprArena,
lowerer: &ExprLowerer<'_>,
passthrough_groups: Option<(&[Option<VarId>], &VarMap)>,
) -> Result<LogicalPlan, LoweringError> {
let input_order = input_sort_exprs(&input);
let mut group_exprs = Vec::new();
let mut row_marker_aliases = Vec::new();
for (i, &id) in group_by.iter().enumerate() {
let passthrough = passthrough_groups.and_then(|(group_vars, var_map)| {
let group_var = group_vars.get(i).copied().flatten()?;
match exprs.get(id) {
IrExpr::VarRef(source) if *source == group_var => {
var_map.get(group_var).map(|alias| (group_var, alias))
}
_ => None,
}
});
if let Some((_var, alias)) = passthrough {
let qualifier = datafusion::common::TableReference::bare(alias);
let columns: Vec<DfExpr> = input
.schema()
.iter()
.filter(|(q, _)| q.as_ref().is_some_and(|q| **q == qualifier))
.map(|(q, field)| {
DfExpr::Column(datafusion::common::Column::new(q.cloned(), field.name()))
})
.collect();
if !columns.is_empty() {
group_exprs.extend(columns);
continue;
}
}
let e = lowerer.lower(id)?;
if matches!(
&e,
DfExpr::Literal(datafusion::scalar::ScalarValue::Null, _)
) && let Some((qualifier, field)) = input
.schema()
.iter()
.find(|(_, field)| {
matches!(
field.name().as_str(),
"node_uuid" | "edge_uuid" | "node_id" | "edge_id" | "src_id" | "dst_id"
)
})
.or_else(|| input.schema().iter().next())
{
let present = DfExpr::Column(datafusion::common::Column::new(
qualifier.cloned(),
field.name(),
));
let marker_alias = format!("__gf_row_marker_{i}");
group_exprs.push(
crate::expr::CYPHER_ROW_MARKER
.call(vec![present])
.alias(&marker_alias),
);
row_marker_aliases.push(marker_alias);
}
group_exprs.push(match group_aliases.get(i).and_then(Option::as_ref) {
Some(alias) => e.alias(alias),
None => e,
});
}
let aggr_exprs: Result<Vec<DfExpr>, LoweringError> = aggs
.iter()
.map(|a| {
let mut arg = a.arg.map(|id| lowerer.lower(id)).transpose()?;
if a.func == AggFunc::Count
&& arg.is_none()
&& let Some((qualifier, field)) = input.schema().iter().last()
{
let column = DfExpr::Column(datafusion::common::Column::new(
qualifier.cloned(),
field.name(),
));
arg = Some(crate::expr::CYPHER_ROW_MARKER.call(vec![column]));
}
let percentile = a.percentile.map(|id| lowerer.lower(id)).transpose()?;
let arg_type = arg.as_ref().and_then(|e| {
use datafusion::logical_expr::ExprSchemable;
e.get_type(input.schema()).ok()
});
let df_agg = lower_agg_func(a.func, arg, percentile, arg_type.as_ref())?;
let df_agg = preserve_collect_order(a.func, df_agg, &input_order)?;
Ok(df_agg.alias(&a.alias))
})
.collect();
let aggregate = LogicalPlanBuilder::from(input)
.aggregate(group_exprs, aggr_exprs?)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
if row_marker_aliases.is_empty() {
return Ok(aggregate);
}
let visible_columns = aggregate
.schema()
.iter()
.filter(|(_, field)| !row_marker_aliases.iter().any(|alias| alias == field.name()))
.map(|(qualifier, field)| {
DfExpr::Column(datafusion::common::Column::new(
qualifier.cloned(),
field.name(),
))
})
.collect::<Vec<_>>();
LogicalPlanBuilder::from(aggregate)
.project(visible_columns)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()
}
fn lower_sort(
keys: &[graphforge_ir::SortKey],
input: LogicalPlan,
lowerer: &ExprLowerer<'_>,
) -> Result<LogicalPlan, LoweringError> {
let sort_exprs: Result<Vec<SortExpr>, LoweringError> = keys
.iter()
.map(|k| {
let e = lowerer.lower(k.expr)?;
let e = match e.get_type(input.schema()) {
Ok(dt) if crate::expr::needs_cypher_order_key_type(&dt) => {
crate::expr::CYPHER_ORDER_KEY.call(vec![e])
}
Err(_) => crate::expr::CYPHER_ORDER_KEY.call(vec![e]),
_ => e,
};
Ok(SortExpr::new(e, k.order == SortOrder::Asc, k.nulls_first))
})
.collect();
LogicalPlanBuilder::from(input)
.sort(sort_exprs?)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()
}
fn lower_limit(count: u64, input: LogicalPlan) -> Result<LogicalPlan, LoweringError> {
let fetch = usize::try_from(count).map_err(|_| {
LoweringError::UnsupportedExpr(format!("LIMIT count {count} exceeds platform usize::MAX"))
})?;
LogicalPlanBuilder::from(input)
.limit(0, Some(fetch))
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()
}
fn lower_skip(count: u64, input: LogicalPlan) -> Result<LogicalPlan, LoweringError> {
let skip = usize::try_from(count).map_err(|_| {
LoweringError::UnsupportedExpr(format!("SKIP count {count} exceeds platform usize::MAX"))
})?;
LogicalPlanBuilder::from(input)
.limit(skip, None)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()
}
fn lower_agg_func(
func: AggFunc,
arg: Option<DfExpr>,
percentile: Option<DfExpr>,
arg_type: Option<&datafusion::arrow::datatypes::DataType>,
) -> Result<DfExpr, LoweringError> {
let het = crate::expr::is_het_struct_type(arg_type);
match func {
AggFunc::Count => Ok(match arg {
Some(e) => count(e),
None => count_all(),
}),
AggFunc::CountDistinct => Ok(count_distinct(arg.ok_or_else(|| {
LoweringError::UnsupportedExpr("COUNT DISTINCT requires an argument".into())
})?)),
AggFunc::Sum => Ok(sum(arg.ok_or_else(|| {
LoweringError::UnsupportedExpr("SUM requires an argument".into())
})?)),
AggFunc::SumDistinct => Ok(sum_distinct(arg.ok_or_else(|| {
LoweringError::UnsupportedExpr("SUM DISTINCT requires an argument".into())
})?)),
AggFunc::Avg => {
let arg = arg
.ok_or_else(|| LoweringError::UnsupportedExpr("AVG requires an argument".into()))?;
let arg = if matches!(arg_type, Some(datafusion::arrow::datatypes::DataType::Null)) {
datafusion::logical_expr::expr_fn::cast(
arg,
datafusion::arrow::datatypes::DataType::Float64,
)
} else {
arg
};
Ok(avg(arg))
}
AggFunc::AvgDistinct => {
let arg = arg.ok_or_else(|| {
LoweringError::UnsupportedExpr("AVG DISTINCT requires an argument".into())
})?;
let arg = if matches!(arg_type, Some(datafusion::arrow::datatypes::DataType::Null)) {
datafusion::logical_expr::expr_fn::cast(
arg,
datafusion::arrow::datatypes::DataType::Float64,
)
} else {
arg
};
Ok(avg_distinct(arg))
}
AggFunc::Min => {
let a = arg
.ok_or_else(|| LoweringError::UnsupportedExpr("MIN requires an argument".into()))?;
Ok(if het {
crate::expr::CYPHER_MIN.call(vec![a])
} else {
min(a)
})
}
AggFunc::Max => {
let a = arg
.ok_or_else(|| LoweringError::UnsupportedExpr("MAX requires an argument".into()))?;
Ok(if het {
crate::expr::CYPHER_MAX.call(vec![a])
} else {
max(a)
})
}
AggFunc::Collect => Ok(crate::expr::CYPHER_COLLECT.call(vec![arg.ok_or_else(|| {
LoweringError::UnsupportedExpr("COLLECT requires an argument".into())
})?])),
AggFunc::CollectDistinct => {
Ok(
crate::expr::CYPHER_COLLECT_DISTINCT.call(vec![arg.ok_or_else(|| {
LoweringError::UnsupportedExpr("COLLECT requires an argument".into())
})?]),
)
}
AggFunc::PercentileDisc => Ok(crate::expr::CYPHER_PERCENTILE_DISC.call(vec![
arg.ok_or_else(|| {
LoweringError::UnsupportedExpr("percentileDisc requires a value argument".into())
})?,
percentile.ok_or_else(|| {
LoweringError::UnsupportedExpr(
"percentileDisc requires a percentile argument".into(),
)
})?,
])),
AggFunc::PercentileCont => Ok(crate::expr::CYPHER_PERCENTILE_CONT.call(vec![
arg.ok_or_else(|| {
LoweringError::UnsupportedExpr("percentileCont requires a value argument".into())
})?,
percentile.ok_or_else(|| {
LoweringError::UnsupportedExpr(
"percentileCont requires a percentile argument".into(),
)
})?,
])),
}
}
fn build_type_id_map(ontology: Option<&OntologyHandle>) -> HashMap<u32, String> {
let mut map = HashMap::new();
if let Some(h) = ontology {
for name in h.relation_type_names() {
if let Some(type_id) = h.relation_type_id(name) {
map.insert(type_id.0, name.to_owned());
}
}
}
map
}
fn build_inference_rules(ontology: Option<&OntologyHandle>) -> HashMap<u32, Vec<(String, String)>> {
let mut map = HashMap::new();
if let Some(h) = ontology {
for name in h.relation_type_names() {
if let Some(type_id) = h.relation_type_id(name) {
let flags = h.semantic_flags(type_id);
let mut rules = Vec::new();
if flags.transitive {
rules.push((format!("transitive:{name}"), "conservative_min".to_owned()));
}
if flags.symmetric {
rules.push((format!("symmetric:{name}"), "conservative_min".to_owned()));
}
if !rules.is_empty() {
map.insert(type_id.0, rules);
}
}
}
}
map
}
fn build_entity_id_map(ontology: Option<&OntologyHandle>) -> HashMap<u32, String> {
let mut map = HashMap::new();
if let Some(h) = ontology {
for name in h.entity_type_names() {
if let Some(type_id) = h.entity_type_id(name) {
map.insert(type_id.0, name.to_owned());
}
}
}
map
}
fn const_eval_scalar(df: &DfExpr) -> Option<datafusion::scalar::ScalarValue> {
use datafusion::arrow::array::{RecordBatch, RecordBatchOptions};
use datafusion::arrow::datatypes::Schema;
use datafusion::execution::context::ExecutionProps;
use datafusion::physical_expr::create_physical_expr;
use datafusion::scalar::ScalarValue;
if let DfExpr::Literal(scalar, _) = df {
return Some(scalar.clone());
}
let schema = datafusion::common::DFSchema::empty();
let phys = create_physical_expr(df, &schema, &ExecutionProps::new()).ok()?;
let batch = RecordBatch::try_new_with_options(
std::sync::Arc::new(Schema::empty()),
vec![],
&RecordBatchOptions::new().with_row_count(Some(1)),
)
.ok()?;
let array = phys.evaluate(&batch).ok()?.into_array(1).ok()?;
ScalarValue::try_from_array(&array, 0).ok()
}
type EvaluatedProps = (Vec<(String, IrLiteral)>, Vec<(String, DfExpr)>);
fn eval_map_literal(
lowerer: &GraphPlanLowerer,
id: Option<ExprId>,
exprs: &ExprArena,
var_map: &VarMap,
input_schema: Option<&datafusion::common::DFSchemaRef>,
) -> Result<EvaluatedProps, LoweringError> {
let Some(id) = id else {
return Ok((Vec::new(), Vec::new()));
};
let IrExpr::MapLiteral(pairs) = exprs.get(id) else {
return Err(LoweringError::UnsupportedExpr(format!(
"CREATE properties must be a map literal, got: {:?}",
exprs.get(id)
)));
};
let mut literals = Vec::new();
let mut computed = Vec::new();
for (k, vexpr) in pairs {
if let IrExpr::Literal(lit) = exprs.get(*vexpr) {
reject_map_property_value(k, lit)?;
literals.push((k.clone(), lit.clone()));
continue;
}
let mut expr_lowerer = lowerer.expr_lowerer(exprs, var_map);
if let Some(schema) = input_schema {
expr_lowerer = expr_lowerer.with_input_schema(schema.clone());
}
let df = expr_lowerer.lower(*vexpr)?;
match const_eval_scalar(&df) {
Some(scalar) => {
let lit = crate::expr::scalar_to_ir_literal(&scalar)?;
reject_map_property_value(k, &lit)?;
literals.push((k.clone(), lit));
}
None => computed.push((k.clone(), df)),
}
}
Ok((literals, computed))
}
fn reject_map_property_value(prop_name: &str, lit: &IrLiteral) -> Result<(), LoweringError> {
if contains_map_literal(lit) {
return Err(LoweringError::UnsupportedExpr(format!(
"CREATE property `{prop_name}` cannot store map values"
)));
}
Ok(())
}
fn contains_map_literal(lit: &IrLiteral) -> bool {
match lit {
IrLiteral::Map(_) => true,
IrLiteral::List(items) => items.iter().any(contains_map_literal),
_ => false,
}
}
fn var_alias(var: VarId) -> String {
format!("var_{}", var.0)
}
fn is_source_op(op: &GraphOp) -> bool {
matches!(
op,
GraphOp::NodeScan { .. }
| GraphOp::EdgeScan { .. }
| GraphOp::TypedEdgeScan { .. }
| GraphOp::Expand { .. }
)
}
fn table_source(schema: datafusion::arrow::datatypes::SchemaRef) -> Arc<LogicalTableSource> {
Arc::new(LogicalTableSource::new(schema))
}
fn node_scan_source(dir: Option<&Path>) -> Arc<dyn datafusion::logical_expr::TableSource> {
use datafusion::datasource::provider_as_source;
match dir {
Some(d) => {
let path = d.join("topology").join("nodes.parquet");
provider_as_source(Arc::new(graphforge_storage::TopologyNodeTable::new(path)))
}
None => table_source(TOPOLOGY_NODES_SCHEMA.clone()),
}
}
fn edge_scan_source(
dir: Option<&Path>,
stem: &str,
schema: datafusion::arrow::datatypes::SchemaRef,
mode: OntologyMode,
) -> Arc<dyn datafusion::logical_expr::TableSource> {
use datafusion::datasource::provider_as_source;
match dir {
Some(d)
if stem == "_exploratory"
&& matches!(mode, OntologyMode::Strict | OntologyMode::Advisory) =>
{
provider_as_source(Arc::new(graphforge_storage::UnionEdgeTable::open(d)))
}
Some(d) => provider_as_source(Arc::new(graphforge_storage::TypedEdgeTable::open(d, stem))),
None => table_source(schema),
}
}
fn filter_node_by_type(
input: LogicalPlan,
alias: &str,
type_id: TypeId,
) -> Result<LogicalPlan, LoweringError> {
use datafusion::functions_nested::expr_fn::array_has;
use datafusion::logical_expr::{col, lit};
LogicalPlanBuilder::from(input)
.filter(array_has(col(format!("{alias}.type_ids")), lit(type_id.0)))
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()
}
fn enrich_bound_node_identity(
input: &LogicalPlan,
alias: &str,
dir: Option<&Path>,
) -> Result<LogicalPlan, LoweringError> {
use datafusion::common::Column;
use datafusion::logical_expr::col;
let identity_alias = format!("__gf_identity_{alias}");
let identity = LogicalPlanBuilder::scan(identity_alias.clone(), node_scan_source(dir), None)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
let joined = LogicalPlanBuilder::from(input.clone())
.join(
identity,
datafusion::logical_expr::JoinType::Inner,
(
vec![Column::from_qualified_name(format!("{alias}.node_uuid"))],
vec![Column::from_qualified_name(format!(
"{identity_alias}.node_uuid"
))],
),
None,
)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
let mut projection = input
.schema()
.iter()
.map(|(qualifier, field)| DfExpr::Column(Column::new(qualifier.cloned(), field.name())))
.collect::<Vec<_>>();
for name in ["node_id", "type_id", "type_ids"] {
projection.push(col(format!("{identity_alias}.{name}")).alias_qualified(Some(alias), name));
}
LogicalPlanBuilder::from(joined)
.project(projection)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()
}
fn lower_node_scan(
var: VarId,
ty: Option<TypeId>,
var_map: &mut VarMap,
dir: Option<&Path>,
pending_nodes: Option<&RecordBatch>,
) -> Result<LogicalPlan, LoweringError> {
let alias = var_alias(var);
var_map.insert(var, alias.clone());
let src = if let Some(batch) = pending_nodes.filter(|batch| batch.num_rows() > 0) {
use datafusion::datasource::{MemTable, provider_as_source};
let mut batches = dir
.map(graphforge_storage::read_nodes)
.transpose()
.map_unsupported_expr()?
.unwrap_or_default();
batches.push(batch.clone());
let table = MemTable::try_new(batch.schema(), vec![batches]).map_unsupported_expr()?;
provider_as_source(Arc::new(table))
} else {
node_scan_source(dir)
};
let mut builder = LogicalPlanBuilder::scan(alias.clone(), src, None).map_unsupported_expr()?;
if let Some(type_id) = ty {
use datafusion::functions_nested::expr_fn::array_has;
use datafusion::logical_expr::{col, lit};
builder = builder
.filter(array_has(col(format!("{alias}.type_ids")), lit(type_id.0)))
.map_unsupported_expr()?;
}
builder.build().map_unsupported_expr()
}
fn lower_typed_edge_scan(
var: VarId,
rel_ty: TypeId,
var_map: &mut VarMap,
catalog: Option<&GraphCatalog>,
type_id_to_rel_name: &HashMap<u32, String>,
dir: Option<&Path>,
mode: OntologyMode,
) -> Result<LogicalPlan, LoweringError> {
use datafusion::catalog::CatalogProvider;
use datafusion::logical_expr::{col, lit};
let alias = var_alias(var);
var_map.insert(var, alias.clone());
let rel_name = type_id_to_rel_name.get(&rel_ty.0).ok_or_else(|| {
LoweringError::UnsupportedExpr(format!(
"TypedEdgeScan: TypeId({}) has no known relation name; \
ontology may be incomplete or stale",
rel_ty.0
))
})?;
let use_exploratory = catalog
.and_then(|c| c.schema("graph"))
.is_none_or(|s| !s.table_exist(&format!("edges_{rel_name}")));
if use_exploratory {
let src = edge_scan_source(dir, "_exploratory", EXPLORATORY_EDGE_SCHEMA.clone(), mode);
let filter_expr = col("rel_type_name").eq(lit(rel_name.as_str()));
LogicalPlanBuilder::scan(alias, src, None)
.and_then(|b| b.filter(filter_expr))
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()
} else {
let src = edge_scan_source(dir, rel_name, TYPED_EDGE_SCHEMA.clone(), mode);
LogicalPlanBuilder::scan(alias, src, None)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()
}
}
fn lower_edge_scan(
var: VarId,
ty: Option<TypeId>,
var_map: &mut VarMap,
type_id_to_rel_name: &HashMap<u32, String>,
dir: Option<&Path>,
mode: OntologyMode,
) -> Result<LogicalPlan, LoweringError> {
use datafusion::logical_expr::{col, lit};
let alias = var_alias(var);
var_map.insert(var, alias.clone());
let src = edge_scan_source(dir, "_exploratory", EXPLORATORY_EDGE_SCHEMA.clone(), mode);
let mut builder = LogicalPlanBuilder::scan(alias, src, None).map_unsupported_expr()?;
if let Some(type_id) = ty
&& let Some(name) = type_id_to_rel_name.get(&type_id.0)
{
builder = builder
.filter(col("rel_type_name").eq(lit(name.as_str())))
.map_unsupported_expr()?;
}
builder.build().map_unsupported_expr()
}
fn unwind_element_field(
list_expr: &DfExpr,
input_schema: &datafusion::common::DFSchemaRef,
property_names: impl IntoIterator<Item = impl AsRef<str>>,
) -> datafusion::arrow::datatypes::Field {
use datafusion::arrow::datatypes::{DataType, Field, Fields};
use datafusion::logical_expr::ExprSchemable;
let element_type =
if let Ok(DataType::List(f) | DataType::LargeList(f) | DataType::FixedSizeList(f, _)) =
list_expr.get_type(input_schema.as_ref())
{
f.data_type().clone()
} else {
let mut names: Vec<String> = property_names
.into_iter()
.map(|name| name.as_ref().to_owned())
.collect();
names.sort();
names.dedup();
if names.is_empty() {
DataType::Int64
} else {
DataType::Struct(Fields::from(
names
.into_iter()
.map(|name| Field::new(name, DataType::Null, true))
.collect::<Vec<_>>(),
))
}
};
Field::new("elem", element_type, true)
}
fn merge_optional_child_vars(child_vm: &VarMap, var_map: &mut VarMap) {
for v in child_vm.var_ids() {
if var_map.get(v).is_none() {
let child_col = child_vm
.get(v)
.expect("var_ids yields only registered vars")
.to_owned();
var_map.insert(v, child_col);
}
}
}
fn promote_optional_entity_vars(
outer: &LogicalPlan,
inner: &LogicalPlan,
child_vm: &VarMap,
var_map: &mut VarMap,
) {
use datafusion::common::TableReference;
for var in child_vm.var_ids() {
let Some(outer_alias) = var_map.get(var) else {
continue;
};
let Some(inner_alias) = child_vm.get(var) else {
continue;
};
let outer_qual = TableReference::bare(outer_alias);
let inner_qual = TableReference::bare(inner_alias);
let outer_is_entity = ["node_uuid", "edge_uuid"].iter().any(|name| {
outer
.schema()
.index_of_column_by_name(Some(&outer_qual), name)
.is_some()
});
let inner_is_entity = ["node_uuid", "edge_uuid"].iter().any(|name| {
inner
.schema()
.index_of_column_by_name(Some(&inner_qual), name)
.is_some()
});
if !outer_is_entity && inner_is_entity {
var_map.insert(var, inner_alias.to_owned());
}
}
}
fn full_subquery_needs_outer_input(child: &GraphPlan, outer_vm: &VarMap) -> bool {
outer_vm.var_ids().any(|var| {
plan_references_var(child, var) && !child.ops.iter().any(|op| graph_op_binds_var(op, var))
})
}
fn plan_references_var(plan: &GraphPlan, var: VarId) -> bool {
let expression_reference = (0..plan.exprs.len()).any(|index| {
let index = u32::try_from(index).expect("ExprArena length is capped at u32::MAX");
matches!(plan.exprs.get(ExprId(index)), IrExpr::VarRef(found) if *found == var)
});
expression_reference
|| plan.ops.iter().any(|op| {
graph_op_binds_var(op, var)
|| match op {
GraphOp::Optional { child }
| GraphOp::Exists { child, .. }
| GraphOp::PatternComprehension { child, .. }
| GraphOp::ListElementPatternComprehension { child, .. } => {
plan_references_var(child, var)
}
GraphOp::Union { inputs, .. } => {
inputs.iter().any(|input| plan_references_var(input, var))
}
_ => false,
}
})
}
fn graph_op_binds_var(op: &GraphOp, var: VarId) -> bool {
match op {
GraphOp::NodeScan { var: found, .. }
| GraphOp::EdgeScan { var: found, .. }
| GraphOp::TypedEdgeScan { var: found, .. } => *found == var,
GraphOp::Expand { src, edge, dst, .. } => *src == var || *edge == var || *dst == var,
_ => false,
}
}
fn optional_join_keys(
outer: &LogicalPlan,
inner: &LogicalPlan,
outer_vm: &VarMap,
inner_vm: &VarMap,
) -> (Vec<(usize, usize)>, Vec<usize>) {
use datafusion::common::TableReference;
let outer_schema = outer.schema();
let inner_schema = inner.schema();
let shared_cols = outer_vm
.var_ids()
.filter_map(|var| {
let outer_col = outer_vm.get(var)?;
let inner_col = inner_vm.get(var)?;
Some((outer_col.to_owned(), inner_col.to_owned()))
})
.collect::<Vec<_>>();
let shared_quals: std::collections::HashSet<String> = shared_cols
.iter()
.filter_map(|(outer_col, inner_col)| {
let outer_qual = TableReference::bare(outer_col.clone());
["node_id", "node_uuid", "edge_uuid"]
.iter()
.any(|identity| {
outer_schema
.index_of_column_by_name(Some(&outer_qual), identity)
.is_some()
})
.then(|| inner_col.clone())
})
.collect();
let mut join_keys: Vec<(usize, usize)> = Vec::new();
for (outer_col, inner_col) in shared_cols {
let outer_qual = TableReference::bare(outer_col.clone());
let inner_qual = TableReference::bare(inner_col);
for identity in ["node_id", "node_uuid", "edge_uuid"] {
if let (Some(o), Some(i)) = (
outer_schema.index_of_column_by_name(Some(&outer_qual), identity),
inner_schema.index_of_column_by_name(Some(&inner_qual), identity),
) {
join_keys.push((o, i));
break;
}
}
if !join_keys.iter().any(|(_, inner)| {
["node_id", "node_uuid", "edge_uuid"]
.iter()
.any(|identity| {
inner_schema.index_of_column_by_name(Some(&inner_qual), identity)
== Some(*inner)
})
}) && let Some(o) = outer_schema.index_of_column_by_name(None, &outer_col)
&& let Some(i) = inner_schema
.index_of_column_by_name(Some(&inner_qual), "node_uuid")
.or_else(|| inner_schema.index_of_column_by_name(Some(&inner_qual), "edge_uuid"))
{
join_keys.push((o, i));
}
}
let inner_keep_idx = inner_schema
.iter()
.enumerate()
.filter(|(_, (q, _))| q.is_none_or(|q| !shared_quals.contains(q.table())))
.map(|(i, _)| i)
.collect();
(join_keys, inner_keep_idx)
}
fn schema_join_column(
schema: &datafusion::common::DFSchema,
index: usize,
) -> datafusion::common::Column {
let (qualifier, field) = schema
.iter()
.nth(index)
.expect("join key index must point at a schema field");
datafusion::common::Column::new(qualifier.cloned(), field.name().to_owned())
}
fn plan_columns(plan: &LogicalPlan) -> Vec<DfExpr> {
use datafusion::common::Column;
plan.schema()
.iter()
.map(|(qualifier, field)| {
DfExpr::Column(Column::new(qualifier.cloned(), field.name().to_owned()))
})
.collect()
}
#[allow(clippy::too_many_arguments)]
#[allow(
clippy::too_many_lines,
reason = "property discovery, bound-variable correlation, and extension shaping are one lowering operation"
)]
fn lower_var_len_expand(
src: VarId,
edge: VarId,
dst: VarId,
rel_ty: Option<TypeId>,
dir: Direction,
min_hops: u16,
max_hops: Option<u16>,
input: LogicalPlan,
var_map: &mut VarMap,
type_id_to_rel_name: &HashMap<u32, String>,
inference_rules: &HashMap<u32, Vec<(String, String)>>,
target: Option<(&Path, OntologyMode)>,
) -> Result<LogicalPlan, LoweringError> {
use datafusion::logical_expr::col;
let bound_edge_list = var_map.get(edge).map(str::to_owned);
let bound_dst = var_map.get(dst).map(str::to_owned);
let traversal_dst = bound_dst
.as_ref()
.map_or(dst, |_| VarId(u32::MAX.saturating_sub(dst.0)));
let rel_name = match rel_ty {
Some(rt) => type_id_to_rel_name.get(&rt.0).cloned().ok_or_else(|| {
LoweringError::UnsupportedExpr(format!(
"VarLenExpand: TypeId({}) has no known relation name; \
ontology may be incomplete or stale",
rt.0
))
})?,
None => "*".to_owned(),
};
let infer_rules: Vec<(String, String)> = rel_ty
.and_then(|rt| inference_rules.get(&rt.0))
.cloned()
.unwrap_or_default();
let rel_for_infer = rel_name.clone();
let (dir_path, mode) = target.ok_or_else(|| {
LoweringError::UnsupportedExpr(
"variable-length expand requires a project directory; \
lower via new_for_writes or new_with_dir"
.into(),
)
})?;
let topology_names = ["edge_uuid", "src_uuid", "dst_uuid", "rel_type"];
let prop_fields: Vec<datafusion::arrow::datatypes::Field> = if rel_name == "*" {
let mut seen = std::collections::HashSet::new();
let mut fields = Vec::new();
for stem in graphforge_storage::list_edge_property_stems(dir_path) {
let prop_table =
graphforge_storage::EdgePropertyTable::open_discovered(dir_path, &stem);
for f in prop_table.schema_ref().fields() {
if topology_names.contains(&f.name().as_str()) {
continue;
}
if seen.insert(f.name().clone()) {
fields.push(f.as_ref().clone().with_nullable(true));
}
}
}
fields
} else {
let prop_table =
graphforge_storage::EdgePropertyTable::open_discovered(dir_path, &rel_name);
prop_table
.schema_ref()
.fields()
.iter()
.filter(|f| !topology_names.contains(&f.name().as_str()))
.map(|f| f.as_ref().clone())
.collect()
};
let dst_fields = TOPOLOGY_NODES_SCHEMA.fields().iter().cloned().collect();
let node = VarLenExpandNode::new(
Arc::new(input),
rel_name,
min_hops,
max_hops,
src.0,
traversal_dst.0,
edge.0,
dir,
rel_ty.map(|t| t.0),
dir_path.to_path_buf(),
mode,
dst_fields,
graphforge_plan::var_len_edge_list_field(&prop_fields),
);
if bound_dst.is_none() {
var_map.insert(dst, var_alias(dst));
}
var_map.insert(
edge,
format!(
"var_{}.{}",
edge.0,
graphforge_plan::VAR_LEN_EDGE_LIST_FIELD
),
);
let mut base = LogicalPlan::Extension(Extension {
node: Arc::new(node),
});
if let Some(bound_dst) = bound_dst {
let traversal_alias = var_alias(traversal_dst);
base = LogicalPlanBuilder::from(base)
.filter(
col(format!("{traversal_alias}.node_uuid"))
.eq(col(format!("{bound_dst}.node_uuid"))),
)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
let traversal_ref = datafusion::common::TableReference::bare(traversal_alias);
let projection = base
.schema()
.iter()
.filter(|(qualifier, _)| {
qualifier
.as_ref()
.is_none_or(|qualifier| **qualifier != traversal_ref)
})
.map(|(qualifier, field)| {
DfExpr::Column(datafusion::common::Column::new(
qualifier.cloned(),
field.name(),
))
})
.collect::<Vec<_>>();
base = LogicalPlanBuilder::from(base)
.project(projection)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
}
if let Some(bound_edge_list) = bound_edge_list {
let produced = format!(
"var_{}.{}",
edge.0,
graphforge_plan::VAR_LEN_EDGE_LIST_FIELD
);
base = LogicalPlanBuilder::from(base)
.filter(col(bound_edge_list).eq(col(produced)))
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
}
let plan = infer_rules
.into_iter()
.fold(base, |acc, (rule_id, conf_model)| {
LogicalPlan::Extension(Extension {
node: Arc::new(graphforge_plan::OntologyInferNode::new(
Arc::new(acc),
rel_for_infer.clone(),
rule_id,
conf_model,
)),
})
});
Ok(plan)
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_lines)]
fn lower_expand(
src: VarId,
edge: VarId,
dst: VarId,
rel_ty: Option<TypeId>,
dir: Direction,
min_hops: u16,
max_hops: Option<u16>,
input: LogicalPlan,
var_map: &mut VarMap,
catalog: Option<&GraphCatalog>,
type_id_to_rel_name: &HashMap<u32, String>,
inference_rules: &HashMap<u32, Vec<(String, String)>>,
target: Option<(&Path, OntologyMode)>,
relational_reference: bool,
) -> Result<LogicalPlan, LoweringError> {
if min_hops != 1 || max_hops != Some(1) {
return lower_var_len_expand(
src,
edge,
dst,
rel_ty,
dir,
min_hops,
max_hops,
input,
var_map,
type_id_to_rel_name,
inference_rules,
target,
);
}
let src_alias = var_map
.get(src)
.ok_or(LoweringError::UnboundVar(src.0))?
.to_owned();
if !relational_reference
&& let Some(plan) = try_lower_provider_expand(
src,
edge,
dst,
rel_ty,
dir,
&input,
var_map,
type_id_to_rel_name,
target,
)?
{
return Ok(plan);
}
match dir {
Direction::Out => expand_single_dir(
src,
edge,
dst,
rel_ty,
&src_alias,
true,
input,
var_map,
catalog,
type_id_to_rel_name,
target,
),
Direction::In => expand_single_dir(
src,
edge,
dst,
rel_ty,
&src_alias,
false,
input,
var_map,
catalog,
type_id_to_rel_name,
target,
),
Direction::Undirected => {
use datafusion::common::Column;
use datafusion::logical_expr::col;
let mut vm_out = var_map.clone();
let mut vm_in = var_map.clone();
let out_plan = expand_single_dir(
src,
edge,
dst,
rel_ty,
&src_alias,
true,
input.clone(),
&mut vm_out,
catalog,
type_id_to_rel_name,
target,
)?;
let in_plan = expand_single_dir(
src,
edge,
dst,
rel_ty,
&src_alias,
false,
input,
&mut vm_in,
catalog,
type_id_to_rel_name,
target,
)?;
if let Some(edge_col) = vm_out.get(edge) {
var_map.insert(edge, edge_col.to_owned());
}
if let Some(dst_col) = vm_out.get(dst) {
var_map.insert(dst, dst_col.to_owned());
}
let edge_alias = var_alias(edge);
let in_plan = LogicalPlanBuilder::from(in_plan)
.filter(
col(format!("{edge_alias}.src_id")).not_eq(col(format!("{edge_alias}.dst_id"))),
)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
let leg_schema = out_plan.schema().clone();
let stable = |plan: LogicalPlan| -> Result<LogicalPlan, LoweringError> {
let proj: Vec<DfExpr> = plan
.schema()
.iter()
.enumerate()
.map(|(i, (q, f))| {
DfExpr::Column(Column::new(q.cloned(), f.name())).alias(format!("__u{i}"))
})
.collect();
LogicalPlanBuilder::from(plan)
.project(proj)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()
};
let out_plan = stable(out_plan)?;
let in_plan = stable(in_plan)?;
let unioned = LogicalPlanBuilder::from(out_plan)
.union(in_plan)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
let projections: Vec<DfExpr> = leg_schema
.iter()
.enumerate()
.map(|(i, (q, f))| {
col(format!("__u{i}")).alias_qualified(q.cloned(), f.name().as_str())
})
.collect();
LogicalPlanBuilder::from(unioned)
.project(projections)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()
}
}
}
#[allow(clippy::too_many_arguments)]
#[allow(
clippy::too_many_lines,
reason = "schema discovery, repeated-destination correlation, and extension shaping are one lowering operation"
)]
fn try_lower_provider_expand(
src: VarId,
edge: VarId,
dst: VarId,
rel_ty: Option<TypeId>,
dir: Direction,
input: &LogicalPlan,
var_map: &mut VarMap,
type_id_to_rel_name: &HashMap<u32, String>,
target: Option<(&Path, OntologyMode)>,
) -> Result<Option<LogicalPlan>, LoweringError> {
let Some((dir_path, mode)) = target else {
return Ok(None); };
if var_map.get(edge).is_some() {
return Ok(None); }
let rel_name = match rel_ty {
Some(rt) => {
let Some(name) = type_id_to_rel_name.get(&rt.0) else {
return Ok(None); };
name.clone()
}
None => "*".to_owned(),
};
let bound_dst = var_map.get(dst).map(str::to_owned);
let traversal_dst = bound_dst
.as_ref()
.map_or(dst, |_| VarId(u32::MAX.saturating_sub(dst.0)));
let edge_schema = if rel_ty.is_none() || matches!(mode, OntologyMode::Exploratory) {
&*EXPLORATORY_EDGE_SCHEMA
} else {
&*TYPED_EDGE_SCHEMA
};
let edge_fields: Vec<Arc<datafusion::arrow::datatypes::Field>> =
edge_schema.fields().iter().cloned().collect();
let base_names: HashSet<&str> = edge_fields.iter().map(|f| f.name().as_str()).collect();
let mut stems = if rel_name == "*" {
graphforge_storage::list_edge_property_stems(dir_path)
} else {
vec![rel_name.clone()]
};
stems.sort();
let mut seen = HashSet::new();
let mut edge_prop_fields = Vec::new();
for stem in stems {
let prop_table = graphforge_storage::EdgePropertyTable::open_discovered(dir_path, &stem);
for field in prop_table.schema_ref().fields() {
if field.name() != "edge_uuid"
&& !base_names.contains(field.name().as_str())
&& seen.insert(field.name().clone())
{
edge_prop_fields.push(Arc::clone(field));
}
}
}
let dst_fields: Vec<Arc<datafusion::arrow::datatypes::Field>> =
TOPOLOGY_NODES_SCHEMA.fields().iter().cloned().collect();
let node = graphforge_plan::ExpandNode::new(
Arc::new(input.clone()),
rel_name,
src.0,
traversal_dst.0,
edge.0,
dir,
rel_ty.map(|rt| rt.0),
dir_path.to_path_buf(),
mode,
edge_fields,
edge_prop_fields,
dst_fields,
);
var_map.insert(edge, var_alias(edge));
if bound_dst.is_none() {
var_map.insert(dst, var_alias(dst));
}
let mut base = LogicalPlan::Extension(datafusion::logical_expr::Extension {
node: Arc::new(node),
});
if let Some(bound_dst) = bound_dst {
use datafusion::logical_expr::col;
let traversal_alias = var_alias(traversal_dst);
base = LogicalPlanBuilder::from(base)
.filter(
col(format!("{traversal_alias}.node_uuid"))
.eq(col(format!("{bound_dst}.node_uuid"))),
)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
let traversal_ref = datafusion::common::TableReference::bare(traversal_alias);
let projection = base
.schema()
.iter()
.filter(|(qualifier, _)| {
qualifier
.as_ref()
.is_none_or(|qualifier| **qualifier != traversal_ref)
})
.map(|(qualifier, field)| {
DfExpr::Column(datafusion::common::Column::new(
qualifier.cloned(),
field.name(),
))
})
.collect::<Vec<_>>();
base = LogicalPlanBuilder::from(base)
.project(projection)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
}
Ok(Some(base))
}
#[allow(clippy::too_many_arguments)]
fn expand_single_dir(
_src: VarId,
edge: VarId,
dst: VarId,
rel_ty: Option<TypeId>,
src_alias: &str,
out_direction: bool,
input: LogicalPlan,
var_map: &mut VarMap,
catalog: Option<&GraphCatalog>,
type_id_to_rel_name: &HashMap<u32, String>,
target: Option<(&Path, OntologyMode)>,
) -> Result<LogicalPlan, LoweringError> {
use datafusion::logical_expr::col;
let bound_dst = var_map.get(dst).and_then(|alias| {
let qualifier = datafusion::common::TableReference::bare(alias);
if input
.schema()
.index_of_column_by_name(Some(&qualifier), "node_id")
.is_some()
{
Some((alias.to_owned(), "node_id", false))
} else {
input
.schema()
.index_of_column_by_name(Some(&qualifier), "node_uuid")
.is_some()
.then(|| (alias.to_owned(), "node_uuid", true))
}
});
let dir = target.map(|(d, _)| d);
let mode = target.map_or(OntologyMode::Exploratory, |(_, m)| m);
if let Some(edge_alias) = var_map.get(edge).map(str::to_owned) {
return expand_bound_edge_single_dir(
dst,
rel_ty,
src_alias,
&edge_alias,
out_direction,
input,
var_map,
type_id_to_rel_name,
target,
);
}
let edge_plan = match rel_ty {
Some(rt) => {
lower_typed_edge_scan(edge, rt, var_map, catalog, type_id_to_rel_name, dir, mode)?
}
None => lower_edge_scan(edge, None, var_map, type_id_to_rel_name, dir, mode)?,
};
let edge_alias = var_map
.get(edge)
.ok_or(LoweringError::UnboundVar(edge.0))?
.to_owned();
let edge_plan = join_edge_properties(&edge_alias, rel_ty, type_id_to_rel_name, dir, edge_plan)?;
let src_col = col(format!("{src_alias}.node_id"));
let edge_src_col = col(if out_direction {
format!("{edge_alias}.src_id")
} else {
format!("{edge_alias}.dst_id")
});
let join_pred = src_col.eq(edge_src_col);
let joined = LogicalPlanBuilder::from(input)
.join_on(edge_plan, JoinType::Inner, vec![join_pred])
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
let edge_dst_col = col(
match (out_direction, bound_dst.as_ref().map(|(_, _, uuid)| *uuid)) {
(true, Some(true)) => format!("{edge_alias}.dst_uuid"),
(false, Some(true)) => format!("{edge_alias}.src_uuid"),
(true, _) => format!("{edge_alias}.dst_id"),
(false, _) => format!("{edge_alias}.src_id"),
},
);
if let Some((dst_alias, dst_field, _)) = bound_dst {
return LogicalPlanBuilder::from(joined)
.filter(edge_dst_col.eq(col(format!("{dst_alias}.{dst_field}"))))
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr();
}
let dst_plan = lower_node_scan(dst, None, var_map, dir, None)?;
let dst_alias = var_map
.get(dst)
.ok_or(LoweringError::UnboundVar(dst.0))?
.to_owned();
let dst_col = col(format!("{dst_alias}.node_id"));
let join_pred2 = edge_dst_col.eq(dst_col);
LogicalPlanBuilder::from(joined)
.join_on(dst_plan, JoinType::Inner, vec![join_pred2])
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()
}
#[allow(clippy::too_many_arguments)]
fn expand_bound_edge_single_dir(
dst: VarId,
rel_ty: Option<TypeId>,
src_alias: &str,
edge_alias: &str,
out_direction: bool,
input: LogicalPlan,
var_map: &mut VarMap,
type_id_to_rel_name: &HashMap<u32, String>,
target: Option<(&Path, OntologyMode)>,
) -> Result<LogicalPlan, LoweringError> {
use datafusion::common::TableReference;
use datafusion::logical_expr::{col, lit};
let edge_src_field = if out_direction { "src_id" } else { "dst_id" };
let edge_dst_field = if out_direction { "dst_id" } else { "src_id" };
let mut predicate =
col(format!("{src_alias}.node_id")).eq(col(format!("{edge_alias}.{edge_src_field}")));
if let Some(rt) = rel_ty {
let rel_name = type_id_to_rel_name.get(&rt.0).ok_or_else(|| {
LoweringError::UnsupportedExpr(format!(
"bound edge TypeId({}) has no known relation name; ontology may be incomplete or stale",
rt.0
))
})?;
let qual = TableReference::bare(edge_alias);
if input
.schema()
.index_of_column_by_name(Some(&qual), "rel_type_name")
.is_some()
{
predicate = predicate
.and(col(format!("{edge_alias}.rel_type_name")).eq(lit(rel_name.as_str())));
}
}
let filtered = LogicalPlanBuilder::from(input)
.filter(predicate)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
let edge_dst = col(format!("{edge_alias}.{edge_dst_field}"));
if let Some(dst_alias) = var_map.get(dst).map(str::to_owned) {
return LogicalPlanBuilder::from(filtered)
.filter(edge_dst.eq(col(format!("{dst_alias}.node_id"))))
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr();
}
let dir = target.map(|(d, _)| d);
let dst_plan = lower_node_scan(dst, None, var_map, dir, None)?;
let dst_alias = var_map
.get(dst)
.ok_or(LoweringError::UnboundVar(dst.0))?
.to_owned();
LogicalPlanBuilder::from(filtered)
.join_on(
dst_plan,
JoinType::Inner,
vec![edge_dst.eq(col(format!("{dst_alias}.node_id")))],
)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()
}
fn join_edge_properties(
edge_alias: &str,
rel_ty: Option<TypeId>,
type_id_to_rel_name: &HashMap<u32, String>,
dir: Option<&Path>,
scan: LogicalPlan,
) -> Result<LogicalPlan, LoweringError> {
use datafusion::logical_expr::{col, lit};
use std::collections::{HashMap as StdHashMap, HashSet};
let Some(dir) = dir else {
return Ok(scan); };
let base_cols: Vec<String> = scan
.schema()
.fields()
.iter()
.map(|f| f.name().clone())
.collect();
let mut prop_sources = Vec::new();
let mut prop_order = Vec::new();
let mut seen_props = HashSet::new();
let mut push_source = |stem: String| {
let table = graphforge_storage::EdgePropertyTable::open_discovered(dir, &stem);
let prop_cols: Vec<String> = table
.schema_ref()
.fields()
.iter()
.map(|f| f.name().clone())
.filter(|n| !base_cols.contains(n))
.collect();
if prop_cols.is_empty() {
return;
}
for name in &prop_cols {
if seen_props.insert(name.clone()) {
prop_order.push(name.clone());
}
}
prop_sources.push((stem, table, prop_cols));
};
if let Some(rel_ty) = rel_ty {
let Some(rel_name) = type_id_to_rel_name.get(&rel_ty.0) else {
return Ok(scan); };
push_source(rel_name.clone());
} else {
for stem in graphforge_storage::list_edge_property_stems(dir) {
push_source(stem);
}
}
if prop_sources.is_empty() {
return Ok(scan);
}
let wildcard = rel_ty.is_none();
let mut joined = scan;
let mut prop_refs: StdHashMap<String, Vec<DfExpr>> = StdHashMap::new();
for (idx, (stem, prop_table, prop_cols)) in prop_sources.into_iter().enumerate() {
let prop_alias = format!("{edge_alias}__eprops_{idx}");
let prop_src = datafusion::datasource::provider_as_source(Arc::new(prop_table));
let prop_scan = LogicalPlanBuilder::scan(prop_alias.clone(), prop_src, None)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
let mut join_pred =
col(format!("{edge_alias}.edge_uuid")).eq(col(format!("{prop_alias}.edge_uuid")));
if wildcard {
join_pred = join_pred.and(col(format!("{edge_alias}.rel_type_name")).eq(lit(stem)));
}
joined = LogicalPlanBuilder::from(joined)
.join_on(prop_scan, JoinType::Left, vec![join_pred])
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()?;
for name in prop_cols {
prop_refs
.entry(name.clone())
.or_default()
.push(crate::expr::qualified_col(&prop_alias, &name));
}
}
let mut projections: Vec<DfExpr> = base_cols
.iter()
.map(|name| col(format!("{edge_alias}.{name}")))
.collect();
for name in prop_order {
let mut refs = prop_refs.remove(&name).unwrap_or_default();
let value = if refs.len() == 1 {
refs.remove(0)
} else {
datafusion::functions::core::expr_fn::coalesce(refs)
};
projections.push(value.alias_qualified(Some(edge_alias), name.as_str()));
}
LogicalPlanBuilder::from(joined)
.project(projections)
.and_then(LogicalPlanBuilder::build)
.map_unsupported_expr()
}
#[cfg(test)]
mod tests {
use super::*;
use datafusion::logical_expr::LogicalPlan as DfLogicalPlan;
use graphforge_core::TypeId;
use graphforge_ir::expr::{IrExpr, IrLiteral};
use graphforge_ir::{Direction, ExprArena, GraphPlan, VarId};
fn empty_base() -> LogicalPlan {
LogicalPlanBuilder::empty(false).build().unwrap()
}
#[test]
fn unsupported_error_mapping_preserves_success_and_exact_diagnostic() {
let success: Result<u8, &str> = Ok(7);
assert_eq!(success.map_unsupported_expr().unwrap(), 7);
let failure: Result<u8, &str> = Err("planner diagnostic: var_9");
assert_eq!(
failure.map_unsupported_expr().unwrap_err().to_string(),
"unsupported expression: planner diagnostic: var_9"
);
}
fn make_catalog_and_lowerer() -> (
tempfile::TempDir,
graphforge_storage::GraphCatalog,
graphforge_ir::RuntimeCatalog,
) {
let dir = tempfile::TempDir::new().unwrap();
let rc = graphforge_ir::RuntimeCatalog::new();
let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
(dir, catalog, rc)
}
fn node_scan_with_alias(alias: &str) -> LogicalPlan {
let schema = std::sync::Arc::new(datafusion::arrow::datatypes::Schema::new(vec![
datafusion::arrow::datatypes::Field::new(
"node_id",
datafusion::arrow::datatypes::DataType::UInt64,
false,
),
]));
LogicalPlanBuilder::scan(alias, table_source(schema), None)
.and_then(LogicalPlanBuilder::build)
.unwrap()
}
#[test]
fn optional_join_keys_use_var_map_qualifiers() {
let outer = node_scan_with_alias("projected_node");
let inner = node_scan_with_alias("pattern_node");
let mut outer_vm = VarMap::new();
outer_vm.insert(VarId(7), "projected_node");
let mut inner_vm = VarMap::new();
inner_vm.insert(VarId(7), "pattern_node");
let (join_keys, inner_keep_idx) = optional_join_keys(&outer, &inner, &outer_vm, &inner_vm);
assert_eq!(join_keys, vec![(0, 0)]);
assert_eq!(inner_keep_idx, Vec::<usize>::new());
}
#[test]
fn filter_lowers_predicate() {
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let mut arena = ExprArena::new();
let lit = arena.push(IrExpr::Literal(IrLiteral::Bool(true)));
let var_map = VarMap::new();
let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
let result = lowerer
.lower_op(
&GraphOp::Filter { predicate: lit },
empty_base(),
&arena,
&var_map,
&expr_lowerer,
)
.unwrap();
assert!(
matches!(result, DfLogicalPlan::Filter(_)),
"expected Filter, got {result:?}"
);
}
#[test]
fn project_lowers_columns() {
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let mut arena = ExprArena::new();
let lit = arena.push(IrExpr::Literal(IrLiteral::Int(1)));
let item = ProjectItem {
expr: lit,
alias: Some("x".into()),
out_var: None,
};
let var_map = VarMap::new();
let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
let result = lowerer
.lower_op(
&GraphOp::Project {
items: vec![item],
distinct: false,
},
empty_base(),
&arena,
&var_map,
&expr_lowerer,
)
.unwrap();
assert!(
matches!(result, DfLogicalPlan::Projection(_)),
"expected Projection, got {result:?}"
);
}
#[test]
fn project_with_distinct_wraps_in_distinct() {
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let mut arena = ExprArena::new();
let lit = arena.push(IrExpr::Literal(IrLiteral::Int(1)));
let item = ProjectItem {
expr: lit,
alias: None,
out_var: None,
};
let var_map = VarMap::new();
let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
let result = lowerer
.lower_op(
&GraphOp::Project {
items: vec![item],
distinct: true,
},
empty_base(),
&arena,
&var_map,
&expr_lowerer,
)
.unwrap();
assert!(
matches!(result, DfLogicalPlan::Distinct(_)),
"expected Distinct, got {result:?}"
);
}
#[test]
fn with_where_scalar_alias_uses_projected_scope_then_drops_inputs() {
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let mut builder = GraphPlan::builder("openCypher");
let value = builder.push_expr(IrExpr::Literal(IrLiteral::Bool(true)));
let predicate = builder.push_expr(IrExpr::VarRef(VarId(9)));
let plan = builder
.push_op(GraphOp::With {
items: vec![ProjectItem {
expr: value,
alias: Some("keep".into()),
out_var: Some(VarId(9)),
}],
distinct: false,
where_predicate: Some(predicate),
})
.build();
let lowered = lowerer.lower_plan(&plan).unwrap();
let DfLogicalPlan::Projection(final_projection) = lowered else {
panic!("expected final WITH projection");
};
assert_eq!(final_projection.schema.fields().len(), 1);
assert_eq!(final_projection.schema.field(0).name(), "keep");
assert!(matches!(
final_projection.input.as_ref(),
DfLogicalPlan::Filter(_)
));
}
#[test]
fn with_where_forwards_complete_node_shape_through_new_scope() {
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let mut builder = GraphPlan::builder("openCypher");
let node = builder.push_expr(IrExpr::VarRef(VarId(0)));
let predicate = builder.push_expr(IrExpr::Literal(IrLiteral::Bool(true)));
let plan = builder
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.push_op(GraphOp::With {
items: vec![ProjectItem {
expr: node,
alias: Some("n".into()),
out_var: Some(VarId(0)),
}],
distinct: false,
where_predicate: Some(predicate),
})
.build();
let lowered = lowerer.lower_plan(&plan).unwrap();
let DfLogicalPlan::Projection(final_projection) = lowered else {
panic!("expected final WITH projection");
};
assert!(final_projection.schema.fields().len() >= 4);
assert!(final_projection.schema.iter().all(|(qualifier, _)| {
qualifier.is_some_and(|qualifier| qualifier.table() == "var_0")
}));
assert!(matches!(
final_projection.input.as_ref(),
DfLogicalPlan::Filter(_)
));
}
#[test]
fn aggregate_count_star() {
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let arena = ExprArena::new();
let agg = AggExpr {
func: AggFunc::Count,
arg: None,
percentile: None,
alias: "total".into(),
out_var: None,
};
let var_map = VarMap::new();
let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
let result = lowerer
.lower_op(
&GraphOp::Aggregate {
group_by: vec![],
group_aliases: vec![],
group_vars: vec![],
aggs: vec![agg],
},
empty_base(),
&arena,
&var_map,
&expr_lowerer,
)
.unwrap();
assert!(
matches!(result, DfLogicalPlan::Aggregate(_)),
"expected Aggregate, got {result:?}"
);
}
#[test]
fn aggregate_function_argument_contract_matrix_and_specialized_paths() {
use datafusion::arrow::datatypes::{DataType, Field, Fields};
use datafusion::logical_expr::lit;
for (function, expected) in [
(
AggFunc::CountDistinct,
"COUNT DISTINCT requires an argument",
),
(AggFunc::Sum, "SUM requires an argument"),
(AggFunc::SumDistinct, "SUM DISTINCT requires an argument"),
(AggFunc::Avg, "AVG requires an argument"),
(AggFunc::AvgDistinct, "AVG DISTINCT requires an argument"),
(AggFunc::Min, "MIN requires an argument"),
(AggFunc::Max, "MAX requires an argument"),
(AggFunc::Collect, "COLLECT requires an argument"),
(AggFunc::CollectDistinct, "COLLECT requires an argument"),
(
AggFunc::PercentileDisc,
"percentileDisc requires a value argument",
),
(
AggFunc::PercentileCont,
"percentileCont requires a value argument",
),
] {
assert_eq!(
lower_agg_func(function, None, None, None)
.unwrap_err()
.to_string(),
format!("unsupported expression: {expected}")
);
}
for function in [AggFunc::PercentileDisc, AggFunc::PercentileCont] {
assert!(
lower_agg_func(function, Some(lit(1_i64)), None, Some(&DataType::Int64))
.unwrap_err()
.to_string()
.contains("percentile argument")
);
}
for function in [
AggFunc::Count,
AggFunc::CountDistinct,
AggFunc::Sum,
AggFunc::SumDistinct,
AggFunc::Avg,
AggFunc::AvgDistinct,
AggFunc::Min,
AggFunc::Max,
AggFunc::Collect,
AggFunc::CollectDistinct,
] {
let arg = (function != AggFunc::Count).then(|| lit(1_i64));
assert!(lower_agg_func(function, arg, None, Some(&DataType::Int64)).is_ok());
}
assert!(
lower_agg_func(
AggFunc::Avg,
Some(lit(datafusion::scalar::ScalarValue::Null)),
None,
Some(&DataType::Null),
)
.is_ok()
);
assert!(
lower_agg_func(
AggFunc::AvgDistinct,
Some(lit(datafusion::scalar::ScalarValue::Null)),
None,
Some(&DataType::Null),
)
.is_ok()
);
let heterogeneous = DataType::Struct(Fields::from(vec![
Field::new("__het_tag", DataType::Int8, false),
Field::new("__het_value_0", DataType::Int64, true),
]));
for function in [AggFunc::Min, AggFunc::Max] {
let expression = lower_agg_func(
function,
Some(lit(datafusion::scalar::ScalarValue::Null)),
None,
Some(&heterogeneous),
)
.unwrap();
assert!(format!("{expression}").contains("cypher_"));
}
}
#[test]
fn sort_lowers_keys() {
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let mut arena = ExprArena::new();
let lit = arena.push(IrExpr::Literal(IrLiteral::Int(1)));
let var_map = VarMap::new();
let key = graphforge_ir::SortKey {
expr: lit,
order: SortOrder::Desc,
nulls_first: false,
};
let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
let result = lowerer
.lower_op(
&GraphOp::Sort { keys: vec![key] },
empty_base(),
&arena,
&var_map,
&expr_lowerer,
)
.unwrap();
assert!(
matches!(result, DfLogicalPlan::Sort(_)),
"expected Sort, got {result:?}"
);
}
#[test]
fn limit_lowers_correctly() {
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let arena = ExprArena::new();
let var_map = VarMap::new();
let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
let result = lowerer
.lower_op(
&GraphOp::Limit { count: 10 },
empty_base(),
&arena,
&var_map,
&expr_lowerer,
)
.unwrap();
assert!(
matches!(result, DfLogicalPlan::Limit(_)),
"expected Limit, got {result:?}"
);
}
#[test]
fn skip_lowers_correctly() {
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let arena = ExprArena::new();
let var_map = VarMap::new();
let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
let result = lowerer
.lower_op(
&GraphOp::Skip { count: 5 },
empty_base(),
&arena,
&var_map,
&expr_lowerer,
)
.unwrap();
assert!(
matches!(result, DfLogicalPlan::Limit(_)),
"expected Limit (skip), got {result:?}"
);
}
#[test]
fn unsupported_op_returns_error() {
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let arena = ExprArena::new();
let var_map = VarMap::new();
let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
let result = lowerer.lower_op(
&GraphOp::NodeScan {
var: VarId(0),
ty: None,
},
empty_base(),
&arena,
&var_map,
&expr_lowerer,
);
assert!(
matches!(result, Err(LoweringError::UnsupportedExpr(_))),
"expected UnsupportedExpr for NodeScan"
);
}
#[test]
fn statement_driver_only_write_forms_are_rejected_by_relational_lowering() {
use graphforge_ir::{LabelItem, RemovePropItem, SetMapItem};
let dir = tempfile::tempdir().unwrap();
let lowerer = GraphPlanLowerer::new_for_writes(
None,
None,
dir.path(),
graphforge_core::OntologyMode::Exploratory,
);
let mut set_builder = GraphPlan::builder("openCypher");
let map = set_builder.push_expr(IrExpr::MapLiteral(vec![]));
let set_map = set_builder
.push_op(GraphOp::Set {
items: vec![],
map_items: vec![SetMapItem {
target: VarId(1),
map,
replace: false,
}],
label_items: vec![],
})
.build();
assert!(
lowerer
.lower_plan(&set_map)
.unwrap_err()
.to_string()
.contains("statement driver")
);
let set_labels = GraphPlan::builder("openCypher")
.push_op(GraphOp::Set {
items: vec![],
map_items: vec![],
label_items: vec![LabelItem {
target: VarId(1),
labels: vec![TypeId(7)],
}],
})
.build();
assert!(
lowerer
.lower_plan(&set_labels)
.unwrap_err()
.to_string()
.contains("statement driver")
);
let remove_labels = GraphPlan::builder("openCypher")
.push_op(GraphOp::Remove {
items: Vec::<RemovePropItem>::new(),
label_items: vec![LabelItem {
target: VarId(1),
labels: vec![TypeId(7)],
}],
})
.build();
assert!(
lowerer
.lower_plan(&remove_labels)
.unwrap_err()
.to_string()
.contains("statement driver")
);
}
#[test]
fn correlated_subquery_shapes_fail_with_precise_contract_errors() {
let lowerer = GraphPlanLowerer::new(None, None);
let no_alternatives = GraphPlan::builder("openCypher")
.push_op(GraphOp::Exists {
child: Box::new(
GraphPlan::builder("openCypher")
.push_op(GraphOp::Union {
all: true,
inputs: vec![],
})
.build(),
),
negated: false,
})
.build();
assert!(
lowerer
.lower_plan(&no_alternatives)
.unwrap_err()
.to_string()
.contains("no alternatives")
);
let uncorrelated = GraphPlan::builder("openCypher")
.push_op(GraphOp::Exists {
child: Box::new(GraphPlan::builder("openCypher").build()),
negated: true,
})
.build();
assert!(
lowerer
.lower_plan(&uncorrelated)
.unwrap_err()
.to_string()
.contains("share at least one bound variable")
);
let empty_comprehension = GraphPlan::builder("openCypher")
.push_op(GraphOp::PatternComprehension {
child: Box::new(GraphPlan::builder("openCypher").build()),
output: VarId(10),
})
.build();
assert!(
lowerer
.lower_plan(&empty_comprehension)
.unwrap_err()
.to_string()
.contains("child is empty")
);
let wrong_terminal = GraphPlan::builder("openCypher")
.push_op(GraphOp::PatternComprehension {
child: Box::new(
GraphPlan::builder("openCypher")
.push_op(GraphOp::Limit { count: 1 })
.build(),
),
output: VarId(10),
})
.build();
assert!(
lowerer
.lower_plan(&wrong_terminal)
.unwrap_err()
.to_string()
.contains("must end in a value projection")
);
}
#[test]
fn pattern_comprehension_projection_contract_is_strict() {
let lowerer = GraphPlanLowerer::new(None, None);
let cases = [
(
true,
PATTERN_COMPREHENSION_VALUE_ALIAS,
"exactly one non-distinct",
),
(false, "wrong_alias", "invalid value projection"),
];
for (distinct, alias, expected) in cases {
let mut child = GraphPlan::builder("openCypher");
let value = child.push_expr(IrExpr::Literal(IrLiteral::Int(1)));
child.push_op_mut(GraphOp::Project {
items: vec![ProjectItem {
expr: value,
alias: Some(alias.into()),
out_var: None,
}],
distinct,
});
let plan = GraphPlan::builder("openCypher")
.push_op(GraphOp::PatternComprehension {
child: Box::new(child.build()),
output: VarId(11),
})
.build();
assert!(
lowerer
.lower_plan(&plan)
.unwrap_err()
.to_string()
.contains(expected)
);
}
}
#[test]
fn terminal_suffix_uses_supplied_schema_and_preserves_scope() {
use datafusion::arrow::datatypes::{DataType, Field, Schema};
use datafusion::common::DFSchema;
let lowerer = GraphPlanLowerer::new(None, None);
let schema = Arc::new(
DFSchema::try_from(Schema::new(vec![Field::new(
"seed",
DataType::Int64,
false,
)]))
.unwrap(),
);
let mut arena = ExprArena::new();
let literal = arena.push(IrExpr::Literal(IrLiteral::Int(9)));
let mut vars = VarMap::new();
vars.insert(VarId(4), "seed");
let suffix = lowerer
.lower_terminal_suffix(
&[GraphOp::Project {
items: vec![ProjectItem {
expr: literal,
alias: Some("answer".into()),
out_var: Some(VarId(5)),
}],
distinct: false,
}],
&arena,
&mut vars,
schema,
)
.unwrap();
assert!(matches!(suffix, DfLogicalPlan::Projection(_)));
assert_eq!(vars.get(VarId(5)), Some("answer"));
}
#[test]
fn lower_plan_empty_ops_succeeds() {
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let plan = GraphPlan::builder("openCypher").build();
let result = lowerer.lower_plan(&plan);
assert!(result.is_ok(), "empty op pipeline should succeed");
}
#[test]
fn union_requires_two_branch_plans() {
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let plan = GraphPlan::builder("openCypher")
.push_op(GraphOp::Union {
all: true,
inputs: vec![GraphPlan::builder("openCypher").build()],
})
.build();
let error = lowerer
.lower_plan(&plan)
.expect_err("one branch is invalid");
assert!(error.to_string().contains("at least two branch plans"));
}
#[test]
fn integration_filter_project_limit_pipeline() {
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let mut builder = GraphPlan::builder("openCypher");
let pred = builder.push_expr(IrExpr::Literal(IrLiteral::Bool(true)));
let col_expr = builder.push_expr(IrExpr::Literal(IrLiteral::Str("n".into())));
let plan = builder
.push_op(GraphOp::Filter { predicate: pred })
.push_op(GraphOp::Project {
items: vec![ProjectItem {
expr: col_expr,
alias: Some("name".into()),
out_var: None,
}],
distinct: false,
})
.push_op(GraphOp::Limit { count: 10 })
.build();
let lp = lowerer.lower_plan(&plan).unwrap();
assert!(
matches!(lp, DfLogicalPlan::Limit(_)),
"expected Limit at top, got {lp:?}"
);
}
#[test]
fn node_scan_no_type_produces_table_scan() {
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let plan = GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.build();
let lp = lowerer.lower_plan(&plan).unwrap();
assert!(
matches!(lp, DfLogicalPlan::TableScan(_)),
"expected TableScan, got {lp:?}"
);
}
#[test]
fn node_scan_with_type_produces_filter_over_scan() {
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let plan = GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: Some(TypeId(1)),
})
.build();
let lp = lowerer.lower_plan(&plan).unwrap();
assert!(
matches!(lp, DfLogicalPlan::Filter(_)),
"expected Filter over TableScan, got {lp:?}"
);
}
#[test]
fn typed_edge_scan_unknown_type_id_returns_error() {
let lowerer = GraphPlanLowerer::new(None, None);
let plan = GraphPlan::builder("openCypher")
.push_op(GraphOp::TypedEdgeScan {
var: VarId(0),
rel_ty: TypeId(42),
})
.build();
let result = lowerer.lower_plan(&plan);
assert!(
result.is_err(),
"unknown TypeId should return an error, not silently fall back"
);
}
#[test]
fn edge_scan_wildcard_produces_table_scan() {
let lowerer = GraphPlanLowerer::new(None, None);
let plan = GraphPlan::builder("openCypher")
.push_op(GraphOp::EdgeScan {
var: VarId(0),
ty: None,
})
.build();
let lp = lowerer.lower_plan(&plan).unwrap();
assert!(
matches!(lp, DfLogicalPlan::TableScan(_)),
"expected TableScan, got {lp:?}"
);
}
fn var_len_plan() -> GraphPlan {
GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.push_op(GraphOp::Expand {
src: VarId(0),
edge: VarId(1),
dst: VarId(2),
rel_ty: None,
dir: Direction::Out,
min_hops: 1,
max_hops: Some(3), })
.build()
}
#[test]
fn expand_var_len_produces_extension_node() {
use datafusion::logical_expr::UserDefinedLogicalNodeCore;
use graphforge_plan::VarLenExpandNode;
let (dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer =
GraphPlanLowerer::new_with_dir(Some(&catalog), None, dir.path(), OntologyMode::Strict);
let lp = lowerer.lower_plan(&var_len_plan()).unwrap();
let DfLogicalPlan::Extension(ext) = &lp else {
panic!("expected Extension (VarLenExpandNode), got {lp:?}");
};
let node = ext
.node
.as_any()
.downcast_ref::<VarLenExpandNode>()
.expect("VarLenExpandNode");
assert_eq!(node.src_var, 0);
assert_eq!(node.dst_var, 2);
assert_eq!(node.direction, Direction::Out);
assert_eq!(node.min_hops, 1);
assert_eq!(node.max_hops, Some(3));
assert_eq!(node.dir, dir.path());
assert_eq!(node.mode, OntologyMode::Strict);
let schema = UserDefinedLogicalNodeCore::schema(node);
let dst = datafusion::common::TableReference::bare("var_2");
assert!(schema.field_with_qualified_name(&dst, "node_id").is_ok());
}
#[test]
fn expand_var_len_without_dir_errors() {
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let err = lowerer.lower_plan(&var_len_plan()).unwrap_err();
assert!(
err.to_string().contains("project directory"),
"expected a project-directory error, got: {err}"
);
}
#[test]
fn unwind_produces_extension_node() {
let lowerer = GraphPlanLowerer::new(None, None);
let mut builder = GraphPlan::builder("openCypher");
let list_expr = builder.push_expr(IrExpr::Literal(IrLiteral::Int(1)));
let plan = builder
.push_op(GraphOp::Unwind {
list_expr,
alias: VarId(0),
})
.build();
let lp = lowerer.lower_plan(&plan).unwrap();
assert!(
matches!(lp, DfLogicalPlan::Extension(_)),
"expected Extension (UnwindNode), got {lp:?}"
);
}
#[test]
fn optional_produces_extension_node() {
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let child = GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(1),
ty: None,
})
.build();
let plan = GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.push_op(GraphOp::Optional {
child: Box::new(child),
})
.build();
let lp = lowerer.lower_plan(&plan).unwrap();
assert!(
matches!(lp, DfLogicalPlan::Extension(_)),
"expected Extension (OptionalMatchNode), got {lp:?}"
);
}
#[test]
fn optional_node_output_schema_appends_nullable_inner() {
use datafusion::logical_expr::UserDefinedLogicalNodeCore;
use graphforge_plan::OptionalMatchNode;
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let child = GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(1),
ty: None,
})
.build();
let plan = GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.push_op(GraphOp::Optional {
child: Box::new(child),
})
.build();
let lp = lowerer.lower_plan(&plan).unwrap();
let DfLogicalPlan::Extension(ext) = &lp else {
panic!("expected Extension (OptionalMatchNode), got {lp:?}");
};
let node = ext
.node
.as_any()
.downcast_ref::<OptionalMatchNode>()
.expect("OptionalMatchNode");
let schema = UserDefinedLogicalNodeCore::schema(node);
assert_eq!(schema.fields().len(), 12, "outer(6) + inner(6)");
for i in 0..6 {
assert!(
!schema.field(i).is_nullable(),
"outer col {i} stays non-null"
);
}
for i in 6..12 {
assert!(
schema.field(i).is_nullable(),
"inner col {i} must be nullable for null-shaping"
);
}
}
#[test]
fn expand_single_hop_out_produces_join() {
let lowerer = GraphPlanLowerer::new(None, None);
let plan = GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.push_op(GraphOp::Expand {
src: VarId(0),
edge: VarId(1),
dst: VarId(2),
rel_ty: None,
dir: Direction::Out,
min_hops: 1,
max_hops: Some(1),
})
.build();
let lp = lowerer.lower_plan(&plan).unwrap();
assert!(
matches!(lp, DfLogicalPlan::Join(_)),
"expected Join, got {lp:?}"
);
}
#[test]
fn fixed_hop_dst_label_is_preserved_as_filter() {
let lowerer = GraphPlanLowerer::new(None, None);
let plan = GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.push_op(GraphOp::Expand {
src: VarId(0),
edge: VarId(1),
dst: VarId(2),
rel_ty: None,
dir: Direction::Out,
min_hops: 1,
max_hops: Some(1),
})
.push_op(GraphOp::NodeScan {
var: VarId(2),
ty: Some(TypeId(7)),
})
.build();
let lp = lowerer.lower_plan(&plan).unwrap();
let rendered = lp.display_indent_schema().to_string();
assert!(
rendered.contains("array_has(var_2.type_ids, UInt32(7))"),
"destination label filter must be applied, got:\n{rendered}"
);
}
#[test]
fn optional_child_with_shared_var_excludes_outer_columns() {
use datafusion::logical_expr::UserDefinedLogicalNodeCore;
use graphforge_plan::OptionalMatchNode;
let (_dir, catalog, _rc) = make_catalog_and_lowerer();
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let child = GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.push_op(GraphOp::Expand {
src: VarId(0),
edge: VarId(1),
dst: VarId(2),
rel_ty: None,
dir: Direction::Out,
min_hops: 1,
max_hops: Some(1),
})
.build();
let plan = GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.push_op(GraphOp::Optional {
child: Box::new(child),
})
.build();
let lp = lowerer.lower_plan(&plan).unwrap();
let DfLogicalPlan::Extension(ext) = &lp else {
panic!("expected Extension (OptionalMatchNode), got {lp:?}");
};
let node = ext
.node
.as_any()
.downcast_ref::<OptionalMatchNode>()
.expect("OptionalMatchNode");
assert_eq!(node.join_keys.len(), 1, "var_0 is the shared join key");
let schema = UserDefinedLogicalNodeCore::schema(node);
assert_eq!(schema.fields().len(), 20, "no duplicate var_0 columns");
}
fn create_plan_with_props() -> GraphPlan {
use graphforge_ir::{CreateNodeSpec, CreatePattern};
let mut builder = GraphPlan::builder("openCypher");
let name_lit = builder.push_expr(IrExpr::Literal(IrLiteral::Str("Alice".into())));
let map = builder.push_expr(IrExpr::MapLiteral(vec![("name".into(), name_lit)]));
builder
.push_op(GraphOp::Create {
pattern: CreatePattern {
nodes: vec![CreateNodeSpec {
var: VarId(0),
labels: vec![TypeId(0)],
properties: Some(map),
is_reference: false,
}],
edges: vec![],
},
})
.build()
}
#[test]
fn create_lowers_to_extension_with_write_target() {
let dir = tempfile::TempDir::new().unwrap();
let lowerer = GraphPlanLowerer::new_for_writes(
None,
None,
dir.path(),
graphforge_core::OntologyMode::Exploratory,
);
let plan = create_plan_with_props();
let lp = lowerer.lower_plan(&plan).unwrap();
assert!(
matches!(lp, DfLogicalPlan::Extension(_)),
"expected Extension (GraphCreateNode), got {lp:?}"
);
}
#[test]
fn create_followed_by_read_emits_created_rows_for_real_pipeline() {
use graphforge_ir::CreateNodeSpec;
use graphforge_plan::GraphCreateNode;
let dir = tempfile::TempDir::new().unwrap();
let lowerer = GraphPlanLowerer::new_for_writes(
None,
None,
dir.path(),
graphforge_core::OntologyMode::Exploratory,
);
let mut builder = GraphPlan::builder("openCypher");
let returned = builder.push_expr(IrExpr::VarRef(VarId(0)));
let plan = builder
.push_op(GraphOp::Create {
pattern: CreatePattern {
nodes: vec![CreateNodeSpec {
var: VarId(0),
labels: vec![],
properties: None,
is_reference: false,
}],
edges: vec![],
},
})
.push_op(GraphOp::Project {
items: vec![ProjectItem {
expr: returned,
alias: Some("created".into()),
out_var: None,
}],
distinct: false,
})
.build();
let lowered = lowerer.lower_plan(&plan).unwrap();
let DfLogicalPlan::Projection(project) = lowered else {
panic!("expected trailing projection");
};
let DfLogicalPlan::Extension(create) = project.input.as_ref() else {
panic!("expected emitting CREATE input");
};
let create = create
.node
.as_any()
.downcast_ref::<GraphCreateNode>()
.expect("GraphCreateNode");
assert!(
!datafusion::logical_expr::UserDefinedLogicalNodeCore::schema(create)
.fields()
.is_empty()
);
}
#[test]
fn create_without_write_target_errors() {
let lowerer = GraphPlanLowerer::new(None, None);
let plan = create_plan_with_props();
let result = lowerer.lower_plan(&plan);
assert!(
result.is_err(),
"CREATE without a write target should error"
);
}
#[test]
fn new_with_dir_does_not_authorize_writes() {
let dir = tempfile::TempDir::new().unwrap();
let lowerer = GraphPlanLowerer::new_with_dir(
None,
None,
dir.path(),
graphforge_core::OntologyMode::Exploratory,
);
let result = lowerer.lower_plan(&create_plan_with_props());
assert!(
result.is_err(),
"new_with_dir must not authorize CREATE; only new_for_writes does"
);
}
#[test]
fn create_non_literal_property_lowers_as_computed() {
use graphforge_ir::{CreateNodeSpec, CreatePattern};
let dir = tempfile::TempDir::new().unwrap();
let lowerer = GraphPlanLowerer::new_for_writes(
None,
None,
dir.path(),
graphforge_core::OntologyMode::Exploratory,
);
let mut builder = GraphPlan::builder("openCypher");
let param = builder.push_expr(IrExpr::Parameter("p".into()));
let map = builder.push_expr(IrExpr::MapLiteral(vec![("name".into(), param)]));
let plan = builder
.push_op(GraphOp::Create {
pattern: CreatePattern {
nodes: vec![CreateNodeSpec {
var: VarId(0),
labels: vec![],
properties: Some(map),
is_reference: false,
}],
edges: vec![],
},
})
.build();
let logical = lowerer
.lower_plan(&plan)
.expect("a non-literal CREATE property lowers to a computed expr");
let datafusion::logical_expr::LogicalPlan::Extension(ext) = &logical else {
panic!("CREATE lowers to an Extension node");
};
let create = ext
.node
.as_any()
.downcast_ref::<graphforge_plan::GraphCreateNode>()
.expect("a GraphCreateNode");
assert!(
create.nodes[0].properties.is_empty(),
"the parameter value is not a baked literal"
);
assert_eq!(
create.nodes[0].computed_properties.len(),
1,
"the parameter value is a row-dependent computed property"
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn created_rows_schema_preserves_input_skips_references_and_types_minted_nodes() {
use std::collections::HashMap;
use datafusion::arrow::datatypes::{DataType, Field};
use datafusion::common::{DFSchema, TableReference};
use datafusion::logical_expr::{col, lit};
use graphforge_plan::ResolvedNodeSpec;
let input = Arc::new(
DFSchema::new_with_metadata(
vec![(
Some(TableReference::bare("input")),
Arc::new(Field::new("seed", DataType::Int64, false)),
)],
HashMap::new(),
)
.unwrap(),
);
let reference = ResolvedNodeSpec {
var: 1,
label_ids: vec![7],
label_names: vec!["Existing".into()],
properties: vec![("ignored".into(), IrLiteral::Int(1))],
computed_properties: vec![],
is_reference: true,
};
let minted = ResolvedNodeSpec {
var: 2,
label_ids: vec![8, 9],
label_names: vec!["New".into(), "Tagged".into()],
properties: vec![("active".into(), IrLiteral::Bool(true))],
computed_properties: vec![("copied_seed".into(), col("seed") + lit(1_i64))],
is_reference: false,
};
let schema = GraphPlanLowerer::created_rows_schema(&[reference, minted], &input).unwrap();
let fields: Vec<_> = schema
.iter()
.map(|(qualifier, field)| {
(
qualifier.map(ToString::to_string),
field.name().clone(),
field.data_type().clone(),
field.is_nullable(),
)
})
.collect();
assert_eq!(
fields.len(),
7,
"one input plus four identity and two property fields"
);
assert_eq!(
fields[0],
(Some("input".into()), "seed".into(), DataType::Int64, false)
);
assert_eq!(
fields[1..5]
.iter()
.map(|(q, name, ty, nullable)| { (q.clone(), name.clone(), ty.clone(), *nullable) })
.collect::<Vec<_>>(),
vec![
(
Some("var_2".into()),
"node_uuid".into(),
DataType::FixedSizeBinary(16),
false
),
(
Some("var_2".into()),
"node_id".into(),
DataType::UInt64,
false,
),
(
Some("var_2".into()),
"type_id".into(),
DataType::UInt32,
false,
),
(
Some("var_2".into()),
"type_ids".into(),
DataType::List(Arc::new(Field::new("item", DataType::UInt32, false))),
false,
),
]
);
assert_eq!(
fields[5],
(
Some("var_2".into()),
"active".into(),
DataType::Boolean,
true
)
);
assert_eq!(
fields[6],
(
Some("var_2".into()),
"copied_seed".into(),
DataType::Int64,
true,
)
);
assert!(
fields
.iter()
.all(|(q, _, _, _)| q.as_deref() != Some("var_1")),
"reference nodes are passed through only and never duplicated"
);
}
#[test]
fn created_rows_schema_rejects_reserved_and_unbound_computed_properties() {
use graphforge_plan::ResolvedNodeSpec;
let input = Arc::new(datafusion::common::DFSchema::empty());
for reserved in ["node_uuid", "node_id", "type_id", "type_ids"] {
let spec = ResolvedNodeSpec {
var: 3,
label_ids: vec![],
label_names: vec![],
properties: vec![(reserved.into(), IrLiteral::Null)],
computed_properties: vec![],
is_reference: false,
};
let error = GraphPlanLowerer::created_rows_schema(&[spec], &input).unwrap_err();
assert_eq!(
error.to_string(),
format!(
"unsupported expression: CREATE property `{reserved}` collides with a reserved node topology field"
)
);
}
let unbound = ResolvedNodeSpec {
var: 4,
label_ids: vec![],
label_names: vec![],
properties: vec![],
computed_properties: vec![("value".into(), datafusion::logical_expr::col("missing"))],
is_reference: false,
};
let error = GraphPlanLowerer::created_rows_schema(&[unbound], &input).unwrap_err();
assert!(
error.to_string().contains("No field named missing"),
"unbound computed properties must retain the DataFusion schema error: {error}"
);
}
fn match_delete_plan(detach: bool) -> GraphPlan {
GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.push_op(GraphOp::Delete {
vars: vec![VarId(0)],
exprs: vec![],
detach,
})
.build()
}
#[test]
fn delete_lowers_to_extension_with_write_target() {
let dir = tempfile::TempDir::new().unwrap();
let rc = graphforge_ir::RuntimeCatalog::new();
let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
let lowerer = GraphPlanLowerer::new_for_writes(
Some(&catalog),
None,
dir.path(),
graphforge_core::OntologyMode::Exploratory,
);
let lp = lowerer.lower_plan(&match_delete_plan(false)).unwrap();
assert!(
matches!(lp, DfLogicalPlan::Extension(_)),
"expected Extension (GraphDeleteNode), got {lp:?}"
);
}
#[test]
fn delete_without_write_target_errors() {
let dir = tempfile::TempDir::new().unwrap();
let rc = graphforge_ir::RuntimeCatalog::new();
let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
let lowerer = GraphPlanLowerer::new_with_dir(
Some(&catalog),
None,
dir.path(),
graphforge_core::OntologyMode::Exploratory,
);
assert!(
lowerer.lower_plan(&match_delete_plan(false)).is_err(),
"DELETE without a write target should error"
);
}
#[test]
fn delete_edge_variable_resolves_is_edge_flag() {
use graphforge_plan::GraphDeleteNode;
let dir = tempfile::TempDir::new().unwrap();
let rc = graphforge_ir::RuntimeCatalog::new();
let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
let lowerer = GraphPlanLowerer::new_for_writes(
Some(&catalog),
None,
dir.path(),
graphforge_core::OntologyMode::Exploratory,
);
let plan = GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.push_op(GraphOp::Expand {
src: VarId(0),
edge: VarId(1),
dst: VarId(2),
rel_ty: None,
dir: Direction::Out,
min_hops: 1,
max_hops: Some(1),
})
.push_op(GraphOp::Delete {
vars: vec![VarId(1)],
exprs: vec![],
detach: false,
})
.build();
let lp = lowerer.lower_plan(&plan).unwrap();
let DfLogicalPlan::Extension(ext) = &lp else {
panic!("expected Extension (GraphDeleteNode), got {lp:?}");
};
let node = ext
.node
.as_any()
.downcast_ref::<GraphDeleteNode>()
.expect("GraphDeleteNode");
assert_eq!(node.targets.len(), 1, "one delete target");
assert_eq!(node.targets[0].var, 1);
assert!(
node.targets[0].is_edge,
"the edge var must resolve to is_edge=true"
);
}
#[test]
fn write_kind_resolution_covers_node_typed_edge_and_invalid_targets() {
use std::collections::HashMap;
use datafusion::arrow::datatypes::{DataType, Field};
use datafusion::common::{DFSchema, TableReference};
let schema = |names: &[&str]| {
Arc::new(
DFSchema::new_with_metadata(
names
.iter()
.map(|name| {
(
Some(TableReference::bare("var_7")),
Arc::new(Field::new(*name, DataType::Utf8, true)),
)
})
.collect(),
HashMap::new(),
)
.unwrap(),
)
};
assert!(
!GraphPlanLowerer::resolve_write_kind(&schema(&["node_uuid"]), VarId(7), "SET")
.unwrap()
);
assert!(
GraphPlanLowerer::resolve_write_kind(
&schema(&["edge_uuid", "rel_type_name"]),
VarId(7),
"REMOVE"
)
.unwrap()
);
let untyped_edge =
GraphPlanLowerer::resolve_write_kind(&schema(&["edge_uuid"]), VarId(7), "SET")
.unwrap_err();
assert!(untyped_edge.to_string().contains("known relation type"));
let unbound =
GraphPlanLowerer::resolve_write_kind(&schema(&[]), VarId(7), "REMOVE").unwrap_err();
assert!(unbound.to_string().contains("must be bound"));
assert!(
!GraphPlanLowerer::resolve_write_kind(
&schema(&["node_uuid", "edge_uuid", "rel_type_name"]),
VarId(7),
"SET"
)
.unwrap()
);
}
fn writes_lowerer<'a>(
catalog: &'a graphforge_storage::GraphCatalog,
dir: &'a std::path::Path,
) -> GraphPlanLowerer<'a> {
GraphPlanLowerer::new_for_writes(
Some(catalog),
None,
dir,
graphforge_core::OntologyMode::Exploratory,
)
}
#[test]
fn set_literal_value_lowers_to_extension() {
use graphforge_ir::SetPropItem;
let dir = tempfile::TempDir::new().unwrap();
let rc = graphforge_ir::RuntimeCatalog::new();
let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
let lowerer = writes_lowerer(&catalog, dir.path());
let mut builder = GraphPlan::builder("openCypher");
let value = builder.push_expr(IrExpr::Literal(IrLiteral::Int(42)));
let plan = builder
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.push_op(GraphOp::Set {
items: vec![SetPropItem {
target: VarId(0),
prop: graphforge_core::PropId(0),
prop_name: "age".into(),
value,
}],
map_items: vec![],
label_items: vec![],
})
.build();
let lp = lowerer.lower_plan(&plan).unwrap();
let DfLogicalPlan::Extension(ext) = &lp else {
panic!("expected Extension (GraphSetNode), got {lp:?}");
};
let node = ext.node.as_any().downcast_ref::<GraphSetNode>().unwrap();
assert_eq!(node.targets.len(), 1);
assert_eq!(node.targets[0].prop_name, "age");
assert!(!node.targets[0].is_edge, "node target");
}
#[test]
fn set_runtime_expr_value_lowers_against_input_schema() {
use graphforge_ir::SetPropItem;
let dir = tempfile::TempDir::new().unwrap();
let rc = graphforge_ir::RuntimeCatalog::new();
let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
let lowerer = writes_lowerer(&catalog, dir.path());
let mut builder = GraphPlan::builder("openCypher");
let var = builder.push_expr(IrExpr::VarRef(VarId(0)));
let age = builder.push_expr(IrExpr::PropertyAccess {
base: var,
prop: graphforge_core::PropId(0),
});
let one = builder.push_expr(IrExpr::Literal(IrLiteral::Int(1)));
let sum = builder.push_expr(IrExpr::BinaryOp {
op: graphforge_ir::expr::BinaryOpKind::Add,
left: age,
right: one,
});
let plan = builder
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.push_op(GraphOp::Set {
items: vec![SetPropItem {
target: VarId(0),
prop: graphforge_core::PropId(0),
prop_name: "age".into(),
value: sum,
}],
map_items: vec![],
label_items: vec![],
})
.build();
let lp = lowerer.lower_plan(&plan).unwrap();
assert!(matches!(lp, DfLogicalPlan::Extension(_)));
}
#[test]
fn remove_lowers_to_extension() {
use graphforge_ir::RemovePropItem;
let dir = tempfile::TempDir::new().unwrap();
let rc = graphforge_ir::RuntimeCatalog::new();
let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
let lowerer = writes_lowerer(&catalog, dir.path());
let plan = GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.push_op(GraphOp::Remove {
items: vec![RemovePropItem {
target: VarId(0),
prop: graphforge_core::PropId(0),
prop_name: "age".into(),
}],
label_items: vec![],
})
.build();
let lp = lowerer.lower_plan(&plan).unwrap();
let DfLogicalPlan::Extension(ext) = &lp else {
panic!("expected Extension (GraphRemoveNode), got {lp:?}");
};
let node = ext.node.as_any().downcast_ref::<GraphRemoveNode>().unwrap();
assert_eq!(node.targets.len(), 1);
assert_eq!(node.targets[0].prop_name, "age");
}
#[test]
fn set_without_write_target_errors() {
use graphforge_ir::SetPropItem;
let dir = tempfile::TempDir::new().unwrap();
let rc = graphforge_ir::RuntimeCatalog::new();
let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
let lowerer = GraphPlanLowerer::new_with_dir(
Some(&catalog),
None,
dir.path(),
graphforge_core::OntologyMode::Exploratory,
);
let mut builder = GraphPlan::builder("openCypher");
let value = builder.push_expr(IrExpr::Literal(IrLiteral::Int(1)));
let plan = builder
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.push_op(GraphOp::Set {
items: vec![SetPropItem {
target: VarId(0),
prop: graphforge_core::PropId(0),
prop_name: "age".into(),
value,
}],
map_items: vec![],
label_items: vec![],
})
.build();
assert!(
lowerer.lower_plan(&plan).is_err(),
"SET without a write target should error"
);
}
fn typed_single_hop_fixture(
dir: Direction,
) -> (
tempfile::TempDir,
graphforge_storage::GraphCatalog,
GraphPlan,
) {
let tmp = tempfile::TempDir::new().unwrap();
let mut rc = graphforge_ir::RuntimeCatalog::new();
let rel = graphforge_ir::runtime_relation_type_id(rc.intern_relation_type("KNOWS"));
let catalog = graphforge_storage::GraphCatalog::open(tmp.path(), None, &rc).unwrap();
let plan = GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.push_op(GraphOp::Expand {
src: VarId(0),
edge: VarId(1),
dst: VarId(2),
rel_ty: Some(rel),
dir,
min_hops: 1,
max_hops: Some(1),
})
.build();
(tmp, catalog, plan)
}
#[test]
fn project_backed_single_hop_emits_expand_extension_node() {
use datafusion::logical_expr::UserDefinedLogicalNodeCore;
let (tmp, catalog, plan) = typed_single_hop_fixture(Direction::Out);
let lowerer =
GraphPlanLowerer::new_with_dir(Some(&catalog), None, tmp.path(), OntologyMode::Strict);
let lp = lowerer.lower_plan(&plan).unwrap();
let DfLogicalPlan::Extension(ext) = &lp else {
panic!("expected Extension (ExpandNode), got {lp:?}");
};
let node = ext
.node
.as_any()
.downcast_ref::<graphforge_plan::ExpandNode>()
.expect("ExpandNode");
assert_eq!(node.rel_type_name, "KNOWS");
assert_eq!(node.src_var, 0);
assert_eq!(node.edge_var, 1);
assert_eq!(node.dst_var, 2);
assert_eq!(node.direction, Direction::Out);
assert_eq!(node.mode, OntologyMode::Strict);
assert_eq!(node.edge_prop_count, 0, "no edge_properties file on disk");
let schema = UserDefinedLogicalNodeCore::schema(node);
let edge = datafusion::common::TableReference::bare("var_1");
let dst = datafusion::common::TableReference::bare("var_2");
for name in ["edge_uuid", "src_id", "dst_id", "edge_id"] {
assert!(
schema.field_with_qualified_name(&edge, name).is_ok(),
"{name}"
);
}
assert!(schema.field_with_qualified_name(&dst, "node_id").is_ok());
}
#[test]
fn project_backed_undirected_single_hop_emits_plain_extension() {
let (tmp, catalog, plan) = typed_single_hop_fixture(Direction::Undirected);
let lowerer =
GraphPlanLowerer::new_with_dir(Some(&catalog), None, tmp.path(), OntologyMode::Strict);
let lp = lowerer.lower_plan(&plan).unwrap();
let DfLogicalPlan::Extension(ext) = &lp else {
panic!("expected Extension (ExpandNode), got {lp:?}");
};
let node = ext
.node
.as_any()
.downcast_ref::<graphforge_plan::ExpandNode>()
.expect("ExpandNode");
assert_eq!(node.direction, Direction::Undirected);
}
#[test]
fn schema_only_single_hop_keeps_join_path() {
let (_tmp, catalog, plan) = typed_single_hop_fixture(Direction::Out);
let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
let lp = lowerer.lower_plan(&plan).unwrap();
assert!(
matches!(lp, DfLogicalPlan::Join(_)),
"expected Join chain, got {lp:?}"
);
}
#[test]
fn exploratory_mode_emits_expand_with_dynamic_edge_schema() {
use datafusion::logical_expr::UserDefinedLogicalNodeCore;
let (tmp, catalog, plan) = typed_single_hop_fixture(Direction::Out);
let lowerer = GraphPlanLowerer::new_with_dir(
Some(&catalog),
None,
tmp.path(),
OntologyMode::Exploratory,
);
let lp = lowerer.lower_plan(&plan).unwrap();
let DfLogicalPlan::Extension(ext) = &lp else {
panic!("expected exploratory ExpandNode, got {lp:?}");
};
let node = ext
.node
.as_any()
.downcast_ref::<graphforge_plan::ExpandNode>()
.expect("ExpandNode");
let edge = datafusion::common::TableReference::bare("var_1");
assert!(
UserDefinedLogicalNodeCore::schema(node)
.field_with_qualified_name(&edge, "rel_type_name")
.is_ok()
);
}
#[test]
fn wildcard_single_hop_emits_expand_extension() {
let tmp = tempfile::TempDir::new().unwrap();
let rc = graphforge_ir::RuntimeCatalog::new();
let catalog = graphforge_storage::GraphCatalog::open(tmp.path(), None, &rc).unwrap();
let plan = GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.push_op(GraphOp::Expand {
src: VarId(0),
edge: VarId(1),
dst: VarId(2),
rel_ty: None,
dir: Direction::Out,
min_hops: 1,
max_hops: Some(1),
})
.build();
let lowerer =
GraphPlanLowerer::new_with_dir(Some(&catalog), None, tmp.path(), OntologyMode::Strict);
let lp = lowerer.lower_plan(&plan).unwrap();
let DfLogicalPlan::Extension(ext) = &lp else {
panic!("expected wildcard ExpandNode, got {lp:?}");
};
let node = ext
.node
.as_any()
.downcast_ref::<graphforge_plan::ExpandNode>()
.expect("ExpandNode");
assert_eq!(node.rel_type_name, "*");
assert_eq!(node.rel_ty, None);
}
#[test]
fn unbound_source_errors_on_provider_path() {
let tmp = tempfile::TempDir::new().unwrap();
let mut rc = graphforge_ir::RuntimeCatalog::new();
let rel = graphforge_ir::runtime_relation_type_id(rc.intern_relation_type("KNOWS"));
let catalog = graphforge_storage::GraphCatalog::open(tmp.path(), None, &rc).unwrap();
let plan = GraphPlan::builder("openCypher")
.push_op(GraphOp::Expand {
src: VarId(0),
edge: VarId(1),
dst: VarId(2),
rel_ty: Some(rel),
dir: Direction::Out,
min_hops: 1,
max_hops: Some(1),
})
.build();
let lowerer =
GraphPlanLowerer::new_with_dir(Some(&catalog), None, tmp.path(), OntologyMode::Strict);
let err = lowerer.lower_plan(&plan).unwrap_err();
assert!(
err.to_string().contains("unbound") || err.to_string().contains("Unbound"),
"expected an unbound-variable error, got {err:?}"
);
}
#[test]
fn pure_lowering_helpers_cover_constants_collections_and_aggregate_contracts() {
use datafusion::arrow::datatypes::{DataType, Field, Schema};
use datafusion::common::DFSchema;
use datafusion::logical_expr::expr_fn::col;
use datafusion::logical_expr::{Operator, lit};
use datafusion::scalar::ScalarValue;
assert_eq!(
const_eval_scalar(&lit(ScalarValue::Int64(Some(7)))),
Some(ScalarValue::Int64(Some(7)))
);
assert_eq!(
const_eval_scalar(&DfExpr::BinaryExpr(
datafusion::logical_expr::BinaryExpr::new(
Box::new(lit(ScalarValue::Int64(Some(2)))),
Operator::Plus,
Box::new(lit(ScalarValue::Int64(Some(3)))),
)
)),
Some(ScalarValue::Int64(Some(5)))
);
assert_eq!(const_eval_scalar(&col("missing")), None);
for literal in [
IrLiteral::Null,
IrLiteral::Int(1),
IrLiteral::List(vec![IrLiteral::Int(1)]),
] {
assert!(!contains_map_literal(&literal));
reject_map_property_value("safe", &literal).unwrap();
}
for literal in [
IrLiteral::Map(vec![]),
IrLiteral::List(vec![IrLiteral::Map(vec![])]),
] {
assert!(contains_map_literal(&literal));
assert!(
reject_map_property_value("nested", &literal)
.unwrap_err()
.to_string()
.contains("cannot store map values")
);
}
assert_eq!(var_alias(VarId(42)), "var_42");
assert!(build_type_id_map(None).is_empty());
assert!(build_entity_id_map(None).is_empty());
assert!(build_inference_rules(None).is_empty());
let schema = Arc::new(
DFSchema::try_from(Schema::new(vec![
Field::new("small", DataType::new_list(DataType::Utf8, true), true),
Field::new(
"large",
DataType::new_large_list(DataType::Int32, true),
true,
),
Field::new(
"fixed",
DataType::FixedSizeList(
Arc::new(Field::new_list_field(DataType::Boolean, true)),
2,
),
true,
),
]))
.unwrap(),
);
for (name, expected) in [
("small", DataType::Utf8),
("large", DataType::Int32),
("fixed", DataType::Boolean),
] {
assert_eq!(
unwind_element_field(&col(name), &schema, std::iter::empty::<&str>()).data_type(),
&expected
);
}
let unknown = unwind_element_field(&col("unknown"), &schema, ["z", "a", "a"]);
let DataType::Struct(fields) = unknown.data_type() else {
panic!("unknown typed UNWIND must expose a map-shaped element")
};
assert_eq!(
fields.iter().map(|f| f.name().as_str()).collect::<Vec<_>>(),
["a", "z"]
);
let value = Some(col("value"));
for function in [
AggFunc::Count,
AggFunc::CountDistinct,
AggFunc::Sum,
AggFunc::SumDistinct,
AggFunc::Avg,
AggFunc::AvgDistinct,
AggFunc::Min,
AggFunc::Max,
AggFunc::Collect,
AggFunc::CollectDistinct,
] {
assert!(
lower_agg_func(function, value.clone(), None, Some(&DataType::Int64)).is_ok(),
"{function:?}"
);
}
for function in [AggFunc::PercentileDisc, AggFunc::PercentileCont] {
assert!(
lower_agg_func(
function,
value.clone(),
Some(lit(ScalarValue::Float64(Some(0.5)))),
Some(&DataType::Int64),
)
.is_ok()
);
}
assert!(lower_agg_func(AggFunc::Count, None, None, None).is_ok());
for function in [
AggFunc::CountDistinct,
AggFunc::Sum,
AggFunc::SumDistinct,
AggFunc::Avg,
AggFunc::AvgDistinct,
AggFunc::Min,
AggFunc::Max,
AggFunc::Collect,
AggFunc::CollectDistinct,
AggFunc::PercentileDisc,
AggFunc::PercentileCont,
] {
assert!(
lower_agg_func(function, None, None, None).is_err(),
"{function:?}"
);
}
for function in [AggFunc::Min, AggFunc::Max] {
let heterogeneous =
DataType::Struct(vec![Field::new("__het_tag", DataType::Int8, false)].into());
assert!(
format!(
"{}",
lower_agg_func(function, value.clone(), None, Some(&heterogeneous),).unwrap()
)
.contains("cypher_")
);
}
}
#[test]
fn exact_zero_var_len_unknown_relation_and_directional_join_paths() {
let unknown = GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.push_op(GraphOp::Expand {
src: VarId(0),
edge: VarId(1),
dst: VarId(2),
rel_ty: Some(TypeId(999_999)),
dir: Direction::Out,
min_hops: 1,
max_hops: Some(3),
})
.build();
assert!(
GraphPlanLowerer::new(None, None)
.lower_plan(&unknown)
.unwrap_err()
.to_string()
.contains("has no known relation name")
);
for direction in [Direction::In, Direction::Undirected] {
let plan = GraphPlan::builder("openCypher")
.push_op(GraphOp::NodeScan {
var: VarId(0),
ty: None,
})
.push_op(GraphOp::NodeScan {
var: VarId(2),
ty: None,
})
.push_op(GraphOp::Expand {
src: VarId(0),
edge: VarId(1),
dst: VarId(2),
rel_ty: None,
dir: direction,
min_hops: 1,
max_hops: Some(1),
})
.build();
let lowered = GraphPlanLowerer::new(None, None).lower_plan(&plan).unwrap();
let rendered = lowered.display_indent_schema().to_string();
assert!(rendered.contains("Filter"), "{direction:?}: {rendered}");
}
}
#[test]
fn exact_zero_recursive_plan_reference_and_binding_analysis() {
let wanted = VarId(17);
let other = VarId(18);
let mut referenced_builder = GraphPlan::builder("openCypher");
referenced_builder.push_expr(IrExpr::VarRef(wanted));
let referenced = referenced_builder.build();
let mut unrelated_builder = GraphPlan::builder("openCypher");
unrelated_builder.push_expr(IrExpr::VarRef(other));
let unrelated = unrelated_builder.build();
assert!(plan_references_var(&referenced, wanted));
assert!(!plan_references_var(&unrelated, wanted));
let wrappers = [
GraphOp::Optional {
child: Box::new(referenced.clone()),
},
GraphOp::Exists {
child: Box::new(referenced.clone()),
negated: false,
},
GraphOp::PatternComprehension {
child: Box::new(referenced.clone()),
output: VarId(20),
},
GraphOp::ListElementPatternComprehension {
list_expr: ExprId(0),
loop_var: VarId(21),
child: Box::new(referenced.clone()),
pattern_output: VarId(22),
filter: None,
projection: None,
output: VarId(23),
},
GraphOp::Union {
all: true,
inputs: vec![unrelated.clone(), referenced.clone()],
},
];
for wrapper in wrappers {
let plan = GraphPlan::builder("openCypher").push_op(wrapper).build();
assert!(plan_references_var(&plan, wanted));
}
for op in [
GraphOp::NodeScan {
var: wanted,
ty: None,
},
GraphOp::EdgeScan {
var: wanted,
ty: None,
},
GraphOp::TypedEdgeScan {
var: wanted,
rel_ty: TypeId(1),
},
GraphOp::Expand {
src: other,
edge: wanted,
dst: VarId(19),
rel_ty: None,
dir: Direction::Out,
min_hops: 1,
max_hops: Some(1),
},
] {
assert!(graph_op_binds_var(&op, wanted));
}
assert!(!graph_op_binds_var(
&GraphOp::NodeScan {
var: other,
ty: None,
},
wanted
));
let mut outer = VarMap::new();
outer.insert(wanted, "outer");
assert!(!full_subquery_needs_outer_input(&unrelated, &outer));
assert!(full_subquery_needs_outer_input(&referenced, &outer));
let mut locally_bound_builder = GraphPlan::builder("openCypher");
locally_bound_builder.push_op_mut(GraphOp::NodeScan {
var: wanted,
ty: None,
});
locally_bound_builder.push_expr(IrExpr::VarRef(wanted));
let locally_bound = locally_bound_builder.build();
assert!(!full_subquery_needs_outer_input(&locally_bound, &outer));
}
#[test]
fn exact_zero_optional_scope_promotes_only_inner_entity_shapes() {
use datafusion::arrow::datatypes::{DataType, Field, Schema};
let scan = |alias: &str, field: Field| {
LogicalPlanBuilder::scan(
alias,
table_source(Arc::new(Schema::new(vec![field]))),
None,
)
.and_then(LogicalPlanBuilder::build)
.unwrap()
};
let outer = scan("outer_scalar", Field::new("value", DataType::Int64, true));
let inner = scan("inner_node", Field::new("node_uuid", DataType::Utf8, false));
let promoted = VarId(30);
let absent_outer = VarId(31);
let mut child_vm = VarMap::new();
child_vm.insert(promoted, "inner_node");
child_vm.insert(absent_outer, "inner_node");
let mut outer_vm = VarMap::new();
outer_vm.insert(promoted, "outer_scalar");
promote_optional_entity_vars(&outer, &inner, &child_vm, &mut outer_vm);
assert_eq!(outer_vm.get(promoted), Some("inner_node"));
assert_eq!(outer_vm.get(absent_outer), None);
let already_entity = scan("outer_node", Field::new("node_uuid", DataType::Utf8, false));
outer_vm.insert(promoted, "outer_node");
promote_optional_entity_vars(&already_entity, &inner, &child_vm, &mut outer_vm);
assert_eq!(outer_vm.get(promoted), Some("outer_node"));
}
#[test]
fn exact_zero_validation_errors_are_specific_and_non_interpolated() {
let lowerer = GraphPlanLowerer::new(None, None);
let empty_vm = VarMap::new();
let no_alternatives = lowerer
.lower_exists_alternatives(&[], false, empty_base(), &empty_vm)
.unwrap_err();
assert_eq!(
no_alternatives.to_string(),
"unsupported expression: pattern predicate has no alternatives"
);
let uncorrelated = GraphPlan::builder("openCypher").build();
let uncorrelated_error = lowerer
.lower_exists_alternatives(&[uncorrelated], true, empty_base(), &empty_vm)
.unwrap_err();
assert!(
uncorrelated_error
.to_string()
.contains("must share at least one bound variable")
);
let mut arena = ExprArena::new();
let not_a_map = arena.push(IrExpr::Literal(IrLiteral::Int(1)));
let map_error =
eval_map_literal(&lowerer, Some(not_a_map), &arena, &empty_vm, None).unwrap_err();
assert!(
map_error
.to_string()
.contains("CREATE properties must be a map literal")
);
assert_eq!(
eval_map_literal(&lowerer, None, &arena, &empty_vm, None).unwrap(),
(Vec::new(), Vec::new())
);
let mut edge_vm = VarMap::new();
let bound_error = expand_bound_edge_single_dir(
VarId(2),
Some(TypeId(999_999)),
"var_0",
"var_1",
true,
empty_base(),
&mut edge_vm,
&HashMap::new(),
None,
)
.unwrap_err();
assert!(bound_error.to_string().contains("TypeId(999999)"));
assert!(bound_error.to_string().contains("no known relation name"));
}
#[test]
fn exact_zero_pagination_accepts_platform_boundary_values() {
assert!(matches!(
lower_limit(0, empty_base()).unwrap(),
DfLogicalPlan::Limit(_)
));
assert!(matches!(
lower_skip(0, empty_base()).unwrap(),
DfLogicalPlan::Limit(_)
));
assert!(matches!(
lower_limit(u64::try_from(usize::MAX).unwrap(), empty_base()).unwrap(),
DfLogicalPlan::Limit(_)
));
assert!(matches!(
lower_skip(u64::try_from(usize::MAX).unwrap(), empty_base()).unwrap(),
DfLogicalPlan::Limit(_)
));
}
}