Skip to main content

reddb_rql/planner/
optimizer.rs

1//! Query Optimizer
2//!
3//! Multi-pass query optimization with pluggable strategies.
4//!
5//! # Optimization Passes
6//!
7//! 1. **PredicatePushdown**: Move filters to data sources
8//! 2. **JoinReordering**: Optimal join order via IDP algorithm
9//! 3. **IndexSelection**: Choose best indexes for scans
10//! 4. **ProjectionPushdown**: Eliminate unused columns early
11//! 5. **ExpressionSimplification**: Simplify complex expressions
12
13use crate::ast::{JoinQuery, JoinType, QueryExpr};
14use crate::sql_lowering::{effective_table_filter, effective_vector_filter};
15
16/// An optimization pass that transforms query expressions
17pub trait OptimizationPass: Send + Sync {
18    /// Pass name for debugging
19    fn name(&self) -> &str;
20
21    /// Apply the optimization pass
22    fn apply(&self, query: QueryExpr) -> QueryExpr;
23
24    /// Estimated benefit (higher = more important)
25    fn benefit(&self) -> u32;
26}
27
28/// Query optimizer with multiple passes
29pub struct QueryOptimizer {
30    /// Ordered optimization passes
31    passes: Vec<Box<dyn OptimizationPass>>,
32    /// Enable cost-based optimization
33    cost_based: bool,
34}
35
36impl QueryOptimizer {
37    /// Create a new optimizer with default passes
38    pub fn new() -> Self {
39        let passes: Vec<Box<dyn OptimizationPass>> = vec![
40            Box::new(PredicatePushdownPass),
41            Box::new(ProjectionPushdownPass),
42            Box::new(JoinReorderingPass),
43            Box::new(IndexSelectionPass),
44            Box::new(LimitPushdownPass),
45        ];
46
47        Self {
48            passes,
49            cost_based: true,
50        }
51    }
52
53    /// Add a custom optimization pass
54    pub fn add_pass(&mut self, pass: Box<dyn OptimizationPass>) {
55        self.passes.push(pass);
56        // Sort by benefit (highest first)
57        self.passes.sort_by_key(|b| std::cmp::Reverse(b.benefit()));
58    }
59
60    /// Optimize a query expression
61    pub fn optimize(&self, query: QueryExpr) -> (QueryExpr, Vec<String>) {
62        let mut optimized = query;
63        let mut applied_passes = Vec::new();
64
65        for pass in &self.passes {
66            let before = format!("{:?}", optimized);
67            optimized = pass.apply(optimized);
68            let after = format!("{:?}", optimized);
69
70            if before != after {
71                applied_passes.push(pass.name().to_string());
72            }
73        }
74
75        (optimized, applied_passes)
76    }
77
78    /// Optimize with hints
79    pub fn optimize_with_hints(&self, query: QueryExpr, hints: &OptimizationHints) -> QueryExpr {
80        let mut optimized = query;
81
82        for pass in &self.passes {
83            // Check if pass is disabled by hints
84            if hints.disabled_passes.contains(&pass.name().to_string()) {
85                continue;
86            }
87
88            optimized = pass.apply(optimized);
89        }
90
91        optimized
92    }
93}
94
95impl Default for QueryOptimizer {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101/// Hints to control optimization
102#[derive(Debug, Clone, Default)]
103pub struct OptimizationHints {
104    /// Disabled optimization passes
105    pub disabled_passes: Vec<String>,
106    /// Force specific join order
107    pub join_order: Option<Vec<String>>,
108    /// Force specific index usage
109    pub force_index: Option<String>,
110    /// Disable parallel execution
111    pub no_parallel: bool,
112}
113
114// =============================================================================
115// Built-in Optimization Passes
116// =============================================================================
117
118/// Push predicates down to data sources
119struct PredicatePushdownPass;
120
121impl OptimizationPass for PredicatePushdownPass {
122    fn name(&self) -> &str {
123        "PredicatePushdown"
124    }
125
126    fn apply(&self, query: QueryExpr) -> QueryExpr {
127        match query {
128            QueryExpr::Join(jq) => self.optimize_join(jq),
129            other => other,
130        }
131    }
132
133    fn benefit(&self) -> u32 {
134        100 // High priority - reduces data early
135    }
136}
137
138impl PredicatePushdownPass {
139    fn optimize_join(&self, query: JoinQuery) -> QueryExpr {
140        // Analyze join condition to find pushable predicates
141        // This is a simplified version - real implementation would analyze
142        // predicate dependencies on join columns
143
144        let left = self.apply(*query.left);
145        let right = self.apply(*query.right);
146
147        QueryExpr::Join(JoinQuery {
148            left: Box::new(left),
149            right: Box::new(right),
150            ..query
151        })
152    }
153}
154
155/// Push projections down to eliminate columns early
156struct ProjectionPushdownPass;
157
158impl OptimizationPass for ProjectionPushdownPass {
159    fn name(&self) -> &str {
160        "ProjectionPushdown"
161    }
162
163    fn apply(&self, query: QueryExpr) -> QueryExpr {
164        match query {
165            QueryExpr::Join(jq) => {
166                // Analyze which columns are actually needed
167                let left = self.apply(*jq.left);
168                let right = self.apply(*jq.right);
169
170                QueryExpr::Join(JoinQuery {
171                    left: Box::new(left),
172                    right: Box::new(right),
173                    ..jq
174                })
175            }
176            QueryExpr::Table(tq) => {
177                // Table projections already use specific column projections
178                // No transformation needed - already efficient
179                QueryExpr::Table(tq)
180            }
181            other => other,
182        }
183    }
184
185    fn benefit(&self) -> u32 {
186        80 // High priority - reduces memory
187    }
188}
189
190/// Reorder joins for optimal execution
191struct JoinReorderingPass;
192
193impl OptimizationPass for JoinReorderingPass {
194    fn name(&self) -> &str {
195        "JoinReordering"
196    }
197
198    fn apply(&self, query: QueryExpr) -> QueryExpr {
199        match query {
200            QueryExpr::Join(jq) => {
201                // For now, just ensure smaller table is on build side
202                // Real IDP algorithm would enumerate join orderings
203                self.optimize_join_order(jq)
204            }
205            other => other,
206        }
207    }
208
209    fn benefit(&self) -> u32 {
210        90 // High priority - join order greatly affects cost
211    }
212}
213
214impl JoinReorderingPass {
215    fn optimize_join_order(&self, query: JoinQuery) -> QueryExpr {
216        // Estimate cardinalities
217        let left_size = Self::estimate_size(&query.left);
218        let right_size = Self::estimate_size(&query.right);
219
220        // For hash join, smaller table should be build side (left)
221        if left_size > right_size && query.join_type == JoinType::Inner {
222            // Swap left and right
223            let JoinQuery {
224                left,
225                right,
226                join_type,
227                on,
228                filter,
229                order_by,
230                limit,
231                offset,
232                return_items,
233                return_,
234            } = query;
235            QueryExpr::Join(JoinQuery {
236                left: right,
237                right: left,
238                join_type,
239                on: swap_condition(on),
240                filter,
241                order_by,
242                limit,
243                offset,
244                return_items,
245                return_,
246            })
247        } else {
248            QueryExpr::Join(query)
249        }
250    }
251
252    fn estimate_size(query: &QueryExpr) -> f64 {
253        match query {
254            QueryExpr::Table(tq) => {
255                let base = 1000.0;
256                if effective_table_filter(tq).is_some() {
257                    base * 0.1
258                } else if tq.limit.is_some() {
259                    tq.limit.unwrap() as f64
260                } else {
261                    base
262                }
263            }
264            QueryExpr::Graph(_) => 100.0,
265            QueryExpr::Join(jq) => {
266                Self::estimate_size(&jq.left) * Self::estimate_size(&jq.right) * 0.1
267            }
268            QueryExpr::Path(_) => 10.0,
269            QueryExpr::Vector(vq) => {
270                // Vector search returns k results
271                if effective_vector_filter(vq).is_some() {
272                    (vq.k as f64).min(100.0)
273                } else {
274                    vq.k as f64
275                }
276            }
277            QueryExpr::Hybrid(hq) => {
278                // Hybrid query combines structured and vector results
279                let structured_size = Self::estimate_size(&hq.structured);
280                let vector_size = hq.vector.k as f64;
281                // Fusion typically reduces to min of both, limited by limit
282                let base = structured_size.min(vector_size);
283                hq.limit.map(|l| base.min(l as f64)).unwrap_or(base)
284            }
285            QueryExpr::Explain(explain) => Self::estimate_size(&explain.inner),
286            // DML/DDL/Command statements return minimal result sets
287            QueryExpr::Insert(_)
288            | QueryExpr::Update(_)
289            | QueryExpr::Delete(_)
290            | QueryExpr::CreateTable(_)
291            | QueryExpr::CreateCollection(_)
292            | QueryExpr::CreateVector(_)
293            | QueryExpr::DropTable(_)
294            | QueryExpr::DropGraph(_)
295            | QueryExpr::DropVector(_)
296            | QueryExpr::DropDocument(_)
297            | QueryExpr::DropKv(_)
298            | QueryExpr::DropCollection(_)
299            | QueryExpr::Truncate(_)
300            | QueryExpr::AlterTable(_)
301            | QueryExpr::CreateVcsRef(_)
302            | QueryExpr::DropVcsRef(_)
303            | QueryExpr::ForkStore(_)
304            | QueryExpr::PromoteFork(_)
305            | QueryExpr::DropFork(_)
306            | QueryExpr::GraphCommand(_)
307            | QueryExpr::SearchCommand(_)
308            | QueryExpr::CreateIndex(_)
309            | QueryExpr::DropIndex(_)
310            | QueryExpr::ProbabilisticCommand(_)
311            | QueryExpr::Ask(_)
312            | QueryExpr::SetConfig { .. }
313            | QueryExpr::ShowConfig { .. }
314            | QueryExpr::Scrub { .. }
315            | QueryExpr::SetSecret { .. }
316            | QueryExpr::DeleteSecret { .. }
317            | QueryExpr::ShowSecrets { .. }
318            | QueryExpr::SetKv { .. }
319            | QueryExpr::DeleteKv { .. }
320            | QueryExpr::SetTenant(_)
321            | QueryExpr::ShowTenant
322            | QueryExpr::CreateTimeSeries(_)
323            | QueryExpr::CreateMetric(_)
324            | QueryExpr::AlterMetric(_)
325            | QueryExpr::CreateSlo(_)
326            | QueryExpr::DropTimeSeries(_)
327            | QueryExpr::CreateQueue(_)
328            | QueryExpr::AlterQueue(_)
329            | QueryExpr::DropQueue(_)
330            | QueryExpr::QueueSelect(_)
331            | QueryExpr::QueueCommand(_)
332            | QueryExpr::KvCommand(_)
333            | QueryExpr::ConfigCommand(_)
334            | QueryExpr::CreateTree(_)
335            | QueryExpr::DropTree(_)
336            | QueryExpr::TreeCommand(_)
337            | QueryExpr::ExplainAlter(_)
338            | QueryExpr::TransactionControl(_)
339            | QueryExpr::MaintenanceCommand(_)
340            | QueryExpr::VcsCommand(_)
341            | QueryExpr::CreateSchema(_)
342            | QueryExpr::DropSchema(_)
343            | QueryExpr::CreateSequence(_)
344            | QueryExpr::DropSequence(_)
345            | QueryExpr::CopyFrom(_)
346            | QueryExpr::CreateView(_)
347            | QueryExpr::DropView(_)
348            | QueryExpr::RefreshMaterializedView(_)
349            | QueryExpr::CreatePolicy(_)
350            | QueryExpr::DropPolicy(_)
351            | QueryExpr::CreateServer(_)
352            | QueryExpr::DropServer(_)
353            | QueryExpr::CreateForeignTable(_)
354            | QueryExpr::DropForeignTable(_)
355            | QueryExpr::Grant(_)
356            | QueryExpr::Revoke(_)
357            | QueryExpr::AlterUser(_)
358            | QueryExpr::CreateUser(_)
359            | QueryExpr::CreateIamPolicy { .. }
360            | QueryExpr::DropIamPolicy { .. }
361            | QueryExpr::AttachPolicy { .. }
362            | QueryExpr::DetachPolicy { .. }
363            | QueryExpr::ShowPolicies { .. }
364            | QueryExpr::ShowEffectivePermissions { .. }
365            | QueryExpr::RankOf(_)
366            | QueryExpr::ApproxRankOf(_)
367            | QueryExpr::RankRange(_)
368            | QueryExpr::SimulatePolicy { .. }
369            | QueryExpr::LintPolicy { .. }
370            | QueryExpr::MigratePolicyMode { .. }
371            | QueryExpr::CreateMigration(_)
372            | QueryExpr::ApplyMigration(_)
373            | QueryExpr::RollbackMigration(_)
374            | QueryExpr::ExplainMigration(_)
375            | QueryExpr::EventsBackfill(_)
376            | QueryExpr::EventsBackfillStatus { .. } => 1.0,
377        }
378    }
379}
380
381/// Select optimal indexes for table scans.
382///
383/// Analyzes filter predicates and annotates the query plan with index hints:
384/// - Equality predicates (`col = value`) → prefer Hash index if available
385/// - Low-cardinality equality → prefer Bitmap index
386/// - Range predicates (`col > value`, `BETWEEN`) → prefer B-tree
387/// - Spatial predicates → prefer R-tree
388///
389/// The hints are stored in the TableQuery's alias field as a prefix
390/// (e.g., `__idx:hash:col_name`) which the executor can read to skip
391/// full scans. This is a lightweight approach that avoids adding new
392/// fields to the AST while enabling index-aware execution.
393struct IndexSelectionPass;
394
395impl OptimizationPass for IndexSelectionPass {
396    fn name(&self) -> &str {
397        "IndexSelection"
398    }
399
400    fn apply(&self, query: QueryExpr) -> QueryExpr {
401        match query {
402            QueryExpr::Table(mut tq) => {
403                if let Some(filter) = effective_table_filter(&tq).as_ref() {
404                    if let Some(hint) = Self::analyze_filter(filter) {
405                        // Store index hint in expand metadata for executor
406                        let expand = tq.expand.get_or_insert_with(Default::default);
407                        expand.index_hint = Some(hint);
408                    }
409                }
410                QueryExpr::Table(tq)
411            }
412            other => other,
413        }
414    }
415
416    fn benefit(&self) -> u32 {
417        70
418    }
419}
420
421impl IndexSelectionPass {
422    /// Analyze a filter predicate and return the best index hint
423    fn analyze_filter(filter: &crate::ast::Filter) -> Option<IndexHint> {
424        match filter {
425            // Equality on a single column → Hash index candidate
426            crate::ast::Filter::Compare { field, op, .. } if *op == crate::ast::CompareOp::Eq => {
427                let col = Self::field_name(field);
428                Some(IndexHint {
429                    method: IndexHintMethod::Hash,
430                    column: col,
431                })
432            }
433            // Range predicates → B-tree candidate
434            crate::ast::Filter::Compare {
435                field,
436                op:
437                    crate::ast::CompareOp::Lt
438                    | crate::ast::CompareOp::Le
439                    | crate::ast::CompareOp::Gt
440                    | crate::ast::CompareOp::Ge,
441                ..
442            } => {
443                let col = Self::field_name(field);
444                Some(IndexHint {
445                    method: IndexHintMethod::BTree,
446                    column: col,
447                })
448            }
449            // BETWEEN → B-tree candidate
450            crate::ast::Filter::Between { field, .. } => {
451                let col = Self::field_name(field);
452                Some(IndexHint {
453                    method: IndexHintMethod::BTree,
454                    column: col,
455                })
456            }
457            // IN with few values → Bitmap candidate
458            crate::ast::Filter::In { field, values } if values.len() <= 10 => {
459                let col = Self::field_name(field);
460                Some(IndexHint {
461                    method: IndexHintMethod::Bitmap,
462                    column: col,
463                })
464            }
465            // AND: pick the most selective hint from left or right
466            crate::ast::Filter::And(left, right) => {
467                Self::analyze_filter(left).or_else(|| Self::analyze_filter(right))
468            }
469            _ => None,
470        }
471    }
472
473    fn field_name(field: &crate::ast::FieldRef) -> String {
474        match field {
475            crate::ast::FieldRef::TableColumn { column, .. } => column.clone(),
476            crate::ast::FieldRef::NodeProperty { property, .. } => property.clone(),
477            crate::ast::FieldRef::EdgeProperty { property, .. } => property.clone(),
478            crate::ast::FieldRef::NodeId { alias } => {
479                format!("{}.id", alias)
480            }
481        }
482    }
483}
484
485// `IndexHint`/`IndexHintMethod` re-homed to the neutral keystone crate (ADR
486// 0053, RQL Phase 2 S4b) so the canonical SQL AST (`ExpandOptions.index_hint`)
487// resolves them without a `reddb-server` edge. This shim keeps the optimizer's
488// existing references valid; the passes that build hints stay here.
489pub use reddb_types::index_hint::{IndexHint, IndexHintMethod};
490
491/// Push LIMIT down through operations
492struct LimitPushdownPass;
493
494impl OptimizationPass for LimitPushdownPass {
495    fn name(&self) -> &str {
496        "LimitPushdown"
497    }
498
499    fn apply(&self, query: QueryExpr) -> QueryExpr {
500        match query {
501            QueryExpr::Join(jq) => {
502                // Can push limit through certain joins
503                let left = self.apply(*jq.left);
504                let right = self.apply(*jq.right);
505
506                QueryExpr::Join(JoinQuery {
507                    left: Box::new(left),
508                    right: Box::new(right),
509                    ..jq
510                })
511            }
512            other => other,
513        }
514    }
515
516    fn benefit(&self) -> u32 {
517        60
518    }
519}
520
521// =============================================================================
522// Helper Functions
523// =============================================================================
524
525fn swap_condition(condition: crate::ast::JoinCondition) -> crate::ast::JoinCondition {
526    crate::ast::JoinCondition {
527        left_field: condition.right_field,
528        right_field: condition.left_field,
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::ast::{
536        CompareOp, DistanceMetric, FieldRef, Filter, FusionStrategy, JoinCondition, Projection,
537        TableQuery,
538    };
539    use reddb_types::Value;
540
541    fn make_table_query(name: &str) -> QueryExpr {
542        QueryExpr::Table(TableQuery {
543            table: name.to_string(),
544            source: None,
545            alias: Some(name.to_string()),
546            select_items: Vec::new(),
547            columns: vec![Projection::All],
548            where_expr: None,
549            filter: None,
550            group_by_exprs: Vec::new(),
551            group_by: Vec::new(),
552            having_expr: None,
553            having: None,
554            order_by: vec![],
555            limit: None,
556            limit_param: None,
557            offset: None,
558            offset_param: None,
559            expand: None,
560            as_of: None,
561            sessionize: None,
562            distinct: false,
563        })
564    }
565
566    #[test]
567    fn test_optimizer_applies_passes() {
568        let optimizer = QueryOptimizer::new();
569        let query = make_table_query("hosts");
570
571        let (optimized, passes) = optimizer.optimize(query);
572        // Should at least attempt the passes
573        assert!(matches!(optimized, QueryExpr::Table(_)));
574    }
575
576    #[test]
577    fn test_join_reordering() {
578        let optimizer = QueryOptimizer::new();
579
580        let small = QueryExpr::Table(TableQuery {
581            table: "small".to_string(),
582            source: None,
583            alias: None,
584            select_items: Vec::new(),
585            columns: vec![Projection::All],
586            where_expr: None,
587            filter: None,
588            group_by_exprs: Vec::new(),
589            group_by: Vec::new(),
590            having_expr: None,
591            having: None,
592            order_by: vec![],
593            limit: Some(10), // Small table
594            limit_param: None,
595            offset: None,
596            offset_param: None,
597            expand: None,
598            as_of: None,
599            sessionize: None,
600            distinct: false,
601        });
602
603        let large = QueryExpr::Table(TableQuery {
604            table: "large".to_string(),
605            source: None,
606            alias: None,
607            select_items: Vec::new(),
608            columns: vec![Projection::All],
609            where_expr: None,
610            filter: None,
611            group_by_exprs: Vec::new(),
612            group_by: Vec::new(),
613            having_expr: None,
614            having: None,
615            order_by: vec![],
616            limit: None, // Large table
617            limit_param: None,
618            offset: None,
619            offset_param: None,
620            expand: None,
621            as_of: None,
622            sessionize: None,
623            distinct: false,
624        });
625
626        let join = QueryExpr::Join(JoinQuery {
627            left: Box::new(large.clone()),
628            right: Box::new(small.clone()),
629            join_type: JoinType::Inner,
630            on: JoinCondition {
631                left_field: FieldRef::TableColumn {
632                    table: "large".to_string(),
633                    column: "id".to_string(),
634                },
635                right_field: FieldRef::TableColumn {
636                    table: "small".to_string(),
637                    column: "id".to_string(),
638                },
639            },
640            filter: None,
641            order_by: Vec::new(),
642            limit: None,
643            offset: None,
644            return_items: Vec::new(),
645            return_: Vec::new(),
646        });
647
648        let (optimized, passes) = optimizer.optimize(join);
649
650        // Should have applied JoinReordering
651        assert!(passes.iter().any(|pass| pass == "JoinReordering"));
652        if let QueryExpr::Join(jq) = optimized {
653            // Small table should now be on left (build side)
654            if let QueryExpr::Table(left) = jq.left.as_ref() {
655                assert_eq!(left.table, "small");
656            }
657            assert!(matches!(
658                &jq.on.left_field,
659                FieldRef::TableColumn { table, column } if table == "small" && column == "id"
660            ));
661        }
662    }
663
664    #[test]
665    fn optimize_with_hints_can_disable_join_reordering() {
666        let optimizer = QueryOptimizer::new();
667
668        let large = make_table_query("large");
669        let mut small_table = TableQuery::new("small");
670        small_table.limit = Some(1);
671        let small = QueryExpr::Table(small_table);
672
673        let join = QueryExpr::Join(JoinQuery {
674            left: Box::new(large),
675            right: Box::new(small),
676            join_type: JoinType::Inner,
677            on: JoinCondition {
678                left_field: FieldRef::TableColumn {
679                    table: "large".to_string(),
680                    column: "id".to_string(),
681                },
682                right_field: FieldRef::TableColumn {
683                    table: "small".to_string(),
684                    column: "id".to_string(),
685                },
686            },
687            filter: None,
688            order_by: Vec::new(),
689            limit: None,
690            offset: None,
691            return_items: Vec::new(),
692            return_: Vec::new(),
693        });
694        let hints = OptimizationHints {
695            disabled_passes: vec!["JoinReordering".to_string()],
696            ..OptimizationHints::default()
697        };
698
699        let optimized = optimizer.optimize_with_hints(join, &hints);
700        let QueryExpr::Join(join) = optimized else {
701            panic!("expected join query");
702        };
703        let QueryExpr::Table(left) = join.left.as_ref() else {
704            panic!("expected table on left side");
705        };
706
707        assert_eq!(left.table, "large");
708        assert!(matches!(
709            &join.on.left_field,
710            FieldRef::TableColumn { table, column } if table == "large" && column == "id"
711        ));
712    }
713
714    #[test]
715    fn optimizer_sets_index_hint_on_table_filters() {
716        let optimizer = QueryOptimizer::new();
717        let mut table = TableQuery::new("hosts");
718        table.filter = Some(Filter::Compare {
719            field: FieldRef::column("", "host_id"),
720            op: CompareOp::Eq,
721            value: Value::Integer(7),
722        });
723
724        let (optimized, passes) = optimizer.optimize(QueryExpr::Table(table));
725        let QueryExpr::Table(table) = optimized else {
726            panic!("expected table query");
727        };
728        let hint = table
729            .expand
730            .and_then(|expand| expand.index_hint)
731            .expect("expected optimizer index hint");
732
733        assert!(passes.iter().any(|pass| pass == "IndexSelection"));
734        assert_eq!(hint.method, IndexHintMethod::Hash);
735        assert_eq!(hint.column, "host_id");
736    }
737
738    #[test]
739    fn index_selection_analyzes_supported_filter_shapes() {
740        let range = IndexSelectionPass::analyze_filter(&Filter::Between {
741            field: FieldRef::node_prop("n", "score"),
742            low: Value::Integer(1),
743            high: Value::Integer(9),
744        })
745        .expect("expected range hint");
746        assert_eq!(range.method, IndexHintMethod::BTree);
747        assert_eq!(range.column, "score");
748
749        let bitmap = IndexSelectionPass::analyze_filter(&Filter::In {
750            field: FieldRef::edge_prop("e", "kind"),
751            values: vec![Value::text("http"), Value::text("ssh")],
752        })
753        .expect("expected bitmap hint");
754        assert_eq!(bitmap.method, IndexHintMethod::Bitmap);
755        assert_eq!(bitmap.column, "kind");
756
757        assert!(IndexSelectionPass::analyze_filter(&Filter::In {
758            field: FieldRef::column("", "status"),
759            values: (0..11).map(Value::Integer).collect(),
760        })
761        .is_none());
762
763        let fallback_right = IndexSelectionPass::analyze_filter(&Filter::And(
764            Box::new(Filter::IsNull(FieldRef::column("", "deleted_at"))),
765            Box::new(Filter::Compare {
766                field: FieldRef::node_id("n"),
767                op: CompareOp::Eq,
768                value: Value::Integer(1),
769            }),
770        ))
771        .expect("expected right-side AND hint");
772        assert_eq!(fallback_right.method, IndexHintMethod::Hash);
773        assert_eq!(fallback_right.column, "n.id");
774    }
775}