Skip to main content

linkrs_query/optimizer/factorization/
group_dependency_analyzer.rs

1use std::collections::{HashMap, HashSet};
2
3use linkrs_core::function::{is_list_lambda, list_lambda_arg_index};
4use linkrs_core::types::expr::ExpressionId;
5
6use crate::planning::plan::factorization::FactorizedSchema;
7
8/// Analysis result for group dependencies.
9#[derive(Debug, Clone, Default)]
10pub struct GroupDependencyAnalysis {
11    pub dependent_groups: HashSet<u32>,
12    pub required_flat_groups: HashSet<u32>,
13    pub dependent_exprs: HashSet<ExpressionId>,
14}
15
16/// Analyzer that walks an expression tree to collect which factorization
17/// groups the expression depends on.
18///
19/// Mirrors `lbug::planner::GroupDependencyAnalyzer` in
20/// `ref/ladybug/src/planner/operator/factorization/flatten_resolver.cpp`.
21pub struct GroupDependencyAnalyzer<'a> {
22    schema: &'a FactorizedSchema,
23    collect_dependent_expr: bool,
24    dependent_groups: HashSet<u32>,
25    required_flat_groups: HashSet<u32>,
26    dependent_exprs: HashSet<ExpressionId>,
27    /// For function handling: maps expr id to inner expression for recursion.
28    /// Borrowed so multi-column operators do not clone the whole store per
29    /// expression.
30    expr_store: Option<&'a HashMap<ExpressionId, linkrs_core::Expression>>,
31    /// Descriptions of expressions that could not be resolved to any group.
32    /// Unresolved expressions conservatively require every unflat group to
33    /// be flattened (see `mark_unresolved`) instead of silently contributing
34    /// no groups.
35    unresolved: Vec<String>,
36}
37
38impl<'a> GroupDependencyAnalyzer<'a> {
39    pub fn new(schema: &'a FactorizedSchema, collect_dependent_expr: bool) -> Self {
40        Self {
41            schema,
42            collect_dependent_expr,
43            dependent_groups: HashSet::new(),
44            required_flat_groups: HashSet::new(),
45            dependent_exprs: HashSet::new(),
46            expr_store: None,
47            unresolved: Vec::new(),
48        }
49    }
50
51    pub fn with_expr_store(
52        schema: &'a FactorizedSchema,
53        collect_dependent_expr: bool,
54        store: &'a HashMap<ExpressionId, linkrs_core::Expression>,
55    ) -> Self {
56        Self {
57            schema,
58            collect_dependent_expr,
59            dependent_groups: HashSet::new(),
60            required_flat_groups: HashSet::new(),
61            dependent_exprs: HashSet::new(),
62            expr_store: Some(store),
63            unresolved: Vec::new(),
64        }
65    }
66
67    pub fn dependent_groups(&self) -> &HashSet<u32> {
68        &self.dependent_groups
69    }
70
71    pub fn required_flat_groups(&self) -> &HashSet<u32> {
72        &self.required_flat_groups
73    }
74
75    pub fn dependent_exprs(&self) -> &HashSet<ExpressionId> {
76        &self.dependent_exprs
77    }
78
79    /// Whether any expression could not be resolved to a group.
80    pub fn has_unresolved(&self) -> bool {
81        !self.unresolved.is_empty()
82    }
83
84    /// Descriptions of the unresolved expressions, for diagnostics.
85    pub fn unresolved(&self) -> &[String] {
86        &self.unresolved
87    }
88
89    /// Record an unresolvable expression and fall back to flattening every
90    /// unflat group, so callers never silently miss a required flatten.
91    /// The recorded description carries the schema shape (group count and
92    /// scope size) to pinpoint missing alias registrations in the producing
93    /// operator's `compute_factorized_schema`.
94    fn mark_unresolved(&mut self, what: String) {
95        let context = format!(
96            "{what} (schema: {} groups, {} expressions in scope)",
97            self.schema.num_groups(),
98            self.schema.expressions_in_scope().len()
99        );
100        log::warn!("GroupDependencyAnalyzer: unresolved {context}");
101        self.unresolved.push(context);
102        for (pos, group) in self.schema.groups().iter().enumerate() {
103            if !group.is_flat() {
104                self.required_flat_groups.insert(pos as u32);
105            }
106        }
107    }
108
109    pub fn into_analysis(self) -> GroupDependencyAnalysis {
110        GroupDependencyAnalysis {
111            dependent_groups: self.dependent_groups,
112            required_flat_groups: self.required_flat_groups,
113            dependent_exprs: self.dependent_exprs,
114        }
115    }
116
117    /// Analyze a single expression id: if it is in scope, record its group.
118    /// Otherwise attempt to walk its inner expression tree if available.
119    ///
120    /// Resolution goes through the canonical `resolve_group_pos` order
121    /// (id, then id-linked name, then bare name) so the three alias paths
122    /// stay ordered in one place instead of drifting across call sites.
123    pub fn visit(&mut self, expr_id: &ExpressionId) {
124        let owned_name = self.schema.expression_name(expr_id).map(str::to_string);
125        if let Some(pos) = self
126            .schema
127            .resolve_group_pos(Some(expr_id), owned_name.as_deref())
128        {
129            self.dependent_groups.insert(pos);
130            if self.collect_dependent_expr {
131                self.dependent_exprs.insert(expr_id.clone());
132            }
133            return;
134        }
135
136        // Not in scope: try to expand via stored expression.
137        if let Some(expr) = self.expr_store.and_then(|store| store.get(expr_id)) {
138            self.visit_expression(expr);
139        } else {
140            self.mark_unresolved(format!("expression {expr_id:?} not in scope"));
141        }
142    }
143
144    pub fn visit_expression(&mut self, expr: &linkrs_core::Expression) {
145        use linkrs_core::Expression;
146        match expr {
147            Expression::Variable(name) => {
148                // Variables carry no expression id, so resolution uses the
149                // canonical alias order with no id (bare-name fallback last).
150                if let Some(pos) = self.schema.resolve_group_pos(None, Some(name)) {
151                    self.dependent_groups.insert(pos);
152                } else {
153                    self.mark_unresolved(format!("variable `{name}` not bound in scope"));
154                }
155            }
156            Expression::Property { object, .. } => {
157                self.visit_expression(object);
158            }
159            Expression::Binary { left, right, .. } => {
160                self.visit_expression(left);
161                self.visit_expression(right);
162            }
163            Expression::Unary { operand, .. } => {
164                self.visit_expression(operand);
165            }
166            Expression::Function { args, name } => {
167                for arg in args {
168                    self.visit_expression(arg.as_expr());
169                }
170                // List lambda functions require their lambda body to be flat.
171                if is_list_lambda(name) {
172                    // For list lambda, all dependent groups of the lambda
173                    // expression must be flat. The body position comes from
174                    // the function registry, not a hardcoded index.
175                    if let Some(body) = list_lambda_arg_index(name).and_then(|i| args.get(i)) {
176                        let mut lambda_analyzer =
177                            GroupDependencyAnalyzer::new(self.schema, self.collect_dependent_expr);
178                        lambda_analyzer.expr_store = self.expr_store;
179                        lambda_analyzer.visit_expression(body.as_expr());
180                        self.required_flat_groups
181                            .extend(lambda_analyzer.dependent_groups.iter().copied());
182                        self.required_flat_groups
183                            .extend(lambda_analyzer.required_flat_groups.iter().copied());
184                        self.unresolved.extend(lambda_analyzer.unresolved);
185                    }
186                }
187            }
188            Expression::Aggregate { args, filter, .. } => {
189                for a in args {
190                    self.visit_expression(a);
191                }
192                if let Some(f) = filter {
193                    self.visit_expression(f);
194                }
195            }
196            Expression::List(items) => {
197                for item in items {
198                    self.visit_expression(item);
199                }
200            }
201            Expression::Map(pairs) => {
202                for (_, v) in pairs {
203                    self.visit_expression(v);
204                }
205            }
206            Expression::Case {
207                test_expr,
208                conditions,
209                default,
210            } => {
211                if let Some(t) = test_expr {
212                    self.visit_expression(t);
213                }
214                for (when, then) in conditions {
215                    self.visit_expression(when);
216                    self.visit_expression(then);
217                }
218                if let Some(d) = default {
219                    self.visit_expression(d);
220                }
221            }
222            Expression::Subscript { collection, index } => {
223                self.visit_expression(collection);
224                self.visit_expression(index);
225            }
226            Expression::Range {
227                collection,
228                start,
229                end,
230            } => {
231                self.visit_expression(collection);
232                if let Some(s) = start {
233                    self.visit_expression(s);
234                }
235                if let Some(e) = end {
236                    self.visit_expression(e);
237                }
238            }
239            Expression::Literal(_)
240            | Expression::Parameter(_)
241            | Expression::Label(_)
242            | Expression::SessionVariable(_) => {}
243            Expression::Path(items) | Expression::PathBuild(items) => {
244                for item in items {
245                    self.visit_expression(item);
246                }
247            }
248            Expression::TypeCast {
249                expression: inner, ..
250            } => {
251                self.visit_expression(inner);
252            }
253            Expression::Reduce {
254                initial,
255                source,
256                mapping,
257                ..
258            } => {
259                self.visit_expression(initial);
260                self.visit_expression(source);
261                self.visit_expression(mapping);
262            }
263            Expression::ListComprehension {
264                variable: _,
265                source,
266                filter,
267                map,
268            } => {
269                self.visit_expression(source);
270                if let Some(f) = filter {
271                    self.visit_expression(f);
272                }
273                if let Some(m) = map {
274                    self.visit_expression(m);
275                }
276            }
277            _ => {
278                // For any other variants, walk children generically.
279                for child in expr.children() {
280                    self.visit_expression(child);
281                }
282            }
283        }
284    }
285
286    /// Convenience: analyze a set of expression ids.
287    pub fn analyze_ids(
288        schema: &'a FactorizedSchema,
289        expr_ids: &[ExpressionId],
290        store: &'a HashMap<ExpressionId, linkrs_core::Expression>,
291    ) -> GroupDependencyAnalysis {
292        let mut analyzer = GroupDependencyAnalyzer::with_expr_store(schema, false, store);
293        for id in expr_ids {
294            analyzer.visit(id);
295        }
296        analyzer.into_analysis()
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::planning::plan::factorization::FactorizedSchema;
304    use linkrs_core::types::expr::ExpressionId;
305    use linkrs_core::types::expr::FunctionArg;
306
307    fn expr(id: u64) -> ExpressionId {
308        ExpressionId::new(id)
309    }
310
311    #[test]
312    fn single_expr_dependency() {
313        let mut schema = FactorizedSchema::new();
314        let g0 = schema.create_flat_group(false);
315        let g1 = schema.create_group();
316        schema.insert_to_group_and_scope(expr(1), g0).unwrap();
317        schema.insert_to_group_and_scope(expr(2), g1).unwrap();
318
319        let mut analyzer = GroupDependencyAnalyzer::new(&schema, false);
320        analyzer.visit(&expr(2));
321        assert!(analyzer.dependent_groups().contains(&g1));
322        assert!(!analyzer.dependent_groups().contains(&g0));
323    }
324
325    #[test]
326    fn expression_tree_walk() {
327        let mut schema = FactorizedSchema::new();
328        let g0 = schema.create_flat_group(false);
329        let g1 = schema.create_group();
330        let id_a = expr(10);
331        let id_b = expr(20);
332        schema.insert_to_group_and_scope(id_a.clone(), g0).unwrap();
333        schema.insert_to_group_and_scope(id_b.clone(), g1).unwrap();
334
335        // Build store where combined expr 30 = a + b
336        let store = HashMap::new();
337        let _combined_expr = linkrs_core::Expression::Binary {
338            left: Box::new(linkrs_core::Expression::Variable("a".to_string())),
339            op: linkrs_core::types::operators::BinaryOperator::Add,
340            right: Box::new(linkrs_core::Expression::Variable("b".to_string())),
341        };
342        // Simulate that id_a and id_b are the leaves but combined not in scope,
343        // analyzer should walk its children when store contains mapping.
344        // Here we use visit_expression directly.
345        let mut analyzer = GroupDependencyAnalyzer::with_expr_store(&schema, false, &store);
346        // Direct visit of Binary should not add groups because variable names not ids
347        // So we test that visit by id works, and expression walk for property.
348        analyzer.visit(&id_a);
349        analyzer.visit(&id_b);
350        assert_eq!(analyzer.dependent_groups().len(), 2);
351    }
352
353    #[test]
354    fn variable_id_primary() {
355        // Resolution by expression id is the primary path: it works without
356        // any name registration.
357        let mut schema = FactorizedSchema::new();
358        let g0 = schema.create_flat_group(false);
359        let g1 = schema.create_group();
360        schema.insert_to_group_and_scope(expr(10), g0).unwrap();
361        schema.insert_to_group_and_scope(expr(20), g1).unwrap();
362
363        let mut analyzer = GroupDependencyAnalyzer::new(&schema, false);
364        analyzer.visit(&expr(20));
365        assert!(analyzer.dependent_groups().contains(&g1));
366        assert!(!analyzer.has_unresolved());
367    }
368
369    #[test]
370    fn variable_name_fallback() {
371        let mut schema = FactorizedSchema::new();
372        let g0 = schema.create_flat_group(false);
373        let g1 = schema.create_group();
374        schema
375            .insert_to_group_and_scope_with_name(expr(10), Some("a".to_string()), g0)
376            .unwrap();
377        schema
378            .insert_to_group_and_scope_with_name(expr(20), Some("b".to_string()), g1)
379            .unwrap();
380
381        let the_expr = linkrs_core::Expression::Binary {
382            left: Box::new(linkrs_core::Expression::Variable("a".to_string())),
383            op: linkrs_core::types::operators::BinaryOperator::Add,
384            right: Box::new(linkrs_core::Expression::Variable("b".to_string())),
385        };
386        let mut store = HashMap::new();
387        let fake_id = expr(999);
388        store.insert(fake_id.clone(), the_expr);
389        let mut analyzer = GroupDependencyAnalyzer::with_expr_store(&schema, false, &store);
390        analyzer.visit(&fake_id);
391        assert!(analyzer.dependent_groups().contains(&g0));
392        assert!(analyzer.dependent_groups().contains(&g1));
393        assert_eq!(analyzer.dependent_groups().len(), 2);
394    }
395
396    #[test]
397    fn unscoped_expression_requires_flatten_all() {
398        let mut schema = FactorizedSchema::new();
399        let g0 = schema.create_flat_group(false);
400        let g1 = schema.create_group();
401        schema.insert_to_group_and_scope(expr(1), g0).unwrap();
402        schema.insert_to_group_and_scope(expr(2), g1).unwrap();
403
404        let empty_store = HashMap::new();
405        let mut analyzer = GroupDependencyAnalyzer::with_expr_store(&schema, false, &empty_store);
406        analyzer.visit(&expr(999));
407        assert!(analyzer.has_unresolved());
408        assert_eq!(analyzer.unresolved().len(), 1);
409        assert!(analyzer.required_flat_groups().contains(&g1));
410        assert!(!analyzer.required_flat_groups().contains(&g0));
411    }
412
413    #[test]
414    fn unbound_variable_requires_flatten_all() {
415        let mut schema = FactorizedSchema::new();
416        let g0 = schema.create_flat_group(false);
417        let g1 = schema.create_group();
418        schema.insert_to_group_and_scope(expr(1), g0).unwrap();
419        schema.insert_to_group_and_scope(expr(2), g1).unwrap();
420
421        let mut analyzer = GroupDependencyAnalyzer::new(&schema, false);
422        analyzer.visit_expression(&linkrs_core::Expression::Variable("ghost".to_string()));
423        assert!(analyzer.has_unresolved());
424        assert!(analyzer.required_flat_groups().contains(&g1));
425    }
426
427    #[test]
428    fn list_filter_lambda_body_requires_flat() {
429        let mut schema = FactorizedSchema::new();
430        let g0 = schema.create_flat_group(false);
431        let g1 = schema.create_group();
432        schema
433            .insert_to_group_and_scope_with_name(expr(10), Some("a".to_string()), g0)
434            .unwrap();
435        schema
436            .insert_to_group_and_scope_with_name(expr(20), Some("items".to_string()), g1)
437            .unwrap();
438
439        // list_filter(a, items.age > 30): the lambda body over the unflat
440        // group must be flattened.
441        let body = linkrs_core::Expression::Binary {
442            left: Box::new(linkrs_core::Expression::Property {
443                object: Box::new(linkrs_core::Expression::Variable("items".to_string())),
444                property: "age".to_string(),
445            }),
446            op: linkrs_core::types::operators::BinaryOperator::GreaterThan,
447            right: Box::new(linkrs_core::Expression::Literal(
448                linkrs_core::value::Value::Int(30),
449            )),
450        };
451        let the_expr = linkrs_core::Expression::Function {
452            name: "list_filter".to_string(),
453            args: vec![
454                FunctionArg::Positional(linkrs_core::Expression::Variable("a".to_string())),
455                FunctionArg::Positional(body),
456            ],
457        };
458        let mut store = HashMap::new();
459        let fake_id = expr(999);
460        store.insert(fake_id.clone(), the_expr);
461        let mut analyzer = GroupDependencyAnalyzer::with_expr_store(&schema, false, &store);
462        analyzer.visit(&fake_id);
463        assert!(analyzer.required_flat_groups().contains(&g1));
464        assert!(!analyzer.has_unresolved());
465    }
466
467    #[test]
468    fn path_expression_visits_items() {
469        let mut schema = FactorizedSchema::new();
470        let g0 = schema.create_flat_group(false);
471        let g1 = schema.create_group();
472        schema
473            .insert_to_group_and_scope_with_name(expr(10), Some("a".to_string()), g0)
474            .unwrap();
475        schema
476            .insert_to_group_and_scope_with_name(expr(20), Some("b".to_string()), g1)
477            .unwrap();
478
479        let the_expr =
480            linkrs_core::Expression::Path(vec![linkrs_core::Expression::Variable("b".to_string())]);
481        let mut analyzer = GroupDependencyAnalyzer::new(&schema, false);
482        analyzer.visit_expression(&the_expr);
483        assert!(analyzer.dependent_groups().contains(&g1));
484        assert!(!analyzer.has_unresolved());
485    }
486
487    #[test]
488    fn list_any_all_single_lambda_body_requires_flat() {
489        for name in ["list_any", "list_all", "list_single"] {
490            let mut schema = FactorizedSchema::new();
491            let g0 = schema.create_flat_group(false);
492            let g1 = schema.create_group();
493            schema
494                .insert_to_group_and_scope_with_name(expr(10), Some("a".to_string()), g0)
495                .unwrap();
496            schema
497                .insert_to_group_and_scope_with_name(expr(20), Some("items".to_string()), g1)
498                .unwrap();
499
500            let body = linkrs_core::Expression::Binary {
501                left: Box::new(linkrs_core::Expression::Property {
502                    object: Box::new(linkrs_core::Expression::Variable("items".to_string())),
503                    property: "age".to_string(),
504                }),
505                op: linkrs_core::types::operators::BinaryOperator::GreaterThan,
506                right: Box::new(linkrs_core::Expression::Literal(
507                    linkrs_core::value::Value::Int(30),
508                )),
509            };
510            let the_expr = linkrs_core::Expression::Function {
511                name: name.to_string(),
512                args: vec![
513                    FunctionArg::Positional(linkrs_core::Expression::Variable("a".to_string())),
514                    FunctionArg::Positional(body),
515                ],
516            };
517            let mut store = HashMap::new();
518            let fake_id = expr(999);
519            store.insert(fake_id.clone(), the_expr);
520            let mut analyzer = GroupDependencyAnalyzer::with_expr_store(&schema, false, &store);
521            analyzer.visit(&fake_id);
522            assert!(
523                analyzer.required_flat_groups().contains(&g1),
524                "{name} lambda body must require flat"
525            );
526            assert!(!analyzer.has_unresolved());
527        }
528    }
529}