Skip to main content

oxirs_core/model/
sparql_optimizer.rs

1//! SPARQL query plan optimizer with rewrite rules.
2//!
3//! Provides a pipeline of optimization passes that transform a logical
4//! query plan into a more efficient equivalent plan.
5
6use std::collections::HashSet;
7
8/// A rewrite rule metadata descriptor.
9#[derive(Debug, Clone)]
10pub struct OptimizerRule {
11    pub name: &'static str,
12    pub description: &'static str,
13}
14
15/// Join execution strategy.
16#[derive(Debug, Clone, PartialEq)]
17pub enum JoinStrategy {
18    NestedLoop,
19    HashJoin,
20    MergeJoin,
21}
22
23/// A node in the logical query plan tree.
24#[derive(Debug, Clone, PartialEq)]
25pub enum PlanOperator {
26    Scan {
27        pattern: String,
28    },
29    Join {
30        strategy: JoinStrategy,
31    },
32    Filter {
33        expr: String,
34    },
35    Project {
36        vars: Vec<String>,
37    },
38    Union,
39    Optional,
40    Distinct,
41    Slice {
42        offset: Option<usize>,
43        limit: Option<usize>,
44    },
45}
46
47/// A node in the query plan tree with cost estimates.
48#[derive(Debug, Clone)]
49pub struct QueryPlan {
50    pub operator: PlanOperator,
51    pub children: Vec<QueryPlan>,
52    pub estimated_cost: f64,
53}
54
55impl QueryPlan {
56    /// Create a new leaf plan node.
57    pub fn new(operator: PlanOperator) -> Self {
58        QueryPlan {
59            operator,
60            children: Vec::new(),
61            estimated_cost: 0.0,
62        }
63    }
64
65    /// Create a new plan node with children.
66    pub fn with_children(operator: PlanOperator, children: Vec<QueryPlan>) -> Self {
67        QueryPlan {
68            operator,
69            children,
70            estimated_cost: 0.0,
71        }
72    }
73
74    /// Recompute cost bottom-up.
75    pub fn recompute_cost(&mut self) {
76        for child in &mut self.children {
77            child.recompute_cost();
78        }
79        self.estimated_cost = estimate_cost(self);
80    }
81}
82
83/// Compute recursive cost estimate for a plan node.
84pub fn estimate_cost(plan: &QueryPlan) -> f64 {
85    match &plan.operator {
86        PlanOperator::Scan { .. } => 100.0,
87        PlanOperator::Join { .. } => {
88            let left = plan
89                .children
90                .first()
91                .map(|c| c.estimated_cost)
92                .unwrap_or(100.0);
93            let right = plan
94                .children
95                .get(1)
96                .map(|c| c.estimated_cost)
97                .unwrap_or(100.0);
98            left * right * 0.1
99        }
100        PlanOperator::Filter { .. } => {
101            let child = plan
102                .children
103                .first()
104                .map(|c| c.estimated_cost)
105                .unwrap_or(100.0);
106            child * 0.5
107        }
108        PlanOperator::Project { .. } => plan
109            .children
110            .first()
111            .map(|c| c.estimated_cost)
112            .unwrap_or(0.0),
113        PlanOperator::Union => plan.children.iter().map(|c| c.estimated_cost).sum::<f64>(),
114        PlanOperator::Optional => {
115            let left = plan
116                .children
117                .first()
118                .map(|c| c.estimated_cost)
119                .unwrap_or(100.0);
120            let right = plan
121                .children
122                .get(1)
123                .map(|c| c.estimated_cost)
124                .unwrap_or(100.0);
125            left + right * 0.3
126        }
127        PlanOperator::Distinct => plan
128            .children
129            .first()
130            .map(|c| c.estimated_cost * 1.1)
131            .unwrap_or(0.0),
132        PlanOperator::Slice { limit, .. } => {
133            let child = plan
134                .children
135                .first()
136                .map(|c| c.estimated_cost)
137                .unwrap_or(0.0);
138            if let Some(lim) = limit {
139                child * (*lim as f64 / 1000.0).min(1.0)
140            } else {
141                child
142            }
143        }
144    }
145}
146
147/// Trait for a single optimization pass.
148pub trait OptimizationPass: Send + Sync {
149    fn name(&self) -> &str;
150    fn apply(&self, plan: QueryPlan) -> QueryPlan;
151}
152
153// ---------------------------------------------------------------------------
154// Pass 1: Filter Pushdown
155// ---------------------------------------------------------------------------
156
157/// Push filter nodes below joins so they run earlier.
158pub struct FilterPushdownPass;
159
160impl FilterPushdownPass {
161    fn push_filter_into_children(filter_expr: String, child: QueryPlan) -> QueryPlan {
162        match &child.operator {
163            PlanOperator::Join { .. } => {
164                // Push filter into the left child (simplistic: push to all that reference the expr)
165                let mut new_children = child.children.clone();
166                if let Some(first) = new_children.first_mut() {
167                    let filter_plan = QueryPlan::with_children(
168                        PlanOperator::Filter { expr: filter_expr },
169                        vec![first.clone()],
170                    );
171                    *first = filter_plan;
172                }
173                QueryPlan::with_children(child.operator.clone(), new_children)
174            }
175            _ => {
176                // Cannot push further; wrap as-is
177                QueryPlan::with_children(PlanOperator::Filter { expr: filter_expr }, vec![child])
178            }
179        }
180    }
181}
182
183impl OptimizationPass for FilterPushdownPass {
184    fn name(&self) -> &str {
185        "FilterPushdown"
186    }
187
188    fn apply(&self, plan: QueryPlan) -> QueryPlan {
189        let children: Vec<QueryPlan> = plan.children.into_iter().map(|c| self.apply(c)).collect();
190        match plan.operator {
191            PlanOperator::Filter { ref expr } => {
192                if let Some(child) = children.into_iter().next() {
193                    Self::push_filter_into_children(expr.clone(), child)
194                } else {
195                    QueryPlan::new(plan.operator)
196                }
197            }
198            op => QueryPlan {
199                operator: op,
200                children,
201                estimated_cost: plan.estimated_cost,
202            },
203        }
204    }
205}
206
207// ---------------------------------------------------------------------------
208// Pass 2: Projection Pruning
209// ---------------------------------------------------------------------------
210
211/// Remove projection nodes that include all upstream variables (redundant projections).
212pub struct ProjectionPruningPass;
213
214impl ProjectionPruningPass {
215    fn collect_scan_vars(plan: &QueryPlan) -> HashSet<String> {
216        let mut vars = HashSet::new();
217        match &plan.operator {
218            PlanOperator::Scan { pattern } => {
219                // Extract ?var tokens from pattern string
220                for token in pattern.split_whitespace() {
221                    if token.starts_with('?') {
222                        vars.insert(token.trim_start_matches('?').to_string());
223                    }
224                }
225            }
226            PlanOperator::Project { vars: pv } => {
227                for v in pv {
228                    vars.insert(v.clone());
229                }
230            }
231            _ => {}
232        }
233        for child in &plan.children {
234            vars.extend(Self::collect_scan_vars(child));
235        }
236        vars
237    }
238}
239
240impl OptimizationPass for ProjectionPruningPass {
241    fn name(&self) -> &str {
242        "ProjectionPruning"
243    }
244
245    fn apply(&self, plan: QueryPlan) -> QueryPlan {
246        let children: Vec<QueryPlan> = plan.children.into_iter().map(|c| self.apply(c)).collect();
247        match &plan.operator {
248            PlanOperator::Project { vars } => {
249                // Collect upstream variables
250                let upstream: HashSet<String> =
251                    children.iter().flat_map(Self::collect_scan_vars).collect();
252                // If all projected vars are covered and projection adds nothing new, skip it
253                let all_included = vars.iter().all(|v| upstream.contains(v));
254                let reduces = vars.len() < upstream.len();
255                if all_included && !reduces && children.len() == 1 {
256                    // Remove redundant projection
257                    children
258                        .into_iter()
259                        .next()
260                        .unwrap_or_else(|| QueryPlan::new(plan.operator.clone()))
261                } else {
262                    QueryPlan {
263                        operator: plan.operator.clone(),
264                        children,
265                        estimated_cost: plan.estimated_cost,
266                    }
267                }
268            }
269            op => QueryPlan {
270                operator: op.clone(),
271                children,
272                estimated_cost: plan.estimated_cost,
273            },
274        }
275    }
276}
277
278// ---------------------------------------------------------------------------
279// Pass 3: Join Reordering
280// ---------------------------------------------------------------------------
281
282/// Reorder join children by ascending estimated cost.
283pub struct JoinReorderPass;
284
285impl OptimizationPass for JoinReorderPass {
286    fn name(&self) -> &str {
287        "JoinReorder"
288    }
289
290    fn apply(&self, plan: QueryPlan) -> QueryPlan {
291        let children: Vec<QueryPlan> = plan.children.into_iter().map(|c| self.apply(c)).collect();
292        match &plan.operator {
293            PlanOperator::Join { .. } => {
294                let mut sorted = children;
295                sorted.sort_by(|a, b| {
296                    a.estimated_cost
297                        .partial_cmp(&b.estimated_cost)
298                        .unwrap_or(std::cmp::Ordering::Equal)
299                });
300                QueryPlan {
301                    operator: plan.operator.clone(),
302                    children: sorted,
303                    estimated_cost: plan.estimated_cost,
304                }
305            }
306            op => QueryPlan {
307                operator: op.clone(),
308                children,
309                estimated_cost: plan.estimated_cost,
310            },
311        }
312    }
313}
314
315// ---------------------------------------------------------------------------
316// Pass 4: Constant Folding
317// ---------------------------------------------------------------------------
318
319/// Fold constant boolean expressions in filters.
320pub struct ConstantFoldingPass;
321
322impl ConstantFoldingPass {
323    fn fold_expr(expr: &str) -> Option<bool> {
324        match expr.trim() {
325            "true" | "TRUE" | "1=1" => Some(true),
326            "false" | "FALSE" | "1=0" | "0=1" => Some(false),
327            _ => None,
328        }
329    }
330}
331
332impl OptimizationPass for ConstantFoldingPass {
333    fn name(&self) -> &str {
334        "ConstantFolding"
335    }
336
337    fn apply(&self, plan: QueryPlan) -> QueryPlan {
338        let operator = plan.operator.clone();
339        let estimated_cost = plan.estimated_cost;
340        let children: Vec<QueryPlan> = plan.children.into_iter().map(|c| self.apply(c)).collect();
341        match operator {
342            PlanOperator::Filter { ref expr } => {
343                match Self::fold_expr(expr) {
344                    Some(true) => {
345                        // Filter always true — remove it; keep first child if present
346                        let mut iter = children.into_iter();
347                        iter.next().unwrap_or_else(|| QueryPlan::new(operator))
348                    }
349                    Some(false) => {
350                        // Filter always false — return empty scan
351                        QueryPlan::new(PlanOperator::Scan {
352                            pattern: String::from("EMPTY"),
353                        })
354                    }
355                    None => QueryPlan {
356                        operator,
357                        children,
358                        estimated_cost,
359                    },
360                }
361            }
362            op => QueryPlan {
363                operator: op,
364                children,
365                estimated_cost,
366            },
367        }
368    }
369}
370
371// ---------------------------------------------------------------------------
372// Pass 5: Redundant Distinct Removal
373// ---------------------------------------------------------------------------
374
375/// Remove DISTINCT operators that are already guaranteed unique.
376pub struct RedundantDistinctPass;
377
378impl RedundantDistinctPass {
379    fn is_unique_source(plan: &QueryPlan) -> bool {
380        matches!(plan.operator, PlanOperator::Scan { .. })
381    }
382}
383
384impl OptimizationPass for RedundantDistinctPass {
385    fn name(&self) -> &str {
386        "RedundantDistinct"
387    }
388
389    fn apply(&self, plan: QueryPlan) -> QueryPlan {
390        let children: Vec<QueryPlan> = plan.children.into_iter().map(|c| self.apply(c)).collect();
391        match &plan.operator {
392            PlanOperator::Distinct => {
393                // If already wrapping a DISTINCT or a Scan (inherently unique), remove
394                if let Some(child) = children.first() {
395                    if matches!(child.operator, PlanOperator::Distinct)
396                        || Self::is_unique_source(child)
397                    {
398                        return children
399                            .into_iter()
400                            .next()
401                            .unwrap_or_else(|| QueryPlan::new(plan.operator.clone()));
402                    }
403                }
404                QueryPlan {
405                    operator: plan.operator.clone(),
406                    children,
407                    estimated_cost: plan.estimated_cost,
408                }
409            }
410            op => QueryPlan {
411                operator: op.clone(),
412                children,
413                estimated_cost: plan.estimated_cost,
414            },
415        }
416    }
417}
418
419// ---------------------------------------------------------------------------
420// Main Optimizer
421// ---------------------------------------------------------------------------
422
423/// SPARQL query plan optimizer that runs a series of optimization passes.
424pub struct SparqlOptimizer {
425    rules: Vec<Box<dyn OptimizationPass>>,
426}
427
428impl SparqlOptimizer {
429    /// Create a new optimizer with all default passes registered.
430    pub fn new() -> Self {
431        let rules: Vec<Box<dyn OptimizationPass>> = vec![
432            Box::new(FilterPushdownPass),
433            Box::new(ProjectionPruningPass),
434            Box::new(JoinReorderPass),
435            Box::new(ConstantFoldingPass),
436            Box::new(RedundantDistinctPass),
437        ];
438        SparqlOptimizer { rules }
439    }
440
441    /// Apply all optimization passes sequentially.
442    pub fn optimize(&self, mut plan: QueryPlan) -> QueryPlan {
443        // Recompute costs before optimization
444        plan.recompute_cost();
445        for pass in &self.rules {
446            plan = pass.apply(plan);
447            plan.recompute_cost();
448        }
449        plan
450    }
451
452    /// Return optimizer rule metadata.
453    pub fn rule_info(&self) -> Vec<OptimizerRule> {
454        vec![
455            OptimizerRule {
456                name: "FilterPushdown",
457                description: "Push filters below joins",
458            },
459            OptimizerRule {
460                name: "ProjectionPruning",
461                description: "Remove redundant projections",
462            },
463            OptimizerRule {
464                name: "JoinReorder",
465                description: "Reorder joins by cost ascending",
466            },
467            OptimizerRule {
468                name: "ConstantFolding",
469                description: "Fold constant expressions",
470            },
471            OptimizerRule {
472                name: "RedundantDistinct",
473                description: "Remove redundant DISTINCT",
474            },
475        ]
476    }
477}
478
479impl Default for SparqlOptimizer {
480    fn default() -> Self {
481        Self::new()
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    fn scan(pattern: &str) -> QueryPlan {
490        let mut p = QueryPlan::new(PlanOperator::Scan {
491            pattern: pattern.to_string(),
492        });
493        p.estimated_cost = 100.0;
494        p
495    }
496
497    fn join(left: QueryPlan, right: QueryPlan) -> QueryPlan {
498        let mut p = QueryPlan::with_children(
499            PlanOperator::Join {
500                strategy: JoinStrategy::NestedLoop,
501            },
502            vec![left, right],
503        );
504        p.recompute_cost();
505        p
506    }
507
508    fn filter(expr: &str, child: QueryPlan) -> QueryPlan {
509        let mut p = QueryPlan::with_children(
510            PlanOperator::Filter {
511                expr: expr.to_string(),
512            },
513            vec![child],
514        );
515        p.recompute_cost();
516        p
517    }
518
519    fn project(vars: Vec<&str>, child: QueryPlan) -> QueryPlan {
520        let mut p = QueryPlan::with_children(
521            PlanOperator::Project {
522                vars: vars.into_iter().map(String::from).collect(),
523            },
524            vec![child],
525        );
526        p.recompute_cost();
527        p
528    }
529
530    fn distinct(child: QueryPlan) -> QueryPlan {
531        let mut p = QueryPlan::with_children(PlanOperator::Distinct, vec![child]);
532        p.recompute_cost();
533        p
534    }
535
536    // Cost estimation tests
537    #[test]
538    fn test_cost_scan() {
539        let p = scan("?s ?p ?o");
540        assert_eq!(estimate_cost(&p), 100.0);
541    }
542
543    #[test]
544    fn test_cost_join() {
545        let j = join(scan("?s ?p ?o"), scan("?s ?p2 ?o2"));
546        assert!((j.estimated_cost - 1000.0).abs() < 1.0);
547    }
548
549    #[test]
550    fn test_cost_filter() {
551        let mut f = filter("?s = 1", scan("?s ?p ?o"));
552        f.recompute_cost();
553        assert!((f.estimated_cost - 50.0).abs() < 1.0);
554    }
555
556    #[test]
557    fn test_cost_project() {
558        let mut p = project(vec!["s"], scan("?s ?p ?o"));
559        p.recompute_cost();
560        assert_eq!(p.estimated_cost, 100.0);
561    }
562
563    #[test]
564    fn test_cost_union() {
565        let u = QueryPlan::with_children(
566            PlanOperator::Union,
567            vec![scan("?a ?b ?c"), scan("?x ?y ?z")],
568        );
569        assert_eq!(estimate_cost(&u), 200.0);
570    }
571
572    #[test]
573    fn test_cost_distinct() {
574        let d = distinct(scan("?s ?p ?o"));
575        assert!((estimate_cost(&d) - 110.0).abs() < 0.01);
576    }
577
578    #[test]
579    fn test_cost_slice_with_limit() {
580        let mut s = QueryPlan::with_children(
581            PlanOperator::Slice {
582                offset: None,
583                limit: Some(10),
584            },
585            vec![scan("?s ?p ?o")],
586        );
587        s.recompute_cost();
588        // 100 * (10/1000) = 1.0
589        assert!((s.estimated_cost - 1.0).abs() < 0.01);
590    }
591
592    #[test]
593    fn test_cost_slice_no_limit() {
594        let mut s = QueryPlan::with_children(
595            PlanOperator::Slice {
596                offset: Some(5),
597                limit: None,
598            },
599            vec![scan("?s ?p ?o")],
600        );
601        s.recompute_cost();
602        assert_eq!(s.estimated_cost, 100.0);
603    }
604
605    #[test]
606    fn test_cost_optional() {
607        let opt = QueryPlan::with_children(
608            PlanOperator::Optional,
609            vec![scan("?a ?b ?c"), scan("?x ?y ?z")],
610        );
611        // 100 + 100 * 0.3 = 130
612        assert!((estimate_cost(&opt) - 130.0).abs() < 0.01);
613    }
614
615    // FilterPushdown tests
616    #[test]
617    fn test_filter_pushdown_through_join() {
618        let pass = FilterPushdownPass;
619        let j = join(scan("?s ?p ?o"), scan("?s ?p2 ?o2"));
620        let f = filter("?s = 1", j);
621        let result = pass.apply(f);
622        // Filter should have been pushed into join's left child
623        if let PlanOperator::Join { .. } = &result.operator {
624            let left = &result.children[0];
625            assert!(matches!(left.operator, PlanOperator::Filter { .. }));
626        } else {
627            panic!("Expected Join at top level after pushdown");
628        }
629    }
630
631    #[test]
632    fn test_filter_pushdown_non_join_child() {
633        let pass = FilterPushdownPass;
634        let f = filter("?x > 5", scan("?x ?y ?z"));
635        let result = pass.apply(f);
636        // Filter stays wrapping scan
637        assert!(matches!(result.operator, PlanOperator::Filter { .. }));
638    }
639
640    #[test]
641    fn test_filter_pushdown_empty_children() {
642        let pass = FilterPushdownPass;
643        let f = QueryPlan::new(PlanOperator::Filter {
644            expr: "?x > 0".to_string(),
645        });
646        let result = pass.apply(f);
647        assert!(matches!(result.operator, PlanOperator::Filter { .. }));
648    }
649
650    // ProjectionPruning tests
651    #[test]
652    fn test_projection_pruning_removes_redundant() {
653        let pass = ProjectionPruningPass;
654        // Project all vars that a scan provides — should be pruned
655        let child = scan("?s ?p ?o");
656        let p = project(vec!["s", "p", "o"], child);
657        let result = pass.apply(p);
658        // Redundant projection removed
659        assert!(matches!(result.operator, PlanOperator::Scan { .. }));
660    }
661
662    #[test]
663    fn test_projection_pruning_keeps_reducing() {
664        let pass = ProjectionPruningPass;
665        let child = scan("?s ?p ?o");
666        let p = project(vec!["s"], child);
667        let result = pass.apply(p);
668        // Reduces variables — keep it
669        assert!(matches!(result.operator, PlanOperator::Project { .. }));
670    }
671
672    #[test]
673    fn test_projection_pruning_no_children() {
674        let pass = ProjectionPruningPass;
675        let p = QueryPlan::new(PlanOperator::Project {
676            vars: vec!["s".to_string()],
677        });
678        let result = pass.apply(p);
679        assert!(matches!(result.operator, PlanOperator::Project { .. }));
680    }
681
682    // JoinReorder tests
683    #[test]
684    fn test_join_reorder_sorts_by_cost() {
685        let pass = JoinReorderPass;
686        let expensive = {
687            let mut p = scan("?a ?b ?c");
688            p.estimated_cost = 500.0;
689            p
690        };
691        let cheap = {
692            let mut p = scan("?x ?y ?z");
693            p.estimated_cost = 50.0;
694            p
695        };
696        let j = QueryPlan::with_children(
697            PlanOperator::Join {
698                strategy: JoinStrategy::HashJoin,
699            },
700            vec![expensive, cheap],
701        );
702        let result = pass.apply(j);
703        assert!(result.children[0].estimated_cost <= result.children[1].estimated_cost);
704    }
705
706    #[test]
707    fn test_join_reorder_already_sorted() {
708        let pass = JoinReorderPass;
709        let mut c1 = scan("?a ?b ?c");
710        c1.estimated_cost = 10.0;
711        let mut c2 = scan("?x ?y ?z");
712        c2.estimated_cost = 200.0;
713        let j = QueryPlan::with_children(
714            PlanOperator::Join {
715                strategy: JoinStrategy::MergeJoin,
716            },
717            vec![c1, c2],
718        );
719        let result = pass.apply(j);
720        assert!(result.children[0].estimated_cost <= result.children[1].estimated_cost);
721    }
722
723    #[test]
724    fn test_join_reorder_non_join_unchanged() {
725        let pass = JoinReorderPass;
726        let s = scan("?s ?p ?o");
727        let result = pass.apply(s);
728        assert!(matches!(result.operator, PlanOperator::Scan { .. }));
729    }
730
731    // ConstantFolding tests
732    #[test]
733    fn test_constant_folding_true_removes_filter() {
734        let pass = ConstantFoldingPass;
735        let f = filter("true", scan("?s ?p ?o"));
736        let result = pass.apply(f);
737        assert!(matches!(result.operator, PlanOperator::Scan { .. }));
738    }
739
740    #[test]
741    fn test_constant_folding_false_returns_empty() {
742        let pass = ConstantFoldingPass;
743        let f = filter("false", scan("?s ?p ?o"));
744        let result = pass.apply(f);
745        if let PlanOperator::Scan { pattern } = &result.operator {
746            assert_eq!(pattern, "EMPTY");
747        } else {
748            panic!("Expected empty scan");
749        }
750    }
751
752    #[test]
753    fn test_constant_folding_tautology_1eq1() {
754        let pass = ConstantFoldingPass;
755        let f = filter("1=1", scan("?s ?p ?o"));
756        let result = pass.apply(f);
757        assert!(matches!(result.operator, PlanOperator::Scan { .. }));
758    }
759
760    #[test]
761    fn test_constant_folding_false_1eq0() {
762        let pass = ConstantFoldingPass;
763        let f = filter("1=0", scan("?s ?p ?o"));
764        let result = pass.apply(f);
765        if let PlanOperator::Scan { pattern } = &result.operator {
766            assert_eq!(pattern, "EMPTY");
767        } else {
768            panic!("Expected empty scan");
769        }
770    }
771
772    #[test]
773    fn test_constant_folding_non_constant_unchanged() {
774        let pass = ConstantFoldingPass;
775        let f = filter("?x > 5", scan("?s ?p ?o"));
776        let result = pass.apply(f);
777        assert!(matches!(result.operator, PlanOperator::Filter { .. }));
778    }
779
780    #[test]
781    fn test_constant_folding_uppercase_true() {
782        let pass = ConstantFoldingPass;
783        let f = filter("TRUE", scan("?s ?p ?o"));
784        let result = pass.apply(f);
785        assert!(matches!(result.operator, PlanOperator::Scan { .. }));
786    }
787
788    // RedundantDistinct tests
789    #[test]
790    fn test_redundant_distinct_scan_removed() {
791        let pass = RedundantDistinctPass;
792        let d = distinct(scan("?s ?p ?o"));
793        let result = pass.apply(d);
794        assert!(matches!(result.operator, PlanOperator::Scan { .. }));
795    }
796
797    #[test]
798    fn test_redundant_distinct_double_distinct() {
799        let pass = RedundantDistinctPass;
800        let d = distinct(distinct(scan("?s ?p ?o")));
801        let result = pass.apply(d);
802        // Inner distinct should collapse to scan; outer sees distinct wrapping scan and removes
803        assert!(matches!(
804            result.operator,
805            PlanOperator::Scan { .. } | PlanOperator::Distinct
806        ));
807    }
808
809    #[test]
810    fn test_redundant_distinct_over_join_kept() {
811        let pass = RedundantDistinctPass;
812        let j = join(scan("?s ?p ?o"), scan("?s ?p2 ?o2"));
813        let d = distinct(j);
814        let result = pass.apply(d);
815        assert!(matches!(result.operator, PlanOperator::Distinct));
816    }
817
818    #[test]
819    fn test_redundant_distinct_empty_children() {
820        let pass = RedundantDistinctPass;
821        let d = QueryPlan::new(PlanOperator::Distinct);
822        let result = pass.apply(d);
823        assert!(matches!(result.operator, PlanOperator::Distinct));
824    }
825
826    // Full optimizer tests
827    #[test]
828    fn test_optimizer_new() {
829        let opt = SparqlOptimizer::new();
830        assert_eq!(opt.rules.len(), 5);
831    }
832
833    #[test]
834    fn test_optimizer_default() {
835        let opt = SparqlOptimizer::default();
836        assert_eq!(opt.rules.len(), 5);
837    }
838
839    #[test]
840    fn test_optimizer_rule_info() {
841        let opt = SparqlOptimizer::new();
842        let rules = opt.rule_info();
843        assert_eq!(rules.len(), 5);
844        assert!(rules.iter().any(|r| r.name == "FilterPushdown"));
845        assert!(rules.iter().any(|r| r.name == "JoinReorder"));
846    }
847
848    #[test]
849    fn test_optimizer_chain_simple() {
850        let opt = SparqlOptimizer::new();
851        let plan = scan("?s ?p ?o");
852        let result = opt.optimize(plan);
853        assert!(matches!(result.operator, PlanOperator::Scan { .. }));
854    }
855
856    #[test]
857    fn test_optimizer_chain_filter_join() {
858        let opt = SparqlOptimizer::new();
859        let j = join(scan("?s ?p ?o"), scan("?s ?p2 ?o2"));
860        let f = filter("?s = 1", j);
861        let result = opt.optimize(f);
862        // After optimization the filter should have been pushed or plan restructured
863        assert!(result.estimated_cost >= 0.0);
864    }
865
866    #[test]
867    fn test_optimizer_chain_constant_filter() {
868        let opt = SparqlOptimizer::new();
869        let f = filter("true", scan("?s ?p ?o"));
870        let result = opt.optimize(f);
871        assert!(matches!(result.operator, PlanOperator::Scan { .. }));
872    }
873
874    #[test]
875    fn test_optimizer_chain_redundant_distinct() {
876        let opt = SparqlOptimizer::new();
877        let d = distinct(scan("?s ?p ?o"));
878        let result = opt.optimize(d);
879        assert!(matches!(result.operator, PlanOperator::Scan { .. }));
880    }
881
882    #[test]
883    fn test_optimizer_empty_plan() {
884        let opt = SparqlOptimizer::new();
885        let plan = QueryPlan::new(PlanOperator::Union);
886        let result = opt.optimize(plan);
887        assert!(matches!(result.operator, PlanOperator::Union));
888    }
889
890    #[test]
891    fn test_optimizer_deeply_nested() {
892        let opt = SparqlOptimizer::new();
893        let s1 = scan("?a ?b ?c");
894        let s2 = scan("?d ?e ?f");
895        let s3 = scan("?g ?h ?i");
896        let j1 = join(s1, s2);
897        let j2 = join(j1, s3);
898        let f = filter("?a > 0", j2);
899        let d = distinct(f);
900        let result = opt.optimize(d);
901        assert!(result.estimated_cost >= 0.0);
902    }
903
904    #[test]
905    fn test_plan_recompute_cost() {
906        let mut j = join(scan("?s ?p ?o"), scan("?x ?y ?z"));
907        j.recompute_cost();
908        assert!((j.estimated_cost - 1000.0).abs() < 1.0);
909    }
910
911    #[test]
912    fn test_join_strategy_variants() {
913        let strategies = [
914            JoinStrategy::NestedLoop,
915            JoinStrategy::HashJoin,
916            JoinStrategy::MergeJoin,
917        ];
918        for strategy in &strategies {
919            let p = QueryPlan::with_children(
920                PlanOperator::Join {
921                    strategy: strategy.clone(),
922                },
923                vec![scan("?a ?b ?c"), scan("?x ?y ?z")],
924            );
925            assert!(matches!(p.operator, PlanOperator::Join { .. }));
926        }
927    }
928}