Skip to main content

linkrs_query/planning/
physical_mapper.rs

1//! Full logical-to-physical plan mapping.
2//!
3//! Converts a logical plan tree into a physical plan tree in one pass,
4//! honoring the cost-based index hints recorded on logical scan nodes and
5//! preserving factorization operators. The optimizer engine merges the
6//! mapped tree with the cost-based physical tree so physical choices made
7//! directly on the physical root (index scans with limits, TopN) survive.
8
9use crate::planning::plan::core::nodes::access::index_scan::IndexScanNode;
10use crate::planning::plan::core::nodes::base::plan_node_traits::SingleInputNode;
11use crate::planning::plan::logical::logical_nodes::access::{
12    IndexHint, LogicalScanEdgesNode, LogicalScanVerticesNode,
13};
14use crate::planning::plan::logical::LogicalNodeEnum;
15use crate::planning::plan::PlanNodeEnum;
16
17/// Full logical-to-physical mapper.
18///
19/// The entry points are [`PhysicalMapper::map`] (logical tree to physical
20/// tree) and [`PhysicalMapper::merge_physical_hints`] (overlay of the
21/// cost-based physical choices onto a mapped tree).
22pub struct PhysicalMapper;
23
24impl PhysicalMapper {
25    /// Convert a logical plan tree into a physical plan tree.
26    ///
27    /// The structure mirrors the shared logical-to-physical converter;
28    /// scan nodes carrying an index hint become index scans instead.
29    pub fn map(logical: LogicalNodeEnum) -> PlanNodeEnum {
30        // Collect index hints by reference first so the converter below can
31        // consume the logical tree by value: only hinted leaf scans are
32        // cloned instead of the whole tree.
33        let hints = collect_index_hints(&logical);
34        let physical = crate::planning::physical_planner::convert_logical_to_physical(logical);
35        apply_collected_hints(physical, &hints)
36    }
37
38    /// Report whether a logical tree needs the full mapping path.
39    ///
40    /// Trees without factorization operators or index hints map to the
41    /// same structure the physical root already has, so the engine keeps
42    /// the physical root untouched in that case.
43    pub(crate) fn needs_physical_mapping(logical: &LogicalNodeEnum) -> bool {
44        if matches!(logical, LogicalNodeEnum::Flatten(_)) {
45            return true;
46        }
47        if let LogicalNodeEnum::ScanVertices(scan) = logical {
48            if scan.index_hint.is_some() {
49                return true;
50            }
51        }
52        if let LogicalNodeEnum::ScanEdges(scan) = logical {
53            if scan.index_hint.is_some() {
54                return true;
55            }
56        }
57        if let LogicalNodeEnum::GetNeighbors(node) = logical {
58            if node.index_hint.is_some() {
59                return true;
60            }
61        }
62        logical_children(logical)
63            .iter()
64            .any(|child| Self::needs_physical_mapping(child))
65    }
66
67    /// Merge a fully mapped tree with the cost-based physical tree.
68    ///
69    /// The mapped tree supplies structure (notably factorization operator
70    /// positions); the physical tree supplies cost-based access choices.
71    /// Returns the merged tree plus notes for every position where the
72    /// trees diverged and the physical choice won, so no fallback is
73    /// silent.
74    pub fn merge_physical_hints(
75        mapped: PlanNodeEnum,
76        physical: PlanNodeEnum,
77    ) -> (PlanNodeEnum, Vec<String>) {
78        let mut notes = Vec::new();
79        let mut mapped = mapped;
80        merge_inner(&mut mapped, physical, &mut notes);
81        (mapped, notes)
82    }
83
84    /// Collect factorization operator positions for plan diagnostics.
85    pub(crate) fn collect_flatten_positions(node: &LogicalNodeEnum, out: &mut Vec<u32>) {
86        if let LogicalNodeEnum::Flatten(flatten) = node {
87            out.push(flatten.group_pos);
88        }
89        for child in logical_children(node) {
90            Self::collect_flatten_positions(child, out);
91        }
92    }
93}
94
95/// A hinted leaf scan collected from the logical tree with its position.
96///
97/// Only the scan node itself is cloned (it carries no subtree); the path
98/// records child indices from the root following [`logical_children`] order,
99/// which the shared converter preserves one-to-one.
100struct IndexHintTarget {
101    path: Vec<usize>,
102    scan: HintedScan,
103}
104
105/// A logical scan node carrying an index hint.
106enum HintedScan {
107    Vertices(LogicalScanVerticesNode),
108    Edges(LogicalScanEdgesNode),
109}
110
111/// Walk the logical tree by reference and collect every hinted scan.
112fn collect_index_hints(logical: &LogicalNodeEnum) -> Vec<IndexHintTarget> {
113    fn walk(node: &LogicalNodeEnum, path: &mut Vec<usize>, out: &mut Vec<IndexHintTarget>) {
114        match node {
115            LogicalNodeEnum::ScanVertices(scan) if scan.index_hint.is_some() => {
116                out.push(IndexHintTarget {
117                    path: path.clone(),
118                    scan: HintedScan::Vertices(scan.clone()),
119                });
120            }
121            LogicalNodeEnum::ScanEdges(scan) if scan.index_hint.is_some() => {
122                out.push(IndexHintTarget {
123                    path: path.clone(),
124                    scan: HintedScan::Edges(scan.clone()),
125                });
126            }
127            _ => {}
128        }
129        for (index, child) in logical_children(node).iter().enumerate() {
130            path.push(index);
131            walk(child, path, out);
132            path.pop();
133        }
134    }
135
136    let mut out = Vec::new();
137    walk(logical, &mut Vec::new(), &mut out);
138    out
139}
140
141/// Overlay collected index hints onto a converted physical tree.
142///
143/// Each hint descends by its recorded child-index path; hints whose path no
144/// longer resolves (or whose target is not the matching scan) are skipped,
145/// keeping the converted node unchanged.
146fn apply_collected_hints(mut physical: PlanNodeEnum, hints: &[IndexHintTarget]) -> PlanNodeEnum {
147    for hint in hints {
148        let Some(target) = descend_to_path(&mut physical, &hint.path) else {
149            continue;
150        };
151        let replacement = match (&hint.scan, &*target) {
152            (HintedScan::Vertices(scan), PlanNodeEnum::ScanVertices(_)) => scan
153                .index_hint
154                .as_ref()
155                .map(|hint| index_scan_from_hint(scan, hint)),
156            (HintedScan::Edges(scan), PlanNodeEnum::ScanEdges(_)) => scan
157                .index_hint
158                .as_ref()
159                .map(|hint| index_scan_from_edge_hint(scan, hint)),
160            _ => None,
161        };
162        if let Some(node) = replacement {
163            *target = node;
164        }
165    }
166    physical
167}
168
169/// Descend a physical tree by child-index path, returning the target slot.
170///
171/// Returns `None` when any step does not resolve, mirroring the previous
172/// parallel walk's shape-divergence fallback.
173fn descend_to_path<'a>(
174    mut node: &'a mut PlanNodeEnum,
175    path: &[usize],
176) -> Option<&'a mut PlanNodeEnum> {
177    use crate::optimizer::cost::child_accessor::ChildAccessor;
178
179    for &index in path {
180        node = node.get_child_mut(index)?;
181    }
182    Some(node)
183}
184
185/// Build an index scan from a hinted logical scan.
186///
187/// Column bindings, the residual filter, the projection list and the
188/// limit travel with the node; scan limits stay with the cost-based
189/// physical choice and win during the merge when present.
190fn index_scan_from_hint(scan: &LogicalScanVerticesNode, hint: &IndexHint) -> PlanNodeEnum {
191    let mut node = IndexScanNode::new_with_str(
192        scan.space_id,
193        hint.tag_id,
194        hint.index_id,
195        &hint.index_name,
196        &hint.schema_name,
197        &hint.scan_type,
198    );
199    if let Some(expression) = &scan.expression {
200        node.set_filter(expression.clone());
201    }
202    node.set_return_columns(scan.projected_properties.clone());
203    if let Some(limit) = scan.limit {
204        node.set_limit(limit);
205    }
206    if let Some(output_var) = &scan.output_var {
207        node.set_output_var(output_var.clone());
208    }
209    node.set_col_names(scan.col_names.clone());
210    node.set_column_types(scan.column_types.clone());
211    PlanNodeEnum::IndexScan(node)
212}
213
214/// Build an index scan from a hinted logical edge scan.
215///
216/// Mirrors [`index_scan_from_hint`] for edge lookups: the edge type name
217/// travels as the schema name and the tag id is 0 (edges have no tag id).
218fn index_scan_from_edge_hint(scan: &LogicalScanEdgesNode, hint: &IndexHint) -> PlanNodeEnum {
219    let mut node = IndexScanNode::new_with_str(
220        scan.space_id,
221        hint.tag_id,
222        hint.index_id,
223        &hint.index_name,
224        &hint.schema_name,
225        &hint.scan_type,
226    );
227    if let Some(expression) = &scan.expression {
228        node.set_filter(expression.clone());
229    }
230    node.set_return_columns(scan.projected_properties.clone());
231    if let Some(limit) = scan.limit {
232        node.set_limit(limit);
233    }
234    if let Some(output_var) = &scan.output_var {
235        node.set_output_var(output_var.clone());
236    }
237    node.set_col_names(scan.col_names.clone());
238    node.set_column_types(scan.column_types.clone());
239    PlanNodeEnum::IndexScan(node)
240}
241
242/// Merge one mapped/physical node pair; collects divergence notes.
243///
244/// The mapped tree is rewritten in place while the physical tree is consumed
245/// by value, so each recursion level holds one pointer plus one owned node
246/// instead of two owned trees.
247fn merge_inner(mapped: &mut PlanNodeEnum, physical: PlanNodeEnum, notes: &mut Vec<String>) {
248    // Factorization operators live only on the mapped side and are always
249    // preserved; the merge continues below them against the same physical
250    // node.
251    if let PlanNodeEnum::Flatten(flatten) = mapped {
252        let placeholder = PlanNodeEnum::Start(crate::planning::plan::core::nodes::StartNode::new());
253        let mut child = std::mem::replace(flatten.input_mut(), placeholder);
254        merge_inner(&mut child, physical, notes);
255        flatten.set_input(child);
256        return;
257    }
258    // A cost-based index scan (with limits) always wins over a mapped
259    // full scan or a limit-less mapped index scan.
260    if let PlanNodeEnum::IndexScan(_) = &physical {
261        if matches!(
262            &*mapped,
263            PlanNodeEnum::ScanVertices(_) | PlanNodeEnum::IndexScan(_)
264        ) {
265            *mapped = physical;
266            return;
267        }
268    }
269    // A mapped index scan wins over a physical full scan: the logical
270    // decision fired where the physical rewrite did not.
271    if let PlanNodeEnum::IndexScan(_) = &*mapped {
272        if matches!(
273            &physical,
274            PlanNodeEnum::ScanVertices(_) | PlanNodeEnum::ScanEdges(_)
275        ) {
276            return;
277        }
278    }
279    // A wired TopN wins over the Sort+Limit shape it was built from.
280    if let PlanNodeEnum::TopN(_) = &physical {
281        if matches!(
282            &*mapped,
283            PlanNodeEnum::TopN(_) | PlanNodeEnum::Sort(_) | PlanNodeEnum::Limit(_)
284        ) {
285            *mapped = physical;
286            return;
287        }
288    }
289    if std::mem::discriminant(&*mapped) == std::mem::discriminant(&physical) {
290        use crate::optimizer::cost::child_accessor::ChildAccessor;
291
292        // Compare child counts before detaching either side so the
293        // divergence fallback below returns the untouched physical subtree.
294        if mapped.child_count() != physical.child_count() {
295            notes.push(format!(
296                "PhysicalMapping: child count diverged (mapped {} vs physical {}); kept physical subtree",
297                mapped.type_name(),
298                physical.type_name()
299            ));
300            *mapped = physical;
301            return;
302        }
303        let mut physical = physical;
304        let mut mapped_children = mapped.take_children();
305        let physical_children = physical.take_children();
306        let mut new_children = Vec::with_capacity(mapped_children.len());
307        for (mapped_child, physical_child) in mapped_children.drain(..).zip(physical_children) {
308            let mut merged_child = mapped_child;
309            merge_inner(&mut merged_child, physical_child, notes);
310            new_children.push(merged_child);
311        }
312        match physical.set_children(new_children) {
313            Ok(()) => {
314                *mapped = physical;
315            }
316            Err(message) => {
317                notes.push(format!(
318                    "PhysicalMapping: rebuild failed for {} ({message}); kept physical subtree",
319                    physical.type_name()
320                ));
321                *mapped = physical;
322            }
323        }
324        return;
325    }
326    notes.push(format!(
327        "PhysicalMapping: structure diverged (mapped {} vs physical {}); kept physical subtree",
328        mapped.type_name(),
329        physical.type_name()
330    ));
331    *mapped = physical;
332}
333
334pub(crate) fn logical_children(
335    node: &crate::planning::plan::logical::LogicalNodeEnum,
336) -> Vec<&crate::planning::plan::logical::LogicalNodeEnum> {
337    use crate::planning::plan::logical::LogicalNodeEnum;
338    match node {
339        LogicalNodeEnum::Flatten(n) => n.input.as_deref().map(|c| vec![c]).unwrap_or_default(),
340        LogicalNodeEnum::Project(n) => n.input.as_deref().map(|c| vec![c]).unwrap_or_default(),
341        LogicalNodeEnum::Filter(n) => n.input.as_deref().map(|c| vec![c]).unwrap_or_default(),
342        LogicalNodeEnum::Sort(n) => n.input.as_deref().map(|c| vec![c]).unwrap_or_default(),
343        LogicalNodeEnum::Limit(n) => n.input.as_deref().map(|c| vec![c]).unwrap_or_default(),
344        LogicalNodeEnum::Skip(n) => n.input.as_deref().map(|c| vec![c]).unwrap_or_default(),
345        LogicalNodeEnum::TopN(n) => n.input.as_deref().map(|c| vec![c]).unwrap_or_default(),
346        LogicalNodeEnum::Sample(n) => n.input.as_deref().map(|c| vec![c]).unwrap_or_default(),
347        LogicalNodeEnum::Dedup(n) => n.input.as_deref().map(|c| vec![c]).unwrap_or_default(),
348        LogicalNodeEnum::Aggregate(n) => n.input.as_deref().map(|c| vec![c]).unwrap_or_default(),
349        LogicalNodeEnum::Window(n) => n.input.as_deref().map(|c| vec![c]).unwrap_or_default(),
350        LogicalNodeEnum::Traverse(n) => n.input.as_deref().map(|c| vec![c]).unwrap_or_default(),
351        LogicalNodeEnum::Assign(n) => n.input.as_deref().map(|c| vec![c]).unwrap_or_default(),
352        LogicalNodeEnum::PipeDeleteVertices(n) => {
353            n.input.as_deref().map(|c| vec![c]).unwrap_or_default()
354        }
355        LogicalNodeEnum::PipeDeleteEdges(n) => {
356            n.input.as_deref().map(|c| vec![c]).unwrap_or_default()
357        }
358        LogicalNodeEnum::DataCollect(n) => n.input.as_deref().map(|c| vec![c]).unwrap_or_default(),
359        LogicalNodeEnum::Materialize(n) => n.input.as_deref().map(|c| vec![c]).unwrap_or_default(),
360        LogicalNodeEnum::RollUpApply(n) => n.input.as_deref().map(|c| vec![c]).unwrap_or_default(),
361        LogicalNodeEnum::Unwind(n) => n.input.as_deref().map(|c| vec![c]).unwrap_or_default(),
362        LogicalNodeEnum::Select(n) => {
363            let mut v = Vec::new();
364            if let Some(b) = n.if_branch() {
365                v.push(b);
366            }
367            if let Some(b) = n.else_branch() {
368                v.push(b);
369            }
370            v
371        }
372        LogicalNodeEnum::Loop(n) => n.body().map(|b| vec![b]).unwrap_or_default(),
373        LogicalNodeEnum::InnerJoin(n) => vec![&n.left, &n.right],
374        LogicalNodeEnum::LeftJoin(n) => vec![&n.left, &n.right],
375        LogicalNodeEnum::RightJoin(n) => vec![&n.left, &n.right],
376        LogicalNodeEnum::CrossJoin(n) => vec![&n.left, &n.right],
377        LogicalNodeEnum::FullOuterJoin(n) => vec![&n.left, &n.right],
378        LogicalNodeEnum::SemiJoin(n) => vec![&n.left, &n.right],
379        LogicalNodeEnum::PatternApply(n) => vec![&n.left, &n.right],
380        LogicalNodeEnum::CorrelatedApply(n) => vec![&n.left, &n.right],
381        LogicalNodeEnum::Apply(n) => vec![n.left_input(), n.right_input()],
382        LogicalNodeEnum::BiExpand(n) => vec![&n.left, &n.right],
383        LogicalNodeEnum::BiTraverse(n) => vec![&n.left, &n.right],
384        LogicalNodeEnum::MultiShortestPath(n) => vec![&n.left, &n.right],
385        LogicalNodeEnum::BFSShortest(n) => vec![&n.left, &n.right],
386        LogicalNodeEnum::AllPaths(n) => vec![&n.left, &n.right],
387        LogicalNodeEnum::ShortestPath(n) => vec![&n.left, &n.right],
388        LogicalNodeEnum::Expand(n) => n.deps.iter().collect(),
389        LogicalNodeEnum::ExpandAll(n) => n.deps.iter().collect(),
390        LogicalNodeEnum::AppendVertices(n) => n.deps.iter().collect(),
391        LogicalNodeEnum::GetVertices(n) => n.deps.iter().collect(),
392        LogicalNodeEnum::GetNeighbors(n) => n.deps.iter().collect(),
393        LogicalNodeEnum::Union(n) => n.deps.iter().collect(),
394        LogicalNodeEnum::Minus(n) => n.deps.iter().collect(),
395        LogicalNodeEnum::Intersect(n) => n.deps.iter().collect(),
396        LogicalNodeEnum::WcoIntersect(n) => n.deps.iter().collect(),
397        LogicalNodeEnum::Start(_)
398        | LogicalNodeEnum::ScanVertices(_)
399        | LogicalNodeEnum::ScanEdges(_)
400        | LogicalNodeEnum::GetEdges(_)
401        | LogicalNodeEnum::Argument(_)
402        | LogicalNodeEnum::PassThrough(_)
403        | LogicalNodeEnum::BeginTransaction(_)
404        | LogicalNodeEnum::Commit(_)
405        | LogicalNodeEnum::Rollback(_)
406        | LogicalNodeEnum::InsertVertices(_)
407        | LogicalNodeEnum::InsertEdges(_)
408        | LogicalNodeEnum::Update(_)
409        | LogicalNodeEnum::DeleteVertices(_)
410        | LogicalNodeEnum::DeleteEdges(_)
411        | LogicalNodeEnum::DeleteIndex(_)
412        | LogicalNodeEnum::CopyFrom(_)
413        | LogicalNodeEnum::CopyTo(_)
414        | LogicalNodeEnum::FulltextSearch(_)
415        | LogicalNodeEnum::FulltextLookup(_)
416        | LogicalNodeEnum::MatchFulltext(_) => vec![],
417        #[cfg(feature = "vector")]
418        LogicalNodeEnum::VectorSearch(_)
419        | LogicalNodeEnum::VectorLookup(_)
420        | LogicalNodeEnum::VectorMatch(_) => vec![],
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use crate::planning::plan::core::nodes::access::graph_scan_node::ScanVerticesNode;
428    use crate::planning::plan::core::nodes::access::index_scan::IndexScanNode;
429    use crate::planning::plan::core::nodes::base::plan_node_traits::SingleInputNode;
430    use crate::planning::plan::core::nodes::operation::filter_node::FilterNode;
431    use crate::planning::plan::core::nodes::operation::flatten_node::FlattenNode;
432    use crate::planning::plan::logical::logical_nodes::flatten::LogicalFlattenNode;
433    use crate::planning::plan::logical::logical_nodes::operation::LogicalFilterNode;
434    use linkrs_core::types::expr::expression_context::ExpressionAnalysisContext;
435    use linkrs_core::types::expr::Expression;
436    use linkrs_core::types::expr::{ContextualExpression, ExpressionMeta};
437    use linkrs_core::types::operators::BinaryOperator;
438    use linkrs_core::value::Value;
439    use std::sync::Arc;
440
441    fn hinted_scan() -> LogicalNodeEnum {
442        LogicalNodeEnum::ScanVertices(LogicalScanVerticesNode {
443            id: 1,
444            space_id: 1,
445            space_name: "test".to_string(),
446            tag: Some("person".to_string()),
447            expression: None,
448            limit: None,
449            projected_properties: vec![],
450            index_hint: Some(IndexHint::new(
451                "idx_name".to_string(),
452                "person".to_string(),
453                7,
454                9,
455                "RANGE".to_string(),
456            )),
457            estimated_cardinality: Some(42),
458            output_var: Some("n".to_string()),
459            col_names: vec!["n".to_string()],
460            column_types: vec![],
461        })
462    }
463
464    fn filter_over(logical_input: LogicalNodeEnum) -> LogicalNodeEnum {
465        let context = Arc::new(ExpressionAnalysisContext::new());
466        let expression = Expression::Binary {
467            left: Box::new(Expression::Property {
468                object: Box::new(Expression::Variable("n".to_string())),
469                property: "name".to_string(),
470            }),
471            op: BinaryOperator::Equal,
472            right: Box::new(Expression::Literal(Value::String("alice".into()))),
473        };
474        let id = context.register_expression(ExpressionMeta::new(expression));
475        LogicalNodeEnum::Filter(LogicalFilterNode {
476            id: 2,
477            input: Some(Box::new(logical_input)),
478            condition: ContextualExpression::new(id, context),
479            output_var: None,
480            col_names: vec![],
481            column_types: vec![],
482        })
483    }
484
485    #[test]
486    fn map_hinted_scan_becomes_index_scan() {
487        let mapped = PhysicalMapper::map(filter_over(hinted_scan()));
488        let filter = match &mapped {
489            PlanNodeEnum::Filter(f) => f,
490            other => panic!("expected physical filter, got {}", other.type_name()),
491        };
492        match filter.input() {
493            PlanNodeEnum::IndexScan(scan) => {
494                assert_eq!(scan.index_name(), "idx_name");
495                assert_eq!(scan.tag_id(), 7);
496                assert_eq!(scan.index_id(), 9);
497            }
498            other => panic!("expected index scan, got {}", other.type_name()),
499        }
500    }
501
502    #[test]
503    fn map_flatten_preserves_group_column_mapping() {
504        let mut flatten = LogicalFlattenNode::new(2, hinted_scan());
505        flatten.set_group_columns(vec!["b".to_string()]);
506        flatten.set_expected_groups(3);
507        let mapped = PhysicalMapper::map(LogicalNodeEnum::Flatten(flatten));
508        match &mapped {
509            PlanNodeEnum::Flatten(f) => {
510                assert_eq!(f.group_pos(), 2);
511                assert_eq!(f.group_columns(), &["b".to_string()]);
512                assert_eq!(f.expected_groups(), Some(3));
513            }
514            other => panic!("expected physical flatten, got {}", other.type_name()),
515        }
516    }
517
518    #[test]
519    fn map_hinted_scan_under_flatten_becomes_index_scan() {
520        let mapped = PhysicalMapper::map(LogicalNodeEnum::Flatten(LogicalFlattenNode::new(
521            0,
522            hinted_scan(),
523        )));
524        let flatten = match &mapped {
525            PlanNodeEnum::Flatten(f) => f,
526            other => panic!("expected physical flatten, got {}", other.type_name()),
527        };
528        match flatten.input() {
529            PlanNodeEnum::IndexScan(scan) => {
530                assert_eq!(scan.index_name(), "idx_name");
531            }
532            other => panic!("expected index scan, got {}", other.type_name()),
533        }
534    }
535
536    #[test]
537    fn merge_preserves_flatten_and_physical_index_scan() {
538        let mapped = PhysicalMapper::map(LogicalNodeEnum::Flatten(LogicalFlattenNode::new(
539            0,
540            hinted_scan(),
541        )));
542        let mut physical_scan = IndexScanNode::new(
543            1,
544            7,
545            9,
546            "idx_name".to_string(),
547            "person".to_string(),
548            crate::planning::plan::core::nodes::access::index_scan::ScanType::Range,
549        );
550        physical_scan.set_col_names(vec!["n".to_string()]);
551        let physical = PlanNodeEnum::IndexScan(physical_scan);
552        let (merged, notes) = PhysicalMapper::merge_physical_hints(mapped, physical);
553        assert!(notes.is_empty());
554        let flatten = match &merged {
555            PlanNodeEnum::Flatten(f) => f,
556            other => panic!("expected flatten, got {}", other.type_name()),
557        };
558        assert!(matches!(flatten.input(), PlanNodeEnum::IndexScan(_)));
559    }
560
561    #[test]
562    fn merge_divergence_keeps_physical_with_note() {
563        let mapped = PhysicalMapper::map(hinted_scan());
564        let mut scan = ScanVerticesNode::new(1, "test");
565        scan.set_col_names(vec!["n".to_string()]);
566        let context = Arc::new(ExpressionAnalysisContext::new());
567        let id =
568            context.register_expression(ExpressionMeta::new(Expression::Literal(Value::Int(1))));
569        let physical = PlanNodeEnum::Filter(
570            FilterNode::new(
571                PlanNodeEnum::ScanVertices(scan),
572                ContextualExpression::new(id, context),
573            )
574            .expect("filter"),
575        );
576        let (merged, notes) = PhysicalMapper::merge_physical_hints(mapped, physical);
577        assert!(matches!(merged, PlanNodeEnum::Filter(_)));
578        assert_eq!(notes.len(), 1);
579        assert!(notes[0].contains("diverged"));
580    }
581
582    #[test]
583    fn unmapped_tree_needs_no_mapping() {
584        let mut scan = hinted_scan();
585        if let LogicalNodeEnum::ScanVertices(s) = &mut scan {
586            s.index_hint = None;
587            s.estimated_cardinality = None;
588        }
589        assert!(!PhysicalMapper::needs_physical_mapping(&scan));
590        assert!(PhysicalMapper::needs_physical_mapping(&hinted_scan()));
591    }
592
593    #[test]
594    fn flatten_take_set_roundtrip() {
595        let mut scan = ScanVerticesNode::new(1, "test");
596        scan.set_col_names(vec!["n".to_string()]);
597        let child = PlanNodeEnum::ScanVertices(scan);
598        let flatten = FlattenNode::new(child, 0).expect("flatten");
599        let mut node = PlanNodeEnum::Flatten(flatten);
600        let taken = node.take_children();
601        assert_eq!(taken.len(), 1);
602        node.set_children(vec![PlanNodeEnum::ScanVertices({
603            let mut s = ScanVerticesNode::new(1, "test");
604            s.set_col_names(vec!["n".to_string()]);
605            s
606        })])
607        .expect("set");
608        assert!(matches!(node, PlanNodeEnum::Flatten(_)));
609    }
610}