1use super::{
16 access_path::index_seek_from_filter,
17 costing::{
18 estimate_node_cartesian_product_cost, estimate_physical_plan_cost,
19 push_optional_relationship_count_sum_cost_decision,
20 },
21 logical_rewrite::{rewrite_logical_plan, LogicalRewriteOutput},
22 selected_trace::selected_plan_trace,
23 stages::{
24 ACCESS_PATH_SELECTION_STAGE, DIRECT_PHYSICAL_FALLBACK_STAGE, LOGICAL_GROUPING_STAGE,
25 PHYSICAL_SEARCH_STAGE, PLAN_FINALIZATION_STAGE, SELECTED_PLAN_COSTING_STAGE,
26 },
27 LogicalPlanRoot, LoweringReadyLogicalPlanRoot, OptimizationSearchReport, OptimizerCatalog,
28 OptimizerConfig, OptimizerTrace, PhysicalPlan, PhysicalPlanRoot, StageStats,
29};
30use crate::{
31 GroupId, Memo, OptimizerContext, OptimizerSearchDirective, OptimizerSearchDirectiveError,
32 RuleEvent, RuleOutcome, SelectedPlanTrace, StageTrace,
33};
34use hawdb_core::Value;
35use hawdb_cypher::RelationshipDirection;
36use hawdb_plan::{
37 AggregateFunction, AggregateTarget, GraphExpansionBudget, LogicalPlan, NodeProjectionAccess,
38 Predicate, Projection, ProjectionExpression, SortItem, SortKey,
39};
40use std::collections::{BTreeMap, BTreeSet};
41
42mod access;
43mod ddl;
44mod join;
45mod mutation;
46mod procedure;
47mod simple;
48mod traversal;
49
50#[cfg(test)]
51mod tests;
52
53type GraphMemo = Memo<GroupExpr>;
54
55const GRAPH_EXPANSION_FANOUT_PER_SEED_HOP: usize = 32;
56const GRAPH_EXPANSION_MAX_CANDIDATES: usize = 4_096;
57const GRAPH_EXPANSION_PAYLOAD_BYTE_LIMIT: usize = 4 * 1024 * 1024;
58
59#[derive(Debug)]
60struct GroupExpr {
61 logical: LogicalPlan,
62 children: Vec<GroupId>,
63}
64
65#[derive(Debug, Default, Clone)]
66pub struct CascadesOptimizer {
67 context: OptimizerContext,
68}
69
70impl CascadesOptimizer {
71 pub fn new(config: OptimizerConfig) -> Self {
72 Self {
73 context: OptimizerContext::from_config(config),
74 }
75 }
76
77 pub fn with_context(context: OptimizerContext) -> Self {
78 Self { context }
79 }
80
81 pub fn context(&self) -> &OptimizerContext {
82 &self.context
83 }
84
85 pub fn refresh_trace_for_physical_plan(
86 &self,
87 trace: &mut OptimizerTrace,
88 plan: &PhysicalPlan,
89 catalog: &OptimizerCatalog,
90 ) {
91 refresh_selected_plan_trace(trace, selected_plan_trace(plan, catalog, &self.context));
92 }
93
94 pub fn optimize(&self, logical: &LogicalPlan) -> PhysicalPlan {
95 self.optimize_root(&LogicalPlanRoot::new(logical.clone()))
96 .into_parts()
97 .0
98 }
99
100 pub fn optimize_with_trace(&self, logical: &LogicalPlan) -> (PhysicalPlan, OptimizerTrace) {
101 self.optimize_root(&LogicalPlanRoot::new(logical.clone()))
102 .into_parts()
103 }
104
105 pub fn optimize_with_catalog(
106 &self,
107 logical: &LogicalPlan,
108 catalog: &OptimizerCatalog,
109 ) -> (PhysicalPlan, OptimizerTrace) {
110 self.optimize_root_with_catalog(&LogicalPlanRoot::new(logical.clone()), catalog)
111 .into_parts()
112 }
113
114 pub fn optimize_root(&self, root: &LogicalPlanRoot) -> PhysicalPlanRoot {
115 self.optimize_root_with_catalog(root, &OptimizerCatalog::optimistic())
116 }
117
118 pub fn optimize_root_with_catalog(
119 &self,
120 root: &LogicalPlanRoot,
121 catalog: &OptimizerCatalog,
122 ) -> PhysicalPlanRoot {
123 self.optimize_lowering_ready_root_with_catalog_and_directive(
124 &root.clone().into_lowering_ready(),
125 catalog,
126 OptimizerSearchDirective::Auto,
127 )
128 .expect("automatic optimizer search cannot reject its directive")
129 }
130
131 pub fn optimize_lowering_ready_root_with_catalog(
132 &self,
133 root: &LoweringReadyLogicalPlanRoot,
134 catalog: &OptimizerCatalog,
135 ) -> PhysicalPlanRoot {
136 self.optimize_lowering_ready_root_with_catalog_and_directive(
137 root,
138 catalog,
139 OptimizerSearchDirective::Auto,
140 )
141 .expect("automatic optimizer search cannot reject its directive")
142 }
143
144 pub fn optimize_root_with_catalog_and_directive(
145 &self,
146 root: &LogicalPlanRoot,
147 catalog: &OptimizerCatalog,
148 directive: OptimizerSearchDirective,
149 ) -> Result<PhysicalPlanRoot, OptimizerSearchDirectiveError> {
150 self.optimize_lowering_ready_root_with_catalog_and_directive(
151 &root.clone().into_lowering_ready(),
152 catalog,
153 directive,
154 )
155 }
156
157 pub fn optimize_lowering_ready_root_with_catalog_and_directive(
158 &self,
159 root: &LoweringReadyLogicalPlanRoot,
160 catalog: &OptimizerCatalog,
161 directive: OptimizerSearchDirective,
162 ) -> Result<PhysicalPlanRoot, OptimizerSearchDirectiveError> {
163 let rewrite = rewrite_logical_plan(root.plan());
164 let logical = rewrite.plan();
165 let required_groups = logical_group_count(logical);
166 let max_groups = self.context.optimizer_config().max_groups;
167 if directive == OptimizerSearchDirective::Memo && required_groups > max_groups {
168 return Err(OptimizerSearchDirectiveError::MemoGroupBudgetExceeded {
169 required_groups,
170 max_groups,
171 });
172 }
173 if directive == OptimizerSearchDirective::DirectFallback
174 || (directive == OptimizerSearchDirective::Auto && required_groups > max_groups)
175 {
176 let mut decisions = Vec::new();
177 let mut stage_events = Vec::new();
178 let plan = lower_logical(
179 logical,
180 LoweringChildren::Direct,
181 catalog,
182 &self.context,
183 &mut decisions,
184 &mut stage_events,
185 );
186 let plan = finalize_physical_plan(plan, &mut decisions, &mut stage_events);
187 let mut report = if directive == OptimizerSearchDirective::DirectFallback {
188 OptimizationSearchReport::forced_direct_fallback(required_groups)
189 } else {
190 OptimizationSearchReport::direct_fallback(required_groups, max_groups)
191 };
192 record_logical_rewrite(&mut report, &rewrite);
193 report.push_stage_event(
194 LOGICAL_GROUPING_STAGE.trace(StageStats::new(1, required_groups)),
195 );
196 for event in stage_events {
197 report.push_stage_event(event);
198 }
199 report.push_stage_event(
200 DIRECT_PHYSICAL_FALLBACK_STAGE.trace(StageStats::new(required_groups, 1)),
201 );
202 report.extend_decisions(decisions);
203 let selected = selected_plan_trace(&plan, catalog, &self.context);
204 report.push_stage_event(SELECTED_PLAN_COSTING_STAGE.trace(StageStats::new(1, 1)));
205 report.record_selected_plan_cost(selected.cost);
206 return Ok(PhysicalPlanRoot::new(plan, report.into_trace(selected)));
207 }
208 let mut memo = GraphMemo::default();
209 let root = insert_logical_group(&mut memo, logical);
210 let mut decisions = Vec::new();
211 let mut stage_events = Vec::new();
212 let plan = best_physical(
213 &memo,
214 root,
215 catalog,
216 &self.context,
217 &mut decisions,
218 &mut stage_events,
219 );
220 let plan = finalize_physical_plan(plan, &mut decisions, &mut stage_events);
221 let mut report = OptimizationSearchReport::memo(memo.group_count());
222 record_logical_rewrite(&mut report, &rewrite);
223 report
224 .push_stage_event(LOGICAL_GROUPING_STAGE.trace(StageStats::new(1, memo.group_count())));
225 let (applied_rules, skipped_rules) = stage_rule_counts(&stage_events);
226 for event in stage_events {
227 report.push_stage_event(event);
228 }
229 report.push_stage_event(PHYSICAL_SEARCH_STAGE.trace(
230 StageStats::new(memo.group_count(), 1).with_rule_counts(applied_rules, skipped_rules),
231 ));
232 report.extend_decisions(decisions);
233 let selected = selected_plan_trace(&plan, catalog, &self.context);
234 report.push_stage_event(SELECTED_PLAN_COSTING_STAGE.trace(StageStats::new(1, 1)));
235 report.record_selected_plan_cost(selected.cost);
236 if directive == OptimizerSearchDirective::Memo {
237 report.push_decision("selected memo search: explicit optimizer search directive");
238 }
239 Ok(PhysicalPlanRoot::new(plan, report.into_trace(selected)))
240 }
241}
242
243fn refresh_selected_plan_trace(trace: &mut OptimizerTrace, selected: SelectedPlanTrace) {
244 let selected_cost = selected.cost.with_cardinality_floor();
245 let selected_cost_breakdown = selected.cost_breakdown.with_cardinality_floor();
246 let cost_detail = format!(
247 "estimated_rows={} cost={}",
248 selected_cost.estimated_rows, selected_cost.cost
249 );
250 let cost_decision = format!("selected physical plan cost: {cost_detail}");
251 if let Some(decision) = trace
252 .decisions
253 .iter_mut()
254 .find(|decision| decision.starts_with("selected physical plan cost: "))
255 {
256 *decision = cost_decision;
257 } else {
258 trace.decisions.push(cost_decision);
259 }
260 let cost_event = RuleEvent::selected("physical plan cost", cost_detail);
261 if let Some(event) = trace.rule_events.iter_mut().find(|event| {
262 event.rule() == "physical plan cost" && event.outcome() == RuleOutcome::Selected
263 }) {
264 *event = cost_event;
265 } else {
266 trace.rule_events.push(cost_event);
267 }
268
269 trace.query_digest = selected.query_digest;
270 trace.selected_plan = selected.explain;
271 trace.selected_plan_fingerprint = selected.fingerprint;
272 trace.selected_plan_cost = selected_cost;
273 trace.selected_plan_cost_breakdown = selected_cost_breakdown;
274 trace.selected_plan_properties = selected.properties;
275 trace.selected_plan_cardinality_estimates = selected.cardinality_estimates;
276 trace.selected_plan_operator_counts = selected.operator_counts;
277 trace.selected_plan_class_counts = selected.class_counts;
278}
279
280pub(super) fn record_logical_rewrite(
281 report: &mut OptimizationSearchReport,
282 rewrite: &LogicalRewriteOutput,
283) {
284 report.push_stage_event(rewrite.trace().clone());
285 if let Some(warning) = rewrite.warning() {
286 report.push_warning(warning);
287 }
288 for event in rewrite.events().iter().cloned() {
289 report.push_rule_event(event);
290 }
291}
292
293fn insert_logical_group(memo: &mut GraphMemo, logical: &LogicalPlan) -> GroupId {
294 let expr = GroupExpr::from_logical(logical, memo);
295 memo.insert_group(expr)
296}
297
298fn best_physical(
299 memo: &GraphMemo,
300 root: GroupId,
301 catalog: &OptimizerCatalog,
302 optimizer_context: &OptimizerContext,
303 decisions: &mut Vec<String>,
304 stage_events: &mut Vec<StageTrace>,
305) -> PhysicalPlan {
306 let group = memo.group(root).expect("memo group id should exist");
307 group
308 .first_expression()
309 .expect("memo group should contain at least one expression")
310 .to_physical(memo, catalog, optimizer_context, decisions, stage_events)
311}
312
313fn select_bounded_sort_plan(
314 items: Vec<SortItem>,
315 offset: usize,
316 limit: usize,
317 input: PhysicalPlan,
318 catalog: &OptimizerCatalog,
319 decisions: &mut Vec<String>,
320) -> PhysicalPlan {
321 let top_n = PhysicalPlan::TopNExec {
322 items: items.clone(),
323 offset,
324 limit,
325 input: Box::new(input.clone()),
326 };
327 let sort_limit = PhysicalPlan::LimitExec {
328 offset,
329 limit: Some(limit),
330 input: Box::new(PhysicalPlan::SortExec {
331 items,
332 input: Box::new(input),
333 }),
334 };
335 let top_n_cost = estimate_physical_plan_cost(&top_n, catalog);
336 let sort_limit_cost = estimate_physical_plan_cost(&sort_limit, catalog);
337 if top_n_cost.cost < sort_limit_cost.cost {
338 decisions.push(format!(
339 "choose TopN for bounded sort: offset={offset} limit={limit} top_n_cost={} sort_limit_cost={}",
340 top_n_cost.cost, sort_limit_cost.cost
341 ));
342 top_n
343 } else {
344 decisions.push(format!(
345 "keep Sort + Limit for bounded sort: offset={offset} limit={limit} top_n_cost={} sort_limit_cost={}",
346 top_n_cost.cost, sort_limit_cost.cost
347 ));
348 sort_limit
349 }
350}
351
352fn stage_rule_counts(stage_events: &[StageTrace]) -> (usize, usize) {
353 stage_events
354 .iter()
355 .fold((0, 0), |(applied, skipped), event| {
356 let stats = event.stats();
357 (
358 applied.saturating_add(stats.applied_rules),
359 skipped.saturating_add(stats.skipped_rules),
360 )
361 })
362}
363
364impl GroupExpr {
365 fn from_logical(logical: &LogicalPlan, memo: &mut GraphMemo) -> Self {
366 match logical {
367 LogicalPlan::GraphMatch { input, .. } => Self {
368 logical: logical.clone(),
369 children: input
370 .iter()
371 .map(|input| insert_logical_group(memo, input))
372 .collect(),
373 },
374 LogicalPlan::CreateNodeLabel { .. }
375 | LogicalPlan::CreateRelationshipType { .. }
376 | LogicalPlan::CreateNodeTable { .. }
377 | LogicalPlan::CreateRelationshipTable { .. }
378 | LogicalPlan::CreateProperty { .. }
379 | LogicalPlan::AlterTableState { .. }
380 | LogicalPlan::AlterPropertyState { .. }
381 | LogicalPlan::CreateIndex { .. }
382 | LogicalPlan::CreateCompositeIndex { .. }
383 | LogicalPlan::CreateRangeIndex { .. }
384 | LogicalPlan::CreateFullTextIndex { .. }
385 | LogicalPlan::CreateUniqueConstraint { .. }
386 | LogicalPlan::CreateNodePropertyExistsConstraint { .. }
387 | LogicalPlan::CreateRelationshipUniqueConstraint { .. }
388 | LogicalPlan::CreateRelationshipPropertyExistsConstraint { .. }
389 | LogicalPlan::ProjectGraph { .. }
390 | LogicalPlan::GraphAlgorithm { .. }
391 | LogicalPlan::VectorSeed { .. }
392 | LogicalPlan::CreateNode { .. }
393 | LogicalPlan::UnwindMutation { .. }
394 | LogicalPlan::MergeNode { .. }
395 | LogicalPlan::MergeRelationship { .. }
396 | LogicalPlan::MergeMatchedRelationship { .. }
397 | LogicalPlan::MergeRelationshipFromMatchedRelationship { .. }
398 | LogicalPlan::MergeRelationshipToMatchedTarget { .. }
399 | LogicalPlan::MergeRelationshipFromMatchedTarget { .. }
400 | LogicalPlan::CreateMatchedRelationship { .. }
401 | LogicalPlan::SetNodeProperty { .. }
402 | LogicalPlan::SetNodeProperties { .. }
403 | LogicalPlan::SetNodePropertiesReturn { .. }
404 | LogicalPlan::SetRelationshipProperty { .. }
405 | LogicalPlan::SetRelationshipProperties { .. }
406 | LogicalPlan::DeleteNode { .. }
407 | LogicalPlan::DeleteRelationship { .. }
408 | LogicalPlan::DeleteRelationshipTargetNodes { .. }
409 | LogicalPlan::CreateRelationship { .. }
410 | LogicalPlan::NodeScan { .. }
411 | LogicalPlan::OptionalRelationshipCountSum { .. }
412 | LogicalPlan::ThreadRepairStats { .. }
413 | LogicalPlan::ShortestPath { .. } => Self {
414 logical: logical.clone(),
415 children: Vec::new(),
416 },
417 LogicalPlan::Limit { limit: Some(0), .. } => Self {
418 logical: logical.clone(),
419 children: Vec::new(),
420 },
421 LogicalPlan::NodeCartesianProduct { left, right } => Self {
422 logical: logical.clone(),
423 children: vec![
424 insert_logical_group(memo, left),
425 insert_logical_group(memo, right),
426 ],
427 },
428 LogicalPlan::Limit {
429 limit: Some(_),
430 input,
431 ..
432 } if matches!(input.as_ref(), LogicalPlan::Sort { .. }) => {
433 let LogicalPlan::Sort { input, .. } = input.as_ref() else {
434 unreachable!("guard requires a sort input");
435 };
436 Self {
437 logical: logical.clone(),
438 children: vec![insert_logical_group(memo, input)],
439 }
440 }
441 LogicalPlan::Expand { input, .. }
442 | LogicalPlan::NodeColumnLookup { input, .. }
443 | LogicalPlan::OptionalDegree { input, .. }
444 | LogicalPlan::Filter { input, .. }
445 | LogicalPlan::Project { input, .. }
446 | LogicalPlan::Aggregate { input, .. }
447 | LogicalPlan::Distinct { input }
448 | LogicalPlan::Sort { input, .. }
449 | LogicalPlan::Limit { input, .. } => Self {
450 logical: logical.clone(),
451 children: vec![insert_logical_group(memo, input)],
452 },
453 }
454 }
455
456 fn to_physical(
457 &self,
458 memo: &GraphMemo,
459 catalog: &OptimizerCatalog,
460 optimizer_context: &OptimizerContext,
461 decisions: &mut Vec<String>,
462 stage_events: &mut Vec<StageTrace>,
463 ) -> PhysicalPlan {
464 lower_logical(
465 &self.logical,
466 LoweringChildren::Memo {
467 memo,
468 children: &self.children,
469 },
470 catalog,
471 optimizer_context,
472 decisions,
473 stage_events,
474 )
475 }
476}
477
478fn push_vector_seed_metadata_filter(
479 input: &mut PhysicalPlan,
480 predicate: &hawdb_plan::Predicate,
481 decisions: &mut Vec<String>,
482) {
483 if let hawdb_plan::Predicate::And(predicates) = predicate {
484 for predicate in predicates {
485 push_vector_seed_metadata_filter(input, predicate, decisions);
486 }
487 return;
488 }
489 let hawdb_plan::Predicate::PropertyEq {
490 variable,
491 property,
492 value,
493 } = predicate
494 else {
495 return;
496 };
497 let Some(filter_field) = vector_seed_metadata_field(property) else {
498 return;
499 };
500 let Some(filter_value) = vector_seed_metadata_value(value) else {
501 return;
502 };
503 if attach_vector_seed_metadata_filter(input, variable, filter_field, filter_value) {
504 decisions.push(format!(
505 "push descriptor-safe vector seed filter {variable}.{property} before candidate generation"
506 ));
507 }
508}
509
510fn attach_vector_seed_metadata_filter(
511 plan: &mut PhysicalPlan,
512 variable: &str,
513 field: &str,
514 value: String,
515) -> bool {
516 match plan {
517 PhysicalPlan::NodeColumnLookupExec {
518 variable: lookup_variable,
519 input,
520 ..
521 } if lookup_variable == variable => {
522 attach_metadata_filter_to_vector_seed(input, field, value)
523 }
524 PhysicalPlan::AdjacencyExpandExec {
525 source_variable,
526 input,
527 ..
528 } if source_variable == variable => {
529 attach_vector_seed_metadata_filter(input, variable, field, value)
530 }
531 _ => false,
532 }
533}
534
535fn attach_metadata_filter_to_vector_seed(
536 plan: &mut PhysicalPlan,
537 field: &str,
538 value: String,
539) -> bool {
540 let PhysicalPlan::VectorSeedScan {
541 metadata_filters,
542 vector_plan,
543 ..
544 } = plan
545 else {
546 return false;
547 };
548 if metadata_filters
549 .get(field)
550 .is_some_and(|existing| existing != &value)
551 {
552 return false;
553 }
554 metadata_filters.insert(field.to_string(), value);
555 attach_vector_filter_field(vector_plan, field);
556 true
557}
558
559fn attach_vector_filter_field(plan: &mut hawdb_plan::VectorPhysicalPlan, field: &str) {
560 match plan {
561 hawdb_plan::VectorPhysicalPlan::Filter { fields } => {
562 if !fields.iter().any(|existing| existing == field) {
563 fields.push(field.to_string());
564 fields.sort();
565 }
566 }
567 hawdb_plan::VectorPhysicalPlan::VectorCandidateScan { input, .. }
568 | hawdb_plan::VectorPhysicalPlan::ResidualFilter { input, .. }
569 | hawdb_plan::VectorPhysicalPlan::RawVectorRerank { input, .. }
570 | hawdb_plan::VectorPhysicalPlan::TopK { input, .. } => {
571 attach_vector_filter_field(input, field);
572 }
573 }
574}
575
576fn vector_seed_metadata_field(property: &str) -> Option<&str> {
577 match property {
578 "id" => Some("external_id"),
579 "kind" | "external_id" | "source_id" | "space_id" | "unit_type" | "lifecycle_state"
580 | "importance" | "confidence" | "created_at" | "updated_at" | "event_start"
581 | "event_end" | "is_latest" => Some(property),
582 _ => None,
583 }
584}
585
586fn vector_seed_metadata_value(value: &Value) -> Option<String> {
587 match value {
588 Value::String(value) => Some(value.clone()),
589 Value::Bool(value) => Some(value.to_string()),
590 Value::Int(value) => Some(value.to_string()),
591 Value::Float(value) if value.is_finite() => Some(value.to_string()),
592 Value::Uuid(value) => Some(value.to_string()),
593 Value::Null | Value::Float(_) | Value::Binary(_) | Value::List(_) | Value::Map(_) => None,
594 }
595}
596
597fn vector_seed_top_k(plan: &PhysicalPlan) -> Option<usize> {
598 match plan {
599 PhysicalPlan::VectorSeedScan { vector_plan, .. } => vector_plan_top_k(vector_plan),
600 PhysicalPlan::NodeColumnLookupExec { input, .. }
601 | PhysicalPlan::AdjacencyExpandExec { input, .. }
602 | PhysicalPlan::AdjacencyExistsExec { input, .. }
603 | PhysicalPlan::FilterExec { input, .. }
604 | PhysicalPlan::ProjectExec { input, .. }
605 | PhysicalPlan::LimitExec { input, .. } => vector_seed_top_k(input),
606 _ => None,
607 }
608}
609
610fn vector_plan_top_k(plan: &hawdb_plan::VectorPhysicalPlan) -> Option<usize> {
611 match plan {
612 hawdb_plan::VectorPhysicalPlan::TopK { limit, .. } => Some(*limit),
613 hawdb_plan::VectorPhysicalPlan::VectorCandidateScan { input, .. }
614 | hawdb_plan::VectorPhysicalPlan::ResidualFilter { input, .. }
615 | hawdb_plan::VectorPhysicalPlan::RawVectorRerank { input, .. } => vector_plan_top_k(input),
616 hawdb_plan::VectorPhysicalPlan::Filter { .. } => None,
617 }
618}
619
620fn graph_expansion_budget(top_k: usize, max_hops: usize) -> GraphExpansionBudget {
621 let candidate_limit = top_k
622 .max(1)
623 .saturating_mul(max_hops.max(1))
624 .saturating_mul(GRAPH_EXPANSION_FANOUT_PER_SEED_HOP)
625 .min(GRAPH_EXPANSION_MAX_CANDIDATES)
626 .max(top_k);
627 GraphExpansionBudget {
628 candidate_limit,
629 payload_byte_limit: GRAPH_EXPANSION_PAYLOAD_BYTE_LIMIT,
630 }
631}
632
633fn logical_group_count(logical: &LogicalPlan) -> usize {
634 match logical {
635 LogicalPlan::GraphMatch { input, .. } => {
636 1 + input.as_deref().map(logical_group_count).unwrap_or(0)
637 }
638 LogicalPlan::Limit { limit: Some(0), .. } => 1,
639 LogicalPlan::Limit {
640 limit: Some(_),
641 input,
642 ..
643 } if matches!(input.as_ref(), LogicalPlan::Sort { .. }) => {
644 let LogicalPlan::Sort { input, .. } = input.as_ref() else {
645 unreachable!("guard requires a sort input");
646 };
647 1 + logical_group_count(input)
649 }
650 LogicalPlan::Expand { input, .. }
651 | LogicalPlan::NodeColumnLookup { input, .. }
652 | LogicalPlan::OptionalDegree { input, .. }
653 | LogicalPlan::Filter { input, .. }
654 | LogicalPlan::Project { input, .. }
655 | LogicalPlan::Aggregate { input, .. }
656 | LogicalPlan::Distinct { input }
657 | LogicalPlan::Sort { input, .. }
658 | LogicalPlan::Limit { input, .. } => 1 + logical_group_count(input),
659 LogicalPlan::OptionalRelationshipCountSum { .. }
660 | LogicalPlan::ThreadRepairStats { .. }
661 | LogicalPlan::ShortestPath { .. } => 1,
662 LogicalPlan::CreateNodeLabel { .. }
663 | LogicalPlan::CreateRelationshipType { .. }
664 | LogicalPlan::CreateNodeTable { .. }
665 | LogicalPlan::CreateRelationshipTable { .. }
666 | LogicalPlan::CreateProperty { .. }
667 | LogicalPlan::AlterTableState { .. }
668 | LogicalPlan::AlterPropertyState { .. }
669 | LogicalPlan::CreateIndex { .. }
670 | LogicalPlan::CreateCompositeIndex { .. }
671 | LogicalPlan::CreateRangeIndex { .. }
672 | LogicalPlan::CreateFullTextIndex { .. }
673 | LogicalPlan::CreateUniqueConstraint { .. }
674 | LogicalPlan::CreateNodePropertyExistsConstraint { .. }
675 | LogicalPlan::CreateRelationshipUniqueConstraint { .. }
676 | LogicalPlan::CreateRelationshipPropertyExistsConstraint { .. }
677 | LogicalPlan::ProjectGraph { .. }
678 | LogicalPlan::GraphAlgorithm { .. }
679 | LogicalPlan::VectorSeed { .. }
680 | LogicalPlan::CreateNode { .. }
681 | LogicalPlan::UnwindMutation { .. }
682 | LogicalPlan::MergeNode { .. }
683 | LogicalPlan::MergeRelationship { .. }
684 | LogicalPlan::MergeMatchedRelationship { .. }
685 | LogicalPlan::MergeRelationshipFromMatchedRelationship { .. }
686 | LogicalPlan::MergeRelationshipToMatchedTarget { .. }
687 | LogicalPlan::MergeRelationshipFromMatchedTarget { .. }
688 | LogicalPlan::CreateMatchedRelationship { .. }
689 | LogicalPlan::SetNodeProperty { .. }
690 | LogicalPlan::SetNodeProperties { .. }
691 | LogicalPlan::SetNodePropertiesReturn { .. }
692 | LogicalPlan::SetRelationshipProperty { .. }
693 | LogicalPlan::SetRelationshipProperties { .. }
694 | LogicalPlan::DeleteNode { .. }
695 | LogicalPlan::DeleteRelationship { .. }
696 | LogicalPlan::DeleteRelationshipTargetNodes { .. }
697 | LogicalPlan::CreateRelationship { .. }
698 | LogicalPlan::NodeScan { .. } => 1,
699 LogicalPlan::NodeCartesianProduct { left, right } => {
700 1 + logical_group_count(left) + logical_group_count(right)
701 }
702 }
703}
704
705enum LoweringChildren<'a> {
708 Memo {
709 memo: &'a GraphMemo,
710 children: &'a [GroupId],
711 },
712 Direct,
713}
714
715impl LoweringChildren<'_> {
716 fn lower(
717 &self,
718 logical: &LogicalPlan,
719 index: usize,
720 catalog: &OptimizerCatalog,
721 optimizer_context: &OptimizerContext,
722 decisions: &mut Vec<String>,
723 stage_events: &mut Vec<StageTrace>,
724 ) -> PhysicalPlan {
725 match self {
726 Self::Memo { memo, children } => best_physical(
727 memo,
728 children[index],
729 catalog,
730 optimizer_context,
731 decisions,
732 stage_events,
733 ),
734 Self::Direct => lower_logical(
735 logical,
736 Self::Direct,
737 catalog,
738 optimizer_context,
739 decisions,
740 stage_events,
741 ),
742 }
743 }
744}
745
746fn lower_logical(
747 logical: &LogicalPlan,
748 children: LoweringChildren<'_>,
749 catalog: &OptimizerCatalog,
750 optimizer_context: &OptimizerContext,
751 decisions: &mut Vec<String>,
752 stage_events: &mut Vec<StageTrace>,
753) -> PhysicalPlan {
754 if let Some(plan) = select_exact_count_fast_path(logical, decisions, stage_events) {
755 return plan;
756 }
757 if let Some(plan) = simple::lower_simple_logical(logical, optimizer_context) {
758 return plan;
759 }
760 match logical {
761 LogicalPlan::GraphMatch { program, input } => PhysicalPlan::GraphMatchExec {
762 program: program.clone(),
763 input: input.as_ref().map(|input| {
764 Box::new(children.lower(
765 input,
766 0,
767 catalog,
768 optimizer_context,
769 decisions,
770 stage_events,
771 ))
772 }),
773 },
774 LogicalPlan::NodeCartesianProduct { left, right } => {
775 let left = children.lower(left, 0, catalog, optimizer_context, decisions, stage_events);
776 let right = children.lower(
777 right,
778 1,
779 catalog,
780 optimizer_context,
781 decisions,
782 stage_events,
783 );
784 let (left, right) =
785 order_single_row_cartesian_product_children(catalog, decisions, left, right);
786 push_cartesian_product_cost_decision(catalog, decisions, &left, &right);
787 PhysicalPlan::NodeCartesianProductExec {
788 left: Box::new(left),
789 right: Box::new(right),
790 }
791 }
792 LogicalPlan::NodeColumnLookup {
793 variable,
794 label,
795 property,
796 column,
797 optional,
798 input,
799 } => PhysicalPlan::NodeColumnLookupExec {
800 variable: variable.clone(),
801 label: label.clone(),
802 property: property.clone(),
803 column: column.clone(),
804 optional: *optional,
805 input: Box::new(children.lower(
806 input,
807 0,
808 catalog,
809 optimizer_context,
810 decisions,
811 stage_events,
812 )),
813 },
814 LogicalPlan::Expand {
815 source_variable,
816 source_label,
817 rel_variable,
818 rel_type,
819 rel_properties,
820 direction,
821 target_variable,
822 target_label,
823 min_hops,
824 max_hops,
825 optional,
826 input,
827 } => {
828 push_expand_estimate_decision(
829 catalog,
830 decisions,
831 ExpandEstimateRequest {
832 source_label,
833 rel_type,
834 rel_properties,
835 target_label,
836 min_hops: *min_hops,
837 max_hops: *max_hops,
838 },
839 );
840 let input = children.lower(
841 input,
842 0,
843 catalog,
844 optimizer_context,
845 decisions,
846 stage_events,
847 );
848 let graph_budget =
849 vector_seed_top_k(&input).map(|top_k| graph_expansion_budget(top_k, *max_hops));
850 if let Some(graph_budget) = graph_budget {
851 decisions.push(format!(
852 "bound vector-seeded graph expansion to {} candidates and {} payload bytes",
853 graph_budget.candidate_limit, graph_budget.payload_byte_limit
854 ));
855 }
856 PhysicalPlan::AdjacencyExpandExec {
857 source_variable: source_variable.clone(),
858 source_label: source_label.clone(),
859 rel_variable: rel_variable.clone(),
860 rel_type: rel_type.clone(),
861 rel_properties: rel_properties.clone(),
862 direction: *direction,
863 target_variable: target_variable.clone(),
864 target_label: target_label.clone(),
865 min_hops: *min_hops,
866 max_hops: *max_hops,
867 optional: *optional,
868 graph_budget,
869 input: Box::new(input),
870 }
871 }
872 LogicalPlan::OptionalDegree {
873 source_variable,
874 rel_type,
875 rel_properties,
876 direction,
877 target_label,
878 target_properties,
879 alias,
880 input,
881 } => PhysicalPlan::OptionalDegreeExec {
882 source_variable: source_variable.clone(),
883 rel_type: rel_type.clone(),
884 rel_properties: rel_properties.clone(),
885 direction: *direction,
886 target_label: target_label.clone(),
887 target_properties: target_properties.clone(),
888 alias: alias.clone(),
889 input: Box::new(children.lower(
890 input,
891 0,
892 catalog,
893 optimizer_context,
894 decisions,
895 stage_events,
896 )),
897 },
898 LogicalPlan::OptionalRelationshipCountSum {
899 variable,
900 label,
901 properties,
902 legs,
903 output,
904 } => {
905 push_optional_relationship_count_sum_cost_decision(
906 catalog, decisions, label, properties, legs,
907 );
908 PhysicalPlan::OptionalRelationshipCountSumExec {
909 variable: variable.clone(),
910 label: label.clone(),
911 properties: properties.clone(),
912 legs: legs.clone(),
913 output: output.clone(),
914 }
915 }
916 LogicalPlan::Filter { predicate, input } => {
917 if let Some(plan) =
918 index_seek_from_filter(predicate, input, catalog, decisions, stage_events)
919 {
920 plan
921 } else if let Some(plan) = access::source_segment_scan_from_filter(predicate, input) {
922 decisions.push(
923 "choose SourceSegmentScan for storage-prunable Source filter".to_string(),
924 );
925 PhysicalPlan::FilterExec {
926 predicate: predicate.clone(),
927 input: Box::new(plan),
928 }
929 } else if let Predicate::BoundRelationshipExists {
930 source_variable,
931 rel_type,
932 direction,
933 target_variable,
934 } = predicate
935 {
936 decisions.push(
937 "lower bound relationship existence predicate to AdjacencyExistsExec"
938 .to_string(),
939 );
940 PhysicalPlan::AdjacencyExistsExec {
941 source_variable: source_variable.clone(),
942 rel_type: rel_type.clone(),
943 direction: *direction,
944 target_variable: target_variable.clone(),
945 input: Box::new(children.lower(
946 input,
947 0,
948 catalog,
949 optimizer_context,
950 decisions,
951 stage_events,
952 )),
953 }
954 } else {
955 let mut input = children.lower(
956 input,
957 0,
958 catalog,
959 optimizer_context,
960 decisions,
961 stage_events,
962 );
963 push_vector_seed_metadata_filter(&mut input, predicate, decisions);
964 input = join::lower_property_join(input, predicate, decisions);
965 PhysicalPlan::FilterExec {
966 predicate: predicate.clone(),
967 input: Box::new(input),
968 }
969 }
970 }
971 LogicalPlan::Project { items, input } => PhysicalPlan::ProjectExec {
972 items: items.clone(),
973 input: Box::new(children.lower(
974 input,
975 0,
976 catalog,
977 optimizer_context,
978 decisions,
979 stage_events,
980 )),
981 },
982 LogicalPlan::Aggregate {
983 group_keys,
984 items,
985 input,
986 } => PhysicalPlan::AggregateExec {
987 group_keys: group_keys.clone(),
988 items: items.clone(),
989 input: Box::new(children.lower(
990 input,
991 0,
992 catalog,
993 optimizer_context,
994 decisions,
995 stage_events,
996 )),
997 },
998 LogicalPlan::Distinct { input } => PhysicalPlan::DistinctExec {
999 input: Box::new(children.lower(
1000 input,
1001 0,
1002 catalog,
1003 optimizer_context,
1004 decisions,
1005 stage_events,
1006 )),
1007 },
1008 LogicalPlan::Sort { items, input } => PhysicalPlan::SortExec {
1009 items: items.clone(),
1010 input: Box::new(children.lower(
1011 input,
1012 0,
1013 catalog,
1014 optimizer_context,
1015 decisions,
1016 stage_events,
1017 )),
1018 },
1019 LogicalPlan::Limit {
1020 offset,
1021 limit,
1022 input,
1023 } => {
1024 if *limit == Some(0) {
1025 PhysicalPlan::EmptyExec
1026 } else if let (Some(limit), LogicalPlan::Sort { items, input }) =
1027 (limit, input.as_ref())
1028 {
1029 let input = children.lower(
1030 input,
1031 0,
1032 catalog,
1033 optimizer_context,
1034 decisions,
1035 stage_events,
1036 );
1037 select_bounded_sort_plan(items.clone(), *offset, *limit, input, catalog, decisions)
1038 } else {
1039 PhysicalPlan::LimitExec {
1040 offset: *offset,
1041 limit: *limit,
1042 input: Box::new(children.lower(
1043 input,
1044 0,
1045 catalog,
1046 optimizer_context,
1047 decisions,
1048 stage_events,
1049 )),
1050 }
1051 }
1052 }
1053 _ => unreachable!("leaf logical plans are lowered before child planning"),
1054 }
1055}
1056
1057fn select_exact_count_fast_path(
1058 logical: &LogicalPlan,
1059 decisions: &mut Vec<String>,
1060 stage_events: &mut Vec<StageTrace>,
1061) -> Option<PhysicalPlan> {
1062 if let Some(plan) = select_node_count_fast_path(logical, decisions, stage_events) {
1063 return Some(plan);
1064 }
1065 select_relationship_count_fast_path(logical, decisions, stage_events)
1066}
1067
1068fn select_node_count_fast_path(
1069 logical: &LogicalPlan,
1070 decisions: &mut Vec<String>,
1071 stage_events: &mut Vec<StageTrace>,
1072) -> Option<PhysicalPlan> {
1073 let LogicalPlan::Aggregate {
1074 group_keys,
1075 items,
1076 input,
1077 } = logical
1078 else {
1079 return None;
1080 };
1081 let [item] = items.as_slice() else {
1082 return None;
1083 };
1084 let LogicalPlan::NodeScan { variable, label } = input.as_ref() else {
1085 return None;
1086 };
1087 let exact_node_count = group_keys.is_empty()
1088 && item.function == AggregateFunction::Count
1089 && match &item.target {
1090 AggregateTarget::All => !item.distinct,
1091 AggregateTarget::Variable(target) => target == variable,
1092 AggregateTarget::Property { .. }
1093 | AggregateTarget::Column(_)
1094 | AggregateTarget::ColumnProperty { .. } => false,
1095 };
1096 if !exact_node_count {
1097 return None;
1098 }
1099
1100 decisions.push(format!(
1101 "selected implementation:node_count_fast_path: use exact label count for {label}"
1102 ));
1103 stage_events
1104 .push(ACCESS_PATH_SELECTION_STAGE.trace(StageStats::new(1, 1).with_rule_counts(1, 0)));
1105 Some(PhysicalPlan::NodeCountExec {
1106 label: label.clone(),
1107 output: item.name.clone(),
1108 })
1109}
1110
1111fn select_relationship_count_fast_path(
1112 logical: &LogicalPlan,
1113 decisions: &mut Vec<String>,
1114 stage_events: &mut Vec<StageTrace>,
1115) -> Option<PhysicalPlan> {
1116 let LogicalPlan::Aggregate {
1117 group_keys,
1118 items,
1119 input,
1120 } = logical
1121 else {
1122 return None;
1123 };
1124 let [item] = items.as_slice() else {
1125 return None;
1126 };
1127 let LogicalPlan::Expand {
1128 source_variable,
1129 source_label,
1130 rel_variable,
1131 rel_type,
1132 rel_properties,
1133 direction,
1134 target_label,
1135 min_hops,
1136 max_hops,
1137 optional,
1138 input,
1139 ..
1140 } = input.as_ref()
1141 else {
1142 return None;
1143 };
1144 let LogicalPlan::NodeScan { variable, label } = input.as_ref() else {
1145 return None;
1146 };
1147 let exact_relationship_count = group_keys.is_empty()
1148 && item.function == AggregateFunction::Count
1149 && !item.distinct
1150 && source_variable == variable
1151 && source_label.is_empty()
1152 && label.is_empty()
1153 && target_label.is_empty()
1154 && rel_properties.is_empty()
1155 && *min_hops == 1
1156 && *max_hops == 1
1157 && !optional
1158 && matches!(
1159 direction,
1160 RelationshipDirection::Outgoing | RelationshipDirection::Incoming
1161 )
1162 && match &item.target {
1163 AggregateTarget::All => true,
1164 AggregateTarget::Variable(target) => rel_variable.as_ref() == Some(target),
1165 AggregateTarget::Property { .. }
1166 | AggregateTarget::Column(_)
1167 | AggregateTarget::ColumnProperty { .. } => false,
1168 };
1169 if !exact_relationship_count {
1170 return None;
1171 }
1172
1173 decisions.push(format!(
1174 "selected implementation:relationship_count_fast_path: use exact relationship count for {rel_type}"
1175 ));
1176 stage_events
1177 .push(ACCESS_PATH_SELECTION_STAGE.trace(StageStats::new(1, 1).with_rule_counts(1, 0)));
1178 Some(PhysicalPlan::RelationshipCountExec {
1179 rel_type: rel_type.clone(),
1180 output: item.name.clone(),
1181 })
1182}
1183
1184fn finalize_physical_plan(
1185 plan: PhysicalPlan,
1186 decisions: &mut Vec<String>,
1187 stage_events: &mut Vec<StageTrace>,
1188) -> PhysicalPlan {
1189 let event_count_before = stage_events.len();
1190 let finalized = finalize_required_node_properties(plan, decisions, stage_events);
1191 if !stage_events[event_count_before..]
1192 .iter()
1193 .any(|event| event.name() == PLAN_FINALIZATION_STAGE.name())
1194 {
1195 stage_events.push(PLAN_FINALIZATION_STAGE.trace(StageStats::new(1, 1)));
1196 }
1197 finalized
1198}
1199
1200fn finalize_required_node_properties(
1201 plan: PhysicalPlan,
1202 decisions: &mut Vec<String>,
1203 stage_events: &mut Vec<StageTrace>,
1204) -> PhysicalPlan {
1205 match plan {
1206 PhysicalPlan::ProjectExec { items, input } => {
1207 select_node_projection_scan(&items, &input, decisions, stage_events)
1208 .unwrap_or(PhysicalPlan::ProjectExec { items, input })
1209 }
1210 PhysicalPlan::AggregateExec {
1211 group_keys,
1212 items,
1213 input,
1214 } => {
1215 let input = select_node_aggregate_required_scan(
1216 &group_keys,
1217 &items,
1218 &input,
1219 decisions,
1220 stage_events,
1221 )
1222 .map(Box::new)
1223 .unwrap_or(input);
1224 PhysicalPlan::AggregateExec {
1225 group_keys,
1226 items,
1227 input,
1228 }
1229 }
1230 PhysicalPlan::LimitExec {
1231 offset,
1232 limit,
1233 input,
1234 } => PhysicalPlan::LimitExec {
1235 offset,
1236 limit,
1237 input: Box::new(finalize_required_node_properties(
1238 *input,
1239 decisions,
1240 stage_events,
1241 )),
1242 },
1243 PhysicalPlan::DistinctExec { input } => PhysicalPlan::DistinctExec {
1244 input: Box::new(finalize_required_node_properties(
1245 *input,
1246 decisions,
1247 stage_events,
1248 )),
1249 },
1250 PhysicalPlan::SortExec { items, input }
1251 if sort_items_use_only_projected_columns(&items) =>
1252 {
1253 PhysicalPlan::SortExec {
1254 items,
1255 input: Box::new(finalize_required_node_properties(
1256 *input,
1257 decisions,
1258 stage_events,
1259 )),
1260 }
1261 }
1262 PhysicalPlan::TopNExec {
1263 items,
1264 offset,
1265 limit,
1266 input,
1267 } if sort_items_use_only_projected_columns(&items) => PhysicalPlan::TopNExec {
1268 items,
1269 offset,
1270 limit,
1271 input: Box::new(finalize_required_node_properties(
1272 *input,
1273 decisions,
1274 stage_events,
1275 )),
1276 },
1277 plan => plan,
1278 }
1279}
1280
1281fn sort_items_use_only_projected_columns(items: &[SortItem]) -> bool {
1282 items
1283 .iter()
1284 .all(|item| matches!(item.key, SortKey::Column(_)))
1285}
1286
1287fn select_node_projection_scan(
1288 items: &[Projection],
1289 input: &PhysicalPlan,
1290 decisions: &mut Vec<String>,
1291 stage_events: &mut Vec<StageTrace>,
1292) -> Option<PhysicalPlan> {
1293 let (source, predicate) = match input {
1294 PhysicalPlan::FilterExec { predicate, input } => (input.as_ref(), Some(predicate)),
1295 source => (source, None),
1296 };
1297 let (variable, label, access) = node_projection_access(source)?;
1298 let mut required_properties = BTreeSet::new();
1299 if !items.iter().all(|item| {
1300 collect_projection_properties(&item.expression, variable, &mut required_properties)
1301 }) || predicate.is_some_and(|predicate| {
1302 !collect_predicate_properties(predicate, variable, &mut required_properties)
1303 }) {
1304 return None;
1305 }
1306 collect_access_properties(&access, &mut required_properties);
1307
1308 let required_properties = required_properties.into_iter().collect::<Vec<_>>();
1309 decisions.push(format!(
1310 "selected implementation:node_projection_scan: access={} decode {} required properties for {variable}:{label}",
1311 access.kind_name(),
1312 required_properties.len(),
1313 ));
1314 stage_events.push(PLAN_FINALIZATION_STAGE.trace(StageStats::new(1, 1).with_rule_counts(1, 0)));
1315 Some(PhysicalPlan::NodeProjectionScanExec {
1316 variable: variable.clone(),
1317 label: label.clone(),
1318 access,
1319 required_properties,
1320 predicate: predicate.cloned(),
1321 items: items.to_vec(),
1322 })
1323}
1324
1325fn select_node_aggregate_required_scan(
1326 group_keys: &[Projection],
1327 items: &[hawdb_plan::Aggregation],
1328 input: &PhysicalPlan,
1329 decisions: &mut Vec<String>,
1330 stage_events: &mut Vec<StageTrace>,
1331) -> Option<PhysicalPlan> {
1332 let (source, predicate) = match input {
1333 PhysicalPlan::FilterExec { predicate, input } => (input.as_ref(), Some(predicate)),
1334 source => (source, None),
1335 };
1336 let (variable, label, access) = node_projection_access(source)?;
1337 let mut required_properties = BTreeSet::new();
1338 if !group_keys.iter().all(|item| {
1339 collect_projection_properties(&item.expression, variable, &mut required_properties)
1340 }) || !items
1341 .iter()
1342 .all(|item| collect_aggregate_properties(item, variable, &mut required_properties))
1343 || predicate.is_some_and(|predicate| {
1344 !collect_predicate_properties(predicate, variable, &mut required_properties)
1345 })
1346 {
1347 return None;
1348 }
1349 collect_access_properties(&access, &mut required_properties);
1350
1351 let required_properties = required_properties.into_iter().collect::<Vec<_>>();
1352 decisions.push(format!(
1353 "selected implementation:node_aggregate_required_scan: access={} decode {} required properties for {variable}:{label}",
1354 access.kind_name(),
1355 required_properties.len(),
1356 ));
1357 stage_events.push(PLAN_FINALIZATION_STAGE.trace(StageStats::new(1, 1).with_rule_counts(1, 0)));
1358 Some(PhysicalPlan::NodeProjectionScanExec {
1359 variable: variable.clone(),
1360 label: label.clone(),
1361 access,
1362 required_properties,
1363 predicate: predicate.cloned(),
1364 items: Vec::new(),
1365 })
1366}
1367
1368fn collect_aggregate_properties(
1369 item: &hawdb_plan::Aggregation,
1370 variable: &str,
1371 required: &mut BTreeSet<String>,
1372) -> bool {
1373 match (&item.function, &item.target) {
1374 (AggregateFunction::Count, AggregateTarget::All) => true,
1375 (AggregateFunction::Count, AggregateTarget::Variable(current)) => current == variable,
1376 (
1377 AggregateFunction::Count
1378 | AggregateFunction::Min
1379 | AggregateFunction::Max
1380 | AggregateFunction::Avg
1381 | AggregateFunction::Collect,
1382 AggregateTarget::Property {
1383 variable: current,
1384 property,
1385 },
1386 ) if current == variable => {
1387 required.insert(property.clone());
1388 true
1389 }
1390 _ => false,
1391 }
1392}
1393
1394fn node_projection_access(plan: &PhysicalPlan) -> Option<(&String, &String, NodeProjectionAccess)> {
1395 match plan {
1396 PhysicalPlan::SeqNodeScan { variable, label } => {
1397 Some((variable, label, NodeProjectionAccess::LabelScan))
1398 }
1399 PhysicalPlan::IndexNodeSeek {
1400 variable,
1401 label,
1402 property,
1403 value,
1404 } => Some((
1405 variable,
1406 label,
1407 NodeProjectionAccess::PropertyValues {
1408 property: property.clone(),
1409 values: vec![value.clone()],
1410 },
1411 )),
1412 PhysicalPlan::IndexNodeMultiSeek {
1413 variable,
1414 label,
1415 property,
1416 values,
1417 } => Some((
1418 variable,
1419 label,
1420 NodeProjectionAccess::PropertyValues {
1421 property: property.clone(),
1422 values: values.clone(),
1423 },
1424 )),
1425 PhysicalPlan::IndexNodeUnionSeek {
1426 variable,
1427 label,
1428 branches,
1429 } => Some((
1430 variable,
1431 label,
1432 NodeProjectionAccess::PropertyUnion {
1433 branches: branches.clone(),
1434 },
1435 )),
1436 PhysicalPlan::IndexNodeCompositeSeek {
1437 variable,
1438 label,
1439 predicates,
1440 } => Some((
1441 variable,
1442 label,
1443 NodeProjectionAccess::CompositeEquality {
1444 predicates: predicates.clone(),
1445 },
1446 )),
1447 PhysicalPlan::IndexNodeCompositeRangeSeek {
1448 variable,
1449 label,
1450 seek,
1451 } => Some((
1452 variable,
1453 label,
1454 NodeProjectionAccess::CompositeRange { seek: seek.clone() },
1455 )),
1456 PhysicalPlan::IndexNodeRangeSeek {
1457 variable,
1458 label,
1459 property,
1460 lower,
1461 upper,
1462 } => Some((
1463 variable,
1464 label,
1465 NodeProjectionAccess::PropertyRange {
1466 property: property.clone(),
1467 lower: lower.clone(),
1468 upper: upper.clone(),
1469 },
1470 )),
1471 PhysicalPlan::IndexNodeTextSeek {
1472 variable,
1473 label,
1474 property,
1475 query,
1476 } => Some((
1477 variable,
1478 label,
1479 NodeProjectionAccess::FullText {
1480 property: property.clone(),
1481 query: query.clone(),
1482 },
1483 )),
1484 _ => None,
1485 }
1486}
1487
1488fn collect_access_properties(access: &NodeProjectionAccess, required: &mut BTreeSet<String>) {
1489 match access {
1490 NodeProjectionAccess::LabelScan => {}
1491 NodeProjectionAccess::PropertyValues { property, .. }
1492 | NodeProjectionAccess::PropertyRange { property, .. }
1493 | NodeProjectionAccess::FullText { property, .. } => {
1494 required.insert(property.clone());
1495 }
1496 NodeProjectionAccess::CompositeEquality { predicates } => {
1497 required.extend(predicates.iter().map(|(property, _)| property.clone()));
1498 }
1499 NodeProjectionAccess::CompositeRange { seek } => {
1500 required.extend(
1501 seek.equality_prefix
1502 .iter()
1503 .map(|(property, _)| property.clone()),
1504 );
1505 required.insert(seek.range_property.clone());
1506 }
1507 NodeProjectionAccess::PropertyUnion { branches } => {
1508 required.extend(branches.iter().map(|branch| branch.property.clone()));
1509 }
1510 }
1511}
1512
1513fn collect_predicate_properties(
1514 predicate: &Predicate,
1515 variable: &str,
1516 required: &mut BTreeSet<String>,
1517) -> bool {
1518 match predicate {
1519 Predicate::And(predicates) | Predicate::Or(predicates) => predicates
1520 .iter()
1521 .all(|predicate| collect_predicate_properties(predicate, variable, required)),
1522 Predicate::Not(predicate) => collect_predicate_properties(predicate, variable, required),
1523 Predicate::ConstantBool(_) => true,
1524 Predicate::IdEq {
1525 variable: current, ..
1526 }
1527 | Predicate::IdNotEq {
1528 variable: current, ..
1529 }
1530 | Predicate::IdCompare {
1531 variable: current, ..
1532 }
1533 | Predicate::IdIn {
1534 variable: current, ..
1535 } => current == variable,
1536 Predicate::PropertyEq {
1537 variable: current,
1538 property,
1539 ..
1540 }
1541 | Predicate::PropertyNotEq {
1542 variable: current,
1543 property,
1544 ..
1545 }
1546 | Predicate::PropertyCompare {
1547 variable: current,
1548 property,
1549 ..
1550 }
1551 | Predicate::PropertyListContains {
1552 variable: current,
1553 property,
1554 ..
1555 }
1556 | Predicate::PropertyListContainsLower {
1557 variable: current,
1558 property,
1559 ..
1560 }
1561 | Predicate::PropertyContains {
1562 variable: current,
1563 property,
1564 ..
1565 }
1566 | Predicate::PropertyStartsWith {
1567 variable: current,
1568 property,
1569 ..
1570 }
1571 | Predicate::PropertyEndsWith {
1572 variable: current,
1573 property,
1574 ..
1575 }
1576 | Predicate::PropertyRegexMatch {
1577 variable: current,
1578 property,
1579 ..
1580 }
1581 | Predicate::PropertyIsNull {
1582 variable: current,
1583 property,
1584 }
1585 | Predicate::PropertyIsNotNull {
1586 variable: current,
1587 property,
1588 }
1589 | Predicate::PropertyIn {
1590 variable: current,
1591 property,
1592 ..
1593 } => {
1594 if current != variable {
1595 return false;
1596 }
1597 required.insert(property.clone());
1598 true
1599 }
1600 Predicate::ExpressionEq { expression, value }
1601 | Predicate::ExpressionNotEq { expression, value }
1602 | Predicate::ExpressionContains { expression, value } => {
1603 collect_projection_properties(expression, variable, required)
1604 && collect_projection_properties(value, variable, required)
1605 }
1606 Predicate::ExpressionCompare {
1607 expression, value, ..
1608 } => {
1609 collect_projection_properties(expression, variable, required)
1610 && collect_projection_properties(value, variable, required)
1611 }
1612 Predicate::RelationshipExists { .. } | Predicate::BoundRelationshipExists { .. } => false,
1613 }
1614}
1615
1616fn collect_projection_properties(
1617 expression: &ProjectionExpression,
1618 variable: &str,
1619 required: &mut BTreeSet<String>,
1620) -> bool {
1621 match expression {
1622 ProjectionExpression::Case { .. }
1623 | ProjectionExpression::Binary { .. }
1624 | ProjectionExpression::Not(_)
1625 | ProjectionExpression::IsNull { .. } => expression
1626 .all_children(|child| collect_projection_properties(child, variable, required)),
1627 ProjectionExpression::Variable { .. }
1628 | ProjectionExpression::RelationshipType { .. }
1629 | ProjectionExpression::Column(_)
1630 | ProjectionExpression::ColumnProperty { .. }
1631 | ProjectionExpression::ColumnDefaultIfNullOrEq { .. }
1632 | ProjectionExpression::ColumnValueDefaultIfNull { .. }
1633 | ProjectionExpression::ColumnValueCasePropertyNotNullOrEq { .. }
1634 | ProjectionExpression::CaseColumnSearchRank(_) => false,
1635 ProjectionExpression::Id { variable: current } => current == variable,
1636 ProjectionExpression::Literal(_) => true,
1637 ProjectionExpression::Property {
1638 variable: current,
1639 property,
1640 }
1641 | ProjectionExpression::DatePart {
1642 variable: current,
1643 property,
1644 ..
1645 }
1646 | ProjectionExpression::DefaultIfNullOrEq {
1647 variable: current,
1648 property,
1649 ..
1650 }
1651 | ProjectionExpression::DefaultIfNull {
1652 variable: current,
1653 property,
1654 ..
1655 }
1656 | ProjectionExpression::CasePropertyNotNullOrEq {
1657 variable: current,
1658 property,
1659 ..
1660 }
1661 | ProjectionExpression::CasePropertyEqualsRank {
1662 variable: current,
1663 property,
1664 ..
1665 }
1666 | ProjectionExpression::CaseLowerPropertyDefault {
1667 variable: current,
1668 property,
1669 ..
1670 } => {
1671 if current != variable {
1672 return false;
1673 }
1674 required.insert(property.clone());
1675 true
1676 }
1677 ProjectionExpression::Coalesce(expressions) => expressions
1678 .iter()
1679 .all(|expression| collect_projection_properties(expression, variable, required)),
1680 ProjectionExpression::Left { expression, .. } | ProjectionExpression::Lower(expression) => {
1681 collect_projection_properties(expression, variable, required)
1682 }
1683 ProjectionExpression::CaseCoalesceDifferenceFloorZero {
1684 variable: current,
1685 terms,
1686 } => {
1687 if current != variable {
1688 return false;
1689 }
1690 required.extend(terms.iter().map(|term| term.property.clone()));
1691 true
1692 }
1693 ProjectionExpression::CaseEntitySearchRank(rank) => {
1694 if rank.variable != variable {
1695 return false;
1696 }
1697 required.insert(rank.name_property.clone());
1698 required.insert(rank.aliases_property.clone());
1699 true
1700 }
1701 }
1702}
1703
1704struct ExpandEstimateRequest<'a> {
1705 source_label: &'a str,
1706 rel_type: &'a str,
1707 rel_properties: &'a BTreeMap<String, Value>,
1708 target_label: &'a str,
1709 min_hops: usize,
1710 max_hops: usize,
1711}
1712
1713fn push_expand_estimate_decision(
1714 catalog: &OptimizerCatalog,
1715 decisions: &mut Vec<String>,
1716 request: ExpandEstimateRequest<'_>,
1717) {
1718 let estimate = catalog.estimate_expand_rows(
1719 request.source_label,
1720 request.rel_type,
1721 request.rel_properties,
1722 request.target_label,
1723 request.min_hops,
1724 request.max_hops,
1725 );
1726 let path_count = estimate
1727 .path_count
1728 .map(|count| count.to_string())
1729 .unwrap_or_else(|| "unknown".to_string());
1730 let hop_rows = estimate
1731 .hop_estimates
1732 .iter()
1733 .map(|estimate| {
1734 format!(
1735 "{}:{}:{}",
1736 estimate.hop,
1737 if estimate.exact { "exact" } else { "fallback" },
1738 estimate.rows
1739 )
1740 })
1741 .collect::<Vec<_>>()
1742 .join(",");
1743 decisions.push(format!(
1744 "estimate AdjacencyExpand for {}-[:{}*{}..{}]->{}: path_count={path_count} rel_count={} rel_type_sources={} average_fanout={} rel_property_distinct_product={} hop_rows=[{}] estimated_rows={}",
1745 request.source_label,
1746 request.rel_type,
1747 request.min_hops,
1748 request.max_hops,
1749 request.target_label,
1750 estimate.rel_count,
1751 estimate.source_count,
1752 estimate.average_fanout,
1753 estimate.property_distinct_product,
1754 hop_rows,
1755 estimate.estimated_rows
1756 ));
1757}
1758
1759fn order_single_row_cartesian_product_children(
1760 catalog: &OptimizerCatalog,
1761 decisions: &mut Vec<String>,
1762 left: PhysicalPlan,
1763 right: PhysicalPlan,
1764) -> (PhysicalPlan, PhysicalPlan) {
1765 let left_cost = estimate_physical_plan_cost(&left, catalog);
1766 let right_cost = estimate_physical_plan_cost(&right, catalog);
1767 if !cartesian_product_leaves_are_single_row(catalog, &left)
1768 || !cartesian_product_leaves_are_single_row(catalog, &right)
1769 {
1770 decisions.push(format!(
1771 "keep NodeCartesianProduct input order: left_rows={} right_rows={} reason=non_single_row_input",
1772 left_cost.estimated_rows, right_cost.estimated_rows
1773 ));
1774 return (left, right);
1775 }
1776
1777 let mut leaves = Vec::new();
1778 collect_cartesian_product_leaves(left, &mut leaves);
1779 collect_cartesian_product_leaves(right, &mut leaves);
1780
1781 let mut keyed_leaves = leaves
1782 .into_iter()
1783 .map(|plan| {
1784 let cost = estimate_physical_plan_cost(&plan, catalog);
1785 let fingerprint = plan.instance_fingerprint();
1786 (cost, fingerprint, plan)
1787 })
1788 .collect::<Vec<_>>();
1789
1790 let original_order = keyed_leaves
1791 .iter()
1792 .map(|(cost, fingerprint, _)| format!("{}:{}", cost.cost, fingerprint))
1793 .collect::<Vec<_>>()
1794 .join("|");
1795 keyed_leaves.sort_by(
1796 |(left_cost, left_fingerprint, _), (right_cost, right_fingerprint, _)| {
1797 (left_cost.cost, left_fingerprint).cmp(&(right_cost.cost, right_fingerprint))
1798 },
1799 );
1800 let ordered = keyed_leaves
1801 .iter()
1802 .map(|(cost, fingerprint, _)| format!("{}:{}", cost.cost, fingerprint))
1803 .collect::<Vec<_>>()
1804 .join("|");
1805 if original_order == ordered {
1806 decisions.push(format!(
1807 "keep NodeCartesianProduct single-row input order: inputs={} order={ordered}",
1808 keyed_leaves.len()
1809 ));
1810 } else {
1811 decisions.push(format!(
1812 "order NodeCartesianProduct single-row inputs: inputs={} original={original_order} ordered={ordered}",
1813 keyed_leaves.len()
1814 ));
1815 }
1816
1817 let mut ordered_plans = keyed_leaves.into_iter().map(|(_, _, plan)| plan);
1818 let left = ordered_plans
1819 .next()
1820 .expect("cartesian product has left input");
1821 let right = rebuild_cartesian_product(ordered_plans);
1822 (left, right)
1823}
1824
1825fn cartesian_product_leaves_are_single_row(
1826 catalog: &OptimizerCatalog,
1827 plan: &PhysicalPlan,
1828) -> bool {
1829 match plan {
1830 PhysicalPlan::NodeCartesianProductExec { left, right } => {
1831 cartesian_product_leaves_are_single_row(catalog, left)
1832 && cartesian_product_leaves_are_single_row(catalog, right)
1833 }
1834 plan => estimate_physical_plan_cost(plan, catalog).estimated_rows == 1,
1835 }
1836}
1837
1838fn collect_cartesian_product_leaves(plan: PhysicalPlan, leaves: &mut Vec<PhysicalPlan>) {
1839 match plan {
1840 PhysicalPlan::NodeCartesianProductExec { left, right } => {
1841 collect_cartesian_product_leaves(*left, leaves);
1842 collect_cartesian_product_leaves(*right, leaves);
1843 }
1844 plan => leaves.push(plan),
1845 }
1846}
1847
1848fn rebuild_cartesian_product(plans: impl IntoIterator<Item = PhysicalPlan>) -> PhysicalPlan {
1849 let mut plans = plans.into_iter();
1850 let first = plans
1851 .next()
1852 .expect("cartesian product rebuild requires at least one input");
1853 plans.fold(first, |left, right| {
1854 PhysicalPlan::NodeCartesianProductExec {
1855 left: Box::new(left),
1856 right: Box::new(right),
1857 }
1858 })
1859}
1860
1861fn push_cartesian_product_cost_decision(
1862 catalog: &OptimizerCatalog,
1863 decisions: &mut Vec<String>,
1864 left: &PhysicalPlan,
1865 right: &PhysicalPlan,
1866) {
1867 let left_cost = estimate_physical_plan_cost(left, catalog);
1868 let right_cost = estimate_physical_plan_cost(right, catalog);
1869 let cost = estimate_node_cartesian_product_cost(left_cost, right_cost);
1870 decisions.push(format!(
1871 "estimate NodeCartesianProduct: left_rows={} right_rows={} output_rows={} left_cost={} right_cost={} cost={}",
1872 left_cost.estimated_rows,
1873 right_cost.estimated_rows,
1874 cost.estimated_rows,
1875 left_cost.cost,
1876 right_cost.cost,
1877 cost.cost
1878 ));
1879}