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 empty_state(mode: OntologyMode) -> BinderState {
6567 BinderState {
6568 vars: HashMap::new(),
6569 path_vars: HashMap::new(),
6570 node_vars: HashMap::new(),
6571 edge_vars: HashMap::new(),
6572 edge_rel_names: HashMap::new(),
6573 scalar_list_edges: HashSet::new(),
6574 var_kinds: HashMap::new(),
6575 next_var: 0,
6576 builder: GraphPlan::builder("openCypher").ontology_mode(mode),
6577 errors: Vec::new(),
6578 warnings: Vec::new(),
6579 captured_pattern_comprehensions: None,
6580 existential_depth: 0,
6581 standalone_call: false,
6582 }
6583 }
6584
6585 fn parsed_return_expr(source: &str) -> Expr {
6586 let ast =
6587 parse(source).unwrap_or_else(|error| panic!("failed to parse {source:?}: {error}"));
6588 let Some(AstClause::Return(clause)) = ast.clauses.last() else {
6589 panic!("expected RETURN clause for {source:?}");
6590 };
6591 clause.items[0].expr.clone()
6592 }
6593
6594 #[test]
6595 fn expression_rewriters_traverse_every_public_ast_container() {
6596 let expressions = [
6597 "RETURN a + 1",
6598 "RETURN NOT a",
6599 "RETURN (a)",
6600 "RETURN a.name",
6601 "RETURN coalesce(a, 1)",
6602 "RETURN [a, 1]",
6603 "RETURN {k: a}",
6604 "RETURN CASE a WHEN 1 THEN a ELSE 0 END",
6605 "RETURN [x IN a WHERE x > 0 | x]",
6606 "RETURN all(x IN a WHERE x > 0)",
6607 "RETURN a IS NULL",
6608 "RETURN a IN [1]",
6609 "RETURN a STARTS WITH 'x'",
6610 "RETURN a =~ 'x'",
6611 "RETURN a:Person",
6612 "RETURN [(a)-->(b) WHERE a.name = 'x' | a]",
6613 "RETURN exists { (a)-->(b) WHERE a.name = 'x' }",
6614 ]
6615 .map(parsed_return_expr);
6616 let alias_projection = ReturnItem {
6617 expr: Expr::Literal(Literal::Int(7, Span::new(0, 1))),
6618 alias: Some("a".into()),
6619 display: None,
6620 span: Span::new(0, 1),
6621 };
6622 let grouping = parsed_return_expr("RETURN a");
6623 let bindings = [(grouping, "group_a".into(), VarId(0))];
6624
6625 for expression in expressions {
6626 let mut refs = Vec::new();
6627 collect_grouping_refs(&expression, &mut refs);
6628 let alias_rewritten = rewrite_projection_alias_refs(
6629 expression.clone(),
6630 std::slice::from_ref(&alias_projection),
6631 );
6632 let grouping_rewritten = rewrite_grouping_refs(expression.clone(), &bindings);
6633 assert_eq!(alias_rewritten.span(), expression.span());
6634 assert_eq!(grouping_rewritten.span(), expression.span());
6635 let _ = expr_contains_pattern_comprehension(&expression);
6636 let _ = expr_contains_pattern_predicate(&expression);
6637 let _ = expr_contains_volatile_function(&expression);
6638 let _ = expr_contains_aggregate_inside_aggregate(&expression, false);
6639 }
6640 }
6641
6642 fn catalog_entry(catalog: &RuntimeCatalog, kind: &str, name: &str) -> (u32, u64) {
6643 let batch = catalog.to_record_batch();
6644 let kinds = batch
6645 .column(0)
6646 .as_any()
6647 .downcast_ref::<StringArray>()
6648 .unwrap();
6649 let names = batch
6650 .column(1)
6651 .as_any()
6652 .downcast_ref::<StringArray>()
6653 .unwrap();
6654 let ids = batch
6655 .column(2)
6656 .as_any()
6657 .downcast_ref::<UInt32Array>()
6658 .unwrap();
6659 let counts = batch
6660 .column(3)
6661 .as_any()
6662 .downcast_ref::<UInt64Array>()
6663 .unwrap();
6664 let row = (0..batch.num_rows())
6665 .find(|&row| kinds.value(row) == kind && names.value(row) == name)
6666 .unwrap_or_else(|| panic!("missing catalog entry {kind}:{name}"));
6667 (ids.value(row), counts.value(row))
6668 }
6669
6670 fn strict_property_binder(shadow_inherited: bool) -> (Binder, Arc<Mutex<RuntimeCatalog>>) {
6671 let shadow = if shadow_inherited {
6672 r#", {"owner":"Host","name":"inherited","type":"utf8"}"#
6673 } else {
6674 ""
6675 };
6676 let doc: OntologyDoc = serde_json::from_str(&format!(
6677 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}]}}"#
6678 ))
6679 .unwrap();
6680 let ontology = OntologyCompiler::compile(&doc).unwrap();
6681 let catalog = Arc::new(Mutex::new(RuntimeCatalog::new()));
6682 catalog.lock().unwrap().intern_property("preexisting", None);
6683 (
6684 Binder::new(
6685 Some(OntologyHandle::new(ontology)),
6686 Arc::clone(&catalog),
6687 OntologyMode::Strict,
6688 ),
6689 catalog,
6690 )
6691 }
6692
6693 fn plan_property_ids(plan: &GraphPlan) -> Vec<PropId> {
6694 (0..u32::try_from(plan.exprs.len()).unwrap())
6695 .filter_map(|index| match plan.exprs.get(ExprId(index)) {
6696 IrExpr::PropertyAccess { prop, .. } => Some(*prop),
6697 _ => None,
6698 })
6699 .collect()
6700 }
6701
6702 fn property_error(binder: &Binder, query: &str, kind: BindErrorKind, span: &str) -> BindError {
6703 let errors = binder.bind(&parse(query).unwrap()).expect_err(query);
6704 assert_eq!(errors.len(), 1, "{query}: {errors:?}");
6705 let error = errors.into_iter().next().unwrap();
6706 assert_eq!(error.kind, kind);
6707 assert_eq!(&query[error.span.start..error.span.end], span);
6708 error
6709 }
6710
6711 #[test]
6712 fn exploratory_unknown_label_succeeds() {
6713 let (binder, catalog) = make_binder(OntologyMode::Exploratory);
6714 let ast = parse("MATCH (a:UnknownLabel)-[:UNKNOWN_REL]->(b) RETURN a").unwrap();
6715 let plan = binder.bind(&ast).expect("exploratory bind should succeed");
6716
6717 assert!(
6718 plan.ops
6719 .iter()
6720 .any(|op| matches!(op, GraphOp::NodeScan { .. }))
6721 );
6722 let cat = catalog.lock().unwrap();
6723 assert!(cat.contains_entity_type("UnknownLabel"));
6724 assert!(cat.relation_types().contains(&"UNKNOWN_REL"));
6725 }
6726
6727 #[test]
6728 fn bind_catalog_mutations_commit_only_after_success() {
6729 let (binder, catalog) = make_binder(OntologyMode::Exploratory);
6730 {
6731 let mut catalog = catalog.lock().unwrap();
6732 assert_eq!(catalog.intern_label("Seed").0, 0);
6733 assert_eq!(catalog.intern_relation_type("SEED_REL").0, 1);
6734 assert_eq!(catalog.intern_property("seed", None).0, 0);
6735 }
6736 let before = catalog.lock().unwrap().to_record_batch();
6737
6738 let errors = binder
6739 .bind(
6740 &parse(
6741 "MATCH (n:Rejected)-[:REJECTED_REL]->() \
6742 RETURN n.rejected, missing",
6743 )
6744 .unwrap(),
6745 )
6746 .expect_err("a later semantic error must reject every staged observation");
6747 assert!(
6748 errors
6749 .iter()
6750 .any(|error| error.kind == BindErrorKind::UndeclaredVariable)
6751 );
6752 assert_eq!(
6753 catalog.lock().unwrap().to_record_batch(),
6754 before,
6755 "failed binding must leave entries, observations, timestamps, and IDs unchanged"
6756 );
6757
6758 binder
6759 .bind(
6760 &parse(
6761 "MATCH (n:Accepted)-[:ACCEPTED_REL]->() \
6762 RETURN n.accepted",
6763 )
6764 .unwrap(),
6765 )
6766 .expect("successful binding must publish the staged catalog");
6767 let catalog = catalog.lock().unwrap();
6768 assert_eq!(catalog_entry(&catalog, "entity_type", "Accepted"), (2, 1));
6769 assert_eq!(
6770 catalog_entry(&catalog, "relation_type", "ACCEPTED_REL"),
6771 (3, 1)
6772 );
6773 assert_eq!(catalog_entry(&catalog, "property", "accepted"), (1, 1));
6774 }
6775
6776 #[test]
6777 fn fixed_pattern_predicate_lowers_to_exists() {
6778 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6779 let ast = parse("MATCH (n) WHERE (n)-[:REL]->() RETURN n").unwrap();
6780 let plan = binder.bind(&ast).expect("pattern predicate binds");
6781
6782 let exists = plan
6783 .ops
6784 .iter()
6785 .find_map(|op| match op {
6786 GraphOp::Exists { child, negated } => Some((child, negated)),
6787 _ => None,
6788 })
6789 .expect("pattern predicate should lower to Exists");
6790 assert!(!*exists.1);
6791 assert!(
6792 exists
6793 .0
6794 .ops
6795 .iter()
6796 .any(|op| matches!(op, GraphOp::Expand { .. })),
6797 "child plan must match the relationship pattern"
6798 );
6799 }
6800
6801 #[test]
6802 fn relationship_uniqueness_is_scoped_to_each_path_pattern() {
6803 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6804 let ast = parse("MATCH (a)-[r1]->(b)-[r2]->(c), (x)-[r3]->(y) RETURN a").unwrap();
6805 let plan = binder.bind(&ast).expect("pattern binds");
6806
6807 let constraints: Vec<_> = plan
6808 .ops
6809 .iter()
6810 .filter_map(|op| match op {
6811 GraphOp::RelationshipUnique { edge, prior_edges } => {
6812 Some((*edge, prior_edges.clone()))
6813 }
6814 _ => None,
6815 })
6816 .collect();
6817 assert_eq!(constraints.len(), 1);
6818 assert_eq!(constraints[0].1.len(), 1);
6819 }
6820
6821 #[test]
6822 fn simple_existential_subquery_allows_child_local_variables() {
6823 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6824 let ast = parse("MATCH (n) WHERE exists { (n)-[r]->(m) WHERE type(r) = 'REL' } RETURN n")
6825 .unwrap();
6826 let plan = binder.bind(&ast).expect("existential subquery binds");
6827
6828 let child = plan
6829 .ops
6830 .iter()
6831 .find_map(|op| match op {
6832 GraphOp::Exists { child, negated } => {
6833 assert!(!negated);
6834 Some(child)
6835 }
6836 _ => None,
6837 })
6838 .expect("expected Exists op");
6839 assert!(
6840 child
6841 .ops
6842 .iter()
6843 .any(|op| matches!(op, GraphOp::Expand { .. }))
6844 );
6845 assert!(
6846 child
6847 .ops
6848 .iter()
6849 .any(|op| matches!(op, GraphOp::Filter { .. }))
6850 );
6851 }
6852
6853 #[test]
6854 fn full_existential_correlation_is_scope_aware() {
6855 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6856 let correlated = parse(
6857 "MATCH (n) WHERE exists { MATCH (m) WHERE m.prop = n.prop RETURN true } RETURN n",
6858 )
6859 .unwrap();
6860 binder
6861 .bind(&correlated)
6862 .expect("an outer variable used only in a child expression must bind");
6863
6864 let shadowed = parse("MATCH (n) WHERE exists { WITH 1 AS n RETURN n } RETURN n").unwrap();
6865 let errors = binder
6866 .bind(&shadowed)
6867 .expect_err("a child-local alias must not count as outer correlation");
6868 assert!(
6869 errors.iter().any(|error| {
6870 error.kind == BindErrorKind::UndeclaredVariable
6871 && error
6872 .message
6873 .contains("must reference at least one outer variable")
6874 }),
6875 "expected uncorrelated-subquery error, got {errors:?}"
6876 );
6877 }
6878
6879 #[test]
6880 fn with_nested_aggregate_lowers_to_aggregate_then_scope_reset() {
6881 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6882 let ast =
6883 parse("MATCH (me)--(you) WITH me.age AS age, me.age + count(you.age) AS agg RETURN *")
6884 .unwrap();
6885 let plan = binder.bind(&ast).expect("nested WITH aggregate binds");
6886 let aggregate = plan
6887 .ops
6888 .iter()
6889 .position(|op| matches!(op, GraphOp::Aggregate { .. }))
6890 .expect("aggregate op");
6891 let with = plan
6892 .ops
6893 .iter()
6894 .skip(aggregate + 1)
6895 .position(|op| matches!(op, GraphOp::With { .. }))
6896 .expect("post-aggregate scope reset");
6897 assert_eq!(with, 0);
6898 }
6899
6900 #[test]
6901 fn with_rejects_ambiguous_and_nested_aggregation() {
6902 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6903 let ambiguous =
6904 parse("MATCH (me)--(you) WITH me.age + count(you.age) AS agg RETURN agg").unwrap();
6905 let error = binder
6906 .bind(&ambiguous)
6907 .expect_err("ambiguous grouping must fail");
6908 assert!(
6909 error
6910 .iter()
6911 .any(|error| error.message.contains("ambiguous aggregation expression"))
6912 );
6913
6914 let nested = parse("MATCH (n) WITH count(count(*)) AS c RETURN c").unwrap();
6915 let error = binder
6916 .bind(&nested)
6917 .expect_err("nested aggregates must fail");
6918 assert!(
6919 error
6920 .iter()
6921 .any(|error| { error.message.contains("may not contain another aggregate") })
6922 );
6923 }
6924
6925 #[test]
6926 fn return_rejects_nested_and_volatile_aggregation() {
6927 for (query, message) in [
6928 (
6929 "RETURN count(count(*))",
6930 "may not contain another aggregate",
6931 ),
6932 (
6933 "RETURN count(rand())",
6934 "non-deterministic functions are not allowed",
6935 ),
6936 ] {
6937 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6938 let ast = parse(query).unwrap();
6939 let errors = binder.bind(&ast).expect_err("query must fail at bind");
6940 assert!(
6941 errors.iter().any(|error| error.message.contains(message)),
6942 "missing {message:?} for {query}: {errors:?}"
6943 );
6944 }
6945 }
6946
6947 #[test]
6948 fn return_rejects_unknown_function_at_bind() {
6949 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6950 let ast = parse("MATCH (a) RETURN foo(a)").unwrap();
6951 let errors = binder.bind(&ast).expect_err("unknown function must fail");
6952 assert!(
6953 errors
6954 .iter()
6955 .any(|error| error.message.contains("unknown function `foo`"))
6956 );
6957 }
6958
6959 #[test]
6960 fn negated_pattern_predicate_lowers_to_anti_exists() {
6961 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6962 let ast = parse("MATCH (n) WHERE NOT (n)-[:REL]-() RETURN n").unwrap();
6963 let plan = binder.bind(&ast).expect("negated pattern predicate binds");
6964
6965 assert!(
6966 plan.ops
6967 .iter()
6968 .any(|op| { matches!(op, GraphOp::Exists { negated: true, .. }) })
6969 );
6970 }
6971
6972 #[test]
6973 fn multi_type_pattern_predicate_lowers_to_union_exists() {
6974 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6975 let ast = parse("MATCH (n), (m) WHERE (n)-[:REL1|REL2]-(m) RETURN n").unwrap();
6976 let plan = binder.bind(&ast).expect("multi-type predicate binds");
6977
6978 let inputs = plan
6979 .ops
6980 .iter()
6981 .find_map(|op| match op {
6982 GraphOp::Exists { child, .. } => match child.ops.as_slice() {
6983 [GraphOp::Union { inputs, .. }] => Some(inputs),
6984 _ => None,
6985 },
6986 _ => None,
6987 })
6988 .expect("multi-type predicate should lower to union-backed Exists");
6989 assert_eq!(inputs.len(), 2);
6990 }
6991
6992 #[test]
6993 fn or_pattern_predicate_lowers_to_union_exists() {
6994 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6995 let ast = parse("MATCH (n) WHERE (n)-[:REL1]-() OR (n)-[:REL2]-() RETURN n").unwrap();
6996 let plan = binder.bind(&ast).expect("OR predicate binds");
6997
6998 let inputs = plan
6999 .ops
7000 .iter()
7001 .find_map(|op| match op {
7002 GraphOp::Exists { child, .. } => match child.ops.as_slice() {
7003 [GraphOp::Union { inputs, .. }] => Some(inputs),
7004 _ => None,
7005 },
7006 _ => None,
7007 })
7008 .expect("OR predicate should lower to union-backed Exists");
7009 assert_eq!(inputs.len(), 2);
7010 }
7011
7012 #[test]
7013 fn pattern_predicate_rejects_new_named_variables() {
7014 expect_bind_error(
7015 "MATCH (n) WHERE (n)-[r]->() RETURN n",
7016 BindErrorKind::UndeclaredVariable,
7017 );
7018 expect_bind_error(
7019 "MATCH (n) WHERE (n)-->(m) RETURN n",
7020 BindErrorKind::UndeclaredVariable,
7021 );
7022 }
7023
7024 #[test]
7025 fn pattern_predicate_rejects_named_path_binding() {
7026 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7027 let mut ast = parse("MATCH (n) WHERE (n)-[:REL]->() RETURN n").unwrap();
7028 let AstClause::Match(m) = &mut ast.clauses[0] else {
7029 panic!("expected MATCH clause");
7030 };
7031 let where_clause = m.where_clause.as_mut().expect("expected WHERE");
7032 let Expr::PatternPredicate(pp) = &mut where_clause.predicate else {
7033 panic!("expected pattern predicate");
7034 };
7035 pp.pattern.var = Some("p".into());
7036
7037 let errs = binder
7038 .bind(&ast)
7039 .expect_err("predicate-local path binding should fail");
7040 assert!(
7041 errs.iter()
7042 .any(|err| err.kind == BindErrorKind::UndeclaredVariable),
7043 "expected undeclared path binding error, got {errs:?}"
7044 );
7045 }
7046
7047 #[test]
7048 fn var_length_pattern_predicate_rejects_relationship_properties() {
7049 expect_bind_error(
7050 "MATCH (n) WHERE (n)-[:REL* {k: 1}]->() RETURN n",
7051 BindErrorKind::InvalidArgument,
7052 );
7053 }
7054
7055 #[test]
7056 fn pattern_predicate_rejects_uncorrelated_patterns() {
7057 expect_bind_error(
7058 "MATCH (n) WHERE ()-[:REL]->() RETURN n",
7059 BindErrorKind::UndeclaredVariable,
7060 );
7061 }
7062
7063 #[test]
7064 fn named_pattern_comprehension_binds_correlated_child_projection() {
7065 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7066 let ast = parse("MATCH (n) RETURN [p = (n)-[:REL]->() | p] AS paths").unwrap();
7067 let plan = binder.bind(&ast).expect("pattern comprehension binds");
7068
7069 let (child, output) = plan
7070 .ops
7071 .iter()
7072 .find_map(|op| match op {
7073 GraphOp::PatternComprehension { child, output } => Some((child, output)),
7074 _ => None,
7075 })
7076 .expect("expected a PatternComprehension op");
7077 assert!(
7078 child
7079 .ops
7080 .iter()
7081 .any(|op| matches!(op, GraphOp::Expand { .. }))
7082 );
7083 let projection = child
7084 .ops
7085 .last()
7086 .and_then(|op| match op {
7087 GraphOp::Project { items, .. } => items.first(),
7088 _ => None,
7089 })
7090 .expect("child must end with one value projection");
7091 assert_eq!(
7092 projection.alias.as_deref(),
7093 Some(PATTERN_COMPREHENSION_VALUE_ALIAS)
7094 );
7095 assert!(matches!(
7096 child.exprs.get(projection.expr),
7097 IrExpr::FunctionCall { name, .. } if name == "_path_struct"
7098 ));
7099 let outer_projection = plan
7100 .ops
7101 .iter()
7102 .rev()
7103 .find_map(|op| match op {
7104 GraphOp::Project { items, .. } => items.first(),
7105 _ => None,
7106 })
7107 .expect("outer RETURN must project the collected result");
7108 assert!(matches!(
7109 plan.exprs.get(outer_projection.expr),
7110 IrExpr::VarRef(var) if var == output
7111 ));
7112 }
7113
7114 #[test]
7115 fn pattern_comprehension_binds_local_node_and_relationship_properties() {
7116 for query in [
7117 "MATCH (n) RETURN [(n)-[:REL]->(b) | b.name] AS names",
7118 "MATCH (n) RETURN [(n)-[r:REL]->() | r.name] AS names",
7119 ] {
7120 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7121 let ast = parse(query).unwrap();
7122 let plan = binder.bind(&ast).expect("local projection binds");
7123 let child = plan
7124 .ops
7125 .iter()
7126 .find_map(|op| match op {
7127 GraphOp::PatternComprehension { child, .. } => Some(child),
7128 _ => None,
7129 })
7130 .expect("expected a PatternComprehension op");
7131 let projection = child
7132 .ops
7133 .last()
7134 .and_then(|op| match op {
7135 GraphOp::Project { items, .. } => items.first(),
7136 _ => None,
7137 })
7138 .expect("child must end with a projection");
7139 assert!(matches!(
7140 child.exprs.get(projection.expr),
7141 IrExpr::PropertyAccess { .. }
7142 ));
7143 }
7144 }
7145
7146 #[test]
7147 fn pattern_comprehension_binds_filter_and_variable_length_match() {
7148 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7149 let ast =
7150 parse("MATCH (n) RETURN [(n)-[r:REL*]->(b) WHERE b.ok = true | b] AS matches").unwrap();
7151 let plan = binder
7152 .bind(&ast)
7153 .expect("filtered var-length comprehension binds");
7154 let child = plan
7155 .ops
7156 .iter()
7157 .find_map(|op| match op {
7158 GraphOp::PatternComprehension { child, .. } => Some(child),
7159 _ => None,
7160 })
7161 .expect("expected a PatternComprehension op");
7162
7163 assert!(child.ops.iter().any(|op| {
7164 matches!(
7165 op,
7166 GraphOp::Expand {
7167 min_hops: 1,
7168 max_hops: None,
7169 ..
7170 }
7171 )
7172 }));
7173 assert!(
7174 child
7175 .ops
7176 .iter()
7177 .any(|op| matches!(op, GraphOp::Filter { .. }))
7178 );
7179 assert!(matches!(child.ops.last(), Some(GraphOp::Project { .. })));
7180 }
7181
7182 #[test]
7183 fn pattern_comprehension_local_variables_do_not_leak() {
7184 expect_bind_error(
7185 "MATCH (n) RETURN [(n)-->(b) | b] AS matches, b",
7186 BindErrorKind::UndeclaredVariable,
7187 );
7188 }
7189
7190 #[test]
7191 fn pattern_comprehension_in_list_element_scope_lifts_to_graph_op() {
7192 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7193 let ast = parse("MATCH (n) RETURN [x IN [n] | [(x)-->() | x]] AS nested").unwrap();
7194 let plan = binder
7195 .bind(&ast)
7196 .expect("bind nested pattern comprehension");
7197 let lifted = plan.ops.iter().find_map(|op| match op {
7198 GraphOp::ListElementPatternComprehension { child, .. } => Some(child),
7199 _ => None,
7200 });
7201 let child = lifted.expect("list-element graph operation");
7202 assert!(matches!(child.ops.last(), Some(GraphOp::Project { .. })));
7203 }
7204
7205 #[test]
7206 fn bare_graph_value_where_predicate_is_rejected() {
7207 expect_bind_error(
7208 "MATCH (n) WHERE (n) RETURN n",
7209 BindErrorKind::InvalidArgument,
7210 );
7211 }
7212
7213 #[test]
7214 fn bare_path_value_where_predicate_is_rejected() {
7215 expect_bind_error(
7216 "MATCH p = (n)-[:REL]->() WHERE p RETURN p",
7217 BindErrorKind::InvalidArgument,
7218 );
7219 }
7220
7221 #[test]
7222 fn with_where_pattern_predicate_lowers_after_with() {
7223 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7224 let ast = parse("MATCH (n) WITH n WHERE (n)-[:REL]->() RETURN n").unwrap();
7225 let plan = binder.bind(&ast).expect("WITH pattern predicate binds");
7226
7227 let with_idx = plan
7228 .ops
7229 .iter()
7230 .position(|op| matches!(op, GraphOp::With { .. }))
7231 .expect("WITH should lower to a With op");
7232 let exists_idx = plan
7233 .ops
7234 .iter()
7235 .position(|op| matches!(op, GraphOp::Exists { .. }))
7236 .expect("WITH WHERE pattern predicate should lower to Exists");
7237 assert!(
7238 with_idx < exists_idx,
7239 "WITH projection must run before its pattern predicate"
7240 );
7241 }
7242
7243 #[test]
7244 fn create_single_node_populates_pattern() {
7245 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7246 let ast = parse("CREATE (:Person {name: 'Alice', age: 30})").unwrap();
7247 let plan = binder.bind(&ast).expect("create bind should succeed");
7248
7249 let create = plan
7250 .ops
7251 .iter()
7252 .find_map(|op| match op {
7253 GraphOp::Create { pattern } => Some(pattern),
7254 _ => None,
7255 })
7256 .expect("expected a Create op");
7257 assert_eq!(create.nodes.len(), 1);
7258 assert!(create.edges.is_empty());
7259 let node = &create.nodes[0];
7260 assert_eq!(node.labels.len(), 1, "Person label should resolve");
7261 let props = node.properties.expect("node should have a property map");
7262 assert!(matches!(plan.exprs.get(props), IrExpr::MapLiteral(_)));
7264 }
7265
7266 #[test]
7267 fn create_edge_threads_src_and_dst_vars() {
7268 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7269 let ast = parse("CREATE (a:Person)-[:KNOWS]->(b:Person)").unwrap();
7270 let plan = binder.bind(&ast).expect("create bind should succeed");
7271
7272 let create = plan
7273 .ops
7274 .iter()
7275 .find_map(|op| match op {
7276 GraphOp::Create { pattern } => Some(pattern),
7277 _ => None,
7278 })
7279 .expect("expected a Create op");
7280 assert_eq!(create.nodes.len(), 2);
7281 assert_eq!(create.edges.len(), 1);
7282 let edge = &create.edges[0];
7283 assert_eq!(edge.src, create.nodes[0].var);
7285 assert_eq!(edge.dst, create.nodes[1].var);
7286 assert_eq!(edge.direction, Direction::Out);
7287 assert!(edge.rel_type.is_some());
7288 }
7289
7290 #[test]
7291 fn standalone_create_node_is_not_a_reference() {
7292 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7294 let ast = parse("CREATE (a:Person)").unwrap();
7295 let plan = binder.bind(&ast).expect("bind");
7296 let create = plan
7297 .ops
7298 .iter()
7299 .find_map(|op| match op {
7300 GraphOp::Create { pattern } => Some(pattern),
7301 _ => None,
7302 })
7303 .expect("Create op");
7304 assert_eq!(create.nodes.len(), 1);
7305 assert!(
7306 !create.nodes[0].is_reference,
7307 "a CREATE-introduced var must be a mint, not a reference"
7308 );
7309 }
7310
7311 fn expect_kind_conflict(query: &str) {
7317 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7318 let ast = parse(query).expect("parse");
7319 let errs = binder
7320 .bind(&ast)
7321 .expect_err(&format!("expected a kind conflict for: {query}"));
7322 assert!(
7323 errs.iter()
7324 .any(|e| e.kind == BindErrorKind::VariableKindConflict),
7325 "expected VariableKindConflict for {query}, got {errs:?}"
7326 );
7327 }
7328
7329 #[test]
7330 fn relationship_var_reused_as_node_conflicts() {
7331 for q in [
7333 "MATCH ()-[r]-() MATCH (r) RETURN r",
7334 "MATCH ()-[r]->() MATCH (r) RETURN r",
7335 "MATCH (), ()-[r]-() MATCH (r) RETURN r",
7336 ] {
7337 expect_kind_conflict(q);
7338 }
7339 }
7340
7341 #[test]
7342 fn path_var_reused_as_node_conflicts() {
7343 expect_kind_conflict("MATCH r = ()-[]-() MATCH (r) RETURN r");
7345 }
7346
7347 #[test]
7348 fn scalar_aliases_reused_as_pattern_entities_conflict() {
7349 expect_kind_conflict("WITH 42 AS n MATCH (n) RETURN n");
7350 expect_kind_conflict("WITH true AS r MATCH ()-[r]->() RETURN r");
7351 expect_kind_conflict(
7352 "MATCH (n) WITH collect(n) AS users MATCH (users)-[:R]->() RETURN users",
7353 );
7354 }
7355
7356 #[test]
7357 fn runtime_polymorphic_pattern_values_remain_bindable() {
7358 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7359 for query in [
7360 "WITH null AS a OPTIONAL MATCH p = (a)-[r]->() RETURN relationships(p)",
7361 "MATCH (a) WITH collect(a) AS nodes UNWIND nodes AS n MATCH (n) RETURN n",
7362 ] {
7363 let ast = parse(query).expect("parse");
7364 binder
7365 .bind(&ast)
7366 .unwrap_or_else(|errors| panic!("expected clean bind for {query}: {errors:?}"));
7367 }
7368 }
7369
7370 #[test]
7371 fn direct_graph_function_kind_mismatches_are_rejected() {
7372 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7373 for query in [
7374 "MATCH (n) RETURN type(n)",
7375 "MATCH ()-[r]->() RETURN labels(r)",
7376 "MATCH (n) RETURN length(n)",
7377 "MATCH ()-[r]->() RETURN length(r)",
7378 "MATCH p = (n) RETURN labels(p)",
7379 ] {
7380 let ast = parse(query).expect("parse");
7381 let errors = binder.bind(&ast).expect_err("expected invalid argument");
7382 assert!(
7383 errors
7384 .iter()
7385 .any(|error| error.kind == BindErrorKind::InvalidArgument),
7386 "expected InvalidArgument for {query}, got {errors:?}"
7387 );
7388 }
7389 }
7390
7391 #[test]
7392 fn compatible_variable_reuse_is_accepted() {
7393 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7396 for q in [
7397 "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)", ] {
7403 let ast = parse(q).expect("parse");
7404 binder
7405 .bind(&ast)
7406 .unwrap_or_else(|e| panic!("expected clean bind for {q}, got {e:?}"));
7407 }
7408 }
7409
7410 #[test]
7411 fn relationship_var_reused_with_different_type_adds_false_filter() {
7412 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7417 let ast = parse("MATCH ()-[r:T]->() WITH r MATCH ()-[r:Y]->() RETURN r").expect("parse");
7418 let plan = binder.bind(&ast).expect("bind");
7419
7420 let has_false_filter = plan.ops.iter().any(|op| match op {
7421 GraphOp::Filter { predicate } => {
7422 matches!(
7423 plan.exprs.get(*predicate),
7424 IrExpr::Literal(IrLiteral::Bool(false))
7425 )
7426 }
7427 _ => false,
7428 });
7429
7430 assert!(
7431 has_false_filter,
7432 "reusing a known relationship variable with a different type should filter to no rows"
7433 );
7434 }
7435
7436 fn expect_bind_error(query: &str, kind: BindErrorKind) {
7442 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7443 let ast = parse(query).expect("parse");
7444 let errs = binder
7445 .bind(&ast)
7446 .expect_err(&format!("expected a bind error for: {query}"));
7447 assert!(
7448 errs.iter().any(|e| e.kind == kind),
7449 "expected {kind:?} for {query}, got {errs:?}"
7450 );
7451 }
7452
7453 #[test]
7454 fn create_pattern_validation() {
7455 for q in [
7458 "CREATE ()-->()", "CREATE ()-[:FOO*2]->()", "CREATE (a)-[:FOO]-(b)", ] {
7462 expect_bind_error(q, BindErrorKind::InvalidArgument);
7463 }
7464 }
7465
7466 #[test]
7467 fn rebinding_a_bound_variable_in_create_is_rejected() {
7468 for q in [
7469 "MATCH (a) CREATE (a)", "MATCH (a) CREATE (a {name: 'x'})", "CREATE (n:Foo) CREATE (n:Bar)-[:OWNS]->(:Dog)", "MATCH ()-[r]->() CREATE ()-[r]->()", ] {
7474 expect_bind_error(q, BindErrorKind::VariableAlreadyBound);
7475 }
7476 }
7477
7478 #[test]
7479 fn create_reference_without_new_shape_is_accepted() {
7480 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7483 let ast = parse("MATCH (a) CREATE (a)-[:R]->(b)").expect("parse");
7484 binder.bind(&ast).expect("valid reference must bind");
7485 }
7486
7487 #[test]
7488 fn aggregate_in_where_and_duplicate_alias_are_rejected() {
7489 expect_bind_error(
7490 "MATCH (a) WHERE count(a) > 10 RETURN a",
7491 BindErrorKind::InvalidArgument,
7492 );
7493 expect_bind_error("RETURN 1 AS a, 2 AS a", BindErrorKind::InvalidArgument);
7494 expect_bind_error(
7495 "WITH 1 AS a, 2 AS a RETURN a",
7496 BindErrorKind::InvalidArgument,
7497 );
7498 }
7499
7500 #[test]
7501 fn skip_limit_reject_negative_and_non_parameter_arguments() {
7502 for q in [
7503 "RETURN 1 SKIP -1",
7504 "RETURN 1 LIMIT -1",
7505 "RETURN 1 SKIP (-1)",
7506 "WITH 1 AS x SKIP -1 RETURN x",
7507 "WITH 1 AS x, count(*) AS c SKIP -1 RETURN x, c",
7508 "MATCH (n) RETURN n SKIP n.count",
7509 "MATCH (n) RETURN n LIMIT rand()",
7510 "MATCH (n) WITH n SKIP n.count RETURN n",
7511 "MATCH (n) WITH n, count(*) AS c LIMIT rand() RETURN n, c",
7512 ] {
7513 expect_bind_error(q, BindErrorKind::InvalidArgument);
7514 }
7515 }
7516
7517 #[test]
7518 fn skip_limit_accept_non_negative_integer_constants_and_parameters() {
7519 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7520 for q in [
7521 "RETURN 1 SKIP 0 LIMIT 1",
7522 "WITH 1 AS x SKIP (1) LIMIT 2 RETURN x",
7523 "WITH 1 AS x, count(*) AS c SKIP 0 LIMIT 1 RETURN x, c",
7524 "RETURN 1 SKIP $s LIMIT $l",
7525 "WITH 1 AS x SKIP ($s) LIMIT ($l) RETURN x",
7526 ] {
7527 let ast = parse(q).expect("parse");
7528 binder
7529 .bind(&ast)
7530 .unwrap_or_else(|e| panic!("expected clean bind for {q}, got {e:?}"));
7531 }
7532 }
7533
7534 #[test]
7535 fn overflowing_float_literal_is_rejected_at_bind() {
7536 expect_bind_error("RETURN 1.34E999", BindErrorKind::InvalidArgument);
7537 }
7538
7539 #[test]
7540 fn matched_var_in_create_is_a_reference_not_a_duplicate_mint() {
7541 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7545 let ast = parse("MATCH (a:Person) CREATE (a)-[:KNOWS]->(b:Person)").expect("parse");
7546 let plan = binder.bind(&ast).expect("bind");
7547 let create = plan
7548 .ops
7549 .iter()
7550 .find_map(|op| match op {
7551 GraphOp::Create { pattern } => Some(pattern),
7552 _ => None,
7553 })
7554 .expect("Create op");
7555
7556 let a_var = create.edges[0].src;
7558 let b_var = create.edges[0].dst;
7559 let a_specs: Vec<_> = create.nodes.iter().filter(|n| n.var == a_var).collect();
7560 assert_eq!(a_specs.len(), 1, "exactly one spec for the matched var `a`");
7561 assert!(a_specs[0].is_reference, "matched `a` must be a reference");
7562 let b_spec = create
7563 .nodes
7564 .iter()
7565 .find(|n| n.var == b_var)
7566 .expect("spec for new `b`");
7567 assert!(!b_spec.is_reference, "CREATE-introduced `b` must be a mint");
7568 }
7569
7570 #[test]
7571 fn delete_clause_lowers_to_delete_op() {
7572 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7574 let ast = parse("MATCH (p:Person) DELETE p").unwrap();
7575 let plan = binder.bind(&ast).expect("DELETE binds");
7576 let delete = plan
7577 .ops
7578 .iter()
7579 .find_map(|op| match op {
7580 GraphOp::Delete { vars, detach, .. } => Some((vars.clone(), *detach)),
7581 _ => None,
7582 })
7583 .expect("a GraphOp::Delete op");
7584 assert_eq!(delete.0.len(), 1, "one target var");
7585 assert!(!delete.1, "plain DELETE is not DETACH");
7586 }
7587
7588 #[test]
7589 fn detach_delete_sets_detach_flag() {
7590 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7591 let ast = parse("MATCH (p:Person) DETACH DELETE p").unwrap();
7592 let plan = binder.bind(&ast).expect("DETACH DELETE binds");
7593 assert!(
7594 plan.ops
7595 .iter()
7596 .any(|op| matches!(op, GraphOp::Delete { detach: true, .. })),
7597 "DETACH DELETE must set detach=true, got {:?}",
7598 plan.ops
7599 );
7600 }
7601
7602 #[test]
7603 fn delete_property_target_lowers_to_runtime_expression() {
7604 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7605 let ast = parse("MATCH (p:Person) DELETE p.name").unwrap();
7606 let plan = binder
7607 .bind(&ast)
7608 .expect("property value binds for runtime typing");
7609 assert!(matches!(
7610 plan.ops.last(),
7611 Some(GraphOp::Delete { vars, exprs, .. }) if vars.is_empty() && exprs.len() == 1
7612 ));
7613 }
7614
7615 #[test]
7616 fn delete_scalar_expression_is_rejected() {
7617 expect_bind_error("MATCH () DELETE 1 + 1", BindErrorKind::InvalidDeleteTarget);
7618 }
7619
7620 #[test]
7621 fn delete_undeclared_variable_is_rejected() {
7622 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7623 let ast = parse("MATCH (p:Person) DELETE q").unwrap();
7624 let errors = binder
7625 .bind(&ast)
7626 .expect_err("DELETE of an unbound var must be rejected");
7627 assert!(
7628 errors
7629 .iter()
7630 .any(|e| e.kind == BindErrorKind::UndeclaredVariable),
7631 "expected UndeclaredVariable, got {errors:?}"
7632 );
7633 }
7634
7635 #[test]
7636 fn set_property_clause_lowers_to_set_op() {
7637 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7638 let ast = parse("MATCH (p:Person) SET p.age = 30").unwrap();
7639 let plan = binder.bind(&ast).expect("SET p.age = 30 must lower");
7640 let set = plan
7641 .ops
7642 .iter()
7643 .find_map(|op| match op {
7644 GraphOp::Set { items, .. } => Some(items),
7645 _ => None,
7646 })
7647 .expect("expected a GraphOp::Set");
7648 assert_eq!(set.len(), 1);
7649 assert_eq!(set[0].prop_name, "age");
7650 }
7651
7652 #[test]
7653 fn set_runtime_expr_value_lowers_to_non_literal() {
7654 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7657 let ast = parse("MATCH (p:Person) SET p.age = p.age + 1").unwrap();
7658 let plan = binder.bind(&ast).expect("SET p.age = p.age + 1 must lower");
7659 let items = plan
7660 .ops
7661 .iter()
7662 .find_map(|op| match op {
7663 GraphOp::Set { items, .. } => Some(items),
7664 _ => None,
7665 })
7666 .expect("expected a GraphOp::Set");
7667 let value = plan.exprs.get(items[0].value);
7668 assert!(
7669 matches!(value, IrExpr::BinaryOp { .. }),
7670 "expected a BinaryOp value expr, got {value:?}"
7671 );
7672 }
7673
7674 #[test]
7675 fn set_multiple_items_lower_to_one_op() {
7676 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7677 let ast = parse("MATCH (p:Person) SET p.age = 30, p.name = 'Al'").unwrap();
7678 let plan = binder.bind(&ast).expect("multi-item SET must lower");
7679 let count = plan
7680 .ops
7681 .iter()
7682 .filter(|op| matches!(op, GraphOp::Set { .. }))
7683 .count();
7684 assert_eq!(count, 1, "expected exactly one GraphOp::Set");
7685 }
7686
7687 #[test]
7688 fn set_labels_lower_to_resolved_label_item() {
7689 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7690 let ast = parse("MATCH (p:Person) SET p:Admin:Staff").unwrap();
7691 let plan = binder.bind(&ast).expect("SET labels must lower");
7692 let labels = plan.ops.iter().find_map(|op| match op {
7693 GraphOp::Set { label_items, .. } => Some(label_items),
7694 _ => None,
7695 });
7696 let labels = labels.expect("SET label items");
7697 assert_eq!(labels.len(), 1);
7698 assert_eq!(labels[0].labels.len(), 2);
7699 }
7700
7701 #[test]
7702 fn set_property_merge_lowers_to_map_item() {
7703 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7704 let ast = parse("MATCH (p:Person) SET p += {age: 30}").unwrap();
7705 let plan = binder.bind(&ast).expect("SET += must lower");
7706 let map_items = plan
7707 .ops
7708 .iter()
7709 .find_map(|op| match op {
7710 GraphOp::Set { map_items, .. } => Some(map_items),
7711 _ => None,
7712 })
7713 .expect("expected SET op");
7714 assert_eq!(map_items.len(), 1);
7715 assert!(!map_items[0].replace);
7716 }
7717
7718 #[test]
7719 fn merge_lowers_real_pattern_specs() {
7720 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7721 let ast = parse(
7722 "MERGE (p:Person {name:'Alice'}) \
7723 ON CREATE SET p.created = 1, p:New \
7724 ON MATCH SET p += {seen:true}",
7725 )
7726 .unwrap();
7727 let plan = binder.bind(&ast).expect("MERGE binds");
7728 let (pattern, on_create, on_match) = plan
7729 .ops
7730 .iter()
7731 .find_map(|op| match op {
7732 GraphOp::Merge {
7733 pattern,
7734 on_create,
7735 on_match,
7736 } => Some((pattern, on_create, on_match)),
7737 _ => None,
7738 })
7739 .expect("MERGE op");
7740 assert_eq!(pattern.nodes.len(), 1);
7741 assert!(pattern.nodes[0].properties.is_some());
7742 assert_eq!(on_create.len(), 2);
7743 assert_eq!(on_match.len(), 1);
7744 }
7745
7746 #[test]
7747 fn set_undeclared_variable_rejected() {
7748 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7749 let ast = parse("MATCH (p:Person) SET q.age = 30").unwrap();
7750 let errors = binder.bind(&ast).expect_err("SET on unbound var must fail");
7751 assert!(
7752 errors
7753 .iter()
7754 .any(|e| e.kind == BindErrorKind::UndeclaredVariable),
7755 "expected UndeclaredVariable, got {errors:?}"
7756 );
7757 }
7758
7759 #[test]
7760 fn remove_property_clause_lowers_to_remove_op() {
7761 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7762 let ast = parse("MATCH (p:Person) REMOVE p.age").unwrap();
7763 let plan = binder.bind(&ast).expect("REMOVE p.age must lower");
7764 let items = plan
7765 .ops
7766 .iter()
7767 .find_map(|op| match op {
7768 GraphOp::Remove { items, .. } => Some(items),
7769 _ => None,
7770 })
7771 .expect("expected a GraphOp::Remove");
7772 assert_eq!(items.len(), 1);
7773 assert_eq!(items[0].prop_name, "age");
7774 }
7775
7776 #[test]
7777 fn remove_labels_lower_to_resolved_label_item() {
7778 let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7779 let ast = parse("MATCH (p:Person) REMOVE p:Admin:Staff").unwrap();
7780 let plan = binder.bind(&ast).expect("REMOVE labels must lower");
7781 let labels = plan.ops.iter().find_map(|op| match op {
7782 GraphOp::Remove { label_items, .. } => Some(label_items),
7783 _ => None,
7784 });
7785 let labels = labels.expect("REMOVE label items");
7786 assert_eq!(labels.len(), 1);
7787 assert_eq!(labels[0].labels.len(), 2);
7788 }
7789
7790 #[test]
7791 fn advisory_unknown_label_produces_warnings() {
7792 let catalog = Arc::new(Mutex::new(RuntimeCatalog::new()));
7793 let binder = Binder::new(None, Arc::clone(&catalog), OntologyMode::Advisory);
7794 let ast = parse("MATCH (a:UnknownLabel) RETURN a").unwrap();
7795
7796 let mut state = BinderState {
7797 vars: HashMap::new(),
7798 path_vars: HashMap::new(),
7799 node_vars: HashMap::new(),
7800 edge_vars: HashMap::new(),
7801 edge_rel_names: HashMap::new(),
7802 scalar_list_edges: HashSet::new(),
7803 var_kinds: HashMap::new(),
7804 next_var: 0,
7805 builder: GraphPlan::builder("openCypher").ontology_mode(OntologyMode::Advisory),
7806 errors: Vec::new(),
7807 warnings: Vec::new(),
7808 captured_pattern_comprehensions: None,
7809 existential_depth: 0,
7810 standalone_call: false,
7811 };
7812 for clause in &ast.clauses {
7813 binder.lower_clause(clause, &mut state);
7814 }
7815
7816 assert!(
7817 !state.warnings.is_empty(),
7818 "advisory mode should produce warnings"
7819 );
7820 assert!(
7821 state.errors.is_empty(),
7822 "advisory mode should not produce errors"
7823 );
7824 assert!(
7825 state
7826 .warnings
7827 .iter()
7828 .any(|w| w.kind == BindErrorKind::UnknownLabel)
7829 );
7830 }
7831
7832 #[test]
7833 fn strict_unknown_label_produces_error() {
7834 let (binder, _) = make_binder(OntologyMode::Strict);
7835 let ast = parse("MATCH (a:UnknownLabel) RETURN a").unwrap();
7836 let result = binder.bind(&ast);
7837 assert!(result.is_err());
7838 let errors = result.unwrap_err();
7839 assert!(errors.iter().any(|e| e.kind == BindErrorKind::UnknownLabel));
7840 }
7841
7842 #[test]
7843 fn strict_mode_allows_durable_identity_fields() {
7844 let (binder, _) = make_binder(OntologyMode::Strict);
7845 let ast = parse(
7846 "MATCH (source)-[relationship]->(target) RETURN source.node_uuid, relationship.edge_uuid, target.node_uuid",
7847 )
7848 .unwrap();
7849
7850 binder
7851 .bind(&ast)
7852 .expect("structural identity fields are not ontology properties");
7853 }
7854
7855 #[test]
7856 fn strict_properties_emit_owner_scoped_runtime_ids() {
7857 let (binder, catalog) = strict_property_binder(false);
7858 let ast = parse("MATCH (host:Host)-[connection:R]->() RETURN host.direct, host.inherited, host.shared, connection.weight, connection.shared").unwrap();
7859 let plan = binder.bind(&ast).unwrap();
7860 let ids = plan_property_ids(&plan);
7861 let catalog = catalog.lock().unwrap();
7862 let names = ids
7863 .iter()
7864 .map(|id| catalog.property_name(crate::RuntimePropId(id.0)).unwrap())
7865 .collect::<Vec<_>>();
7866 assert_eq!(names, ["direct", "inherited", "shared", "weight", "shared"]);
7867 assert!(ids.iter().all(|id| id.0 > 0));
7868 assert_ne!(ids[2], ids[4]);
7869 }
7870
7871 #[test]
7872 fn strict_property_writes_and_inline_filters_share_owner_rules() {
7873 let (binder, catalog) = strict_property_binder(false);
7874 let ast = parse("MATCH (host:Host)-[connection:R]->() SET host.direct = 'ready', host.inherited = 'asset', connection.weight = 7 REMOVE host.direct, connection.weight").unwrap();
7875 let plan = binder.bind(&ast).unwrap();
7876 let catalog = catalog.lock().unwrap();
7877 let write_ids = plan
7878 .ops
7879 .iter()
7880 .flat_map(|op| match op {
7881 GraphOp::Set { items, .. } => items.iter().map(|item| item.prop).collect(),
7882 GraphOp::Remove { items, .. } => items.iter().map(|item| item.prop).collect(),
7883 _ => Vec::new(),
7884 })
7885 .collect::<Vec<_>>();
7886 assert_eq!(write_ids.len(), 5);
7887 assert!(
7888 write_ids
7889 .iter()
7890 .all(|id| catalog.property_name(crate::RuntimePropId(id.0)).is_some())
7891 );
7892 drop(catalog);
7893
7894 for query in [
7895 "MATCH (:Host {direct: 'ready'})-[:R {weight: 7}]->() RETURN 1",
7896 "MATCH (:Host {inherited: 'asset'})-[:R*1..2 {weight: 7}]->() RETURN 1",
7897 ] {
7898 binder
7899 .bind(&parse(query).unwrap())
7900 .expect("anonymous fixed and variable-length owners should bind");
7901 }
7902 }
7903
7904 #[test]
7905 fn strict_properties_reject_wrong_owner_and_ambiguity_with_exact_spans() {
7906 let (binder, _) = strict_property_binder(false);
7907 for (query, span) in [
7908 ("MATCH (host:Host) RETURN host.weight", "host.weight"),
7909 ("MATCH ()-[r:R]->() RETURN r.direct", "r.direct"),
7910 ("MATCH (host:Host) RETURN host.missing", "host.missing"),
7911 (
7912 "MATCH (host:Host) WITH host AS forwarded RETURN forwarded.weight",
7913 "forwarded.weight",
7914 ),
7915 (
7916 "MATCH ()-[r:R]->() WITH r AS forwarded RETURN forwarded.direct",
7917 "forwarded.direct",
7918 ),
7919 ] {
7920 property_error(&binder, query, BindErrorKind::UnknownProperty, span);
7921 }
7922 for (query, span) in [
7923 ("MATCH (:Host {weight: 7}) RETURN 1", "weight"),
7924 ("MATCH ()-[:R {missing: 7}]->() RETURN 1", "missing"),
7925 ] {
7926 property_error(&binder, query, BindErrorKind::UnknownProperty, span);
7927 }
7928 property_error(
7929 &binder,
7930 "MATCH ()-[*1..2 {weight: 7}]->() RETURN 1",
7931 BindErrorKind::AmbiguousProperty,
7932 "weight",
7933 );
7934
7935 let (binder, _) = strict_property_binder(true);
7936 let query = "MATCH (host:Host) RETURN host.inherited";
7937 let error = property_error(
7938 &binder,
7939 query,
7940 BindErrorKind::AmbiguousProperty,
7941 "host.inherited",
7942 );
7943 assert!(error.message.contains("Asset, Host"));
7944 property_error(
7945 &binder,
7946 "MATCH (:Host {inherited: 7}) RETURN 1",
7947 BindErrorKind::AmbiguousProperty,
7948 "inherited",
7949 );
7950 }
7951
7952 #[test]
7953 fn strict_mode_rejects_mismatched_durable_identity_fields() {
7954 for query in [
7955 "MATCH (node) RETURN node.edge_uuid",
7956 "MATCH ()-[relationship]->() RETURN relationship.node_uuid",
7957 ] {
7958 let (binder, _) = make_binder(OntologyMode::Strict);
7959 let errors = binder
7960 .bind(&parse(query).unwrap())
7961 .expect_err("identity fields must match the bound entity kind");
7962 assert!(errors.iter().any(|error| {
7963 error.kind == BindErrorKind::InvalidArgument
7964 && error.message.contains("valid only on")
7965 }));
7966 }
7967 }
7968
7969 #[test]
7970 fn structural_identity_fields_are_read_only() {
7971 for query in [
7972 "MATCH (node) SET node.node_uuid = 'replacement'",
7973 "MATCH (node) REMOVE node.node_uuid",
7974 "MATCH ()-[relationship]->() SET relationship.edge_uuid = 'replacement'",
7975 "MATCH ()-[relationship]->() REMOVE relationship.edge_uuid",
7976 ] {
7977 let (binder, _) = make_binder(OntologyMode::Strict);
7978 let errors = binder
7979 .bind(&parse(query).unwrap())
7980 .expect_err("identity fields must not be mutable");
7981 assert!(errors.iter().any(|error| {
7982 error.kind == BindErrorKind::InvalidArgument
7983 && error.message.contains("is read-only")
7984 }));
7985 }
7986 }
7987
7988 #[test]
7989 fn fixed_hop_with_known_relation_emits_expand() {
7990 use graphforge_ontology::{
7991 EntityTypeDef, OntologyCompiler, OntologyDoc, OntologyHandle, RelationTypeDef,
7992 SemanticFlags,
7993 };
7994
7995 let doc = OntologyDoc {
7996 ontology_id: "test".into(),
7997 version: "1.0".into(),
7998 entity_types: vec![
7999 EntityTypeDef {
8000 name: "Person".into(),
8001 r#abstract: false,
8002 parent: None,
8003 },
8004 EntityTypeDef {
8005 name: "Organization".into(),
8006 r#abstract: false,
8007 parent: None,
8008 },
8009 ],
8010 relation_types: vec![RelationTypeDef {
8011 name: "WORKS_AT".into(),
8012 src: "Person".into(),
8013 dst: "Organization".into(),
8014 inverse: None,
8015 semantic: SemanticFlags::default(),
8016 }],
8017 properties: vec![],
8018 constraints: vec![],
8019 migrations: vec![],
8020 };
8021 let runtime = OntologyCompiler::compile(&doc).unwrap();
8022 let handle = OntologyHandle::new(runtime);
8023 let catalog = Arc::new(Mutex::new(RuntimeCatalog::new()));
8024 let binder = Binder::new(Some(handle), catalog, OntologyMode::Strict);
8025
8026 let ast = parse("MATCH (a:Person)-[:WORKS_AT]->(b:Organization) RETURN a").unwrap();
8027 let plan = binder.bind(&ast).expect("known ontology should succeed");
8028
8029 let expand = plan.ops.iter().find_map(|op| match op {
8032 GraphOp::Expand {
8033 rel_ty,
8034 min_hops,
8035 max_hops,
8036 ..
8037 } => Some((rel_ty, *min_hops, *max_hops)),
8038 _ => None,
8039 });
8040 let (rel_ty, min_hops, max_hops) = expand.expect("fixed hop should emit Expand");
8041 assert!(
8042 rel_ty.is_some(),
8043 "WORKS_AT should resolve to a relation type"
8044 );
8045 assert_eq!((min_hops, max_hops), (1, Some(1)), "fixed hop is 1..1");
8046 assert!(
8047 !plan
8048 .ops
8049 .iter()
8050 .any(|op| matches!(op, GraphOp::TypedEdgeScan { .. } | GraphOp::EdgeScan { .. }))
8051 );
8052 }
8053
8054 #[test]
8055 fn wildcard_fixed_hop_emits_untyped_expand() {
8056 let (binder, _) = make_binder(OntologyMode::Exploratory);
8057 let ast = parse("MATCH (a)-[r]->(b) RETURN r").unwrap();
8058 let plan = binder.bind(&ast).expect("wildcard bind should succeed");
8059
8060 let expand = plan.ops.iter().find_map(|op| match op {
8063 GraphOp::Expand {
8064 rel_ty,
8065 min_hops,
8066 max_hops,
8067 ..
8068 } => Some((rel_ty, *min_hops, *max_hops)),
8069 _ => None,
8070 });
8071 let (rel_ty, min_hops, max_hops) = expand.expect("wildcard hop should emit Expand");
8072 assert!(rel_ty.is_none(), "wildcard hop has no relation type");
8073 assert_eq!((min_hops, max_hops), (1, Some(1)));
8074 assert!(
8075 !plan
8076 .ops
8077 .iter()
8078 .any(|op| matches!(op, GraphOp::TypedEdgeScan { .. } | GraphOp::EdgeScan { .. }))
8079 );
8080 }
8081
8082 #[test]
8083 fn undeclared_variable_in_return_is_error() {
8084 let (binder, _) = make_binder(OntologyMode::Exploratory);
8085 let ast = parse("MATCH (a:Person) RETURN x").unwrap();
8086 let result = binder.bind(&ast);
8087 assert!(result.is_err());
8088 let errors = result.unwrap_err();
8089 assert!(
8090 errors
8091 .iter()
8092 .any(|e| e.kind == BindErrorKind::UndeclaredVariable)
8093 );
8094 }
8095
8096 #[test]
8097 fn multi_error_collection_in_strict_mode() {
8098 let (binder, _) = make_binder(OntologyMode::Strict);
8099 let ast = parse("MATCH (a:LabelA)-[:REL_B]->(b:LabelC) RETURN a").unwrap();
8100 let result = binder.bind(&ast);
8101 assert!(result.is_err());
8102 let errors = result.unwrap_err();
8103 assert!(
8105 errors.len() >= 3,
8106 "expected ≥3 errors, got {}",
8107 errors.len()
8108 );
8109 }
8110
8111 #[test]
8112 fn where_clause_emits_filter_op() {
8113 let (binder, _) = make_binder(OntologyMode::Exploratory);
8114 let ast = parse("MATCH (a:Person) WHERE a.age > 30 RETURN a").unwrap();
8115 let plan = binder.bind(&ast).unwrap();
8116 assert!(
8117 plan.ops
8118 .iter()
8119 .any(|op| matches!(op, GraphOp::Filter { .. }))
8120 );
8121 }
8122
8123 #[test]
8124 fn inline_node_property_emits_filter() {
8125 let (binder, _) = make_binder(OntologyMode::Exploratory);
8127 let ast = parse("MATCH (a:Person {name:'Alice'}) RETURN a.name").unwrap();
8128 let plan = binder.bind(&ast).unwrap();
8129 assert!(
8130 plan.ops
8131 .iter()
8132 .any(|op| matches!(op, GraphOp::NodeScan { .. })),
8133 "scan present"
8134 );
8135 assert!(
8136 plan.ops
8137 .iter()
8138 .any(|op| matches!(op, GraphOp::Filter { .. })),
8139 "inline property must emit a Filter"
8140 );
8141 }
8142
8143 #[test]
8144 fn inline_multi_property_emits_single_filter() {
8145 let (binder, _) = make_binder(OntologyMode::Exploratory);
8147 let ast = parse("MATCH (a:Person {name:'Alice', age:30}) RETURN a.name").unwrap();
8148 let plan = binder.bind(&ast).unwrap();
8149 let filters = plan
8150 .ops
8151 .iter()
8152 .filter(|op| matches!(op, GraphOp::Filter { .. }))
8153 .count();
8154 assert_eq!(filters, 1, "multi-property map → one AND-ed Filter");
8155 }
8156
8157 #[test]
8158 fn node_without_inline_properties_emits_no_filter() {
8159 let (binder, _) = make_binder(OntologyMode::Exploratory);
8161 let ast = parse("MATCH (a:Person) RETURN a.name").unwrap();
8162 let plan = binder.bind(&ast).unwrap();
8163 assert!(
8164 !plan
8165 .ops
8166 .iter()
8167 .any(|op| matches!(op, GraphOp::Filter { .. })),
8168 "no inline properties → no Filter"
8169 );
8170 }
8171
8172 #[test]
8173 fn inline_rel_property_emits_filter() {
8174 let (binder, _) = make_binder(OntologyMode::Exploratory);
8177 let ast =
8178 parse("MATCH (a:Person)-[r:KNOWS {since:2020}]->(b:Person) RETURN b.name").unwrap();
8179 let plan = binder.bind(&ast).unwrap();
8180 assert!(
8181 plan.ops
8182 .iter()
8183 .any(|op| matches!(op, GraphOp::Expand { .. })),
8184 "expand present"
8185 );
8186 assert!(
8187 plan.ops
8188 .iter()
8189 .any(|op| matches!(op, GraphOp::Filter { .. })),
8190 "inline relationship property must emit a Filter"
8191 );
8192 }
8193
8194 #[test]
8195 fn inline_multi_rel_property_emits_single_filter() {
8196 let (binder, _) = make_binder(OntologyMode::Exploratory);
8198 let ast =
8199 parse("MATCH (a:Person)-[r:KNOWS {since:2020, weight:5}]->(b:Person) RETURN b.name")
8200 .unwrap();
8201 let plan = binder.bind(&ast).unwrap();
8202 let filters = plan
8203 .ops
8204 .iter()
8205 .filter(|op| matches!(op, GraphOp::Filter { .. }))
8206 .count();
8207 assert_eq!(filters, 1, "multi-property rel map → one AND-ed Filter");
8208 }
8209
8210 #[test]
8211 fn rel_without_inline_properties_emits_no_filter() {
8212 let (binder, _) = make_binder(OntologyMode::Exploratory);
8214 let ast = parse("MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN b.name").unwrap();
8215 let plan = binder.bind(&ast).unwrap();
8216 assert!(
8217 !plan
8218 .ops
8219 .iter()
8220 .any(|op| matches!(op, GraphOp::Filter { .. })),
8221 "no inline rel properties → no Filter"
8222 );
8223 }
8224
8225 #[test]
8226 fn inline_property_on_var_length_rel_emits_all_filter() {
8227 let (binder, _) = make_binder(OntologyMode::Exploratory);
8228 let ast = parse("MATCH (a:Person)-[r:KNOWS*1..2 {since:2020}]->(b:Person) RETURN b.name")
8229 .unwrap();
8230 let plan = binder.bind(&ast).unwrap();
8231 let predicate = plan.ops.iter().find_map(|op| match op {
8232 GraphOp::Filter { predicate } => Some(*predicate),
8233 _ => None,
8234 });
8235 assert!(
8236 predicate.is_some_and(|predicate| matches!(
8237 plan.exprs.get(predicate),
8238 IrExpr::Quantifier {
8239 kind: graphforge_ast::QuantifierKind::All,
8240 ..
8241 }
8242 )),
8243 "variable-length relationship properties require an all() filter"
8244 );
8245 }
8246
8247 #[test]
8248 fn return_clause_emits_project_op() {
8249 let (binder, _) = make_binder(OntologyMode::Exploratory);
8250 let ast = parse("MATCH (a:Person) RETURN a.name").unwrap();
8251 let plan = binder.bind(&ast).unwrap();
8252 assert!(
8253 plan.ops
8254 .iter()
8255 .any(|op| matches!(op, GraphOp::Project { .. }))
8256 );
8257 }
8258
8259 #[test]
8260 fn return_with_count_emits_aggregate() {
8261 let (binder, _) = make_binder(OntologyMode::Exploratory);
8264 let ast = parse("MATCH (n:Person) RETURN count(n) AS total").unwrap();
8265 let plan = binder.bind(&ast).unwrap();
8266 let agg = plan.ops.iter().find_map(|op| match op {
8267 GraphOp::Aggregate { group_by, aggs, .. } => Some((group_by, aggs)),
8268 _ => None,
8269 });
8270 let (group_by, aggs) = agg.expect("count should emit an Aggregate");
8271 assert!(
8272 group_by.is_empty(),
8273 "no non-aggregate items → no group keys"
8274 );
8275 assert_eq!(aggs.len(), 1);
8276 assert_eq!(aggs[0].func, AggFunc::Count);
8277 assert_eq!(aggs[0].alias, "total");
8278 assert!(
8279 !plan
8280 .ops
8281 .iter()
8282 .any(|op| matches!(op, GraphOp::Project { .. })),
8283 "an aggregate RETURN must not also emit a Project"
8284 );
8285 }
8286
8287 #[test]
8288 fn return_with_grouping_keys_and_aggregate() {
8289 let (binder, _) = make_binder(OntologyMode::Exploratory);
8291 let ast = parse("MATCH (n:Person) RETURN n.name AS name, count(n) AS total").unwrap();
8292 let plan = binder.bind(&ast).unwrap();
8293 let (group_by, aggs) = plan
8294 .ops
8295 .iter()
8296 .find_map(|op| match op {
8297 GraphOp::Aggregate { group_by, aggs, .. } => Some((group_by, aggs)),
8298 _ => None,
8299 })
8300 .expect("Aggregate");
8301 assert_eq!(group_by.len(), 1, "n.name is the group key");
8302 assert_eq!(aggs.len(), 1);
8303 }
8304
8305 fn bind_errors(query: &str) -> Vec<BindError> {
8311 let (binder, _) = make_binder(OntologyMode::Exploratory);
8312 let ast = parse(query).unwrap();
8313 binder
8314 .bind(&ast)
8315 .expect_err("bind should fail for this query")
8316 }
8317
8318 #[test]
8319 fn path_var_functions_bind_with_anonymous_edge() {
8320 let (binder, _) = make_binder(OntologyMode::Exploratory);
8323 let ast = parse(
8324 "MATCH p = (a:Person)-[*1..2]->(b) \
8325 RETURN nodes(p) AS ns, relationships(p) AS rs, length(p) AS l",
8326 )
8327 .unwrap();
8328 binder.bind(&ast).expect("path functions should bind");
8329 }
8330
8331 #[test]
8332 fn path_function_on_non_path_falls_through() {
8333 let (binder, _) = make_binder(OntologyMode::Exploratory);
8337 let ast =
8338 parse("MATCH (a)-[r:KNOWS*1..2]->(b) RETURN length(r) AS l, nodes(a) AS ns").unwrap();
8339 let plan = binder.bind(&ast).expect("bind succeeds");
8340 let names: Vec<&str> = (0..plan.exprs.len())
8341 .filter_map(
8342 |i| match plan.exprs.get(ExprId(u32::try_from(i).unwrap())) {
8343 IrExpr::FunctionCall { name, .. } => Some(name.as_str()),
8344 _ => None,
8345 },
8346 )
8347 .collect();
8348 assert!(names.contains(&"length"), "generic length kept: {names:?}");
8349 assert!(names.contains(&"nodes"), "generic nodes kept: {names:?}");
8350 assert!(
8351 !names.contains(&"_path_nodes"),
8352 "no path rewrite without a path var: {names:?}"
8353 );
8354 }
8355
8356 #[test]
8357 fn bare_path_var_binds_to_path_struct() {
8358 let (binder, _) = make_binder(OntologyMode::Exploratory);
8360 let ast = parse("MATCH p = (a)-[:KNOWS*1..2]->(b) RETURN p").unwrap();
8361 let plan = binder.bind(&ast).expect("bare path value binds");
8362 let has_struct = (0..plan.exprs.len()).any(|i| {
8363 matches!(
8364 plan.exprs.get(ExprId(u32::try_from(i).unwrap())),
8365 IrExpr::FunctionCall { name, .. } if name == "_path_struct"
8366 )
8367 });
8368 assert!(has_struct, "RETURN p must rewrite to _path_struct");
8369 }
8370
8371 #[test]
8372 fn multi_segment_path_var_composes_path_functions() {
8373 let (binder, _) = make_binder(OntologyMode::Exploratory);
8374 let ast = parse(
8375 "MATCH p = (a)-[:KNOWS]->(b)-[:KNOWS]->(c) \
8376 RETURN length(p), nodes(p), relationships(p)",
8377 )
8378 .unwrap();
8379 let plan = binder.bind(&ast).expect("multi-segment path binds");
8380 let add_count = (0..plan.exprs.len())
8381 .map(|index| plan.exprs.get(ExprId(index as u32)))
8382 .filter(|expr| {
8383 matches!(
8384 expr,
8385 IrExpr::BinaryOp {
8386 op: BinaryOpKind::Add,
8387 ..
8388 }
8389 )
8390 })
8391 .count();
8392 assert!(add_count >= 3, "each path function composes its segments");
8393 }
8394
8395 #[test]
8396 fn fixed_segment_path_functions_bind() {
8397 let (binder, _) = make_binder(OntologyMode::Exploratory);
8401 let ast = parse(
8402 "MATCH p = (a)-[:KNOWS]->(b) \
8403 RETURN length(p) AS l, nodes(p) AS ns, relationships(p) AS rs",
8404 )
8405 .unwrap();
8406 let plan = binder.bind(&ast).expect("fixed path functions bind");
8407 let names: Vec<&str> = (0..plan.exprs.len())
8408 .filter_map(
8409 |i| match plan.exprs.get(ExprId(u32::try_from(i).unwrap())) {
8410 IrExpr::FunctionCall { name, .. } => Some(name.as_str()),
8411 _ => None,
8412 },
8413 )
8414 .collect();
8415 for expected in [
8416 "_path_fixed_length",
8417 "_node_struct_list",
8418 "_rel_struct_list",
8419 ] {
8420 assert!(names.contains(&expected), "missing {expected}: {names:?}");
8421 }
8422 }
8423
8424 #[test]
8425 fn explicit_one_hop_routes_as_fixed_segment() {
8426 let (binder, _) = make_binder(OntologyMode::Exploratory);
8429 let ast = parse("MATCH p = (a)-[:KNOWS*1..1]->(b) RETURN length(p) AS l").unwrap();
8430 let plan = binder.bind(&ast).expect("explicit 1..1 binds as fixed");
8431 let has_fixed = (0..plan.exprs.len()).any(|i| {
8432 matches!(
8433 plan.exprs.get(ExprId(u32::try_from(i).unwrap())),
8434 IrExpr::FunctionCall { name, .. } if name == "_path_fixed_length"
8435 )
8436 });
8437 assert!(
8438 has_fixed,
8439 "explicit *1..1 must use the fixed-segment rewrite"
8440 );
8441 }
8442
8443 #[test]
8444 fn path_var_name_conflict_is_rejected() {
8445 let errors = bind_errors("MATCH p = (p)-[:KNOWS*1..2]->(b) RETURN length(p)");
8446 assert!(
8447 errors
8448 .iter()
8449 .any(|e| matches!(e.kind, BindErrorKind::DuplicateVariable)),
8450 "expected DuplicateVariable, got {errors:?}"
8451 );
8452 }
8453
8454 #[test]
8455 fn optional_match_path_var_functions_bind() {
8456 let (binder, _) = make_binder(OntologyMode::Exploratory);
8459 let ast = parse(
8460 "MATCH (a:Person) \
8461 OPTIONAL MATCH p = (a)-[:KNOWS*1..2]->(b) \
8462 RETURN nodes(p) AS ns, length(p) AS l",
8463 )
8464 .unwrap();
8465 binder.bind(&ast).expect("optional path functions bind");
8466 }
8467
8468 fn procedure_binder() -> Binder {
8469 let (binder, _) = make_binder(OntologyMode::Exploratory);
8470 let procedure = ProcedureDefinition {
8471 name: "test.proc".into(),
8472 inputs: vec![ProcedureField {
8473 name: "in".into(),
8474 type_name: "INTEGER".into(),
8475 nullable: true,
8476 }],
8477 outputs: vec![ProcedureField {
8478 name: "out".into(),
8479 type_name: "INTEGER".into(),
8480 nullable: true,
8481 }],
8482 rows: vec![vec![IrLiteral::Int(1), IrLiteral::Int(2)]],
8483 };
8484 binder.with_procedures(Arc::new(ProcedureRegistry::from([(
8485 procedure.name.clone(),
8486 procedure,
8487 )])))
8488 }
8489
8490 #[test]
8491 fn call_binds_explicit_args_and_yield_alias() {
8492 let plan = procedure_binder()
8493 .bind(&parse("CALL test.proc(1) YIELD out AS value RETURN value").unwrap())
8494 .expect("CALL should bind");
8495 let GraphOp::Call { args, yields, .. } = &plan.ops[0] else {
8496 panic!("expected CALL op")
8497 };
8498 assert_eq!(args.len(), 1);
8499 assert_eq!(yields[0].field, "out");
8500 assert_eq!(yields[0].alias, "value");
8501 }
8502
8503 #[test]
8504 fn call_without_parentheses_uses_implicit_parameters() {
8505 let plan = procedure_binder()
8506 .bind(&parse("CALL test.proc YIELD out").unwrap())
8507 .expect("implicit CALL should bind");
8508 let GraphOp::Call { args, .. } = &plan.ops[0] else {
8509 panic!("expected CALL op")
8510 };
8511 assert!(matches!(plan.exprs.get(args[0]), IrExpr::Parameter(name) if name == "in"));
8512 }
8513
8514 #[test]
8515 fn call_rejects_unknown_procedure_argument_count_and_yield() {
8516 for (query, message) in [
8517 ("CALL missing.proc()", "ProcedureNotFound"),
8518 ("CALL test.proc()", "InvalidNumberOfArguments"),
8519 ("CALL test.proc(1) YIELD missing", "ProcedureOutputNotFound"),
8520 ] {
8521 let errors = procedure_binder()
8522 .bind(&parse(query).unwrap())
8523 .expect_err("CALL should fail");
8524 assert!(
8525 errors.iter().any(|error| error.message.contains(message)),
8526 "expected {message}, got {errors:?}"
8527 );
8528 }
8529 }
8530
8531 #[test]
8532 fn union_binds_branch_plans_and_mode() {
8533 let (binder, _) = make_binder(OntologyMode::Exploratory);
8534 let plan = binder
8535 .bind(&parse("RETURN 1 AS x UNION ALL RETURN 2 AS x").unwrap())
8536 .expect("UNION ALL binds");
8537 let [GraphOp::Union { all, inputs }] = plan.ops.as_slice() else {
8538 panic!("expected one UNION op")
8539 };
8540 assert!(*all);
8541 assert_eq!(inputs.len(), 2);
8542 assert!(inputs.iter().all(|branch| !branch.ops.is_empty()));
8543 }
8544
8545 #[test]
8546 fn union_rejects_mixed_modes_and_different_columns() {
8547 let (binder, _) = make_binder(OntologyMode::Exploratory);
8548 for (query, message) in [
8549 (
8550 "RETURN 1 AS x UNION RETURN 2 AS x UNION ALL RETURN 3 AS x",
8551 "InvalidCombinationOfUnion",
8552 ),
8553 (
8554 "RETURN 1 AS x UNION RETURN 2 AS y",
8555 "DifferentColumnsInUnion",
8556 ),
8557 ("CREATE (:A) UNION CREATE (:B)", "DifferentColumnsInUnion"),
8558 ] {
8559 let errors = binder
8560 .bind(&parse(query).unwrap())
8561 .expect_err("UNION should fail");
8562 assert!(errors.iter().any(|error| error.message.contains(message)));
8563 }
8564 }
8565
8566 #[test]
8567 fn return_wildcard_requires_a_variable_in_scope() {
8568 let (binder, _) = make_binder(OntologyMode::Exploratory);
8569 let errors = binder
8570 .bind(&parse("RETURN *").unwrap())
8571 .expect_err("empty-scope RETURN wildcard must fail");
8572 assert!(errors.iter().any(|error| {
8573 error
8574 .message
8575 .contains("wildcard requires at least one variable")
8576 }));
8577 assert_ne!(errors[0].span, Span::default());
8578 }
8579
8580 #[test]
8581 fn with_wildcard_preserves_an_empty_named_scope() {
8582 let (binder, _) = make_binder(OntologyMode::Exploratory);
8583 let plan = binder
8584 .bind(&parse("CREATE () WITH * CREATE ()").unwrap())
8585 .expect("empty-scope WITH wildcard should preserve pipeline rows");
8586 assert_eq!(plan.ops.len(), 3);
8587 assert!(matches!(&plan.ops[1], GraphOp::With { items, .. } if items.is_empty()));
8588 }
8589
8590 #[test]
8591 fn typed_uuid_parameters_are_identity_only_across_expression_surfaces() {
8592 let params = HashMap::from([("id".into(), IrLiteral::Uuid([0x55; 16]))]);
8593 for query in [
8594 "MATCH (n:Person) WHERE NOT (n.node_uuid = $id) RETURN n",
8595 "MATCH (n:Person) WHERE n.node_uuid <> $id RETURN n",
8596 "MATCH (n:Person) WHERE n.node_uuid > $id RETURN n",
8597 "MATCH (n:Person) WHERE n.node_uuid IN [$id] RETURN n",
8598 "RETURN $id",
8599 "RETURN toString($id)",
8600 "RETURN size([$id])",
8601 "RETURN [$id]",
8602 "RETURN 1 AS value SKIP $id",
8603 "RETURN 1 AS value LIMIT $id",
8604 "MATCH (n:Person) WHERE (($id = n.name) OR false) RETURN n",
8605 "MATCH (n:Person) WHERE n.name IN [$id] RETURN n",
8606 "MATCH (n:Person) RETURN n.name = $id AS bad",
8607 "MATCH (n:Person) WITH n, n.name = $id AS bad RETURN bad",
8608 "MATCH (n:Person) RETURN n.name AS name ORDER BY n.name = $id",
8609 "MATCH (n:Person) UNWIND [n.name = $id] AS bad RETURN bad",
8610 "MATCH (n:Person {probe: n.name = $id}) RETURN n",
8611 "MATCH (n:Person) SET n.probe = (n.name = $id) RETURN n",
8612 "MATCH (n:Person) MERGE (m:Other {probe: n.name = $id}) RETURN m",
8613 "MATCH (n:Person) DELETE (n.name = $id)",
8614 "MATCH (n:Person)-[r:KNOWS]->() WHERE r.node_uuid = $id RETURN r",
8615 "MATCH (n:Person)-[r:KNOWS]->() WHERE n.edge_uuid = $id RETURN n",
8616 ] {
8617 let (binder, _) = make_binder(OntologyMode::Exploratory);
8618 let errors = binder
8619 .with_parameter_literals(¶ms)
8620 .bind(&parse(query).unwrap())
8621 .expect_err("typed UUID must not reach a non-identity expression");
8622 assert!(
8623 errors.iter().any(|error| {
8624 error.kind == BindErrorKind::InvalidArgument
8625 && error.message
8626 == "typed UUID parameter `$id` is only supported as a direct node_uuid or edge_uuid identity equality predicate"
8627 }),
8628 "query={query} errors={errors:?}"
8629 );
8630 }
8631
8632 let errors = procedure_binder()
8633 .with_parameter_literals(¶ms)
8634 .bind(
8635 &parse("MATCH (n:Person) CALL test.proc(n.name = $id) YIELD out RETURN out")
8636 .unwrap(),
8637 )
8638 .expect_err("CALL argument must enforce typed UUID identity semantics");
8639 assert!(
8640 errors.iter().any(|error| {
8641 error.kind == BindErrorKind::InvalidArgument
8642 && error.message.starts_with("typed UUID parameter `$id`")
8643 }),
8644 "errors={errors:?}"
8645 );
8646 }
8647
8648 #[test]
8649 fn typed_uuid_parameters_allow_only_kind_correct_identity_fields() {
8650 let params = HashMap::from([("id".into(), IrLiteral::Uuid([0x55; 16]))]);
8651 for query in [
8652 "MATCH (n:Person) WHERE n.node_uuid = $id RETURN n.node_uuid",
8653 "MATCH (n:Person) WHERE $id = n.node_uuid RETURN n.node_uuid",
8654 "MATCH ()-[r:KNOWS]->() WHERE r.edge_uuid = $id RETURN r.edge_uuid",
8655 "MATCH ()-[r:KNOWS]->() WHERE $id = r.edge_uuid RETURN r.edge_uuid",
8656 ] {
8657 let (binder, _) = make_binder(OntologyMode::Exploratory);
8658 binder
8659 .with_parameter_literals(¶ms)
8660 .bind(&parse(query).unwrap())
8661 .unwrap_or_else(|errors| panic!("query={query} errors={errors:?}"));
8662 }
8663
8664 for nested in [
8665 IrLiteral::List(vec![IrLiteral::Uuid([0x55; 16])]),
8666 IrLiteral::Map(vec![(
8667 "nested".into(),
8668 IrLiteral::List(vec![IrLiteral::Uuid([0x55; 16])]),
8669 )]),
8670 ] {
8671 let (binder, _) = make_binder(OntologyMode::Exploratory);
8672 let errors = binder
8673 .with_parameter_literals(&HashMap::from([("id".into(), nested)]))
8674 .bind(
8675 &parse("MATCH (n:Person) WHERE n.node_uuid = $id RETURN n.node_uuid").unwrap(),
8676 )
8677 .expect_err("containers containing UUID values are never identity scalars");
8678 assert!(errors.iter().any(|error| {
8679 error.kind == BindErrorKind::InvalidArgument
8680 && error.message.starts_with("typed UUID parameter `$id`")
8681 }));
8682 }
8683 }
8684
8685 #[test]
8686 fn nested_aggregate_rewrite_covers_scalar_container_shapes() {
8687 let cases = [
8688 "MATCH (n:Person) RETURN count(*) + 1 AS value",
8689 "MATCH (n:Person) RETURN -count(*) AS value",
8690 "MATCH (n:Person) RETURN (count(*)) AS value",
8691 "MATCH (n:Person) RETURN coalesce(count(*), 0) AS value",
8692 "MATCH (n:Person) RETURN [count(*), 1] AS value",
8693 "MATCH (n:Person) RETURN {total: count(*), fallback: 0} AS value",
8694 "MATCH (n:Person) RETURN CASE count(*) WHEN 0 THEN 1 ELSE count(*) END AS value",
8695 "MATCH (n:Person) RETURN count(*) IS NULL AS value",
8696 "MATCH (n:Person) RETURN count(*) IN [0, 1] AS value",
8697 "MATCH (n:Person) RETURN toString(count(*)) STARTS WITH '1' AS value",
8698 "MATCH (n:Person) RETURN toString(count(*)) =~ '.*' AS value",
8699 "MATCH (n:Person) RETURN [x IN collect(n.name) | x] AS value",
8700 "MATCH (n:Person) RETURN all(x IN collect(n.name) WHERE x IS NOT NULL) AS value",
8701 ];
8702
8703 for query in cases {
8704 let (binder, _) = make_binder(OntologyMode::Exploratory);
8705 let plan = binder
8706 .bind(&parse(query).unwrap())
8707 .unwrap_or_else(|errors| panic!("query={query} errors={errors:?}"));
8708 let aggregate_count = plan
8709 .ops
8710 .iter()
8711 .filter(|op| matches!(op, GraphOp::Aggregate { .. }))
8712 .count();
8713 assert_eq!(aggregate_count, 1, "query={query} ops={:?}", plan.ops);
8714 assert!(
8715 plan.ops
8716 .iter()
8717 .any(|op| matches!(op, GraphOp::Project { .. })),
8718 "nested aggregate needs a final projection: query={query} ops={:?}",
8719 plan.ops
8720 );
8721 }
8722 }
8723
8724 #[test]
8725 fn typed_uuid_discovery_traverses_every_expression_container() {
8726 let params = HashMap::from([("id".into(), IrLiteral::Uuid([0x55; 16]))]);
8727 let (binder, _) = make_binder(OntologyMode::Exploratory);
8728 let binder = binder.with_parameter_literals(¶ms);
8729 let cases = [
8730 "$id",
8731 "($id)",
8732 "-$id",
8733 "[$id]",
8734 "{value: $id}",
8735 "coalesce(null, $id)",
8736 "1 + $id",
8737 "CASE $id WHEN null THEN 0 ELSE 1 END",
8738 "CASE 1 WHEN $id THEN 0 ELSE 1 END",
8739 "CASE 1 WHEN 0 THEN $id ELSE 1 END",
8740 "CASE 1 WHEN 0 THEN 1 ELSE $id END",
8741 "[x IN [$id] WHERE x IS NOT NULL | x]",
8742 "[x IN [1] WHERE $id IS NOT NULL | x]",
8743 "[x IN [1] | $id]",
8744 "all(x IN [$id] WHERE x IS NOT NULL)",
8745 "all(x IN [1] WHERE $id IS NOT NULL)",
8746 "[(a)-->(b) WHERE $id IS NOT NULL | a]",
8747 "[(a)-->(b) | $id]",
8748 "$id IS NULL",
8749 "$id IN [1]",
8750 "1 IN [$id]",
8751 "$id STARTS WITH 'x'",
8752 "'x' STARTS WITH $id",
8753 "$id =~ 'x'",
8754 "'x' =~ $id",
8755 ];
8756 for source in cases {
8757 let expression = parsed_return_expr(&format!("RETURN {source}"));
8758 assert_eq!(
8759 binder.typed_uuid_param_in(&expression),
8760 Some("id"),
8761 "{source}"
8762 );
8763 }
8764 assert_eq!(
8765 binder.typed_uuid_param_in(&parsed_return_expr("RETURN [1, 2, 3]")),
8766 None
8767 );
8768 }
8769
8770 #[test]
8771 fn expression_shape_comparison_covers_scalar_and_container_variants() {
8772 let equal = [
8773 ("1", "1"),
8774 ("1.5", "1.5"),
8775 ("'x'", "'x'"),
8776 ("true", "true"),
8777 ("null", "null"),
8778 ("$value", "$value"),
8779 ("a", "a"),
8780 ("a.name", "a.name"),
8781 ("a + 1", "a + 1"),
8782 ("NOT a", "NOT a"),
8783 ("coalesce(a, 1)", "COALESCE(a, 1)"),
8784 ];
8785 for (left, right) in equal {
8786 assert!(
8787 same_expr_shape(
8788 &parsed_return_expr(&format!("RETURN {left}")),
8789 &parsed_return_expr(&format!("RETURN {right}")),
8790 ),
8791 "{left} should have the same shape as {right}"
8792 );
8793 }
8794
8795 let unequal = [
8796 ("1", "2"),
8797 ("1.5", "2.5"),
8798 ("'x'", "'y'"),
8799 ("true", "false"),
8800 ("$left", "$right"),
8801 ("a", "b"),
8802 ("a.name", "a.age"),
8803 ("a + 1", "a - 1"),
8804 ("NOT a", "-a"),
8805 ("coalesce(a, 1)", "coalesce(a, 2)"),
8806 ("[a, 1]", "[a]"),
8807 ("{a: 1}", "{a: 2}"),
8808 ("CASE WHEN true THEN 1 END", "1"),
8809 ];
8810 for (left, right) in unequal {
8811 assert!(
8812 !same_expr_shape(
8813 &parsed_return_expr(&format!("RETURN {left}")),
8814 &parsed_return_expr(&format!("RETURN {right}")),
8815 ),
8816 "{left} should differ from {right}"
8817 );
8818 }
8819
8820 assert!(same_grouping_expr(
8821 &parsed_return_expr("RETURN [a, 1]"),
8822 &parsed_return_expr("RETURN [a, 1]"),
8823 ));
8824 assert!(!same_grouping_expr(
8825 &parsed_return_expr("RETURN [a, 1]"),
8826 &parsed_return_expr("RETURN [a]"),
8827 ));
8828 assert!(same_grouping_expr(
8829 &parsed_return_expr("RETURN {a: 1, b: true}"),
8830 &parsed_return_expr("RETURN {b: true, a: 1}"),
8831 ));
8832 assert!(!same_grouping_expr(
8833 &parsed_return_expr("RETURN {a: 1}"),
8834 &parsed_return_expr("RETURN {a: 2}"),
8835 ));
8836 }
8837
8838 #[test]
8839 fn row_count_constant_classification_covers_every_ast_shape() {
8840 for source in ["1", "(1)", "-1", "$n", "1 + 2", "toInteger(1.5)"] {
8841 assert!(
8842 row_count_expr_is_integer(&parsed_return_expr(&format!("RETURN {source}"))),
8843 "{source}"
8844 );
8845 }
8846 assert!(!row_count_expr_is_integer(&parsed_return_expr(
8847 "RETURN 1.5"
8848 )));
8849
8850 for source in ["1.5", "(1.5)", "-1.5"] {
8851 assert!(
8852 is_float_constant(&parsed_return_expr(&format!("RETURN {source}"))),
8853 "{source}"
8854 );
8855 }
8856 assert!(!is_float_constant(&parsed_return_expr("RETURN 1")));
8857
8858 assert_eq!(
8859 extract_parameter_name(&parsed_return_expr("RETURN ($rows)")),
8860 Some("rows".into())
8861 );
8862 assert_eq!(
8863 extract_parameter_name(&parsed_return_expr("RETURN 1")),
8864 None
8865 );
8866 assert_eq!(
8867 extract_int_constant(&parsed_return_expr("RETURN -(-1)")),
8868 Some(1)
8869 );
8870 assert_eq!(
8871 extract_int_constant(&parsed_return_expr("RETURN true")),
8872 None
8873 );
8874 assert_eq!(
8875 extract_non_negative_int_constant(&parsed_return_expr("RETURN -1")),
8876 None
8877 );
8878 }
8879
8880 #[test]
8881 fn procedure_literal_type_checks_cover_nullability_and_scalar_domains() {
8882 let field = |type_name: &str, nullable| ProcedureField {
8883 name: "arg".into(),
8884 type_name: type_name.into(),
8885 nullable,
8886 };
8887 assert!(procedure_argument_type_matches(
8888 &parsed_return_expr("RETURN null"),
8889 &field("STRING", true)
8890 ));
8891 assert!(!procedure_argument_type_matches(
8892 &parsed_return_expr("RETURN null"),
8893 &field("STRING", false)
8894 ));
8895 assert!(procedure_argument_type_matches(
8896 &parsed_return_expr("RETURN 1"),
8897 &field("INTEGER", false)
8898 ));
8899 assert!(procedure_argument_type_matches(
8900 &parsed_return_expr("RETURN 1"),
8901 &field("NUMBER", false)
8902 ));
8903 assert!(!procedure_argument_type_matches(
8904 &parsed_return_expr("RETURN 1"),
8905 &field("STRING", false)
8906 ));
8907 assert!(procedure_argument_type_matches(
8908 &parsed_return_expr("RETURN 1.5"),
8909 &field("FLOAT", false)
8910 ));
8911 assert!(!procedure_argument_type_matches(
8912 &parsed_return_expr("RETURN 1.5"),
8913 &field("INTEGER", false)
8914 ));
8915 assert!(procedure_argument_type_matches(
8916 &parsed_return_expr("RETURN 'x'"),
8917 &field("STRING", false)
8918 ));
8919 assert!(!procedure_argument_type_matches(
8920 &parsed_return_expr("RETURN 'x'"),
8921 &field("BOOLEAN", false)
8922 ));
8923 assert!(procedure_argument_type_matches(
8924 &parsed_return_expr("RETURN true"),
8925 &field("BOOLEAN", false)
8926 ));
8927 assert!(!procedure_argument_type_matches(
8928 &parsed_return_expr("RETURN true"),
8929 &field("STRING", false)
8930 ));
8931 assert!(procedure_argument_type_matches(
8932 &parsed_return_expr("RETURN (1)"),
8933 &field("INTEGER", false)
8934 ));
8935 assert!(procedure_argument_type_matches(
8936 &parsed_return_expr("RETURN a"),
8937 &field("ANY", false)
8938 ));
8939 }
8940
8941 #[test]
8942 fn binder_exercises_fragmented_clause_and_expression_error_paths() {
8943 let cases = [
8944 ("MATCH (n) WHERE n.active = true RETURN n", true),
8945 ("MATCH (n) SET missing.value = 1 RETURN n", false),
8946 ("MATCH (n) REMOVE missing.value RETURN n", false),
8947 ("MATCH (n) DELETE n.name", true),
8948 ("MATCH (n) WITH n.name RETURN n", false),
8949 ("MATCH (n) WITH count(*) AS total RETURN total", true),
8950 (
8951 "MATCH (n) RETURN n ORDER BY n.name DESC SKIP (1 + 2) LIMIT toInteger(3.5)",
8952 true,
8953 ),
8954 ("MATCH (n) RETURN percentileCont(n.value)", false),
8955 (
8956 "MATCH (n) RETURN percentileCont(DISTINCT n.value, 0.5)",
8957 false,
8958 ),
8959 ("MATCH (n) RETURN 'a' + 'b'", true),
8960 ("MATCH (n) RETURN NOT (n.value IN [1, 2])", true),
8961 (
8962 "MATCH (n) WHERE exists { (n)-->(m) WHERE count(*) > 0 } RETURN n",
8963 false,
8964 ),
8965 ("MATCH (n) RETURN [(n)-->(m) WHERE count(*) > 0 | m]", false),
8966 ("MATCH (n) WHERE (n)-->(m) OR (n)-->(x) RETURN n", false),
8967 ("MATCH (n) WHERE n.active OR (n)-->(m) RETURN n", false),
8968 ("UNWIND [1, 2] AS x RETURN x", true),
8969 ];
8970 for (query, should_bind) in cases {
8971 let ast = parse(query).unwrap_or_else(|error| panic!("query={query}: {error}"));
8972 let (binder, _) = make_binder(OntologyMode::Exploratory);
8973 let result = binder.bind(&ast);
8974 assert_eq!(
8975 result.is_ok(),
8976 should_bind,
8977 "query={query}, result={result:?}"
8978 );
8979 }
8980 }
8981
8982 #[test]
8983 fn binder_query_matrix_freezes_union_write_and_predicate_boundaries() {
8984 let cases = [
8985 ("RETURN 1 AS x UNION RETURN 2 AS x", true),
8986 ("RETURN 1 AS x UNION ALL RETURN 2 AS x", true),
8987 (
8988 "RETURN 1 AS x UNION RETURN 2 AS x UNION ALL RETURN 3 AS x",
8989 false,
8990 ),
8991 ("RETURN 1 AS x UNION RETURN 2 AS y", false),
8992 (
8993 "MERGE (n:Person {id: 1}) ON CREATE SET n.name = 'Ada' ON MATCH SET n.name = 'Grace' RETURN n",
8994 true,
8995 ),
8996 (
8997 "MATCH (n:Person) SET n += {score: 1}, n:Employee RETURN n",
8998 true,
8999 ),
9000 ("MATCH (n:Person) SET n = {score: 1} RETURN n", true),
9001 ("MATCH (n:Person) REMOVE n.score, n:Employee RETURN n", true),
9002 ("MATCH (n:Person) WHERE n:Employee RETURN n", true),
9003 ("MATCH (a)-[r:KNOWS]->(b) WHERE r:LIKES RETURN r", true),
9004 ("UNWIND [1, 2] AS x RETURN all(y IN [x] WHERE y > 0)", true),
9005 (
9006 "MATCH (n) RETURN [x IN [1, 2] WHERE x > 1 | x + 1] AS values",
9007 true,
9008 ),
9009 ("MATCH (n) RETURN n:Person", true),
9010 ("WITH 1 AS x RETURN x:Person", false),
9011 (
9012 "MATCH (a)-[r:KNOWS*1..2 {since: 2020, active: true}]->(b) RETURN r",
9013 true,
9014 ),
9015 (
9016 "MERGE (n:Person) ON CREATE SET missing += {score: 1} RETURN n",
9017 false,
9018 ),
9019 (
9020 "MERGE (n:Person) ON MATCH SET missing:Employee RETURN n",
9021 false,
9022 ),
9023 ("MATCH (n) SET missing += {score: 1} RETURN n", false),
9024 ("MATCH (n) SET missing:Person RETURN n", false),
9025 ("MATCH (n) REMOVE missing:Person RETURN n", false),
9026 ("MATCH (n) DELETE [n]", false),
9027 ("MATCH (n) RETURN NOT (n.score IN [1, 2])", true),
9028 ("MATCH (n) RETURN missing:Person", false),
9029 ("MATCH (n) CALL missing.procedure() RETURN n", false),
9030 ("MATCH (n) RETURN exists { (n)-->(m) }", false),
9031 ];
9032
9033 for (query, should_bind) in cases {
9034 let ast = parse(query).unwrap_or_else(|error| panic!("query={query}: {error}"));
9035 let (binder, _) = make_binder(OntologyMode::Exploratory);
9036 let result = binder.bind(&ast);
9037 assert_eq!(
9038 result.is_ok(),
9039 should_bind,
9040 "query={query}, result={result:?}"
9041 );
9042 }
9043 }
9044
9045 #[test]
9046 fn strict_without_ontology_rejects_property_resolution() {
9047 let (binder, _) = make_binder(OntologyMode::Strict);
9048 let ast = parse("MATCH (n:Person) RETURN n.unknown").unwrap();
9049 let errors = binder.bind(&ast).unwrap_err();
9050 assert!(errors.iter().any(|error| {
9051 error.kind == BindErrorKind::UnknownProperty
9052 && error.message == "unknown property `unknown` (strict mode has no ontology)"
9053 }));
9054 }
9055
9056 #[test]
9057 fn exact_zero_binder_semantic_branches_have_public_query_oracles() {
9058 let cases = [
9059 ("RETURN 1 + 2 AS value", true),
9060 ("RETURN count(*) AS x UNION RETURN count(*) AS x", true),
9061 (
9062 "MATCH p=(a)-[r]->(b) WITH p, count(*) AS total RETURN p, total",
9063 true,
9064 ),
9065 (
9066 "MATCH p=(a)-[r]->(b) WITH p, count(*) + 1 AS total RETURN p, total",
9067 true,
9068 ),
9069 (
9070 "MATCH (a)-[r]->(b) WITH r, count(*) + 1 AS total RETURN r, total",
9071 true,
9072 ),
9073 (
9074 "WITH 7 AS x RETURN any(x IN [1, 2] WHERE x > 1) AS found, x",
9075 true,
9076 ),
9077 ];
9078 for (query, should_bind) in cases {
9079 let (binder, _) = make_binder(OntologyMode::Exploratory);
9080 let result = binder.bind(&parse(query).unwrap());
9081 assert_eq!(
9082 result.is_ok(),
9083 should_bind,
9084 "query={query}, result={result:?}"
9085 );
9086 }
9087 }
9088
9089 #[test]
9090 fn exact_zero_concat_ast_lowers_to_string_function() {
9091 let mut expression = parsed_return_expr("RETURN 'left' + 'right'");
9092 let Expr::BinaryOp(binary) = &mut expression else {
9093 panic!("expected binary expression")
9094 };
9095 binary.op = AstBinOp::Concat;
9096 let (binder, _) = make_binder(OntologyMode::Exploratory);
9097 let mut state = empty_state(OntologyMode::Exploratory);
9098 let id = binder.lower_expr(&expression, expression.span(), &mut state);
9099 assert!(state.errors.is_empty());
9100 let plan = state.builder.build();
9101 assert!(matches!(
9102 plan.exprs.get(id),
9103 IrExpr::FunctionCall { name, args } if name == "string.concat" && args.len() == 2
9104 ));
9105 }
9106
9107 #[test]
9108 fn exact_zero_union_empty_branches_are_reported_without_panicking() {
9109 let parsed = parse("RETURN 1 AS x").unwrap();
9110 for clauses in [
9111 vec![
9112 AstClause::Union(graphforge_ast::UnionClause {
9113 all: false,
9114 span: Span::new(0, 5),
9115 }),
9116 parsed.clauses[0].clone(),
9117 ],
9118 vec![
9119 parsed.clauses[0].clone(),
9120 AstClause::Union(graphforge_ast::UnionClause {
9121 all: false,
9122 span: Span::new(10, 15),
9123 }),
9124 ],
9125 ] {
9126 let query = AstQuery {
9127 dialect: parsed.dialect,
9128 clauses,
9129 span: Span::new(0, 15),
9130 };
9131 let (binder, _) = make_binder(OntologyMode::Exploratory);
9132 let errors = binder
9133 .bind(&query)
9134 .expect_err("empty UNION branch must fail");
9135 assert!(errors.iter().any(|error| {
9136 error.kind == BindErrorKind::InvalidArgument
9137 && error.message == "UNION requires a query on both sides"
9138 }));
9139 }
9140 }
9141
9142 #[test]
9143 fn exact_zero_direct_property_guards_cover_path_identity_and_write_targets() {
9144 let (binder, _) = make_binder(OntologyMode::Exploratory);
9145 let mut state = empty_state(OntologyMode::Exploratory);
9146 state.path_vars.insert(
9147 "p".into(),
9148 PathBinding {
9149 nodes: vec![VarId(0)],
9150 segments: Vec::new(),
9151 },
9152 );
9153 let path_property = parsed_return_expr("RETURN p.name");
9154 binder.lower_expr(&path_property, path_property.span(), &mut state);
9155 assert!(state.errors.iter().any(|error| {
9156 error.kind == BindErrorKind::InvalidArgument
9157 && error.message.contains("not valid on a path")
9158 }));
9159
9160 state.vars.insert("r".into(), VarId(1));
9161 state.var_kinds.insert(VarId(1), VarKind::Relationship);
9162 let wrong_identity = parsed_return_expr("RETURN r.node_uuid");
9163 binder.lower_expr(&wrong_identity, wrong_identity.span(), &mut state);
9164 assert!(state.errors.iter().any(|error| {
9165 error.kind == BindErrorKind::InvalidArgument
9166 && error.message.contains("valid only on a node")
9167 }));
9168
9169 let malformed = PropertyAccess {
9170 object: Box::new(Expr::Literal(Literal::Int(1, Span::new(0, 1)))),
9171 key: "name".into(),
9172 span: Span::new(0, 6),
9173 };
9174 assert!(
9175 binder
9176 .resolve_write_target(&malformed, &mut state)
9177 .is_none()
9178 );
9179 assert!(state.errors.iter().any(|error| {
9180 error.kind == BindErrorKind::InvalidDeleteTarget
9181 && error
9182 .message
9183 .contains("write target must be a bound variable")
9184 }));
9185 }
9186
9187 #[test]
9188 fn exact_zero_embedded_pattern_predicate_shape_is_rejected() {
9189 let ast = parse("MATCH (n) WHERE (n)-->(m) RETURN n").unwrap();
9190 let AstClause::Match(match_clause) = &ast.clauses[0] else {
9191 panic!("expected MATCH clause")
9192 };
9193 let where_clause = match_clause.where_clause.as_ref().expect("inline WHERE");
9194 let predicate = Expr::BinaryOp(graphforge_ast::BinaryOp {
9195 op: AstBinOp::Eq,
9196 left: Box::new(where_clause.predicate.clone()),
9197 right: Box::new(Expr::Literal(Literal::Bool(true, where_clause.span))),
9198 span: where_clause.span,
9199 });
9200 let (binder, _) = make_binder(OntologyMode::Exploratory);
9201 let mut state = empty_state(OntologyMode::Exploratory);
9202 state.vars.insert("n".into(), VarId(0));
9203 state.node_vars.insert(VarId(0), None);
9204 state.var_kinds.insert(VarId(0), VarKind::Node);
9205 binder.lower_where_predicate(&predicate, predicate.span(), &mut state);
9206 assert!(state.errors.iter().any(|error| {
9207 error.kind == BindErrorKind::InvalidArgument
9208 && error
9209 .message
9210 .contains("supported only as single-relationship")
9211 }));
9212 }
9213
9214 #[test]
9215 fn exact_zero_nested_pattern_comprehension_cardinality_is_rejected() {
9216 let query = "MATCH (a) RETURN [x IN nodes([(a)-->(b) | a]) | [(x)-->(c) | c] + [(x)-->(d) | d]] AS values";
9217 let (binder, _) = make_binder(OntologyMode::Exploratory);
9218 let errors = binder
9219 .bind(&parse(query).unwrap())
9220 .expect_err("one list comprehension cannot capture multiple child patterns");
9221 assert!(
9222 errors.iter().any(|error| {
9223 error.kind == BindErrorKind::InvalidArgument
9224 && error
9225 .message
9226 .contains("exactly one nested pattern comprehension")
9227 }),
9228 "errors={errors:?}"
9229 );
9230 }
9231
9232 #[test]
9233 fn exact_zero_strict_property_owner_matrix_resolves_declared_properties() {
9234 use graphforge_ontology::{
9235 EntityTypeDef, OntologyCompiler, OntologyDoc, OntologyHandle, PropertyDef,
9236 PropertyValueType, RelationTypeDef, SemanticFlags,
9237 };
9238
9239 let doc = OntologyDoc {
9240 ontology_id: "property-owner-test".into(),
9241 version: "1.0".into(),
9242 entity_types: vec![EntityTypeDef {
9243 name: "Person".into(),
9244 r#abstract: false,
9245 parent: None,
9246 }],
9247 relation_types: vec![RelationTypeDef {
9248 name: "KNOWS".into(),
9249 src: "Person".into(),
9250 dst: "Person".into(),
9251 inverse: None,
9252 semantic: SemanticFlags::default(),
9253 }],
9254 properties: vec![
9255 PropertyDef {
9256 owner: "Person".into(),
9257 name: "name".into(),
9258 value_type: PropertyValueType::Utf8,
9259 nullable: true,
9260 multivalued: false,
9261 default_json: None,
9262 },
9263 PropertyDef {
9264 owner: "KNOWS".into(),
9265 name: "since".into(),
9266 value_type: PropertyValueType::Int64,
9267 nullable: true,
9268 multivalued: false,
9269 default_json: None,
9270 },
9271 ],
9272 constraints: vec![],
9273 migrations: vec![],
9274 };
9275 let handle = OntologyHandle::new(OntologyCompiler::compile(&doc).unwrap());
9276 for query in [
9277 "MATCH (n:Person) RETURN n.name",
9278 "MATCH (n) RETURN n.name",
9279 "MATCH ()-[r:KNOWS]->() RETURN r.since",
9280 "MATCH ()-[r]->() RETURN r.since",
9281 ] {
9282 let binder = Binder::new(
9283 Some(handle.clone()),
9284 Arc::new(Mutex::new(RuntimeCatalog::new())),
9285 OntologyMode::Strict,
9286 );
9287 binder
9288 .bind(&parse(query).unwrap())
9289 .unwrap_or_else(|errors| panic!("query={query}, errors={errors:?}"));
9290 }
9291 }
9292}