Skip to main content

radixdb_executor/
planner.rs

1// Copyright 2026 RadixDB Contributors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Query Planner - Integrates statistics and cost-based optimization
16//!
17//! This module provides the QueryPlanner which coordinates between:
18//! - Table statistics stored in system tables (sys_table_stats, sys_column_stats)
19//! - Cost estimator for choosing access methods
20//! - Zone maps for segment pruning
21//! - Index selection for efficient access paths
22//!
23//! The planner is used by the executor to make informed decisions about:
24//! - Whether to use an index vs sequential scan
25//! - Which join algorithm to use
26//! - Which segments can be skipped using zone maps
27
28use std::sync::Arc;
29
30use radixdb_core::StringMap;
31
32use crate::optimizer::feedback::{fingerprint_predicate, FeedbackCache};
33use crate::optimizer::workload::{EdgeAwarePlanner, EdgeJoinRecommendation};
34use radixdb_core::{DataType, Operator, Result, Schema, Value};
35use radixdb_sql::ast::Expression;
36use radixdb_storage::mvcc::engine::MVCCEngine;
37use radixdb_storage::statistics::{
38    decode_statistics_value, Histogram, HistogramOp, TableStats, SYS_COLUMN_STATS, SYS_TABLE_STATS,
39};
40use radixdb_storage::traits::{Engine, Table, Transaction};
41use radixdb_storage::volume::zonemap::TableZoneMap;
42
43/// Query planner that integrates statistics-based optimization
44pub struct QueryPlanner {
45    /// Reference to the storage engine for reading statistics
46    engine: Arc<MVCCEngine>,
47    /// Cache of table statistics to avoid repeated lookups
48    stats_cache: std::sync::RwLock<StringMap<CachedStats>>,
49    /// Feedback is scoped to one engine owner and invalidated with its data.
50    feedback_cache: Arc<FeedbackCache>,
51}
52
53/// Default TTL for cached statistics (5 minutes)
54/// After this time, stats are considered potentially stale and will be refreshed
55const STATS_CACHE_TTL_SECS: u64 = 300;
56
57/// Maximum number of tables to cache statistics for (LRU eviction threshold)
58/// This prevents unbounded memory growth for databases with many tables
59const MAX_STATS_CACHE_SIZE: usize = 1000;
60const STORAGE_PAGE_BYTES: u64 = 4096;
61
62#[doc(hidden)]
63pub fn estimated_schema_column_width(data_type: DataType, vector_dimensions: u16) -> u64 {
64    match data_type {
65        DataType::Null => 1,
66        DataType::Boolean => 1,
67        DataType::Date => 4,
68        DataType::Integer | DataType::Float | DataType::Timestamp => 8,
69        DataType::Uuid => 16,
70        DataType::Decimal => 24,
71        DataType::Text | DataType::Json | DataType::Bytes => 32,
72        DataType::Vector => u64::from(vector_dimensions).saturating_mul(4).max(16),
73    }
74}
75
76#[doc(hidden)]
77pub fn estimated_schema_row_width(schema: &Schema) -> u64 {
78    schema
79        .columns
80        .iter()
81        .map(|column| {
82            estimated_schema_column_width(column.data_type, column.vector_dimensions)
83                .saturating_add(u64::from(column.nullable))
84        })
85        .sum::<u64>()
86        .max(1)
87}
88
89#[inline]
90fn decode_nonnegative_stat(value: Option<&Value>) -> Option<u64> {
91    match value {
92        Some(Value::Integer(value)) => u64::try_from(*value).ok(),
93        _ => None,
94    }
95}
96
97/// Cached statistics for a table
98#[derive(Clone)]
99struct CachedStats {
100    table_stats: TableStats,
101    column_stats: StringMap<ColumnStatsCache>,
102    /// Timestamp when this cache entry was created
103    cached_at: radixdb_core::time_compat::Instant,
104    /// Timestamp of last access (for LRU eviction)
105    last_accessed: radixdb_core::time_compat::Instant,
106}
107
108impl CachedStats {
109    /// Check if this cache entry is stale (older than TTL)
110    fn is_stale(&self) -> bool {
111        self.cached_at.elapsed().as_secs() > STATS_CACHE_TTL_SECS
112    }
113
114    /// Update last accessed time
115    fn touch(&mut self) {
116        self.last_accessed = radixdb_core::time_compat::Instant::now();
117    }
118}
119
120/// Cached column stats (simplified for internal use)
121#[derive(Clone)]
122pub struct ColumnStatsCache {
123    /// Number of null values in the column
124    pub null_count: u64,
125    /// Number of distinct values in the column
126    pub distinct_count: u64,
127    /// Minimum value in the column
128    pub min_value: Option<Value>,
129    /// Maximum value in the column
130    pub max_value: Option<Value>,
131    /// Histogram for range selectivity estimation
132    pub histogram: Option<Histogram>,
133}
134
135impl QueryPlanner {
136    /// Create a new query planner
137    pub fn new(engine: Arc<MVCCEngine>) -> Self {
138        Self::with_feedback_cache(engine, Arc::new(FeedbackCache::new()))
139    }
140
141    #[doc(hidden)]
142    pub fn with_feedback_cache(
143        engine: Arc<MVCCEngine>,
144        feedback_cache: Arc<FeedbackCache>,
145    ) -> Self {
146        Self {
147            engine,
148            stats_cache: std::sync::RwLock::new(StringMap::new()),
149            feedback_cache,
150        }
151    }
152
153    /// Invalidate cached statistics for a table
154    ///
155    /// Call this after ANALYZE to ensure fresh statistics are used.
156    pub fn invalidate_stats_cache(&self, table_name: &str) {
157        let mut cache = self.stats_cache.write().unwrap();
158        cache.remove(&table_name.to_lowercase());
159    }
160
161    /// Clear all cached statistics
162    pub fn clear_stats_cache(&self) {
163        let mut cache = self.stats_cache.write().unwrap();
164        cache.clear();
165    }
166
167    /// Get or load statistics for a table
168    ///
169    /// If cached stats are stale (older than TTL), they will be refreshed
170    /// from the system tables.
171    ///
172    /// Returns None if:
173    /// - No statistics have been collected (ANALYZE not run)
174    /// - Statistics have row_count == 0 (empty/invalid stats)
175    pub fn get_table_stats(&self, table_name: &str) -> Option<TableStats> {
176        let key = table_name.to_lowercase();
177
178        // Check cache first - use read lock then upgrade to write for LRU touch
179        {
180            let cache = self.stats_cache.read().unwrap();
181            if let Some(cached) = cache.get(&key) {
182                // Return cached stats if still fresh and valid (row_count > 0)
183                if !cached.is_stale() && cached.table_stats.row_count > 0 {
184                    let result = cached.table_stats.clone();
185                    // We need to touch the entry - drop read lock first
186                    drop(cache);
187                    // Update last_accessed for LRU
188                    if let Ok(mut write_cache) = self.stats_cache.write() {
189                        if let Some(entry) = write_cache.get_mut(&key) {
190                            entry.touch();
191                        }
192                    }
193                    return Some(result);
194                }
195                // Stats are stale or invalid, will reload below
196            }
197        }
198
199        // Load from system tables (will update cache)
200        // Only return stats if they have valid row_count > 0
201        self.load_stats_from_system_tables(table_name)
202            .ok()
203            .filter(|stats| stats.row_count > 0)
204    }
205
206    /// Get table statistics with fallback to runtime estimation
207    ///
208    /// If ANALYZE hasn't been run, computes basic statistics from the table.
209    /// This ensures the optimizer always has some statistics to work with.
210    pub fn get_table_stats_with_fallback(&self, table: &dyn Table) -> TableStats {
211        let table_name = table.name();
212
213        // Try to get analyzed stats first
214        if let Some(stats) = self.get_table_stats(table_name) {
215            if stats.row_count > 0 {
216                return stats;
217            }
218        }
219
220        // Fallback: compute basic stats from table (use hint for O(1))
221        let row_count = table.row_count_hint() as u64;
222        let avg_row_size = estimated_schema_row_width(table.schema());
223        TableStats {
224            table_name: table_name.to_string(),
225            row_count,
226            page_count: row_count
227                .saturating_mul(avg_row_size)
228                .div_ceil(STORAGE_PAGE_BYTES)
229                .max(1),
230            avg_row_size,
231        }
232    }
233
234    /// Get column statistics
235    ///
236    /// If cached stats are stale (older than TTL), they will be refreshed.
237    pub fn get_column_stats(
238        &self,
239        table_name: &str,
240        column_name: &str,
241    ) -> Option<ColumnStatsCache> {
242        let table_key = table_name.to_lowercase();
243        let col_key = column_name.to_lowercase();
244
245        // Check cache first
246        let should_reload = {
247            let cache = self.stats_cache.read().unwrap();
248            if let Some(cached) = cache.get(&table_key) {
249                if !cached.is_stale() {
250                    let result = cached.column_stats.get(&col_key).cloned();
251                    // Touch the entry for LRU
252                    drop(cache);
253                    if let Ok(mut write_cache) = self.stats_cache.write() {
254                        if let Some(entry) = write_cache.get_mut(&table_key) {
255                            entry.touch();
256                        }
257                    }
258                    return result;
259                }
260                true // Stale, need to reload
261            } else {
262                true // Not in cache, need to load
263            }
264        };
265
266        if should_reload {
267            // Load stats which will populate cache
268            let _ = self.load_stats_from_system_tables(table_name);
269        }
270
271        // Try cache again
272        let cache = self.stats_cache.read().unwrap();
273        let result = cache
274            .get(&table_key)
275            .and_then(|c| c.column_stats.get(&col_key).cloned());
276
277        // Touch the entry for LRU if found
278        if result.is_some() {
279            drop(cache);
280            if let Ok(mut write_cache) = self.stats_cache.write() {
281                if let Some(entry) = write_cache.get_mut(&table_key) {
282                    entry.touch();
283                }
284            }
285        }
286
287        result
288    }
289
290    /// Get zone maps for a table (from table, not system tables)
291    /// Uses Arc to avoid cloning on high QPS workloads
292    pub fn get_zone_maps(&self, table: &dyn Table) -> Option<std::sync::Arc<TableZoneMap>> {
293        table.get_zone_maps()
294    }
295
296    /// Load statistics from system tables
297    fn load_stats_from_system_tables(&self, table_name: &str) -> Result<TableStats> {
298        let tx = self.engine.begin_transaction()?;
299
300        // Check if system tables exist
301        let tables = tx.list_tables()?;
302        let has_table_stats = tables
303            .iter()
304            .any(|t| t.eq_ignore_ascii_case(SYS_TABLE_STATS));
305        let has_column_stats = tables
306            .iter()
307            .any(|t| t.eq_ignore_ascii_case(SYS_COLUMN_STATS));
308
309        if !has_table_stats {
310            // No statistics available - return default
311            return Ok(TableStats::default());
312        }
313
314        // Read table statistics
315        let table_stats = self.read_table_stats(&*tx, table_name)?;
316
317        // Read column statistics if available
318        let column_stats = if has_column_stats {
319            self.read_column_stats(&*tx, table_name, table_stats.row_count)?
320        } else {
321            StringMap::new()
322        };
323
324        // Cache the stats with current timestamp
325        {
326            let mut cache = self.stats_cache.write().unwrap();
327
328            // LRU eviction: if cache is full, remove least recently used entries
329            if cache.len() >= MAX_STATS_CACHE_SIZE {
330                // Find the least recently used entry (oldest last_accessed time)
331                if let Some(lru_key) = cache
332                    .iter()
333                    .min_by_key(|(_, v)| v.last_accessed)
334                    .map(|(k, _)| k.clone())
335                {
336                    cache.remove(&lru_key);
337                }
338            }
339
340            let now = radixdb_core::time_compat::Instant::now();
341            cache.insert(
342                table_name.to_lowercase(),
343                CachedStats {
344                    table_stats: table_stats.clone(),
345                    column_stats,
346                    cached_at: now,
347                    last_accessed: now,
348                },
349            );
350        }
351
352        Ok(table_stats)
353    }
354
355    /// Read table statistics from sys_table_stats
356    ///
357    /// Table schema is:
358    /// id (0), table_name (1), row_count (2), page_count (3), avg_row_size (4), last_analyzed (5)
359    fn read_table_stats(&self, tx: &dyn Transaction, table_name: &str) -> Result<TableStats> {
360        let stats_table = match tx.get_table(SYS_TABLE_STATS) {
361            Ok(t) => t,
362            Err(_) => return Ok(TableStats::default()),
363        };
364
365        // Scan for this table's stats (all columns, no filter)
366        let mut result = stats_table.scan(&[], None)?;
367        while result.next() {
368            let row = result.row();
369            // Check if this row is for our table (table_name is column 1)
370            if let Some(Value::Text(name)) = row.get(1) {
371                if name.eq_ignore_ascii_case(table_name) {
372                    let Some(row_count) = decode_nonnegative_stat(row.get(2)) else {
373                        return Ok(TableStats::default());
374                    };
375                    let Some(page_count) = decode_nonnegative_stat(row.get(3)) else {
376                        return Ok(TableStats::default());
377                    };
378                    let Some(avg_row_size) =
379                        decode_nonnegative_stat(row.get(4)).filter(|value| *value > 0)
380                    else {
381                        return Ok(TableStats::default());
382                    };
383                    if row_count > 0 && page_count == 0 {
384                        return Ok(TableStats::default());
385                    }
386                    return Ok(TableStats {
387                        table_name: table_name.to_string(),
388                        row_count,
389                        page_count,
390                        avg_row_size,
391                    });
392                }
393            }
394        }
395
396        // No stats found - return default
397        Ok(TableStats::default())
398    }
399
400    /// Read column statistics from sys_column_stats
401    ///
402    /// Table schema is:
403    /// id (0), table_name (1), column_name (2), null_count (3), distinct_count (4),
404    /// min_value (5), max_value (6), avg_width (7), histogram (8)
405    fn read_column_stats(
406        &self,
407        tx: &dyn Transaction,
408        table_name: &str,
409        table_row_count: u64,
410    ) -> Result<StringMap<ColumnStatsCache>> {
411        let mut stats = StringMap::new();
412
413        let stats_table = match tx.get_table(SYS_COLUMN_STATS) {
414            Ok(t) => t,
415            Err(_) => return Ok(stats),
416        };
417
418        // Scan for this table's column stats (all columns, no filter)
419        let mut result = stats_table.scan(&[], None)?;
420        while result.next() {
421            let row = result.row();
422            // Check if this row is for our table (table_name is column 1)
423            if let Some(Value::Text(name)) = row.get(1) {
424                if name.eq_ignore_ascii_case(table_name) {
425                    if let Some(Value::Text(col_name)) = row.get(2) {
426                        let null_count = decode_nonnegative_stat(row.get(3));
427                        let distinct_count = decode_nonnegative_stat(row.get(4));
428                        let (Some(null_count), Some(distinct_count)) = (null_count, distinct_count)
429                        else {
430                            continue;
431                        };
432                        if null_count > table_row_count || distinct_count > table_row_count {
433                            continue;
434                        }
435                        // Parse histogram from JSON string if available
436                        let histogram = row
437                            .get(8)
438                            .and_then(|v| match v {
439                                Value::Text(s) => Some(s.to_string()),
440                                _ => None,
441                            })
442                            .and_then(|s| Histogram::from_json(&s));
443
444                        let col_stats = ColumnStatsCache {
445                            null_count,
446                            distinct_count,
447                            min_value: row.get(5).and_then(|value| match value {
448                                Value::Text(encoded) => decode_statistics_value(encoded),
449                                Value::Null(_) => None,
450                                value => Some(value.clone()),
451                            }),
452                            max_value: row.get(6).and_then(|value| match value {
453                                Value::Text(encoded) => decode_statistics_value(encoded),
454                                Value::Null(_) => None,
455                                value => Some(value.clone()),
456                            }),
457                            histogram,
458                        };
459                        stats.insert(col_name.to_lowercase().to_string(), col_stats);
460                    }
461                }
462            }
463        }
464
465        Ok(stats)
466    }
467
468    /// Estimate selectivity for a predicate
469    fn estimate_selectivity(
470        &self,
471        op: Option<Operator>,
472        value: Option<&Value>,
473        col_stats: Option<&ColumnStatsCache>,
474        table_stats: &TableStats,
475    ) -> f64 {
476        match (op, value, col_stats) {
477            (Some(Operator::Eq), _, Some(stats)) if stats.distinct_count > 0 => {
478                // Equality: 1/distinct_count
479                1.0 / stats.distinct_count as f64
480            }
481            (Some(Operator::Eq), _, _) => {
482                // Default equality selectivity
483                0.1
484            }
485            (Some(Operator::Ne), _, Some(stats)) if stats.distinct_count > 0 => {
486                // Not equal: 1 - 1/distinct_count
487                1.0 - (1.0 / stats.distinct_count as f64)
488            }
489            (Some(Operator::Ne), _, _) => 0.9,
490            (
491                Some(Operator::Lt | Operator::Lte | Operator::Gt | Operator::Gte),
492                Some(val),
493                Some(stats),
494            ) => {
495                // Use histogram for accurate range selectivity if available
496                if let Some(ref histogram) = stats.histogram {
497                    let hist_op = match op {
498                        Some(Operator::Lt) => HistogramOp::LessThan,
499                        Some(Operator::Lte) => HistogramOp::LessThanOrEqual,
500                        Some(Operator::Gt) => HistogramOp::GreaterThan,
501                        Some(Operator::Gte) => HistogramOp::GreaterThanOrEqual,
502                        _ => HistogramOp::Equal,
503                    };
504                    return histogram.estimate_selectivity(val, hist_op);
505                }
506
507                // Fall back to min/max based heuristic
508                if let (Some(min), Some(max)) = (&stats.min_value, &stats.max_value) {
509                    if min < max {
510                        // Estimate position in range using linear interpolation
511                        let position = Self::estimate_value_position(val, min, max);
512                        match op {
513                            Some(Operator::Lt | Operator::Lte) => {
514                                if val <= min {
515                                    0.01
516                                } else if val >= max {
517                                    0.99
518                                } else {
519                                    position.clamp(0.01, 0.99)
520                                }
521                            }
522                            Some(Operator::Gt | Operator::Gte) => {
523                                if val >= max {
524                                    0.01
525                                } else if val <= min {
526                                    0.99
527                                } else {
528                                    (1.0 - position).clamp(0.01, 0.99)
529                                }
530                            }
531                            _ => 0.33,
532                        }
533                    } else {
534                        0.33
535                    }
536                } else {
537                    0.33
538                }
539            }
540            (Some(Operator::Lt | Operator::Lte | Operator::Gt | Operator::Gte), _, _) => {
541                // Default range selectivity
542                0.33
543            }
544            (Some(Operator::Like), _, _) => {
545                // LIKE selectivity depends on pattern
546                0.25
547            }
548            (Some(Operator::In), _, _) => {
549                // IN selectivity
550                0.2
551            }
552            (Some(Operator::NotIn), _, _) => {
553                // NOT IN selectivity
554                0.8
555            }
556            (Some(Operator::IsNull), _, Some(stats)) if table_stats.row_count > 0 => {
557                stats.null_count as f64 / table_stats.row_count as f64
558            }
559            (Some(Operator::IsNotNull), _, Some(stats)) if table_stats.row_count > 0 => {
560                1.0 - (stats.null_count as f64 / table_stats.row_count as f64)
561            }
562            _ => 1.0, // No selectivity reduction
563        }
564    }
565
566    /// Estimate the position of a value within a range (0.0 to 1.0)
567    /// Used for linear interpolation when histogram is not available
568    fn estimate_value_position(value: &Value, min: &Value, max: &Value) -> f64 {
569        match (min, max, value) {
570            (Value::Integer(lo), Value::Integer(hi), Value::Integer(v)) => {
571                if hi == lo {
572                    0.5
573                } else {
574                    ((*v - *lo) as f64 / (*hi - *lo) as f64).clamp(0.0, 1.0)
575                }
576            }
577            (Value::Float(lo), Value::Float(hi), Value::Float(v)) => {
578                if (hi - lo).abs() < f64::EPSILON {
579                    0.5
580                } else {
581                    ((v - lo) / (hi - lo)).clamp(0.0, 1.0)
582                }
583            }
584            // Handle mixed integer/float comparisons
585            (Value::Integer(lo), Value::Integer(hi), Value::Float(v)) => {
586                let lo_f = *lo as f64;
587                let hi_f = *hi as f64;
588                if (hi_f - lo_f).abs() < f64::EPSILON {
589                    0.5
590                } else {
591                    ((v - lo_f) / (hi_f - lo_f)).clamp(0.0, 1.0)
592                }
593            }
594            (Value::Float(lo), Value::Float(hi), Value::Integer(v)) => {
595                let v_f = *v as f64;
596                if (hi - lo).abs() < f64::EPSILON {
597                    0.5
598                } else {
599                    ((v_f - lo) / (hi - lo)).clamp(0.0, 1.0)
600                }
601            }
602            _ => 0.5, // Default for non-comparable types
603        }
604    }
605
606    /// Check if zone maps indicate that no rows can possibly match the expression
607    ///
608    /// Returns true if the entire scan can be skipped (zone maps show no match possible).
609    /// Returns false if:
610    /// - Zone maps are not available
611    /// - Some segments might match
612    /// - Expression cannot be evaluated against zone maps
613    ///
614    /// This enables early exit optimization for range queries on ordered data.
615    pub fn can_prune_entire_scan(
616        &self,
617        table: &dyn Table,
618        expr: &dyn radixdb_storage::expression::Expression,
619    ) -> bool {
620        let zone_maps = match table.get_zone_maps() {
621            Some(zm) => zm,
622            None => return false, // No zone maps, cannot prune
623        };
624
625        // Check if zone maps are stale
626        if zone_maps.is_stale() {
627            return false; // Stale zone maps, don't trust them
628        }
629
630        // Extract all comparisons from the expression
631        let comparisons = expr.collect_comparisons();
632        if comparisons.is_empty() {
633            return false; // No simple comparisons to check
634        }
635
636        // For AND expressions: ALL comparisons must show no possible match
637        // For a single comparison: check if any segment could match
638        for (column, op, value) in comparisons {
639            if let Some(segments) = zone_maps.get_segments_to_scan(column, op, value) {
640                if !segments.is_empty() {
641                    return false; // At least one segment might match
642                }
643            } else {
644                return false; // Cannot evaluate this comparison
645            }
646        }
647
648        // All comparisons indicate no segments match - can skip entire scan
649        true
650    }
651
652    /// Get overall health of statistics for a table
653    pub fn stats_health(&self, table_name: &str) -> StatsHealth {
654        let key = table_name.to_lowercase();
655        if let Some(cached) = self.stats_cache.read().unwrap().get(&key) {
656            return Self::classify_stats_health(cached.table_stats.row_count, cached.is_stale());
657        }
658
659        let table_stats = self.get_table_stats(table_name);
660
661        match table_stats {
662            Some(stats) => Self::classify_stats_health(stats.row_count, false),
663            None => StatsHealth::Missing,
664        }
665    }
666
667    fn classify_stats_health(row_count: u64, stale: bool) -> StatsHealth {
668        if row_count == 0 {
669            StatsHealth::Missing
670        } else if stale {
671            StatsHealth::Stale
672        } else {
673            StatsHealth::Current
674        }
675    }
676
677    // =========================================================================
678    // Cardinality Estimation for Scans
679    // =========================================================================
680
681    /// Estimate the number of rows that will be returned by a scan with a predicate
682    ///
683    /// This method uses table statistics and column statistics to estimate
684    /// selectivity of predicates. It also applies cardinality feedback corrections
685    /// if available from previous query executions.
686    ///
687    /// # Arguments
688    /// * `table_name` - Name of the table being scanned
689    /// * `predicate` - Optional WHERE clause predicate
690    ///
691    /// # Returns
692    /// Estimated number of rows, or None if stats are unavailable
693    pub fn estimate_scan_rows(
694        &self,
695        table_name: &str,
696        predicate: Option<&Expression>,
697    ) -> Option<u64> {
698        let table_stats = self.get_table_stats(table_name)?;
699        let base_rows = table_stats.row_count;
700
701        if base_rows == 0 {
702            return Some(0);
703        }
704
705        let predicate = match predicate {
706            Some(p) => p,
707            None => return Some(base_rows), // Full table scan
708        };
709
710        // Estimate selectivity from the predicate
711        let selectivity = self.estimate_predicate_selectivity(table_name, predicate, &table_stats);
712        let estimated = ((base_rows as f64) * selectivity).max(1.0) as u64;
713
714        // Apply feedback correction
715        Some(self.estimate_with_feedback(table_name, Some(predicate), estimated))
716    }
717
718    /// Estimate selectivity of a predicate expression
719    fn estimate_predicate_selectivity(
720        &self,
721        table_name: &str,
722        expr: &Expression,
723        table_stats: &TableStats,
724    ) -> f64 {
725        use radixdb_sql::ast::{InfixOperator, PrefixOperator};
726
727        match expr {
728            // Infix expressions (a AND b, a OR b, a = b, a IS NULL, etc.)
729            Expression::Infix(infix) => {
730                match infix.op_type {
731                    // AND: multiply selectivities (assuming independence)
732                    InfixOperator::And => {
733                        let left_sel = self.estimate_predicate_selectivity(
734                            table_name,
735                            &infix.left,
736                            table_stats,
737                        );
738                        let right_sel = self.estimate_predicate_selectivity(
739                            table_name,
740                            &infix.right,
741                            table_stats,
742                        );
743                        left_sel * right_sel
744                    }
745                    // OR: use inclusion-exclusion principle
746                    InfixOperator::Or => {
747                        let left_sel = self.estimate_predicate_selectivity(
748                            table_name,
749                            &infix.left,
750                            table_stats,
751                        );
752                        let right_sel = self.estimate_predicate_selectivity(
753                            table_name,
754                            &infix.right,
755                            table_stats,
756                        );
757                        // P(A or B) = P(A) + P(B) - P(A and B)
758                        (left_sel + right_sel - left_sel * right_sel).min(1.0)
759                    }
760                    // IS NULL
761                    InfixOperator::Is => {
762                        // Check if right side is NULL
763                        if matches!(infix.right.as_ref(), Expression::NullLiteral(_)) {
764                            let col_name = self.extract_column_name(&infix.left);
765                            let col_stats =
766                                col_name.and_then(|name| self.get_column_stats(table_name, &name));
767                            self.estimate_selectivity(
768                                Some(Operator::IsNull),
769                                None,
770                                col_stats.as_ref(),
771                                table_stats,
772                            )
773                        } else {
774                            0.5
775                        }
776                    }
777                    // IS NOT NULL
778                    InfixOperator::IsNot => {
779                        if matches!(infix.right.as_ref(), Expression::NullLiteral(_)) {
780                            let col_name = self.extract_column_name(&infix.left);
781                            let col_stats =
782                                col_name.and_then(|name| self.get_column_stats(table_name, &name));
783                            self.estimate_selectivity(
784                                Some(Operator::IsNotNull),
785                                None,
786                                col_stats.as_ref(),
787                                table_stats,
788                            )
789                        } else {
790                            0.5
791                        }
792                    }
793                    // Comparison operators
794                    InfixOperator::Equal => {
795                        let col_name = self
796                            .extract_column_name(&infix.left)
797                            .or_else(|| self.extract_column_name(&infix.right));
798                        let value = self
799                            .extract_value(&infix.right)
800                            .or_else(|| self.extract_value(&infix.left));
801                        let col_stats =
802                            col_name.and_then(|name| self.get_column_stats(table_name, &name));
803                        self.estimate_selectivity(
804                            Some(Operator::Eq),
805                            value.as_ref(),
806                            col_stats.as_ref(),
807                            table_stats,
808                        )
809                    }
810                    InfixOperator::NotEqual => {
811                        let col_name = self
812                            .extract_column_name(&infix.left)
813                            .or_else(|| self.extract_column_name(&infix.right));
814                        let value = self
815                            .extract_value(&infix.right)
816                            .or_else(|| self.extract_value(&infix.left));
817                        let col_stats =
818                            col_name.and_then(|name| self.get_column_stats(table_name, &name));
819                        self.estimate_selectivity(
820                            Some(Operator::Ne),
821                            value.as_ref(),
822                            col_stats.as_ref(),
823                            table_stats,
824                        )
825                    }
826                    InfixOperator::LessThan => {
827                        let col_name = self.extract_column_name(&infix.left);
828                        let value = self.extract_value(&infix.right);
829                        let col_stats =
830                            col_name.and_then(|name| self.get_column_stats(table_name, &name));
831                        self.estimate_selectivity(
832                            Some(Operator::Lt),
833                            value.as_ref(),
834                            col_stats.as_ref(),
835                            table_stats,
836                        )
837                    }
838                    InfixOperator::LessEqual => {
839                        let col_name = self.extract_column_name(&infix.left);
840                        let value = self.extract_value(&infix.right);
841                        let col_stats =
842                            col_name.and_then(|name| self.get_column_stats(table_name, &name));
843                        self.estimate_selectivity(
844                            Some(Operator::Lte),
845                            value.as_ref(),
846                            col_stats.as_ref(),
847                            table_stats,
848                        )
849                    }
850                    InfixOperator::GreaterThan => {
851                        let col_name = self.extract_column_name(&infix.left);
852                        let value = self.extract_value(&infix.right);
853                        let col_stats =
854                            col_name.and_then(|name| self.get_column_stats(table_name, &name));
855                        self.estimate_selectivity(
856                            Some(Operator::Gt),
857                            value.as_ref(),
858                            col_stats.as_ref(),
859                            table_stats,
860                        )
861                    }
862                    InfixOperator::GreaterEqual => {
863                        let col_name = self.extract_column_name(&infix.left);
864                        let value = self.extract_value(&infix.right);
865                        let col_stats =
866                            col_name.and_then(|name| self.get_column_stats(table_name, &name));
867                        self.estimate_selectivity(
868                            Some(Operator::Gte),
869                            value.as_ref(),
870                            col_stats.as_ref(),
871                            table_stats,
872                        )
873                    }
874                    InfixOperator::Like | InfixOperator::ILike => {
875                        let pattern_str = self.extract_string_value(&infix.right);
876                        match pattern_str {
877                            Some(p) if !p.starts_with('%') => 0.1, // Prefix match is more selective
878                            Some(_) => 0.25,                       // Suffix or contains
879                            None => 0.25,
880                        }
881                    }
882                    InfixOperator::NotLike | InfixOperator::NotILike => {
883                        let pattern_str = self.extract_string_value(&infix.right);
884                        let like_sel = match pattern_str {
885                            Some(p) if !p.starts_with('%') => 0.1,
886                            Some(_) => 0.25,
887                            None => 0.25,
888                        };
889                        1.0 - like_sel
890                    }
891                    // Default for other operators
892                    _ => 0.5,
893                }
894            }
895            // IN expression
896            Expression::In(in_expr) => {
897                let col_name = self.extract_column_name(&in_expr.left);
898                let col_stats = col_name.and_then(|name| self.get_column_stats(table_name, &name));
899
900                // Get list size from the right side
901                let list_size = match in_expr.right.as_ref() {
902                    Expression::List(list) => list.elements.len() as f64,
903                    Expression::ExpressionList(list) => list.expressions.len() as f64,
904                    _ => 5.0, // Default assumption
905                };
906                let distinct = col_stats
907                    .map(|s| s.distinct_count.max(1) as f64)
908                    .unwrap_or(100.0);
909                let in_selectivity = (list_size / distinct).min(1.0);
910
911                if in_expr.not {
912                    1.0 - in_selectivity
913                } else {
914                    in_selectivity
915                }
916            }
917            // BETWEEN expression
918            Expression::Between(between) => {
919                let col_name = self.extract_column_name(&between.expr);
920                let col_stats = col_name.and_then(|name| self.get_column_stats(table_name, &name));
921                let low_val = self.extract_value(&between.lower);
922                let high_val = self.extract_value(&between.upper);
923
924                // Estimate as (high - low) / (max - min)
925                let range_sel = if let (Some(ref stats), Some(low_v), Some(high_v)) =
926                    (&col_stats, low_val, high_val)
927                {
928                    if let (Some(min), Some(max)) = (&stats.min_value, &stats.max_value) {
929                        let low_pos = Self::estimate_value_position(&low_v, min, max);
930                        let high_pos = Self::estimate_value_position(&high_v, min, max);
931                        (high_pos - low_pos).abs().clamp(0.01, 0.99)
932                    } else {
933                        0.25 // Default BETWEEN selectivity
934                    }
935                } else {
936                    0.25
937                };
938
939                if between.not {
940                    1.0 - range_sel
941                } else {
942                    range_sel
943                }
944            }
945            // LIKE expression (standalone)
946            Expression::Like(like_expr) => {
947                let is_negated = like_expr.operator.to_uppercase().contains("NOT");
948                let pattern_str = self.extract_string_value(&like_expr.pattern);
949                let base_sel = match pattern_str {
950                    Some(p) if !p.starts_with('%') => 0.1,
951                    Some(_) => 0.25,
952                    None => 0.25,
953                };
954                if is_negated {
955                    1.0 - base_sel
956                } else {
957                    base_sel
958                }
959            }
960            // Prefix expressions (NOT x, -x)
961            Expression::Prefix(prefix) => match prefix.op_type {
962                PrefixOperator::Not => {
963                    1.0 - self.estimate_predicate_selectivity(
964                        table_name,
965                        &prefix.right,
966                        table_stats,
967                    )
968                }
969                _ => 0.5,
970            },
971            // Unknown expressions - conservative estimate
972            _ => 0.5,
973        }
974    }
975
976    /// Extract column name from an expression
977    fn extract_column_name(&self, expr: &Expression) -> Option<String> {
978        match expr {
979            Expression::Identifier(id) => Some(id.value_lower.to_string()),
980            Expression::QualifiedIdentifier(qid) => Some(qid.name.value_lower.to_string()),
981            _ => None,
982        }
983    }
984
985    /// Extract a Value from a literal expression
986    fn extract_value(&self, expr: &Expression) -> Option<Value> {
987        match expr {
988            Expression::IntegerLiteral(lit) => Some(Value::Integer(lit.value)),
989            Expression::FloatLiteral(lit) => Some(Value::Float(lit.value)),
990            Expression::StringLiteral(lit) => Some(Value::Text(lit.value.to_string().into())),
991            Expression::BooleanLiteral(lit) => Some(Value::Boolean(lit.value)),
992            Expression::NullLiteral(_) => None, // NULL doesn't have a comparable value
993            _ => None,
994        }
995    }
996
997    /// Extract string value from an expression
998    fn extract_string_value(&self, expr: &Expression) -> Option<String> {
999        match expr {
1000            Expression::StringLiteral(lit) => Some(lit.value.to_string()),
1001            _ => None,
1002        }
1003    }
1004
1005    // =========================================================================
1006    // Cardinality Feedback Integration
1007    // =========================================================================
1008
1009    /// Estimate row count with cardinality feedback correction
1010    ///
1011    /// This method combines statistics-based estimation with learned corrections
1012    /// from previous query executions. When similar predicates have been executed
1013    /// before, the correction factor improves accuracy.
1014    ///
1015    /// # Arguments
1016    /// * `table_name` - Name of the table being scanned
1017    /// * `predicate` - The WHERE clause predicate (for fingerprinting)
1018    /// * `base_estimate` - Initial row count estimate from statistics
1019    ///
1020    /// # Returns
1021    /// Corrected row count estimate
1022    pub fn estimate_with_feedback(
1023        &self,
1024        table_name: &str,
1025        predicate: Option<&Expression>,
1026        base_estimate: u64,
1027    ) -> u64 {
1028        let predicate = match predicate {
1029            Some(p) => p,
1030            None => return base_estimate, // No predicate, no feedback
1031        };
1032
1033        // Get fingerprint for this predicate pattern
1034        let fingerprint = fingerprint_predicate(table_name, predicate);
1035
1036        // Look up and apply any learned correction
1037        self.feedback_cache
1038            .apply_correction(table_name, fingerprint, base_estimate)
1039    }
1040
1041    /// Record cardinality feedback after query execution
1042    ///
1043    /// This method stores the difference between estimated and actual row counts,
1044    /// enabling future queries with similar predicates to benefit from the correction.
1045    ///
1046    /// # Arguments
1047    /// * `table_name` - Name of the table that was scanned
1048    /// * `predicate` - The WHERE clause predicate (for fingerprinting)
1049    /// * `column_name` - Optional column name for more specific feedback
1050    /// * `estimated_rows` - Row count estimate used during planning
1051    /// * `actual_rows` - Actual row count observed during execution
1052    pub fn record_feedback(
1053        &self,
1054        table_name: &str,
1055        predicate: &Expression,
1056        column_name: Option<String>,
1057        estimated_rows: u64,
1058        actual_rows: u64,
1059    ) {
1060        // Only record if there's meaningful difference (avoid noise from perfect estimates)
1061        if estimated_rows == actual_rows {
1062            return;
1063        }
1064
1065        // Only record if actual rows are significant (avoid learning from tiny results)
1066        if actual_rows < 10 && estimated_rows < 10 {
1067            return;
1068        }
1069
1070        let fingerprint = fingerprint_predicate(table_name, predicate);
1071        self.feedback_cache.record_feedback(
1072            table_name,
1073            fingerprint,
1074            column_name,
1075            estimated_rows,
1076            actual_rows,
1077        );
1078    }
1079
1080    /// Get the correction factor for a predicate (for debugging/EXPLAIN)
1081    ///
1082    /// Returns 1.0 if no feedback is available or if feedback is not yet reliable.
1083    pub fn get_feedback_correction(&self, table_name: &str, predicate: &Expression) -> f64 {
1084        let fingerprint = fingerprint_predicate(table_name, predicate);
1085        self.feedback_cache.get_correction(table_name, fingerprint)
1086    }
1087}
1088
1089/// Health status of table statistics
1090#[derive(Debug, Clone, Copy, PartialEq)]
1091pub enum StatsHealth {
1092    /// Statistics are current (recently analyzed)
1093    Current,
1094    /// Statistics exist but may be stale
1095    Stale,
1096    /// No statistics available
1097    Missing,
1098}
1099
1100pub use crate::join_executor::{RuntimeJoinAlgorithm, RuntimeJoinDecision};
1101
1102/// Cost input for choosing between a bounded indexed lookup and the general
1103/// scan/hash path for one equality JOIN edge.
1104///
1105/// Costs are expressed as comparable byte-work units.  This is deliberately a
1106/// physical input contract: the caller supplies the visible inner cardinality,
1107/// storage pages, measured/schema-derived widths and the number of outer rows
1108/// that can reach this edge.  No fixed "bytes per row" guess is hidden here.
1109#[derive(Debug, Clone, Copy)]
1110#[doc(hidden)]
1111pub struct IndexedJoinCostInput {
1112    pub outer_rows: u64,
1113    pub inner_rows: u64,
1114    pub inner_pages: u64,
1115    pub inner_distinct_keys: Option<u64>,
1116    pub inner_row_width: u64,
1117    pub projected_inner_width: u64,
1118    pub lookup_unique: bool,
1119    pub limit: Option<u64>,
1120}
1121
1122/// Costed physical access decision for one indexed JOIN edge.
1123#[derive(Debug, Clone, PartialEq, Eq)]
1124#[doc(hidden)]
1125pub struct IndexedJoinCostDecision {
1126    pub use_index_lookup: bool,
1127    pub lookup_cost: u64,
1128    pub scan_hash_cost: u64,
1129    pub expected_matches: u64,
1130    pub explanation: String,
1131}
1132
1133impl QueryPlanner {
1134    /// Compare a deduplicated batch lookup with materializing the complete
1135    /// inner relation and building/probing the general equality JOIN.
1136    ///
1137    /// The model intentionally charges one metadata page plus one random page
1138    /// per distinct outer key.  Batch lookup can therefore lose for tiny inner
1139    /// relations, while a selective root wins as the unrelated inner relation
1140    /// grows.  Non-unique fan-out comes from ANALYZE distinctness when present;
1141    /// without it the square-root estimate is conservative and deterministic.
1142    #[doc(hidden)]
1143    pub fn plan_indexed_join_access(&self, input: IndexedJoinCostInput) -> IndexedJoinCostDecision {
1144        let outer_rows = match input.limit {
1145            Some(limit) if input.lookup_unique => input.outer_rows.min(limit.saturating_mul(2)),
1146            _ => input.outer_rows,
1147        };
1148        let distinct_inner = if input.lookup_unique {
1149            input.inner_rows
1150        } else {
1151            input
1152                .inner_distinct_keys
1153                .filter(|count| *count > 0)
1154                .unwrap_or_else(|| input.inner_rows.max(1).isqrt())
1155                .min(input.inner_rows.max(1))
1156        };
1157        let fanout = if input.lookup_unique || input.inner_rows == 0 {
1158            u64::from(input.inner_rows > 0)
1159        } else {
1160            input.inner_rows.div_ceil(distinct_inner.max(1)).max(1)
1161        };
1162        let distinct_probes = outer_rows.min(distinct_inner.max(1));
1163        let expected_matches = outer_rows.saturating_mul(fanout);
1164
1165        let lookup_cost = STORAGE_PAGE_BYTES
1166            .saturating_add(distinct_probes.saturating_mul(STORAGE_PAGE_BYTES))
1167            .saturating_add(expected_matches.saturating_mul(input.inner_row_width))
1168            .saturating_add(expected_matches.saturating_mul(input.projected_inner_width));
1169        let scan_hash_cost = input
1170            .inner_pages
1171            .saturating_mul(STORAGE_PAGE_BYTES)
1172            .saturating_add(input.inner_rows.saturating_mul(input.projected_inner_width))
1173            .saturating_add(outer_rows.saturating_mul(input.projected_inner_width.max(1)));
1174
1175        // An empty outer input needs no inner scan.  Otherwise ties stay on the
1176        // general path because it has less index/batch setup work.
1177        let use_index_lookup = outer_rows == 0 || lookup_cost < scan_hash_cost;
1178        let selected = if use_index_lookup {
1179            "batch index lookup"
1180        } else {
1181            "scan/hash"
1182        };
1183        IndexedJoinCostDecision {
1184            use_index_lookup,
1185            lookup_cost,
1186            scan_hash_cost,
1187            expected_matches,
1188            explanation: format!(
1189                "{selected}: lookup_cost={lookup_cost}, scan_hash_cost={scan_hash_cost}, outer_rows={outer_rows}, inner_rows={}, distinct_keys={distinct_inner}, expected_matches={expected_matches}",
1190                input.inner_rows
1191            ),
1192        }
1193    }
1194
1195    /// Make a runtime join algorithm decision based on actual row counts
1196    ///
1197    /// This is called during execution with the actual materialized row counts,
1198    /// enabling adaptive decisions that account for runtime conditions.
1199    /// Also consults the EdgeAwarePlanner for workload-learned hints.
1200    ///
1201    /// # Arguments
1202    /// * `left_rows` - Actual row count from left side
1203    /// * `right_rows` - Actual row count from right side
1204    /// * `has_equality_keys` - Whether join has equality conditions (a.x = b.x)
1205    ///
1206    /// # Returns
1207    /// Decision on which algorithm to use and whether to swap sides
1208    pub fn plan_runtime_join(
1209        &self,
1210        left_rows: usize,
1211        right_rows: usize,
1212        has_equality_keys: bool,
1213    ) -> RuntimeJoinDecision {
1214        self.plan_runtime_join_with_sort_info(
1215            left_rows,
1216            right_rows,
1217            has_equality_keys,
1218            false,
1219            false,
1220        )
1221    }
1222
1223    /// Make a runtime join algorithm decision with sort information
1224    ///
1225    /// Extended version that also considers whether inputs are pre-sorted,
1226    /// which enables merge join optimization.
1227    ///
1228    /// # Arguments
1229    /// * `left_rows` - Actual row count from left side
1230    /// * `right_rows` - Actual row count from right side
1231    /// * `has_equality_keys` - Whether join has equality conditions (a.x = b.x)
1232    /// * `left_sorted` - Whether left input is sorted on join keys
1233    /// * `right_sorted` - Whether right input is sorted on join keys
1234    pub fn plan_runtime_join_with_sort_info(
1235        &self,
1236        left_rows: usize,
1237        right_rows: usize,
1238        has_equality_keys: bool,
1239        left_sorted: bool,
1240        right_sorted: bool,
1241    ) -> RuntimeJoinDecision {
1242        // For small tables, nested loop is faster (no hash table overhead)
1243        // PostgreSQL uses similar thresholds
1244        const NESTED_LOOP_MAX: usize = 200;
1245        const HASH_JOIN_MIN_BENEFIT: usize = 50;
1246        const ESTIMATED_BYTES_PER_ROW: u64 = 100;
1247        // Merge join is preferred over hash when both inputs are sorted
1248        // and tables are large enough to benefit from avoiding hash overhead
1249        const MERGE_JOIN_MIN_ROWS: usize = 500;
1250
1251        let total_rows = left_rows + right_rows;
1252        let product = left_rows.saturating_mul(right_rows);
1253
1254        // Case 1: No equality keys - must use nested loop
1255        if !has_equality_keys {
1256            return RuntimeJoinDecision {
1257                algorithm: RuntimeJoinAlgorithm::NestedLoop,
1258                swap_sides: false,
1259                explanation: "Nested loop: no equality join keys".to_string(),
1260            };
1261        }
1262
1263        // Consult EdgeAwarePlanner for workload-learned hints
1264        let edge_planner = EdgeAwarePlanner::from_global();
1265        let (build_rows_u64, probe_rows_u64) = if right_rows < left_rows {
1266            (right_rows as u64, left_rows as u64)
1267        } else {
1268            (left_rows as u64, right_rows as u64)
1269        };
1270
1271        let edge_recommendation = edge_planner.recommend_join_for_edge(
1272            build_rows_u64,
1273            probe_rows_u64,
1274            ESTIMATED_BYTES_PER_ROW,
1275        );
1276
1277        // Check if edge constraints force a specific algorithm
1278        match edge_recommendation {
1279            EdgeJoinRecommendation::ForceNestedLoop { reason } => {
1280                return RuntimeJoinDecision {
1281                    algorithm: RuntimeJoinAlgorithm::NestedLoop,
1282                    swap_sides: false,
1283                    explanation: format!("Nested loop (edge constraint): {}", reason),
1284                };
1285            }
1286            EdgeJoinRecommendation::PreferNestedLoop { .. } => {
1287                // A learned interactive-workload preference must not override
1288                // the physical equality-join cost.  In particular, long
1289                // selective chains frequently keep both sides below a few
1290                // hundred rows; choosing nested loop at every edge turns that
1291                // useful selectivity into repeated O(N*M) JoinFilter work.
1292                // The cardinality cost below remains authoritative; the hint
1293                // may influence future first-row/index alternatives, but not
1294                // replace an equality operator with repeated expression-VM
1295                // evaluation.
1296            }
1297            EdgeJoinRecommendation::PreferHashJoin { .. } => {
1298                // Edge mode prefers hash join - skip nested loop checks for medium tables
1299                // But still consider merge join if both inputs are sorted
1300                if total_rows > NESTED_LOOP_MAX && !(left_sorted && right_sorted) {
1301                    let swap = right_rows < left_rows;
1302                    let (build, probe) = if swap {
1303                        (right_rows, left_rows)
1304                    } else {
1305                        (left_rows, right_rows)
1306                    };
1307                    return RuntimeJoinDecision {
1308                        algorithm: RuntimeJoinAlgorithm::HashJoin,
1309                        swap_sides: swap,
1310                        explanation: format!(
1311                            "Hash join (batch workload): build {} rows, probe {} rows",
1312                            build, probe
1313                        ),
1314                    };
1315                }
1316            }
1317            EdgeJoinRecommendation::UseDefault => {
1318                // Fall through to standard cost-based decision
1319            }
1320        }
1321
1322        // Case 2: An empty input never benefits from building hash state. This
1323        // must precede tiny/tiny because zero is also "tiny".
1324        if left_rows == 0 || right_rows == 0 {
1325            return RuntimeJoinDecision {
1326                algorithm: RuntimeJoinAlgorithm::NestedLoop,
1327                swap_sides: false,
1328                explanation: "Nested loop: one side empty".to_string(),
1329            };
1330        }
1331
1332        // Case 3: Both sides tiny - use hash join (still faster than nested loop)
1333        // RATIONALE: Even for small datasets, hash join with equality keys is O(N+M)
1334        // while nested loop is O(N*M) with JoinFilter VM evaluation per comparison.
1335        // The hash table overhead is minimal for small tables, and we avoid expensive
1336        // per-comparison expression evaluation.
1337        if left_rows <= NESTED_LOOP_MAX && right_rows <= NESTED_LOOP_MAX {
1338            let swap = right_rows < left_rows;
1339            return RuntimeJoinDecision {
1340                algorithm: RuntimeJoinAlgorithm::HashJoin,
1341                swap_sides: swap,
1342                explanation: format!(
1343                    "Hash join: small tables ({} + {} = {} ops vs {} comparisons)",
1344                    left_rows, right_rows, total_rows, product
1345                ),
1346            };
1347        }
1348
1349        // Case 4: Merge join when both inputs are already sorted
1350        // Merge join is O(N + M) like hash join, but avoids hash table overhead
1351        // It's optimal when both inputs are pre-sorted on join keys
1352        if left_sorted && right_sorted && total_rows >= MERGE_JOIN_MIN_ROWS {
1353            return RuntimeJoinDecision {
1354                algorithm: RuntimeJoinAlgorithm::MergeJoin,
1355                swap_sides: false,
1356                explanation: format!(
1357                    "Merge join: both inputs sorted ({} + {} rows)",
1358                    left_rows, right_rows
1359                ),
1360            };
1361        }
1362
1363        // Case 5: Hash join cost analysis
1364        // Hash join is O(N + M) vs nested loop O(N * M)
1365        // But hash join has setup cost, so only use when beneficial
1366        let hash_cost = total_rows as f64;
1367        let nested_cost = product as f64;
1368
1369        if nested_cost < hash_cost + HASH_JOIN_MIN_BENEFIT as f64 {
1370            // Nested loop is cheaper even accounting for setup
1371            return RuntimeJoinDecision {
1372                algorithm: RuntimeJoinAlgorithm::NestedLoop,
1373                swap_sides: false,
1374                explanation: format!(
1375                    "Nested loop: cheaper than hash ({} < {} + setup)",
1376                    product, total_rows
1377                ),
1378            };
1379        }
1380
1381        // Case 6: Use hash join with smaller side as build side
1382        let swap = right_rows < left_rows;
1383        let (build_rows, probe_rows) = if swap {
1384            (right_rows, left_rows)
1385        } else {
1386            (left_rows, right_rows)
1387        };
1388
1389        RuntimeJoinDecision {
1390            algorithm: RuntimeJoinAlgorithm::HashJoin,
1391            swap_sides: swap,
1392            explanation: format!(
1393                "Hash join: build {} rows, probe {} rows (swap={})",
1394                build_rows, probe_rows, swap
1395            ),
1396        }
1397    }
1398}
1399
1400#[cfg(test)]
1401mod tests {
1402    use super::*;
1403    use radixdb_core::SchemaBuilder;
1404
1405    #[test]
1406    fn fallback_width_is_schema_derived_instead_of_fixed() {
1407        let narrow = SchemaBuilder::new("narrow")
1408            .add_primary_key("id", DataType::Integer)
1409            .add("active", DataType::Boolean)
1410            .build();
1411        let wide = SchemaBuilder::new("wide")
1412            .add_primary_key("id", DataType::Integer)
1413            .add_nullable("payload", DataType::Text)
1414            .add("uuid", DataType::Uuid)
1415            .build();
1416
1417        assert_eq!(estimated_schema_row_width(&narrow), 9);
1418        assert_eq!(estimated_schema_row_width(&wide), 57);
1419        assert_ne!(estimated_schema_row_width(&wide), 100);
1420    }
1421
1422    #[test]
1423    fn v2_r5_catalog_statistics_reject_negative_values() {
1424        assert_eq!(decode_nonnegative_stat(Some(&Value::Integer(0))), Some(0));
1425        assert_eq!(decode_nonnegative_stat(Some(&Value::Integer(42))), Some(42));
1426        assert_eq!(decode_nonnegative_stat(Some(&Value::Integer(-1))), None);
1427        assert_eq!(
1428            decode_nonnegative_stat(Some(&Value::Text("1".into()))),
1429            None
1430        );
1431    }
1432
1433    #[test]
1434    fn r8_l01_batch_h_empty_join_bypasses_tiny_hash_plan() {
1435        let planner = QueryPlanner::new(Arc::new(MVCCEngine::in_memory()));
1436        for (left, right) in [(0, 0), (0, 17), (17, 0)] {
1437            let decision = planner.plan_runtime_join(left, right, true);
1438            assert!(decision.use_nested_loop(), "{left} x {right}: {decision:?}");
1439        }
1440    }
1441
1442    #[test]
1443    fn test_selectivity_estimation() {
1444        let planner = QueryPlanner::new(Arc::new(MVCCEngine::in_memory()));
1445
1446        // Test equality selectivity with distinct count
1447        let col_stats = ColumnStatsCache {
1448            null_count: 0,
1449            distinct_count: 100,
1450            min_value: Some(Value::Integer(1)),
1451            max_value: Some(Value::Integer(100)),
1452            histogram: None,
1453        };
1454        let table_stats = TableStats::default();
1455
1456        let sel = planner.estimate_selectivity(
1457            Some(Operator::Eq),
1458            Some(&Value::Integer(50)),
1459            Some(&col_stats),
1460            &table_stats,
1461        );
1462        assert!((sel - 0.01).abs() < 0.001); // 1/100 = 0.01
1463
1464        // Test no column stats
1465        let sel_no_stats = planner.estimate_selectivity(
1466            Some(Operator::Eq),
1467            Some(&Value::Integer(50)),
1468            None,
1469            &table_stats,
1470        );
1471        assert!((sel_no_stats - 0.1).abs() < 0.001); // Default
1472    }
1473
1474    #[test]
1475    fn test_estimate_value_position_integers() {
1476        // Value in middle of range
1477        let pos = QueryPlanner::estimate_value_position(
1478            &Value::Integer(50),
1479            &Value::Integer(0),
1480            &Value::Integer(100),
1481        );
1482        assert!((pos - 0.5).abs() < 0.001);
1483
1484        // Value at start
1485        let pos = QueryPlanner::estimate_value_position(
1486            &Value::Integer(0),
1487            &Value::Integer(0),
1488            &Value::Integer(100),
1489        );
1490        assert!(pos.abs() < 0.001);
1491
1492        // Value at end
1493        let pos = QueryPlanner::estimate_value_position(
1494            &Value::Integer(100),
1495            &Value::Integer(0),
1496            &Value::Integer(100),
1497        );
1498        assert!((pos - 1.0).abs() < 0.001);
1499    }
1500
1501    #[test]
1502    fn test_estimate_value_position_floats() {
1503        let pos = QueryPlanner::estimate_value_position(
1504            &Value::Float(0.75),
1505            &Value::Float(0.0),
1506            &Value::Float(1.0),
1507        );
1508        assert!((pos - 0.75).abs() < 0.001);
1509    }
1510
1511    #[test]
1512    fn test_estimate_value_position_equal_bounds() {
1513        // Equal bounds should return 0.5
1514        let pos = QueryPlanner::estimate_value_position(
1515            &Value::Integer(50),
1516            &Value::Integer(50),
1517            &Value::Integer(50),
1518        );
1519        assert!((pos - 0.5).abs() < 0.001);
1520    }
1521
1522    #[test]
1523    fn test_estimate_value_position_clamping() {
1524        // Value below range should clamp to 0
1525        let pos = QueryPlanner::estimate_value_position(
1526            &Value::Integer(-10),
1527            &Value::Integer(0),
1528            &Value::Integer(100),
1529        );
1530        assert!(pos.abs() < 0.001);
1531
1532        // Value above range should clamp to 1
1533        let pos = QueryPlanner::estimate_value_position(
1534            &Value::Integer(200),
1535            &Value::Integer(0),
1536            &Value::Integer(100),
1537        );
1538        assert!((pos - 1.0).abs() < 0.001);
1539    }
1540
1541    #[test]
1542    fn test_runtime_join_decision_hash_join() {
1543        let planner = QueryPlanner::new(Arc::new(MVCCEngine::in_memory()));
1544
1545        // For equality joins with reasonable sizes, hash join should be selected
1546        let decision = planner.plan_runtime_join(1000, 1000, true);
1547        assert!(decision.use_hash_join());
1548        assert!(!decision.use_merge_join());
1549        assert!(!decision.use_nested_loop());
1550    }
1551
1552    #[test]
1553    fn test_runtime_join_decision_nested_loop() {
1554        let planner = QueryPlanner::new(Arc::new(MVCCEngine::in_memory()));
1555
1556        // Non-equality joins should use nested loop
1557        let decision = planner.plan_runtime_join(100, 100, false);
1558        assert!(decision.use_nested_loop());
1559        assert!(!decision.use_hash_join());
1560        assert!(!decision.use_merge_join());
1561    }
1562
1563    #[test]
1564    fn test_runtime_join_decision_small_tables() {
1565        let planner = QueryPlanner::new(Arc::new(MVCCEngine::in_memory()));
1566
1567        // Very small tables might still use hash join for equality
1568        let decision = planner.plan_runtime_join(10, 10, true);
1569        // Small tables with equality should still use efficient algorithm
1570        assert!(decision.use_hash_join() || decision.use_nested_loop());
1571    }
1572
1573    #[test]
1574    fn test_runtime_join_decision_merge_join() {
1575        let planner = QueryPlanner::new(Arc::new(MVCCEngine::in_memory()));
1576
1577        // When both sides are sorted, merge join might be preferred
1578        let decision = planner.plan_runtime_join_with_sort_info(10000, 10000, true, true, true);
1579        assert!(decision.use_merge_join() || decision.use_hash_join());
1580    }
1581
1582    #[test]
1583    fn test_selectivity_range_operators() {
1584        let planner = QueryPlanner::new(Arc::new(MVCCEngine::in_memory()));
1585
1586        let col_stats = ColumnStatsCache {
1587            null_count: 0,
1588            distinct_count: 100,
1589            min_value: Some(Value::Integer(1)),
1590            max_value: Some(Value::Integer(100)),
1591            histogram: None,
1592        };
1593        let table_stats = TableStats::default();
1594
1595        // Test Greater Than - should be about 50% for value in middle
1596        let sel = planner.estimate_selectivity(
1597            Some(Operator::Gt),
1598            Some(&Value::Integer(50)),
1599            Some(&col_stats),
1600            &table_stats,
1601        );
1602        assert!(sel > 0.0 && sel < 1.0);
1603
1604        // Test Less Than
1605        let sel = planner.estimate_selectivity(
1606            Some(Operator::Lt),
1607            Some(&Value::Integer(50)),
1608            Some(&col_stats),
1609            &table_stats,
1610        );
1611        assert!(sel > 0.0 && sel < 1.0);
1612    }
1613
1614    #[test]
1615    fn test_selectivity_no_operator() {
1616        let planner = QueryPlanner::new(Arc::new(MVCCEngine::in_memory()));
1617        let table_stats = TableStats::default();
1618
1619        // No operator should return default selectivity
1620        let sel = planner.estimate_selectivity(None, Some(&Value::Integer(50)), None, &table_stats);
1621        assert!((sel - 1.0).abs() < 0.001);
1622    }
1623
1624    #[test]
1625    fn test_stats_health_missing() {
1626        let planner = QueryPlanner::new(Arc::new(MVCCEngine::in_memory()));
1627
1628        // Non-existent table should return Missing
1629        let health = planner.stats_health("non_existent_table");
1630        assert!(matches!(health, StatsHealth::Missing));
1631    }
1632
1633    #[test]
1634    fn r5_l04_batch_g_stats_health_classifies_stale_cache_entries() {
1635        assert_eq!(
1636            QueryPlanner::classify_stats_health(42, true),
1637            StatsHealth::Stale
1638        );
1639        assert_eq!(
1640            QueryPlanner::classify_stats_health(42, false),
1641            StatsHealth::Current
1642        );
1643        assert_eq!(
1644            QueryPlanner::classify_stats_health(0, true),
1645            StatsHealth::Missing
1646        );
1647    }
1648
1649    #[test]
1650    fn test_estimate_value_position_mixed_types() {
1651        // Integer min/max with float value
1652        let pos = QueryPlanner::estimate_value_position(
1653            &Value::Float(50.5),
1654            &Value::Integer(0),
1655            &Value::Integer(100),
1656        );
1657        assert!(pos > 0.49 && pos < 0.52);
1658
1659        // Float min/max with integer value
1660        let pos = QueryPlanner::estimate_value_position(
1661            &Value::Integer(75),
1662            &Value::Float(0.0),
1663            &Value::Float(100.0),
1664        );
1665        assert!((pos - 0.75).abs() < 0.001);
1666    }
1667
1668    #[test]
1669    fn test_runtime_join_decision_explanation() {
1670        let planner = QueryPlanner::new(Arc::new(MVCCEngine::in_memory()));
1671
1672        let decision = planner.plan_runtime_join(1000, 1000, true);
1673        // Explanation should not be empty
1674        assert!(!decision.explanation.is_empty());
1675    }
1676
1677    #[test]
1678    fn indexed_join_cost_prefers_scan_for_tiny_inner_relation() {
1679        let planner = QueryPlanner::new(Arc::new(MVCCEngine::in_memory()));
1680        let decision = planner.plan_indexed_join_access(IndexedJoinCostInput {
1681            outer_rows: 3,
1682            inner_rows: 4,
1683            inner_pages: 1,
1684            inner_distinct_keys: Some(4),
1685            inner_row_width: 24,
1686            projected_inner_width: 16,
1687            lookup_unique: true,
1688            limit: None,
1689        });
1690
1691        assert!(!decision.use_index_lookup, "{}", decision.explanation);
1692        assert!(decision.scan_hash_cost < decision.lookup_cost);
1693    }
1694
1695    #[test]
1696    fn indexed_join_cost_prefers_lookup_for_selective_large_inner_relation() {
1697        let planner = QueryPlanner::new(Arc::new(MVCCEngine::in_memory()));
1698        let decision = planner.plan_indexed_join_access(IndexedJoinCostInput {
1699            outer_rows: 1,
1700            inner_rows: 10_000,
1701            inner_pages: 196,
1702            inner_distinct_keys: Some(10_000),
1703            inner_row_width: 80,
1704            projected_inner_width: 16,
1705            lookup_unique: true,
1706            limit: None,
1707        });
1708
1709        assert!(decision.use_index_lookup, "{}", decision.explanation);
1710        assert!(decision.lookup_cost < decision.scan_hash_cost);
1711        assert_eq!(decision.expected_matches, 1);
1712    }
1713
1714    #[test]
1715    fn indexed_join_cost_accounts_for_non_unique_fanout() {
1716        let planner = QueryPlanner::new(Arc::new(MVCCEngine::in_memory()));
1717        let low_fanout = planner.plan_indexed_join_access(IndexedJoinCostInput {
1718            outer_rows: 4,
1719            inner_rows: 100_000,
1720            inner_pages: 2_000,
1721            inner_distinct_keys: Some(100_000),
1722            inner_row_width: 80,
1723            projected_inner_width: 16,
1724            lookup_unique: false,
1725            limit: None,
1726        });
1727        let high_fanout = planner.plan_indexed_join_access(IndexedJoinCostInput {
1728            inner_distinct_keys: Some(1),
1729            ..IndexedJoinCostInput {
1730                outer_rows: 4,
1731                inner_rows: 100_000,
1732                inner_pages: 2_000,
1733                inner_distinct_keys: None,
1734                inner_row_width: 80,
1735                projected_inner_width: 16,
1736                lookup_unique: false,
1737                limit: None,
1738            }
1739        });
1740
1741        assert!(low_fanout.use_index_lookup, "{}", low_fanout.explanation);
1742        assert!(!high_fanout.use_index_lookup, "{}", high_fanout.explanation);
1743        assert!(high_fanout.expected_matches > low_fanout.expected_matches);
1744    }
1745
1746    #[test]
1747    fn indexed_join_cost_uses_safe_limit_for_unique_edge() {
1748        let planner = QueryPlanner::new(Arc::new(MVCCEngine::in_memory()));
1749        let without_limit = planner.plan_indexed_join_access(IndexedJoinCostInput {
1750            outer_rows: 1_000,
1751            inner_rows: 10_000,
1752            inner_pages: 100,
1753            inner_distinct_keys: Some(10_000),
1754            inner_row_width: 80,
1755            projected_inner_width: 100,
1756            lookup_unique: true,
1757            limit: None,
1758        });
1759        let with_limit = planner.plan_indexed_join_access(IndexedJoinCostInput {
1760            limit: Some(10),
1761            ..IndexedJoinCostInput {
1762                outer_rows: 1_000,
1763                inner_rows: 10_000,
1764                inner_pages: 100,
1765                inner_distinct_keys: Some(10_000),
1766                inner_row_width: 80,
1767                projected_inner_width: 100,
1768                lookup_unique: true,
1769                limit: None,
1770            }
1771        });
1772
1773        assert!(
1774            !without_limit.use_index_lookup,
1775            "{}",
1776            without_limit.explanation
1777        );
1778        assert!(with_limit.use_index_lookup, "{}", with_limit.explanation);
1779        assert!(with_limit.lookup_cost < without_limit.lookup_cost);
1780    }
1781}