1use std::collections::{HashMap, HashSet};
12use std::sync::{Arc, Mutex};
13
14use graphforge_ast::{
15 AstClause, AstQuery, BinaryOpKind as AstBinOp, CallClause, CaseExpr, CreateClause,
16 DialectVersion, ExistentialSubqueryBody, Expr, FunctionCall, LabelPredicate, Literal,
17 MapLiteral, PathElement, PathPattern, PatternPredicate, PropertyAccess, RemoveClause,
18 RemoveItem, ReturnItem, SetClause, SetItem, SortItem, SortOrder as AstSortOrder, StringOpKind,
19 UnaryOpKind as AstUnOp, VarRef, WhereClause, WithClause,
20};
21use graphforge_core::{PropId, Span, TypeId};
22use graphforge_ontology::OntologyHandle;
23
24use crate::catalog::RuntimeCatalog;
25use crate::expr::{BinaryOpKind, CaseArm, IrExpr, IrLiteral, UnaryOpKind};
26use crate::plan::{
27 GraphOp, GraphPlan, GraphPlanBuilder, OntologyMode, PATTERN_COMPREHENSION_VALUE_ALIAS, SortKey,
28};
29use crate::{
30 AggExpr, AggFunc, CreateEdgeSpec, CreateNodeSpec, CreatePattern, Direction, ExprId,
31 MergeSetItem, OntologyVersion, ProcedureRegistry, ProcedureYield, ProjectItem, RemovePropItem,
32 SetMapItem, SetPropItem, SortOrder, VarId,
33};
34
35#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum BindErrorKind {
42 UnknownLabel,
44 UnknownRelationType,
46 UnknownProperty,
48 UndeclaredVariable,
50 DuplicateVariable,
52 AmbiguousProperty,
54 UnsupportedClause,
58 InvalidDeleteTarget,
61 VariableKindConflict,
65 VariableAlreadyBound,
69 InvalidArgument,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub(crate) enum VarKind {
86 Unknown,
88 Node,
90 Relationship,
92}
93
94impl std::fmt::Display for VarKind {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 f.write_str(match self {
97 VarKind::Unknown => "a runtime value",
98 VarKind::Node => "a node",
99 VarKind::Relationship => "a relationship",
100 })
101 }
102}
103
104#[derive(Debug, Clone)]
106pub struct BindError {
107 pub kind: BindErrorKind,
109 pub span: Span,
111 pub message: String,
113}
114
115impl BindError {
116 fn new(kind: BindErrorKind, span: Span, message: impl Into<String>) -> Self {
117 Self {
118 kind,
119 span,
120 message: message.into(),
121 }
122 }
123}
124
125pub struct Binder {
141 ontology: Option<OntologyHandle>,
142 catalog: Arc<Mutex<RuntimeCatalog>>,
143 mode: OntologyMode,
144 procedures: Arc<ProcedureRegistry>,
145 typed_uuid_params: HashMap<String, UuidParamClass>,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149enum UuidParamClass {
150 ExactUuid,
151 ContainsUuid,
152}
153
154impl Binder {
155 #[must_use]
161 pub fn new(
162 ontology: Option<OntologyHandle>,
163 catalog: Arc<Mutex<RuntimeCatalog>>,
164 mode: OntologyMode,
165 ) -> Self {
166 Self {
167 ontology,
168 catalog,
169 mode,
170 procedures: Arc::new(ProcedureRegistry::new()),
171 typed_uuid_params: HashMap::new(),
172 }
173 }
174
175 #[must_use]
177 pub fn with_procedures(mut self, procedures: Arc<ProcedureRegistry>) -> Self {
178 self.procedures = procedures;
179 self
180 }
181
182 #[must_use]
184 pub fn with_parameter_literals(mut self, params: &HashMap<String, IrLiteral>) -> Self {
185 self.typed_uuid_params = params
186 .iter()
187 .filter_map(|(name, value)| {
188 classify_uuid_parameter(value).map(|class| (name.clone(), class))
189 })
190 .collect();
191 self
192 }
193
194 pub fn bind(&self, ast: &AstQuery) -> Result<GraphPlan, Vec<BindError>> {
204 let mut shared_catalog = self.catalog.lock().expect("runtime catalog poisoned");
208 let staged_catalog = Arc::new(Mutex::new(shared_catalog.clone()));
209 let staged_binder = Self {
210 ontology: self.ontology.clone(),
211 catalog: Arc::clone(&staged_catalog),
212 mode: self.mode,
213 procedures: Arc::clone(&self.procedures),
214 typed_uuid_params: self.typed_uuid_params.clone(),
215 };
216 let result = staged_binder.bind_staged(ast);
217 if result.is_ok() {
218 *shared_catalog = staged_catalog
219 .lock()
220 .expect("staged runtime catalog poisoned")
221 .clone();
222 }
223 result
224 }
225
226 fn bind_staged(&self, ast: &AstQuery) -> Result<GraphPlan, Vec<BindError>> {
227 let dialect = match ast.dialect {
228 DialectVersion::OpenCypher9 => "openCypher",
229 };
230
231 let ontology_version: Option<OntologyVersion> = self
232 .ontology
233 .as_ref()
234 .map(|h| OntologyVersion::from(format!("{}:{}", h.version(), h.checksum())));
235
236 if ast
237 .clauses
238 .iter()
239 .any(|clause| matches!(clause, AstClause::Union(_)))
240 {
241 return self.bind_union_query(ast, dialect, ontology_version);
242 }
243
244 let mut builder = GraphPlan::builder(dialect).ontology_mode(self.mode);
245 if let Some(v) = ontology_version {
246 builder = builder.ontology_version(v);
247 }
248
249 let mut state = BinderState {
250 vars: HashMap::new(),
251 path_vars: HashMap::new(),
252 node_vars: HashMap::new(),
253 edge_vars: HashMap::new(),
254 edge_rel_names: HashMap::new(),
255 scalar_list_edges: HashSet::new(),
256 var_kinds: HashMap::new(),
257 next_var: 0,
258 builder,
259 errors: Vec::new(),
260 warnings: Vec::new(),
261 captured_pattern_comprehensions: None,
262 existential_depth: 0,
263 standalone_call: ast.clauses.len() == 1 && matches!(ast.clauses[0], AstClause::Call(_)),
264 };
265
266 for clause in &ast.clauses {
267 self.lower_clause(clause, &mut state);
268 }
269
270 if !state.errors.is_empty() {
271 return Err(state.errors);
272 }
273 Ok(state.builder.build())
274 }
275
276 fn bind_union_query(
277 &self,
278 ast: &AstQuery,
279 dialect: &str,
280 ontology_version: Option<OntologyVersion>,
281 ) -> Result<GraphPlan, Vec<BindError>> {
282 let markers: Vec<(usize, &graphforge_ast::UnionClause)> = ast
283 .clauses
284 .iter()
285 .enumerate()
286 .filter_map(|(index, clause)| match clause {
287 AstClause::Union(union) => Some((index, union)),
288 _ => None,
289 })
290 .collect();
291 let all = markers[0].1.all;
292 if markers.iter().any(|(_, marker)| marker.all != all) {
293 return Err(vec![BindError::new(
294 BindErrorKind::InvalidArgument,
295 markers[0].1.span,
296 "InvalidCombinationOfUnion: UNION and UNION ALL cannot be mixed",
297 )]);
298 }
299
300 let mut starts = vec![0];
301 starts.extend(markers.iter().map(|(index, _)| index + 1));
302 let mut ends: Vec<usize> = markers.iter().map(|(index, _)| *index).collect();
303 ends.push(ast.clauses.len());
304 let mut inputs = Vec::with_capacity(starts.len());
305 let mut errors = Vec::new();
306 for (start, end) in starts.into_iter().zip(ends) {
307 if start == end {
308 errors.push(BindError::new(
309 BindErrorKind::InvalidArgument,
310 ast.span,
311 "UNION requires a query on both sides",
312 ));
313 continue;
314 }
315 let branch = AstQuery {
316 dialect: ast.dialect,
317 clauses: ast.clauses[start..end].to_vec(),
318 span: ast.span,
319 };
320 match self.bind_staged(&branch) {
321 Ok(plan) => inputs.push(plan),
322 Err(mut branch_errors) => errors.append(&mut branch_errors),
323 }
324 }
325 if !errors.is_empty() {
326 return Err(errors);
327 }
328
329 let expected = union_output_names(&inputs[0]);
330 if expected.is_none()
331 || inputs
332 .iter()
333 .skip(1)
334 .any(|branch| union_output_names(branch) != expected)
335 {
336 return Err(vec![BindError::new(
337 BindErrorKind::InvalidArgument,
338 ast.span,
339 "DifferentColumnsInUnion: all UNION branches must return the same columns",
340 )]);
341 }
342
343 let mut builder = GraphPlan::builder(dialect).ontology_mode(self.mode);
344 if let Some(version) = ontology_version {
345 builder = builder.ontology_version(version);
346 }
347 Ok(builder.push_op(GraphOp::Union { all, inputs }).build())
348 }
349
350 fn lower_clause(&self, clause: &AstClause, s: &mut BinderState) {
355 match clause {
356 AstClause::Match(m) => self.lower_match(m, false, s),
357 AstClause::OptionalMatch(m) => self.lower_match(m, true, s),
358 AstClause::Where(w) => self.lower_where(w, s),
359 AstClause::With(w) => self.lower_with(w, s),
360 AstClause::Return(r) => self.lower_return(r, s),
361 AstClause::Unwind(u) => self.lower_unwind(u, s),
362 AstClause::Create(c) => self.lower_create(c, s),
363 AstClause::Merge(m) => self.lower_merge(m, s),
364 AstClause::Union(u) => {
365 s.builder.push_op_mut(GraphOp::Union {
366 all: u.all,
367 inputs: vec![],
368 });
369 }
370 AstClause::Delete(d) => self.lower_delete(d, s),
371 AstClause::Set(st) => self.lower_set(st, s),
372 AstClause::Remove(r) => self.lower_remove(r, s),
373 AstClause::Call(c) => self.lower_call(c, s),
374 _ => s.errors.push(BindError::new(
378 BindErrorKind::UnsupportedClause,
379 Span::default(),
380 "clause is not yet implemented",
381 )),
382 }
383 }
384
385 fn lower_match(&self, m: &graphforge_ast::MatchClause, optional: bool, s: &mut BinderState) {
386 if optional {
387 let mut sub_state = BinderState {
389 vars: s.vars.clone(),
390 path_vars: s.path_vars.clone(),
391 node_vars: s.node_vars.clone(),
392 edge_vars: s.edge_vars.clone(),
393 edge_rel_names: s.edge_rel_names.clone(),
394 scalar_list_edges: s.scalar_list_edges.clone(),
395 var_kinds: s.var_kinds.clone(),
396 next_var: s.next_var,
397 builder: GraphPlan::builder("openCypher").ontology_mode(self.mode),
398 errors: Vec::new(),
399 warnings: Vec::new(),
400 captured_pattern_comprehensions: None,
401 existential_depth: s.existential_depth,
402 standalone_call: false,
403 };
404 for pat in &m.patterns {
405 self.lower_path_pattern(pat, &mut sub_state);
406 }
407 if let Some(w) = &m.where_clause {
408 self.lower_where(w, &mut sub_state);
409 }
410 s.next_var = sub_state.next_var;
412 for (name, id) in sub_state.vars {
414 s.vars.entry(name).or_insert(id);
415 }
416 for (name, binding) in sub_state.path_vars {
417 s.path_vars.entry(name).or_insert(binding);
418 }
419 for (v, label) in sub_state.node_vars {
420 s.node_vars.entry(v).or_insert(label);
421 }
422 for (v, rel_name) in sub_state.edge_rel_names {
423 s.edge_rel_names.entry(v).or_insert(rel_name);
424 }
425 for (v, kind) in sub_state.var_kinds {
426 s.var_kinds.entry(v).or_insert(kind);
427 }
428 s.errors.extend(sub_state.errors);
440 s.warnings.extend(sub_state.warnings);
441 let mut child = sub_state.builder.build();
442 let referenced_vars = (0..child.exprs.len())
443 .filter_map(|index| {
444 let index = u32::try_from(index).ok()?;
445 match child.exprs.get(ExprId(index)) {
446 IrExpr::VarRef(var) => Some(*var),
447 _ => None,
448 }
449 })
450 .collect::<HashSet<_>>();
451 let bound_vars = child
452 .ops
453 .iter()
454 .flat_map(graph_op_bound_vars)
455 .collect::<HashSet<_>>();
456 let mut correlated_scans = referenced_vars
457 .difference(&bound_vars)
458 .filter(|var| s.node_vars.contains_key(var))
459 .copied()
460 .collect::<Vec<_>>();
461 correlated_scans.sort_by_key(|var| var.0);
462 for var in correlated_scans.into_iter().rev() {
463 child.ops.insert(0, GraphOp::NodeScan { var, ty: None });
464 }
465 let mut correlated_edges = referenced_vars
466 .difference(&bound_vars)
467 .filter(|var| s.edge_rel_names.contains_key(var))
468 .copied()
469 .collect::<Vec<_>>();
470 correlated_edges.sort_by_key(|var| var.0);
471 for var in correlated_edges.into_iter().rev() {
472 child.ops.insert(0, GraphOp::EdgeScan { var, ty: None });
473 }
474 s.builder.push_op_mut(GraphOp::Optional {
475 child: Box::new(child),
476 });
477 } else {
478 for pat in &m.patterns {
479 self.lower_path_pattern(pat, s);
480 }
481 if let Some(w) = &m.where_clause {
482 self.lower_where(w, s);
483 }
484 }
485 }
486
487 #[allow(clippy::too_many_lines)]
488 fn lower_path_pattern(&self, pat: &PathPattern, s: &mut BinderState) {
489 let mut prev_node_var: Option<VarId> = None;
490 let mut iter = pat.elements.iter().peekable();
491 let mut pending_dst: Option<VarId> = None;
498 let mut path_nodes: Vec<VarId> = Vec::new();
499 let mut path_segments: Vec<PathSegment> = Vec::new();
500 let mut path_edges: Vec<VarId> = Vec::new();
501
502 while let Some(elem) = iter.next() {
503 match elem {
504 PathElement::Node(node) => {
505 let var = pending_dst.take().unwrap_or_else(|| {
506 ensure_pattern_var(node.var.as_deref(), VarKind::Node, node.span, s)
507 });
508 let ty = node
509 .labels
510 .first()
511 .map(|label| self.resolve_label(label, node.span, s));
512 s.builder.push_op_mut(GraphOp::NodeScan { var, ty });
513 s.node_vars.insert(var, node.labels.first().cloned());
514 if node.labels.len() > 1 {
515 let predicate =
516 self.lower_node_label_predicate(var, &node.labels[1..], node.span, s);
517 s.builder.push_op_mut(GraphOp::Filter { predicate });
518 }
519 self.lower_inline_property_filter(var, node.properties.as_ref(), node.span, s);
523 prev_node_var = Some(var);
524 }
525 PathElement::Rel(rel) => {
526 let edge_var =
527 ensure_pattern_var(rel.var.as_deref(), VarKind::Relationship, rel.span, s);
528
529 let dst_var = if let Some(PathElement::Node(dst)) = iter.peek() {
533 let v = ensure_pattern_var(dst.var.as_deref(), VarKind::Node, dst.span, s);
534 pending_dst = Some(v);
535 v
536 } else {
537 alloc_anon_var(s)
538 };
539
540 let src_var = prev_node_var.unwrap_or_else(|| alloc_anon_var(s));
541 let dir = lower_direction(rel.direction);
542 let rel_name = (rel.types.len() == 1)
543 .then(|| rel.types.first().cloned())
544 .flatten();
545 let rel_ty = rel_name
546 .as_ref()
547 .map(|t| self.resolve_relation_type(t, rel.span, s));
548 let is_var_hop = rel.min_hops.is_some() || rel.max_hops.is_some();
549
550 let (min_hops, max_hops) = if is_var_hop {
560 let min = u16::try_from(rel.min_hops.unwrap_or(1)).unwrap_or(u16::MAX);
561 let max = rel.max_hops.map(|h| u16::try_from(h).unwrap_or(u16::MAX));
562 (min, max)
563 } else {
564 (1, Some(1))
565 };
566 let is_scalar_hop = min_hops == 1 && max_hops == Some(1);
567 if is_var_hop && is_scalar_hop {
568 s.scalar_list_edges.insert(edge_var);
569 }
570 let bound_rel_type_conflict =
571 bound_rel_type_conflict(edge_var, rel_name.as_deref(), is_scalar_hop, s);
572 s.builder.push_op_mut(GraphOp::Expand {
573 src: src_var,
574 edge: edge_var,
575 dst: dst_var,
576 rel_ty,
577 dir,
578 min_hops,
579 max_hops,
580 });
581 if rel.types.len() > 1 {
582 if is_scalar_hop {
583 s.edge_rel_names.insert(edge_var, None);
584 }
585 let predicate = self
586 .lower_relationship_type_predicate(edge_var, &rel.types, rel.span, s);
587 s.builder.push_op_mut(GraphOp::Filter { predicate });
588 }
589 let prior_edges = path_edges.clone();
590 if path_edges.contains(&edge_var) {
591 s.errors.push(BindError::new(
592 BindErrorKind::InvalidArgument,
593 rel.span,
594 "RelationshipUniquenessViolation: a relationship variable may not be reused within one pattern",
595 ));
596 }
597 if !prior_edges.is_empty() {
598 s.builder.push_op_mut(GraphOp::RelationshipUnique {
599 edge: edge_var,
600 prior_edges,
601 });
602 }
603 path_edges.push(edge_var);
604 if bound_rel_type_conflict {
605 push_false_filter(s);
606 }
607
608 if is_scalar_hop {
620 s.edge_rel_names.insert(edge_var, rel_name.clone());
621 let endpoints = match dir {
622 Direction::Out => Some((src_var, dst_var)),
623 Direction::In => Some((dst_var, src_var)),
624 Direction::Undirected => None,
625 };
626 if let Some(endpoints) = endpoints {
627 s.edge_vars.insert(edge_var, endpoints);
628 }
629 }
630
631 if path_nodes.is_empty() {
632 path_nodes.push(src_var);
633 }
634 path_nodes.push(dst_var);
635 path_segments.push(PathSegment {
636 edge: edge_var,
637 var_len: !(min_hops == 1 && max_hops == Some(1)),
642 rel_name: rel_name.clone(),
643 });
644
645 if is_scalar_hop {
654 self.lower_inline_property_filter(
655 edge_var,
656 rel.properties.as_ref(),
657 rel.span,
658 s,
659 );
660 } else {
661 self.lower_varlen_inline_property_filter(
662 edge_var,
663 rel_name.as_deref(),
664 rel.properties.as_ref(),
665 rel.span,
666 s,
667 );
668 }
669
670 prev_node_var = Some(dst_var);
671 }
672 }
673 }
674
675 if let Some(name) = &pat.var {
676 if path_nodes.is_empty()
677 && let Some(node) = prev_node_var
678 {
679 path_nodes.push(node);
680 }
681 Self::bind_path_var(name, pat.span, path_nodes, path_segments, s);
682 }
683 }
684
685 fn bind_path_var(
690 name: &str,
691 span: Span,
692 nodes: Vec<VarId>,
693 segments: Vec<PathSegment>,
694 s: &mut BinderState,
695 ) {
696 if s.vars.contains_key(name) || s.path_vars.contains_key(name) {
697 s.errors.push(BindError::new(
698 BindErrorKind::DuplicateVariable,
699 span,
700 format!("path variable `{name}` conflicts with an existing variable"),
701 ));
702 return;
703 }
704 s.path_vars
705 .insert(name.to_owned(), PathBinding { nodes, segments });
706 }
707
708 fn lower_inline_property_filter(
715 &self,
716 var: VarId,
717 properties: Option<&Expr>,
718 span: Span,
719 s: &mut BinderState,
720 ) {
721 let Some(Expr::Map(map)) = properties else {
722 return;
723 };
724 let mut keys: Vec<&String> = map.entries.keys().collect();
728 keys.sort();
729 let mut combined: Option<ExprId> = None;
730 for key in keys {
731 let value = self.lower_expr(&map.entries[key], span, s);
732 let owner = property_owner_for_var(var, s);
733 let prop_span = map.key_spans.get(key).copied().unwrap_or(span);
734 let prop = self.resolve_property(key, prop_span, owner, s);
735 let base = s.builder.push_expr(IrExpr::VarRef(var));
736 let access = s.builder.push_expr(IrExpr::PropertyAccess { base, prop });
737 let eq = s.builder.push_expr(IrExpr::BinaryOp {
738 op: BinaryOpKind::Eq,
739 left: access,
740 right: value,
741 });
742 combined = Some(match combined {
743 None => eq,
744 Some(acc) => s.builder.push_expr(IrExpr::BinaryOp {
745 op: BinaryOpKind::And,
746 left: acc,
747 right: eq,
748 }),
749 });
750 }
751 if let Some(predicate) = combined {
752 s.builder.push_op_mut(GraphOp::Filter { predicate });
753 }
754 }
755
756 fn lower_varlen_inline_property_filter(
757 &self,
758 edge_var: VarId,
759 rel_name: Option<&str>,
760 properties: Option<&Expr>,
761 span: Span,
762 s: &mut BinderState,
763 ) {
764 let Some(Expr::Map(map)) = properties else {
765 return;
766 };
767 let mut keys: Vec<&String> = map.entries.keys().collect();
768 keys.sort();
769 let mut combined = None;
770 for key in keys {
771 let value = self.lower_expr(&map.entries[key], span, s);
772 let owner = BoundPropertyOwner::Relationship(rel_name.map(str::to_owned));
773 let prop_span = map.key_spans.get(key).copied().unwrap_or(span);
774 let prop = self.resolve_property(key, prop_span, owner, s);
775 let loop_var = alloc_anon_var(s);
776 let element = s.builder.push_expr(IrExpr::VarRef(loop_var));
777 let access = s.builder.push_expr(IrExpr::PropertyAccess {
778 base: element,
779 prop,
780 });
781 let predicate = s.builder.push_expr(IrExpr::BinaryOp {
782 op: BinaryOpKind::Eq,
783 left: access,
784 right: value,
785 });
786 let list = s.builder.push_expr(IrExpr::VarRef(edge_var));
787 let all = s.builder.push_expr(IrExpr::Quantifier {
788 kind: graphforge_ast::QuantifierKind::All,
789 loop_var,
790 list,
791 predicate,
792 });
793 combined = Some(match combined {
794 None => all,
795 Some(acc) => s.builder.push_expr(IrExpr::BinaryOp {
796 op: BinaryOpKind::And,
797 left: acc,
798 right: all,
799 }),
800 });
801 }
802 if let Some(predicate) = combined {
803 s.builder.push_op_mut(GraphOp::Filter { predicate });
804 }
805 }
806
807 fn lower_create(&self, c: &CreateClause, s: &mut BinderState) {
817 let pattern = self.bind_create_patterns(&c.patterns, false, s);
818 s.builder.push_op_mut(GraphOp::Create { pattern });
819 }
820
821 fn lower_merge(&self, m: &graphforge_ast::MergeClause, s: &mut BinderState) {
822 let pattern = self.bind_create_patterns(std::slice::from_ref(&m.pattern), true, s);
823 let on_create = self.lower_merge_actions(&m.on_create, s);
824 let on_match = self.lower_merge_actions(&m.on_match, s);
825 s.builder.push_op_mut(GraphOp::Merge {
826 pattern,
827 on_create,
828 on_match,
829 });
830 }
831
832 #[allow(
833 clippy::too_many_lines,
834 reason = "one stateful walk keeps node, relationship, and named-path bindings aligned"
835 )]
836 fn bind_create_patterns(
837 &self,
838 patterns: &[graphforge_ast::PathPattern],
839 allow_undirected_relationship: bool,
840 s: &mut BinderState,
841 ) -> CreatePattern {
842 let mut pattern = CreatePattern::default();
843 let mut created_property_bindings: Vec<ReturnItem> = Vec::new();
844
845 let bound_before: std::collections::HashSet<VarId> = s.vars.values().copied().collect();
851
852 for pat in patterns {
853 let mut path_nodes = Vec::new();
854 let mut path_segments = Vec::new();
855 if let [PathElement::Node(node)] = pat.elements.as_slice()
860 && let Some(name) = node.var.as_deref()
861 && s.vars.get(name).is_some_and(|v| bound_before.contains(v))
862 {
863 s.errors.push(BindError::new(
864 BindErrorKind::VariableAlreadyBound,
865 node.span,
866 format!("variable `{name}` is already bound and cannot be re-created"),
867 ));
868 }
869 let mut prev_node_var: Option<VarId> = None;
870 let mut pending_dst: Option<VarId> = None;
876 let mut iter = pat.elements.iter().peekable();
877
878 while let Some(elem) = iter.next() {
879 match elem {
880 PathElement::Node(node) => {
881 let var = pending_dst
882 .take()
883 .unwrap_or_else(|| ensure_var(node.var.as_ref(), s));
884 if let Some(name) = node.var.as_deref() {
885 bind_var_kind(var, VarKind::Node, name, node.span, s);
886 }
887 let already_specced = pattern.nodes.iter().any(|n| n.var == var);
893 let has_new_shape = !node.labels.is_empty() || node.properties.is_some();
894 if (bound_before.contains(&var) || already_specced) && has_new_shape {
895 s.errors.push(BindError::new(
896 BindErrorKind::VariableAlreadyBound,
897 node.span,
898 format!(
899 "variable `{}` is already bound and cannot be re-declared \
900 with new labels or properties",
901 node.var.as_deref().unwrap_or("?")
902 ),
903 ));
904 }
905 if !pattern.nodes.iter().any(|n| n.var == var) {
910 let labels = node
911 .labels
912 .iter()
913 .map(|label| self.resolve_label(label, node.span, s))
914 .collect();
915 let resolved_properties = node.properties.as_ref().map(|expr| {
916 rewrite_projection_alias_refs(
917 expr.clone(),
918 &created_property_bindings,
919 )
920 });
921 let properties = resolved_properties
922 .as_ref()
923 .map(|expr| self.lower_expr(expr, node.span, s));
924 let is_reference = bound_before.contains(&var);
925 pattern.nodes.push(CreateNodeSpec {
926 var,
927 labels,
928 properties,
929 is_reference,
930 });
931 if !is_reference {
937 s.node_vars
938 .entry(var)
939 .or_insert_with(|| node.labels.first().cloned());
940 if let Some(name) = node.var.as_deref() {
941 created_property_bindings.push(ReturnItem {
942 expr: resolved_properties.unwrap_or_else(|| {
943 Expr::Map(MapLiteral {
944 entries: HashMap::new(),
945 key_spans: HashMap::new(),
946 span: node.span,
947 })
948 }),
949 alias: Some(name.to_owned()),
950 display: Some(name.to_owned()),
951 span: node.span,
952 });
953 }
954 }
955 }
956 prev_node_var = Some(var);
957 if path_nodes.is_empty() {
958 path_nodes.push(var);
959 }
960 }
961 PathElement::Rel(rel) => {
962 validate_created_rel(rel, &bound_before, allow_undirected_relationship, s);
963 let edge_var = ensure_var(rel.var.as_ref(), s);
964 if let Some(name) = rel.var.as_deref() {
965 bind_var_kind(edge_var, VarKind::Relationship, name, rel.span, s);
966 }
967 let dst_var = if let Some(PathElement::Node(dst)) = iter.peek() {
968 let v = ensure_var(dst.var.as_ref(), s);
969 pending_dst = Some(v);
970 v
971 } else {
972 alloc_anon_var(s)
973 };
974 let src_var = prev_node_var.unwrap_or_else(|| alloc_anon_var(s));
975 let rel_type = rel
976 .types
977 .first()
978 .map(|t| self.resolve_relation_type(t, rel.span, s));
979 let properties = rel
980 .properties
981 .as_ref()
982 .map(|expr| self.lower_expr(expr, rel.span, s));
983 pattern.edges.push(CreateEdgeSpec {
984 var: edge_var,
985 src: src_var,
986 dst: dst_var,
987 rel_type,
988 direction: lower_direction(rel.direction),
989 properties,
990 });
991 s.edge_rel_names
992 .insert(edge_var, rel.types.first().cloned());
993 let endpoints = match lower_direction(rel.direction) {
994 Direction::In => (dst_var, src_var),
995 Direction::Out | Direction::Undirected => (src_var, dst_var),
996 };
997 s.edge_vars.insert(edge_var, endpoints);
998 path_nodes.push(dst_var);
999 path_segments.push(PathSegment {
1000 edge: edge_var,
1001 var_len: false,
1002 rel_name: rel.types.first().cloned(),
1003 });
1004 prev_node_var = Some(dst_var);
1005 }
1006 }
1007 }
1008 if let Some(name) = &pat.var {
1009 Self::bind_path_var(name, pat.span, path_nodes, path_segments, s);
1010 }
1011 }
1012
1013 pattern
1014 }
1015
1016 fn lower_merge_actions(&self, actions: &[SetItem], s: &mut BinderState) -> Vec<MergeSetItem> {
1017 let mut lowered = Vec::with_capacity(actions.len());
1018 for action in actions {
1019 match action {
1020 SetItem::Property {
1021 target,
1022 value,
1023 span,
1024 } => {
1025 if let Some((var, prop, prop_name)) = self.resolve_write_target(target, s) {
1026 lowered.push(MergeSetItem::Property(SetPropItem {
1027 target: var,
1028 prop,
1029 prop_name,
1030 value: self.lower_expr(value, *span, s),
1031 }));
1032 }
1033 }
1034 SetItem::PropertyMerge { var, map, span }
1035 | SetItem::PropertyReplace { var, map, span } => {
1036 if let Some(&target) = s.vars.get(var) {
1037 lowered.push(MergeSetItem::Map(SetMapItem {
1038 target,
1039 map: self.lower_set_map_source(map, *span, s),
1040 replace: matches!(action, SetItem::PropertyReplace { .. }),
1041 }));
1042 } else {
1043 s.errors.push(BindError::new(
1044 BindErrorKind::UndeclaredVariable,
1045 *span,
1046 format!("undefined variable `{var}`"),
1047 ));
1048 }
1049 }
1050 SetItem::Label { var, labels, span } => {
1051 if let Some(&target) = s.vars.get(var) {
1052 lowered.push(MergeSetItem::AddLabels {
1053 target,
1054 labels: labels
1055 .iter()
1056 .map(|label| self.resolve_label(label, *span, s))
1057 .collect(),
1058 });
1059 } else {
1060 s.errors.push(BindError::new(
1061 BindErrorKind::UndeclaredVariable,
1062 *span,
1063 format!("undefined variable `{var}`"),
1064 ));
1065 }
1066 }
1067 _ => unreachable!("future SET forms are rejected by the parser contract"),
1068 }
1069 }
1070 lowered
1071 }
1072
1073 fn lower_delete(&self, d: &graphforge_ast::DeleteClause, s: &mut BinderState) {
1079 let mut vars: Vec<VarId> = Vec::with_capacity(d.exprs.len());
1080 let mut exprs = Vec::new();
1081 for expr in &d.exprs {
1082 match expr {
1083 Expr::Var(VarRef { name, span }) => match s.vars.get(name) {
1084 Some(&var_id) => vars.push(var_id),
1085 None => {
1086 if let Some(path) = s.path_vars.get(name) {
1087 vars.extend(path.nodes.iter().copied());
1088 vars.extend(path.segments.iter().map(|segment| segment.edge));
1089 } else {
1090 s.errors.push(BindError::new(
1091 BindErrorKind::UndeclaredVariable,
1092 *span,
1093 format!("DELETE target `{name}` is not a bound variable"),
1094 ));
1095 }
1096 }
1097 },
1098 Expr::Property(_) => exprs.push(self.lower_expr(expr, expr.span(), s)),
1099 Expr::FunctionCall(call) if call.name.as_slice() == ["_subscript"] => {
1100 exprs.push(self.lower_expr(expr, expr.span(), s));
1101 }
1102 Expr::Parenthesized { inner, .. } => {
1103 if matches!(inner.as_ref(), Expr::Property(_))
1104 || matches!(inner.as_ref(), Expr::FunctionCall(call) if call.name.as_slice() == ["_subscript"])
1105 {
1106 exprs.push(self.lower_expr(inner, inner.span(), s));
1107 } else {
1108 if self.typed_uuid_param_in(inner).is_some() {
1109 self.lower_expr(inner, inner.span(), s);
1110 }
1111 s.errors.push(BindError::new(
1112 BindErrorKind::InvalidDeleteTarget,
1113 expr.span(),
1114 "DELETE target must be a node, relationship, or path value",
1115 ));
1116 }
1117 }
1118 other => {
1119 if self.typed_uuid_param_in(other).is_some() {
1120 self.lower_expr(other, other.span(), s);
1121 }
1122 s.errors.push(BindError::new(
1123 BindErrorKind::InvalidDeleteTarget,
1124 other.span(),
1125 "DELETE target must be a node, relationship, or path value",
1126 ));
1127 }
1128 }
1129 }
1130 vars.sort_unstable_by_key(|var| var.0);
1131 vars.dedup();
1132 s.builder.push_op_mut(GraphOp::Delete {
1133 vars,
1134 exprs,
1135 detach: d.detach,
1136 });
1137 }
1138
1139 fn lower_set(&self, st: &SetClause, s: &mut BinderState) {
1144 let mut items: Vec<SetPropItem> = Vec::with_capacity(st.items.len());
1145 let mut map_items = Vec::new();
1146 let mut label_items = Vec::new();
1147 for item in &st.items {
1148 match item {
1149 SetItem::Property {
1150 target,
1151 value,
1152 span,
1153 } => {
1154 let Some((var, prop, prop_name)) = self.resolve_write_target(target, s) else {
1155 continue;
1156 };
1157 let value = self.lower_expr(value, *span, s);
1158 items.push(SetPropItem {
1159 target: var,
1160 prop,
1161 prop_name,
1162 value,
1163 });
1164 }
1165 SetItem::PropertyMerge { var, map, span }
1166 | SetItem::PropertyReplace { var, map, span } => {
1167 let Some(&target) = s.vars.get(var) else {
1168 s.errors.push(BindError::new(
1169 BindErrorKind::UndeclaredVariable,
1170 *span,
1171 format!("undefined variable `{var}`"),
1172 ));
1173 continue;
1174 };
1175 map_items.push(SetMapItem {
1176 target,
1177 map: self.lower_set_map_source(map, *span, s),
1178 replace: matches!(item, SetItem::PropertyReplace { .. }),
1179 });
1180 }
1181 SetItem::Label { var, labels, span } => match s.vars.get(var).copied() {
1182 Some(target) => label_items.push(crate::LabelItem {
1183 target,
1184 labels: labels
1185 .iter()
1186 .map(|label| self.resolve_label(label, *span, s))
1187 .collect(),
1188 }),
1189 None => s.errors.push(BindError::new(
1190 BindErrorKind::UndeclaredVariable,
1191 *span,
1192 format!("undefined variable `{var}`"),
1193 )),
1194 },
1195 _ => unreachable!("future SET forms rejected above"),
1196 }
1197 }
1198 s.builder.push_op_mut(GraphOp::Set {
1199 items,
1200 map_items,
1201 label_items,
1202 });
1203 }
1204
1205 fn lower_set_map_source(&self, map: &Expr, span: Span, s: &mut BinderState) -> ExprId {
1206 if let Expr::Var(VarRef { name, .. }) = map
1207 && let Some(&var) = s.vars.get(name)
1208 && (s.node_vars.contains_key(&var) || s.edge_rel_names.contains_key(&var))
1209 {
1210 let value = s.builder.push_expr(IrExpr::VarRef(var));
1211 return s.builder.push_expr(IrExpr::FunctionCall {
1212 name: "properties".into(),
1213 args: vec![value],
1214 });
1215 }
1216 self.lower_expr(map, span, s)
1217 }
1218
1219 fn lower_remove(&self, r: &RemoveClause, s: &mut BinderState) {
1224 let mut items: Vec<RemovePropItem> = Vec::with_capacity(r.items.len());
1225 let mut label_items = Vec::new();
1226 for item in &r.items {
1227 match item {
1228 RemoveItem::Property(target, _span) => {
1229 let Some((var, prop, prop_name)) = self.resolve_write_target(target, s) else {
1230 continue;
1231 };
1232 items.push(RemovePropItem {
1233 target: var,
1234 prop,
1235 prop_name,
1236 });
1237 }
1238 RemoveItem::Label { var, labels, span } => match s.vars.get(var).copied() {
1239 Some(target) => label_items.push(crate::LabelItem {
1240 target,
1241 labels: labels
1242 .iter()
1243 .map(|label| self.resolve_label(label, *span, s))
1244 .collect(),
1245 }),
1246 None => s.errors.push(BindError::new(
1247 BindErrorKind::UndeclaredVariable,
1248 *span,
1249 format!("undefined variable `{var}`"),
1250 )),
1251 },
1252 _ => s.errors.push(BindError::new(
1253 BindErrorKind::UnsupportedClause,
1254 r.span,
1255 "this form of REMOVE is not yet supported",
1256 )),
1257 }
1258 }
1259 s.builder
1260 .push_op_mut(GraphOp::Remove { items, label_items });
1261 }
1262
1263 fn resolve_write_target(
1273 &self,
1274 target: &PropertyAccess,
1275 s: &mut BinderState,
1276 ) -> Option<(VarId, PropId, String)> {
1277 if matches!(target.key.as_str(), "node_uuid" | "edge_uuid") {
1278 s.errors.push(BindError::new(
1279 BindErrorKind::InvalidArgument,
1280 target.span,
1281 format!("structural identity field `{}` is read-only", target.key),
1282 ));
1283 return None;
1284 }
1285 let Expr::Var(VarRef { name, span }) = target.object.as_ref() else {
1286 s.errors.push(BindError::new(
1287 BindErrorKind::InvalidDeleteTarget,
1288 target.span,
1289 "write target must be a bound variable's property \
1290 (e.g. `SET n.prop = …`)",
1291 ));
1292 return None;
1293 };
1294 let Some(&var) = s.vars.get(name) else {
1295 s.errors.push(BindError::new(
1296 BindErrorKind::UndeclaredVariable,
1297 *span,
1298 format!("write target `{name}` is not a bound variable"),
1299 ));
1300 return None;
1301 };
1302 let owner = property_owner_for_var(var, s);
1303 let prop = self.resolve_property(&target.key, target.span, owner, s);
1304 Some((var, prop, target.key.clone()))
1305 }
1306
1307 fn lower_where(&self, w: &WhereClause, s: &mut BinderState) {
1308 if expr_contains_aggregate(&w.predicate) {
1312 s.errors.push(BindError::new(
1313 BindErrorKind::InvalidArgument,
1314 w.span,
1315 "an aggregate function may not be used in WHERE".to_string(),
1316 ));
1317 }
1318 self.lower_where_predicate(&w.predicate, w.span, s);
1319 }
1320
1321 fn lower_where_predicate(&self, expr: &Expr, parent_span: Span, s: &mut BinderState) {
1322 match expr {
1323 Expr::Parenthesized { inner, .. } => self.lower_where_predicate(inner, parent_span, s),
1324 Expr::PatternPredicate(pp) => self.lower_pattern_predicate(pp, false, s),
1325 Expr::ExistentialSubquery(es) => self.lower_existential_subquery(es, s),
1326 Expr::UnaryOp(graphforge_ast::UnaryOp {
1327 op: AstUnOp::Not,
1328 expr: inner,
1329 ..
1330 }) if matches_pattern_predicate(inner) => {
1331 let Expr::PatternPredicate(pp) = strip_parens(inner) else {
1332 unreachable!("matches_pattern_predicate ensured the inner shape");
1333 };
1334 self.lower_pattern_predicate(pp, true, s);
1335 }
1336 Expr::BinaryOp(graphforge_ast::BinaryOp {
1337 op: AstBinOp::And,
1338 left,
1339 right,
1340 ..
1341 }) => {
1342 self.lower_where_predicate(left, parent_span, s);
1343 self.lower_where_predicate(right, parent_span, s);
1344 }
1345 Expr::BinaryOp(graphforge_ast::BinaryOp {
1346 op: AstBinOp::Or, ..
1347 }) => {
1348 let mut alternatives = Vec::new();
1349 if collect_pattern_disjunction(expr, &mut alternatives) {
1350 self.lower_pattern_predicate_alternatives(&alternatives, false, s);
1351 } else if expr_contains_pattern_predicate(expr) {
1352 let mut branches = Vec::new();
1353 if collect_mixed_pattern_disjunction(expr, &mut branches) {
1354 self.lower_mixed_pattern_predicate_alternatives(&branches, s);
1355 } else {
1356 s.errors.push(BindError::new(
1357 BindErrorKind::InvalidArgument,
1358 expr.span(),
1359 "each OR branch containing a pattern predicate must contain exactly one pattern alternative",
1360 ));
1361 }
1362 } else {
1363 Self::reject_bare_graph_value_predicate(expr, parent_span, s);
1364 let pred = self.lower_expr(expr, parent_span, s);
1365 s.builder.push_op_mut(GraphOp::Filter { predicate: pred });
1366 }
1367 }
1368 other if expr_contains_pattern_predicate(other) => {
1369 s.errors.push(BindError::new(
1370 BindErrorKind::InvalidArgument,
1371 other.span(),
1372 "pattern predicates are currently supported only as single-relationship \
1373 WHERE predicates, with optional NOT and AND",
1374 ));
1375 }
1376 other => {
1377 Self::reject_bare_graph_value_predicate(other, parent_span, s);
1378 let pred = self.lower_expr(other, parent_span, s);
1379 s.builder.push_op_mut(GraphOp::Filter { predicate: pred });
1380 }
1381 }
1382 }
1383
1384 fn lower_pattern_predicate(&self, pp: &PatternPredicate, negated: bool, s: &mut BinderState) {
1385 self.lower_pattern_predicate_alternatives(&[pp], negated, s);
1386 }
1387
1388 fn lower_existential_subquery(
1389 &self,
1390 es: &graphforge_ast::ExistentialSubquery,
1391 s: &mut BinderState,
1392 ) {
1393 let prior_error_count = s.errors.len();
1394 let outer_vars = s.vars.values().copied().collect::<HashSet<_>>();
1395
1396 let mut sub_state = BinderState {
1397 vars: s.vars.clone(),
1398 path_vars: s.path_vars.clone(),
1399 node_vars: s.node_vars.clone(),
1400 edge_vars: s.edge_vars.clone(),
1401 edge_rel_names: s.edge_rel_names.clone(),
1402 scalar_list_edges: s.scalar_list_edges.clone(),
1403 var_kinds: s.var_kinds.clone(),
1404 next_var: s.next_var,
1405 builder: GraphPlan::builder("openCypher").ontology_mode(self.mode),
1406 errors: Vec::new(),
1407 warnings: Vec::new(),
1408 captured_pattern_comprehensions: None,
1409 existential_depth: s.existential_depth + 1,
1410 standalone_call: false,
1411 };
1412
1413 match &es.body {
1414 ExistentialSubqueryBody::Simple { pattern, filter } => {
1415 self.lower_path_pattern(pattern, &mut sub_state);
1416 if let Some(filter) = filter {
1417 if expr_contains_aggregate(filter) {
1418 sub_state.errors.push(BindError::new(
1419 BindErrorKind::InvalidArgument,
1420 es.span,
1421 "an aggregate function may not be used in a simple existential subquery",
1422 ));
1423 }
1424 self.lower_where_predicate(filter, es.span, &mut sub_state);
1425 }
1426 }
1427 ExistentialSubqueryBody::Full(query) => {
1428 let last = query.clauses.len().saturating_sub(1);
1429 for (index, clause) in query.clauses.iter().enumerate() {
1430 let allowed = matches!(
1431 clause,
1432 AstClause::Match(_)
1433 | AstClause::OptionalMatch(_)
1434 | AstClause::With(_)
1435 | AstClause::Unwind(_)
1436 ) || matches!(clause, AstClause::Return(_)) && index == last;
1437 if !allowed {
1438 sub_state.errors.push(BindError::new(
1439 BindErrorKind::InvalidArgument,
1440 clause.span(),
1441 "a full existential subquery must contain only read clauses and end in RETURN",
1442 ));
1443 continue;
1444 }
1445 self.lower_clause(clause, &mut sub_state);
1446 }
1447 if !matches!(query.clauses.last(), Some(AstClause::Return(_))) {
1448 sub_state.errors.push(BindError::new(
1449 BindErrorKind::InvalidArgument,
1450 es.span,
1451 "a full existential subquery must end in RETURN",
1452 ));
1453 }
1454 }
1455 }
1456
1457 let child = sub_state.builder.build();
1458 let references_outer = plan_references_any_var(&child, &outer_vars);
1459 s.next_var = s.next_var.max(sub_state.next_var);
1460 s.errors.extend(sub_state.errors);
1461 s.warnings.extend(sub_state.warnings);
1462 if !references_outer {
1463 s.errors.push(BindError::new(
1464 BindErrorKind::UndeclaredVariable,
1465 es.span,
1466 "existential subquery must reference at least one outer variable",
1467 ));
1468 }
1469 if s.errors.len() == prior_error_count {
1470 s.builder.push_op_mut(GraphOp::Exists {
1471 child: Box::new(child),
1472 negated: false,
1473 });
1474 }
1475 }
1476
1477 fn lower_pattern_predicate_alternatives(
1478 &self,
1479 alternatives: &[&PatternPredicate],
1480 negated: bool,
1481 s: &mut BinderState,
1482 ) {
1483 let prior_error_count = s.errors.len();
1484 let mut children = Vec::new();
1485 for pp in alternatives {
1486 if let Some(name) = pp.pattern.var.as_deref() {
1487 s.errors.push(BindError::new(
1488 BindErrorKind::UndeclaredVariable,
1489 pp.span,
1490 format!("path variable `{name}` is not bound in this pattern predicate scope"),
1491 ));
1492 continue;
1493 }
1494 if !is_single_relationship_pattern(&pp.pattern) && s.existential_depth < 2 {
1495 s.errors.push(BindError::new(
1496 BindErrorKind::InvalidArgument,
1497 pp.span,
1498 "multi-relationship pattern predicates are supported only in nested existential subqueries",
1499 ));
1500 continue;
1501 }
1502 if pattern_has_var_length_relationship_properties(&pp.pattern) {
1503 s.errors.push(BindError::new(
1504 BindErrorKind::InvalidArgument,
1505 pp.span,
1506 "variable-length relationships in pattern predicates cannot have property maps",
1507 ));
1508 continue;
1509 }
1510 for pattern in relationship_type_alternatives(&pp.pattern) {
1511 if let Some(child) = self.bind_pattern_predicate_child(&pattern, pp.span, s) {
1512 children.push(child);
1513 }
1514 }
1515 }
1516
1517 if children.is_empty() || s.errors.len() > prior_error_count {
1518 return;
1519 }
1520 let child = if children.len() == 1 {
1521 children.pop().expect("one child remains")
1522 } else {
1523 GraphPlan::builder("openCypher")
1524 .push_op(GraphOp::Union {
1525 all: true,
1526 inputs: children,
1527 })
1528 .build()
1529 };
1530 s.builder.push_op_mut(GraphOp::Exists {
1531 child: Box::new(child),
1532 negated,
1533 });
1534 }
1535
1536 fn lower_mixed_pattern_predicate_alternatives(
1537 &self,
1538 branches: &[MixedPatternBranch<'_>],
1539 s: &mut BinderState,
1540 ) {
1541 let prior_error_count = s.errors.len();
1542 let mut children = Vec::new();
1543 for branch in branches {
1544 for pattern in relationship_type_alternatives(&branch.pattern.pattern) {
1545 if let Some(child) = self.bind_pattern_predicate_child_with_filters(
1546 &pattern,
1547 branch.pattern.span,
1548 &branch.scalar_filters,
1549 s,
1550 ) {
1551 children.push(child);
1552 }
1553 }
1554 }
1555 if children.is_empty() || s.errors.len() > prior_error_count {
1556 return;
1557 }
1558 let child = if children.len() == 1 {
1559 children.pop().expect("one child remains")
1560 } else {
1561 GraphPlan::builder("openCypher")
1562 .push_op(GraphOp::Union {
1563 all: true,
1564 inputs: children,
1565 })
1566 .build()
1567 };
1568 s.builder.push_op_mut(GraphOp::Exists {
1569 child: Box::new(child),
1570 negated: false,
1571 });
1572 }
1573
1574 fn bind_pattern_predicate_child(
1575 &self,
1576 pattern: &PathPattern,
1577 span: Span,
1578 s: &mut BinderState,
1579 ) -> Option<GraphPlan> {
1580 self.bind_pattern_predicate_child_with_filters(pattern, span, &[], s)
1581 }
1582
1583 fn bind_pattern_predicate_child_with_filters(
1584 &self,
1585 pattern: &PathPattern,
1586 span: Span,
1587 scalar_filters: &[&Expr],
1588 s: &mut BinderState,
1589 ) -> Option<GraphPlan> {
1590 let prior_error_count = s.errors.len();
1591 let mut sub_state = BinderState {
1592 vars: s.vars.clone(),
1593 path_vars: s.path_vars.clone(),
1594 node_vars: s.node_vars.clone(),
1595 edge_vars: s.edge_vars.clone(),
1596 edge_rel_names: s.edge_rel_names.clone(),
1597 scalar_list_edges: s.scalar_list_edges.clone(),
1598 var_kinds: s.var_kinds.clone(),
1599 next_var: s.next_var,
1600 builder: GraphPlan::builder("openCypher").ontology_mode(self.mode),
1601 errors: Vec::new(),
1602 warnings: Vec::new(),
1603 captured_pattern_comprehensions: None,
1604 existential_depth: s.existential_depth,
1605 standalone_call: false,
1606 };
1607 self.lower_path_pattern(pattern, &mut sub_state);
1608 for filter in scalar_filters {
1609 self.lower_where_predicate(filter, filter.span(), &mut sub_state);
1610 }
1611
1612 for name in sub_state.vars.keys() {
1613 if !s.vars.contains_key(name) {
1614 sub_state.errors.push(BindError::new(
1615 BindErrorKind::UndeclaredVariable,
1616 span,
1617 format!("variable `{name}` is not bound in this pattern predicate scope"),
1618 ));
1619 }
1620 }
1621
1622 if !pattern_references_bound_var(pattern, s) {
1623 sub_state.errors.push(BindError::new(
1624 BindErrorKind::UndeclaredVariable,
1625 span,
1626 "pattern predicate must reference at least one bound variable",
1627 ));
1628 }
1629
1630 s.errors.extend(sub_state.errors);
1631 s.warnings.extend(sub_state.warnings);
1632 if s.errors.len() > prior_error_count {
1633 return None;
1634 }
1635
1636 Some(sub_state.builder.build())
1637 }
1638
1639 fn lower_pattern_comprehension(
1640 &self,
1641 pc: &graphforge_ast::PatternComprehension,
1642 s: &mut BinderState,
1643 ) -> ExprId {
1644 let prior_error_count = s.errors.len();
1645 let output = VarId(s.next_var);
1646 s.next_var += 1;
1647
1648 let mut pattern = pc.pattern.clone();
1649 pattern.var.clone_from(&pc.var);
1650 let alternatives = relationship_type_alternatives(&pattern);
1651 let mut children = Vec::with_capacity(alternatives.len());
1652 for pattern in alternatives {
1653 let mut sub_state = BinderState {
1654 vars: s.vars.clone(),
1655 path_vars: s.path_vars.clone(),
1656 node_vars: s.node_vars.clone(),
1657 edge_vars: s.edge_vars.clone(),
1658 edge_rel_names: s.edge_rel_names.clone(),
1659 scalar_list_edges: s.scalar_list_edges.clone(),
1660 var_kinds: s.var_kinds.clone(),
1661 next_var: s.next_var,
1662 builder: GraphPlan::builder("openCypher").ontology_mode(self.mode),
1663 errors: Vec::new(),
1664 warnings: Vec::new(),
1665 captured_pattern_comprehensions: None,
1666 existential_depth: s.existential_depth,
1667 standalone_call: false,
1668 };
1669 self.lower_path_pattern(&pattern, &mut sub_state);
1670
1671 if let Some(filter) = &pc.filter {
1672 if expr_contains_aggregate(filter) {
1673 sub_state.errors.push(BindError::new(
1674 BindErrorKind::InvalidArgument,
1675 pc.span,
1676 "an aggregate function may not be used in a pattern comprehension filter",
1677 ));
1678 }
1679 self.lower_where_predicate(filter, pc.span, &mut sub_state);
1680 }
1681 if expr_contains_aggregate(&pc.projection) {
1682 sub_state.errors.push(BindError::new(
1683 BindErrorKind::InvalidArgument,
1684 pc.span,
1685 "an aggregate function may not be used in a pattern comprehension projection",
1686 ));
1687 }
1688
1689 let projection = self
1690 .lower_projection_value_expr(&pc.projection, pc.span, &mut sub_state, true)
1691 .unwrap_or_else(|| self.lower_expr(&pc.projection, pc.span, &mut sub_state));
1692 sub_state.builder.push_op_mut(GraphOp::Project {
1693 items: vec![ProjectItem {
1694 expr: projection,
1695 alias: Some(PATTERN_COMPREHENSION_VALUE_ALIAS.into()),
1696 out_var: None,
1697 }],
1698 distinct: false,
1699 });
1700
1701 s.next_var = s.next_var.max(sub_state.next_var);
1702 s.errors.extend(sub_state.errors);
1703 s.warnings.extend(sub_state.warnings);
1704 children.push(sub_state.builder.build());
1705 }
1706 if s.errors.len() > prior_error_count {
1707 return s.builder.push_expr(IrExpr::Literal(IrLiteral::Null));
1708 }
1709
1710 let mut outputs = Vec::with_capacity(children.len());
1711 for (index, child) in children.into_iter().enumerate() {
1712 let child_output = if index == 0 {
1713 output
1714 } else {
1715 let next = VarId(s.next_var);
1716 s.next_var += 1;
1717 next
1718 };
1719 let child = Box::new(child);
1720 if let Some(captured) = s.captured_pattern_comprehensions.as_mut() {
1721 captured.push((child, child_output));
1722 } else {
1723 s.builder.push_op_mut(GraphOp::PatternComprehension {
1724 child,
1725 output: child_output,
1726 });
1727 }
1728 outputs.push(s.builder.push_expr(IrExpr::VarRef(child_output)));
1729 }
1730 let mut outputs = outputs.into_iter();
1731 let Some(mut combined) = outputs.next() else {
1732 return s.builder.push_expr(IrExpr::ListLiteral(Vec::new()));
1733 };
1734 for next in outputs {
1735 combined = s.builder.push_expr(IrExpr::BinaryOp {
1736 op: BinaryOpKind::Add,
1737 left: combined,
1738 right: next,
1739 });
1740 }
1741 combined
1742 }
1743
1744 fn reject_bare_graph_value_predicate(expr: &Expr, span: Span, s: &mut BinderState) {
1745 let Expr::Var(VarRef { name, .. }) = strip_parens(expr) else {
1746 return;
1747 };
1748 if s.path_vars.contains_key(name) {
1749 s.errors.push(BindError::new(
1750 BindErrorKind::InvalidArgument,
1751 span,
1752 format!("WHERE predicate `{name}` must be a boolean expression"),
1753 ));
1754 return;
1755 }
1756 let Some(var_id) = s.vars.get(name).copied() else {
1757 return;
1758 };
1759 if s.node_vars.contains_key(&var_id) || s.edge_rel_names.contains_key(&var_id) {
1760 s.errors.push(BindError::new(
1761 BindErrorKind::InvalidArgument,
1762 span,
1763 format!("WHERE predicate `{name}` must be a boolean expression"),
1764 ));
1765 }
1766 }
1767
1768 #[allow(clippy::too_many_lines)]
1771 fn lower_with(&self, w: &WithClause, s: &mut BinderState) {
1772 type ForwardedEdge = (VarId, Option<String>, Option<(VarId, VarId)>);
1773
1774 let items_ast = expand_projection_wildcard(&w.items, s);
1775 check_duplicate_aliases(&items_ast, s);
1776 if w.order_by
1777 .as_ref()
1778 .is_some_and(|order_by| has_unprojected_order_aggregate(&items_ast, &order_by.items))
1779 {
1780 s.errors.push(BindError::new(
1781 BindErrorKind::InvalidArgument,
1782 w.order_by.as_ref().expect("checked above").span,
1783 "an aggregate function in ORDER BY must also appear in the WITH projection",
1784 ));
1785 return;
1786 }
1787 let has_nested_agg = items_ast.iter().any(|i| match agg_func_of(&i.expr) {
1802 Some(_) => match &i.expr {
1805 Expr::FunctionCall(call) => call.args.iter().any(expr_contains_aggregate),
1806 _ => false,
1807 },
1808 None => expr_contains_aggregate(&i.expr),
1810 });
1811 let has_aggregate_inside_aggregate = items_ast
1812 .iter()
1813 .any(|item| expr_contains_aggregate_inside_aggregate(&item.expr, false));
1814 if has_aggregate_inside_aggregate {
1815 s.errors.push(BindError::new(
1816 BindErrorKind::InvalidArgument,
1817 w.span,
1818 "an aggregate function may not contain another aggregate function",
1819 ));
1820 return;
1821 }
1822 if has_nested_agg && items_ast.iter().any(|i| expr_contains_aggregate(&i.expr)) {
1823 self.lower_with_aggregate_arith(w, &items_ast, s);
1824 return;
1825 }
1826 if !has_nested_agg && items_ast.iter().any(|i| agg_func_of(&i.expr).is_some()) {
1827 self.lower_with_aggregate(w, &items_ast, s);
1828 return;
1829 }
1830 let mut items: Vec<ProjectItem> = Vec::with_capacity(items_ast.len());
1831 let mut new_scope: Vec<(String, VarId)> = Vec::new();
1832 let mut forwarded_nodes: Vec<(VarId, Option<String>)> = Vec::new();
1836 let mut forwarded_edges: Vec<ForwardedEdge> = Vec::new();
1837 let mut forwarded_paths: Vec<(String, PathBinding)> = Vec::new();
1838 let mut forwarded_path_vars = HashSet::new();
1839 for item in &items_ast {
1840 if expr_contains_aggregate(&item.expr) {
1845 s.errors.push(BindError::new(
1846 BindErrorKind::UnsupportedClause,
1847 item.span,
1848 "aggregation in WITH is not yet supported (#814 follow-up)".to_string(),
1849 ));
1850 }
1851
1852 let node_forward = match &item.expr {
1858 Expr::Var(VarRef { name, .. }) => s
1859 .vars
1860 .get(name)
1861 .copied()
1862 .filter(|v| s.node_vars.contains_key(v)),
1863 _ => None,
1864 };
1865 let edge_forward = match &item.expr {
1866 Expr::Var(VarRef { name, .. }) => s
1867 .vars
1868 .get(name)
1869 .copied()
1870 .filter(|v| s.edge_rel_names.contains_key(v)),
1871 _ => None,
1872 };
1873 let computed_node_label = match &item.expr {
1874 Expr::FunctionCall(call)
1875 if is_function_named(call, "coalesce") && !call.args.is_empty() =>
1876 {
1877 call.args
1878 .iter()
1879 .map(|arg| match arg {
1880 Expr::Var(VarRef { name, .. }) => s
1881 .vars
1882 .get(name)
1883 .and_then(|var| s.node_vars.get(var))
1884 .cloned(),
1885 _ => None,
1886 })
1887 .collect::<Option<Vec<_>>>()
1888 .map(|labels| labels.into_iter().flatten().next())
1889 }
1890 _ => None,
1891 };
1892 let path_forward = match &item.expr {
1893 Expr::Var(VarRef { name, .. }) => s.path_vars.get(name).cloned(),
1894 _ => None,
1895 };
1896 let expr_id = if computed_node_label.is_some() || path_forward.is_some() {
1897 self.lower_return_item_expr(&item.expr, item.span, s, true)
1898 } else {
1899 self.lower_expr(&item.expr, item.span, s)
1900 };
1901
1902 if let Some(v) = node_forward {
1903 let label = s.node_vars.get(&v).cloned().flatten();
1907 let expr_name = match &item.expr {
1908 Expr::Var(VarRef { name, .. }) => name.clone(),
1909 _ => unreachable!("node_forward is Some only for a Var item"),
1910 };
1911 let name = item.alias.clone().unwrap_or(expr_name);
1912 let out_var = alloc_anon_var(s);
1913 forwarded_nodes.push((out_var, label));
1914 new_scope.push((name.clone(), out_var));
1915 items.push(ProjectItem {
1916 expr: expr_id,
1917 alias: Some(name),
1918 out_var: Some(out_var),
1919 });
1920 continue;
1921 }
1922 if let Some(v) = edge_forward {
1923 let rel_name = s.edge_rel_names.get(&v).cloned().flatten();
1924 let endpoints = s.edge_vars.get(&v).copied();
1925 let expr_name = match &item.expr {
1926 Expr::Var(VarRef { name, .. }) => name.clone(),
1927 _ => unreachable!("edge_forward is Some only for a Var item"),
1928 };
1929 let name = item.alias.clone().unwrap_or(expr_name);
1930 let out_var = alloc_anon_var(s);
1931 forwarded_edges.push((out_var, rel_name, endpoints));
1932 new_scope.push((name.clone(), out_var));
1933 items.push(ProjectItem {
1934 expr: expr_id,
1935 alias: Some(name),
1936 out_var: Some(out_var),
1937 });
1938 continue;
1939 }
1940
1941 let alias = item.alias.clone().or_else(|| match &item.expr {
1944 Expr::Var(VarRef { name, .. }) => Some(name.clone()),
1945 _ => None,
1946 });
1947 let Some(alias) = alias else {
1948 s.errors.push(BindError::new(
1949 BindErrorKind::UnsupportedClause,
1950 item.span,
1951 "a non-variable WITH item must be aliased (`expr AS name`)".to_string(),
1952 ));
1953 continue;
1954 };
1955 let out_var = alloc_anon_var(s);
1956 if matches!(item.expr, Expr::Literal(Literal::Null(_))) {
1957 s.var_kinds.insert(out_var, VarKind::Unknown);
1958 }
1959 if let Some(label) = computed_node_label {
1960 forwarded_nodes.push((out_var, label));
1961 }
1962 if let Some(binding) = path_forward {
1963 s.var_kinds.insert(out_var, VarKind::Unknown);
1964 forwarded_path_vars.extend(binding.nodes.iter().copied());
1965 forwarded_path_vars.extend(binding.segments.iter().map(|segment| segment.edge));
1966 forwarded_paths.push((alias.clone(), binding));
1967 }
1968 if let Expr::List(graphforge_ast::ListLiteral { elements, .. }) = &item.expr
1969 && !elements.is_empty()
1970 && elements.iter().all(|element| {
1971 matches!(element, Expr::Var(VarRef { name, .. })
1972 if s.vars.get(name).is_some_and(|var| s.edge_rel_names.contains_key(var)))
1973 })
1974 {
1975 forwarded_edges.push((out_var, None, None));
1976 }
1977 new_scope.push((alias.clone(), out_var));
1978 items.push(ProjectItem {
1979 expr: expr_id,
1980 alias: Some(alias),
1981 out_var: Some(out_var),
1982 });
1983 }
1984
1985 let mut forwarded_path_vars = forwarded_path_vars.into_iter().collect::<Vec<_>>();
1986 forwarded_path_vars.sort_by_key(|var| var.0);
1987 for var in forwarded_path_vars {
1988 let expr = s.builder.push_expr(IrExpr::VarRef(var));
1989 items.push(ProjectItem {
1990 expr,
1991 alias: Some(format!("__gf_path_component_{}", var.0)),
1992 out_var: Some(var),
1993 });
1994 if let Some(label) = s.node_vars.get(&var).cloned() {
1995 forwarded_nodes.push((var, label));
1996 }
1997 if let Some(rel_name) = s.edge_rel_names.get(&var).cloned() {
1998 forwarded_edges.push((var, rel_name, s.edge_vars.get(&var).copied()));
1999 }
2000 }
2001
2002 if let Some(wc) = w
2007 .where_clause
2008 .as_ref()
2009 .filter(|wc| !expr_contains_pattern_predicate(&wc.predicate))
2010 {
2011 let predicate = rewrite_projection_alias_refs(wc.predicate.clone(), &items_ast);
2012 let predicate = self.lower_expr(&predicate, wc.span, s);
2013 s.builder.push_op_mut(GraphOp::Filter { predicate });
2014 }
2015 let where_pattern_predicate = w
2016 .where_clause
2017 .as_ref()
2018 .filter(|wc| expr_contains_pattern_predicate(&wc.predicate))
2019 .cloned();
2020
2021 let projection_bindings: Vec<(Expr, String, VarId)> = items_ast
2022 .iter()
2023 .filter_map(|item| {
2024 let alias = item.alias.clone().or_else(|| match &item.expr {
2025 Expr::Var(VarRef { name, .. }) => Some(name.clone()),
2026 _ => None,
2027 })?;
2028 let var = new_scope
2029 .iter()
2030 .find_map(|(name, var)| (name == &alias).then_some(*var))?;
2031 Some((item.expr.clone(), alias, var))
2032 })
2033 .collect();
2034
2035 let projected_names = projection_bindings
2040 .iter()
2041 .map(|(_, alias, _)| alias.as_str())
2042 .collect::<std::collections::HashSet<_>>();
2043 let sort_fits_projection = w.order_by.as_ref().is_none_or(|order_by| {
2044 order_by.items.iter().all(|item| {
2045 let rewritten = rewrite_grouping_refs(item.expr.clone(), &projection_bindings);
2046 let mut refs = Vec::new();
2047 collect_grouping_refs(&rewritten, &mut refs);
2048 refs.iter().all(|reference| {
2049 grouping_ref_root_name(reference)
2050 .is_some_and(|name| projected_names.contains(name))
2051 })
2052 })
2053 });
2054 let sort_before_projection = w.order_by.is_some() && !w.distinct && !sort_fits_projection;
2055 if sort_before_projection {
2056 self.push_sort_before_projection(
2057 &w.order_by.as_ref().expect("checked above").items,
2058 &items_ast,
2059 s,
2060 );
2061 }
2062
2063 s.vars.clear();
2066 s.node_vars.clear();
2067 s.edge_vars.clear();
2068 s.edge_rel_names.clear();
2069 s.path_vars.clear();
2070 for (name, v) in new_scope {
2071 s.vars.insert(name, v);
2072 }
2073 for (v, label) in forwarded_nodes {
2074 s.node_vars.insert(v, label);
2075 }
2076 for (v, rel_name, endpoints) in forwarded_edges {
2077 s.edge_rel_names.insert(v, rel_name);
2078 if let Some(endpoints) = endpoints {
2079 s.edge_vars.insert(v, endpoints);
2080 }
2081 }
2082 for (name, binding) in forwarded_paths {
2083 s.path_vars.insert(name, binding);
2084 }
2085
2086 s.builder.push_op_mut(GraphOp::With {
2087 items,
2088 distinct: w.distinct,
2089 where_predicate: None,
2090 });
2091 if let Some(wc) = where_pattern_predicate {
2092 self.lower_where_predicate(&wc.predicate, wc.span, s);
2093 }
2094 if let Some(ob) = &w.order_by
2095 && !sort_before_projection
2096 {
2097 self.push_sort_rewritten(&ob.items, &projection_bindings, s);
2098 }
2099 push_skip_limit(self, w.skip.as_ref(), w.limit.as_ref(), s);
2100 }
2101
2102 #[allow(clippy::too_many_lines)]
2115 fn lower_with_aggregate(&self, w: &WithClause, items: &[ReturnItem], s: &mut BinderState) {
2116 let mut group_by: Vec<ExprId> = Vec::new();
2117 let mut group_aliases: Vec<Option<String>> = Vec::new();
2118 let mut group_vars: Vec<Option<VarId>> = Vec::new();
2119 let mut aggs: Vec<AggExpr> = Vec::new();
2120 let mut new_scope: Vec<(String, VarId)> = Vec::new();
2121 let mut forwarded_nodes: Vec<(VarId, Option<String>)> = Vec::new();
2122 let mut forwarded_edges: Vec<ForwardedEdgeBinding> = Vec::new();
2123 let mut forwarded_paths: Vec<(String, PathBinding)> = Vec::new();
2124 let mut projection_bindings: Vec<(Expr, String, VarId)> = Vec::new();
2125
2126 for item in items {
2127 let alias = item.alias.clone().or_else(|| match &item.expr {
2129 Expr::Var(VarRef { name, .. }) => Some(name.clone()),
2130 _ => None,
2131 });
2132
2133 if let Some(func) = agg_func_of(&item.expr) {
2134 let Expr::FunctionCall(call) = &item.expr else {
2135 unreachable!("agg_func_of only matches FunctionCall");
2136 };
2137 let Some(alias) = alias else {
2138 s.errors.push(BindError::new(
2139 BindErrorKind::UnsupportedClause,
2140 item.span,
2141 "an aggregate in WITH must be aliased (`count(*) AS name`)".to_string(),
2142 ));
2143 continue;
2144 };
2145 let out_var = alloc_anon_var(s);
2146 aggs.push(self.build_agg(call, func, alias.clone(), Some(out_var), s));
2147 projection_bindings.push((item.expr.clone(), alias.clone(), out_var));
2148 new_scope.push((alias, out_var));
2149 } else {
2150 if let Expr::Var(VarRef { name, .. }) = &item.expr
2151 && let Some(binding) = s.path_vars.get(name).cloned()
2152 {
2153 let Some(alias) = alias else {
2154 unreachable!("a bare path always has its variable name as alias")
2155 };
2156 let mut vars = binding.nodes.clone();
2157 vars.extend(binding.segments.iter().map(|segment| segment.edge));
2158 vars.sort_by_key(|var| var.0);
2159 vars.dedup();
2160 for var in vars {
2161 let expr = s.builder.push_expr(IrExpr::VarRef(var));
2162 group_by.push(expr);
2163 group_aliases.push(None);
2164 group_vars.push(Some(var));
2165 if let Some(label) = s.node_vars.get(&var).cloned() {
2166 forwarded_nodes.push((var, label));
2167 }
2168 if let Some(rel_name) = s.edge_rel_names.get(&var).cloned() {
2169 forwarded_edges.push(ForwardedEdgeBinding {
2170 var,
2171 rel_name,
2172 endpoints: s.edge_vars.get(&var).copied(),
2173 });
2174 }
2175 }
2176 forwarded_paths.push((alias, binding));
2177 continue;
2178 }
2179
2180 let entity_var = match &item.expr {
2181 Expr::Var(VarRef { name, .. }) => s.vars.get(name).copied().filter(|var| {
2182 s.node_vars.contains_key(var) || s.edge_rel_names.contains_key(var)
2183 }),
2184 Expr::FunctionCall(call) => Self::resolve_endpoint_node(call, s),
2185 _ => None,
2186 };
2187 if let Some(var) = entity_var {
2188 let Some(alias) = alias else {
2189 unreachable!("a graph variable always has an output alias")
2190 };
2191 let expr = s.builder.push_expr(IrExpr::VarRef(var));
2192 group_by.push(expr);
2193 group_aliases.push(None);
2194 group_vars.push(Some(var));
2195 projection_bindings.push((item.expr.clone(), alias.clone(), var));
2196 new_scope.push((alias, var));
2197 if let Some(label) = s.node_vars.get(&var).cloned() {
2198 forwarded_nodes.push((var, label));
2199 }
2200 if let Some(rel_name) = s.edge_rel_names.get(&var).cloned() {
2201 forwarded_edges.push(ForwardedEdgeBinding {
2202 var,
2203 rel_name,
2204 endpoints: s.edge_vars.get(&var).copied(),
2205 });
2206 }
2207 continue;
2208 }
2209 let Some(alias) = alias else {
2210 s.errors.push(BindError::new(
2211 BindErrorKind::UnsupportedClause,
2212 item.span,
2213 "a non-variable WITH item must be aliased (`expr AS name`)".to_string(),
2214 ));
2215 continue;
2216 };
2217 let expr_id = self.lower_expr(&item.expr, item.span, s);
2218 let out_var = alloc_anon_var(s);
2219 group_by.push(expr_id);
2220 group_aliases.push(Some(alias.clone()));
2221 group_vars.push(Some(out_var));
2222 projection_bindings.push((item.expr.clone(), alias.clone(), out_var));
2223 new_scope.push((alias, out_var));
2224 }
2225 }
2226 s.vars.clear();
2228 s.node_vars.clear();
2229 s.edge_vars.clear();
2230 s.edge_rel_names.clear();
2231 s.path_vars.clear();
2232 for (name, v) in new_scope {
2233 s.vars.insert(name, v);
2234 }
2235 for (var, label) in forwarded_nodes {
2236 s.node_vars.insert(var, label);
2237 }
2238 for edge in forwarded_edges {
2239 s.edge_rel_names.insert(edge.var, edge.rel_name);
2240 if let Some(endpoints) = edge.endpoints {
2241 s.edge_vars.insert(edge.var, endpoints);
2242 }
2243 }
2244 for (name, binding) in forwarded_paths {
2245 s.path_vars.insert(name, binding);
2246 }
2247
2248 s.builder.push_op_mut(GraphOp::Aggregate {
2249 group_by,
2250 group_aliases,
2251 group_vars,
2252 aggs,
2253 });
2254
2255 if let Some(wc) = &w.where_clause {
2258 let pred = self.lower_expr(&wc.predicate, wc.span, s);
2259 s.builder.push_op_mut(GraphOp::Filter { predicate: pred });
2260 }
2261 if let Some(ob) = &w.order_by {
2262 self.push_sort_rewritten(&ob.items, &projection_bindings, s);
2263 }
2264 push_skip_limit(self, w.skip.as_ref(), w.limit.as_ref(), s);
2265 }
2266
2267 #[allow(clippy::too_many_lines)]
2270 fn lower_with_aggregate_arith(
2271 &self,
2272 w: &WithClause,
2273 items: &[ReturnItem],
2274 s: &mut BinderState,
2275 ) {
2276 let group_items: Vec<&ReturnItem> = items
2277 .iter()
2278 .filter(|item| !expr_contains_aggregate(&item.expr))
2279 .collect();
2280 let mut group_by = Vec::with_capacity(group_items.len());
2281 let mut group_aliases = Vec::with_capacity(group_items.len());
2282 let mut group_vars = Vec::with_capacity(group_items.len());
2283 let mut group_bindings: Vec<(Expr, String, VarId)> = Vec::with_capacity(group_items.len());
2284 let mut grouped_nodes: Vec<(String, VarId, Option<String>)> = Vec::new();
2285 let mut grouped_edges: Vec<GroupedEdgeBinding> = Vec::new();
2286 let mut grouped_paths: Vec<(String, PathBinding)> = Vec::new();
2287 let mut grouped_path_nodes: Vec<(VarId, Option<String>)> = Vec::new();
2288 let mut grouped_path_edges: Vec<ForwardedEdgeBinding> = Vec::new();
2289
2290 for item in &group_items {
2291 let alias = item.alias.clone().or_else(|| match &item.expr {
2292 Expr::Var(VarRef { name, .. }) => Some(name.clone()),
2293 _ => None,
2294 });
2295 let Some(alias) = alias else {
2296 s.errors.push(BindError::new(
2297 BindErrorKind::UnsupportedClause,
2298 item.span,
2299 "a non-variable WITH item must be aliased (`expr AS name`)",
2300 ));
2301 continue;
2302 };
2303 if let Expr::Var(VarRef { name, .. }) = &item.expr
2304 && let Some(binding) = s.path_vars.get(name).cloned()
2305 {
2306 let mut vars = binding.nodes.clone();
2307 vars.extend(binding.segments.iter().map(|segment| segment.edge));
2308 vars.sort_by_key(|var| var.0);
2309 vars.dedup();
2310 for var in vars {
2311 group_by.push(s.builder.push_expr(IrExpr::VarRef(var)));
2312 group_aliases.push(None);
2313 group_vars.push(Some(var));
2314 if let Some(label) = s.node_vars.get(&var).cloned() {
2315 grouped_path_nodes.push((var, label));
2316 }
2317 if let Some(rel_name) = s.edge_rel_names.get(&var).cloned() {
2318 grouped_path_edges.push(ForwardedEdgeBinding {
2319 var,
2320 rel_name,
2321 endpoints: s.edge_vars.get(&var).copied(),
2322 });
2323 }
2324 }
2325 grouped_paths.push((alias, binding));
2326 continue;
2327 }
2328 let entity_var = match &item.expr {
2329 Expr::Var(VarRef { name, .. }) => s.vars.get(name).copied().filter(|var| {
2330 s.node_vars.contains_key(var) || s.edge_rel_names.contains_key(var)
2331 }),
2332 Expr::FunctionCall(call) => Self::resolve_endpoint_node(call, s),
2333 _ => None,
2334 };
2335 if let Some(var) = entity_var {
2336 group_by.push(s.builder.push_expr(IrExpr::VarRef(var)));
2337 group_aliases.push(None);
2338 group_vars.push(Some(var));
2339 group_bindings.push((item.expr.clone(), alias.clone(), var));
2340 if let Some(label) = s.node_vars.get(&var).cloned() {
2341 grouped_nodes.push((alias, var, label));
2342 } else if let Some(rel_name) = s.edge_rel_names.get(&var).cloned() {
2343 grouped_edges.push(GroupedEdgeBinding {
2344 alias,
2345 var,
2346 rel_name,
2347 endpoints: s.edge_vars.get(&var).copied(),
2348 });
2349 }
2350 continue;
2351 }
2352
2353 let out_var = alloc_anon_var(s);
2354 group_by.push(self.lower_expr(&item.expr, item.span, s));
2355 group_aliases.push(Some(alias.clone()));
2356 group_vars.push(Some(out_var));
2357 group_bindings.push((item.expr.clone(), alias, out_var));
2358 }
2359
2360 let mut aggs = Vec::new();
2361 let mut aggregate_bindings = Vec::new();
2362 let mut rewritten_items = Vec::with_capacity(items.len());
2363 for item in items {
2364 let alias = item.alias.clone().or_else(|| match &item.expr {
2365 Expr::Var(VarRef { name, .. }) => Some(name.clone()),
2366 _ => None,
2367 });
2368 let Some(alias) = alias else {
2369 s.errors.push(BindError::new(
2370 BindErrorKind::UnsupportedClause,
2371 item.span,
2372 "an aggregate expression in WITH must be aliased (`expr AS name`)",
2373 ));
2374 continue;
2375 };
2376
2377 if expr_contains_aggregate(&item.expr) {
2378 let mut refs = Vec::new();
2379 collect_grouping_refs(&item.expr, &mut refs);
2380 let ambiguous = refs.iter().any(|reference| {
2381 !group_bindings.iter().any(|(group, _, _)| {
2382 is_atomic_grouping_expr(group) && same_grouping_expr(reference, group)
2383 })
2384 });
2385 if ambiguous {
2386 s.errors.push(BindError::new(
2387 BindErrorKind::InvalidArgument,
2388 item.span,
2389 "ambiguous aggregation expression: every variable or property outside an \
2390 aggregate must be projected as its own WITH grouping key",
2391 ));
2392 }
2393 let first_agg = aggs.len();
2394 let rewritten = self.rewrite_aggs(&item.expr, &mut aggs, s);
2395 for (index, agg) in aggs.iter().enumerate().skip(first_agg) {
2396 if let Some(var) = agg.out_var {
2397 aggregate_bindings.push((format!("__agg_{index}"), var));
2398 }
2399 }
2400 if aggs.len() == first_agg + 1 {
2401 aggs[first_agg].alias.clone_from(&alias);
2402 }
2403 rewritten_items.push((rewritten, alias, item.span));
2404 } else {
2405 rewritten_items.push((
2406 Expr::Var(VarRef {
2407 name: alias.clone(),
2408 span: item.span,
2409 }),
2410 alias,
2411 item.span,
2412 ));
2413 }
2414 }
2415
2416 let aggregate_scope: Vec<(String, VarId)> = group_bindings
2417 .iter()
2418 .map(|(_, alias, var)| (alias.clone(), *var))
2419 .chain(aggregate_bindings)
2420 .collect();
2421
2422 s.vars.clear();
2423 s.node_vars.clear();
2424 s.edge_vars.clear();
2425 s.edge_rel_names.clear();
2426 s.path_vars.clear();
2427 for (name, var) in &aggregate_scope {
2428 s.vars.insert(name.clone(), *var);
2429 }
2430 for (_, var, label) in &grouped_nodes {
2431 s.node_vars.insert(*var, label.clone());
2432 }
2433 for edge in &grouped_edges {
2434 s.edge_rel_names.insert(edge.var, edge.rel_name.clone());
2435 if let Some(endpoints) = edge.endpoints {
2436 s.edge_vars.insert(edge.var, endpoints);
2437 }
2438 }
2439 for (var, label) in &grouped_path_nodes {
2440 s.node_vars.insert(*var, label.clone());
2441 }
2442 for edge in &grouped_path_edges {
2443 s.edge_rel_names.insert(edge.var, edge.rel_name.clone());
2444 if let Some(endpoints) = edge.endpoints {
2445 s.edge_vars.insert(edge.var, endpoints);
2446 }
2447 }
2448 for (alias, binding) in &grouped_paths {
2449 s.path_vars.insert(alias.clone(), binding.clone());
2450 }
2451
2452 s.builder.push_op_mut(GraphOp::Aggregate {
2453 group_by,
2454 group_aliases,
2455 group_vars,
2456 aggs,
2457 });
2458
2459 let mut project = Vec::with_capacity(rewritten_items.len());
2460 let mut final_scope = Vec::with_capacity(rewritten_items.len());
2461 let mut final_nodes = Vec::new();
2462 let mut final_edges = Vec::new();
2463 let mut final_paths = Vec::new();
2464 for (expr, alias, span) in rewritten_items {
2465 let expr = rewrite_grouping_refs(expr, &group_bindings);
2466 let expr = self.lower_expr(&expr, span, s);
2467 let node_binding = grouped_nodes
2468 .iter()
2469 .find(|(group_alias, _, _)| group_alias == &alias);
2470 let edge_binding = grouped_edges.iter().find(|binding| binding.alias == alias);
2471 let out_var = node_binding
2472 .map(|(_, var, _)| *var)
2473 .or_else(|| edge_binding.map(|binding| binding.var))
2474 .unwrap_or_else(|| alloc_anon_var(s));
2475 project.push(ProjectItem {
2476 expr,
2477 alias: Some(alias.clone()),
2478 out_var: Some(out_var),
2479 });
2480 if let Some((_, _, label)) = node_binding {
2481 final_nodes.push((out_var, label.clone()));
2482 }
2483 if let Some(binding) = edge_binding {
2484 final_edges.push((out_var, binding.rel_name.clone(), binding.endpoints));
2485 }
2486 if let Some((_, binding)) = grouped_paths
2487 .iter()
2488 .find(|(group_alias, _)| group_alias == &alias)
2489 {
2490 final_paths.push((alias.clone(), binding.clone()));
2491 }
2492 final_scope.push((alias, out_var));
2493 }
2494 let mut hidden_path_vars = grouped_paths
2498 .iter()
2499 .flat_map(|(_, binding)| {
2500 binding
2501 .nodes
2502 .iter()
2503 .copied()
2504 .chain(binding.segments.iter().map(|segment| segment.edge))
2505 })
2506 .collect::<Vec<_>>();
2507 hidden_path_vars.sort_by_key(|var| var.0);
2508 hidden_path_vars.dedup();
2509 for var in hidden_path_vars {
2510 project.push(ProjectItem {
2511 expr: s.builder.push_expr(IrExpr::VarRef(var)),
2512 alias: Some(format!("__path_{}", var.0)),
2513 out_var: Some(var),
2514 });
2515 }
2516 s.builder.push_op_mut(GraphOp::With {
2517 items: project,
2518 distinct: w.distinct,
2519 where_predicate: None,
2520 });
2521
2522 s.vars.clear();
2523 s.node_vars.clear();
2524 s.edge_vars.clear();
2525 s.edge_rel_names.clear();
2526 s.path_vars.clear();
2527 for (name, var) in final_scope {
2528 s.vars.insert(name, var);
2529 }
2530 for (var, label) in final_nodes {
2531 s.node_vars.insert(var, label);
2532 }
2533 for (var, rel_name, endpoints) in final_edges {
2534 s.edge_rel_names.insert(var, rel_name);
2535 if let Some(endpoints) = endpoints {
2536 s.edge_vars.insert(var, endpoints);
2537 }
2538 }
2539 for (name, binding) in final_paths {
2540 s.path_vars.insert(name, binding);
2541 }
2542 for (var, label) in grouped_path_nodes {
2543 s.node_vars.insert(var, label);
2544 }
2545 for edge in grouped_path_edges {
2546 s.edge_rel_names.insert(edge.var, edge.rel_name);
2547 if let Some(endpoints) = edge.endpoints {
2548 s.edge_vars.insert(edge.var, endpoints);
2549 }
2550 }
2551 if let Some(wc) = &w.where_clause {
2552 self.lower_where_predicate(&wc.predicate, wc.span, s);
2553 }
2554 if let Some(ob) = &w.order_by {
2555 self.push_sort(&ob.items, s);
2556 }
2557 push_skip_limit(self, w.skip.as_ref(), w.limit.as_ref(), s);
2558 }
2559
2560 fn lower_return(&self, r: &graphforge_ast::ReturnClause, s: &mut BinderState) {
2561 if reject_empty_projection_wildcard(&r.items, s) {
2562 return;
2563 }
2564 check_duplicate_aliases(&r.items, s);
2565 let items = expand_projection_wildcard(&r.items, s);
2567 if r.order_by
2568 .as_ref()
2569 .is_some_and(|order_by| has_unprojected_order_aggregate(&items, &order_by.items))
2570 {
2571 s.errors.push(BindError::new(
2572 BindErrorKind::InvalidArgument,
2573 r.order_by.as_ref().expect("checked above").span,
2574 "an aggregate function in ORDER BY must also appear in the RETURN projection",
2575 ));
2576 return;
2577 }
2578 let has_nested = items
2583 .iter()
2584 .any(|i| agg_func_of(&i.expr).is_none() && expr_contains_aggregate(&i.expr));
2585 if items
2586 .iter()
2587 .any(|item| expr_contains_aggregate_inside_aggregate(&item.expr, false))
2588 {
2589 s.errors.push(BindError::new(
2590 BindErrorKind::InvalidArgument,
2591 r.span,
2592 "an aggregate function may not contain another aggregate function",
2593 ));
2594 return;
2595 }
2596 let mut aggregate_exprs = Vec::new();
2597 for item in &items {
2598 collect_aggregate_exprs(&item.expr, &mut aggregate_exprs);
2599 }
2600 if aggregate_exprs.iter().any(|expr| match expr {
2601 Expr::FunctionCall(call) => call.args.iter().any(expr_contains_volatile_function),
2602 _ => false,
2603 }) {
2604 s.errors.push(BindError::new(
2605 BindErrorKind::InvalidArgument,
2606 r.span,
2607 "non-deterministic functions are not allowed inside aggregate arguments",
2608 ));
2609 return;
2610 }
2611 if has_nested {
2616 self.lower_nested_return(r, &items, s);
2617 } else if items.iter().any(|i| agg_func_of(&i.expr).is_some()) {
2618 let bindings = self.lower_return_aggregate(&items, s);
2619 if let Some(ob) = &r.order_by {
2620 self.push_sort_rewritten(&ob.items, &bindings, s);
2621 }
2622 push_skip_limit(self, r.skip.as_ref(), r.limit.as_ref(), s);
2623 } else {
2624 let sort_before_projection = r.order_by.is_some() && !r.distinct;
2625 let (lowered, projection_scope) =
2626 self.lower_return_items(&items, s, true, r.order_by.is_some());
2627 if sort_before_projection {
2628 self.push_sort_before_projection(
2629 &r.order_by.as_ref().expect("checked above").items,
2630 &items,
2631 s,
2632 );
2633 }
2634 let projection_bindings: Vec<(Expr, String, VarId)> = items
2635 .iter()
2636 .zip(&projection_scope)
2637 .map(|(item, (alias, var))| (item.expr.clone(), alias.clone(), *var))
2638 .collect();
2639 s.builder.push_op_mut(GraphOp::Project {
2640 items: lowered,
2641 distinct: r.distinct,
2642 });
2643 if r.distinct {
2644 s.vars.clear();
2645 s.node_vars.clear();
2646 s.edge_vars.clear();
2647 s.edge_rel_names.clear();
2648 s.path_vars.clear();
2649 }
2650 for (name, v) in &projection_scope {
2651 s.vars.insert(name.clone(), *v);
2652 }
2653 if let Some(ob) = &r.order_by
2654 && !sort_before_projection
2655 {
2656 self.push_sort_rewritten(&ob.items, &projection_bindings, s);
2657 }
2658 push_skip_limit(self, r.skip.as_ref(), r.limit.as_ref(), s);
2659 }
2660 }
2661
2662 fn lower_nested_return(
2663 &self,
2664 r: &graphforge_ast::ReturnClause,
2665 items: &[ReturnItem],
2666 s: &mut BinderState,
2667 ) {
2668 let aggregate_items = items
2669 .iter()
2670 .cloned()
2671 .map(|mut item| {
2672 if item.alias.is_none() {
2673 item.alias.clone_from(&item.display);
2674 }
2675 item
2676 })
2677 .collect::<Vec<_>>();
2678 let aggregate_return = WithClause {
2679 distinct: r.distinct,
2680 items: aggregate_items.clone(),
2681 order_by: r.order_by.clone(),
2682 skip: r.skip.clone(),
2683 limit: r.limit.clone(),
2684 where_clause: None,
2685 span: r.span,
2686 };
2687 self.lower_with_aggregate_arith(&aggregate_return, &aggregate_items, s);
2688 let terminal_items = aggregate_items
2689 .iter()
2690 .filter_map(|item| {
2691 let name = item.alias.clone()?;
2692 Some(ReturnItem {
2693 expr: Expr::Var(VarRef {
2694 name: name.clone(),
2695 span: item.span,
2696 }),
2697 alias: Some(name.clone()),
2698 display: Some(name),
2699 span: item.span,
2700 })
2701 })
2702 .collect();
2703 self.lower_return(
2704 &graphforge_ast::ReturnClause {
2705 distinct: false,
2706 items: terminal_items,
2707 order_by: None,
2708 skip: None,
2709 limit: None,
2710 span: r.span,
2711 },
2712 s,
2713 );
2714 }
2715
2716 fn lower_unwind(&self, u: &graphforge_ast::UnwindClause, s: &mut BinderState) {
2717 let list_expr = self.lower_expr(&u.expr, u.span, s);
2718 let alias = ensure_var_name(&u.alias, s);
2719 s.var_kinds.insert(alias, VarKind::Unknown);
2720 s.builder.push_op_mut(GraphOp::Unwind { list_expr, alias });
2721 }
2722
2723 #[allow(clippy::too_many_lines)]
2725 fn lower_call(&self, call: &CallClause, s: &mut BinderState) {
2726 if call.procedure.is_empty() {
2727 s.errors.push(BindError::new(
2728 BindErrorKind::UnsupportedClause,
2729 call.span,
2730 "CALL subqueries are not procedure calls",
2731 ));
2732 return;
2733 }
2734
2735 let name = call.procedure.join(".");
2736 let Some(procedure) = self.procedures.get(&name).cloned() else {
2737 s.errors.push(BindError::new(
2738 BindErrorKind::InvalidArgument,
2739 call.span,
2740 format!("ProcedureNotFound: `{name}` is not registered"),
2741 ));
2742 return;
2743 };
2744
2745 if call.args.iter().any(expr_contains_aggregate) {
2746 s.errors.push(BindError::new(
2747 BindErrorKind::InvalidArgument,
2748 call.span,
2749 "InvalidAggregation: aggregate expressions are not valid procedure arguments",
2750 ));
2751 return;
2752 }
2753
2754 let args = if call.args_explicit {
2755 if call.args.len() != procedure.inputs.len() {
2756 s.errors.push(BindError::new(
2757 BindErrorKind::InvalidArgument,
2758 call.span,
2759 format!(
2760 "InvalidNumberOfArguments: `{name}` expects {}, found {}",
2761 procedure.inputs.len(),
2762 call.args.len()
2763 ),
2764 ));
2765 return;
2766 }
2767 for (arg, field) in call.args.iter().zip(&procedure.inputs) {
2768 if !procedure_argument_type_matches(arg, field) {
2769 s.errors.push(BindError::new(
2770 BindErrorKind::InvalidArgument,
2771 arg.span(),
2772 format!("InvalidArgumentType: expected {}", field.type_name),
2773 ));
2774 }
2775 }
2776 call.args
2777 .iter()
2778 .map(|arg| self.lower_expr(arg, call.span, s))
2779 .collect()
2780 } else {
2781 if !s.standalone_call && !procedure.inputs.is_empty() {
2782 s.errors.push(BindError::new(
2783 BindErrorKind::InvalidArgument,
2784 call.span,
2785 "InvalidArgumentPassingMode: in-query calls require explicit arguments",
2786 ));
2787 return;
2788 }
2789 procedure
2790 .inputs
2791 .iter()
2792 .map(|field| s.builder.push_expr(IrExpr::Parameter(field.name.clone())))
2793 .collect()
2794 };
2795
2796 let yield_all = call.yield_items.len() == 1
2797 && matches!(&call.yield_items[0].expr, Expr::Var(VarRef { name, .. }) if name == "*");
2798 if yield_all && !s.standalone_call {
2799 s.errors.push(BindError::new(
2800 BindErrorKind::InvalidArgument,
2801 call.span,
2802 "UnexpectedSyntax: YIELD * is only valid for standalone calls",
2803 ));
2804 return;
2805 }
2806 let selected: Vec<(String, String)> = if call.yield_items.is_empty() && !s.standalone_call {
2807 vec![]
2808 } else if call.yield_items.is_empty() || yield_all {
2809 procedure
2810 .outputs
2811 .iter()
2812 .map(|field| (field.name.clone(), field.name.clone()))
2813 .collect()
2814 } else {
2815 let mut selected = Vec::with_capacity(call.yield_items.len());
2816 for item in &call.yield_items {
2817 let Expr::Var(VarRef { name: field, .. }) = &item.expr else {
2818 s.errors.push(BindError::new(
2819 BindErrorKind::InvalidArgument,
2820 item.span,
2821 "YIELD items must name procedure outputs",
2822 ));
2823 continue;
2824 };
2825 if !procedure.outputs.iter().any(|output| output.name == *field) {
2826 s.errors.push(BindError::new(
2827 BindErrorKind::InvalidArgument,
2828 item.span,
2829 format!("ProcedureOutputNotFound: `{name}` has no output `{field}`"),
2830 ));
2831 continue;
2832 }
2833 selected.push((
2834 field.clone(),
2835 item.alias.clone().unwrap_or_else(|| field.clone()),
2836 ));
2837 }
2838 selected
2839 };
2840
2841 let mut yields = Vec::with_capacity(selected.len());
2842 for (field, alias) in selected {
2843 if s.vars.contains_key(&alias) {
2844 s.errors.push(BindError::new(
2845 BindErrorKind::DuplicateVariable,
2846 call.span,
2847 format!("VariableAlreadyBound: `{alias}`"),
2848 ));
2849 continue;
2850 }
2851 let var = ensure_var_name(&alias, s);
2852 yields.push(ProcedureYield { field, alias, var });
2853 }
2854
2855 s.builder.push_op_mut(GraphOp::Call {
2856 procedure,
2857 args,
2858 yields,
2859 });
2860 }
2861
2862 fn push_sort(&self, items: &[SortItem], s: &mut BinderState) {
2863 let keys: Vec<SortKey> = items
2864 .iter()
2865 .map(|item| {
2866 let expr = self.lower_expr(&item.expr, item.span, s);
2867 SortKey {
2868 expr,
2869 order: match item.order {
2870 AstSortOrder::Ascending => SortOrder::Asc,
2871 AstSortOrder::Descending => SortOrder::Desc,
2872 },
2873 nulls_first: false,
2874 }
2875 })
2876 .collect();
2877 if !keys.is_empty() {
2878 s.builder.push_op_mut(GraphOp::Sort { keys });
2879 }
2880 }
2881
2882 fn push_sort_rewritten(
2883 &self,
2884 items: &[SortItem],
2885 bindings: &[(Expr, String, VarId)],
2886 s: &mut BinderState,
2887 ) {
2888 let keys = items
2889 .iter()
2890 .map(|item| {
2891 let rewritten = rewrite_grouping_refs(item.expr.clone(), bindings);
2892 SortKey {
2893 expr: self.lower_expr(&rewritten, item.span, s),
2894 order: match item.order {
2895 AstSortOrder::Ascending => SortOrder::Asc,
2896 AstSortOrder::Descending => SortOrder::Desc,
2897 },
2898 nulls_first: false,
2899 }
2900 })
2901 .collect::<Vec<_>>();
2902 if !keys.is_empty() {
2903 s.builder.push_op_mut(GraphOp::Sort { keys });
2904 }
2905 }
2906
2907 fn push_sort_before_projection(
2908 &self,
2909 items: &[SortItem],
2910 projections: &[ReturnItem],
2911 s: &mut BinderState,
2912 ) {
2913 let rewritten = items
2914 .iter()
2915 .map(|item| {
2916 let expr = rewrite_projection_alias_refs(item.expr.clone(), projections);
2917 SortKey {
2918 expr: self.lower_expr(&expr, item.span, s),
2919 order: match item.order {
2920 AstSortOrder::Ascending => SortOrder::Asc,
2921 AstSortOrder::Descending => SortOrder::Desc,
2922 },
2923 nulls_first: false,
2924 }
2925 })
2926 .collect::<Vec<_>>();
2927 if !rewritten.is_empty() {
2928 s.builder.push_op_mut(GraphOp::Sort { keys: rewritten });
2929 }
2930 }
2931
2932 fn lower_return_items(
2933 &self,
2934 items: &[ReturnItem],
2935 s: &mut BinderState,
2936 materialize_nodes: bool,
2937 bind_projection_scope: bool,
2938 ) -> (Vec<ProjectItem>, Vec<(String, VarId)>) {
2939 let mut projection_scope = Vec::new();
2940 let out = items
2941 .iter()
2942 .map(|item| {
2943 let expr = self.lower_return_item_expr(&item.expr, item.span, s, materialize_nodes);
2944 let alias = item.alias.clone().or_else(|| match &item.expr {
2952 Expr::Var(VarRef { name, .. })
2953 if s.path_vars.contains_key(name)
2954 || (materialize_nodes
2955 && s.vars.get(name).is_some_and(|v| {
2956 s.node_vars.contains_key(v) || s.edge_rel_names.contains_key(v)
2957 })) =>
2958 {
2959 Some(name.clone())
2960 }
2961 _ => item.display.clone(),
2962 });
2963 let out_var = if bind_projection_scope {
2964 alias.as_ref().map(|name| {
2965 let v = alloc_anon_var(s);
2966 projection_scope.push((name.clone(), v));
2967 v
2968 })
2969 } else {
2970 None
2971 };
2972 ProjectItem {
2973 expr,
2974 alias,
2975 out_var,
2976 }
2977 })
2978 .collect();
2979 (out, projection_scope)
2980 }
2981
2982 fn lower_return_item_expr(
2990 &self,
2991 expr: &Expr,
2992 span: Span,
2993 s: &mut BinderState,
2994 materialize_nodes: bool,
2995 ) -> ExprId {
2996 if let Some(id) = self.lower_projection_value_expr(expr, span, s, materialize_nodes) {
2997 return id;
2998 }
2999 self.lower_expr(expr, span, s)
3000 }
3001
3002 fn lower_projection_value_expr(
3003 &self,
3004 expr: &Expr,
3005 span: Span,
3006 s: &mut BinderState,
3007 materialize_nodes: bool,
3008 ) -> Option<ExprId> {
3009 if materialize_nodes {
3010 let node_var = match expr {
3014 Expr::Var(VarRef { name, .. }) => s
3015 .vars
3016 .get(name)
3017 .copied()
3018 .filter(|v| s.node_vars.contains_key(v)),
3019 Expr::FunctionCall(call) => Self::resolve_endpoint_node(call, s),
3020 _ => None,
3021 };
3022 if let Some(v) = node_var {
3023 return Some(Self::node_struct_expr(v, s));
3024 }
3025 }
3026
3027 match expr {
3028 Expr::Var(VarRef { name, .. }) => {
3029 let var = s
3030 .vars
3031 .get(name)
3032 .copied()
3033 .filter(|v| s.edge_rel_names.contains_key(v))?;
3034 Some(if s.scalar_list_edges.contains(&var) {
3035 Self::relationship_struct_list_expr(var, s)
3036 } else {
3037 Self::relationship_struct_expr(var, s)
3038 })
3039 }
3040 Expr::FunctionCall(call)
3041 if is_function_named(call, "type")
3042 && call.args.len() == 1
3043 && !Self::invalid_direct_graph_function_argument(call, s) =>
3044 {
3045 let arg = self
3046 .lower_projection_value_expr(&call.args[0], call.span, s, materialize_nodes)
3047 .unwrap_or_else(|| self.lower_expr(&call.args[0], call.span, s));
3048 Some(s.builder.push_expr(IrExpr::FunctionCall {
3049 name: "type".into(),
3050 args: vec![arg],
3051 }))
3052 }
3053 Expr::FunctionCall(call)
3054 if materialize_nodes
3055 && is_function_named(call, "coalesce")
3056 && !call.args.is_empty() =>
3057 {
3058 let args = call
3059 .args
3060 .iter()
3061 .map(|arg| self.lower_projection_value_expr(arg, span, s, true))
3062 .collect::<Option<Vec<_>>>()?;
3063 Some(s.builder.push_expr(IrExpr::FunctionCall {
3064 name: "coalesce".into(),
3065 args,
3066 }))
3067 }
3068 Expr::List(graphforge_ast::ListLiteral { elements, .. }) => {
3069 let ids: Vec<ExprId> = elements
3070 .iter()
3071 .map(|e| {
3072 self.lower_projection_value_expr(e, span, s, materialize_nodes)
3073 .unwrap_or_else(|| self.lower_expr(e, span, s))
3074 })
3075 .collect();
3076 Some(s.builder.push_expr(IrExpr::ListLiteral(ids)))
3077 }
3078 _ => None,
3079 }
3080 }
3081
3082 fn lower_return_aggregate(
3086 &self,
3087 items: &[ReturnItem],
3088 s: &mut BinderState,
3089 ) -> Vec<(Expr, String, VarId)> {
3090 let mut group_by: Vec<ExprId> = Vec::new();
3091 let mut group_aliases: Vec<Option<String>> = Vec::new();
3092 let mut group_vars: Vec<Option<VarId>> = Vec::new();
3093 let mut aggs: Vec<AggExpr> = Vec::new();
3094 let mut bindings = Vec::with_capacity(items.len());
3095 for (idx, item) in items.iter().enumerate() {
3096 if let Some(func) = agg_func_of(&item.expr) {
3097 let Expr::FunctionCall(call) = &item.expr else {
3098 unreachable!("agg_func_of only matches FunctionCall");
3099 };
3100 let alias = item
3105 .alias
3106 .clone()
3107 .or_else(|| item.display.clone())
3108 .unwrap_or_else(|| format!("agg_{idx}"));
3109 let out_var = alloc_anon_var(s);
3110 aggs.push(self.build_agg(call, func, alias.clone(), Some(out_var), s));
3111 bindings.push((item.expr.clone(), alias, out_var));
3112 } else {
3113 group_by.push(self.lower_return_item_expr(&item.expr, item.span, s, true));
3119 let alias = item
3125 .alias
3126 .clone()
3127 .or_else(|| item.display.clone())
3128 .unwrap_or_else(|| format!("group_{idx}"));
3129 let out_var = alloc_anon_var(s);
3130 group_aliases.push(Some(alias.clone()));
3131 group_vars.push(Some(out_var));
3132 bindings.push((item.expr.clone(), alias, out_var));
3133 }
3134 }
3135 s.builder.push_op_mut(GraphOp::Aggregate {
3136 group_by,
3137 group_aliases,
3138 group_vars,
3139 aggs,
3140 });
3141 s.vars.clear();
3142 s.node_vars.clear();
3143 s.edge_vars.clear();
3144 s.edge_rel_names.clear();
3145 s.path_vars.clear();
3146 for (_, alias, var) in &bindings {
3147 s.vars.insert(alias.clone(), *var);
3148 }
3149 bindings
3150 }
3151
3152 fn build_agg(
3161 &self,
3162 call: &graphforge_ast::FunctionCall,
3163 func: AggFunc,
3164 alias: String,
3165 out_var: Option<VarId>,
3166 s: &mut BinderState,
3167 ) -> AggExpr {
3168 let bare_var_arg = matches!(call.args.first(), Some(Expr::Var(_)));
3169 let is_percentile = matches!(func, AggFunc::PercentileDisc | AggFunc::PercentileCont);
3170 if is_percentile {
3171 if call.star || call.args.len() != 2 {
3172 s.errors.push(BindError::new(
3173 BindErrorKind::InvalidArgument,
3174 call.span,
3175 "percentile aggregate functions require value and percentile arguments",
3176 ));
3177 }
3178 if call.distinct {
3179 s.errors.push(BindError::new(
3180 BindErrorKind::InvalidArgument,
3181 call.span,
3182 "percentile aggregate functions do not support DISTINCT",
3183 ));
3184 }
3185 }
3186 let arg = if !is_percentile
3187 && (call.star || (func == AggFunc::Count && bare_var_arg && !call.distinct))
3188 {
3189 None
3190 } else {
3191 call.args.first().map(|a| {
3192 if func == AggFunc::Collect {
3193 self.lower_projection_value_expr(a, call.span, s, true)
3194 .unwrap_or_else(|| self.lower_expr(a, call.span, s))
3195 } else {
3196 self.lower_expr(a, call.span, s)
3197 }
3198 })
3199 };
3200 let percentile = if is_percentile {
3201 call.args.get(1).map(|p| self.lower_expr(p, call.span, s))
3202 } else {
3203 None
3204 };
3205 let func = match (call.distinct, func, arg.is_some()) {
3206 (true, AggFunc::Count, true) => AggFunc::CountDistinct,
3207 (true, AggFunc::Sum, true) => AggFunc::SumDistinct,
3208 (true, AggFunc::Avg, true) => AggFunc::AvgDistinct,
3209 (true, AggFunc::Collect, true) => AggFunc::CollectDistinct,
3210 _ => func,
3211 };
3212 AggExpr {
3213 func,
3214 arg,
3215 percentile,
3216 alias,
3217 out_var,
3218 }
3219 }
3220
3221 #[allow(clippy::too_many_lines)]
3228 fn rewrite_aggs(&self, expr: &Expr, aggs: &mut Vec<AggExpr>, s: &mut BinderState) -> Expr {
3229 if let Some(func) = agg_func_of(expr) {
3230 let Expr::FunctionCall(call) = expr else {
3231 unreachable!("agg_func_of only matches FunctionCall");
3232 };
3233 let name = format!("__agg_{}", aggs.len());
3234 let out_var = ensure_var_name(&name, s);
3235 let agg = self.build_agg(call, func, name.clone(), Some(out_var), s);
3236 aggs.push(agg);
3237 return Expr::Var(VarRef {
3238 name,
3239 span: call.span,
3240 });
3241 }
3242 match expr {
3243 Expr::BinaryOp(b) => Expr::BinaryOp(graphforge_ast::BinaryOp {
3244 op: b.op,
3245 left: Box::new(self.rewrite_aggs(&b.left, aggs, s)),
3246 right: Box::new(self.rewrite_aggs(&b.right, aggs, s)),
3247 span: b.span,
3248 }),
3249 Expr::UnaryOp(u) => Expr::UnaryOp(graphforge_ast::UnaryOp {
3250 op: u.op,
3251 expr: Box::new(self.rewrite_aggs(&u.expr, aggs, s)),
3252 span: u.span,
3253 }),
3254 Expr::Parenthesized { inner, span } => Expr::Parenthesized {
3255 inner: Box::new(self.rewrite_aggs(inner, aggs, s)),
3256 span: *span,
3257 },
3258 Expr::ListComprehension(lc) => {
3259 Expr::ListComprehension(graphforge_ast::ListComprehension {
3260 var: lc.var.clone(),
3261 list: Box::new(self.rewrite_aggs(&lc.list, aggs, s)),
3262 filter: lc.filter.clone(),
3265 projection: lc.projection.clone(),
3266 span: lc.span,
3267 })
3268 }
3269 Expr::FunctionCall(c) => Expr::FunctionCall(graphforge_ast::FunctionCall {
3270 name: c.name.clone(),
3271 distinct: c.distinct,
3272 star: c.star,
3273 args: c
3274 .args
3275 .iter()
3276 .map(|a| self.rewrite_aggs(a, aggs, s))
3277 .collect(),
3278 span: c.span,
3279 }),
3280 Expr::List(l) => Expr::List(graphforge_ast::ListLiteral {
3281 elements: l
3282 .elements
3283 .iter()
3284 .map(|e| self.rewrite_aggs(e, aggs, s))
3285 .collect(),
3286 span: l.span,
3287 }),
3288 Expr::Map(m) => Expr::Map(graphforge_ast::MapLiteral {
3289 entries: m
3290 .entries
3291 .iter()
3292 .map(|(key, value)| (key.clone(), self.rewrite_aggs(value, aggs, s)))
3293 .collect(),
3294 key_spans: m.key_spans.clone(),
3295 span: m.span,
3296 }),
3297 Expr::Case(c) => Expr::Case(graphforge_ast::CaseExpr {
3298 subject: c
3299 .subject
3300 .as_deref()
3301 .map(|subject| Box::new(self.rewrite_aggs(subject, aggs, s))),
3302 when_clauses: c
3303 .when_clauses
3304 .iter()
3305 .map(|when| graphforge_ast::WhenClause {
3306 condition: self.rewrite_aggs(&when.condition, aggs, s),
3307 result: self.rewrite_aggs(&when.result, aggs, s),
3308 span: when.span,
3309 })
3310 .collect(),
3311 else_expr: c
3312 .else_expr
3313 .as_deref()
3314 .map(|else_expr| Box::new(self.rewrite_aggs(else_expr, aggs, s))),
3315 span: c.span,
3316 }),
3317 Expr::Quantifier(q) => Expr::Quantifier(graphforge_ast::Quantifier {
3318 kind: q.kind,
3319 var: q.var.clone(),
3320 list: Box::new(self.rewrite_aggs(&q.list, aggs, s)),
3321 predicate: Box::new(self.rewrite_aggs(&q.predicate, aggs, s)),
3322 span: q.span,
3323 }),
3324 Expr::PatternComprehension(pc) => {
3325 Expr::PatternComprehension(graphforge_ast::PatternComprehension {
3326 var: pc.var.clone(),
3327 pattern: pc.pattern.clone(),
3328 filter: pc.filter.clone(),
3329 projection: pc.projection.clone(),
3330 span: pc.span,
3331 })
3332 }
3333 Expr::ExistentialSubquery(es) => Expr::ExistentialSubquery(es.clone()),
3334 Expr::Property(p) => Expr::Property(graphforge_ast::PropertyAccess {
3335 object: Box::new(self.rewrite_aggs(&p.object, aggs, s)),
3336 key: p.key.clone(),
3337 span: p.span,
3338 }),
3339 Expr::IsNull {
3340 expr,
3341 negated,
3342 span,
3343 } => Expr::IsNull {
3344 expr: Box::new(self.rewrite_aggs(expr, aggs, s)),
3345 negated: *negated,
3346 span: *span,
3347 },
3348 Expr::InList {
3349 expr,
3350 list,
3351 negated,
3352 span,
3353 } => Expr::InList {
3354 expr: Box::new(self.rewrite_aggs(expr, aggs, s)),
3355 list: Box::new(self.rewrite_aggs(list, aggs, s)),
3356 negated: *negated,
3357 span: *span,
3358 },
3359 Expr::StringOp {
3360 expr,
3361 op,
3362 pattern,
3363 span,
3364 } => Expr::StringOp {
3365 expr: Box::new(self.rewrite_aggs(expr, aggs, s)),
3366 op: *op,
3367 pattern: Box::new(self.rewrite_aggs(pattern, aggs, s)),
3368 span: *span,
3369 },
3370 Expr::RegexMatch {
3371 expr,
3372 pattern,
3373 span,
3374 } => Expr::RegexMatch {
3375 expr: Box::new(self.rewrite_aggs(expr, aggs, s)),
3376 pattern: Box::new(self.rewrite_aggs(pattern, aggs, s)),
3377 span: *span,
3378 },
3379 other => other.clone(),
3380 }
3381 }
3382
3383 fn typed_uuid_param_in<'a>(&self, expr: &'a Expr) -> Option<&'a str> {
3388 match expr {
3389 Expr::Param(param) if self.typed_uuid_params.contains_key(¶m.name) => {
3390 Some(¶m.name)
3391 }
3392 Expr::Parenthesized { inner, .. }
3393 | Expr::UnaryOp(graphforge_ast::UnaryOp { expr: inner, .. }) => {
3394 self.typed_uuid_param_in(inner)
3395 }
3396 Expr::List(list) => list
3397 .elements
3398 .iter()
3399 .find_map(|item| self.typed_uuid_param_in(item)),
3400 Expr::Map(map) => map
3401 .entries
3402 .values()
3403 .find_map(|value| self.typed_uuid_param_in(value)),
3404 Expr::FunctionCall(call) => call
3405 .args
3406 .iter()
3407 .find_map(|arg| self.typed_uuid_param_in(arg)),
3408 Expr::BinaryOp(binary) => self
3409 .typed_uuid_param_in(&binary.left)
3410 .or_else(|| self.typed_uuid_param_in(&binary.right)),
3411 Expr::Case(case) => case
3412 .subject
3413 .as_deref()
3414 .and_then(|subject| self.typed_uuid_param_in(subject))
3415 .or_else(|| {
3416 case.when_clauses.iter().find_map(|when| {
3417 self.typed_uuid_param_in(&when.condition)
3418 .or_else(|| self.typed_uuid_param_in(&when.result))
3419 })
3420 })
3421 .or_else(|| {
3422 case.else_expr
3423 .as_deref()
3424 .and_then(|otherwise| self.typed_uuid_param_in(otherwise))
3425 }),
3426 Expr::ListComprehension(comprehension) => self
3427 .typed_uuid_param_in(&comprehension.list)
3428 .or_else(|| {
3429 comprehension
3430 .filter
3431 .as_deref()
3432 .and_then(|filter| self.typed_uuid_param_in(filter))
3433 })
3434 .or_else(|| {
3435 comprehension
3436 .projection
3437 .as_deref()
3438 .and_then(|projection| self.typed_uuid_param_in(projection))
3439 }),
3440 Expr::Quantifier(quantifier) => self
3441 .typed_uuid_param_in(&quantifier.list)
3442 .or_else(|| self.typed_uuid_param_in(&quantifier.predicate)),
3443 Expr::PatternComprehension(comprehension) => comprehension
3444 .filter
3445 .as_deref()
3446 .and_then(|filter| self.typed_uuid_param_in(filter))
3447 .or_else(|| self.typed_uuid_param_in(&comprehension.projection)),
3448 Expr::IsNull { expr, .. } => self.typed_uuid_param_in(expr),
3449 Expr::InList { expr, list, .. } => self
3450 .typed_uuid_param_in(expr)
3451 .or_else(|| self.typed_uuid_param_in(list)),
3452 Expr::StringOp { expr, pattern, .. } | Expr::RegexMatch { expr, pattern, .. } => self
3453 .typed_uuid_param_in(expr)
3454 .or_else(|| self.typed_uuid_param_in(pattern)),
3455 _ => None,
3456 }
3457 }
3458
3459 fn direct_typed_uuid_identity_parameter(
3460 &self,
3461 property_expr: &Expr,
3462 value_expr: &Expr,
3463 s: &BinderState,
3464 ) -> Option<String> {
3465 let mut value_expr = value_expr;
3466 while let Expr::Parenthesized { inner, .. } = value_expr {
3467 value_expr = inner;
3468 }
3469 let Expr::Param(param) = value_expr else {
3470 return None;
3471 };
3472 if self.typed_uuid_params.get(¶m.name) != Some(&UuidParamClass::ExactUuid) {
3473 return None;
3474 }
3475 let mut property_expr = property_expr;
3476 while let Expr::Parenthesized { inner, .. } = property_expr {
3477 property_expr = inner;
3478 }
3479 let Expr::Property(PropertyAccess { object, key, .. }) = property_expr else {
3480 return None;
3481 };
3482 let mut object = object.as_ref();
3483 while let Expr::Parenthesized { inner, .. } = object {
3484 object = inner;
3485 }
3486 let actual = match object {
3487 Expr::Var(VarRef { name, .. }) => s
3488 .vars
3489 .get(name)
3490 .and_then(|var| s.var_kinds.get(var))
3491 .copied(),
3492 _ => None,
3493 };
3494 let compatible = matches!(
3495 (key.as_str(), actual),
3496 ("node_uuid", Some(VarKind::Node)) | ("edge_uuid", Some(VarKind::Relationship))
3497 );
3498 compatible.then(|| param.name.clone())
3499 }
3500
3501 fn lower_expr_with_direct_uuid_allowed(
3502 &self,
3503 expr: &Expr,
3504 parent_span: Span,
3505 allowed: Option<&str>,
3506 s: &mut BinderState,
3507 ) -> ExprId {
3508 let mut direct = expr;
3509 while let Expr::Parenthesized { inner, .. } = direct {
3510 direct = inner;
3511 }
3512 if let Expr::Param(param) = direct
3513 && allowed == Some(param.name.as_str())
3514 {
3515 return s.builder.push_expr(IrExpr::Parameter(param.name.clone()));
3516 }
3517 self.lower_expr(expr, parent_span, s)
3518 }
3519
3520 #[allow(clippy::only_used_in_recursion, clippy::too_many_lines)]
3521 fn lower_expr(&self, expr: &Expr, parent_span: Span, s: &mut BinderState) -> ExprId {
3522 match expr {
3523 Expr::Literal(lit) => {
3524 if let Literal::Float(f, span) = lit
3525 && !f.is_finite()
3526 {
3527 s.errors.push(BindError::new(
3528 BindErrorKind::InvalidArgument,
3529 *span,
3530 "float literal is outside the supported f64 range",
3531 ));
3532 }
3533 s.builder.push_expr(IrExpr::Literal(lower_literal(lit)))
3534 }
3535
3536 Expr::Var(VarRef { name, span }) => {
3537 if let Some(&var_id) = s.vars.get(name) {
3538 s.builder.push_expr(IrExpr::VarRef(var_id))
3539 } else if let Some(binding) = s.path_vars.get(name).cloned() {
3540 Self::path_struct_expr(&binding, s)
3543 } else {
3544 s.errors.push(BindError::new(
3545 BindErrorKind::UndeclaredVariable,
3546 *span,
3547 format!("variable `{name}` used before it was introduced"),
3548 ));
3549 s.builder.push_expr(IrExpr::Literal(IrLiteral::Null))
3550 }
3551 }
3552
3553 Expr::Property(graphforge_ast::PropertyAccess { object, key, span }) => {
3554 if matches!(object.as_ref(), Expr::Var(VarRef { name, .. }) if s.path_vars.contains_key(name))
3555 {
3556 s.errors.push(BindError::new(
3557 BindErrorKind::InvalidArgument,
3558 *span,
3559 "property access is not valid on a path value",
3560 ));
3561 return s.builder.push_expr(IrExpr::Literal(IrLiteral::Null));
3562 }
3563 if matches!(key.as_str(), "node_uuid" | "edge_uuid") {
3564 let expected = if key == "node_uuid" {
3565 VarKind::Node
3566 } else {
3567 VarKind::Relationship
3568 };
3569 let actual = match object.as_ref() {
3570 Expr::Var(VarRef { name, .. }) => s
3571 .vars
3572 .get(name)
3573 .and_then(|var| s.var_kinds.get(var))
3574 .copied(),
3575 _ => None,
3576 };
3577 if actual != Some(expected) {
3578 s.errors.push(BindError::new(
3579 BindErrorKind::InvalidArgument,
3580 *span,
3581 format!(
3582 "structural identity field `{key}` is valid only on {expected}"
3583 ),
3584 ));
3585 return s.builder.push_expr(IrExpr::Literal(IrLiteral::Null));
3586 }
3587 }
3588 let owner = property_owner_for_expr(object, s);
3589 let base = self.lower_expr(object, *span, s);
3590 let prop = self.resolve_property(key, *span, owner, s);
3591 s.builder.push_expr(IrExpr::PropertyAccess { base, prop })
3592 }
3593
3594 Expr::BinaryOp(graphforge_ast::BinaryOp {
3595 op,
3596 left,
3597 right,
3598 span,
3599 }) => {
3600 let right_allowed = (*op == AstBinOp::Eq)
3601 .then(|| self.direct_typed_uuid_identity_parameter(left, right, s))
3602 .flatten();
3603 let left_allowed = (*op == AstBinOp::Eq)
3604 .then(|| self.direct_typed_uuid_identity_parameter(right, left, s))
3605 .flatten();
3606 match op {
3607 AstBinOp::Concat => {
3608 let a = self.lower_expr_with_direct_uuid_allowed(
3609 left,
3610 *span,
3611 left_allowed.as_deref(),
3612 s,
3613 );
3614 let b = self.lower_expr_with_direct_uuid_allowed(
3615 right,
3616 *span,
3617 right_allowed.as_deref(),
3618 s,
3619 );
3620 s.builder.push_expr(IrExpr::FunctionCall {
3621 name: "string.concat".into(),
3622 args: vec![a, b],
3623 })
3624 }
3625 other => {
3626 let l = self.lower_expr_with_direct_uuid_allowed(
3627 left,
3628 *span,
3629 left_allowed.as_deref(),
3630 s,
3631 );
3632 let r = self.lower_expr_with_direct_uuid_allowed(
3633 right,
3634 *span,
3635 right_allowed.as_deref(),
3636 s,
3637 );
3638 s.builder.push_expr(IrExpr::BinaryOp {
3639 op: lower_binop(*other),
3640 left: l,
3641 right: r,
3642 })
3643 }
3644 }
3645 }
3646
3647 Expr::UnaryOp(graphforge_ast::UnaryOp {
3648 op,
3649 expr: inner,
3650 span,
3651 }) => {
3652 if let Some(name) = self.typed_uuid_param_in(inner) {
3653 s.errors.push(BindError::new(
3654 BindErrorKind::InvalidArgument,
3655 *span,
3656 format!(
3657 "typed UUID parameter `${name}` is only supported as a direct node_uuid or edge_uuid identity equality predicate"
3658 ),
3659 ));
3660 }
3661 let ir_op = match op {
3662 AstUnOp::Not => UnaryOpKind::Not,
3663 AstUnOp::Neg => UnaryOpKind::Neg,
3664 };
3665 let e = self.lower_expr(inner, *span, s);
3666 s.builder.push_expr(IrExpr::UnaryOp { op: ir_op, expr: e })
3667 }
3668
3669 Expr::IsNull {
3670 expr: inner,
3671 negated,
3672 span,
3673 } => {
3674 let op = if *negated {
3675 UnaryOpKind::IsNotNull
3676 } else {
3677 UnaryOpKind::IsNull
3678 };
3679 let e = self.lower_expr(inner, *span, s);
3680 s.builder.push_expr(IrExpr::UnaryOp { op, expr: e })
3681 }
3682
3683 Expr::InList {
3684 expr: lhs,
3685 list: rhs,
3686 negated,
3687 span,
3688 } => {
3689 let l = self.lower_expr(lhs, *span, s);
3690 let r = self.lower_expr(rhs, *span, s);
3691 let in_id = s.builder.push_expr(IrExpr::BinaryOp {
3692 op: BinaryOpKind::In,
3693 left: l,
3694 right: r,
3695 });
3696 if *negated {
3697 s.builder.push_expr(IrExpr::UnaryOp {
3698 op: UnaryOpKind::Not,
3699 expr: in_id,
3700 })
3701 } else {
3702 in_id
3703 }
3704 }
3705
3706 Expr::StringOp {
3707 expr: lhs,
3708 op,
3709 pattern: rhs,
3710 span,
3711 } => {
3712 let ir_op = match op {
3713 StringOpKind::StartsWith => BinaryOpKind::StartsWith,
3714 StringOpKind::EndsWith => BinaryOpKind::EndsWith,
3715 StringOpKind::Contains => BinaryOpKind::Contains,
3716 };
3717 let l = self.lower_expr(lhs, *span, s);
3718 let r = self.lower_expr(rhs, *span, s);
3719 s.builder.push_expr(IrExpr::BinaryOp {
3720 op: ir_op,
3721 left: l,
3722 right: r,
3723 })
3724 }
3725
3726 Expr::RegexMatch {
3727 expr: lhs,
3728 pattern: rhs,
3729 span,
3730 } => {
3731 let l = self.lower_expr(lhs, *span, s);
3732 let r = self.lower_expr(rhs, *span, s);
3733 s.builder.push_expr(IrExpr::BinaryOp {
3734 op: BinaryOpKind::RegexMatch,
3735 left: l,
3736 right: r,
3737 })
3738 }
3739
3740 Expr::Parenthesized { inner, .. } => self.lower_expr(inner, parent_span, s),
3741
3742 Expr::FunctionCall(call) => {
3743 if !is_known_cypher_function(call) {
3744 s.errors.push(BindError::new(
3745 BindErrorKind::InvalidArgument,
3746 call.span,
3747 format!("unknown function `{}`", call.name.join(".")),
3748 ));
3749 s.builder.push_expr(IrExpr::Literal(IrLiteral::Null))
3750 } else if Self::invalid_direct_graph_function_argument(call, s) {
3751 s.errors.push(BindError::new(
3752 BindErrorKind::InvalidArgument,
3753 call.span,
3754 format!(
3755 "{}() does not accept this graph value type",
3756 call.name.join(".")
3757 ),
3758 ));
3759 s.builder.push_expr(IrExpr::Literal(IrLiteral::Null))
3760 } else if Self::is_size_of_path_variable(call, s) {
3761 s.errors.push(BindError::new(
3762 BindErrorKind::InvalidArgument,
3763 call.span,
3764 "size() is not valid for paths; use length(path) instead",
3765 ));
3766 s.builder.push_expr(IrExpr::Literal(IrLiteral::Null))
3767 } else if is_function_named(call, "type") && call.args.len() == 1 {
3768 let arg = match &call.args[0] {
3769 Expr::Var(VarRef { name, .. }) => {
3770 let edge_var = s
3771 .vars
3772 .get(name)
3773 .copied()
3774 .filter(|var| s.edge_rel_names.contains_key(var));
3775 if let Some(var) = edge_var {
3776 Self::relationship_struct_expr(var, s)
3777 } else {
3778 self.lower_expr(&call.args[0], parent_span, s)
3779 }
3780 }
3781 other => self.lower_expr(other, parent_span, s),
3782 };
3783 s.builder.push_expr(IrExpr::FunctionCall {
3784 name: "type".into(),
3785 args: vec![arg],
3786 })
3787 } else if let Some(id) = Self::lower_path_function(call, s) {
3788 id
3789 } else if let Some(node_var) = Self::resolve_endpoint_node(call, s) {
3790 s.builder.push_expr(IrExpr::VarRef(node_var))
3796 } else {
3797 let fn_name = call.name.join(".");
3798 let ir_args: Vec<ExprId> = call
3799 .args
3800 .iter()
3801 .map(|a| self.lower_expr(a, parent_span, s))
3802 .collect();
3803 s.builder.push_expr(IrExpr::FunctionCall {
3804 name: fn_name,
3805 args: ir_args,
3806 })
3807 }
3808 }
3809
3810 Expr::Param(graphforge_ast::ParamRef { name, span }) => {
3811 if self.typed_uuid_params.contains_key(name) {
3812 s.errors.push(BindError::new(
3813 BindErrorKind::InvalidArgument,
3814 *span,
3815 format!(
3816 "typed UUID parameter `${name}` is only supported as a direct node_uuid or edge_uuid identity equality predicate"
3817 ),
3818 ));
3819 }
3820 s.builder.push_expr(IrExpr::Parameter(name.clone()))
3821 }
3822
3823 Expr::Case(CaseExpr {
3824 subject,
3825 when_clauses,
3826 else_expr,
3827 ..
3828 }) => {
3829 let operand = subject
3830 .as_deref()
3831 .map(|e| self.lower_expr(e, parent_span, s));
3832 let arms: Vec<CaseArm> = when_clauses
3833 .iter()
3834 .map(|w| {
3835 let when = self.lower_expr(&w.condition, w.span, s);
3836 let when = operand.map_or(when, |subject| {
3837 s.builder.push_expr(IrExpr::BinaryOp {
3838 op: BinaryOpKind::Eq,
3839 left: subject,
3840 right: when,
3841 })
3842 });
3843 CaseArm {
3844 when,
3845 then: self.lower_expr(&w.result, w.span, s),
3846 }
3847 })
3848 .collect();
3849 let else_id = else_expr
3850 .as_deref()
3851 .map(|e| self.lower_expr(e, parent_span, s));
3852 s.builder.push_expr(IrExpr::Case {
3853 operand: None,
3854 arms,
3855 else_expr: else_id,
3856 })
3857 }
3858
3859 Expr::List(graphforge_ast::ListLiteral { elements, .. }) => {
3860 let ids: Vec<ExprId> = elements
3861 .iter()
3862 .map(|e| self.lower_expr(e, parent_span, s))
3863 .collect();
3864 s.builder.push_expr(IrExpr::ListLiteral(ids))
3865 }
3866
3867 Expr::Map(MapLiteral { entries, .. }) => {
3868 let mut pairs: Vec<(&String, &Expr)> = entries.iter().collect();
3869 pairs.sort_by_key(|(k, _)| k.as_str());
3870 let ids: Vec<(String, ExprId)> = pairs
3871 .into_iter()
3872 .map(|(k, v)| (k.clone(), self.lower_expr(v, parent_span, s)))
3873 .collect();
3874 s.builder.push_expr(IrExpr::MapLiteral(ids))
3875 }
3876
3877 Expr::ListComprehension(lc) => {
3882 if lc.filter.as_deref().is_some_and(expr_contains_aggregate)
3883 || lc
3884 .projection
3885 .as_deref()
3886 .is_some_and(expr_contains_aggregate)
3887 {
3888 s.errors.push(BindError::new(
3889 BindErrorKind::InvalidArgument,
3890 lc.span,
3891 "an aggregate function may not be used inside a list \
3892 comprehension filter or projection",
3893 ));
3894 }
3895 let list = self.lower_expr(&lc.list, parent_span, s);
3896 let has_nested_pattern = lc
3897 .filter
3898 .as_deref()
3899 .is_some_and(expr_contains_pattern_comprehension)
3900 || lc
3901 .projection
3902 .as_deref()
3903 .is_some_and(expr_contains_pattern_comprehension);
3904 let prev = s.vars.get(&lc.var).copied();
3905 let loop_var = VarId(s.next_var);
3906 s.next_var += 1;
3907 s.vars.insert(lc.var.clone(), loop_var);
3908 let previous_node = s.node_vars.get(&loop_var).cloned();
3909 let previous_kind = s.var_kinds.get(&loop_var).copied();
3910 if has_nested_pattern {
3911 s.node_vars.insert(loop_var, None);
3914 s.var_kinds.insert(loop_var, VarKind::Node);
3915 }
3916 let previous_capture = if has_nested_pattern {
3917 s.captured_pattern_comprehensions.replace(Vec::new())
3918 } else {
3919 None
3920 };
3921 let filter = lc
3922 .filter
3923 .as_ref()
3924 .map(|f| self.lower_expr(f, parent_span, s));
3925 let projection = lc
3926 .projection
3927 .as_ref()
3928 .map(|p| self.lower_expr(p, parent_span, s));
3929 let captured = if has_nested_pattern {
3930 let captured = s.captured_pattern_comprehensions.take().unwrap_or_default();
3931 s.captured_pattern_comprehensions = previous_capture;
3932 captured
3933 } else {
3934 Vec::new()
3935 };
3936 match prev {
3937 Some(v) => {
3938 s.vars.insert(lc.var.clone(), v);
3939 }
3940 None => {
3941 s.vars.remove(&lc.var);
3942 }
3943 }
3944 match previous_node {
3945 Some(shape) => {
3946 s.node_vars.insert(loop_var, shape);
3947 }
3948 None => {
3949 s.node_vars.remove(&loop_var);
3950 }
3951 }
3952 match previous_kind {
3953 Some(kind) => {
3954 s.var_kinds.insert(loop_var, kind);
3955 }
3956 None => {
3957 s.var_kinds.remove(&loop_var);
3958 }
3959 }
3960 if has_nested_pattern {
3961 let [(child, pattern_output)] = captured.try_into().unwrap_or_else(|captured: Vec<_>| {
3962 s.errors.push(BindError::new(
3963 BindErrorKind::InvalidArgument,
3964 lc.span,
3965 format!(
3966 "a list comprehension currently supports exactly one nested pattern comprehension, found {}",
3967 captured.len()
3968 ),
3969 ));
3970 [(Box::new(GraphPlan::builder("openCypher").build()), loop_var)]
3971 });
3972 let output = VarId(s.next_var);
3973 s.next_var += 1;
3974 s.builder
3975 .push_op_mut(GraphOp::ListElementPatternComprehension {
3976 list_expr: list,
3977 loop_var,
3978 child,
3979 pattern_output,
3980 filter,
3981 projection,
3982 output,
3983 });
3984 return s.builder.push_expr(IrExpr::VarRef(output));
3985 }
3986 s.builder.push_expr(IrExpr::ListComprehension {
3987 loop_var,
3988 list,
3989 filter,
3990 projection,
3991 })
3992 }
3993
3994 Expr::Quantifier(q) => {
3998 let list = self.lower_expr(&q.list, parent_span, s);
3999 let prev = s.vars.get(&q.var).copied();
4000 let loop_var = VarId(s.next_var);
4001 s.next_var += 1;
4002 s.vars.insert(q.var.clone(), loop_var);
4003 let predicate = self.lower_expr(&q.predicate, parent_span, s);
4004 match prev {
4005 Some(v) => {
4006 s.vars.insert(q.var.clone(), v);
4007 }
4008 None => {
4009 s.vars.remove(&q.var);
4010 }
4011 }
4012 s.builder.push_expr(IrExpr::Quantifier {
4013 kind: q.kind,
4014 loop_var,
4015 list,
4016 predicate,
4017 })
4018 }
4019
4020 Expr::PatternPredicate(pp) => {
4021 s.errors.push(BindError::new(
4022 BindErrorKind::InvalidArgument,
4023 pp.span,
4024 "pattern predicates are invalid outside WHERE",
4025 ));
4026 s.builder.push_expr(IrExpr::Literal(IrLiteral::Null))
4027 }
4028
4029 Expr::ExistentialSubquery(es) => {
4030 s.errors.push(BindError::new(
4031 BindErrorKind::InvalidArgument,
4032 es.span,
4033 "existential subqueries are currently valid only as WHERE predicates",
4034 ));
4035 s.builder.push_expr(IrExpr::Literal(IrLiteral::Null))
4036 }
4037
4038 Expr::PatternComprehension(pc) => self.lower_pattern_comprehension(pc, s),
4039
4040 Expr::LabelPredicate(lp) => self.lower_label_predicate(lp, s),
4041
4042 _ => s.builder.push_expr(IrExpr::Literal(IrLiteral::Null)),
4044 }
4045 }
4046
4047 fn lower_label_predicate(&self, lp: &LabelPredicate, s: &mut BinderState) -> ExprId {
4048 let Some(&var_id) = s.vars.get(&lp.var) else {
4049 s.errors.push(BindError::new(
4050 BindErrorKind::UndeclaredVariable,
4051 lp.span,
4052 format!("variable `{}` used before it was introduced", lp.var),
4053 ));
4054 return s.builder.push_expr(IrExpr::Literal(IrLiteral::Null));
4055 };
4056
4057 let kind = s.var_kinds.get(&var_id).copied().or_else(|| {
4058 if s.node_vars.contains_key(&var_id) {
4059 Some(VarKind::Node)
4060 } else if s.edge_rel_names.contains_key(&var_id) {
4061 Some(VarKind::Relationship)
4062 } else {
4063 None
4064 }
4065 });
4066
4067 match kind {
4068 Some(VarKind::Node) => self.lower_node_label_predicate(var_id, &lp.labels, lp.span, s),
4069 Some(VarKind::Relationship) => {
4070 self.lower_relationship_type_predicate(var_id, &lp.labels, lp.span, s)
4071 }
4072 Some(VarKind::Unknown) | None => {
4073 s.errors.push(BindError::new(
4074 BindErrorKind::InvalidArgument,
4075 lp.span,
4076 format!(
4077 "`{}:{}` requires a node or relationship variable",
4078 lp.var,
4079 lp.labels.join(":")
4080 ),
4081 ));
4082 s.builder.push_expr(IrExpr::Literal(IrLiteral::Null))
4083 }
4084 }
4085 }
4086
4087 fn lower_node_label_predicate(
4088 &self,
4089 var_id: VarId,
4090 labels: &[String],
4091 span: Span,
4092 s: &mut BinderState,
4093 ) -> ExprId {
4094 let var_expr = s.builder.push_expr(IrExpr::VarRef(var_id));
4095 let node_labels_expr = s.builder.push_expr(IrExpr::FunctionCall {
4096 name: "labels".into(),
4097 args: vec![var_expr],
4098 });
4099 let mut predicates = Vec::with_capacity(labels.len());
4100 for label in labels {
4101 self.resolve_label(label, span, s);
4102 let expected_label_expr = s
4103 .builder
4104 .push_expr(IrExpr::Literal(IrLiteral::Str(label.clone())));
4105 predicates.push(s.builder.push_expr(IrExpr::BinaryOp {
4106 op: BinaryOpKind::In,
4107 left: expected_label_expr,
4108 right: node_labels_expr,
4109 }));
4110 }
4111 push_conjunction(predicates, s)
4112 }
4113
4114 fn lower_relationship_type_predicate(
4115 &self,
4116 var_id: VarId,
4117 types: &[String],
4118 span: Span,
4119 s: &mut BinderState,
4120 ) -> ExprId {
4121 let rel_expr = if s.edge_rel_names.contains_key(&var_id) {
4122 Self::relationship_struct_expr(var_id, s)
4123 } else {
4124 s.builder.push_expr(IrExpr::VarRef(var_id))
4125 };
4126 let type_expr = s.builder.push_expr(IrExpr::FunctionCall {
4127 name: "type".into(),
4128 args: vec![rel_expr],
4129 });
4130 let mut predicates = Vec::with_capacity(types.len());
4131 for rel_type in types {
4132 self.resolve_relation_type(rel_type, span, s);
4133 let type_lit = s
4134 .builder
4135 .push_expr(IrExpr::Literal(IrLiteral::Str(rel_type.clone())));
4136 predicates.push(s.builder.push_expr(IrExpr::BinaryOp {
4137 op: BinaryOpKind::Eq,
4138 left: type_expr,
4139 right: type_lit,
4140 }));
4141 }
4142 let mut predicates = predicates.into_iter();
4143 let Some(mut predicate) = predicates.next() else {
4144 return s.builder.push_expr(IrExpr::Literal(IrLiteral::Bool(false)));
4145 };
4146 for next in predicates {
4147 predicate = s.builder.push_expr(IrExpr::BinaryOp {
4148 op: BinaryOpKind::Or,
4149 left: predicate,
4150 right: next,
4151 });
4152 }
4153 predicate
4154 }
4155
4156 fn lower_path_function(call: &FunctionCall, s: &mut BinderState) -> Option<ExprId> {
4163 let [fn_name] = call.name.as_slice() else {
4164 return None;
4165 };
4166 let fn_name = fn_name.to_ascii_lowercase();
4167 if !matches!(fn_name.as_str(), "nodes" | "relationships" | "length") {
4168 return None;
4169 }
4170 let [Expr::Var(VarRef { name, .. })] = call.args.as_slice() else {
4171 return None;
4172 };
4173 let binding = s.path_vars.get(name)?.clone();
4174 let id = match fn_name.as_str() {
4175 "nodes" => Self::path_nodes_expr(&binding, s),
4176 "relationships" => Self::path_rels_expr(&binding, s),
4177 "length" => Self::path_length_expr(&binding, s),
4178 _ => unreachable!("guarded by the name match above"),
4179 };
4180 Some(id)
4181 }
4182
4183 fn invalid_direct_graph_function_argument(call: &FunctionCall, s: &BinderState) -> bool {
4184 let [name] = call.name.as_slice() else {
4185 return false;
4186 };
4187 let [Expr::Var(VarRef { name: var_name, .. })] = call.args.as_slice() else {
4188 return false;
4189 };
4190 let function = name.to_ascii_lowercase();
4191 if s.path_vars.contains_key(var_name) {
4192 return matches!(function.as_str(), "labels" | "type");
4193 }
4194 let Some(var) = s.vars.get(var_name) else {
4195 return false;
4196 };
4197 let is_node = s.node_vars.contains_key(var);
4198 let is_relationship = s.edge_rel_names.contains_key(var);
4199 matches!(function.as_str(), "type" | "length") && is_node
4200 || matches!(function.as_str(), "labels" | "length") && is_relationship
4201 }
4202
4203 fn is_size_of_path_variable(call: &FunctionCall, s: &BinderState) -> bool {
4204 let [fn_name] = call.name.as_slice() else {
4205 return false;
4206 };
4207 if !fn_name.eq_ignore_ascii_case("size") {
4208 return false;
4209 }
4210 let [Expr::Var(VarRef { name, .. })] = call.args.as_slice() else {
4211 return false;
4212 };
4213 s.path_vars.contains_key(name)
4214 }
4215
4216 fn resolve_endpoint_node(call: &FunctionCall, s: &BinderState) -> Option<VarId> {
4225 let [fn_name] = call.name.as_slice() else {
4226 return None;
4227 };
4228 let start = match fn_name.to_ascii_lowercase().as_str() {
4229 "startnode" => true,
4230 "endnode" => false,
4231 _ => return None,
4232 };
4233 let [Expr::Var(VarRef { name, .. })] = call.args.as_slice() else {
4234 return None;
4235 };
4236 let edge_var = s.vars.get(name)?;
4237 let (src, dst) = s.edge_vars.get(edge_var)?;
4238 Some(if start { *src } else { *dst })
4239 }
4240
4241 fn node_struct_expr(var: VarId, s: &mut BinderState) -> ExprId {
4245 let label_opt = s.node_vars.get(&var).cloned().flatten();
4249 let var_ref = s.builder.push_expr(IrExpr::VarRef(var));
4250 let mut args = vec![var_ref];
4251 if let Some(label) = label_opt {
4252 let lit = s.builder.push_expr(IrExpr::Literal(IrLiteral::Str(label)));
4253 args.push(lit);
4254 }
4255 s.builder.push_expr(IrExpr::FunctionCall {
4256 name: "_node_struct".to_string(),
4257 args,
4258 })
4259 }
4260
4261 fn relationship_struct_expr(var: VarId, s: &mut BinderState) -> ExprId {
4262 let rel_name = s.edge_rel_names.get(&var).cloned().flatten();
4263 let edge = s.builder.push_expr(IrExpr::VarRef(var));
4264 let rel_name = s.builder.push_expr(IrExpr::Literal(match rel_name {
4265 Some(name) => IrLiteral::Str(name),
4266 None => IrLiteral::Null,
4267 }));
4268 s.builder.push_expr(IrExpr::FunctionCall {
4269 name: "_rel_struct".into(),
4270 args: vec![edge, rel_name],
4271 })
4272 }
4273
4274 fn relationship_struct_list_expr(var: VarId, s: &mut BinderState) -> ExprId {
4275 let edge = s.builder.push_expr(IrExpr::VarRef(var));
4276 let rel_name = s.builder.push_expr(IrExpr::Literal(
4277 match s.edge_rel_names.get(&var).cloned().flatten() {
4278 Some(name) => IrLiteral::Str(name),
4279 None => IrLiteral::Null,
4280 },
4281 ));
4282 s.builder.push_expr(IrExpr::FunctionCall {
4283 name: "_rel_struct_list".into(),
4284 args: vec![edge, rel_name],
4285 })
4286 }
4287
4288 fn path_nodes_expr(binding: &PathBinding, s: &mut BinderState) -> ExprId {
4297 if binding.segments.is_empty() {
4298 let node = Self::node_struct_expr(binding.nodes[0], s);
4299 return s.builder.push_expr(IrExpr::ListLiteral(vec![node]));
4300 }
4301 if binding.segments.len() > 1 {
4302 let mut combined = None;
4303 for (index, segment) in binding.segments.iter().enumerate() {
4304 let part = PathBinding {
4305 nodes: binding.nodes[index..=index + 1].to_vec(),
4306 segments: vec![segment.clone()],
4307 };
4308 let mut nodes = Self::path_nodes_expr(&part, s);
4309 if index > 0 {
4310 nodes = s.builder.push_expr(IrExpr::FunctionCall {
4311 name: "tail".into(),
4312 args: vec![nodes],
4313 });
4314 }
4315 combined = Some(match combined {
4316 None => nodes,
4317 Some(left) => s.builder.push_expr(IrExpr::BinaryOp {
4318 op: BinaryOpKind::Add,
4319 left,
4320 right: nodes,
4321 }),
4322 });
4323 }
4324 return combined.expect("multi-segment path has node parts");
4325 }
4326 let seg = &binding.segments[0];
4327 if seg.var_len {
4328 let start = s.builder.push_expr(IrExpr::VarRef(binding.nodes[0]));
4329 let rels = s.builder.push_expr(IrExpr::VarRef(seg.edge));
4330 s.builder.push_expr(IrExpr::FunctionCall {
4331 name: "_path_nodes".into(),
4332 args: vec![start, rels],
4333 })
4334 } else {
4335 let a = s.builder.push_expr(IrExpr::VarRef(binding.nodes[0]));
4339 let b = s.builder.push_expr(IrExpr::VarRef(binding.nodes[1]));
4340 let edge = s.builder.push_expr(IrExpr::VarRef(seg.edge));
4341 s.builder.push_expr(IrExpr::FunctionCall {
4342 name: "_node_struct_list".into(),
4343 args: vec![a, b, edge],
4344 })
4345 }
4346 }
4347
4348 fn path_rels_expr(binding: &PathBinding, s: &mut BinderState) -> ExprId {
4356 if binding.segments.is_empty() {
4357 return s.builder.push_expr(IrExpr::ListLiteral(Vec::new()));
4358 }
4359 if binding.segments.len() > 1 {
4360 let mut combined = None;
4361 for (index, segment) in binding.segments.iter().enumerate() {
4362 let part = PathBinding {
4363 nodes: binding.nodes[index..=index + 1].to_vec(),
4364 segments: vec![segment.clone()],
4365 };
4366 let rels = Self::path_rels_expr(&part, s);
4367 combined = Some(match combined {
4368 None => rels,
4369 Some(left) => s.builder.push_expr(IrExpr::BinaryOp {
4370 op: BinaryOpKind::Add,
4371 left,
4372 right: rels,
4373 }),
4374 });
4375 }
4376 return combined.expect("multi-segment path has relationship parts");
4377 }
4378 let seg = &binding.segments[0];
4379 if seg.var_len {
4380 s.builder.push_expr(IrExpr::VarRef(seg.edge))
4381 } else {
4382 let edge = s.builder.push_expr(IrExpr::VarRef(seg.edge));
4383 let rel_name = s.builder.push_expr(IrExpr::Literal(match &seg.rel_name {
4384 Some(n) => IrLiteral::Str(n.clone()),
4385 None => IrLiteral::Null,
4386 }));
4387 s.builder.push_expr(IrExpr::FunctionCall {
4388 name: "_rel_struct_list".into(),
4389 args: vec![edge, rel_name],
4390 })
4391 }
4392 }
4393
4394 fn path_length_expr(binding: &PathBinding, s: &mut BinderState) -> ExprId {
4401 if binding.segments.is_empty() {
4402 return s.builder.push_expr(IrExpr::Literal(IrLiteral::Int(0)));
4403 }
4404 if binding.segments.len() > 1 {
4405 let mut total = None;
4406 for (index, segment) in binding.segments.iter().enumerate() {
4407 let part = PathBinding {
4408 nodes: binding.nodes[index..=index + 1].to_vec(),
4409 segments: vec![segment.clone()],
4410 };
4411 let length = Self::path_length_expr(&part, s);
4412 total = Some(match total {
4413 None => length,
4414 Some(left) => s.builder.push_expr(IrExpr::BinaryOp {
4415 op: BinaryOpKind::Add,
4416 left,
4417 right: length,
4418 }),
4419 });
4420 }
4421 return total.expect("multi-segment path has lengths");
4422 }
4423 let seg = &binding.segments[0];
4424 let rels = s.builder.push_expr(IrExpr::VarRef(seg.edge));
4425 if seg.var_len {
4426 s.builder.push_expr(IrExpr::FunctionCall {
4427 name: "length".into(),
4428 args: vec![rels],
4429 })
4430 } else {
4431 s.builder.push_expr(IrExpr::FunctionCall {
4432 name: "_path_fixed_length".into(),
4433 args: vec![rels],
4434 })
4435 }
4436 }
4437
4438 fn path_struct_expr(binding: &PathBinding, s: &mut BinderState) -> ExprId {
4442 let nodes = Self::path_nodes_expr(binding, s);
4443 let rels = Self::path_rels_expr(binding, s);
4444 s.builder.push_expr(IrExpr::FunctionCall {
4445 name: "_path_struct".into(),
4446 args: vec![nodes, rels],
4447 })
4448 }
4449
4450 fn resolve_label(&self, name: &str, span: Span, s: &mut BinderState) -> TypeId {
4455 if let Some(handle) = &self.ontology
4456 && let Some(id) = handle.entity_type_id(name)
4457 {
4458 return id;
4459 }
4460 match self.mode {
4461 OntologyMode::Strict => {
4462 s.errors.push(BindError::new(
4463 BindErrorKind::UnknownLabel,
4464 span,
4465 format!("unknown label `{name}` (strict mode)"),
4466 ));
4467 TypeId(u32::MAX)
4468 }
4469 OntologyMode::Advisory => {
4470 s.warnings.push(BindError::new(
4471 BindErrorKind::UnknownLabel,
4472 span,
4473 format!("unknown label `{name}` — using runtime catalog"),
4474 ));
4475 TypeId(self.catalog.lock().unwrap().intern_label(name).0)
4476 }
4477 OntologyMode::Exploratory => TypeId(self.catalog.lock().unwrap().intern_label(name).0),
4478 }
4479 }
4480
4481 fn resolve_relation_type(&self, name: &str, span: Span, s: &mut BinderState) -> TypeId {
4482 if let Some(handle) = &self.ontology
4483 && let Some(id) = handle.relation_type_id(name)
4484 {
4485 return id;
4486 }
4487 match self.mode {
4488 OntologyMode::Strict => {
4489 s.errors.push(BindError::new(
4490 BindErrorKind::UnknownRelationType,
4491 span,
4492 format!("unknown relation type `{name}` (strict mode)"),
4493 ));
4494 TypeId(u32::MAX)
4495 }
4496 OntologyMode::Advisory => {
4497 s.warnings.push(BindError::new(
4498 BindErrorKind::UnknownRelationType,
4499 span,
4500 format!("unknown relation type `{name}` — using runtime catalog"),
4501 ));
4502 crate::runtime_relation_type_id(
4503 self.catalog.lock().unwrap().intern_relation_type(name),
4504 )
4505 }
4506 OntologyMode::Exploratory => crate::runtime_relation_type_id(
4507 self.catalog.lock().unwrap().intern_relation_type(name),
4508 ),
4509 }
4510 }
4511
4512 fn resolve_property(
4513 &self,
4514 name: &str,
4515 span: Span,
4516 owner: BoundPropertyOwner,
4517 s: &mut BinderState,
4518 ) -> PropId {
4519 if matches!(name, "node_uuid" | "edge_uuid") {
4524 return PropId(self.catalog.lock().unwrap().intern_property(name, None).0);
4525 }
4526 match self.mode {
4527 OntologyMode::Strict => self.resolve_strict_property(name, span, owner, s),
4528 OntologyMode::Advisory => {
4529 s.warnings.push(BindError::new(
4530 BindErrorKind::UnknownProperty,
4531 span,
4532 format!("unknown property `{name}` — using runtime catalog"),
4533 ));
4534 PropId(self.catalog.lock().unwrap().intern_property(name, None).0)
4535 }
4536 OntologyMode::Exploratory => {
4537 PropId(self.catalog.lock().unwrap().intern_property(name, None).0)
4538 }
4539 }
4540 }
4541
4542 fn resolve_strict_property(
4543 &self,
4544 name: &str,
4545 span: Span,
4546 owner: BoundPropertyOwner,
4547 s: &mut BinderState,
4548 ) -> PropId {
4549 if owner == BoundPropertyOwner::Value {
4550 return PropId(self.catalog.lock().unwrap().intern_property(name, None).0);
4551 }
4552 let Some(handle) = &self.ontology else {
4553 s.errors.push(BindError::new(
4554 BindErrorKind::UnknownProperty,
4555 span,
4556 format!("unknown property `{name}` (strict mode has no ontology)"),
4557 ));
4558 return PropId(u32::MAX);
4559 };
4560
4561 let (declarations, description, runtime_owner, entity_owner) = match owner {
4562 BoundPropertyOwner::Entity(Some(owner)) => (
4563 handle
4564 .entity_type_id(&owner)
4565 .map(|id| handle.entity_property_declarations(id, name))
4566 .unwrap_or_default(),
4567 format!("entity `{owner}`"),
4568 Some(owner),
4569 true,
4570 ),
4571 BoundPropertyOwner::Entity(None) => (
4572 handle.all_entity_property_declarations(name),
4573 "unlabeled entity".to_owned(),
4574 None,
4575 true,
4576 ),
4577 BoundPropertyOwner::Relationship(Some(owner)) => (
4578 handle
4579 .relation_type_id(&owner)
4580 .map(|id| handle.relation_property_declarations(id, name))
4581 .unwrap_or_default(),
4582 format!("relationship `{owner}`"),
4583 Some(owner),
4584 false,
4585 ),
4586 BoundPropertyOwner::Relationship(None) => (
4587 handle.all_relation_property_declarations(name),
4588 "untyped relationship".to_owned(),
4589 None,
4590 false,
4591 ),
4592 BoundPropertyOwner::Value => unreachable!("value properties returned above"),
4593 };
4594
4595 if declarations.len() == 1 {
4596 return PropId(
4597 self.catalog
4598 .lock()
4599 .unwrap()
4600 .intern_property(name, runtime_owner.as_deref())
4601 .0,
4602 );
4603 }
4604 let (kind, message) = if declarations.is_empty() {
4605 (
4606 BindErrorKind::UnknownProperty,
4607 format!("property `{name}` is not declared for {description} (strict mode)"),
4608 )
4609 } else {
4610 let owners = declarations
4611 .iter()
4612 .filter_map(|(id, _)| {
4613 if entity_owner {
4614 handle.entity_type_name(*id)
4615 } else {
4616 handle.relation_type_name(*id)
4617 }
4618 })
4619 .collect::<Vec<_>>()
4620 .join(", ");
4621 (
4622 BindErrorKind::AmbiguousProperty,
4623 format!("property `{name}` is ambiguous for {description}; declarations: {owners}"),
4624 )
4625 };
4626 s.errors.push(BindError::new(kind, span, message));
4627 PropId(u32::MAX)
4628 }
4629
4630 }
4634
4635fn agg_func_of(expr: &Expr) -> Option<AggFunc> {
4642 let Expr::FunctionCall(call) = expr else {
4643 return None;
4644 };
4645 let [name] = call.name.as_slice() else {
4647 return None;
4648 };
4649 match name.to_ascii_lowercase().as_str() {
4650 "count" => Some(AggFunc::Count),
4651 "sum" => Some(AggFunc::Sum),
4652 "avg" => Some(AggFunc::Avg),
4653 "min" => Some(AggFunc::Min),
4654 "max" => Some(AggFunc::Max),
4655 "collect" => Some(AggFunc::Collect),
4656 "percentiledisc" => Some(AggFunc::PercentileDisc),
4657 "percentilecont" => Some(AggFunc::PercentileCont),
4658 _ => None,
4659 }
4660}
4661
4662fn is_function_named(call: &FunctionCall, name: &str) -> bool {
4663 matches!(call.name.as_slice(), [n] if n.eq_ignore_ascii_case(name))
4664}
4665
4666#[allow(
4667 clippy::too_many_lines,
4668 reason = "flat compile-time registry mirrors the scalar-function dispatch table"
4669)]
4670fn is_known_cypher_function(call: &FunctionCall) -> bool {
4671 let name = call.name.join(".").to_ascii_lowercase();
4672 matches!(
4673 name.as_str(),
4674 "abs"
4675 | "allshortestpaths"
4676 | "avg"
4677 | "ceil"
4678 | "char_length"
4679 | "character_length"
4680 | "coalesce"
4681 | "collect"
4682 | "concat"
4683 | "count"
4684 | "date"
4685 | "datetime"
4686 | "datetime.fromepoch"
4687 | "datetime.fromepochmillis"
4688 | "date.truncate"
4689 | "datetime.truncate"
4690 | "duration"
4691 | "duration.between"
4692 | "duration.indays"
4693 | "duration.inmonths"
4694 | "duration.inseconds"
4695 | "elementid"
4696 | "endnode"
4697 | "exp"
4698 | "exists"
4699 | "extract"
4700 | "filter"
4701 | "floor"
4702 | "head"
4703 | "id"
4704 | "keys"
4705 | "labels"
4706 | "last"
4707 | "length"
4708 | "localdatetime"
4709 | "localdatetime.truncate"
4710 | "localtime"
4711 | "localtime.truncate"
4712 | "log"
4713 | "lower"
4714 | "ltrim"
4715 | "max"
4716 | "min"
4717 | "nodes"
4718 | "percentilecont"
4719 | "percentiledisc"
4720 | "point"
4721 | "power"
4722 | "properties"
4723 | "rand"
4724 | "range"
4725 | "reduce"
4726 | "relationships"
4727 | "replace"
4728 | "reverse"
4729 | "round"
4730 | "rtrim"
4731 | "shortestpath"
4732 | "sign"
4733 | "size"
4734 | "split"
4735 | "sqrt"
4736 | "startnode"
4737 | "string.concat"
4738 | "substring"
4739 | "sum"
4740 | "tail"
4741 | "time"
4742 | "time.truncate"
4743 | "toboolean"
4744 | "tofloat"
4745 | "tointeger"
4746 | "tolower"
4747 | "tostring"
4748 | "toupper"
4749 | "trim"
4750 | "type"
4751 | "upper"
4752 | "timestamp"
4753 | "_slice"
4754 | "_slice_from_start"
4755 | "_slice_to_end"
4756 | "_subscript"
4757 ) || matches!(
4758 name.as_str(),
4759 "date.transaction"
4760 | "date.statement"
4761 | "date.realtime"
4762 | "datetime.transaction"
4763 | "datetime.statement"
4764 | "datetime.realtime"
4765 | "localdatetime.transaction"
4766 | "localdatetime.statement"
4767 | "localdatetime.realtime"
4768 | "localtime.transaction"
4769 | "localtime.statement"
4770 | "localtime.realtime"
4771 | "time.transaction"
4772 | "time.statement"
4773 | "time.realtime"
4774 )
4775}
4776
4777fn expr_contains_aggregate(expr: &Expr) -> bool {
4778 if agg_func_of(expr).is_some() {
4779 return true;
4780 }
4781 match expr {
4782 Expr::BinaryOp(b) => expr_contains_aggregate(&b.left) || expr_contains_aggregate(&b.right),
4783 Expr::UnaryOp(u) => expr_contains_aggregate(&u.expr),
4784 Expr::Parenthesized { inner, .. } => expr_contains_aggregate(inner),
4785 Expr::FunctionCall(c) => c.args.iter().any(expr_contains_aggregate),
4786 Expr::Property(p) => expr_contains_aggregate(&p.object),
4787 Expr::List(l) => l.elements.iter().any(expr_contains_aggregate),
4788 Expr::Map(m) => m.entries.values().any(expr_contains_aggregate),
4789 Expr::Case(c) => {
4790 c.subject.as_deref().is_some_and(expr_contains_aggregate)
4791 || c.when_clauses.iter().any(|when| {
4792 expr_contains_aggregate(&when.condition)
4793 || expr_contains_aggregate(&when.result)
4794 })
4795 || c.else_expr.as_deref().is_some_and(expr_contains_aggregate)
4796 }
4797 Expr::ListComprehension(lc) => {
4798 expr_contains_aggregate(&lc.list)
4799 || lc.filter.as_deref().is_some_and(expr_contains_aggregate)
4800 || lc
4801 .projection
4802 .as_deref()
4803 .is_some_and(expr_contains_aggregate)
4804 }
4805 Expr::Quantifier(q) => {
4806 expr_contains_aggregate(&q.list) || expr_contains_aggregate(&q.predicate)
4807 }
4808 Expr::PatternComprehension(pc) => {
4809 pc.filter.as_deref().is_some_and(expr_contains_aggregate)
4810 || expr_contains_aggregate(&pc.projection)
4811 }
4812 Expr::ExistentialSubquery(es) => match &es.body {
4813 ExistentialSubqueryBody::Simple { filter, .. } => {
4814 filter.as_deref().is_some_and(expr_contains_aggregate)
4815 }
4816 ExistentialSubqueryBody::Full(_) => false,
4817 },
4818 Expr::IsNull { expr, .. } => expr_contains_aggregate(expr),
4819 Expr::InList { expr, list, .. } => {
4820 expr_contains_aggregate(expr) || expr_contains_aggregate(list)
4821 }
4822 Expr::StringOp { expr, pattern, .. } | Expr::RegexMatch { expr, pattern, .. } => {
4823 expr_contains_aggregate(expr) || expr_contains_aggregate(pattern)
4824 }
4825 _ => false,
4826 }
4827}
4828
4829fn procedure_argument_type_matches(expr: &Expr, field: &crate::ProcedureField) -> bool {
4830 let expected = field.type_name.to_ascii_uppercase();
4831 match expr {
4832 Expr::Literal(Literal::Null(_)) => field.nullable,
4833 Expr::Literal(Literal::Int(_, _)) => {
4834 matches!(expected.as_str(), "INTEGER" | "FLOAT" | "NUMBER")
4835 }
4836 Expr::Literal(Literal::Float(_, _)) => matches!(expected.as_str(), "FLOAT" | "NUMBER"),
4837 Expr::Literal(Literal::Str(_, _)) => expected == "STRING",
4838 Expr::Literal(Literal::Bool(_, _)) => expected == "BOOLEAN",
4839 Expr::Parenthesized { inner, .. } => procedure_argument_type_matches(inner, field),
4840 _ => true,
4841 }
4842}
4843
4844fn union_output_names(plan: &GraphPlan) -> Option<Vec<String>> {
4845 match plan.ops.last() {
4846 Some(GraphOp::Project { items, .. } | GraphOp::With { items, .. }) => Some(
4847 items
4848 .iter()
4849 .map(|item| item.alias.clone().unwrap_or_default())
4850 .collect(),
4851 ),
4852 Some(GraphOp::Aggregate {
4853 group_aliases,
4854 aggs,
4855 ..
4856 }) => Some(
4857 group_aliases
4858 .iter()
4859 .map(|alias| alias.clone().unwrap_or_default())
4860 .chain(aggs.iter().map(|agg| agg.alias.clone()))
4861 .collect(),
4862 ),
4863 _ => None,
4864 }
4865}
4866
4867fn collect_aggregate_exprs(expr: &Expr, out: &mut Vec<Expr>) {
4868 if agg_func_of(expr).is_some() {
4869 if !out.iter().any(|existing| same_expr_shape(existing, expr)) {
4870 out.push(expr.clone());
4871 }
4872 return;
4873 }
4874 match expr {
4875 Expr::BinaryOp(binary) => {
4876 collect_aggregate_exprs(&binary.left, out);
4877 collect_aggregate_exprs(&binary.right, out);
4878 }
4879 Expr::UnaryOp(unary) => collect_aggregate_exprs(&unary.expr, out),
4880 Expr::Parenthesized { inner, .. } => collect_aggregate_exprs(inner, out),
4881 Expr::FunctionCall(call) => {
4882 for arg in &call.args {
4883 collect_aggregate_exprs(arg, out);
4884 }
4885 }
4886 Expr::Property(property) => collect_aggregate_exprs(&property.object, out),
4887 Expr::List(list) => {
4888 for element in &list.elements {
4889 collect_aggregate_exprs(element, out);
4890 }
4891 }
4892 Expr::Map(map) => {
4893 for value in map.entries.values() {
4894 collect_aggregate_exprs(value, out);
4895 }
4896 }
4897 Expr::Case(case) => {
4898 if let Some(subject) = &case.subject {
4899 collect_aggregate_exprs(subject, out);
4900 }
4901 for when in &case.when_clauses {
4902 collect_aggregate_exprs(&when.condition, out);
4903 collect_aggregate_exprs(&when.result, out);
4904 }
4905 if let Some(else_expr) = &case.else_expr {
4906 collect_aggregate_exprs(else_expr, out);
4907 }
4908 }
4909 Expr::IsNull { expr, .. } => collect_aggregate_exprs(expr, out),
4910 Expr::InList { expr, list, .. } => {
4911 collect_aggregate_exprs(expr, out);
4912 collect_aggregate_exprs(list, out);
4913 }
4914 Expr::StringOp { expr, pattern, .. } | Expr::RegexMatch { expr, pattern, .. } => {
4915 collect_aggregate_exprs(expr, out);
4916 collect_aggregate_exprs(pattern, out);
4917 }
4918 _ => {}
4919 }
4920}
4921
4922fn has_unprojected_order_aggregate(projections: &[ReturnItem], order_by: &[SortItem]) -> bool {
4923 let mut projected = Vec::new();
4924 for item in projections {
4925 collect_aggregate_exprs(&item.expr, &mut projected);
4926 }
4927 let mut ordered = Vec::new();
4928 for item in order_by {
4929 collect_aggregate_exprs(&item.expr, &mut ordered);
4930 }
4931 ordered
4932 .iter()
4933 .any(|order_agg| !projected.iter().any(|agg| same_expr_shape(agg, order_agg)))
4934}
4935
4936fn expr_contains_aggregate_inside_aggregate(expr: &Expr, inside_aggregate: bool) -> bool {
4937 let is_aggregate = agg_func_of(expr).is_some();
4938 if is_aggregate && inside_aggregate {
4939 return true;
4940 }
4941 let inside_aggregate = inside_aggregate || is_aggregate;
4942 match expr {
4943 Expr::BinaryOp(b) => {
4944 expr_contains_aggregate_inside_aggregate(&b.left, inside_aggregate)
4945 || expr_contains_aggregate_inside_aggregate(&b.right, inside_aggregate)
4946 }
4947 Expr::UnaryOp(u) => expr_contains_aggregate_inside_aggregate(&u.expr, inside_aggregate),
4948 Expr::Parenthesized { inner, .. } => {
4949 expr_contains_aggregate_inside_aggregate(inner, inside_aggregate)
4950 }
4951 Expr::FunctionCall(c) => c
4952 .args
4953 .iter()
4954 .any(|arg| expr_contains_aggregate_inside_aggregate(arg, inside_aggregate)),
4955 Expr::Property(p) => expr_contains_aggregate_inside_aggregate(&p.object, inside_aggregate),
4956 Expr::List(l) => l
4957 .elements
4958 .iter()
4959 .any(|element| expr_contains_aggregate_inside_aggregate(element, inside_aggregate)),
4960 Expr::Map(m) => m
4961 .entries
4962 .values()
4963 .any(|value| expr_contains_aggregate_inside_aggregate(value, inside_aggregate)),
4964 Expr::Case(c) => {
4965 c.subject.as_deref().is_some_and(|subject| {
4966 expr_contains_aggregate_inside_aggregate(subject, inside_aggregate)
4967 }) || c.when_clauses.iter().any(|when| {
4968 expr_contains_aggregate_inside_aggregate(&when.condition, inside_aggregate)
4969 || expr_contains_aggregate_inside_aggregate(&when.result, inside_aggregate)
4970 }) || c.else_expr.as_deref().is_some_and(|else_expr| {
4971 expr_contains_aggregate_inside_aggregate(else_expr, inside_aggregate)
4972 })
4973 }
4974 Expr::ListComprehension(lc) => {
4975 expr_contains_aggregate_inside_aggregate(&lc.list, inside_aggregate)
4976 || lc.filter.as_deref().is_some_and(|filter| {
4977 expr_contains_aggregate_inside_aggregate(filter, inside_aggregate)
4978 })
4979 || lc.projection.as_deref().is_some_and(|projection| {
4980 expr_contains_aggregate_inside_aggregate(projection, inside_aggregate)
4981 })
4982 }
4983 Expr::Quantifier(q) => {
4984 expr_contains_aggregate_inside_aggregate(&q.list, inside_aggregate)
4985 || expr_contains_aggregate_inside_aggregate(&q.predicate, inside_aggregate)
4986 }
4987 Expr::PatternComprehension(pc) => {
4988 pc.filter.as_deref().is_some_and(|filter| {
4989 expr_contains_aggregate_inside_aggregate(filter, inside_aggregate)
4990 }) || expr_contains_aggregate_inside_aggregate(&pc.projection, inside_aggregate)
4991 }
4992 Expr::ExistentialSubquery(es) => match &es.body {
4993 ExistentialSubqueryBody::Simple { filter, .. } => {
4994 filter.as_deref().is_some_and(|filter| {
4995 expr_contains_aggregate_inside_aggregate(filter, inside_aggregate)
4996 })
4997 }
4998 ExistentialSubqueryBody::Full(_) => false,
4999 },
5000 Expr::IsNull { expr, .. } => {
5001 expr_contains_aggregate_inside_aggregate(expr, inside_aggregate)
5002 }
5003 Expr::InList { expr, list, .. } => {
5004 expr_contains_aggregate_inside_aggregate(expr, inside_aggregate)
5005 || expr_contains_aggregate_inside_aggregate(list, inside_aggregate)
5006 }
5007 Expr::StringOp { expr, pattern, .. } | Expr::RegexMatch { expr, pattern, .. } => {
5008 expr_contains_aggregate_inside_aggregate(expr, inside_aggregate)
5009 || expr_contains_aggregate_inside_aggregate(pattern, inside_aggregate)
5010 }
5011 _ => false,
5012 }
5013}
5014
5015fn expr_contains_volatile_function(expr: &Expr) -> bool {
5016 match expr {
5017 Expr::FunctionCall(call) => {
5018 is_function_named(call, "rand") || call.args.iter().any(expr_contains_volatile_function)
5019 }
5020 Expr::BinaryOp(binary) => {
5021 expr_contains_volatile_function(&binary.left)
5022 || expr_contains_volatile_function(&binary.right)
5023 }
5024 Expr::UnaryOp(unary) => expr_contains_volatile_function(&unary.expr),
5025 Expr::Parenthesized { inner, .. } => expr_contains_volatile_function(inner),
5026 Expr::Property(property) => expr_contains_volatile_function(&property.object),
5027 Expr::List(list) => list.elements.iter().any(expr_contains_volatile_function),
5028 Expr::Map(map) => map.entries.values().any(expr_contains_volatile_function),
5029 Expr::Case(case) => {
5030 case.subject
5031 .as_deref()
5032 .is_some_and(expr_contains_volatile_function)
5033 || case.when_clauses.iter().any(|when| {
5034 expr_contains_volatile_function(&when.condition)
5035 || expr_contains_volatile_function(&when.result)
5036 })
5037 || case
5038 .else_expr
5039 .as_deref()
5040 .is_some_and(expr_contains_volatile_function)
5041 }
5042 Expr::IsNull { expr, .. } => expr_contains_volatile_function(expr),
5043 Expr::InList { expr, list, .. } => {
5044 expr_contains_volatile_function(expr) || expr_contains_volatile_function(list)
5045 }
5046 Expr::StringOp { expr, pattern, .. } | Expr::RegexMatch { expr, pattern, .. } => {
5047 expr_contains_volatile_function(expr) || expr_contains_volatile_function(pattern)
5048 }
5049 _ => false,
5050 }
5051}
5052
5053fn is_atomic_grouping_expr(expr: &Expr) -> bool {
5054 matches!(strip_parens(expr), Expr::Var(_) | Expr::Property(_))
5055}
5056
5057fn grouping_ref_root_name(expr: &Expr) -> Option<&str> {
5058 match strip_parens(expr) {
5059 Expr::Var(var) => Some(var.name.as_str()),
5060 Expr::Property(property) => grouping_ref_root_name(&property.object),
5061 _ => None,
5062 }
5063}
5064
5065fn same_grouping_expr(left: &Expr, right: &Expr) -> bool {
5066 match (strip_parens(left), strip_parens(right)) {
5067 (Expr::Var(a), Expr::Var(b)) => a.name == b.name,
5068 (Expr::Property(a), Expr::Property(b)) => {
5069 a.key == b.key && same_grouping_expr(&a.object, &b.object)
5070 }
5071 (Expr::FunctionCall(a), Expr::FunctionCall(b)) => {
5072 a.name
5073 .iter()
5074 .map(|part| part.to_ascii_lowercase())
5075 .eq(b.name.iter().map(|part| part.to_ascii_lowercase()))
5076 && a.distinct == b.distinct
5077 && a.star == b.star
5078 && a.args.len() == b.args.len()
5079 && a.args
5080 .iter()
5081 .zip(&b.args)
5082 .all(|(left, right)| same_expr_shape(left, right))
5083 }
5084 (Expr::List(a), Expr::List(b)) => {
5085 a.elements.len() == b.elements.len()
5086 && a.elements
5087 .iter()
5088 .zip(&b.elements)
5089 .all(|(left, right)| same_expr_shape(left, right))
5090 }
5091 (Expr::Map(a), Expr::Map(b)) => {
5092 a.entries.len() == b.entries.len()
5093 && a.entries.iter().all(|(key, left)| {
5094 b.entries
5095 .get(key)
5096 .is_some_and(|right| same_expr_shape(left, right))
5097 })
5098 }
5099 _ => false,
5100 }
5101}
5102
5103fn same_expr_shape(left: &Expr, right: &Expr) -> bool {
5104 match (strip_parens(left), strip_parens(right)) {
5105 (Expr::Literal(Literal::Int(a, _)), Expr::Literal(Literal::Int(b, _))) => a == b,
5106 (Expr::Literal(Literal::Float(a, _)), Expr::Literal(Literal::Float(b, _))) => {
5107 a.to_bits() == b.to_bits()
5108 }
5109 (Expr::Literal(Literal::Str(a, _)), Expr::Literal(Literal::Str(b, _))) => a == b,
5110 (Expr::Literal(Literal::Bool(a, _)), Expr::Literal(Literal::Bool(b, _))) => a == b,
5111 (Expr::Literal(Literal::Null(_)), Expr::Literal(Literal::Null(_))) => true,
5112 (Expr::Param(a), Expr::Param(b)) => a.name == b.name,
5113 (Expr::Var(a), Expr::Var(b)) => a.name == b.name,
5114 (Expr::Property(a), Expr::Property(b)) => {
5115 a.key == b.key && same_expr_shape(&a.object, &b.object)
5116 }
5117 (Expr::BinaryOp(a), Expr::BinaryOp(b)) => {
5118 a.op == b.op && same_expr_shape(&a.left, &b.left) && same_expr_shape(&a.right, &b.right)
5119 }
5120 (Expr::UnaryOp(a), Expr::UnaryOp(b)) => a.op == b.op && same_expr_shape(&a.expr, &b.expr),
5121 (Expr::FunctionCall(a), Expr::FunctionCall(b)) => {
5122 a.name
5123 .iter()
5124 .map(|part| part.to_ascii_lowercase())
5125 .eq(b.name.iter().map(|part| part.to_ascii_lowercase()))
5126 && a.distinct == b.distinct
5127 && a.star == b.star
5128 && a.args.len() == b.args.len()
5129 && a.args
5130 .iter()
5131 .zip(&b.args)
5132 .all(|(left, right)| same_expr_shape(left, right))
5133 }
5134 _ => false,
5135 }
5136}
5137
5138fn rewrite_projection_alias_refs(expr: Expr, projections: &[ReturnItem]) -> Expr {
5139 rewrite_projection_alias_refs_except(expr, projections, &[])
5140}
5141
5142#[allow(clippy::too_many_lines)]
5143fn rewrite_projection_alias_refs_except(
5144 expr: Expr,
5145 projections: &[ReturnItem],
5146 hidden: &[String],
5147) -> Expr {
5148 if let Expr::Var(var) = &expr
5149 && !hidden.contains(&var.name)
5150 && let Some(projection) = projections
5151 .iter()
5152 .find(|projection| projection.alias.as_deref() == Some(var.name.as_str()))
5153 {
5154 return projection.expr.clone();
5155 }
5156 match expr {
5157 Expr::BinaryOp(binary) => Expr::BinaryOp(graphforge_ast::BinaryOp {
5158 op: binary.op,
5159 left: Box::new(rewrite_projection_alias_refs_except(
5160 *binary.left,
5161 projections,
5162 hidden,
5163 )),
5164 right: Box::new(rewrite_projection_alias_refs_except(
5165 *binary.right,
5166 projections,
5167 hidden,
5168 )),
5169 span: binary.span,
5170 }),
5171 Expr::UnaryOp(unary) => Expr::UnaryOp(graphforge_ast::UnaryOp {
5172 op: unary.op,
5173 expr: Box::new(rewrite_projection_alias_refs_except(
5174 *unary.expr,
5175 projections,
5176 hidden,
5177 )),
5178 span: unary.span,
5179 }),
5180 Expr::Parenthesized { inner, span } => Expr::Parenthesized {
5181 inner: Box::new(rewrite_projection_alias_refs_except(
5182 *inner,
5183 projections,
5184 hidden,
5185 )),
5186 span,
5187 },
5188 Expr::Property(mut property) => {
5189 property.object = Box::new(rewrite_projection_alias_refs_except(
5190 *property.object,
5191 projections,
5192 hidden,
5193 ));
5194 Expr::Property(property)
5195 }
5196 Expr::FunctionCall(mut call) => {
5197 call.args = call
5198 .args
5199 .into_iter()
5200 .map(|arg| rewrite_projection_alias_refs_except(arg, projections, hidden))
5201 .collect();
5202 Expr::FunctionCall(call)
5203 }
5204 Expr::List(mut list) => {
5205 list.elements = list
5206 .elements
5207 .into_iter()
5208 .map(|element| rewrite_projection_alias_refs_except(element, projections, hidden))
5209 .collect();
5210 Expr::List(list)
5211 }
5212 Expr::Map(mut map) => {
5213 map.entries = map
5214 .entries
5215 .into_iter()
5216 .map(|(key, value)| {
5217 (
5218 key,
5219 rewrite_projection_alias_refs_except(value, projections, hidden),
5220 )
5221 })
5222 .collect();
5223 Expr::Map(map)
5224 }
5225 Expr::Case(mut case) => {
5226 case.subject = case.subject.map(|subject| {
5227 Box::new(rewrite_projection_alias_refs_except(
5228 *subject,
5229 projections,
5230 hidden,
5231 ))
5232 });
5233 for when in &mut case.when_clauses {
5234 when.condition = rewrite_projection_alias_refs_except(
5235 when.condition.clone(),
5236 projections,
5237 hidden,
5238 );
5239 when.result =
5240 rewrite_projection_alias_refs_except(when.result.clone(), projections, hidden);
5241 }
5242 case.else_expr = case.else_expr.map(|else_expr| {
5243 Box::new(rewrite_projection_alias_refs_except(
5244 *else_expr,
5245 projections,
5246 hidden,
5247 ))
5248 });
5249 Expr::Case(case)
5250 }
5251 Expr::ListComprehension(mut comprehension) => {
5252 comprehension.list = Box::new(rewrite_projection_alias_refs_except(
5253 *comprehension.list,
5254 projections,
5255 hidden,
5256 ));
5257 let mut body_hidden = hidden.to_vec();
5258 body_hidden.push(comprehension.var.clone());
5259 comprehension.filter = comprehension.filter.map(|filter| {
5260 Box::new(rewrite_projection_alias_refs_except(
5261 *filter,
5262 projections,
5263 &body_hidden,
5264 ))
5265 });
5266 comprehension.projection = comprehension.projection.map(|projection| {
5267 Box::new(rewrite_projection_alias_refs_except(
5268 *projection,
5269 projections,
5270 &body_hidden,
5271 ))
5272 });
5273 Expr::ListComprehension(comprehension)
5274 }
5275 Expr::Quantifier(mut quantifier) => {
5276 quantifier.list = Box::new(rewrite_projection_alias_refs_except(
5277 *quantifier.list,
5278 projections,
5279 hidden,
5280 ));
5281 let mut body_hidden = hidden.to_vec();
5282 body_hidden.push(quantifier.var.clone());
5283 quantifier.predicate = Box::new(rewrite_projection_alias_refs_except(
5284 *quantifier.predicate,
5285 projections,
5286 &body_hidden,
5287 ));
5288 Expr::Quantifier(quantifier)
5289 }
5290 Expr::IsNull {
5291 expr,
5292 negated,
5293 span,
5294 } => Expr::IsNull {
5295 expr: Box::new(rewrite_projection_alias_refs_except(
5296 *expr,
5297 projections,
5298 hidden,
5299 )),
5300 negated,
5301 span,
5302 },
5303 Expr::InList {
5304 expr,
5305 list,
5306 negated,
5307 span,
5308 } => Expr::InList {
5309 expr: Box::new(rewrite_projection_alias_refs_except(
5310 *expr,
5311 projections,
5312 hidden,
5313 )),
5314 list: Box::new(rewrite_projection_alias_refs_except(
5315 *list,
5316 projections,
5317 hidden,
5318 )),
5319 negated,
5320 span,
5321 },
5322 Expr::StringOp {
5323 expr,
5324 op,
5325 pattern,
5326 span,
5327 } => Expr::StringOp {
5328 expr: Box::new(rewrite_projection_alias_refs_except(
5329 *expr,
5330 projections,
5331 hidden,
5332 )),
5333 op,
5334 pattern: Box::new(rewrite_projection_alias_refs_except(
5335 *pattern,
5336 projections,
5337 hidden,
5338 )),
5339 span,
5340 },
5341 Expr::RegexMatch {
5342 expr,
5343 pattern,
5344 span,
5345 } => Expr::RegexMatch {
5346 expr: Box::new(rewrite_projection_alias_refs_except(
5347 *expr,
5348 projections,
5349 hidden,
5350 )),
5351 pattern: Box::new(rewrite_projection_alias_refs_except(
5352 *pattern,
5353 projections,
5354 hidden,
5355 )),
5356 span,
5357 },
5358 other => other,
5359 }
5360}
5361
5362fn collect_grouping_refs(expr: &Expr, out: &mut Vec<Expr>) {
5363 collect_grouping_refs_except(expr, out, &[]);
5364}
5365
5366#[allow(clippy::too_many_lines)]
5367fn collect_grouping_refs_except(expr: &Expr, out: &mut Vec<Expr>, hidden: &[String]) {
5368 if agg_func_of(expr).is_some() {
5369 return;
5370 }
5371 match expr {
5372 Expr::Var(_) | Expr::Property(_) if !grouping_ref_is_hidden(expr, hidden) => {
5373 out.push(expr.clone());
5374 }
5375 Expr::BinaryOp(binary) => {
5376 collect_grouping_refs_except(&binary.left, out, hidden);
5377 collect_grouping_refs_except(&binary.right, out, hidden);
5378 }
5379 Expr::UnaryOp(unary) => collect_grouping_refs_except(&unary.expr, out, hidden),
5380 Expr::Parenthesized { inner, .. } => collect_grouping_refs_except(inner, out, hidden),
5381 Expr::FunctionCall(call) => {
5382 for arg in &call.args {
5383 collect_grouping_refs_except(arg, out, hidden);
5384 }
5385 }
5386 Expr::List(list) => {
5387 for element in &list.elements {
5388 collect_grouping_refs_except(element, out, hidden);
5389 }
5390 }
5391 Expr::Map(map) => {
5392 for value in map.entries.values() {
5393 collect_grouping_refs_except(value, out, hidden);
5394 }
5395 }
5396 Expr::Case(case) => {
5397 if let Some(subject) = &case.subject {
5398 collect_grouping_refs_except(subject, out, hidden);
5399 }
5400 for when in &case.when_clauses {
5401 collect_grouping_refs_except(&when.condition, out, hidden);
5402 collect_grouping_refs_except(&when.result, out, hidden);
5403 }
5404 if let Some(else_expr) = &case.else_expr {
5405 collect_grouping_refs_except(else_expr, out, hidden);
5406 }
5407 }
5408 Expr::ListComprehension(lc) => {
5409 collect_grouping_refs_except(&lc.list, out, hidden);
5410 let mut body_hidden = hidden.to_vec();
5411 body_hidden.push(lc.var.clone());
5412 if let Some(filter) = &lc.filter {
5413 collect_grouping_refs_except(filter, out, &body_hidden);
5414 }
5415 if let Some(projection) = &lc.projection {
5416 collect_grouping_refs_except(projection, out, &body_hidden);
5417 }
5418 }
5419 Expr::Quantifier(q) => {
5420 collect_grouping_refs_except(&q.list, out, hidden);
5421 let mut predicate_hidden = hidden.to_vec();
5422 predicate_hidden.push(q.var.clone());
5423 collect_grouping_refs_except(&q.predicate, out, &predicate_hidden);
5424 }
5425 Expr::PatternComprehension(pc) => {
5426 let body_hidden = hidden_with_pattern_vars(hidden, &pc.pattern, pc.var.as_ref());
5427 if let Some(filter) = &pc.filter {
5428 collect_grouping_refs_except(filter, out, &body_hidden);
5429 }
5430 collect_grouping_refs_except(&pc.projection, out, &body_hidden);
5431 }
5432 Expr::ExistentialSubquery(es) => {
5433 if let ExistentialSubqueryBody::Simple { pattern, filter } = &es.body {
5434 let body_hidden = hidden_with_pattern_vars(hidden, pattern, None);
5435 if let Some(filter) = filter {
5436 collect_grouping_refs_except(filter, out, &body_hidden);
5437 }
5438 }
5439 }
5440 Expr::LabelPredicate(label) if !hidden.contains(&label.var) => {
5441 out.push(Expr::Var(VarRef {
5442 name: label.var.clone(),
5443 span: label.span,
5444 }));
5445 }
5446 Expr::IsNull { expr, .. } => collect_grouping_refs_except(expr, out, hidden),
5447 Expr::InList { expr, list, .. } => {
5448 collect_grouping_refs_except(expr, out, hidden);
5449 collect_grouping_refs_except(list, out, hidden);
5450 }
5451 Expr::StringOp { expr, pattern, .. } | Expr::RegexMatch { expr, pattern, .. } => {
5452 collect_grouping_refs_except(expr, out, hidden);
5453 collect_grouping_refs_except(pattern, out, hidden);
5454 }
5455 _ => {}
5456 }
5457}
5458
5459fn grouping_ref_is_hidden(expr: &Expr, hidden: &[String]) -> bool {
5460 match strip_parens(expr) {
5461 Expr::Var(var) => hidden.contains(&var.name),
5462 Expr::Property(property) => grouping_ref_is_hidden(&property.object, hidden),
5463 _ => false,
5464 }
5465}
5466
5467fn hidden_with_pattern_vars(
5468 hidden: &[String],
5469 pattern: &PathPattern,
5470 path_var: Option<&String>,
5471) -> Vec<String> {
5472 let mut body_hidden = hidden.to_vec();
5473 body_hidden.extend(pattern.elements.iter().filter_map(|element| match element {
5474 PathElement::Node(node) => node.var.clone(),
5475 PathElement::Rel(rel) => rel.var.clone(),
5476 }));
5477 body_hidden.extend(path_var.cloned());
5478 body_hidden
5479}
5480
5481#[allow(clippy::too_many_lines)]
5482fn rewrite_grouping_refs(expr: Expr, bindings: &[(Expr, String, VarId)]) -> Expr {
5483 rewrite_grouping_refs_except(expr, bindings, &[])
5484}
5485
5486#[allow(clippy::too_many_lines)]
5487fn rewrite_grouping_refs_except(
5488 expr: Expr,
5489 bindings: &[(Expr, String, VarId)],
5490 hidden: &[String],
5491) -> Expr {
5492 if !grouping_ref_is_hidden(&expr, hidden)
5493 && let Some((_, alias, _)) = bindings
5494 .iter()
5495 .find(|(group, _, _)| same_grouping_expr(&expr, group))
5496 {
5497 return Expr::Var(VarRef {
5498 name: alias.clone(),
5499 span: expr.span(),
5500 });
5501 }
5502 match expr {
5503 Expr::BinaryOp(binary) => Expr::BinaryOp(graphforge_ast::BinaryOp {
5504 op: binary.op,
5505 left: Box::new(rewrite_grouping_refs_except(*binary.left, bindings, hidden)),
5506 right: Box::new(rewrite_grouping_refs_except(
5507 *binary.right,
5508 bindings,
5509 hidden,
5510 )),
5511 span: binary.span,
5512 }),
5513 Expr::UnaryOp(unary) => Expr::UnaryOp(graphforge_ast::UnaryOp {
5514 op: unary.op,
5515 expr: Box::new(rewrite_grouping_refs_except(*unary.expr, bindings, hidden)),
5516 span: unary.span,
5517 }),
5518 Expr::Parenthesized { inner, span } => Expr::Parenthesized {
5519 inner: Box::new(rewrite_grouping_refs_except(*inner, bindings, hidden)),
5520 span,
5521 },
5522 Expr::FunctionCall(call) => Expr::FunctionCall(graphforge_ast::FunctionCall {
5523 name: call.name,
5524 distinct: call.distinct,
5525 star: call.star,
5526 args: call
5527 .args
5528 .into_iter()
5529 .map(|arg| rewrite_grouping_refs_except(arg, bindings, hidden))
5530 .collect(),
5531 span: call.span,
5532 }),
5533 Expr::Property(mut property) => {
5534 property.object = Box::new(rewrite_grouping_refs_except(
5535 *property.object,
5536 bindings,
5537 hidden,
5538 ));
5539 Expr::Property(property)
5540 }
5541 Expr::List(list) => Expr::List(graphforge_ast::ListLiteral {
5542 elements: list
5543 .elements
5544 .into_iter()
5545 .map(|element| rewrite_grouping_refs_except(element, bindings, hidden))
5546 .collect(),
5547 span: list.span,
5548 }),
5549 Expr::Map(mut map) => {
5550 map.entries = map
5551 .entries
5552 .into_iter()
5553 .map(|(key, value)| (key, rewrite_grouping_refs_except(value, bindings, hidden)))
5554 .collect();
5555 Expr::Map(map)
5556 }
5557 Expr::Case(mut case) => {
5558 case.subject = case
5559 .subject
5560 .map(|subject| Box::new(rewrite_grouping_refs_except(*subject, bindings, hidden)));
5561 for when in &mut case.when_clauses {
5562 when.condition =
5563 rewrite_grouping_refs_except(when.condition.clone(), bindings, hidden);
5564 when.result = rewrite_grouping_refs_except(when.result.clone(), bindings, hidden);
5565 }
5566 case.else_expr = case.else_expr.map(|else_expr| {
5567 Box::new(rewrite_grouping_refs_except(*else_expr, bindings, hidden))
5568 });
5569 Expr::Case(case)
5570 }
5571 Expr::ListComprehension(mut lc) => {
5572 lc.list = Box::new(rewrite_grouping_refs_except(*lc.list, bindings, hidden));
5573 let mut body_hidden = hidden.to_vec();
5574 body_hidden.push(lc.var.clone());
5575 lc.filter = lc.filter.map(|filter| {
5576 Box::new(rewrite_grouping_refs_except(
5577 *filter,
5578 bindings,
5579 &body_hidden,
5580 ))
5581 });
5582 lc.projection = lc.projection.map(|projection| {
5583 Box::new(rewrite_grouping_refs_except(
5584 *projection,
5585 bindings,
5586 &body_hidden,
5587 ))
5588 });
5589 Expr::ListComprehension(lc)
5590 }
5591 Expr::Quantifier(mut q) => {
5592 q.list = Box::new(rewrite_grouping_refs_except(*q.list, bindings, hidden));
5593 let mut predicate_hidden = hidden.to_vec();
5594 predicate_hidden.push(q.var.clone());
5595 q.predicate = Box::new(rewrite_grouping_refs_except(
5596 *q.predicate,
5597 bindings,
5598 &predicate_hidden,
5599 ));
5600 Expr::Quantifier(q)
5601 }
5602 Expr::PatternComprehension(mut pc) => {
5603 let body_hidden = hidden_with_pattern_vars(hidden, &pc.pattern, pc.var.as_ref());
5604 pc.filter = pc.filter.map(|filter| {
5605 Box::new(rewrite_grouping_refs_except(
5606 *filter,
5607 bindings,
5608 &body_hidden,
5609 ))
5610 });
5611 pc.projection = Box::new(rewrite_grouping_refs_except(
5612 *pc.projection,
5613 bindings,
5614 &body_hidden,
5615 ));
5616 Expr::PatternComprehension(pc)
5617 }
5618 Expr::ExistentialSubquery(mut es) => {
5619 if let ExistentialSubqueryBody::Simple { pattern, filter } = &mut es.body {
5620 let body_hidden = hidden_with_pattern_vars(hidden, pattern, None);
5621 *filter = filter.take().map(|filter| {
5622 Box::new(rewrite_grouping_refs_except(
5623 *filter,
5624 bindings,
5625 &body_hidden,
5626 ))
5627 });
5628 }
5629 Expr::ExistentialSubquery(es)
5630 }
5631 Expr::LabelPredicate(mut label) => {
5632 let reference = Expr::Var(VarRef {
5633 name: label.var.clone(),
5634 span: label.span,
5635 });
5636 if !hidden.contains(&label.var)
5637 && let Some((_, alias, _)) = bindings
5638 .iter()
5639 .find(|(group, _, _)| same_grouping_expr(&reference, group))
5640 {
5641 label.var.clone_from(alias);
5642 }
5643 Expr::LabelPredicate(label)
5644 }
5645 Expr::IsNull {
5646 expr,
5647 negated,
5648 span,
5649 } => Expr::IsNull {
5650 expr: Box::new(rewrite_grouping_refs_except(*expr, bindings, hidden)),
5651 negated,
5652 span,
5653 },
5654 Expr::InList {
5655 expr,
5656 list,
5657 negated,
5658 span,
5659 } => Expr::InList {
5660 expr: Box::new(rewrite_grouping_refs_except(*expr, bindings, hidden)),
5661 list: Box::new(rewrite_grouping_refs_except(*list, bindings, hidden)),
5662 negated,
5663 span,
5664 },
5665 Expr::StringOp {
5666 expr,
5667 op,
5668 pattern,
5669 span,
5670 } => Expr::StringOp {
5671 expr: Box::new(rewrite_grouping_refs_except(*expr, bindings, hidden)),
5672 op,
5673 pattern: Box::new(rewrite_grouping_refs_except(*pattern, bindings, hidden)),
5674 span,
5675 },
5676 Expr::RegexMatch {
5677 expr,
5678 pattern,
5679 span,
5680 } => Expr::RegexMatch {
5681 expr: Box::new(rewrite_grouping_refs_except(*expr, bindings, hidden)),
5682 pattern: Box::new(rewrite_grouping_refs_except(*pattern, bindings, hidden)),
5683 span,
5684 },
5685 other => other,
5686 }
5687}
5688
5689fn expr_contains_pattern_comprehension(expr: &Expr) -> bool {
5690 match expr {
5691 Expr::PatternComprehension(_) => true,
5692 Expr::Parenthesized { inner, .. } => expr_contains_pattern_comprehension(inner),
5693 Expr::BinaryOp(b) => {
5694 expr_contains_pattern_comprehension(&b.left)
5695 || expr_contains_pattern_comprehension(&b.right)
5696 }
5697 Expr::UnaryOp(u) => expr_contains_pattern_comprehension(&u.expr),
5698 Expr::IsNull { expr, .. } => expr_contains_pattern_comprehension(expr),
5699 Expr::InList { expr, list, .. } => {
5700 expr_contains_pattern_comprehension(expr) || expr_contains_pattern_comprehension(list)
5701 }
5702 Expr::StringOp { expr, pattern, .. } | Expr::RegexMatch { expr, pattern, .. } => {
5703 expr_contains_pattern_comprehension(expr)
5704 || expr_contains_pattern_comprehension(pattern)
5705 }
5706 Expr::FunctionCall(call) => call.args.iter().any(expr_contains_pattern_comprehension),
5707 Expr::Property(property) => expr_contains_pattern_comprehension(&property.object),
5708 Expr::List(list) => list
5709 .elements
5710 .iter()
5711 .any(expr_contains_pattern_comprehension),
5712 Expr::Map(map) => map
5713 .entries
5714 .values()
5715 .any(expr_contains_pattern_comprehension),
5716 Expr::Case(case) => {
5717 case.subject
5718 .as_deref()
5719 .is_some_and(expr_contains_pattern_comprehension)
5720 || case.when_clauses.iter().any(|when| {
5721 expr_contains_pattern_comprehension(&when.condition)
5722 || expr_contains_pattern_comprehension(&when.result)
5723 })
5724 || case
5725 .else_expr
5726 .as_deref()
5727 .is_some_and(expr_contains_pattern_comprehension)
5728 }
5729 Expr::ListComprehension(lc) => {
5730 expr_contains_pattern_comprehension(&lc.list)
5731 || lc
5732 .filter
5733 .as_deref()
5734 .is_some_and(expr_contains_pattern_comprehension)
5735 || lc
5736 .projection
5737 .as_deref()
5738 .is_some_and(expr_contains_pattern_comprehension)
5739 }
5740 Expr::Quantifier(q) => {
5741 expr_contains_pattern_comprehension(&q.list)
5742 || expr_contains_pattern_comprehension(&q.predicate)
5743 }
5744 _ => false,
5745 }
5746}
5747
5748fn strip_parens(expr: &Expr) -> &Expr {
5749 match expr {
5750 Expr::Parenthesized { inner, .. } => strip_parens(inner),
5751 other => other,
5752 }
5753}
5754
5755fn matches_pattern_predicate(expr: &Expr) -> bool {
5756 matches!(strip_parens(expr), Expr::PatternPredicate(_))
5757}
5758
5759fn expr_contains_pattern_predicate(expr: &Expr) -> bool {
5760 match expr {
5761 Expr::PatternPredicate(_) => true,
5762 Expr::Parenthesized { inner, .. } => expr_contains_pattern_predicate(inner),
5763 Expr::BinaryOp(b) => {
5764 expr_contains_pattern_predicate(&b.left) || expr_contains_pattern_predicate(&b.right)
5765 }
5766 Expr::UnaryOp(u) => expr_contains_pattern_predicate(&u.expr),
5767 Expr::IsNull { expr, .. } => expr_contains_pattern_predicate(expr),
5768 Expr::InList { expr, list, .. } => {
5769 expr_contains_pattern_predicate(expr) || expr_contains_pattern_predicate(list)
5770 }
5771 Expr::StringOp { expr, pattern, .. } | Expr::RegexMatch { expr, pattern, .. } => {
5772 expr_contains_pattern_predicate(expr) || expr_contains_pattern_predicate(pattern)
5773 }
5774 Expr::FunctionCall(call) => call.args.iter().any(expr_contains_pattern_predicate),
5775 Expr::List(list) => list.elements.iter().any(expr_contains_pattern_predicate),
5776 Expr::Map(map) => map.entries.values().any(expr_contains_pattern_predicate),
5777 Expr::Case(case) => {
5778 case.subject
5779 .as_deref()
5780 .is_some_and(expr_contains_pattern_predicate)
5781 || case.when_clauses.iter().any(|when| {
5782 expr_contains_pattern_predicate(&when.condition)
5783 || expr_contains_pattern_predicate(&when.result)
5784 })
5785 || case
5786 .else_expr
5787 .as_deref()
5788 .is_some_and(expr_contains_pattern_predicate)
5789 }
5790 Expr::ListComprehension(lc) => {
5791 expr_contains_pattern_predicate(&lc.list)
5792 || lc
5793 .filter
5794 .as_deref()
5795 .is_some_and(expr_contains_pattern_predicate)
5796 || lc
5797 .projection
5798 .as_deref()
5799 .is_some_and(expr_contains_pattern_predicate)
5800 }
5801 Expr::Quantifier(q) => {
5802 expr_contains_pattern_predicate(&q.list)
5803 || expr_contains_pattern_predicate(&q.predicate)
5804 }
5805 Expr::PatternComprehension(pc) => {
5806 pc.filter
5807 .as_deref()
5808 .is_some_and(expr_contains_pattern_predicate)
5809 || expr_contains_pattern_predicate(&pc.projection)
5810 }
5811 _ => false,
5812 }
5813}
5814
5815fn relationship_type_alternatives(pattern: &PathPattern) -> Vec<PathPattern> {
5816 let Some(PathElement::Rel(rel)) = pattern.elements.get(1) else {
5817 return vec![pattern.clone()];
5818 };
5819 if rel.types.len() <= 1 {
5820 return vec![pattern.clone()];
5821 }
5822
5823 rel.types
5824 .iter()
5825 .map(|rel_type| {
5826 let mut alternative = pattern.clone();
5827 let PathElement::Rel(rel) = &mut alternative.elements[1] else {
5828 unreachable!("single-relationship pattern shape was checked")
5829 };
5830 rel.types = vec![rel_type.clone()];
5831 alternative
5832 })
5833 .collect()
5834}
5835
5836fn is_single_relationship_pattern(pattern: &PathPattern) -> bool {
5837 matches!(
5838 pattern.elements.as_slice(),
5839 [
5840 PathElement::Node(_),
5841 PathElement::Rel(_),
5842 PathElement::Node(_)
5843 ]
5844 )
5845}
5846
5847fn pattern_has_var_length_relationship_properties(pattern: &PathPattern) -> bool {
5848 pattern.elements.iter().any(|element| {
5849 let PathElement::Rel(rel) = element else {
5850 return false;
5851 };
5852 (rel.min_hops.is_some() || rel.max_hops.is_some()) && rel.properties.is_some()
5853 })
5854}
5855
5856fn collect_pattern_disjunction<'a>(
5857 expr: &'a Expr,
5858 alternatives: &mut Vec<&'a PatternPredicate>,
5859) -> bool {
5860 match expr {
5861 Expr::Parenthesized { inner, .. } => collect_pattern_disjunction(inner, alternatives),
5862 Expr::PatternPredicate(pp) => {
5863 alternatives.push(pp);
5864 true
5865 }
5866 Expr::BinaryOp(graphforge_ast::BinaryOp {
5867 op: AstBinOp::Or,
5868 left,
5869 right,
5870 ..
5871 }) => {
5872 collect_pattern_disjunction(left, alternatives)
5873 && collect_pattern_disjunction(right, alternatives)
5874 }
5875 _ => false,
5876 }
5877}
5878
5879struct MixedPatternBranch<'a> {
5880 pattern: &'a PatternPredicate,
5881 scalar_filters: Vec<&'a Expr>,
5882}
5883
5884fn collect_mixed_pattern_disjunction<'a>(
5885 expr: &'a Expr,
5886 branches: &mut Vec<MixedPatternBranch<'a>>,
5887) -> bool {
5888 match expr {
5889 Expr::Parenthesized { inner, .. } => collect_mixed_pattern_disjunction(inner, branches),
5890 Expr::BinaryOp(graphforge_ast::BinaryOp {
5891 op: AstBinOp::Or,
5892 left,
5893 right,
5894 ..
5895 }) => {
5896 collect_mixed_pattern_disjunction(left, branches)
5897 && collect_mixed_pattern_disjunction(right, branches)
5898 }
5899 branch => {
5900 let mut conjuncts = Vec::new();
5901 collect_conjuncts(branch, &mut conjuncts);
5902 let mut pattern = None;
5903 let mut scalar_filters = Vec::new();
5904 for conjunct in conjuncts {
5905 let conjunct = strip_parens(conjunct);
5906 if let Expr::PatternPredicate(found) = conjunct {
5907 if pattern.replace(found).is_some() {
5908 return false;
5909 }
5910 } else if expr_contains_pattern_predicate(conjunct) {
5911 return false;
5912 } else {
5913 scalar_filters.push(conjunct);
5914 }
5915 }
5916 let Some(pattern) = pattern else {
5917 return false;
5918 };
5919 branches.push(MixedPatternBranch {
5920 pattern,
5921 scalar_filters,
5922 });
5923 true
5924 }
5925 }
5926}
5927
5928fn collect_conjuncts<'a>(expr: &'a Expr, conjuncts: &mut Vec<&'a Expr>) {
5929 match expr {
5930 Expr::Parenthesized { inner, .. } => collect_conjuncts(inner, conjuncts),
5931 Expr::BinaryOp(graphforge_ast::BinaryOp {
5932 op: AstBinOp::And,
5933 left,
5934 right,
5935 ..
5936 }) => {
5937 collect_conjuncts(left, conjuncts);
5938 collect_conjuncts(right, conjuncts);
5939 }
5940 other => conjuncts.push(other),
5941 }
5942}
5943
5944fn plan_references_any_var(plan: &GraphPlan, vars: &HashSet<VarId>) -> bool {
5945 let expression_reference = (0..plan.exprs.len()).any(|index| {
5946 let index = u32::try_from(index).expect("ExprArena length is capped at u32::MAX");
5947 matches!(
5948 plan.exprs.get(ExprId(index)),
5949 IrExpr::VarRef(var) if vars.contains(var)
5950 )
5951 });
5952 expression_reference
5953 || plan.ops.iter().any(|op| match op {
5954 GraphOp::NodeScan { var, .. }
5955 | GraphOp::EdgeScan { var, .. }
5956 | GraphOp::TypedEdgeScan { var, .. } => vars.contains(var),
5957 GraphOp::Expand { src, edge, dst, .. } => {
5958 vars.contains(src) || vars.contains(edge) || vars.contains(dst)
5959 }
5960 GraphOp::Optional { child }
5961 | GraphOp::Exists { child, .. }
5962 | GraphOp::PatternComprehension { child, .. }
5963 | GraphOp::ListElementPatternComprehension { child, .. } => {
5964 plan_references_any_var(child, vars)
5965 }
5966 GraphOp::Union { inputs, .. } => inputs
5967 .iter()
5968 .any(|input| plan_references_any_var(input, vars)),
5969 _ => false,
5970 })
5971}
5972
5973fn graph_op_bound_vars(op: &GraphOp) -> Vec<VarId> {
5974 match op {
5975 GraphOp::NodeScan { var, .. }
5976 | GraphOp::EdgeScan { var, .. }
5977 | GraphOp::TypedEdgeScan { var, .. } => vec![*var],
5978 GraphOp::Expand { src, edge, dst, .. } => vec![*src, *edge, *dst],
5979 _ => Vec::new(),
5980 }
5981}
5982
5983fn pattern_references_bound_var(pattern: &PathPattern, s: &BinderState) -> bool {
5984 pattern.elements.iter().any(|element| match element {
5985 PathElement::Node(node) => node
5986 .var
5987 .as_deref()
5988 .is_some_and(|name| s.vars.contains_key(name)),
5989 PathElement::Rel(rel) => rel
5990 .var
5991 .as_deref()
5992 .is_some_and(|name| s.vars.contains_key(name)),
5993 })
5994}
5995
5996fn expand_projection_wildcard(items: &[ReturnItem], s: &BinderState) -> Vec<ReturnItem> {
6007 let is_star = |e: &Expr| matches!(e, Expr::Var(VarRef { name, .. }) if name == "*");
6008 if !items.iter().any(|i| is_star(&i.expr)) {
6009 return items.to_vec();
6010 }
6011 let mut names: Vec<String> = s.vars.keys().chain(s.path_vars.keys()).cloned().collect();
6012 names.sort();
6013 names.dedup();
6014 let mut out = Vec::new();
6015 for item in items {
6016 if is_star(&item.expr) {
6017 for name in &names {
6018 out.push(ReturnItem {
6019 expr: Expr::Var(VarRef {
6020 name: name.clone(),
6021 span: item.span,
6022 }),
6023 alias: None,
6024 display: Some(name.clone()),
6026 span: item.span,
6027 });
6028 }
6029 } else {
6030 out.push(item.clone());
6031 }
6032 }
6033 out
6034}
6035
6036fn reject_empty_projection_wildcard(items: &[ReturnItem], s: &mut BinderState) -> bool {
6037 let Some(star) = items
6038 .iter()
6039 .find(|item| matches!(&item.expr, Expr::Var(VarRef { name, .. }) if name == "*"))
6040 else {
6041 return false;
6042 };
6043 if !s.vars.is_empty() || !s.path_vars.is_empty() {
6044 return false;
6045 }
6046 s.errors.push(BindError::new(
6047 BindErrorKind::InvalidArgument,
6048 star.span,
6049 "projection wildcard requires at least one variable in scope",
6050 ));
6051 true
6052}
6053
6054fn ensure_var(name: Option<&String>, s: &mut BinderState) -> VarId {
6055 if let Some(n) = name {
6056 ensure_var_name(n, s)
6057 } else {
6058 alloc_anon_var(s)
6059 }
6060}
6061
6062fn bound_rel_type_conflict(
6063 edge_var: VarId,
6064 rel_name: Option<&str>,
6065 is_scalar_hop: bool,
6066 s: &BinderState,
6067) -> bool {
6068 is_scalar_hop
6069 && matches!(
6070 (s.edge_rel_names.get(&edge_var).and_then(Option::as_deref), rel_name),
6071 (Some(prev), Some(next)) if prev != next
6072 )
6073}
6074
6075fn push_false_filter(s: &mut BinderState) {
6076 let predicate = s.builder.push_expr(IrExpr::Literal(IrLiteral::Bool(false)));
6077 s.builder.push_op_mut(GraphOp::Filter { predicate });
6078}
6079
6080fn push_conjunction(mut predicates: Vec<ExprId>, s: &mut BinderState) -> ExprId {
6081 let Some(mut acc) = predicates.pop() else {
6082 return s.builder.push_expr(IrExpr::Literal(IrLiteral::Bool(true)));
6083 };
6084 while let Some(next) = predicates.pop() {
6085 acc = s.builder.push_expr(IrExpr::BinaryOp {
6086 op: BinaryOpKind::And,
6087 left: next,
6088 right: acc,
6089 });
6090 }
6091 acc
6092}
6093
6094fn ensure_pattern_var(name: Option<&str>, kind: VarKind, span: Span, s: &mut BinderState) -> VarId {
6100 let Some(name) = name else {
6101 let id = alloc_anon_var(s);
6102 s.var_kinds.insert(id, kind);
6103 return id;
6104 };
6105 if let Some(&existing) = s.vars.get(name)
6106 && !s.var_kinds.contains_key(&existing)
6107 && !s.node_vars.contains_key(&existing)
6108 && !s.edge_rel_names.contains_key(&existing)
6109 {
6110 s.errors.push(BindError::new(
6111 BindErrorKind::VariableKindConflict,
6112 span,
6113 format!("variable `{name}` is bound as a value but used here as {kind}"),
6114 ));
6115 return existing;
6116 }
6117 let id = ensure_var_name(name, s);
6118 bind_var_kind(id, kind, name, span, s);
6119 id
6120}
6121
6122fn bind_var_kind(id: VarId, kind: VarKind, name: &str, span: Span, s: &mut BinderState) {
6123 if s.path_vars.contains_key(name) {
6128 s.errors.push(BindError::new(
6129 BindErrorKind::VariableKindConflict,
6130 span,
6131 format!("variable `{name}` is bound as a path but used here as {kind}"),
6132 ));
6133 return;
6134 }
6135 match s.var_kinds.get(&id) {
6136 Some(VarKind::Unknown) => {
6137 s.var_kinds.insert(id, kind);
6138 }
6139 Some(prev) if *prev != kind => s.errors.push(BindError::new(
6140 BindErrorKind::VariableKindConflict,
6141 span,
6142 format!("variable `{name}` is bound as {prev} but used here as {kind}"),
6143 )),
6144 _ => {
6145 s.var_kinds.insert(id, kind);
6146 }
6147 }
6148}
6149
6150fn alloc_anon_var(s: &mut BinderState) -> VarId {
6151 let id = VarId(s.next_var);
6152 s.next_var += 1;
6153 id
6154}
6155
6156fn ensure_var_name(name: &str, s: &mut BinderState) -> VarId {
6157 if let Some(&existing) = s.vars.get(name) {
6158 return existing;
6159 }
6160 let id = VarId(s.next_var);
6161 s.next_var += 1;
6162 s.vars.insert(name.to_owned(), id);
6163 id
6164}
6165
6166#[derive(Debug, Clone, PartialEq, Eq)]
6167enum BoundPropertyOwner {
6168 Entity(Option<String>),
6169 Relationship(Option<String>),
6170 Value,
6171}
6172fn property_owner_for_expr(expr: &Expr, s: &BinderState) -> BoundPropertyOwner {
6173 let Expr::Var(VarRef { name, .. }) = expr else {
6174 return BoundPropertyOwner::Value;
6175 };
6176 s.vars.get(name).map_or(BoundPropertyOwner::Value, |var| {
6177 property_owner_for_var(*var, s)
6178 })
6179}
6180fn property_owner_for_var(var: VarId, s: &BinderState) -> BoundPropertyOwner {
6181 let kind = s.var_kinds.get(&var).copied().or_else(|| {
6182 if s.node_vars.contains_key(&var) {
6183 Some(VarKind::Node)
6184 } else if s.edge_rel_names.contains_key(&var) {
6185 Some(VarKind::Relationship)
6186 } else {
6187 None
6188 }
6189 });
6190 match kind {
6191 Some(VarKind::Node) => BoundPropertyOwner::Entity(s.node_vars.get(&var).cloned().flatten()),
6192 Some(VarKind::Relationship) => {
6193 BoundPropertyOwner::Relationship(s.edge_rel_names.get(&var).cloned().flatten())
6194 }
6195 Some(VarKind::Unknown) | None => BoundPropertyOwner::Value,
6196 }
6197}
6198
6199struct BinderState {
6204 vars: HashMap<String, VarId>,
6205 path_vars: HashMap<String, PathBinding>,
6210 node_vars: HashMap<VarId, Option<String>>,
6215 edge_vars: HashMap<VarId, (VarId, VarId)>,
6221 edge_rel_names: HashMap<VarId, Option<String>>,
6226 scalar_list_edges: HashSet<VarId>,
6229 var_kinds: HashMap<VarId, VarKind>,
6234 next_var: u32,
6235 builder: GraphPlanBuilder,
6236 errors: Vec<BindError>,
6237 warnings: Vec<BindError>,
6238 captured_pattern_comprehensions: Option<Vec<(Box<GraphPlan>, VarId)>>,
6242 existential_depth: usize,
6244 standalone_call: bool,
6246}
6247
6248#[derive(Clone)]
6251struct PathBinding {
6252 nodes: Vec<VarId>,
6254 segments: Vec<PathSegment>,
6255}
6256
6257struct ForwardedEdgeBinding {
6258 var: VarId,
6259 rel_name: Option<String>,
6260 endpoints: Option<(VarId, VarId)>,
6261}
6262
6263struct GroupedEdgeBinding {
6264 alias: String,
6265 var: VarId,
6266 rel_name: Option<String>,
6267 endpoints: Option<(VarId, VarId)>,
6268}
6269
6270#[derive(Clone)]
6272struct PathSegment {
6273 edge: VarId,
6274 var_len: bool,
6277 rel_name: Option<String>,
6281}
6282
6283fn classify_uuid_parameter(value: &IrLiteral) -> Option<UuidParamClass> {
6288 match value {
6289 IrLiteral::Uuid(_) => Some(UuidParamClass::ExactUuid),
6290 IrLiteral::List(items) if items.iter().any(ir_literal_contains_uuid) => {
6291 Some(UuidParamClass::ContainsUuid)
6292 }
6293 IrLiteral::Map(entries)
6294 if entries
6295 .iter()
6296 .any(|(_, value)| ir_literal_contains_uuid(value)) =>
6297 {
6298 Some(UuidParamClass::ContainsUuid)
6299 }
6300 _ => None,
6301 }
6302}
6303
6304fn ir_literal_contains_uuid(value: &IrLiteral) -> bool {
6305 classify_uuid_parameter(value).is_some()
6306}
6307
6308fn lower_literal(lit: &Literal) -> IrLiteral {
6309 match lit {
6310 Literal::Int(n, _) => IrLiteral::Int(*n),
6311 Literal::Float(f, _) => IrLiteral::Float(*f),
6312 Literal::Str(s, _) => IrLiteral::Str(s.clone()),
6313 Literal::Bool(b, _) => IrLiteral::Bool(*b),
6314 Literal::Null(_) => IrLiteral::Null,
6315 }
6316}
6317
6318fn lower_binop(op: AstBinOp) -> BinaryOpKind {
6319 match op {
6320 AstBinOp::Eq => BinaryOpKind::Eq,
6321 AstBinOp::Neq => BinaryOpKind::Neq,
6322 AstBinOp::Lt => BinaryOpKind::Lt,
6323 AstBinOp::Lte => BinaryOpKind::Lte,
6324 AstBinOp::Gt => BinaryOpKind::Gt,
6325 AstBinOp::Gte => BinaryOpKind::Gte,
6326 AstBinOp::And => BinaryOpKind::And,
6327 AstBinOp::Or => BinaryOpKind::Or,
6328 AstBinOp::Xor => BinaryOpKind::Xor,
6329 AstBinOp::Add => BinaryOpKind::Add,
6330 AstBinOp::Sub => BinaryOpKind::Sub,
6331 AstBinOp::Mul => BinaryOpKind::Mul,
6332 AstBinOp::Div => BinaryOpKind::Div,
6333 AstBinOp::Mod => BinaryOpKind::Mod,
6334 AstBinOp::Pow => BinaryOpKind::Pow,
6335 AstBinOp::Concat => unreachable!("handled separately"),
6336 }
6337}
6338
6339fn lower_direction(dir: graphforge_ast::Direction) -> Direction {
6340 match dir {
6341 graphforge_ast::Direction::Out => Direction::Out,
6342 graphforge_ast::Direction::In => Direction::In,
6343 graphforge_ast::Direction::Undirected => Direction::Undirected,
6344 }
6345}
6346
6347#[derive(Clone, Copy)]
6348enum RowCountOp {
6349 Skip,
6350 Limit,
6351}
6352
6353impl RowCountOp {
6354 fn keyword(self) -> &'static str {
6355 match self {
6356 Self::Skip => "SKIP",
6357 Self::Limit => "LIMIT",
6358 }
6359 }
6360
6361 fn graph_op(self, count: u64) -> GraphOp {
6362 match self {
6363 Self::Skip => GraphOp::Skip { count },
6364 Self::Limit => GraphOp::Limit { count },
6365 }
6366 }
6367
6368 fn graph_param_op(self, name: String) -> GraphOp {
6369 match self {
6370 Self::Skip => GraphOp::SkipParam { name },
6371 Self::Limit => GraphOp::LimitParam { name },
6372 }
6373 }
6374
6375 fn graph_expr_op(self, expr: ExprId) -> GraphOp {
6376 match self {
6377 Self::Skip => GraphOp::SkipExpr { expr },
6378 Self::Limit => GraphOp::LimitExpr { expr },
6379 }
6380 }
6381}
6382
6383fn push_skip_limit(
6384 binder: &Binder,
6385 skip: Option<&Expr>,
6386 limit: Option<&Expr>,
6387 s: &mut BinderState,
6388) {
6389 if let Some(expr) = skip {
6390 push_row_count_op(binder, RowCountOp::Skip, expr, s);
6391 }
6392 if let Some(expr) = limit {
6393 push_row_count_op(binder, RowCountOp::Limit, expr, s);
6394 }
6395}
6396
6397fn push_row_count_op(binder: &Binder, kind: RowCountOp, expr: &Expr, s: &mut BinderState) {
6398 if binder.typed_uuid_param_in(expr).is_some() {
6399 binder.lower_expr(expr, expr.span(), s);
6400 return;
6401 }
6402 if let Some(n) = extract_non_negative_int_constant(expr) {
6403 s.builder.push_op_mut(kind.graph_op(n));
6404 return;
6405 }
6406 if let Some(name) = extract_parameter_name(expr) {
6407 s.builder.push_op_mut(kind.graph_param_op(name));
6408 return;
6409 }
6410 if extract_int_constant(expr).is_some() || is_float_constant(expr) {
6411 s.errors.push(BindError::new(
6412 BindErrorKind::InvalidArgument,
6413 expr.span(),
6414 format!("{} requires a non-negative integer value", kind.keyword()),
6415 ));
6416 return;
6417 }
6418 let mut refs = Vec::new();
6419 collect_grouping_refs(expr, &mut refs);
6420 if refs.is_empty() && !expr_contains_aggregate(expr) && row_count_expr_is_integer(expr) {
6421 let expr = binder.lower_expr(expr, expr.span(), s);
6422 s.builder.push_op_mut(kind.graph_expr_op(expr));
6423 return;
6424 }
6425 s.errors.push(BindError::new(
6426 BindErrorKind::InvalidArgument,
6427 expr.span(),
6428 format!(
6429 "{} requires a non-negative variable-independent integer expression",
6430 kind.keyword()
6431 ),
6432 ));
6433}
6434
6435fn row_count_expr_is_integer(expr: &Expr) -> bool {
6436 match expr {
6437 Expr::Literal(Literal::Int(_, _)) | Expr::Param(_) => true,
6438 Expr::Parenthesized { inner, .. } => row_count_expr_is_integer(inner),
6439 Expr::UnaryOp(graphforge_ast::UnaryOp {
6440 op: AstUnOp::Neg,
6441 expr,
6442 ..
6443 }) => row_count_expr_is_integer(expr),
6444 Expr::BinaryOp(binary) => {
6445 row_count_expr_is_integer(&binary.left) && row_count_expr_is_integer(&binary.right)
6446 }
6447 Expr::FunctionCall(call) => is_function_named(call, "toInteger"),
6448 _ => false,
6449 }
6450}
6451
6452fn is_float_constant(expr: &Expr) -> bool {
6453 match expr {
6454 Expr::Literal(Literal::Float(_, _)) => true,
6455 Expr::Parenthesized { inner, .. } => is_float_constant(inner),
6456 Expr::UnaryOp(graphforge_ast::UnaryOp {
6457 op: AstUnOp::Neg,
6458 expr,
6459 ..
6460 }) => is_float_constant(expr),
6461 _ => false,
6462 }
6463}
6464
6465fn extract_parameter_name(expr: &Expr) -> Option<String> {
6466 match expr {
6467 Expr::Param(graphforge_ast::ParamRef { name, .. }) => Some(name.clone()),
6468 Expr::Parenthesized { inner, .. } => extract_parameter_name(inner),
6469 _ => None,
6470 }
6471}
6472
6473fn extract_non_negative_int_constant(expr: &Expr) -> Option<u64> {
6474 let n = extract_int_constant(expr)?;
6475 u64::try_from(n).ok()
6476}
6477
6478fn extract_int_constant(expr: &Expr) -> Option<i64> {
6479 match expr {
6480 Expr::Literal(Literal::Int(n, _)) => Some(*n),
6481 Expr::Parenthesized { inner, .. } => extract_int_constant(inner),
6482 Expr::UnaryOp(graphforge_ast::UnaryOp {
6483 op: AstUnOp::Neg,
6484 expr,
6485 ..
6486 }) => extract_int_constant(expr)?.checked_neg(),
6487 _ => None,
6488 }
6489}
6490
6491fn validate_created_rel(
6501 rel: &graphforge_ast::RelPattern,
6502 bound_before: &std::collections::HashSet<VarId>,
6503 allow_undirected: bool,
6504 s: &mut BinderState,
6505) {
6506 let mut err = |m: &str| {
6507 s.errors.push(BindError::new(
6508 BindErrorKind::InvalidArgument,
6509 rel.span,
6510 m.to_string(),
6511 ));
6512 };
6513 if rel.types.len() != 1 {
6514 err("a created relationship must have exactly one type");
6515 }
6516 if rel.min_hops.is_some() || rel.max_hops.is_some() {
6517 err("cannot create a variable-length relationship");
6518 }
6519 if !allow_undirected && matches!(rel.direction, graphforge_ast::Direction::Undirected) {
6520 err("a created relationship must be directed");
6521 }
6522 if let Some(name) = rel.var.as_deref()
6523 && s.vars.get(name).is_some_and(|v| bound_before.contains(v))
6524 {
6525 s.errors.push(BindError::new(
6526 BindErrorKind::VariableAlreadyBound,
6527 rel.span,
6528 format!("relationship variable `{name}` is already bound"),
6529 ));
6530 }
6531}
6532
6533fn check_duplicate_aliases(items: &[ReturnItem], s: &mut BinderState) {
6534 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
6535 for item in items {
6536 if let Some(a) = item.alias.as_deref()
6537 && !seen.insert(a)
6538 {
6539 s.errors.push(BindError::new(
6540 BindErrorKind::InvalidArgument,
6541 item.span,
6542 format!("multiple result columns with the same name `{a}`"),
6543 ));
6544 }
6545 }
6546}
6547
6548#[cfg(test)]
6553mod tests {
6554 use super::*;
6555 use crate::{ProcedureDefinition, ProcedureField};
6556 use arrow::array::{StringArray, UInt32Array, UInt64Array};
6557 use graphforge_cypher::parse;
6558 use graphforge_ontology::{OntologyCompiler, OntologyDoc, OntologyHandle};
6559
6560 fn make_binder(mode: OntologyMode) -> (Binder, Arc<Mutex<RuntimeCatalog>>) {
6561 let catalog = Arc::new(Mutex::new(RuntimeCatalog::new()));
6562 let binder = Binder::new(None, Arc::clone(&catalog), mode);
6563 (binder, catalog)
6564 }
6565
6566 fn catalog_entry(catalog: &RuntimeCatalog, kind: &str, name: &str) -> (u32, u64) {
6567 let batch = catalog.to_record_batch();
6568 let kinds = batch
6569 .column(0)
6570 .as_any()
6571 .downcast_ref::<StringArray>()
6572 .unwrap();
6573 let names = batch
6574 .column(1)
6575 .as_any()
6576 .downcast_ref::<StringArray>()
6577 .unwrap();
6578 let ids = batch
6579 .column(2)
6580 .as_any()
6581 .downcast_ref::<UInt32Array>()
6582 .unwrap();
6583 let counts = batch
6584 .column(3)
6585 .as_any()
6586 .downcast_ref::<UInt64Array>()
6587 .unwrap();
6588 let row = (0..batch.num_rows())
6589 .find(|&row| kinds.value(row) == kind && names.value(row) == name)
6590 .unwrap_or_else(|| panic!("missing catalog entry {kind}:{name}"));
6591 (ids.value(row), counts.value(row))
6592 }
6593
6594 fn strict_property_binder(shadow_inherited: bool) -> (Binder, Arc<Mutex<RuntimeCatalog>>) {
6595 let shadow = if shadow_inherited {
6596 r#", {"owner":"Host","name":"inherited","type":"utf8"}"#
6597 } else {
6598 ""
6599 };
6600 let doc: OntologyDoc = serde_json::from_str(&format!(
6601 r#"{{"ontology_id":"strict-properties","version":"1","entity_types":[{{"name":"Asset"}},{{"name":"Host","parent":"Asset"}}],"relation_types":[{{"name":"R","src":"Host","dst":"Host"}},{{"name":"S","src":"Host","dst":"Host"}}],"properties":[{{"owner":"Asset","name":"inherited","type":"utf8"}},{{"owner":"Host","name":"direct","type":"utf8"}},{{"owner":"R","name":"weight","type":"int64"}},{{"owner":"S","name":"weight","type":"int64"}},{{"owner":"Asset","name":"shared","type":"utf8"}},{{"owner":"R","name":"shared","type":"utf8"}}{shadow}]}}"#
6602 ))
6603 .unwrap();
6604 let ontology = OntologyCompiler::compile(&doc).unwrap();
6605 let catalog = Arc::new(Mutex::new(RuntimeCatalog::new()));
6606 catalog.lock().unwrap().intern_property("preexisting", None);
6607 (
6608 Binder::new(
6609 Some(OntologyHandle::new(ontology)),
6610 Arc::clone(&catalog),
6611 OntologyMode::Strict,
6612 ),
6613 catalog,
6614 )
6615 }
6616
6617 fn plan_property_ids(plan: &GraphPlan) -> Vec<PropId> {
6618 (0..u32::try_from(plan.exprs.len()).unwrap())
6619 .filter_map(|index| match plan.exprs.get(ExprId(index)) {
6620 IrExpr::PropertyAccess { prop, .. } => Some(*prop),
6621 _ => None,
6622 })
6623 .collect()
6624 }
6625
6626 fn property_error(binder: &Binder, query: &str, kind: BindErrorKind, span: &str) -> BindError {
6627 let errors = binder.bind(&parse(query).unwrap()).expect_err(query);
6628 assert_eq!(errors.len(), 1, "{query}: {errors:?}");
6629 let error = errors.into_iter().next().unwrap();
6630 assert_eq!(error.kind, kind);
6631 assert_eq!(&query[error.span.start..error.span.end], span);
6632 error
6633 }
6634
6635 #[test]
6636 fn exploratory_unknown_label_succeeds() {
6637 let (binder, catalog) = make_binder(OntologyMode::Exploratory);
6638 let ast = parse("MATCH (a:UnknownLabel)-[:UNKNOWN_REL]->(b) RETURN a").unwrap();
6639 let plan = binder.bind(&ast).expect("exploratory bind should succeed");
6640
6641 assert!(
6642 plan.ops
6643 .iter()
6644 .any(|op| matches!(op, GraphOp::NodeScan { .. }))
6645 );
6646 let cat = catalog.lock().unwrap();
6647 assert!(cat.contains_entity_type("UnknownLabel"));
6648 assert!(cat.relation_types().contains(&"UNKNOWN_REL"));
6649 }
6650
6651 #[test]
6652 fn bind_catalog_mutations_commit_only_after_success() {
6653 let (binder, catalog) = make_binder(OntologyMode::Exploratory);
6654 {
6655 let mut catalog = catalog.lock().unwrap();
6656 assert_eq!(catalog.intern_label("Seed").0, 0);
6657 assert_eq!(catalog.intern_relation_type("SEED_REL").0, 1);
6658 assert_eq!(catalog.intern_property("seed", None).0, 0);
6659 }
6660 let before = catalog.lock().unwrap().to_record_batch();
6661
6662 let errors = binder
6663 .bind(
6664 &parse(
6665 "MATCH (n:Rejected)-[:REJECTED_REL]->() \
6666 RETURN n.rejected, missing",
6667 )
6668 .unwrap(),
6669 )
6670 .expect_err("a later semantic error must reject every staged observation");
6671 assert!(
6672 errors
6673 .iter()
6674 .any(|error| error.kind == BindErrorKind::UndeclaredVariable)
6675 );
6676 assert_eq!(
6677 catalog.lock().unwrap().to_record_batch(),
6678 before,
6679 "failed binding must leave entries, observations, timestamps, and IDs unchanged"
6680 );
6681
6682 binder
6683 .bind(
6684 &parse(
6685 "MATCH (n:Accepted)-[:ACCEPTED_REL]->() \
6686 RETURN n.accepted",
6687 )
6688 .unwrap(),
6689 )
6690 .expect("successful binding must publish the staged catalog");
6691 let catalog = catalog.lock().unwrap();
6692 assert_eq!(catalog_entry(&catalog, "entity_type", "Accepted"), (2, 1));
6693 assert_eq!(
6694 catalog_entry(&catalog, "relation_type", "ACCEPTED_REL"),
6695 (3, 1)
6696 );
6697 assert_eq!(catalog_entry(&catalog, "property", "accepted"), (1, 1));
6698 }
6699
6700 #[test]
6701 fn fixed_pattern_predicate_lowers_to_exists() {
6702 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6703 let ast = parse("MATCH (n) WHERE (n)-[:REL]->() RETURN n").unwrap();
6704 let plan = binder.bind(&ast).expect("pattern predicate binds");
6705
6706 let exists = plan
6707 .ops
6708 .iter()
6709 .find_map(|op| match op {
6710 GraphOp::Exists { child, negated } => Some((child, negated)),
6711 _ => None,
6712 })
6713 .expect("pattern predicate should lower to Exists");
6714 assert!(!*exists.1);
6715 assert!(
6716 exists
6717 .0
6718 .ops
6719 .iter()
6720 .any(|op| matches!(op, GraphOp::Expand { .. })),
6721 "child plan must match the relationship pattern"
6722 );
6723 }
6724
6725 #[test]
6726 fn relationship_uniqueness_is_scoped_to_each_path_pattern() {
6727 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6728 let ast = parse("MATCH (a)-[r1]->(b)-[r2]->(c), (x)-[r3]->(y) RETURN a").unwrap();
6729 let plan = binder.bind(&ast).expect("pattern binds");
6730
6731 let constraints: Vec<_> = plan
6732 .ops
6733 .iter()
6734 .filter_map(|op| match op {
6735 GraphOp::RelationshipUnique { edge, prior_edges } => {
6736 Some((*edge, prior_edges.clone()))
6737 }
6738 _ => None,
6739 })
6740 .collect();
6741 assert_eq!(constraints.len(), 1);
6742 assert_eq!(constraints[0].1.len(), 1);
6743 }
6744
6745 #[test]
6746 fn simple_existential_subquery_allows_child_local_variables() {
6747 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6748 let ast = parse("MATCH (n) WHERE exists { (n)-[r]->(m) WHERE type(r) = 'REL' } RETURN n")
6749 .unwrap();
6750 let plan = binder.bind(&ast).expect("existential subquery binds");
6751
6752 let child = plan
6753 .ops
6754 .iter()
6755 .find_map(|op| match op {
6756 GraphOp::Exists { child, negated } => {
6757 assert!(!negated);
6758 Some(child)
6759 }
6760 _ => None,
6761 })
6762 .expect("expected Exists op");
6763 assert!(
6764 child
6765 .ops
6766 .iter()
6767 .any(|op| matches!(op, GraphOp::Expand { .. }))
6768 );
6769 assert!(
6770 child
6771 .ops
6772 .iter()
6773 .any(|op| matches!(op, GraphOp::Filter { .. }))
6774 );
6775 }
6776
6777 #[test]
6778 fn full_existential_correlation_is_scope_aware() {
6779 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6780 let correlated = parse(
6781 "MATCH (n) WHERE exists { MATCH (m) WHERE m.prop = n.prop RETURN true } RETURN n",
6782 )
6783 .unwrap();
6784 binder
6785 .bind(&correlated)
6786 .expect("an outer variable used only in a child expression must bind");
6787
6788 let shadowed = parse("MATCH (n) WHERE exists { WITH 1 AS n RETURN n } RETURN n").unwrap();
6789 let errors = binder
6790 .bind(&shadowed)
6791 .expect_err("a child-local alias must not count as outer correlation");
6792 assert!(
6793 errors.iter().any(|error| {
6794 error.kind == BindErrorKind::UndeclaredVariable
6795 && error
6796 .message
6797 .contains("must reference at least one outer variable")
6798 }),
6799 "expected uncorrelated-subquery error, got {errors:?}"
6800 );
6801 }
6802
6803 #[test]
6804 fn with_nested_aggregate_lowers_to_aggregate_then_scope_reset() {
6805 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6806 let ast =
6807 parse("MATCH (me)--(you) WITH me.age AS age, me.age + count(you.age) AS agg RETURN *")
6808 .unwrap();
6809 let plan = binder.bind(&ast).expect("nested WITH aggregate binds");
6810 let aggregate = plan
6811 .ops
6812 .iter()
6813 .position(|op| matches!(op, GraphOp::Aggregate { .. }))
6814 .expect("aggregate op");
6815 let with = plan
6816 .ops
6817 .iter()
6818 .skip(aggregate + 1)
6819 .position(|op| matches!(op, GraphOp::With { .. }))
6820 .expect("post-aggregate scope reset");
6821 assert_eq!(with, 0);
6822 }
6823
6824 #[test]
6825 fn with_rejects_ambiguous_and_nested_aggregation() {
6826 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6827 let ambiguous =
6828 parse("MATCH (me)--(you) WITH me.age + count(you.age) AS agg RETURN agg").unwrap();
6829 let error = binder
6830 .bind(&ambiguous)
6831 .expect_err("ambiguous grouping must fail");
6832 assert!(
6833 error
6834 .iter()
6835 .any(|error| error.message.contains("ambiguous aggregation expression"))
6836 );
6837
6838 let nested = parse("MATCH (n) WITH count(count(*)) AS c RETURN c").unwrap();
6839 let error = binder
6840 .bind(&nested)
6841 .expect_err("nested aggregates must fail");
6842 assert!(
6843 error
6844 .iter()
6845 .any(|error| { error.message.contains("may not contain another aggregate") })
6846 );
6847 }
6848
6849 #[test]
6850 fn return_rejects_nested_and_volatile_aggregation() {
6851 for (query, message) in [
6852 (
6853 "RETURN count(count(*))",
6854 "may not contain another aggregate",
6855 ),
6856 (
6857 "RETURN count(rand())",
6858 "non-deterministic functions are not allowed",
6859 ),
6860 ] {
6861 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6862 let ast = parse(query).unwrap();
6863 let errors = binder.bind(&ast).expect_err("query must fail at bind");
6864 assert!(
6865 errors.iter().any(|error| error.message.contains(message)),
6866 "missing {message:?} for {query}: {errors:?}"
6867 );
6868 }
6869 }
6870
6871 #[test]
6872 fn return_rejects_unknown_function_at_bind() {
6873 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6874 let ast = parse("MATCH (a) RETURN foo(a)").unwrap();
6875 let errors = binder.bind(&ast).expect_err("unknown function must fail");
6876 assert!(
6877 errors
6878 .iter()
6879 .any(|error| error.message.contains("unknown function `foo`"))
6880 );
6881 }
6882
6883 #[test]
6884 fn negated_pattern_predicate_lowers_to_anti_exists() {
6885 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6886 let ast = parse("MATCH (n) WHERE NOT (n)-[:REL]-() RETURN n").unwrap();
6887 let plan = binder.bind(&ast).expect("negated pattern predicate binds");
6888
6889 assert!(
6890 plan.ops
6891 .iter()
6892 .any(|op| { matches!(op, GraphOp::Exists { negated: true, .. }) })
6893 );
6894 }
6895
6896 #[test]
6897 fn multi_type_pattern_predicate_lowers_to_union_exists() {
6898 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6899 let ast = parse("MATCH (n), (m) WHERE (n)-[:REL1|REL2]-(m) RETURN n").unwrap();
6900 let plan = binder.bind(&ast).expect("multi-type predicate binds");
6901
6902 let inputs = plan
6903 .ops
6904 .iter()
6905 .find_map(|op| match op {
6906 GraphOp::Exists { child, .. } => match child.ops.as_slice() {
6907 [GraphOp::Union { inputs, .. }] => Some(inputs),
6908 _ => None,
6909 },
6910 _ => None,
6911 })
6912 .expect("multi-type predicate should lower to union-backed Exists");
6913 assert_eq!(inputs.len(), 2);
6914 }
6915
6916 #[test]
6917 fn or_pattern_predicate_lowers_to_union_exists() {
6918 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6919 let ast = parse("MATCH (n) WHERE (n)-[:REL1]-() OR (n)-[:REL2]-() RETURN n").unwrap();
6920 let plan = binder.bind(&ast).expect("OR predicate binds");
6921
6922 let inputs = plan
6923 .ops
6924 .iter()
6925 .find_map(|op| match op {
6926 GraphOp::Exists { child, .. } => match child.ops.as_slice() {
6927 [GraphOp::Union { inputs, .. }] => Some(inputs),
6928 _ => None,
6929 },
6930 _ => None,
6931 })
6932 .expect("OR predicate should lower to union-backed Exists");
6933 assert_eq!(inputs.len(), 2);
6934 }
6935
6936 #[test]
6937 fn pattern_predicate_rejects_new_named_variables() {
6938 expect_bind_error(
6939 "MATCH (n) WHERE (n)-[r]->() RETURN n",
6940 BindErrorKind::UndeclaredVariable,
6941 );
6942 expect_bind_error(
6943 "MATCH (n) WHERE (n)-->(m) RETURN n",
6944 BindErrorKind::UndeclaredVariable,
6945 );
6946 }
6947
6948 #[test]
6949 fn pattern_predicate_rejects_named_path_binding() {
6950 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6951 let mut ast = parse("MATCH (n) WHERE (n)-[:REL]->() RETURN n").unwrap();
6952 let AstClause::Match(m) = &mut ast.clauses[0] else {
6953 panic!("expected MATCH clause");
6954 };
6955 let where_clause = m.where_clause.as_mut().expect("expected WHERE");
6956 let Expr::PatternPredicate(pp) = &mut where_clause.predicate else {
6957 panic!("expected pattern predicate");
6958 };
6959 pp.pattern.var = Some("p".into());
6960
6961 let errs = binder
6962 .bind(&ast)
6963 .expect_err("predicate-local path binding should fail");
6964 assert!(
6965 errs.iter()
6966 .any(|err| err.kind == BindErrorKind::UndeclaredVariable),
6967 "expected undeclared path binding error, got {errs:?}"
6968 );
6969 }
6970
6971 #[test]
6972 fn var_length_pattern_predicate_rejects_relationship_properties() {
6973 expect_bind_error(
6974 "MATCH (n) WHERE (n)-[:REL* {k: 1}]->() RETURN n",
6975 BindErrorKind::InvalidArgument,
6976 );
6977 }
6978
6979 #[test]
6980 fn pattern_predicate_rejects_uncorrelated_patterns() {
6981 expect_bind_error(
6982 "MATCH (n) WHERE ()-[:REL]->() RETURN n",
6983 BindErrorKind::UndeclaredVariable,
6984 );
6985 }
6986
6987 #[test]
6988 fn named_pattern_comprehension_binds_correlated_child_projection() {
6989 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6990 let ast = parse("MATCH (n) RETURN [p = (n)-[:REL]->() | p] AS paths").unwrap();
6991 let plan = binder.bind(&ast).expect("pattern comprehension binds");
6992
6993 let (child, output) = plan
6994 .ops
6995 .iter()
6996 .find_map(|op| match op {
6997 GraphOp::PatternComprehension { child, output } => Some((child, output)),
6998 _ => None,
6999 })
7000 .expect("expected a PatternComprehension op");
7001 assert!(
7002 child
7003 .ops
7004 .iter()
7005 .any(|op| matches!(op, GraphOp::Expand { .. }))
7006 );
7007 let projection = child
7008 .ops
7009 .last()
7010 .and_then(|op| match op {
7011 GraphOp::Project { items, .. } => items.first(),
7012 _ => None,
7013 })
7014 .expect("child must end with one value projection");
7015 assert_eq!(
7016 projection.alias.as_deref(),
7017 Some(PATTERN_COMPREHENSION_VALUE_ALIAS)
7018 );
7019 assert!(matches!(
7020 child.exprs.get(projection.expr),
7021 IrExpr::FunctionCall { name, .. } if name == "_path_struct"
7022 ));
7023 let outer_projection = plan
7024 .ops
7025 .iter()
7026 .rev()
7027 .find_map(|op| match op {
7028 GraphOp::Project { items, .. } => items.first(),
7029 _ => None,
7030 })
7031 .expect("outer RETURN must project the collected result");
7032 assert!(matches!(
7033 plan.exprs.get(outer_projection.expr),
7034 IrExpr::VarRef(var) if var == output
7035 ));
7036 }
7037
7038 #[test]
7039 fn pattern_comprehension_binds_local_node_and_relationship_properties() {
7040 for query in [
7041 "MATCH (n) RETURN [(n)-[:REL]->(b) | b.name] AS names",
7042 "MATCH (n) RETURN [(n)-[r:REL]->() | r.name] AS names",
7043 ] {
7044 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7045 let ast = parse(query).unwrap();
7046 let plan = binder.bind(&ast).expect("local projection binds");
7047 let child = plan
7048 .ops
7049 .iter()
7050 .find_map(|op| match op {
7051 GraphOp::PatternComprehension { child, .. } => Some(child),
7052 _ => None,
7053 })
7054 .expect("expected a PatternComprehension op");
7055 let projection = child
7056 .ops
7057 .last()
7058 .and_then(|op| match op {
7059 GraphOp::Project { items, .. } => items.first(),
7060 _ => None,
7061 })
7062 .expect("child must end with a projection");
7063 assert!(matches!(
7064 child.exprs.get(projection.expr),
7065 IrExpr::PropertyAccess { .. }
7066 ));
7067 }
7068 }
7069
7070 #[test]
7071 fn pattern_comprehension_binds_filter_and_variable_length_match() {
7072 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7073 let ast =
7074 parse("MATCH (n) RETURN [(n)-[r:REL*]->(b) WHERE b.ok = true | b] AS matches").unwrap();
7075 let plan = binder
7076 .bind(&ast)
7077 .expect("filtered var-length comprehension binds");
7078 let child = plan
7079 .ops
7080 .iter()
7081 .find_map(|op| match op {
7082 GraphOp::PatternComprehension { child, .. } => Some(child),
7083 _ => None,
7084 })
7085 .expect("expected a PatternComprehension op");
7086
7087 assert!(child.ops.iter().any(|op| {
7088 matches!(
7089 op,
7090 GraphOp::Expand {
7091 min_hops: 1,
7092 max_hops: None,
7093 ..
7094 }
7095 )
7096 }));
7097 assert!(
7098 child
7099 .ops
7100 .iter()
7101 .any(|op| matches!(op, GraphOp::Filter { .. }))
7102 );
7103 assert!(matches!(child.ops.last(), Some(GraphOp::Project { .. })));
7104 }
7105
7106 #[test]
7107 fn pattern_comprehension_local_variables_do_not_leak() {
7108 expect_bind_error(
7109 "MATCH (n) RETURN [(n)-->(b) | b] AS matches, b",
7110 BindErrorKind::UndeclaredVariable,
7111 );
7112 }
7113
7114 #[test]
7115 fn pattern_comprehension_in_list_element_scope_lifts_to_graph_op() {
7116 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7117 let ast = parse("MATCH (n) RETURN [x IN [n] | [(x)-->() | x]] AS nested").unwrap();
7118 let plan = binder
7119 .bind(&ast)
7120 .expect("bind nested pattern comprehension");
7121 let lifted = plan.ops.iter().find_map(|op| match op {
7122 GraphOp::ListElementPatternComprehension { child, .. } => Some(child),
7123 _ => None,
7124 });
7125 let child = lifted.expect("list-element graph operation");
7126 assert!(matches!(child.ops.last(), Some(GraphOp::Project { .. })));
7127 }
7128
7129 #[test]
7130 fn bare_graph_value_where_predicate_is_rejected() {
7131 expect_bind_error(
7132 "MATCH (n) WHERE (n) RETURN n",
7133 BindErrorKind::InvalidArgument,
7134 );
7135 }
7136
7137 #[test]
7138 fn bare_path_value_where_predicate_is_rejected() {
7139 expect_bind_error(
7140 "MATCH p = (n)-[:REL]->() WHERE p RETURN p",
7141 BindErrorKind::InvalidArgument,
7142 );
7143 }
7144
7145 #[test]
7146 fn with_where_pattern_predicate_lowers_after_with() {
7147 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7148 let ast = parse("MATCH (n) WITH n WHERE (n)-[:REL]->() RETURN n").unwrap();
7149 let plan = binder.bind(&ast).expect("WITH pattern predicate binds");
7150
7151 let with_idx = plan
7152 .ops
7153 .iter()
7154 .position(|op| matches!(op, GraphOp::With { .. }))
7155 .expect("WITH should lower to a With op");
7156 let exists_idx = plan
7157 .ops
7158 .iter()
7159 .position(|op| matches!(op, GraphOp::Exists { .. }))
7160 .expect("WITH WHERE pattern predicate should lower to Exists");
7161 assert!(
7162 with_idx < exists_idx,
7163 "WITH projection must run before its pattern predicate"
7164 );
7165 }
7166
7167 #[test]
7168 fn create_single_node_populates_pattern() {
7169 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7170 let ast = parse("CREATE (:Person {name: 'Alice', age: 30})").unwrap();
7171 let plan = binder.bind(&ast).expect("create bind should succeed");
7172
7173 let create = plan
7174 .ops
7175 .iter()
7176 .find_map(|op| match op {
7177 GraphOp::Create { pattern } => Some(pattern),
7178 _ => None,
7179 })
7180 .expect("expected a Create op");
7181 assert_eq!(create.nodes.len(), 1);
7182 assert!(create.edges.is_empty());
7183 let node = &create.nodes[0];
7184 assert_eq!(node.labels.len(), 1, "Person label should resolve");
7185 let props = node.properties.expect("node should have a property map");
7186 assert!(matches!(plan.exprs.get(props), IrExpr::MapLiteral(_)));
7188 }
7189
7190 #[test]
7191 fn create_edge_threads_src_and_dst_vars() {
7192 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7193 let ast = parse("CREATE (a:Person)-[:KNOWS]->(b:Person)").unwrap();
7194 let plan = binder.bind(&ast).expect("create bind should succeed");
7195
7196 let create = plan
7197 .ops
7198 .iter()
7199 .find_map(|op| match op {
7200 GraphOp::Create { pattern } => Some(pattern),
7201 _ => None,
7202 })
7203 .expect("expected a Create op");
7204 assert_eq!(create.nodes.len(), 2);
7205 assert_eq!(create.edges.len(), 1);
7206 let edge = &create.edges[0];
7207 assert_eq!(edge.src, create.nodes[0].var);
7209 assert_eq!(edge.dst, create.nodes[1].var);
7210 assert_eq!(edge.direction, Direction::Out);
7211 assert!(edge.rel_type.is_some());
7212 }
7213
7214 #[test]
7215 fn standalone_create_node_is_not_a_reference() {
7216 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7218 let ast = parse("CREATE (a:Person)").unwrap();
7219 let plan = binder.bind(&ast).expect("bind");
7220 let create = plan
7221 .ops
7222 .iter()
7223 .find_map(|op| match op {
7224 GraphOp::Create { pattern } => Some(pattern),
7225 _ => None,
7226 })
7227 .expect("Create op");
7228 assert_eq!(create.nodes.len(), 1);
7229 assert!(
7230 !create.nodes[0].is_reference,
7231 "a CREATE-introduced var must be a mint, not a reference"
7232 );
7233 }
7234
7235 fn expect_kind_conflict(query: &str) {
7241 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7242 let ast = parse(query).expect("parse");
7243 let errs = binder
7244 .bind(&ast)
7245 .expect_err(&format!("expected a kind conflict for: {query}"));
7246 assert!(
7247 errs.iter()
7248 .any(|e| e.kind == BindErrorKind::VariableKindConflict),
7249 "expected VariableKindConflict for {query}, got {errs:?}"
7250 );
7251 }
7252
7253 #[test]
7254 fn relationship_var_reused_as_node_conflicts() {
7255 for q in [
7257 "MATCH ()-[r]-() MATCH (r) RETURN r",
7258 "MATCH ()-[r]->() MATCH (r) RETURN r",
7259 "MATCH (), ()-[r]-() MATCH (r) RETURN r",
7260 ] {
7261 expect_kind_conflict(q);
7262 }
7263 }
7264
7265 #[test]
7266 fn path_var_reused_as_node_conflicts() {
7267 expect_kind_conflict("MATCH r = ()-[]-() MATCH (r) RETURN r");
7269 }
7270
7271 #[test]
7272 fn scalar_aliases_reused_as_pattern_entities_conflict() {
7273 expect_kind_conflict("WITH 42 AS n MATCH (n) RETURN n");
7274 expect_kind_conflict("WITH true AS r MATCH ()-[r]->() RETURN r");
7275 expect_kind_conflict(
7276 "MATCH (n) WITH collect(n) AS users MATCH (users)-[:R]->() RETURN users",
7277 );
7278 }
7279
7280 #[test]
7281 fn runtime_polymorphic_pattern_values_remain_bindable() {
7282 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7283 for query in [
7284 "WITH null AS a OPTIONAL MATCH p = (a)-[r]->() RETURN relationships(p)",
7285 "MATCH (a) WITH collect(a) AS nodes UNWIND nodes AS n MATCH (n) RETURN n",
7286 ] {
7287 let ast = parse(query).expect("parse");
7288 binder
7289 .bind(&ast)
7290 .unwrap_or_else(|errors| panic!("expected clean bind for {query}: {errors:?}"));
7291 }
7292 }
7293
7294 #[test]
7295 fn direct_graph_function_kind_mismatches_are_rejected() {
7296 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7297 for query in [
7298 "MATCH (n) RETURN type(n)",
7299 "MATCH ()-[r]->() RETURN labels(r)",
7300 "MATCH (n) RETURN length(n)",
7301 "MATCH ()-[r]->() RETURN length(r)",
7302 "MATCH p = (n) RETURN labels(p)",
7303 ] {
7304 let ast = parse(query).expect("parse");
7305 let errors = binder.bind(&ast).expect_err("expected invalid argument");
7306 assert!(
7307 errors
7308 .iter()
7309 .any(|error| error.kind == BindErrorKind::InvalidArgument),
7310 "expected InvalidArgument for {query}, got {errors:?}"
7311 );
7312 }
7313 }
7314
7315 #[test]
7316 fn compatible_variable_reuse_is_accepted() {
7317 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7320 for q in [
7321 "MATCH (a) MATCH (a) RETURN a", "MATCH (a), (b) RETURN a, b", "MATCH (a)-[r]->(b), (b)-[s]->(c) RETURN a", "MATCH (a) WITH a MATCH (b) RETURN a, b", "MATCH (a) CREATE (a)-[:R]->(b)", ] {
7327 let ast = parse(q).expect("parse");
7328 binder
7329 .bind(&ast)
7330 .unwrap_or_else(|e| panic!("expected clean bind for {q}, got {e:?}"));
7331 }
7332 }
7333
7334 #[test]
7335 fn relationship_var_reused_with_different_type_adds_false_filter() {
7336 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7341 let ast = parse("MATCH ()-[r:T]->() WITH r MATCH ()-[r:Y]->() RETURN r").expect("parse");
7342 let plan = binder.bind(&ast).expect("bind");
7343
7344 let has_false_filter = plan.ops.iter().any(|op| match op {
7345 GraphOp::Filter { predicate } => {
7346 matches!(
7347 plan.exprs.get(*predicate),
7348 IrExpr::Literal(IrLiteral::Bool(false))
7349 )
7350 }
7351 _ => false,
7352 });
7353
7354 assert!(
7355 has_false_filter,
7356 "reusing a known relationship variable with a different type should filter to no rows"
7357 );
7358 }
7359
7360 fn expect_bind_error(query: &str, kind: BindErrorKind) {
7366 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7367 let ast = parse(query).expect("parse");
7368 let errs = binder
7369 .bind(&ast)
7370 .expect_err(&format!("expected a bind error for: {query}"));
7371 assert!(
7372 errs.iter().any(|e| e.kind == kind),
7373 "expected {kind:?} for {query}, got {errs:?}"
7374 );
7375 }
7376
7377 #[test]
7378 fn create_pattern_validation() {
7379 for q in [
7382 "CREATE ()-->()", "CREATE ()-[:FOO*2]->()", "CREATE (a)-[:FOO]-(b)", ] {
7386 expect_bind_error(q, BindErrorKind::InvalidArgument);
7387 }
7388 }
7389
7390 #[test]
7391 fn rebinding_a_bound_variable_in_create_is_rejected() {
7392 for q in [
7393 "MATCH (a) CREATE (a)", "MATCH (a) CREATE (a {name: 'x'})", "CREATE (n:Foo) CREATE (n:Bar)-[:OWNS]->(:Dog)", "MATCH ()-[r]->() CREATE ()-[r]->()", ] {
7398 expect_bind_error(q, BindErrorKind::VariableAlreadyBound);
7399 }
7400 }
7401
7402 #[test]
7403 fn create_reference_without_new_shape_is_accepted() {
7404 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7407 let ast = parse("MATCH (a) CREATE (a)-[:R]->(b)").expect("parse");
7408 binder.bind(&ast).expect("valid reference must bind");
7409 }
7410
7411 #[test]
7412 fn aggregate_in_where_and_duplicate_alias_are_rejected() {
7413 expect_bind_error(
7414 "MATCH (a) WHERE count(a) > 10 RETURN a",
7415 BindErrorKind::InvalidArgument,
7416 );
7417 expect_bind_error("RETURN 1 AS a, 2 AS a", BindErrorKind::InvalidArgument);
7418 expect_bind_error(
7419 "WITH 1 AS a, 2 AS a RETURN a",
7420 BindErrorKind::InvalidArgument,
7421 );
7422 }
7423
7424 #[test]
7425 fn skip_limit_reject_negative_and_non_parameter_arguments() {
7426 for q in [
7427 "RETURN 1 SKIP -1",
7428 "RETURN 1 LIMIT -1",
7429 "RETURN 1 SKIP (-1)",
7430 "WITH 1 AS x SKIP -1 RETURN x",
7431 "WITH 1 AS x, count(*) AS c SKIP -1 RETURN x, c",
7432 "MATCH (n) RETURN n SKIP n.count",
7433 "MATCH (n) RETURN n LIMIT rand()",
7434 "MATCH (n) WITH n SKIP n.count RETURN n",
7435 "MATCH (n) WITH n, count(*) AS c LIMIT rand() RETURN n, c",
7436 ] {
7437 expect_bind_error(q, BindErrorKind::InvalidArgument);
7438 }
7439 }
7440
7441 #[test]
7442 fn skip_limit_accept_non_negative_integer_constants_and_parameters() {
7443 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7444 for q in [
7445 "RETURN 1 SKIP 0 LIMIT 1",
7446 "WITH 1 AS x SKIP (1) LIMIT 2 RETURN x",
7447 "WITH 1 AS x, count(*) AS c SKIP 0 LIMIT 1 RETURN x, c",
7448 "RETURN 1 SKIP $s LIMIT $l",
7449 "WITH 1 AS x SKIP ($s) LIMIT ($l) RETURN x",
7450 ] {
7451 let ast = parse(q).expect("parse");
7452 binder
7453 .bind(&ast)
7454 .unwrap_or_else(|e| panic!("expected clean bind for {q}, got {e:?}"));
7455 }
7456 }
7457
7458 #[test]
7459 fn overflowing_float_literal_is_rejected_at_bind() {
7460 expect_bind_error("RETURN 1.34E999", BindErrorKind::InvalidArgument);
7461 }
7462
7463 #[test]
7464 fn matched_var_in_create_is_a_reference_not_a_duplicate_mint() {
7465 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7469 let ast = parse("MATCH (a:Person) CREATE (a)-[:KNOWS]->(b:Person)").expect("parse");
7470 let plan = binder.bind(&ast).expect("bind");
7471 let create = plan
7472 .ops
7473 .iter()
7474 .find_map(|op| match op {
7475 GraphOp::Create { pattern } => Some(pattern),
7476 _ => None,
7477 })
7478 .expect("Create op");
7479
7480 let a_var = create.edges[0].src;
7482 let b_var = create.edges[0].dst;
7483 let a_specs: Vec<_> = create.nodes.iter().filter(|n| n.var == a_var).collect();
7484 assert_eq!(a_specs.len(), 1, "exactly one spec for the matched var `a`");
7485 assert!(a_specs[0].is_reference, "matched `a` must be a reference");
7486 let b_spec = create
7487 .nodes
7488 .iter()
7489 .find(|n| n.var == b_var)
7490 .expect("spec for new `b`");
7491 assert!(!b_spec.is_reference, "CREATE-introduced `b` must be a mint");
7492 }
7493
7494 #[test]
7495 fn delete_clause_lowers_to_delete_op() {
7496 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7498 let ast = parse("MATCH (p:Person) DELETE p").unwrap();
7499 let plan = binder.bind(&ast).expect("DELETE binds");
7500 let delete = plan
7501 .ops
7502 .iter()
7503 .find_map(|op| match op {
7504 GraphOp::Delete { vars, detach, .. } => Some((vars.clone(), *detach)),
7505 _ => None,
7506 })
7507 .expect("a GraphOp::Delete op");
7508 assert_eq!(delete.0.len(), 1, "one target var");
7509 assert!(!delete.1, "plain DELETE is not DETACH");
7510 }
7511
7512 #[test]
7513 fn detach_delete_sets_detach_flag() {
7514 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7515 let ast = parse("MATCH (p:Person) DETACH DELETE p").unwrap();
7516 let plan = binder.bind(&ast).expect("DETACH DELETE binds");
7517 assert!(
7518 plan.ops
7519 .iter()
7520 .any(|op| matches!(op, GraphOp::Delete { detach: true, .. })),
7521 "DETACH DELETE must set detach=true, got {:?}",
7522 plan.ops
7523 );
7524 }
7525
7526 #[test]
7527 fn delete_property_target_lowers_to_runtime_expression() {
7528 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7529 let ast = parse("MATCH (p:Person) DELETE p.name").unwrap();
7530 let plan = binder
7531 .bind(&ast)
7532 .expect("property value binds for runtime typing");
7533 assert!(matches!(
7534 plan.ops.last(),
7535 Some(GraphOp::Delete { vars, exprs, .. }) if vars.is_empty() && exprs.len() == 1
7536 ));
7537 }
7538
7539 #[test]
7540 fn delete_scalar_expression_is_rejected() {
7541 expect_bind_error("MATCH () DELETE 1 + 1", BindErrorKind::InvalidDeleteTarget);
7542 }
7543
7544 #[test]
7545 fn delete_undeclared_variable_is_rejected() {
7546 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7547 let ast = parse("MATCH (p:Person) DELETE q").unwrap();
7548 let errors = binder
7549 .bind(&ast)
7550 .expect_err("DELETE of an unbound var must be rejected");
7551 assert!(
7552 errors
7553 .iter()
7554 .any(|e| e.kind == BindErrorKind::UndeclaredVariable),
7555 "expected UndeclaredVariable, got {errors:?}"
7556 );
7557 }
7558
7559 #[test]
7560 fn set_property_clause_lowers_to_set_op() {
7561 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7562 let ast = parse("MATCH (p:Person) SET p.age = 30").unwrap();
7563 let plan = binder.bind(&ast).expect("SET p.age = 30 must lower");
7564 let set = plan
7565 .ops
7566 .iter()
7567 .find_map(|op| match op {
7568 GraphOp::Set { items, .. } => Some(items),
7569 _ => None,
7570 })
7571 .expect("expected a GraphOp::Set");
7572 assert_eq!(set.len(), 1);
7573 assert_eq!(set[0].prop_name, "age");
7574 }
7575
7576 #[test]
7577 fn set_runtime_expr_value_lowers_to_non_literal() {
7578 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7581 let ast = parse("MATCH (p:Person) SET p.age = p.age + 1").unwrap();
7582 let plan = binder.bind(&ast).expect("SET p.age = p.age + 1 must lower");
7583 let items = plan
7584 .ops
7585 .iter()
7586 .find_map(|op| match op {
7587 GraphOp::Set { items, .. } => Some(items),
7588 _ => None,
7589 })
7590 .expect("expected a GraphOp::Set");
7591 let value = plan.exprs.get(items[0].value);
7592 assert!(
7593 matches!(value, IrExpr::BinaryOp { .. }),
7594 "expected a BinaryOp value expr, got {value:?}"
7595 );
7596 }
7597
7598 #[test]
7599 fn set_multiple_items_lower_to_one_op() {
7600 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7601 let ast = parse("MATCH (p:Person) SET p.age = 30, p.name = 'Al'").unwrap();
7602 let plan = binder.bind(&ast).expect("multi-item SET must lower");
7603 let count = plan
7604 .ops
7605 .iter()
7606 .filter(|op| matches!(op, GraphOp::Set { .. }))
7607 .count();
7608 assert_eq!(count, 1, "expected exactly one GraphOp::Set");
7609 }
7610
7611 #[test]
7612 fn set_labels_lower_to_resolved_label_item() {
7613 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7614 let ast = parse("MATCH (p:Person) SET p:Admin:Staff").unwrap();
7615 let plan = binder.bind(&ast).expect("SET labels must lower");
7616 let labels = plan.ops.iter().find_map(|op| match op {
7617 GraphOp::Set { label_items, .. } => Some(label_items),
7618 _ => None,
7619 });
7620 let labels = labels.expect("SET label items");
7621 assert_eq!(labels.len(), 1);
7622 assert_eq!(labels[0].labels.len(), 2);
7623 }
7624
7625 #[test]
7626 fn set_property_merge_lowers_to_map_item() {
7627 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7628 let ast = parse("MATCH (p:Person) SET p += {age: 30}").unwrap();
7629 let plan = binder.bind(&ast).expect("SET += must lower");
7630 let map_items = plan
7631 .ops
7632 .iter()
7633 .find_map(|op| match op {
7634 GraphOp::Set { map_items, .. } => Some(map_items),
7635 _ => None,
7636 })
7637 .expect("expected SET op");
7638 assert_eq!(map_items.len(), 1);
7639 assert!(!map_items[0].replace);
7640 }
7641
7642 #[test]
7643 fn merge_lowers_real_pattern_specs() {
7644 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7645 let ast = parse(
7646 "MERGE (p:Person {name:'Alice'}) \
7647 ON CREATE SET p.created = 1, p:New \
7648 ON MATCH SET p += {seen:true}",
7649 )
7650 .unwrap();
7651 let plan = binder.bind(&ast).expect("MERGE binds");
7652 let (pattern, on_create, on_match) = plan
7653 .ops
7654 .iter()
7655 .find_map(|op| match op {
7656 GraphOp::Merge {
7657 pattern,
7658 on_create,
7659 on_match,
7660 } => Some((pattern, on_create, on_match)),
7661 _ => None,
7662 })
7663 .expect("MERGE op");
7664 assert_eq!(pattern.nodes.len(), 1);
7665 assert!(pattern.nodes[0].properties.is_some());
7666 assert_eq!(on_create.len(), 2);
7667 assert_eq!(on_match.len(), 1);
7668 }
7669
7670 #[test]
7671 fn set_undeclared_variable_rejected() {
7672 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7673 let ast = parse("MATCH (p:Person) SET q.age = 30").unwrap();
7674 let errors = binder.bind(&ast).expect_err("SET on unbound var must fail");
7675 assert!(
7676 errors
7677 .iter()
7678 .any(|e| e.kind == BindErrorKind::UndeclaredVariable),
7679 "expected UndeclaredVariable, got {errors:?}"
7680 );
7681 }
7682
7683 #[test]
7684 fn remove_property_clause_lowers_to_remove_op() {
7685 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7686 let ast = parse("MATCH (p:Person) REMOVE p.age").unwrap();
7687 let plan = binder.bind(&ast).expect("REMOVE p.age must lower");
7688 let items = plan
7689 .ops
7690 .iter()
7691 .find_map(|op| match op {
7692 GraphOp::Remove { items, .. } => Some(items),
7693 _ => None,
7694 })
7695 .expect("expected a GraphOp::Remove");
7696 assert_eq!(items.len(), 1);
7697 assert_eq!(items[0].prop_name, "age");
7698 }
7699
7700 #[test]
7701 fn remove_labels_lower_to_resolved_label_item() {
7702 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7703 let ast = parse("MATCH (p:Person) REMOVE p:Admin:Staff").unwrap();
7704 let plan = binder.bind(&ast).expect("REMOVE labels must lower");
7705 let labels = plan.ops.iter().find_map(|op| match op {
7706 GraphOp::Remove { label_items, .. } => Some(label_items),
7707 _ => None,
7708 });
7709 let labels = labels.expect("REMOVE label items");
7710 assert_eq!(labels.len(), 1);
7711 assert_eq!(labels[0].labels.len(), 2);
7712 }
7713
7714 #[test]
7715 fn advisory_unknown_label_produces_warnings() {
7716 let catalog = Arc::new(Mutex::new(RuntimeCatalog::new()));
7717 let binder = Binder::new(None, Arc::clone(&catalog), OntologyMode::Advisory);
7718 let ast = parse("MATCH (a:UnknownLabel) RETURN a").unwrap();
7719
7720 let mut state = BinderState {
7721 vars: HashMap::new(),
7722 path_vars: HashMap::new(),
7723 node_vars: HashMap::new(),
7724 edge_vars: HashMap::new(),
7725 edge_rel_names: HashMap::new(),
7726 scalar_list_edges: HashSet::new(),
7727 var_kinds: HashMap::new(),
7728 next_var: 0,
7729 builder: GraphPlan::builder("openCypher").ontology_mode(OntologyMode::Advisory),
7730 errors: Vec::new(),
7731 warnings: Vec::new(),
7732 captured_pattern_comprehensions: None,
7733 existential_depth: 0,
7734 standalone_call: false,
7735 };
7736 for clause in &ast.clauses {
7737 binder.lower_clause(clause, &mut state);
7738 }
7739
7740 assert!(
7741 !state.warnings.is_empty(),
7742 "advisory mode should produce warnings"
7743 );
7744 assert!(
7745 state.errors.is_empty(),
7746 "advisory mode should not produce errors"
7747 );
7748 assert!(
7749 state
7750 .warnings
7751 .iter()
7752 .any(|w| w.kind == BindErrorKind::UnknownLabel)
7753 );
7754 }
7755
7756 #[test]
7757 fn strict_unknown_label_produces_error() {
7758 let (binder, _) = make_binder(OntologyMode::Strict);
7759 let ast = parse("MATCH (a:UnknownLabel) RETURN a").unwrap();
7760 let result = binder.bind(&ast);
7761 assert!(result.is_err());
7762 let errors = result.unwrap_err();
7763 assert!(errors.iter().any(|e| e.kind == BindErrorKind::UnknownLabel));
7764 }
7765
7766 #[test]
7767 fn strict_mode_allows_durable_identity_fields() {
7768 let (binder, _) = make_binder(OntologyMode::Strict);
7769 let ast = parse(
7770 "MATCH (source)-[relationship]->(target) RETURN source.node_uuid, relationship.edge_uuid, target.node_uuid",
7771 )
7772 .unwrap();
7773
7774 binder
7775 .bind(&ast)
7776 .expect("structural identity fields are not ontology properties");
7777 }
7778
7779 #[test]
7780 fn strict_properties_emit_owner_scoped_runtime_ids() {
7781 let (binder, catalog) = strict_property_binder(false);
7782 let ast = parse("MATCH (host:Host)-[connection:R]->() RETURN host.direct, host.inherited, host.shared, connection.weight, connection.shared").unwrap();
7783 let plan = binder.bind(&ast).unwrap();
7784 let ids = plan_property_ids(&plan);
7785 let catalog = catalog.lock().unwrap();
7786 let names = ids
7787 .iter()
7788 .map(|id| catalog.property_name(crate::RuntimePropId(id.0)).unwrap())
7789 .collect::<Vec<_>>();
7790 assert_eq!(names, ["direct", "inherited", "shared", "weight", "shared"]);
7791 assert!(ids.iter().all(|id| id.0 > 0));
7792 assert_ne!(ids[2], ids[4]);
7793 }
7794
7795 #[test]
7796 fn strict_property_writes_and_inline_filters_share_owner_rules() {
7797 let (binder, catalog) = strict_property_binder(false);
7798 let ast = parse("MATCH (host:Host)-[connection:R]->() SET host.direct = 'ready', host.inherited = 'asset', connection.weight = 7 REMOVE host.direct, connection.weight").unwrap();
7799 let plan = binder.bind(&ast).unwrap();
7800 let catalog = catalog.lock().unwrap();
7801 let write_ids = plan
7802 .ops
7803 .iter()
7804 .flat_map(|op| match op {
7805 GraphOp::Set { items, .. } => items.iter().map(|item| item.prop).collect(),
7806 GraphOp::Remove { items, .. } => items.iter().map(|item| item.prop).collect(),
7807 _ => Vec::new(),
7808 })
7809 .collect::<Vec<_>>();
7810 assert_eq!(write_ids.len(), 5);
7811 assert!(
7812 write_ids
7813 .iter()
7814 .all(|id| catalog.property_name(crate::RuntimePropId(id.0)).is_some())
7815 );
7816 drop(catalog);
7817
7818 for query in [
7819 "MATCH (:Host {direct: 'ready'})-[:R {weight: 7}]->() RETURN 1",
7820 "MATCH (:Host {inherited: 'asset'})-[:R*1..2 {weight: 7}]->() RETURN 1",
7821 ] {
7822 binder
7823 .bind(&parse(query).unwrap())
7824 .expect("anonymous fixed and variable-length owners should bind");
7825 }
7826 }
7827
7828 #[test]
7829 fn strict_properties_reject_wrong_owner_and_ambiguity_with_exact_spans() {
7830 let (binder, _) = strict_property_binder(false);
7831 for (query, span) in [
7832 ("MATCH (host:Host) RETURN host.weight", "host.weight"),
7833 ("MATCH ()-[r:R]->() RETURN r.direct", "r.direct"),
7834 ("MATCH (host:Host) RETURN host.missing", "host.missing"),
7835 (
7836 "MATCH (host:Host) WITH host AS forwarded RETURN forwarded.weight",
7837 "forwarded.weight",
7838 ),
7839 (
7840 "MATCH ()-[r:R]->() WITH r AS forwarded RETURN forwarded.direct",
7841 "forwarded.direct",
7842 ),
7843 ] {
7844 property_error(&binder, query, BindErrorKind::UnknownProperty, span);
7845 }
7846 for (query, span) in [
7847 ("MATCH (:Host {weight: 7}) RETURN 1", "weight"),
7848 ("MATCH ()-[:R {missing: 7}]->() RETURN 1", "missing"),
7849 ] {
7850 property_error(&binder, query, BindErrorKind::UnknownProperty, span);
7851 }
7852 property_error(
7853 &binder,
7854 "MATCH ()-[*1..2 {weight: 7}]->() RETURN 1",
7855 BindErrorKind::AmbiguousProperty,
7856 "weight",
7857 );
7858
7859 let (binder, _) = strict_property_binder(true);
7860 let query = "MATCH (host:Host) RETURN host.inherited";
7861 let error = property_error(
7862 &binder,
7863 query,
7864 BindErrorKind::AmbiguousProperty,
7865 "host.inherited",
7866 );
7867 assert!(error.message.contains("Asset, Host"));
7868 property_error(
7869 &binder,
7870 "MATCH (:Host {inherited: 7}) RETURN 1",
7871 BindErrorKind::AmbiguousProperty,
7872 "inherited",
7873 );
7874 }
7875
7876 #[test]
7877 fn strict_mode_rejects_mismatched_durable_identity_fields() {
7878 for query in [
7879 "MATCH (node) RETURN node.edge_uuid",
7880 "MATCH ()-[relationship]->() RETURN relationship.node_uuid",
7881 ] {
7882 let (binder, _) = make_binder(OntologyMode::Strict);
7883 let errors = binder
7884 .bind(&parse(query).unwrap())
7885 .expect_err("identity fields must match the bound entity kind");
7886 assert!(errors.iter().any(|error| {
7887 error.kind == BindErrorKind::InvalidArgument
7888 && error.message.contains("valid only on")
7889 }));
7890 }
7891 }
7892
7893 #[test]
7894 fn structural_identity_fields_are_read_only() {
7895 for query in [
7896 "MATCH (node) SET node.node_uuid = 'replacement'",
7897 "MATCH (node) REMOVE node.node_uuid",
7898 "MATCH ()-[relationship]->() SET relationship.edge_uuid = 'replacement'",
7899 "MATCH ()-[relationship]->() REMOVE relationship.edge_uuid",
7900 ] {
7901 let (binder, _) = make_binder(OntologyMode::Strict);
7902 let errors = binder
7903 .bind(&parse(query).unwrap())
7904 .expect_err("identity fields must not be mutable");
7905 assert!(errors.iter().any(|error| {
7906 error.kind == BindErrorKind::InvalidArgument
7907 && error.message.contains("is read-only")
7908 }));
7909 }
7910 }
7911
7912 #[test]
7913 fn fixed_hop_with_known_relation_emits_expand() {
7914 use graphforge_ontology::{
7915 EntityTypeDef, OntologyCompiler, OntologyDoc, OntologyHandle, RelationTypeDef,
7916 SemanticFlags,
7917 };
7918
7919 let doc = OntologyDoc {
7920 ontology_id: "test".into(),
7921 version: "1.0".into(),
7922 entity_types: vec![
7923 EntityTypeDef {
7924 name: "Person".into(),
7925 r#abstract: false,
7926 parent: None,
7927 },
7928 EntityTypeDef {
7929 name: "Organization".into(),
7930 r#abstract: false,
7931 parent: None,
7932 },
7933 ],
7934 relation_types: vec![RelationTypeDef {
7935 name: "WORKS_AT".into(),
7936 src: "Person".into(),
7937 dst: "Organization".into(),
7938 inverse: None,
7939 semantic: SemanticFlags::default(),
7940 }],
7941 properties: vec![],
7942 constraints: vec![],
7943 migrations: vec![],
7944 };
7945 let runtime = OntologyCompiler::compile(&doc).unwrap();
7946 let handle = OntologyHandle::new(runtime);
7947 let catalog = Arc::new(Mutex::new(RuntimeCatalog::new()));
7948 let binder = Binder::new(Some(handle), catalog, OntologyMode::Strict);
7949
7950 let ast = parse("MATCH (a:Person)-[:WORKS_AT]->(b:Organization) RETURN a").unwrap();
7951 let plan = binder.bind(&ast).expect("known ontology should succeed");
7952
7953 let expand = plan.ops.iter().find_map(|op| match op {
7956 GraphOp::Expand {
7957 rel_ty,
7958 min_hops,
7959 max_hops,
7960 ..
7961 } => Some((rel_ty, *min_hops, *max_hops)),
7962 _ => None,
7963 });
7964 let (rel_ty, min_hops, max_hops) = expand.expect("fixed hop should emit Expand");
7965 assert!(
7966 rel_ty.is_some(),
7967 "WORKS_AT should resolve to a relation type"
7968 );
7969 assert_eq!((min_hops, max_hops), (1, Some(1)), "fixed hop is 1..1");
7970 assert!(
7971 !plan
7972 .ops
7973 .iter()
7974 .any(|op| matches!(op, GraphOp::TypedEdgeScan { .. } | GraphOp::EdgeScan { .. }))
7975 );
7976 }
7977
7978 #[test]
7979 fn wildcard_fixed_hop_emits_untyped_expand() {
7980 let (binder, _) = make_binder(OntologyMode::Exploratory);
7981 let ast = parse("MATCH (a)-[r]->(b) RETURN r").unwrap();
7982 let plan = binder.bind(&ast).expect("wildcard bind should succeed");
7983
7984 let expand = plan.ops.iter().find_map(|op| match op {
7987 GraphOp::Expand {
7988 rel_ty,
7989 min_hops,
7990 max_hops,
7991 ..
7992 } => Some((rel_ty, *min_hops, *max_hops)),
7993 _ => None,
7994 });
7995 let (rel_ty, min_hops, max_hops) = expand.expect("wildcard hop should emit Expand");
7996 assert!(rel_ty.is_none(), "wildcard hop has no relation type");
7997 assert_eq!((min_hops, max_hops), (1, Some(1)));
7998 assert!(
7999 !plan
8000 .ops
8001 .iter()
8002 .any(|op| matches!(op, GraphOp::TypedEdgeScan { .. } | GraphOp::EdgeScan { .. }))
8003 );
8004 }
8005
8006 #[test]
8007 fn undeclared_variable_in_return_is_error() {
8008 let (binder, _) = make_binder(OntologyMode::Exploratory);
8009 let ast = parse("MATCH (a:Person) RETURN x").unwrap();
8010 let result = binder.bind(&ast);
8011 assert!(result.is_err());
8012 let errors = result.unwrap_err();
8013 assert!(
8014 errors
8015 .iter()
8016 .any(|e| e.kind == BindErrorKind::UndeclaredVariable)
8017 );
8018 }
8019
8020 #[test]
8021 fn multi_error_collection_in_strict_mode() {
8022 let (binder, _) = make_binder(OntologyMode::Strict);
8023 let ast = parse("MATCH (a:LabelA)-[:REL_B]->(b:LabelC) RETURN a").unwrap();
8024 let result = binder.bind(&ast);
8025 assert!(result.is_err());
8026 let errors = result.unwrap_err();
8027 assert!(
8029 errors.len() >= 3,
8030 "expected ≥3 errors, got {}",
8031 errors.len()
8032 );
8033 }
8034
8035 #[test]
8036 fn where_clause_emits_filter_op() {
8037 let (binder, _) = make_binder(OntologyMode::Exploratory);
8038 let ast = parse("MATCH (a:Person) WHERE a.age > 30 RETURN a").unwrap();
8039 let plan = binder.bind(&ast).unwrap();
8040 assert!(
8041 plan.ops
8042 .iter()
8043 .any(|op| matches!(op, GraphOp::Filter { .. }))
8044 );
8045 }
8046
8047 #[test]
8048 fn inline_node_property_emits_filter() {
8049 let (binder, _) = make_binder(OntologyMode::Exploratory);
8051 let ast = parse("MATCH (a:Person {name:'Alice'}) RETURN a.name").unwrap();
8052 let plan = binder.bind(&ast).unwrap();
8053 assert!(
8054 plan.ops
8055 .iter()
8056 .any(|op| matches!(op, GraphOp::NodeScan { .. })),
8057 "scan present"
8058 );
8059 assert!(
8060 plan.ops
8061 .iter()
8062 .any(|op| matches!(op, GraphOp::Filter { .. })),
8063 "inline property must emit a Filter"
8064 );
8065 }
8066
8067 #[test]
8068 fn inline_multi_property_emits_single_filter() {
8069 let (binder, _) = make_binder(OntologyMode::Exploratory);
8071 let ast = parse("MATCH (a:Person {name:'Alice', age:30}) RETURN a.name").unwrap();
8072 let plan = binder.bind(&ast).unwrap();
8073 let filters = plan
8074 .ops
8075 .iter()
8076 .filter(|op| matches!(op, GraphOp::Filter { .. }))
8077 .count();
8078 assert_eq!(filters, 1, "multi-property map → one AND-ed Filter");
8079 }
8080
8081 #[test]
8082 fn node_without_inline_properties_emits_no_filter() {
8083 let (binder, _) = make_binder(OntologyMode::Exploratory);
8085 let ast = parse("MATCH (a:Person) RETURN a.name").unwrap();
8086 let plan = binder.bind(&ast).unwrap();
8087 assert!(
8088 !plan
8089 .ops
8090 .iter()
8091 .any(|op| matches!(op, GraphOp::Filter { .. })),
8092 "no inline properties → no Filter"
8093 );
8094 }
8095
8096 #[test]
8097 fn inline_rel_property_emits_filter() {
8098 let (binder, _) = make_binder(OntologyMode::Exploratory);
8101 let ast =
8102 parse("MATCH (a:Person)-[r:KNOWS {since:2020}]->(b:Person) RETURN b.name").unwrap();
8103 let plan = binder.bind(&ast).unwrap();
8104 assert!(
8105 plan.ops
8106 .iter()
8107 .any(|op| matches!(op, GraphOp::Expand { .. })),
8108 "expand present"
8109 );
8110 assert!(
8111 plan.ops
8112 .iter()
8113 .any(|op| matches!(op, GraphOp::Filter { .. })),
8114 "inline relationship property must emit a Filter"
8115 );
8116 }
8117
8118 #[test]
8119 fn inline_multi_rel_property_emits_single_filter() {
8120 let (binder, _) = make_binder(OntologyMode::Exploratory);
8122 let ast =
8123 parse("MATCH (a:Person)-[r:KNOWS {since:2020, weight:5}]->(b:Person) RETURN b.name")
8124 .unwrap();
8125 let plan = binder.bind(&ast).unwrap();
8126 let filters = plan
8127 .ops
8128 .iter()
8129 .filter(|op| matches!(op, GraphOp::Filter { .. }))
8130 .count();
8131 assert_eq!(filters, 1, "multi-property rel map → one AND-ed Filter");
8132 }
8133
8134 #[test]
8135 fn rel_without_inline_properties_emits_no_filter() {
8136 let (binder, _) = make_binder(OntologyMode::Exploratory);
8138 let ast = parse("MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN b.name").unwrap();
8139 let plan = binder.bind(&ast).unwrap();
8140 assert!(
8141 !plan
8142 .ops
8143 .iter()
8144 .any(|op| matches!(op, GraphOp::Filter { .. })),
8145 "no inline rel properties → no Filter"
8146 );
8147 }
8148
8149 #[test]
8150 fn inline_property_on_var_length_rel_emits_all_filter() {
8151 let (binder, _) = make_binder(OntologyMode::Exploratory);
8152 let ast = parse("MATCH (a:Person)-[r:KNOWS*1..2 {since:2020}]->(b:Person) RETURN b.name")
8153 .unwrap();
8154 let plan = binder.bind(&ast).unwrap();
8155 let predicate = plan.ops.iter().find_map(|op| match op {
8156 GraphOp::Filter { predicate } => Some(*predicate),
8157 _ => None,
8158 });
8159 assert!(
8160 predicate.is_some_and(|predicate| matches!(
8161 plan.exprs.get(predicate),
8162 IrExpr::Quantifier {
8163 kind: graphforge_ast::QuantifierKind::All,
8164 ..
8165 }
8166 )),
8167 "variable-length relationship properties require an all() filter"
8168 );
8169 }
8170
8171 #[test]
8172 fn return_clause_emits_project_op() {
8173 let (binder, _) = make_binder(OntologyMode::Exploratory);
8174 let ast = parse("MATCH (a:Person) RETURN a.name").unwrap();
8175 let plan = binder.bind(&ast).unwrap();
8176 assert!(
8177 plan.ops
8178 .iter()
8179 .any(|op| matches!(op, GraphOp::Project { .. }))
8180 );
8181 }
8182
8183 #[test]
8184 fn return_with_count_emits_aggregate() {
8185 let (binder, _) = make_binder(OntologyMode::Exploratory);
8188 let ast = parse("MATCH (n:Person) RETURN count(n) AS total").unwrap();
8189 let plan = binder.bind(&ast).unwrap();
8190 let agg = plan.ops.iter().find_map(|op| match op {
8191 GraphOp::Aggregate { group_by, aggs, .. } => Some((group_by, aggs)),
8192 _ => None,
8193 });
8194 let (group_by, aggs) = agg.expect("count should emit an Aggregate");
8195 assert!(
8196 group_by.is_empty(),
8197 "no non-aggregate items → no group keys"
8198 );
8199 assert_eq!(aggs.len(), 1);
8200 assert_eq!(aggs[0].func, AggFunc::Count);
8201 assert_eq!(aggs[0].alias, "total");
8202 assert!(
8203 !plan
8204 .ops
8205 .iter()
8206 .any(|op| matches!(op, GraphOp::Project { .. })),
8207 "an aggregate RETURN must not also emit a Project"
8208 );
8209 }
8210
8211 #[test]
8212 fn return_with_grouping_keys_and_aggregate() {
8213 let (binder, _) = make_binder(OntologyMode::Exploratory);
8215 let ast = parse("MATCH (n:Person) RETURN n.name AS name, count(n) AS total").unwrap();
8216 let plan = binder.bind(&ast).unwrap();
8217 let (group_by, aggs) = plan
8218 .ops
8219 .iter()
8220 .find_map(|op| match op {
8221 GraphOp::Aggregate { group_by, aggs, .. } => Some((group_by, aggs)),
8222 _ => None,
8223 })
8224 .expect("Aggregate");
8225 assert_eq!(group_by.len(), 1, "n.name is the group key");
8226 assert_eq!(aggs.len(), 1);
8227 }
8228
8229 fn bind_errors(query: &str) -> Vec<BindError> {
8235 let (binder, _) = make_binder(OntologyMode::Exploratory);
8236 let ast = parse(query).unwrap();
8237 binder
8238 .bind(&ast)
8239 .expect_err("bind should fail for this query")
8240 }
8241
8242 #[test]
8243 fn path_var_functions_bind_with_anonymous_edge() {
8244 let (binder, _) = make_binder(OntologyMode::Exploratory);
8247 let ast = parse(
8248 "MATCH p = (a:Person)-[*1..2]->(b) \
8249 RETURN nodes(p) AS ns, relationships(p) AS rs, length(p) AS l",
8250 )
8251 .unwrap();
8252 binder.bind(&ast).expect("path functions should bind");
8253 }
8254
8255 #[test]
8256 fn path_function_on_non_path_falls_through() {
8257 let (binder, _) = make_binder(OntologyMode::Exploratory);
8261 let ast =
8262 parse("MATCH (a)-[r:KNOWS*1..2]->(b) RETURN length(r) AS l, nodes(a) AS ns").unwrap();
8263 let plan = binder.bind(&ast).expect("bind succeeds");
8264 let names: Vec<&str> = (0..plan.exprs.len())
8265 .filter_map(
8266 |i| match plan.exprs.get(ExprId(u32::try_from(i).unwrap())) {
8267 IrExpr::FunctionCall { name, .. } => Some(name.as_str()),
8268 _ => None,
8269 },
8270 )
8271 .collect();
8272 assert!(names.contains(&"length"), "generic length kept: {names:?}");
8273 assert!(names.contains(&"nodes"), "generic nodes kept: {names:?}");
8274 assert!(
8275 !names.contains(&"_path_nodes"),
8276 "no path rewrite without a path var: {names:?}"
8277 );
8278 }
8279
8280 #[test]
8281 fn bare_path_var_binds_to_path_struct() {
8282 let (binder, _) = make_binder(OntologyMode::Exploratory);
8284 let ast = parse("MATCH p = (a)-[:KNOWS*1..2]->(b) RETURN p").unwrap();
8285 let plan = binder.bind(&ast).expect("bare path value binds");
8286 let has_struct = (0..plan.exprs.len()).any(|i| {
8287 matches!(
8288 plan.exprs.get(ExprId(u32::try_from(i).unwrap())),
8289 IrExpr::FunctionCall { name, .. } if name == "_path_struct"
8290 )
8291 });
8292 assert!(has_struct, "RETURN p must rewrite to _path_struct");
8293 }
8294
8295 #[test]
8296 fn multi_segment_path_var_composes_path_functions() {
8297 let (binder, _) = make_binder(OntologyMode::Exploratory);
8298 let ast = parse(
8299 "MATCH p = (a)-[:KNOWS]->(b)-[:KNOWS]->(c) \
8300 RETURN length(p), nodes(p), relationships(p)",
8301 )
8302 .unwrap();
8303 let plan = binder.bind(&ast).expect("multi-segment path binds");
8304 let add_count = (0..plan.exprs.len())
8305 .map(|index| plan.exprs.get(ExprId(index as u32)))
8306 .filter(|expr| {
8307 matches!(
8308 expr,
8309 IrExpr::BinaryOp {
8310 op: BinaryOpKind::Add,
8311 ..
8312 }
8313 )
8314 })
8315 .count();
8316 assert!(add_count >= 3, "each path function composes its segments");
8317 }
8318
8319 #[test]
8320 fn fixed_segment_path_functions_bind() {
8321 let (binder, _) = make_binder(OntologyMode::Exploratory);
8325 let ast = parse(
8326 "MATCH p = (a)-[:KNOWS]->(b) \
8327 RETURN length(p) AS l, nodes(p) AS ns, relationships(p) AS rs",
8328 )
8329 .unwrap();
8330 let plan = binder.bind(&ast).expect("fixed path functions bind");
8331 let names: Vec<&str> = (0..plan.exprs.len())
8332 .filter_map(
8333 |i| match plan.exprs.get(ExprId(u32::try_from(i).unwrap())) {
8334 IrExpr::FunctionCall { name, .. } => Some(name.as_str()),
8335 _ => None,
8336 },
8337 )
8338 .collect();
8339 for expected in [
8340 "_path_fixed_length",
8341 "_node_struct_list",
8342 "_rel_struct_list",
8343 ] {
8344 assert!(names.contains(&expected), "missing {expected}: {names:?}");
8345 }
8346 }
8347
8348 #[test]
8349 fn explicit_one_hop_routes_as_fixed_segment() {
8350 let (binder, _) = make_binder(OntologyMode::Exploratory);
8353 let ast = parse("MATCH p = (a)-[:KNOWS*1..1]->(b) RETURN length(p) AS l").unwrap();
8354 let plan = binder.bind(&ast).expect("explicit 1..1 binds as fixed");
8355 let has_fixed = (0..plan.exprs.len()).any(|i| {
8356 matches!(
8357 plan.exprs.get(ExprId(u32::try_from(i).unwrap())),
8358 IrExpr::FunctionCall { name, .. } if name == "_path_fixed_length"
8359 )
8360 });
8361 assert!(
8362 has_fixed,
8363 "explicit *1..1 must use the fixed-segment rewrite"
8364 );
8365 }
8366
8367 #[test]
8368 fn path_var_name_conflict_is_rejected() {
8369 let errors = bind_errors("MATCH p = (p)-[:KNOWS*1..2]->(b) RETURN length(p)");
8370 assert!(
8371 errors
8372 .iter()
8373 .any(|e| matches!(e.kind, BindErrorKind::DuplicateVariable)),
8374 "expected DuplicateVariable, got {errors:?}"
8375 );
8376 }
8377
8378 #[test]
8379 fn optional_match_path_var_functions_bind() {
8380 let (binder, _) = make_binder(OntologyMode::Exploratory);
8383 let ast = parse(
8384 "MATCH (a:Person) \
8385 OPTIONAL MATCH p = (a)-[:KNOWS*1..2]->(b) \
8386 RETURN nodes(p) AS ns, length(p) AS l",
8387 )
8388 .unwrap();
8389 binder.bind(&ast).expect("optional path functions bind");
8390 }
8391
8392 fn procedure_binder() -> Binder {
8393 let (binder, _) = make_binder(OntologyMode::Exploratory);
8394 let procedure = ProcedureDefinition {
8395 name: "test.proc".into(),
8396 inputs: vec![ProcedureField {
8397 name: "in".into(),
8398 type_name: "INTEGER".into(),
8399 nullable: true,
8400 }],
8401 outputs: vec![ProcedureField {
8402 name: "out".into(),
8403 type_name: "INTEGER".into(),
8404 nullable: true,
8405 }],
8406 rows: vec![vec![IrLiteral::Int(1), IrLiteral::Int(2)]],
8407 };
8408 binder.with_procedures(Arc::new(ProcedureRegistry::from([(
8409 procedure.name.clone(),
8410 procedure,
8411 )])))
8412 }
8413
8414 #[test]
8415 fn call_binds_explicit_args_and_yield_alias() {
8416 let plan = procedure_binder()
8417 .bind(&parse("CALL test.proc(1) YIELD out AS value RETURN value").unwrap())
8418 .expect("CALL should bind");
8419 let GraphOp::Call { args, yields, .. } = &plan.ops[0] else {
8420 panic!("expected CALL op")
8421 };
8422 assert_eq!(args.len(), 1);
8423 assert_eq!(yields[0].field, "out");
8424 assert_eq!(yields[0].alias, "value");
8425 }
8426
8427 #[test]
8428 fn call_without_parentheses_uses_implicit_parameters() {
8429 let plan = procedure_binder()
8430 .bind(&parse("CALL test.proc YIELD out").unwrap())
8431 .expect("implicit CALL should bind");
8432 let GraphOp::Call { args, .. } = &plan.ops[0] else {
8433 panic!("expected CALL op")
8434 };
8435 assert!(matches!(plan.exprs.get(args[0]), IrExpr::Parameter(name) if name == "in"));
8436 }
8437
8438 #[test]
8439 fn call_rejects_unknown_procedure_argument_count_and_yield() {
8440 for (query, message) in [
8441 ("CALL missing.proc()", "ProcedureNotFound"),
8442 ("CALL test.proc()", "InvalidNumberOfArguments"),
8443 ("CALL test.proc(1) YIELD missing", "ProcedureOutputNotFound"),
8444 ] {
8445 let errors = procedure_binder()
8446 .bind(&parse(query).unwrap())
8447 .expect_err("CALL should fail");
8448 assert!(
8449 errors.iter().any(|error| error.message.contains(message)),
8450 "expected {message}, got {errors:?}"
8451 );
8452 }
8453 }
8454
8455 #[test]
8456 fn union_binds_branch_plans_and_mode() {
8457 let (binder, _) = make_binder(OntologyMode::Exploratory);
8458 let plan = binder
8459 .bind(&parse("RETURN 1 AS x UNION ALL RETURN 2 AS x").unwrap())
8460 .expect("UNION ALL binds");
8461 let [GraphOp::Union { all, inputs }] = plan.ops.as_slice() else {
8462 panic!("expected one UNION op")
8463 };
8464 assert!(*all);
8465 assert_eq!(inputs.len(), 2);
8466 assert!(inputs.iter().all(|branch| !branch.ops.is_empty()));
8467 }
8468
8469 #[test]
8470 fn union_rejects_mixed_modes_and_different_columns() {
8471 let (binder, _) = make_binder(OntologyMode::Exploratory);
8472 for (query, message) in [
8473 (
8474 "RETURN 1 AS x UNION RETURN 2 AS x UNION ALL RETURN 3 AS x",
8475 "InvalidCombinationOfUnion",
8476 ),
8477 (
8478 "RETURN 1 AS x UNION RETURN 2 AS y",
8479 "DifferentColumnsInUnion",
8480 ),
8481 ("CREATE (:A) UNION CREATE (:B)", "DifferentColumnsInUnion"),
8482 ] {
8483 let errors = binder
8484 .bind(&parse(query).unwrap())
8485 .expect_err("UNION should fail");
8486 assert!(errors.iter().any(|error| error.message.contains(message)));
8487 }
8488 }
8489
8490 #[test]
8491 fn return_wildcard_requires_a_variable_in_scope() {
8492 let (binder, _) = make_binder(OntologyMode::Exploratory);
8493 let errors = binder
8494 .bind(&parse("RETURN *").unwrap())
8495 .expect_err("empty-scope RETURN wildcard must fail");
8496 assert!(errors.iter().any(|error| {
8497 error
8498 .message
8499 .contains("wildcard requires at least one variable")
8500 }));
8501 assert_ne!(errors[0].span, Span::default());
8502 }
8503
8504 #[test]
8505 fn with_wildcard_preserves_an_empty_named_scope() {
8506 let (binder, _) = make_binder(OntologyMode::Exploratory);
8507 let plan = binder
8508 .bind(&parse("CREATE () WITH * CREATE ()").unwrap())
8509 .expect("empty-scope WITH wildcard should preserve pipeline rows");
8510 assert_eq!(plan.ops.len(), 3);
8511 assert!(matches!(&plan.ops[1], GraphOp::With { items, .. } if items.is_empty()));
8512 }
8513
8514 #[test]
8515 fn typed_uuid_parameters_are_identity_only_across_expression_surfaces() {
8516 let params = HashMap::from([("id".into(), IrLiteral::Uuid([0x55; 16]))]);
8517 for query in [
8518 "MATCH (n:Person) WHERE NOT (n.node_uuid = $id) RETURN n",
8519 "MATCH (n:Person) WHERE n.node_uuid <> $id RETURN n",
8520 "MATCH (n:Person) WHERE n.node_uuid > $id RETURN n",
8521 "MATCH (n:Person) WHERE n.node_uuid IN [$id] RETURN n",
8522 "RETURN $id",
8523 "RETURN toString($id)",
8524 "RETURN size([$id])",
8525 "RETURN [$id]",
8526 "RETURN 1 AS value SKIP $id",
8527 "RETURN 1 AS value LIMIT $id",
8528 "MATCH (n:Person) WHERE (($id = n.name) OR false) RETURN n",
8529 "MATCH (n:Person) WHERE n.name IN [$id] RETURN n",
8530 "MATCH (n:Person) RETURN n.name = $id AS bad",
8531 "MATCH (n:Person) WITH n, n.name = $id AS bad RETURN bad",
8532 "MATCH (n:Person) RETURN n.name AS name ORDER BY n.name = $id",
8533 "MATCH (n:Person) UNWIND [n.name = $id] AS bad RETURN bad",
8534 "MATCH (n:Person {probe: n.name = $id}) RETURN n",
8535 "MATCH (n:Person) SET n.probe = (n.name = $id) RETURN n",
8536 "MATCH (n:Person) MERGE (m:Other {probe: n.name = $id}) RETURN m",
8537 "MATCH (n:Person) DELETE (n.name = $id)",
8538 "MATCH (n:Person)-[r:KNOWS]->() WHERE r.node_uuid = $id RETURN r",
8539 "MATCH (n:Person)-[r:KNOWS]->() WHERE n.edge_uuid = $id RETURN n",
8540 ] {
8541 let (binder, _) = make_binder(OntologyMode::Exploratory);
8542 let errors = binder
8543 .with_parameter_literals(¶ms)
8544 .bind(&parse(query).unwrap())
8545 .expect_err("typed UUID must not reach a non-identity expression");
8546 assert!(
8547 errors.iter().any(|error| {
8548 error.kind == BindErrorKind::InvalidArgument
8549 && error.message
8550 == "typed UUID parameter `$id` is only supported as a direct node_uuid or edge_uuid identity equality predicate"
8551 }),
8552 "query={query} errors={errors:?}"
8553 );
8554 }
8555
8556 let errors = procedure_binder()
8557 .with_parameter_literals(¶ms)
8558 .bind(
8559 &parse("MATCH (n:Person) CALL test.proc(n.name = $id) YIELD out RETURN out")
8560 .unwrap(),
8561 )
8562 .expect_err("CALL argument must enforce typed UUID identity semantics");
8563 assert!(
8564 errors.iter().any(|error| {
8565 error.kind == BindErrorKind::InvalidArgument
8566 && error.message.starts_with("typed UUID parameter `$id`")
8567 }),
8568 "errors={errors:?}"
8569 );
8570 }
8571
8572 #[test]
8573 fn typed_uuid_parameters_allow_only_kind_correct_identity_fields() {
8574 let params = HashMap::from([("id".into(), IrLiteral::Uuid([0x55; 16]))]);
8575 for query in [
8576 "MATCH (n:Person) WHERE n.node_uuid = $id RETURN n.node_uuid",
8577 "MATCH (n:Person) WHERE $id = n.node_uuid RETURN n.node_uuid",
8578 "MATCH ()-[r:KNOWS]->() WHERE r.edge_uuid = $id RETURN r.edge_uuid",
8579 "MATCH ()-[r:KNOWS]->() WHERE $id = r.edge_uuid RETURN r.edge_uuid",
8580 ] {
8581 let (binder, _) = make_binder(OntologyMode::Exploratory);
8582 binder
8583 .with_parameter_literals(¶ms)
8584 .bind(&parse(query).unwrap())
8585 .unwrap_or_else(|errors| panic!("query={query} errors={errors:?}"));
8586 }
8587
8588 for nested in [
8589 IrLiteral::List(vec![IrLiteral::Uuid([0x55; 16])]),
8590 IrLiteral::Map(vec![(
8591 "nested".into(),
8592 IrLiteral::List(vec![IrLiteral::Uuid([0x55; 16])]),
8593 )]),
8594 ] {
8595 let (binder, _) = make_binder(OntologyMode::Exploratory);
8596 let errors = binder
8597 .with_parameter_literals(&HashMap::from([("id".into(), nested)]))
8598 .bind(
8599 &parse("MATCH (n:Person) WHERE n.node_uuid = $id RETURN n.node_uuid").unwrap(),
8600 )
8601 .expect_err("containers containing UUID values are never identity scalars");
8602 assert!(errors.iter().any(|error| {
8603 error.kind == BindErrorKind::InvalidArgument
8604 && error.message.starts_with("typed UUID parameter `$id`")
8605 }));
8606 }
8607 }
8608}