Skip to main content

oxirs_tdb/
query_optimizer.rs

1//! Advanced query optimizer for TDB storage
2//!
3//! Provides cost-based query optimization using statistics to select
4//! the most efficient execution plan for triple pattern queries.
5//!
6//! ## Features
7//! - Cost-based index selection using cardinality statistics
8//! - Query plan generation with multiple access paths
9//! - Execution cost estimation (I/O, CPU, memory)
10//! - Integration with query hints for manual tuning
11//! - Support for complex query patterns
12//! - Adaptive optimization based on historical performance
13
14use crate::dictionary::NodeId;
15use crate::error::{Result, TdbError};
16use crate::query_hints::{IndexType, QueryHints, QueryStats};
17use crate::statistics::TripleStatistics;
18use serde::{Deserialize, Serialize};
19use std::sync::Arc;
20
21/// Query pattern with bound/unbound positions
22#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
23pub struct QueryPattern {
24    /// Subject node ID (None = wildcard)
25    pub subject: Option<NodeId>,
26    /// Predicate node ID (None = wildcard)
27    pub predicate: Option<NodeId>,
28    /// Object node ID (None = wildcard)
29    pub object: Option<NodeId>,
30}
31
32impl QueryPattern {
33    /// Create a new query pattern
34    pub fn new(s: Option<NodeId>, p: Option<NodeId>, o: Option<NodeId>) -> Self {
35        Self {
36            subject: s,
37            predicate: p,
38            object: o,
39        }
40    }
41
42    /// Check if all positions are bound (exact match)
43    pub fn is_exact(&self) -> bool {
44        self.subject.is_some() && self.predicate.is_some() && self.object.is_some()
45    }
46
47    /// Check if all positions are unbound (full scan)
48    pub fn is_full_scan(&self) -> bool {
49        self.subject.is_none() && self.predicate.is_none() && self.object.is_none()
50    }
51
52    /// Count number of bound positions
53    pub fn bound_count(&self) -> usize {
54        let mut count = 0;
55        if self.subject.is_some() {
56            count += 1;
57        }
58        if self.predicate.is_some() {
59            count += 1;
60        }
61        if self.object.is_some() {
62            count += 1;
63        }
64        count
65    }
66
67    /// Get selectivity factor (0.0 = very selective, 1.0 = not selective)
68    ///
69    /// More bound positions = more selective = lower selectivity factor
70    pub fn selectivity(&self) -> f64 {
71        let bound = self.bound_count();
72        match bound {
73            3 => 0.01, // Exact match - very selective
74            2 => 0.1,  // Two bound - moderately selective
75            1 => 0.5,  // One bound - less selective
76            _ => 1.0,  // No bounds - full scan
77        }
78    }
79}
80
81/// Query execution plan with cost estimates
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct QueryPlan {
84    /// Pattern being queried
85    #[serde(skip)]
86    pub pattern: QueryPattern,
87    /// Index to use for execution
88    pub index: IndexType,
89    /// Estimated number of results
90    pub estimated_results: usize,
91    /// Estimated I/O cost (page reads)
92    pub estimated_io_cost: f64,
93    /// Estimated CPU cost (comparisons)
94    pub estimated_cpu_cost: f64,
95    /// Total estimated cost
96    pub total_cost: f64,
97    /// Confidence in cost estimate (0.0 - 1.0)
98    pub confidence: f64,
99    /// Explanation of plan choice
100    pub explanation: String,
101}
102
103impl QueryPlan {
104    /// Create a new query plan
105    pub fn new(pattern: QueryPattern, index: IndexType, estimated_results: usize) -> Self {
106        // Simple cost model:
107        // I/O cost = log(total_triples) + log(results)
108        // CPU cost = results * comparison_factor
109        let io_cost = (estimated_results as f64 + 1.0).ln();
110        let cpu_cost = estimated_results as f64 * 0.1;
111        let total_cost = io_cost + cpu_cost;
112
113        Self {
114            pattern,
115            index,
116            estimated_results,
117            estimated_io_cost: io_cost,
118            estimated_cpu_cost: cpu_cost,
119            total_cost,
120            confidence: 0.7, // Default confidence
121            explanation: String::new(),
122        }
123    }
124
125    /// Update with explanation
126    pub fn with_explanation(mut self, explanation: String) -> Self {
127        self.explanation = explanation;
128        self
129    }
130
131    /// Update confidence level
132    pub fn with_confidence(mut self, confidence: f64) -> Self {
133        self.confidence = confidence.clamp(0.0, 1.0);
134        self
135    }
136
137    /// Generate ASCII tree visualization of the query plan
138    pub fn to_ascii_tree(&self) -> String {
139        let mut output = String::new();
140
141        output.push_str("Query Plan\n");
142        output.push_str("══════════\n\n");
143
144        // Pattern visualization
145        output.push_str("├─ Pattern\n");
146        output.push_str(&format!("│  ├─ Subject:   {:?}\n", self.pattern.subject));
147        output.push_str(&format!("│  ├─ Predicate: {:?}\n", self.pattern.predicate));
148        output.push_str(&format!("│  └─ Object:    {:?}\n", self.pattern.object));
149        output.push_str("│\n");
150
151        // Index selection
152        output.push_str(&format!("├─ Index: {:?}\n", self.index));
153        output.push_str("│\n");
154
155        // Cost estimates
156        output.push_str("├─ Cost Estimates\n");
157        output.push_str(&format!(
158            "│  ├─ I/O Cost:     {:.2}\n",
159            self.estimated_io_cost
160        ));
161        output.push_str(&format!(
162            "│  ├─ CPU Cost:     {:.2}\n",
163            self.estimated_cpu_cost
164        ));
165        output.push_str(&format!("│  ├─ Total Cost:   {:.2}\n", self.total_cost));
166        output.push_str(&format!("│  ├─ Est. Results: {}\n", self.estimated_results));
167        output.push_str(&format!(
168            "│  └─ Confidence:   {:.0}%\n",
169            self.confidence * 100.0
170        ));
171        output.push_str("│\n");
172
173        // Explanation
174        if !self.explanation.is_empty() {
175            output.push_str("└─ Explanation\n");
176            for line in self.explanation.lines() {
177                output.push_str(&format!("   {}\n", line));
178            }
179        }
180
181        output
182    }
183
184    /// Generate DOT format for Graphviz visualization
185    pub fn to_dot(&self) -> String {
186        let mut output = String::new();
187
188        output.push_str("digraph QueryPlan {\n");
189        output.push_str("  rankdir=TB;\n");
190        output.push_str("  node [shape=box, style=rounded];\n\n");
191
192        // Root node
193        output.push_str("  query [label=\"Query Pattern\"];\n");
194
195        // Pattern node
196        let pattern_label = format!(
197            "S: {:?}\\nP: {:?}\\nO: {:?}",
198            self.pattern.subject, self.pattern.predicate, self.pattern.object
199        );
200        output.push_str(&format!("  pattern [label=\"{}\"];\n", pattern_label));
201        output.push_str("  query -> pattern;\n\n");
202
203        // Index selection node
204        let index_label = format!("Index: {:?}", self.index);
205        output.push_str(&format!("  index [label=\"{}\"];\n", index_label));
206        output.push_str("  pattern -> index;\n\n");
207
208        // Cost node
209        let cost_label = format!(
210            "I/O: {:.2}\\nCPU: {:.2}\\nTotal: {:.2}\\nResults: {}",
211            self.estimated_io_cost,
212            self.estimated_cpu_cost,
213            self.total_cost,
214            self.estimated_results
215        );
216        output.push_str(&format!(
217            "  cost [label=\"{}\", shape=ellipse];\n",
218            cost_label
219        ));
220        output.push_str("  index -> cost;\n\n");
221
222        output.push_str("}\n");
223        output
224    }
225
226    /// Generate JSON representation of the query plan
227    pub fn to_json(&self) -> Result<String> {
228        serde_json::to_string_pretty(self)
229            .map_err(|e| TdbError::Other(format!("JSON serialization error: {}", e)))
230    }
231
232    /// Generate compact summary of the query plan
233    pub fn to_summary(&self) -> String {
234        format!(
235            "Plan: {:?} index | Cost: {:.2} | Results: {} | Confidence: {:.0}%",
236            self.index,
237            self.total_cost,
238            self.estimated_results,
239            self.confidence * 100.0
240        )
241    }
242}
243
244/// Advanced query optimizer
245pub struct QueryOptimizer {
246    /// Statistics collector
247    statistics: Arc<TripleStatistics>,
248    /// Cache of previously seen query patterns and their costs
249    plan_cache: parking_lot::RwLock<std::collections::HashMap<QueryPattern, QueryPlan>>,
250    /// Optimization level (0=disabled, 1=basic, 2=advanced)
251    optimization_level: u8,
252}
253
254impl QueryOptimizer {
255    /// Create a new query optimizer
256    pub fn new(statistics: Arc<TripleStatistics>) -> Self {
257        Self {
258            statistics,
259            plan_cache: parking_lot::RwLock::new(std::collections::HashMap::new()),
260            optimization_level: 2, // Advanced by default
261        }
262    }
263
264    /// Set optimization level
265    pub fn with_optimization_level(mut self, level: u8) -> Self {
266        self.optimization_level = level.min(2);
267        self
268    }
269
270    /// Generate optimal query plan
271    ///
272    /// Considers:
273    /// - Query pattern selectivity
274    /// - Available statistics
275    /// - User-provided hints
276    /// - Historical query performance
277    pub fn optimize(&self, pattern: QueryPattern, hints: &QueryHints) -> Result<QueryPlan> {
278        // If optimization is disabled, use simple heuristics
279        if self.optimization_level == 0 {
280            return Ok(self.simple_optimization(pattern, hints));
281        }
282
283        // Check plan cache first
284        {
285            let cache = self.plan_cache.read();
286            if let Some(cached_plan) = cache.get(&pattern) {
287                // Apply hints to cached plan if needed
288                if let Some(preferred_index) = hints.preferred_index {
289                    let mut plan = cached_plan.clone();
290                    plan.index = preferred_index;
291                    plan.explanation = format!(
292                        "Using hint-specified index {:?} (overriding cached plan)",
293                        preferred_index
294                    );
295                    return Ok(plan);
296                }
297                return Ok(cached_plan.clone());
298            }
299        }
300
301        // Generate and cache new plan
302        let plan = if self.optimization_level >= 2 {
303            self.advanced_optimization(pattern.clone(), hints)?
304        } else {
305            self.basic_optimization(pattern.clone(), hints)
306        };
307
308        // Cache the plan
309        {
310            let mut cache = self.plan_cache.write();
311            cache.insert(pattern.clone(), plan.clone());
312        }
313
314        Ok(plan)
315    }
316
317    /// Simple optimization using only query pattern
318    fn simple_optimization(&self, pattern: QueryPattern, hints: &QueryHints) -> QueryPlan {
319        // Respect user hints if provided
320        if let Some(preferred_index) = hints.preferred_index {
321            return QueryPlan::new(pattern.clone(), preferred_index, 1000)
322                .with_explanation(format!("Using hint-specified index {:?}", preferred_index));
323        }
324
325        // Use pattern-based heuristics
326        let index = self.select_index_by_pattern(&pattern);
327        let estimated_results = self.estimate_results_simple(&pattern);
328
329        QueryPlan::new(pattern, index, estimated_results)
330            .with_explanation(format!("Simple heuristic selection: {:?}", index))
331            .with_confidence(0.5)
332    }
333
334    /// Basic optimization using simple statistics
335    fn basic_optimization(&self, pattern: QueryPattern, hints: &QueryHints) -> QueryPlan {
336        // Respect user hints if provided
337        if let Some(preferred_index) = hints.preferred_index {
338            return QueryPlan::new(pattern.clone(), preferred_index, 1000)
339                .with_explanation(format!("Using hint-specified index {:?}", preferred_index));
340        }
341
342        // Use pattern-based selection with simple statistics
343        let index = self.select_index_by_pattern(&pattern);
344        let estimated_results = self.estimate_results_basic(&pattern);
345
346        QueryPlan::new(pattern, index, estimated_results)
347            .with_explanation(format!("Basic optimization with statistics: {:?}", index))
348            .with_confidence(0.7)
349    }
350
351    /// Advanced optimization using detailed statistics and cost model
352    fn advanced_optimization(
353        &self,
354        pattern: QueryPattern,
355        hints: &QueryHints,
356    ) -> Result<QueryPlan> {
357        // Generate candidate plans for each possible index
358        let mut candidates = Vec::new();
359
360        // Consider SPO index
361        if self.is_index_viable(&pattern, IndexType::SPO) {
362            let plan = self.generate_plan(&pattern, IndexType::SPO)?;
363            candidates.push(plan);
364        }
365
366        // Consider POS index
367        if self.is_index_viable(&pattern, IndexType::POS) {
368            let plan = self.generate_plan(&pattern, IndexType::POS)?;
369            candidates.push(plan);
370        }
371
372        // Consider OSP index
373        if self.is_index_viable(&pattern, IndexType::OSP) {
374            let plan = self.generate_plan(&pattern, IndexType::OSP)?;
375            candidates.push(plan);
376        }
377
378        // If user provided a hint, prefer that index
379        if let Some(preferred_index) = hints.preferred_index {
380            if let Some(plan) = candidates.iter().find(|p| p.index == preferred_index) {
381                return Ok(plan.clone());
382            }
383        }
384
385        // Select plan with lowest cost
386        candidates
387            .into_iter()
388            .min_by(|a, b| {
389                a.total_cost
390                    .partial_cmp(&b.total_cost)
391                    .unwrap_or(std::cmp::Ordering::Equal)
392            })
393            .ok_or_else(|| TdbError::Other("No viable query plan found".to_string()))
394    }
395
396    /// Generate a query plan for a specific index
397    fn generate_plan(&self, pattern: &QueryPattern, index: IndexType) -> Result<QueryPlan> {
398        let stats = self.statistics.export();
399
400        // Estimate cardinality using average statistics for better accuracy
401        let base_estimate = match index {
402            IndexType::SPO => {
403                if pattern.subject.is_some() {
404                    // S bound: use average properties per subject
405                    stats.avg_properties_per_subject as usize
406                } else {
407                    stats.total_triples as usize
408                }
409            }
410            IndexType::POS => {
411                if pattern.predicate.is_some() {
412                    // P bound: use average objects per predicate
413                    stats.avg_objects_per_predicate as usize
414                } else {
415                    stats.total_triples as usize
416                }
417            }
418            IndexType::OSP => {
419                if pattern.object.is_some() {
420                    // O bound: use average subjects per object
421                    stats.avg_subjects_per_object as usize
422                } else {
423                    stats.total_triples as usize
424                }
425            }
426        };
427
428        // Refine estimate based on additional bound positions with more accurate factors
429        let refined_estimate = match pattern.bound_count() {
430            3 => 1, // Exact match - single triple
431            2 => {
432                // Two bound positions: estimate intersection selectivity
433                // Use geometric mean of two estimates for more accuracy
434                let factor = if base_estimate > 10 {
435                    (base_estimate as f64).sqrt() as usize
436                } else {
437                    base_estimate
438                };
439                factor.max(1)
440            }
441            1 => base_estimate.max(1),
442            _ => stats.total_triples as usize,
443        };
444
445        // Calculate confidence based on data distribution
446        let confidence = if stats.total_triples > 0 {
447            match pattern.bound_count() {
448                3 => 0.95, // High confidence for exact matches
449                2 => 0.85, // Good confidence with two bounds
450                1 => 0.70, // Moderate confidence with one bound
451                _ => 0.50, // Low confidence for full scans
452            }
453        } else {
454            0.30 // Low confidence with no data
455        };
456
457        let explanation = format!(
458            "Cost-based selection: {:?} index with estimated {} results (avg={:.2}, confidence={:.0}%)",
459            index,
460            refined_estimate,
461            match index {
462                IndexType::SPO => stats.avg_properties_per_subject,
463                IndexType::POS => stats.avg_objects_per_predicate,
464                IndexType::OSP => stats.avg_subjects_per_object,
465            },
466            confidence * 100.0
467        );
468
469        Ok(QueryPlan::new(pattern.clone(), index, refined_estimate)
470            .with_explanation(explanation)
471            .with_confidence(confidence))
472    }
473
474    /// Check if an index is viable for a query pattern
475    fn is_index_viable(&self, pattern: &QueryPattern, index: IndexType) -> bool {
476        match index {
477            IndexType::SPO => true, // Always viable (fallback)
478            IndexType::POS => pattern.predicate.is_some(),
479            IndexType::OSP => pattern.object.is_some(),
480        }
481    }
482
483    /// Select index based on query pattern alone
484    fn select_index_by_pattern(&self, pattern: &QueryPattern) -> IndexType {
485        match (
486            pattern.subject.is_some(),
487            pattern.predicate.is_some(),
488            pattern.object.is_some(),
489        ) {
490            (true, _, _) => IndexType::SPO,          // S bound -> SPO
491            (false, true, _) => IndexType::POS,      // P bound, S not -> POS
492            (false, false, true) => IndexType::OSP,  // O bound, S,P not -> OSP
493            (false, false, false) => IndexType::SPO, // All unbound -> SPO (default)
494        }
495    }
496
497    /// Simple result estimation without statistics
498    fn estimate_results_simple(&self, pattern: &QueryPattern) -> usize {
499        match pattern.bound_count() {
500            3 => 1,     // Exact match
501            2 => 100,   // Two bound
502            1 => 1000,  // One bound
503            _ => 10000, // Full scan
504        }
505    }
506
507    /// Basic result estimation with simple statistics
508    fn estimate_results_basic(&self, pattern: &QueryPattern) -> usize {
509        let stats = self.statistics.export();
510        let total = stats.total_triples.max(1) as usize;
511
512        match pattern.bound_count() {
513            3 => 1,                                     // Exact match
514            2 => (total as f64 * 0.01).ceil() as usize, // 1% of data
515            1 => (total as f64 * 0.1).ceil() as usize,  // 10% of data
516            _ => total,                                 // Full scan
517        }
518    }
519
520    /// Clear the plan cache
521    pub fn clear_cache(&self) {
522        let mut cache = self.plan_cache.write();
523        cache.clear();
524    }
525
526    /// Get cache statistics
527    pub fn cache_stats(&self) -> QueryOptimizerStats {
528        let cache = self.plan_cache.read();
529        QueryOptimizerStats {
530            cached_plans: cache.len(),
531            optimization_level: self.optimization_level,
532        }
533    }
534}
535
536/// Query optimizer statistics
537#[derive(Debug, Clone, Serialize, Deserialize)]
538pub struct QueryOptimizerStats {
539    /// Number of cached query plans
540    pub cached_plans: usize,
541    /// Current optimization level (0-2)
542    pub optimization_level: u8,
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use crate::statistics::StatisticsConfig;
549
550    fn create_test_optimizer() -> QueryOptimizer {
551        let stats = Arc::new(TripleStatistics::new(StatisticsConfig::default()));
552        QueryOptimizer::new(stats)
553    }
554
555    #[test]
556    fn test_query_pattern_selectivity() {
557        let pattern_exact = QueryPattern::new(
558            Some(NodeId::new(1)),
559            Some(NodeId::new(2)),
560            Some(NodeId::new(3)),
561        );
562        assert_eq!(pattern_exact.selectivity(), 0.01);
563        assert!(pattern_exact.is_exact());
564
565        let pattern_two = QueryPattern::new(Some(NodeId::new(1)), Some(NodeId::new(2)), None);
566        assert_eq!(pattern_two.selectivity(), 0.1);
567        assert_eq!(pattern_two.bound_count(), 2);
568
569        let pattern_one = QueryPattern::new(Some(NodeId::new(1)), None, None);
570        assert_eq!(pattern_one.selectivity(), 0.5);
571
572        let pattern_none = QueryPattern::new(None, None, None);
573        assert_eq!(pattern_none.selectivity(), 1.0);
574        assert!(pattern_none.is_full_scan());
575    }
576
577    #[test]
578    fn test_simple_optimization() {
579        let optimizer = create_test_optimizer();
580        let hints = QueryHints::new();
581
582        // S bound -> should select SPO
583        let pattern = QueryPattern::new(Some(NodeId::new(1)), None, None);
584        let plan = optimizer.simple_optimization(pattern, &hints);
585        assert_eq!(plan.index, IndexType::SPO);
586
587        // P bound -> should select POS
588        let pattern = QueryPattern::new(None, Some(NodeId::new(2)), None);
589        let plan = optimizer.simple_optimization(pattern, &hints);
590        assert_eq!(plan.index, IndexType::POS);
591
592        // O bound -> should select OSP
593        let pattern = QueryPattern::new(None, None, Some(NodeId::new(3)));
594        let plan = optimizer.simple_optimization(pattern, &hints);
595        assert_eq!(plan.index, IndexType::OSP);
596    }
597
598    #[test]
599    fn test_hint_override() {
600        let optimizer = create_test_optimizer();
601
602        // Pattern suggests SPO, but hint overrides to POS
603        let pattern = QueryPattern::new(Some(NodeId::new(1)), None, None);
604        let hints = QueryHints::new().with_index(IndexType::POS);
605
606        let plan = optimizer.simple_optimization(pattern, &hints);
607        assert_eq!(plan.index, IndexType::POS);
608    }
609
610    #[test]
611    fn test_plan_cost_estimation() {
612        let pattern = QueryPattern::new(Some(NodeId::new(1)), Some(NodeId::new(2)), None);
613        let plan = QueryPlan::new(pattern, IndexType::SPO, 100);
614
615        assert!(plan.estimated_io_cost > 0.0);
616        assert!(plan.estimated_cpu_cost > 0.0);
617        assert_eq!(
618            plan.total_cost,
619            plan.estimated_io_cost + plan.estimated_cpu_cost
620        );
621    }
622
623    #[test]
624    fn test_optimization_levels() {
625        let stats = Arc::new(TripleStatistics::new(StatisticsConfig::default()));
626
627        let optimizer_disabled = QueryOptimizer::new(stats.clone()).with_optimization_level(0);
628        assert_eq!(optimizer_disabled.optimization_level, 0);
629
630        let optimizer_basic = QueryOptimizer::new(stats.clone()).with_optimization_level(1);
631        assert_eq!(optimizer_basic.optimization_level, 1);
632
633        let optimizer_advanced = QueryOptimizer::new(stats.clone()).with_optimization_level(2);
634        assert_eq!(optimizer_advanced.optimization_level, 2);
635    }
636
637    #[test]
638    fn test_index_viability() {
639        let optimizer = create_test_optimizer();
640
641        // SPO is always viable
642        let pattern = QueryPattern::new(None, None, None);
643        assert!(optimizer.is_index_viable(&pattern, IndexType::SPO));
644
645        // POS requires P bound
646        let pattern_no_p = QueryPattern::new(Some(NodeId::new(1)), None, None);
647        assert!(!optimizer.is_index_viable(&pattern_no_p, IndexType::POS));
648
649        let pattern_with_p = QueryPattern::new(None, Some(NodeId::new(2)), None);
650        assert!(optimizer.is_index_viable(&pattern_with_p, IndexType::POS));
651
652        // OSP requires O bound
653        let pattern_no_o = QueryPattern::new(Some(NodeId::new(1)), None, None);
654        assert!(!optimizer.is_index_viable(&pattern_no_o, IndexType::OSP));
655
656        let pattern_with_o = QueryPattern::new(None, None, Some(NodeId::new(3)));
657        assert!(optimizer.is_index_viable(&pattern_with_o, IndexType::OSP));
658    }
659
660    #[test]
661    fn test_cache_functionality() {
662        let optimizer = create_test_optimizer();
663        let pattern = QueryPattern::new(Some(NodeId::new(1)), None, None);
664        let hints = QueryHints::new();
665
666        // First call - should cache the plan
667        let plan1 = optimizer.optimize(pattern.clone(), &hints).unwrap();
668
669        // Second call - should retrieve from cache
670        let plan2 = optimizer.optimize(pattern.clone(), &hints).unwrap();
671
672        assert_eq!(plan1.index, plan2.index);
673
674        let cache_stats = optimizer.cache_stats();
675        assert_eq!(cache_stats.cached_plans, 1);
676
677        optimizer.clear_cache();
678        let cache_stats = optimizer.cache_stats();
679        assert_eq!(cache_stats.cached_plans, 0);
680    }
681
682    #[test]
683    fn test_result_estimation() {
684        let optimizer = create_test_optimizer();
685
686        // Exact match
687        let pattern_exact = QueryPattern::new(
688            Some(NodeId::new(1)),
689            Some(NodeId::new(2)),
690            Some(NodeId::new(3)),
691        );
692        assert_eq!(optimizer.estimate_results_simple(&pattern_exact), 1);
693
694        // Two bound
695        let pattern_two = QueryPattern::new(Some(NodeId::new(1)), Some(NodeId::new(2)), None);
696        assert_eq!(optimizer.estimate_results_simple(&pattern_two), 100);
697
698        // One bound
699        let pattern_one = QueryPattern::new(Some(NodeId::new(1)), None, None);
700        assert_eq!(optimizer.estimate_results_simple(&pattern_one), 1000);
701
702        // Full scan
703        let pattern_none = QueryPattern::new(None, None, None);
704        assert_eq!(optimizer.estimate_results_simple(&pattern_none), 10000);
705    }
706
707    #[test]
708    fn test_query_plan_ascii_visualization() {
709        let pattern = QueryPattern::new(Some(NodeId::new(1)), None, Some(NodeId::new(3)));
710        let plan = QueryPlan::new(pattern, IndexType::SPO, 100)
711            .with_explanation("Using SPO index for subject-bound query".to_string())
712            .with_confidence(0.85);
713
714        let ascii_tree = plan.to_ascii_tree();
715
716        // Verify key components are present
717        assert!(ascii_tree.contains("Query Plan"));
718        assert!(ascii_tree.contains("Pattern"));
719        assert!(ascii_tree.contains("Subject:"));
720        assert!(ascii_tree.contains("Predicate:"));
721        assert!(ascii_tree.contains("Object:"));
722        assert!(ascii_tree.contains("Index: SPO"));
723        assert!(ascii_tree.contains("I/O Cost:"));
724        assert!(ascii_tree.contains("CPU Cost:"));
725        assert!(ascii_tree.contains("Total Cost:"));
726        assert!(ascii_tree.contains("Est. Results: 100"));
727        assert!(ascii_tree.contains("Confidence:   85%"));
728        assert!(ascii_tree.contains("Explanation"));
729        assert!(ascii_tree.contains("Using SPO index"));
730    }
731
732    #[test]
733    fn test_query_plan_dot_visualization() {
734        let pattern = QueryPattern::new(Some(NodeId::new(1)), Some(NodeId::new(2)), None);
735        let plan = QueryPlan::new(pattern, IndexType::SPO, 50);
736
737        let dot_output = plan.to_dot();
738
739        // Verify DOT format structure
740        assert!(dot_output.starts_with("digraph QueryPlan {"));
741        assert!(dot_output.contains("rankdir=TB"));
742        assert!(dot_output.contains("query [label=\"Query Pattern\"]"));
743        assert!(dot_output.contains("pattern [label="));
744        assert!(dot_output.contains("index [label=\"Index: SPO\"]"));
745        assert!(dot_output.contains("cost [label="));
746        assert!(dot_output.contains("query -> pattern"));
747        assert!(dot_output.contains("pattern -> index"));
748        assert!(dot_output.contains("index -> cost"));
749        assert!(dot_output.ends_with("}\n"));
750    }
751
752    #[test]
753    fn test_query_plan_json_visualization() {
754        let pattern = QueryPattern::new(Some(NodeId::new(1)), None, None);
755        let plan = QueryPlan::new(pattern, IndexType::SPO, 200).with_confidence(0.9);
756
757        let json_result = plan.to_json();
758        assert!(json_result.is_ok());
759
760        let json_output = json_result.unwrap();
761
762        // Verify JSON contains key fields
763        assert!(json_output.contains("\"index\""));
764        assert!(json_output.contains("\"estimated_results\": 200"));
765        assert!(json_output.contains("\"estimated_io_cost\""));
766        assert!(json_output.contains("\"estimated_cpu_cost\""));
767        assert!(json_output.contains("\"total_cost\""));
768        assert!(json_output.contains("\"confidence\": 0.9"));
769    }
770
771    #[test]
772    fn test_query_plan_summary() {
773        let pattern = QueryPattern::new(None, Some(NodeId::new(2)), None);
774        let plan = QueryPlan::new(pattern, IndexType::POS, 300).with_confidence(0.75);
775
776        let summary = plan.to_summary();
777
778        // Verify summary format
779        assert!(summary.contains("POS index"));
780        assert!(summary.contains("Cost:"));
781        assert!(summary.contains("Results: 300"));
782        assert!(summary.contains("Confidence: 75%"));
783    }
784
785    #[test]
786    fn test_visualization_with_empty_explanation() {
787        let pattern = QueryPattern::new(
788            Some(NodeId::new(1)),
789            Some(NodeId::new(2)),
790            Some(NodeId::new(3)),
791        );
792        let plan = QueryPlan::new(pattern, IndexType::SPO, 1);
793
794        let ascii_tree = plan.to_ascii_tree();
795
796        // Should still render properly without explanation
797        assert!(ascii_tree.contains("Query Plan"));
798        assert!(ascii_tree.contains("Pattern"));
799        assert!(ascii_tree.contains("Cost Estimates"));
800    }
801
802    #[test]
803    fn test_visualization_format_consistency() {
804        let pattern = QueryPattern::new(Some(NodeId::new(10)), None, Some(NodeId::new(20)));
805        let plan = QueryPlan::new(pattern, IndexType::OSP, 500)
806            .with_explanation("Multi-line\nexplanation\ntest".to_string())
807            .with_confidence(0.95);
808
809        // Test all formats work without errors
810        let _ = plan.to_ascii_tree();
811        let _ = plan.to_dot();
812        let _ = plan.to_summary();
813        assert!(plan.to_json().is_ok());
814    }
815}