1use 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
43pub struct QueryPlanner {
45 engine: Arc<MVCCEngine>,
47 stats_cache: std::sync::RwLock<StringMap<CachedStats>>,
49 feedback_cache: Arc<FeedbackCache>,
51}
52
53const STATS_CACHE_TTL_SECS: u64 = 300;
56
57const 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#[derive(Clone)]
99struct CachedStats {
100 table_stats: TableStats,
101 column_stats: StringMap<ColumnStatsCache>,
102 cached_at: radixdb_core::time_compat::Instant,
104 last_accessed: radixdb_core::time_compat::Instant,
106}
107
108impl CachedStats {
109 fn is_stale(&self) -> bool {
111 self.cached_at.elapsed().as_secs() > STATS_CACHE_TTL_SECS
112 }
113
114 fn touch(&mut self) {
116 self.last_accessed = radixdb_core::time_compat::Instant::now();
117 }
118}
119
120#[derive(Clone)]
122pub struct ColumnStatsCache {
123 pub null_count: u64,
125 pub distinct_count: u64,
127 pub min_value: Option<Value>,
129 pub max_value: Option<Value>,
131 pub histogram: Option<Histogram>,
133}
134
135impl QueryPlanner {
136 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 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 pub fn clear_stats_cache(&self) {
163 let mut cache = self.stats_cache.write().unwrap();
164 cache.clear();
165 }
166
167 pub fn get_table_stats(&self, table_name: &str) -> Option<TableStats> {
176 let key = table_name.to_lowercase();
177
178 {
180 let cache = self.stats_cache.read().unwrap();
181 if let Some(cached) = cache.get(&key) {
182 if !cached.is_stale() && cached.table_stats.row_count > 0 {
184 let result = cached.table_stats.clone();
185 drop(cache);
187 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 }
197 }
198
199 self.load_stats_from_system_tables(table_name)
202 .ok()
203 .filter(|stats| stats.row_count > 0)
204 }
205
206 pub fn get_table_stats_with_fallback(&self, table: &dyn Table) -> TableStats {
211 let table_name = table.name();
212
213 if let Some(stats) = self.get_table_stats(table_name) {
215 if stats.row_count > 0 {
216 return stats;
217 }
218 }
219
220 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 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 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 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 } else {
262 true }
264 };
265
266 if should_reload {
267 let _ = self.load_stats_from_system_tables(table_name);
269 }
270
271 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 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 pub fn get_zone_maps(&self, table: &dyn Table) -> Option<std::sync::Arc<TableZoneMap>> {
293 table.get_zone_maps()
294 }
295
296 fn load_stats_from_system_tables(&self, table_name: &str) -> Result<TableStats> {
298 let tx = self.engine.begin_transaction()?;
299
300 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 return Ok(TableStats::default());
312 }
313
314 let table_stats = self.read_table_stats(&*tx, table_name)?;
316
317 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 {
326 let mut cache = self.stats_cache.write().unwrap();
327
328 if cache.len() >= MAX_STATS_CACHE_SIZE {
330 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 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 let mut result = stats_table.scan(&[], None)?;
367 while result.next() {
368 let row = result.row();
369 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 Ok(TableStats::default())
398 }
399
400 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 let mut result = stats_table.scan(&[], None)?;
420 while result.next() {
421 let row = result.row();
422 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 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 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 1.0 / stats.distinct_count as f64
480 }
481 (Some(Operator::Eq), _, _) => {
482 0.1
484 }
485 (Some(Operator::Ne), _, Some(stats)) if stats.distinct_count > 0 => {
486 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 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 if let (Some(min), Some(max)) = (&stats.min_value, &stats.max_value) {
509 if min < max {
510 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 0.33
543 }
544 (Some(Operator::Like), _, _) => {
545 0.25
547 }
548 (Some(Operator::In), _, _) => {
549 0.2
551 }
552 (Some(Operator::NotIn), _, _) => {
553 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, }
564 }
565
566 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 (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, }
604 }
605
606 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, };
624
625 if zone_maps.is_stale() {
627 return false; }
629
630 let comparisons = expr.collect_comparisons();
632 if comparisons.is_empty() {
633 return false; }
635
636 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; }
643 } else {
644 return false; }
646 }
647
648 true
650 }
651
652 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 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), };
709
710 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 Some(self.estimate_with_feedback(table_name, Some(predicate), estimated))
716 }
717
718 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 Expression::Infix(infix) => {
730 match infix.op_type {
731 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 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 (left_sel + right_sel - left_sel * right_sel).min(1.0)
759 }
760 InfixOperator::Is => {
762 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 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 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, Some(_) => 0.25, 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 _ => 0.5,
893 }
894 }
895 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 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, };
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 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 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 }
935 } else {
936 0.25
937 };
938
939 if between.not {
940 1.0 - range_sel
941 } else {
942 range_sel
943 }
944 }
945 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 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 _ => 0.5,
973 }
974 }
975
976 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 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, _ => None,
994 }
995 }
996
997 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 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, };
1032
1033 let fingerprint = fingerprint_predicate(table_name, predicate);
1035
1036 self.feedback_cache
1038 .apply_correction(table_name, fingerprint, base_estimate)
1039 }
1040
1041 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 if estimated_rows == actual_rows {
1062 return;
1063 }
1064
1065 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 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#[derive(Debug, Clone, Copy, PartialEq)]
1091pub enum StatsHealth {
1092 Current,
1094 Stale,
1096 Missing,
1098}
1099
1100pub use crate::join_executor::{RuntimeJoinAlgorithm, RuntimeJoinDecision};
1101
1102#[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#[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 #[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 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 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 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 const NESTED_LOOP_MAX: usize = 200;
1245 const HASH_JOIN_MIN_BENEFIT: usize = 50;
1246 const ESTIMATED_BYTES_PER_ROW: u64 = 100;
1247 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 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 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 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 }
1297 EdgeJoinRecommendation::PreferHashJoin { .. } => {
1298 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 }
1320 }
1321
1322 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 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 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 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 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 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 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); 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); }
1473
1474 #[test]
1475 fn test_estimate_value_position_integers() {
1476 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 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 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 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 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 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 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 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 let decision = planner.plan_runtime_join(10, 10, true);
1569 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 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 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 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 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 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 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 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 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}