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