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