Skip to main content

lance_csr/datafusion_planner/
analysis.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Query Analysis Phase
5//!
6//! Assigns unique IDs to relationship instances and collects variable-to-label mappings
7
8use crate::ast::RelationshipDirection;
9use crate::error::Result;
10use crate::logical_plan::*;
11use std::collections::{HashMap, HashSet};
12
13/// Analysis result containing all metadata needed for planning
14#[derive(Debug, Clone, Default)]
15pub struct QueryAnalysis {
16    /// Variable → Label mappings (e.g., "n" → "Person")
17    pub var_to_label: HashMap<String, String>,
18
19    /// Relationship instances with unique IDs to avoid column conflicts
20    pub relationship_instances: Vec<RelationshipInstance>,
21
22    /// All datasets required for this query
23    pub required_datasets: HashSet<String>,
24}
25
26/// Represents a single relationship expansion with a unique instance ID
27#[derive(Debug, Clone)]
28pub struct RelationshipInstance {
29    pub id: usize, // Unique instance number
30    pub rel_type: String,
31    pub source_var: String,
32    pub target_var: String,
33    pub direction: RelationshipDirection,
34    pub alias: String, // e.g., "friend_of_1", "friend_of_2"
35}
36
37/// Planning context that tracks state during plan building
38pub struct PlanningContext<'a> {
39    pub analysis: &'a QueryAnalysis,
40    pub(crate) path_sources: crate::path_inventory::SourceBatches,
41    pub(crate) relationship_instance_idx: HashMap<String, usize>,
42    /// Element rows of the list comprehensions being built, innermost last.
43    pub(crate) call_input: Option<datafusion::logical_expr::LogicalPlan>,
44    pub(crate) comprehension_elements: Vec<datafusion::logical_expr::LogicalPlan>,
45}
46
47impl<'a> PlanningContext<'a> {
48    pub fn new(analysis: &'a QueryAnalysis) -> Self {
49        Self {
50            analysis,
51            path_sources: Default::default(),
52            relationship_instance_idx: HashMap::new(),
53            comprehension_elements: Vec::new(),
54            call_input: None,
55        }
56    }
57
58    /// Get the next relationship instance for a given type (returns a clone)
59    pub fn next_relationship_instance(&mut self, rel_type: &str) -> Result<RelationshipInstance> {
60        let idx = self
61            .relationship_instance_idx
62            .entry(rel_type.to_string())
63            .and_modify(|i| *i += 1)
64            .or_insert(0);
65
66        self.analysis
67            .relationship_instances
68            .iter()
69            .filter(|r| r.rel_type == rel_type)
70            .nth(*idx)
71            .cloned()
72            .ok_or_else(|| crate::error::GraphError::PlanError {
73                message: format!("No relationship instance found for: {}", rel_type),
74                location: snafu::Location::new(file!(), line!(), column!()),
75            })
76    }
77}
78
79/// Analyze the logical plan to extract metadata
80/// The label a variable-length target resolves to: its own label, or the source's
81/// when the pattern left it unlabeled (the planner's `"Node"` default), so
82/// `(a:Person)-[:KNOWS*1..2]->(b)` makes `b` a Person, as single-label graphs expect.
83// shortcut: an explicit `(b:Node)` is indistinguishable from the default; make the default an Option if a graph ever needs a real `Node` label on a variable-length target.
84pub(crate) fn variable_length_target_label(
85    target_label: &str,
86    source_label: Option<&String>,
87) -> String {
88    match source_label {
89        Some(source) if target_label == "Node" => source.clone(),
90        _ => target_label.to_string(),
91    }
92}
93
94pub fn analyze(logical_plan: &LogicalOperator) -> Result<QueryAnalysis> {
95    let mut analysis = QueryAnalysis::default();
96    let mut rel_counter: HashMap<String, usize> = HashMap::new();
97
98    analyze_operator(logical_plan, &mut analysis, &mut rel_counter)?;
99    Ok(analysis)
100}
101
102/// Recursively analyze operators to build QueryAnalysis
103fn analyze_operator(
104    op: &LogicalOperator,
105    analysis: &mut QueryAnalysis,
106    rel_counter: &mut HashMap<String, usize>,
107) -> Result<()> {
108    match op {
109        LogicalOperator::ScanByLabel {
110            variable, label, ..
111        } => {
112            analysis
113                .var_to_label
114                .insert(variable.clone(), label.clone());
115            analysis.required_datasets.insert(label.clone());
116        }
117        LogicalOperator::Expand {
118            input,
119            source_variable,
120            target_variable,
121            target_label,
122            relationship_types,
123            direction,
124            relationship_variable,
125            ..
126        } => {
127            // Recursively analyze input first
128            analyze_operator(input, analysis, rel_counter)?;
129
130            // Register the target variable with its label from the logical plan
131            analysis
132                .var_to_label
133                .insert(target_variable.clone(), target_label.clone());
134
135            // Assign unique instance ID for this relationship
136            if let Some(rel_type) = relationship_types.first() {
137                let instance_id = rel_counter
138                    .entry(rel_type.clone())
139                    .and_modify(|c| *c += 1)
140                    .or_insert(1);
141
142                // Use relationship variable if provided, otherwise use type_instanceId
143                let alias = if let Some(rel_var) = relationship_variable {
144                    rel_var.clone()
145                } else {
146                    format!("{}_{}", rel_type.to_lowercase(), instance_id)
147                };
148
149                analysis.relationship_instances.push(RelationshipInstance {
150                    id: *instance_id,
151                    rel_type: rel_type.clone(),
152                    source_var: source_variable.clone(),
153                    target_var: target_variable.clone(),
154                    direction: direction.clone(),
155                    alias,
156                });
157
158                analysis.required_datasets.insert(rel_type.clone());
159            }
160        }
161        LogicalOperator::VariableLengthExpand {
162            input,
163            source_variable,
164            target_variable,
165            target_label,
166            relationship_types,
167            direction,
168            relationship_variable,
169            min_length,
170            max_length,
171            ..
172        } => {
173            // Recursively analyze input first
174            analyze_operator(input, analysis, rel_counter)?;
175
176            let label = variable_length_target_label(
177                target_label,
178                analysis.var_to_label.get(source_variable),
179            );
180            analysis.var_to_label.insert(target_variable.clone(), label);
181
182            // For variable-length paths, register multiple instances (one per hop)
183            // We need to register instances for all possible hop counts
184            if let Some(rel_type) = relationship_types.first() {
185                let max_hops = max_length.unwrap_or(crate::MAX_VARIABLE_LENGTH_HOPS);
186                let min_hops = min_length.unwrap_or(1).max(1);
187
188                // Register instances for each hop count we'll generate
189                for hop_count in min_hops..=max_hops {
190                    for _ in 0..hop_count {
191                        let instance_id = rel_counter
192                            .entry(rel_type.clone())
193                            .and_modify(|c| *c += 1)
194                            .or_insert(1);
195
196                        // Use relationship variable if provided, otherwise use type_instanceId
197                        let alias = if let Some(rel_var) = relationship_variable {
198                            format!("{}_{}", rel_var, instance_id)
199                        } else {
200                            format!("{}_{}", rel_type.to_lowercase(), instance_id)
201                        };
202
203                        analysis.relationship_instances.push(RelationshipInstance {
204                            id: *instance_id,
205                            rel_type: rel_type.clone(),
206                            source_var: source_variable.clone(),
207                            target_var: target_variable.clone(),
208                            direction: direction.clone(),
209                            alias,
210                        });
211                    }
212                }
213
214                analysis.required_datasets.insert(rel_type.clone());
215            }
216        }
217        LogicalOperator::Filter { input, .. }
218        | LogicalOperator::Project { input, .. }
219        | LogicalOperator::Sort { input, .. }
220        | LogicalOperator::Limit { input, .. }
221        | LogicalOperator::Offset { input, .. }
222        | LogicalOperator::Distinct { input } => {
223            analyze_operator(input, analysis, rel_counter)?;
224        }
225        LogicalOperator::Join { left, right, .. }
226        | LogicalOperator::QueryUnion { left, right, .. } => {
227            analyze_operator(left, analysis, rel_counter)?;
228            analyze_operator(right, analysis, rel_counter)?;
229        }
230        LogicalOperator::Unwind {
231            input,
232            alias,
233            label,
234            ..
235        } => {
236            if let Some(op) = input {
237                analyze_operator(op, analysis, rel_counter)?;
238            }
239            if let Some(label) = label {
240                analysis.var_to_label.insert(alias.clone(), label.clone());
241                analysis.required_datasets.insert(label.clone());
242            }
243        }
244        LogicalOperator::PathMatch { input, path } => {
245            analyze_operator(input, analysis, rel_counter)?;
246            for node in
247                std::iter::once(&path.start_node).chain(path.segments.iter().map(|s| &s.end_node))
248            {
249                if let (Some(variable), Some(label)) = (&node.variable, node.labels.first()) {
250                    analysis
251                        .var_to_label
252                        .insert(variable.clone(), label.clone());
253                }
254            }
255        }
256        LogicalOperator::ShortestPath { input, .. } => {
257            analyze_operator(input, analysis, rel_counter)?;
258        }
259        // Same order as `build_optional_match` builds them: input, then the optional side.
260        LogicalOperator::OptionalMatch {
261            input, optional, ..
262        } => {
263            analyze_operator(input, analysis, rel_counter)?;
264            analyze_operator(optional, analysis, rel_counter)?;
265        }
266        // Branch labels may differ; `build_union` re-derives them per branch.
267        LogicalOperator::Union { inputs } => {
268            for input in inputs {
269                analyze_operator(input, analysis, rel_counter)?;
270            }
271        }
272        // Same order as `build_list_comprehension`: the rows, then the element plan.
273        LogicalOperator::ListComprehension {
274            input,
275            variable,
276            label,
277            element,
278            ..
279        } => {
280            analyze_operator(input, analysis, rel_counter)?;
281            if let Some(label) = label {
282                analysis
283                    .var_to_label
284                    .insert(variable.clone(), label.clone());
285                analysis.required_datasets.insert(label.clone());
286            }
287            analyze_operator(element, analysis, rel_counter)?;
288        }
289        LogicalOperator::Apply {
290            input,
291            inner,
292            labels,
293            ..
294        } => {
295            analyze_operator(input, analysis, rel_counter)?;
296            analysis
297                .required_datasets
298                .extend(analyze(inner)?.required_datasets);
299            analysis.var_to_label.extend(labels.clone());
300        }
301        LogicalOperator::CallInput { labels } => {
302            analysis.var_to_label.extend(labels.clone());
303        }
304        LogicalOperator::ComprehensionElement | LogicalOperator::SingleRow => {}
305    }
306    Ok(())
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::ast::RelationshipDirection;
313    use crate::logical_plan::LogicalOperator;
314    use std::collections::HashMap;
315
316    #[test]
317    fn test_query_analysis_single_hop() {
318        // Test that analysis correctly identifies relationship instances
319        let scan_a = LogicalOperator::ScanByLabel {
320            variable: "a".to_string(),
321            label: "Person".to_string(),
322            properties: Default::default(),
323        };
324        let expand = LogicalOperator::Expand {
325            input: Box::new(scan_a),
326            source_variable: "a".to_string(),
327            target_variable: "b".to_string(),
328            target_label: "Person".to_string(),
329            relationship_types: vec!["KNOWS".to_string()],
330            direction: RelationshipDirection::Outgoing,
331            relationship_variable: None,
332            properties: Default::default(),
333            target_properties: Default::default(),
334        };
335
336        let cfg = crate::config::GraphConfig::builder()
337            .node_table("Person", "id")
338            .rel_table("KNOWS", "Person", "Person", "src_id", "dst_id")
339            .build()
340            .unwrap();
341        let _planner = crate::datafusion_planner::DataFusionPlanner::new(cfg);
342        let analysis = analyze(&expand).unwrap();
343
344        // Should have two variable mappings: a and b both map to Person
345        assert_eq!(analysis.var_to_label.len(), 2);
346        assert_eq!(analysis.var_to_label.get("a"), Some(&"Person".to_string()));
347        assert_eq!(analysis.var_to_label.get("b"), Some(&"Person".to_string()));
348
349        // Should have one relationship instance
350        assert_eq!(analysis.relationship_instances.len(), 1);
351        assert_eq!(analysis.relationship_instances[0].rel_type, "KNOWS");
352        assert_eq!(analysis.relationship_instances[0].alias, "knows_1");
353        assert_eq!(analysis.relationship_instances[0].id, 1);
354    }
355
356    #[test]
357    fn test_query_analysis_two_hop() {
358        // Test that two-hop queries get unique relationship instances
359        let scan_a = LogicalOperator::ScanByLabel {
360            variable: "a".to_string(),
361            label: "Person".to_string(),
362            properties: Default::default(),
363        };
364        let expand1 = LogicalOperator::Expand {
365            input: Box::new(scan_a),
366            source_variable: "a".to_string(),
367            target_variable: "b".to_string(),
368            target_label: "Person".to_string(),
369            relationship_types: vec!["KNOWS".to_string()],
370            direction: RelationshipDirection::Outgoing,
371            relationship_variable: None,
372            properties: Default::default(),
373            target_properties: Default::default(),
374        };
375        let expand2 = LogicalOperator::Expand {
376            input: Box::new(expand1),
377            source_variable: "b".to_string(),
378            target_variable: "c".to_string(),
379            target_label: "Person".to_string(),
380            relationship_types: vec!["KNOWS".to_string()],
381            direction: RelationshipDirection::Outgoing,
382            relationship_variable: None,
383            properties: Default::default(),
384            target_properties: Default::default(),
385        };
386
387        let cfg = crate::config::GraphConfig::builder()
388            .node_table("Person", "id")
389            .rel_table("KNOWS", "Person", "Person", "src_id", "dst_id")
390            .build()
391            .unwrap();
392        let _planner = crate::datafusion_planner::DataFusionPlanner::new(cfg);
393        let analysis = analyze(&expand2).unwrap();
394
395        // Should have two relationship instances with UNIQUE aliases
396        assert_eq!(analysis.relationship_instances.len(), 2);
397        assert_eq!(analysis.relationship_instances[0].alias, "knows_1");
398        assert_eq!(analysis.relationship_instances[1].alias, "knows_2");
399
400        // Both should be KNOWS but with different IDs
401        assert_eq!(analysis.relationship_instances[0].rel_type, "KNOWS");
402        assert_eq!(analysis.relationship_instances[1].rel_type, "KNOWS");
403        assert_eq!(analysis.relationship_instances[0].id, 1);
404        assert_eq!(analysis.relationship_instances[1].id, 2);
405    }
406
407    #[test]
408    fn test_varlength_expand_analysis_registers_instances() {
409        // Test that analysis phase correctly registers multiple relationship instances
410        let scan_a = LogicalOperator::ScanByLabel {
411            variable: "a".to_string(),
412            label: "Person".to_string(),
413            properties: Default::default(),
414        };
415        let vlexpand = LogicalOperator::VariableLengthExpand {
416            input: Box::new(scan_a),
417            source_variable: "a".to_string(),
418            target_variable: "b".to_string(),
419            target_label: "Person".to_string(),
420            relationship_types: vec!["KNOWS".to_string()],
421            direction: RelationshipDirection::Outgoing,
422            relationship_variable: None,
423            min_length: Some(1),
424            max_length: Some(2),
425            target_properties: HashMap::new(),
426        };
427
428        let cfg = crate::config::GraphConfig::builder()
429            .node_table("Person", "id")
430            .rel_table(
431                "KNOWS",
432                "Person",
433                "Person",
434                "src_person_id",
435                "dst_person_id",
436            )
437            .build()
438            .unwrap();
439        let _planner = crate::datafusion_planner::DataFusionPlanner::new(cfg);
440        let analysis = analyze(&vlexpand).unwrap();
441
442        // For *1..2, should register 1 + 2 = 3 instances
443        let knows_instances: Vec<_> = analysis
444            .relationship_instances
445            .iter()
446            .filter(|r| r.rel_type == "KNOWS")
447            .collect();
448
449        assert_eq!(
450            knows_instances.len(),
451            3,
452            "Expected 3 KNOWS instances (1 for 1-hop + 2 for 2-hop)"
453        );
454    }
455
456    #[test]
457    fn test_planning_context_tracks_instances() {
458        // Test that PlanningContext correctly iterates through instances
459        let instances = vec![
460            RelationshipInstance {
461                id: 1,
462                rel_type: "KNOWS".to_string(),
463                source_var: "a".to_string(),
464                target_var: "b".to_string(),
465                direction: RelationshipDirection::Outgoing,
466                alias: "knows_1".to_string(),
467            },
468            RelationshipInstance {
469                id: 2,
470                rel_type: "KNOWS".to_string(),
471                source_var: "b".to_string(),
472                target_var: "c".to_string(),
473                direction: RelationshipDirection::Outgoing,
474                alias: "knows_2".to_string(),
475            },
476        ];
477
478        let analysis = QueryAnalysis {
479            var_to_label: HashMap::new(),
480            relationship_instances: instances,
481            required_datasets: HashSet::new(),
482        };
483
484        let mut ctx = PlanningContext::new(&analysis);
485
486        // First call should return first instance
487        let inst1 = ctx.next_relationship_instance("KNOWS").unwrap();
488        assert_eq!(inst1.alias, "knows_1");
489
490        // Second call should return second instance
491        let inst2 = ctx.next_relationship_instance("KNOWS").unwrap();
492        assert_eq!(inst2.alias, "knows_2");
493
494        // Third call should error (no more instances)
495        assert!(ctx.next_relationship_instance("KNOWS").is_err());
496    }
497}