Skip to main content

citadel_sql/
planner.rs

1//! Query planner: chooses between seq scan, PK lookup, or index scan.
2
3use crate::encoding::encode_composite_key;
4use crate::parser::{BinOp, Expr};
5use crate::types::{IndexDef, IndexKey, IndexKind, InvertedKind, TableSchema, Value};
6
7/// Canonical form of an expression for symbolic-equivalence matching against expression indexes.
8/// Strips table qualifiers, lowercases identifiers and function names, sorts commutative operands.
9#[derive(Debug, Clone, PartialEq, Eq)]
10enum CanonicalExpr {
11    Literal(String),
12    Column(String),
13    Function {
14        name: String,
15        args: Vec<CanonicalExpr>,
16    },
17    BinaryOp {
18        op: BinOp,
19        operands: Vec<CanonicalExpr>,
20    },
21    UnaryOp {
22        op: crate::parser::UnaryOp,
23        operand: Box<CanonicalExpr>,
24    },
25    Cast {
26        expr: Box<CanonicalExpr>,
27        data_type: crate::types::DataType,
28    },
29    Other(String),
30}
31
32fn canonicalize(expr: &Expr) -> CanonicalExpr {
33    match expr {
34        Expr::Literal(v) => CanonicalExpr::Literal(format!("{v:?}")),
35        Expr::Column(name) => CanonicalExpr::Column(name.to_ascii_lowercase()),
36        Expr::QualifiedColumn { column, .. } => CanonicalExpr::Column(column.to_ascii_lowercase()),
37        Expr::Function { name, args, .. } => {
38            let canon_args: Vec<CanonicalExpr> = args.iter().map(canonicalize).collect();
39            CanonicalExpr::Function {
40                name: name.to_ascii_lowercase(),
41                args: canon_args,
42            }
43        }
44        Expr::BinaryOp { left, op, right } => {
45            let mut operands = vec![canonicalize(left), canonicalize(right)];
46            if is_commutative(*op) {
47                operands.sort_by_key(|e| format!("{e:?}"));
48            }
49            CanonicalExpr::BinaryOp { op: *op, operands }
50        }
51        Expr::UnaryOp { op, expr: inner } => CanonicalExpr::UnaryOp {
52            op: *op,
53            operand: Box::new(canonicalize(inner)),
54        },
55        Expr::Cast {
56            expr: inner,
57            data_type,
58        } => CanonicalExpr::Cast {
59            expr: Box::new(canonicalize(inner)),
60            data_type: *data_type,
61        },
62        Expr::Collate { expr: inner, .. } => canonicalize(inner),
63        other => CanonicalExpr::Other(format!("{other:?}")),
64    }
65}
66
67fn is_commutative(op: BinOp) -> bool {
68    matches!(op, BinOp::Add | BinOp::Mul | BinOp::And | BinOp::Or)
69}
70
71#[derive(Debug, Clone)]
72pub enum ScanPlan {
73    SeqScan,
74    PkLookup {
75        pk_values: Vec<Value>,
76    },
77    PkRangeScan {
78        start_key: Vec<u8>,
79        range_conds: Vec<(BinOp, Value)>,
80        num_pk_cols: usize,
81    },
82    IndexScan {
83        index_name: String,
84        idx_table: Vec<u8>,
85        prefix: Vec<u8>,
86        num_prefix_cols: usize,
87        range_conds: Vec<(BinOp, Value)>,
88        is_unique: bool,
89        index_columns: Vec<u16>,
90    },
91    InvertedScan {
92        kind: InvertedKind,
93        idx_table: Vec<u8>,
94        column_idx: u16,
95        probe_entries: Vec<Vec<u8>>,
96        recheck_expr: Expr,
97        recheck_needed: bool,
98    },
99}
100
101struct SimplePredicate {
102    col_idx: usize,
103    op: BinOp,
104    value: Value,
105}
106
107fn flatten_and(expr: &Expr) -> Vec<&Expr> {
108    match expr {
109        Expr::BinaryOp {
110            left,
111            op: BinOp::And,
112            right,
113        } => {
114            let mut v = flatten_and(left);
115            v.extend(flatten_and(right));
116            v
117        }
118        _ => vec![expr],
119    }
120}
121
122fn is_comparison(op: BinOp) -> bool {
123    matches!(
124        op,
125        BinOp::Eq | BinOp::Lt | BinOp::LtEq | BinOp::Gt | BinOp::GtEq
126    )
127}
128
129fn is_range_op(op: BinOp) -> bool {
130    matches!(op, BinOp::Lt | BinOp::LtEq | BinOp::Gt | BinOp::GtEq)
131}
132
133fn flip_op(op: BinOp) -> BinOp {
134    match op {
135        BinOp::Lt => BinOp::Gt,
136        BinOp::LtEq => BinOp::GtEq,
137        BinOp::Gt => BinOp::Lt,
138        BinOp::GtEq => BinOp::LtEq,
139        other => other,
140    }
141}
142
143fn resolve_column_name(expr: &Expr) -> Option<&str> {
144    match expr {
145        Expr::Column(name) => Some(name.as_str()),
146        Expr::QualifiedColumn { column, .. } => Some(column.as_str()),
147        _ => None,
148    }
149}
150
151fn resolve_literal(expr: &Expr) -> Option<Value> {
152    match expr {
153        Expr::Literal(v) => Some(v.clone()),
154        Expr::Parameter(n) => crate::eval::resolve_scoped_param(*n).ok(),
155        Expr::Function { .. } | Expr::Cast { .. } => {
156            let col_map = crate::eval::ColumnMap::new(&[]);
157            let ctx = crate::eval::EvalCtx::new(&col_map, &[]);
158            crate::eval::eval_expr(expr, &ctx).ok()
159        }
160        _ => None,
161    }
162}
163
164fn extract_simple_predicate(expr: &Expr, schema: &TableSchema) -> Option<SimplePredicate> {
165    match expr {
166        Expr::BinaryOp { left, op, right } if is_comparison(*op) => {
167            if let (Some(name), Some(val)) = (resolve_column_name(left), resolve_literal(right)) {
168                let col_idx = schema.column_index(name)?;
169                return Some(SimplePredicate {
170                    col_idx,
171                    op: *op,
172                    value: val,
173                });
174            }
175            if let (Some(val), Some(name)) = (resolve_literal(left), resolve_column_name(right)) {
176                let col_idx = schema.column_index(name)?;
177                return Some(SimplePredicate {
178                    col_idx,
179                    op: flip_op(*op),
180                    value: val,
181                });
182            }
183            None
184        }
185        _ => None,
186    }
187}
188
189/// Decompose BETWEEN into two range predicates for planner use.
190fn flatten_between(expr: &Expr, schema: &TableSchema, out: &mut Vec<SimplePredicate>) {
191    match expr {
192        Expr::Between {
193            expr: col_expr,
194            low,
195            high,
196            negated: false,
197        } => {
198            if let (Some(name), Some(lo), Some(hi)) = (
199                resolve_column_name(col_expr),
200                resolve_literal(low),
201                resolve_literal(high),
202            ) {
203                if let Some(col_idx) = schema.column_index(name) {
204                    out.push(SimplePredicate {
205                        col_idx,
206                        op: BinOp::GtEq,
207                        value: lo,
208                    });
209                    out.push(SimplePredicate {
210                        col_idx,
211                        op: BinOp::LtEq,
212                        value: hi,
213                    });
214                }
215            }
216        }
217        Expr::BinaryOp {
218            left,
219            op: BinOp::And,
220            right,
221        } => {
222            flatten_between(left, schema, out);
223            flatten_between(right, schema, out);
224        }
225        _ => {}
226    }
227}
228
229pub fn plan_select(schema: &TableSchema, where_clause: &Option<Expr>) -> ScanPlan {
230    plan_select_inner(schema, where_clause, false)
231}
232
233pub fn plan_select_inverted(schema: &TableSchema, where_clause: &Option<Expr>) -> ScanPlan {
234    plan_select_inner(schema, where_clause, true)
235}
236
237fn plan_select_inner(
238    schema: &TableSchema,
239    where_clause: &Option<Expr>,
240    allow_inverted: bool,
241) -> ScanPlan {
242    let where_expr = match where_clause {
243        Some(e) => e,
244        None => return ScanPlan::SeqScan,
245    };
246
247    let predicates = flatten_and(where_expr);
248    let simple: Vec<Option<SimplePredicate>> = predicates
249        .iter()
250        .map(|p| extract_simple_predicate(p, schema))
251        .collect();
252
253    if let Some(plan) = try_pk_lookup(schema, &simple) {
254        return plan;
255    }
256
257    let mut range_preds: Vec<SimplePredicate> = simple
258        .iter()
259        .filter_map(|p| {
260            let p = p.as_ref()?;
261            if is_range_op(p.op) {
262                Some(SimplePredicate {
263                    col_idx: p.col_idx,
264                    op: p.op,
265                    value: p.value.clone(),
266                })
267            } else {
268                None
269            }
270        })
271        .collect();
272    flatten_between(where_expr, schema, &mut range_preds);
273
274    if let Some(plan) = try_pk_range_scan(schema, &range_preds) {
275        return plan;
276    }
277
278    if allow_inverted {
279        if let Some(plan) = try_inverted_scan(schema, where_expr) {
280            return plan;
281        }
282    }
283
284    if let Some(plan) = try_best_index(schema, where_expr, &simple) {
285        return plan;
286    }
287
288    ScanPlan::SeqScan
289}
290
291fn try_inverted_scan(schema: &TableSchema, where_expr: &Expr) -> Option<ScanPlan> {
292    use crate::parser::BinOp as B;
293    let (col_idx, rhs_val, op) = match where_expr {
294        Expr::BinaryOp {
295            left,
296            op: B::JsonContains,
297            right,
298        } => {
299            let name = resolve_column_name(left)?;
300            let col_idx = schema.column_index(name)? as u16;
301            let rhs = resolve_literal(right)?;
302            (col_idx, rhs, B::JsonContains)
303        }
304        Expr::BinaryOp {
305            left,
306            op: B::JsonPathMatch,
307            right,
308        } => {
309            let name = resolve_column_name(left)?;
310            let col_idx = schema.column_index(name)? as u16;
311            let rhs = resolve_literal(right)?;
312            (col_idx, rhs, B::JsonPathMatch)
313        }
314        _ => return None,
315    };
316    let idx = schema.indices.iter().find(|i| {
317        matches!(i.kind, IndexKind::Inverted(_))
318            && i.column_positions_iter()
319                .next()
320                .is_some_and(|c| c == col_idx)
321            && i.predicate_expr.is_none()
322    })?;
323    let kind = match idx.kind {
324        IndexKind::Inverted(k) => k,
325        _ => return None,
326    };
327    match (kind, op) {
328        (InvertedKind::Gin(_), B::JsonContains) => {}
329        (InvertedKind::Fts { .. }, B::JsonPathMatch) => {}
330        _ => return None,
331    }
332    let probe_entries = extract_inverted_probe(&rhs_val, kind)?;
333    if probe_entries.is_empty() {
334        return None;
335    }
336    let recheck_needed = inverted_recheck_needed(kind, &rhs_val);
337    let idx_table = TableSchema::index_table_name(&schema.name, &idx.name);
338    Some(ScanPlan::InvertedScan {
339        kind,
340        idx_table,
341        column_idx: col_idx,
342        probe_entries,
343        recheck_expr: where_expr.clone(),
344        recheck_needed,
345    })
346}
347
348fn inverted_recheck_needed(kind: InvertedKind, rhs: &Value) -> bool {
349    match kind {
350        InvertedKind::Gin(_) => true,
351        InvertedKind::Fts { .. } => match rhs {
352            Value::TsQuery(bytes) => match crate::fts::TsQueryAst::decode(bytes) {
353                Ok(ast) => !fts_ast_exact_for_index(&ast),
354                Err(_) => true,
355            },
356            _ => true,
357        },
358        InvertedKind::Ann { .. } => true,
359    }
360}
361
362fn fts_ast_exact_for_index(ast: &crate::fts::TsQueryAst) -> bool {
363    use crate::fts::TsQueryAst;
364    match ast {
365        TsQueryAst::Lexeme {
366            prefix: false,
367            weight_mask: 0,
368            ..
369        } => true,
370        TsQueryAst::Lexeme { .. } => false,
371        TsQueryAst::And(l, r) => fts_ast_exact_for_index(l) && fts_ast_exact_for_index(r),
372        _ => false,
373    }
374}
375
376pub(crate) fn fts_ast_is_pure_phrase(ast: &crate::fts::TsQueryAst) -> bool {
377    use crate::fts::TsQueryAst;
378    match ast {
379        TsQueryAst::Lexeme {
380            prefix: false,
381            weight_mask: 0,
382            ..
383        } => true,
384        TsQueryAst::Phrase { left, right, .. } => {
385            fts_ast_is_pure_phrase(left) && fts_ast_is_pure_phrase(right)
386        }
387        _ => false,
388    }
389}
390
391fn extract_inverted_probe(rhs: &Value, kind: InvertedKind) -> Option<Vec<Vec<u8>>> {
392    use crate::types::GinOpsClass;
393    match kind {
394        InvertedKind::Gin(ops) => {
395            let entries = crate::json::extract_gin_entries(rhs, ops).ok()?;
396            let filtered: Vec<Vec<u8>> = match ops {
397                GinOpsClass::JsonbOps => entries
398                    .into_iter()
399                    .filter(|e| !matches!(e.first(), Some(&0x01)))
400                    .collect(),
401                GinOpsClass::JsonbPathOps => entries,
402            };
403            Some(filtered)
404        }
405        InvertedKind::Fts { .. } => match rhs {
406            Value::TsQuery(bytes) => {
407                let ast = crate::fts::TsQueryAst::decode(bytes).ok()?;
408                let required = fts_required_lexemes(&ast)?;
409                if required.is_empty() {
410                    None
411                } else {
412                    Some(required)
413                }
414            }
415            _ => None,
416        },
417        InvertedKind::Ann { .. } => None,
418    }
419}
420
421fn fts_required_lexemes(ast: &crate::fts::TsQueryAst) -> Option<Vec<Vec<u8>>> {
422    let mut out: std::collections::BTreeSet<Vec<u8>> = std::collections::BTreeSet::new();
423    let ok = collect_required(ast, &mut out);
424    if !ok || out.is_empty() {
425        None
426    } else {
427        Some(out.into_iter().collect())
428    }
429}
430
431fn collect_required(
432    ast: &crate::fts::TsQueryAst,
433    out: &mut std::collections::BTreeSet<Vec<u8>>,
434) -> bool {
435    use crate::fts::TsQueryAst;
436    match ast {
437        TsQueryAst::Lexeme { prefix, .. } if *prefix => false,
438        TsQueryAst::Lexeme { lexeme, .. } => {
439            out.insert(lexeme.clone());
440            true
441        }
442        TsQueryAst::And(l, r) => {
443            let lo = collect_required(l, out);
444            let ro = collect_required(r, out);
445            lo || ro
446        }
447        TsQueryAst::Or(..) => false,
448        TsQueryAst::Not(_) => false,
449        TsQueryAst::Phrase { left, right, .. } => {
450            let lo = collect_required(left, out);
451            let ro = collect_required(right, out);
452            lo && ro
453        }
454    }
455}
456
457fn try_pk_range_scan(schema: &TableSchema, range_preds: &[SimplePredicate]) -> Option<ScanPlan> {
458    if schema.primary_key_columns.len() != 1 {
459        return None; // Only single-column PK for now
460    }
461    let pk_col = schema.primary_key_columns[0] as usize;
462    let conds: Vec<(BinOp, Value)> = range_preds
463        .iter()
464        .filter(|p| p.col_idx == pk_col)
465        .map(|p| (p.op, p.value.clone()))
466        .collect();
467    if conds.is_empty() {
468        return None;
469    }
470    let start_key = conds
471        .iter()
472        .filter(|(op, _)| matches!(op, BinOp::GtEq | BinOp::Gt))
473        .map(|(_, v)| encode_composite_key(std::slice::from_ref(v)))
474        .min_by(|a, b| a.cmp(b))
475        .unwrap_or_default();
476    Some(ScanPlan::PkRangeScan {
477        start_key,
478        range_conds: conds,
479        num_pk_cols: 1,
480    })
481}
482
483fn try_pk_lookup(schema: &TableSchema, predicates: &[Option<SimplePredicate>]) -> Option<ScanPlan> {
484    let pk_cols = &schema.primary_key_columns;
485    // No PK → fall through to SeqScan. An empty-key PkLookup would silently match 0 rows.
486    if pk_cols.is_empty() {
487        return None;
488    }
489    let mut pk_values: Vec<Option<Value>> = vec![None; pk_cols.len()];
490
491    for pred in predicates.iter().flatten() {
492        if pred.op == BinOp::Eq {
493            if let Some(pk_pos) = pk_cols.iter().position(|&c| c == pred.col_idx as u16) {
494                pk_values[pk_pos] = Some(pred.value.clone());
495            }
496        }
497    }
498
499    if pk_values.iter().all(|v| v.is_some()) {
500        let values: Vec<Value> = pk_values.into_iter().map(|v| v.unwrap()).collect();
501        Some(ScanPlan::PkLookup { pk_values: values })
502    } else {
503        None
504    }
505}
506
507#[derive(PartialEq, Eq, PartialOrd, Ord)]
508struct IndexScore {
509    num_equality: usize,
510    has_range: bool,
511    is_unique: bool,
512}
513
514fn try_best_index(
515    schema: &TableSchema,
516    where_expr: &Expr,
517    predicates: &[Option<SimplePredicate>],
518) -> Option<ScanPlan> {
519    let mut best_score: Option<IndexScore> = None;
520    let mut best_plan: Option<ScanPlan> = None;
521
522    let conjuncts = flatten_and(where_expr);
523    for idx in &schema.indices {
524        if !partial_predicate_implied(idx, where_expr, &conjuncts) {
525            continue;
526        }
527        if let Some((score, plan)) = try_index_scan(schema, idx, predicates) {
528            if best_score.is_none() || score > *best_score.as_ref().unwrap() {
529                best_score = Some(score);
530                best_plan = Some(plan);
531            }
532        }
533        if !idx.is_pure_column_index() {
534            if let Some((score, plan)) = try_expr_index_scan(schema, idx, &conjuncts) {
535                if best_score.is_none() || score > *best_score.as_ref().unwrap() {
536                    best_score = Some(score);
537                    best_plan = Some(plan);
538                }
539            }
540        }
541    }
542
543    best_plan
544}
545
546fn try_expr_index_scan(
547    schema: &TableSchema,
548    idx: &IndexDef,
549    conjuncts: &[&Expr],
550) -> Option<(IndexScore, ScanPlan)> {
551    // Only equality on the first expression key supported (`WHERE LOWER(email) = ?`).
552    let first_key = idx.keys.first()?;
553    let key_expr = match first_key {
554        IndexKey::Expr { expr, .. } => expr,
555        IndexKey::Column { .. } => return None,
556    };
557    let canonical_key = canonicalize(key_expr);
558
559    let mut matched: Option<Value> = None;
560    for conj in conjuncts {
561        if let Expr::BinaryOp {
562            left,
563            op: BinOp::Eq,
564            right,
565        } = conj
566        {
567            let (expr_side, value_side) = match (left.as_ref(), right.as_ref()) {
568                (Expr::Literal(v), other) | (other, Expr::Literal(v)) => (other, v.clone()),
569                _ => continue,
570            };
571            if canonicalize(expr_side) == canonical_key {
572                matched = Some(value_side);
573                break;
574            }
575        }
576    }
577
578    let value = matched?;
579    let score = IndexScore {
580        num_equality: 1,
581        has_range: false,
582        is_unique: idx.unique,
583    };
584    let prefix = encode_composite_key(&[value]);
585    let idx_table = TableSchema::index_table_name(&schema.name, &idx.name);
586    Some((
587        score,
588        ScanPlan::IndexScan {
589            index_name: idx.name.clone(),
590            idx_table,
591            prefix,
592            num_prefix_cols: 1,
593            range_conds: vec![],
594            is_unique: idx.unique,
595            index_columns: vec![],
596        },
597    ))
598}
599
600fn partial_predicate_implied(idx: &IndexDef, where_expr: &Expr, conjuncts: &[&Expr]) -> bool {
601    let Some(pred) = idx.predicate_expr.as_ref() else {
602        return true;
603    };
604    if expr_structurally_eq(pred, where_expr) {
605        return true;
606    }
607    if conjuncts.iter().any(|c| expr_structurally_eq(pred, c)) {
608        return true;
609    }
610    if let Expr::IsNotNull(target) = pred {
611        if let Expr::Column(col) = target.as_ref() {
612            return conjuncts.iter().any(|c| conjunct_proves_not_null(c, col));
613        }
614    }
615    false
616}
617
618fn expr_structurally_eq(a: &Expr, b: &Expr) -> bool {
619    format!("{a:?}") == format!("{b:?}")
620}
621
622fn conjunct_proves_not_null(expr: &Expr, col: &str) -> bool {
623    let mentions = |e: &Expr| matches!(e, Expr::Column(n) if n.eq_ignore_ascii_case(col));
624    match expr {
625        Expr::BinaryOp {
626            left,
627            op: BinOp::Eq | BinOp::NotEq | BinOp::Lt | BinOp::LtEq | BinOp::Gt | BinOp::GtEq,
628            right,
629        } => mentions(left) || mentions(right),
630        Expr::IsNotNull(inner) => mentions(inner),
631        _ => false,
632    }
633}
634
635fn try_index_scan(
636    schema: &TableSchema,
637    idx: &IndexDef,
638    predicates: &[Option<SimplePredicate>],
639) -> Option<(IndexScore, ScanPlan)> {
640    let mut used = Vec::new();
641    let mut equality_values: Vec<Value> = Vec::new();
642    let mut range_conds: Vec<(BinOp, Value)> = Vec::new();
643
644    // Expression-key indexes go through `try_expr_index_scan`, not the column path.
645    if !idx.is_pure_column_index() {
646        return None;
647    }
648    let idx_columns = idx.columns_vec();
649    for &col_idx in &idx_columns {
650        let mut found_eq = false;
651        for (i, pred) in predicates.iter().enumerate() {
652            if used.contains(&i) {
653                continue;
654            }
655            if let Some(sp) = pred {
656                if sp.col_idx == col_idx as usize && sp.op == BinOp::Eq {
657                    equality_values.push(sp.value.clone());
658                    used.push(i);
659                    found_eq = true;
660                    break;
661                }
662            }
663        }
664        if !found_eq {
665            for (i, pred) in predicates.iter().enumerate() {
666                if used.contains(&i) {
667                    continue;
668                }
669                if let Some(sp) = pred {
670                    if sp.col_idx == col_idx as usize && is_range_op(sp.op) {
671                        range_conds.push((sp.op, sp.value.clone()));
672                        used.push(i);
673                    }
674                }
675            }
676            break;
677        }
678    }
679
680    if equality_values.is_empty() && range_conds.is_empty() {
681        return None;
682    }
683
684    let score = IndexScore {
685        num_equality: equality_values.len(),
686        has_range: !range_conds.is_empty(),
687        is_unique: idx.unique,
688    };
689
690    let prefix = encode_composite_key(&equality_values);
691    let idx_table = TableSchema::index_table_name(&schema.name, &idx.name);
692
693    Some((
694        score,
695        ScanPlan::IndexScan {
696            index_name: idx.name.clone(),
697            idx_table,
698            prefix,
699            num_prefix_cols: equality_values.len(),
700            range_conds,
701            is_unique: idx.unique,
702            index_columns: idx_columns.clone(),
703        },
704    ))
705}
706
707pub fn describe_plan(plan: &ScanPlan, table_schema: &TableSchema) -> String {
708    match plan {
709        ScanPlan::SeqScan => String::new(),
710
711        ScanPlan::PkLookup { pk_values } => {
712            let pk_cols: Vec<&str> = table_schema
713                .primary_key_columns
714                .iter()
715                .map(|&idx| table_schema.columns[idx as usize].name.as_str())
716                .collect();
717            let conditions: Vec<String> = pk_cols
718                .iter()
719                .zip(pk_values.iter())
720                .map(|(col, val)| format!("{col} = {}", format_value(val)))
721                .collect();
722            format!("USING PRIMARY KEY ({})", conditions.join(", "))
723        }
724
725        ScanPlan::PkRangeScan { range_conds, .. } => {
726            let pk_col = &table_schema.columns[table_schema.primary_key_columns[0] as usize].name;
727            let conditions: Vec<String> = range_conds
728                .iter()
729                .map(|(op, val)| format!("{pk_col} {} {}", op_symbol(*op), format_value(val)))
730                .collect();
731            format!("USING PRIMARY KEY RANGE ({})", conditions.join(", "))
732        }
733
734        ScanPlan::IndexScan {
735            index_name,
736            num_prefix_cols,
737            range_conds,
738            index_columns,
739            ..
740        } => {
741            let mut conditions = Vec::new();
742            for &col in index_columns.iter().take(*num_prefix_cols) {
743                let col_idx = col as usize;
744                let col_name = &table_schema.columns[col_idx].name;
745                conditions.push(format!("{col_name} = ?"));
746            }
747            if !range_conds.is_empty() && *num_prefix_cols < index_columns.len() {
748                let col_idx = index_columns[*num_prefix_cols] as usize;
749                let col_name = &table_schema.columns[col_idx].name;
750                for (op, _) in range_conds {
751                    conditions.push(format!("{col_name} {} ?", op_symbol(*op)));
752                }
753            }
754            if conditions.is_empty() {
755                format!("USING INDEX {index_name}")
756            } else {
757                format!("USING INDEX {index_name} ({})", conditions.join(", "))
758            }
759        }
760
761        ScanPlan::InvertedScan { .. } => "USING INVERTED INDEX".to_string(),
762    }
763}
764
765fn format_value(val: &Value) -> String {
766    match val {
767        Value::Null => "NULL".into(),
768        Value::Integer(i) => i.to_string(),
769        Value::Real(f) => format!("{f}"),
770        Value::Text(s) => format!("'{s}'"),
771        Value::Blob(_) => "BLOB".into(),
772        Value::Boolean(b) => b.to_string(),
773        Value::Date(d) => format!("DATE '{}'", crate::datetime::format_date(*d)),
774        Value::Time(t) => format!("TIME '{}'", crate::datetime::format_time(*t)),
775        Value::Timestamp(t) => format!("TIMESTAMP '{}'", crate::datetime::format_timestamp(*t)),
776        Value::Interval {
777            months,
778            days,
779            micros,
780        } => format!(
781            "INTERVAL '{}'",
782            crate::datetime::format_interval(*months, *days, *micros)
783        ),
784        Value::Json(s) => format!("JSON '{s}'"),
785        Value::Jsonb(_) => "JSONB '<binary>'".into(),
786        Value::TsVector(_) => "TSVECTOR '<binary>'".into(),
787        Value::TsQuery(_) => "TSQUERY '<binary>'".into(),
788        Value::Array(_) => val.to_string(),
789        Value::Vector(v) => format!("VECTOR({})", v.len()),
790    }
791}
792
793fn op_symbol(op: BinOp) -> &'static str {
794    match op {
795        BinOp::Lt => "<",
796        BinOp::LtEq => "<=",
797        BinOp::Gt => ">",
798        BinOp::GtEq => ">=",
799        BinOp::Eq => "=",
800        BinOp::NotEq => "!=",
801        _ => "?",
802    }
803}
804
805#[cfg(test)]
806#[path = "planner_tests.rs"]
807mod tests;