Skip to main content

cqlite_core/query/
select_optimizer.rs

1//! Query optimizer for SELECT statements - basic planning and predicate pushdown.
2
3use super::select_ast::*;
4use super::select_naming::{
5    aggregate_arg_source_columns, aggregate_column_and_alias, aggregate_output_name,
6    projection_source_and_output, unwrap_aggregate,
7};
8use crate::{schema::SchemaManager, storage::StorageEngine, Error, Result, TableId, Value};
9use std::collections::HashMap;
10use std::sync::Arc;
11
12// Test-only counter for the number of times `SelectOptimizer::optimize` runs
13// (issue #1587, E5). A prepared statement must reuse its optimized plan across
14// repeated executes with identical parameters, so this stays `<= 1` over many
15// executes. Same thread-local rationale as the executor's other work counters
16// (`#[tokio::test]` current-thread runtime keeps the future on this thread).
17#[cfg(test)]
18thread_local! {
19    pub(crate) static OPTIMIZE_INVOCATIONS: std::cell::Cell<usize> =
20        const { std::cell::Cell::new(0) };
21}
22
23/// Query optimizer for SELECT statements
24#[derive(Debug)]
25pub struct SelectOptimizer {
26    #[allow(dead_code)]
27    schema: Arc<SchemaManager>,
28    #[allow(dead_code)]
29    storage: Arc<StorageEngine>,
30}
31
32/// Optimized query execution plan
33#[derive(Debug, Clone)]
34pub struct OptimizedQueryPlan {
35    pub statement: SelectStatement,
36    pub execution_steps: Vec<ExecutionStep>,
37    pub sstable_predicates: Vec<SSTablePredicate>,
38    pub aggregation_plan: Option<AggregationPlan>,
39}
40
41/// Individual execution step
42#[derive(Debug, Clone)]
43pub enum ExecutionStep {
44    SSTableScan {
45        table: TableId,
46        predicates: Vec<SSTablePredicate>,
47        projection: Vec<String>,
48    },
49    Filter {
50        expression: WhereExpression,
51    },
52    Sort {
53        order_by: OrderByClause,
54    },
55    Aggregate {
56        plan: AggregationPlan,
57    },
58    Limit {
59        count: u64,
60        offset: Option<u64>,
61    },
62    /// Cap rows emitted per partition key, applied upstream of `Limit`
63    /// (Cassandra `PER PARTITION LIMIT`, Issue #757).
64    PerPartitionLimit {
65        count: u64,
66    },
67    Project {
68        columns: Vec<SelectExpression>,
69    },
70}
71
72impl ExecutionStep {
73    /// Static variant label for diagnostics. Data-safety (issue #1694): this
74    /// intentionally returns only the variant NAME (a shape), never the step's
75    /// contents (predicate values, filter expressions, literals), so it is safe
76    /// to log at any level.
77    pub fn variant_name(&self) -> &'static str {
78        match self {
79            ExecutionStep::SSTableScan { .. } => "SSTableScan",
80            ExecutionStep::Filter { .. } => "Filter",
81            ExecutionStep::Sort { .. } => "Sort",
82            ExecutionStep::Aggregate { .. } => "Aggregate",
83            ExecutionStep::Limit { .. } => "Limit",
84            ExecutionStep::PerPartitionLimit { .. } => "PerPartitionLimit",
85            ExecutionStep::Project { .. } => "Project",
86        }
87    }
88}
89
90/// SSTable-level predicate that can be pushed down
91#[derive(Debug, Clone)]
92pub struct SSTablePredicate {
93    pub column: String,
94    pub operation: SSTableFilterOp,
95    pub values: Vec<Value>,
96    /// When `Some`, this predicate constrains the Murmur3 *token* of the
97    /// partition key formed by these columns (e.g. `WHERE token(pk) >= ?`),
98    /// rather than a stored column value (Issue #955, Epic #951). The bound(s)
99    /// in [`Self::values`] are `Value::BigInt` token values, and evaluation
100    /// hashes the row's raw partition key with `cassandra_murmur3_token` and
101    /// compares it to the bound — Cassandra's `Murmur3Partitioner` semantics.
102    ///
103    /// `None` for ordinary column predicates. Carries the partition-key column
104    /// names (in declared order) so a multi-column `token(a, b)` is supported,
105    /// keeping the representation typed rather than encoding intent in
106    /// [`Self::column`].
107    pub token_columns: Option<Vec<String>>,
108}
109
110impl SSTablePredicate {
111    /// Construct an ordinary column predicate (`token_columns: None`).
112    pub fn column(
113        column: impl Into<String>,
114        operation: SSTableFilterOp,
115        values: Vec<Value>,
116    ) -> Self {
117        Self {
118            column: column.into(),
119            operation,
120            values,
121            token_columns: None,
122        }
123    }
124
125    /// Construct a token predicate over `token_columns` (the partition-key
126    /// columns, in declared order). `column` is a human-readable label
127    /// (`"token(a, b)"`) used only for diagnostics; evaluation never reads a
128    /// stored column with that name — it hashes the row key instead.
129    pub fn token(
130        token_columns: Vec<String>,
131        operation: SSTableFilterOp,
132        values: Vec<Value>,
133    ) -> Self {
134        let label = format!("token({})", token_columns.join(", "));
135        Self {
136            column: label,
137            operation,
138            values,
139            token_columns: Some(token_columns),
140        }
141    }
142
143    /// True if this predicate constrains the partition-key token rather than a
144    /// stored column (Issue #955).
145    pub fn is_token(&self) -> bool {
146        self.token_columns.is_some()
147    }
148}
149
150/// SSTable filter operations
151#[derive(Debug, Clone)]
152pub enum SSTableFilterOp {
153    Equal,
154    Range,
155    /// Single-bound clustering inequalities (`>`, `>=`, `<`, `<=`). Each carries
156    /// one bound value in `SSTablePredicate::values`. Issue #788.
157    Gt,
158    Gte,
159    Lt,
160    Lte,
161    In,
162    Prefix,
163    BloomFilter,
164}
165
166/// Aggregation execution plan
167#[derive(Debug, Clone)]
168pub struct AggregationPlan {
169    /// Raw stored column names `build_group_key` reads from each scanned row.
170    pub group_by_columns: Vec<String>,
171    /// Issue #1763: SELECT OUTPUT name per grouped dimension (parallel to
172    /// `group_by_columns`); `finalize_group` emits the value under this name so it
173    /// equals the metadata column name (both from `select_naming`). Alias when
174    /// projected with one, else the column name.
175    pub group_by_output_names: Vec<String>,
176    pub aggregates: Vec<AggregateComputation>,
177}
178
179/// Individual aggregate computation
180#[derive(Debug, Clone)]
181pub struct AggregateComputation {
182    pub function: AggregateType,
183    pub column: String,
184    pub alias: String,
185    pub distinct: bool,
186}
187
188impl SelectOptimizer {
189    /// Create a new query optimizer
190    pub fn new(schema: Arc<SchemaManager>, storage: Arc<StorageEngine>) -> Self {
191        Self { schema, storage }
192    }
193
194    /// Optimize a SELECT statement
195    pub async fn optimize(&self, statement: SelectStatement) -> Result<OptimizedQueryPlan> {
196        #[cfg(test)]
197        OPTIMIZE_INVOCATIONS.with(|c| c.set(c.get() + 1));
198
199        let mut plan = OptimizedQueryPlan {
200            statement: statement.clone(),
201            execution_steps: Vec::new(),
202            sstable_predicates: Vec::new(),
203            aggregation_plan: None,
204        };
205
206        // Constant expressions (no FROM) need no execution steps.
207        let Some(from_clause) = statement.from_clause.as_ref() else {
208            return Ok(plan);
209        };
210        let table_id = match from_clause {
211            FromClause::Table(t) | FromClause::TableAlias(t, _) => t.clone(),
212        };
213
214        // Issue #1763: reject `SELECT DISTINCT <aggregate>` (e.g.
215        // `SELECT DISTINCT COUNT(*)`). `is_aggregate()` now unwraps aliased
216        // aggregates inside a `DISTINCT` clause, so `requires_aggregation()`
217        // returns true and an aggregation is planned — but `plan_aggregation`
218        // collects its computations ONLY from `SelectClause::Columns`, so a
219        // DISTINCT aggregate would plan an aggregation with NO computations and
220        // silently drop the result column. DISTINCT over an aggregate is also not
221        // a meaningful shape (the aggregate already collapses all rows to a
222        // single value, making DISTINCT redundant) and Cassandra rejects it.
223        // Fail cleanly here rather than return a wrong result.
224        if let SelectClause::Distinct(exprs) = &statement.select_clause {
225            if exprs.iter().any(|e| e.is_aggregate()) {
226                return Err(Error::query_execution(
227                    "SELECT DISTINCT with an aggregate function is not supported; \
228                     DISTINCT over an aggregate is redundant because the aggregate \
229                     already collapses rows to a single value"
230                        .to_string(),
231                ));
232            }
233        }
234
235        if let Some(where_clause) = &statement.where_clause {
236            // Validate EVERY token() restriction in the WHERE tree before pushdown
237            // (roborev FINDING: token() under OR/NOT was never traversed, so an
238            // unsupported or non-pushable token restriction was silently dropped
239            // while a sibling pushable predicate suppressed the residual Filter).
240            // This whole-tree pass guarantees no token() restriction is ever
241            // ignored: it is pushed, or it errors here.
242            validate_token_forms_whole_tree(where_clause, true)?;
243            plan.sstable_predicates = collect_sstable_predicates(where_clause)?;
244        }
245
246        // The scan projection is the COMPLETE set of source columns any later
247        // step reads from the scanned row (see `extract_projection_columns`).
248        // Computed once and reused below to decide whether the redundant
249        // bare-column `Project` step can be skipped (issue #1587) — it can only be
250        // skipped when the scan projection is EXACTLY the selected columns.
251        let scan_projection = extract_projection_columns(&statement);
252        plan.execution_steps.push(ExecutionStep::SSTableScan {
253            table: table_id,
254            predicates: plan.sstable_predicates.clone(),
255            projection: scan_projection.clone(),
256        });
257
258        // If we couldn't push any predicates down, keep the original WHERE as
259        // a post-scan filter step.
260        if let Some(where_clause) = &statement.where_clause {
261            if plan.sstable_predicates.is_empty() {
262                plan.execution_steps.push(ExecutionStep::Filter {
263                    expression: where_clause.clone(),
264                });
265            }
266        }
267
268        let needs_aggregation = statement.requires_aggregation();
269        if needs_aggregation {
270            let agg_plan = plan_aggregation(&statement);
271            plan.execution_steps.push(ExecutionStep::Aggregate {
272                plan: agg_plan.clone(),
273            });
274            plan.aggregation_plan = Some(agg_plan);
275        }
276
277        if let Some(order_by) = &statement.order_by {
278            plan.execution_steps.push(ExecutionStep::Sort {
279                order_by: order_by.clone(),
280            });
281        }
282
283        // PER PARTITION LIMIT caps rows per partition before the query-wide
284        // LIMIT, so it must be emitted ahead of the Limit step.
285        if let Some(count) = statement.per_partition_limit {
286            plan.execution_steps
287                .push(ExecutionStep::PerPartitionLimit { count });
288        }
289
290        if let Some(limit) = &statement.limit {
291            plan.execution_steps.push(ExecutionStep::Limit {
292                count: limit.count,
293                offset: statement.offset,
294            });
295        }
296
297        // Aggregation already produces the final shape; an explicit Project
298        // step on top would be redundant.
299        if !needs_aggregation {
300            if let SelectClause::Columns(exprs) | SelectClause::Distinct(exprs) =
301                &statement.select_clause
302            {
303                // Issue #1587 (E5): a bare-column SELECT (every item a plain
304                // `Column`, no alias/function/arithmetic) is ALREADY projected by
305                // the SSTable scan — `SSTableScan.projection` is exactly these
306                // column names (see `extract_projection_columns`), and
307                // `build_row_from_scan` keeps precisely those cells, keyed by the
308                // same column name the `Project` step would re-key them to. So the
309                // second `Project` re-projects every row into a byte-identical
310                // value map: skip it and project each row ONCE (in the scan).
311                //
312                // The optimization is scoped to `SelectClause::Columns` (not
313                // `Distinct`, which is not a pure projection) and to all-plain-
314                // `Column` items — an alias / expression / `WRITETIME`/`TTL`
315                // reshapes or renames the row and still needs the `Project` step.
316                let is_bare_columns = matches!(&statement.select_clause, SelectClause::Columns(_))
317                    && exprs
318                        .iter()
319                        .all(|e| matches!(e, SelectExpression::Column(_)));
320
321                // The scan projection is now the COMPLETE referenced-column set, so
322                // it may carry WHERE / ORDER BY helper columns that are NOT in the
323                // SELECT list. When that happens the scan row has MORE columns than
324                // the SELECT clause, and the `Project` step is REQUIRED to trim the
325                // output back to the selected columns — otherwise those helper
326                // columns would leak into the result. Skip the redundant projection
327                // only when the scan projection is EXACTLY the bare selected columns
328                // (same names, same order).
329                let projection_is_exactly_selected = is_bare_columns && {
330                    let selected: Vec<&str> = exprs
331                        .iter()
332                        .filter_map(|e| match e {
333                            SelectExpression::Column(c) => Some(c.column.as_str()),
334                            _ => None,
335                        })
336                        .collect();
337                    scan_projection.len() == selected.len()
338                        && scan_projection
339                            .iter()
340                            .zip(&selected)
341                            .all(|(scanned, wanted)| scanned.as_str() == *wanted)
342                };
343
344                if !projection_is_exactly_selected {
345                    plan.execution_steps.push(ExecutionStep::Project {
346                        columns: exprs.clone(),
347                    });
348                }
349            }
350        }
351
352        Ok(plan)
353    }
354}
355
356/// Walk a WHERE expression tree, collecting comparisons that can be turned
357/// into SSTable-level predicates. OR/NOT branches are intentionally skipped:
358/// those require capabilities the SSTable filter pushdown doesn't have.
359fn collect_sstable_predicates(expr: &WhereExpression) -> Result<Vec<SSTablePredicate>> {
360    let mut out = Vec::new();
361    fn walk(expr: &WhereExpression, out: &mut Vec<SSTablePredicate>) -> Result<()> {
362        match expr {
363            WhereExpression::Comparison(comp) => {
364                // An unsupported `token(...)` form is a planning error here, not
365                // a dropped predicate (roborev FINDING 2).
366                if let Some(predicate) = comparison_to_sstable_predicate(comp)? {
367                    out.push(predicate);
368                }
369            }
370            WhereExpression::And(exprs) => {
371                for e in exprs {
372                    walk(e, out)?;
373                }
374            }
375            WhereExpression::Parentheses(inner) => walk(inner, out)?,
376            WhereExpression::Or(_) | WhereExpression::Not(_) => {}
377        }
378        Ok(())
379    }
380    walk(expr, &mut out)?;
381    Ok(out)
382}
383
384/// Whole-WHERE-tree validation that NO `token(...)` restriction is ever silently
385/// dropped, regardless of its position (top-level, AND, OR, NOT, parenthesized).
386///
387/// `collect_sstable_predicates` only descends pushable AND branches, so a
388/// `token(...)` comparison under OR/NOT was previously never inspected: an
389/// unsupported form (IN/BETWEEN/non-literal RHS/non-pk args) was not rejected,
390/// and even a *supported* range/equality token form could not be pushed there.
391/// In both cases, if a sibling predicate was pushable, the optimizer dropped the
392/// residual Filter and the token restriction was ignored — wrong results
393/// (roborev FINDING).
394///
395/// This pass visits every branch. At each `token(...)` comparison it:
396///   * runs the full token-form validation (`token_comparison_to_predicate`),
397///     so an UNSUPPORTED form is a planning error anywhere in the tree; and
398///   * if the comparison is in a NON-PUSHABLE position (`pushable == false`,
399///     i.e. under OR/NOT), rejects even a SUPPORTED token form with a clear
400///     planning error.
401///
402/// Why reject supported token() forms under OR/NOT rather than evaluate them as
403/// a residual filter: the row-level WHERE evaluator (`evaluate_select_expression`
404/// in `select_executor.rs`) returns "Function expressions not yet implemented"
405/// for `SelectExpression::Function`, so it CANNOT compute
406/// `cassandra_murmur3_token(row key)` at the row level. Token evaluation exists
407/// only in `evaluate_leaf` over pushed `SSTablePredicate`s, which are fed solely
408/// by pushable AND branches. A token() leaf under OR/NOT can therefore be neither
409/// pushed nor evaluated — so the only honest outcome is a clear error, never a
410/// dropped restriction. This is a narrow, documented limitation
411/// (`token()` under OR/NOT is unsupported).
412///
413/// `pushable` starts `true` at the root and at AND children (the positions
414/// `collect_sstable_predicates` actually descends) and flips to `false` under
415/// OR and NOT. `Parentheses` is transparent and preserves the current value.
416fn validate_token_forms_whole_tree(expr: &WhereExpression, pushable: bool) -> Result<()> {
417    match expr {
418        WhereExpression::Comparison(comp) => {
419            if is_token_comparison(comp) {
420                // Reject unsupported token() forms regardless of position. The
421                // returned predicate is discarded here; pushable positions are
422                // lowered separately by `collect_sstable_predicates`.
423                let _supported = comparison_to_sstable_predicate(comp)?;
424                if !pushable {
425                    return Err(Error::query_execution(
426                        "token() restriction is only supported at the top level or within an \
427                         AND conjunction; token() under OR/NOT cannot be pushed down and the \
428                         row-level evaluator cannot compute a token, so it is rejected rather \
429                         than silently ignored"
430                            .to_string(),
431                    ));
432                }
433            }
434        }
435        // AND children remain in a pushable position (the same branches
436        // `collect_sstable_predicates` descends).
437        WhereExpression::And(exprs) => {
438            for e in exprs {
439                validate_token_forms_whole_tree(e, pushable)?;
440            }
441        }
442        // OR/NOT children are not pushed down; a token() inside them cannot be
443        // enforced, so mark the subtree non-pushable.
444        WhereExpression::Or(exprs) => {
445            for e in exprs {
446                validate_token_forms_whole_tree(e, false)?;
447            }
448        }
449        WhereExpression::Not(inner) => validate_token_forms_whole_tree(inner, false)?,
450        // Parentheses are transparent: they neither create nor remove a pushable
451        // position, so the current `pushable` flag carries through.
452        WhereExpression::Parentheses(inner) => validate_token_forms_whole_tree(inner, pushable)?,
453    }
454    Ok(())
455}
456
457/// True when a comparison's left side is a `token(...)` call (case-insensitive),
458/// i.e. it denotes a partition-key token restriction rather than a column.
459fn is_token_comparison(comp: &ComparisonExpression) -> bool {
460    matches!(
461        &comp.left,
462        SelectExpression::Function(func) if func.name.eq_ignore_ascii_case("token")
463    )
464}
465
466/// Returns `Ok(Some(pred))` for a pushable predicate, `Ok(None)` for a
467/// comparison that is legitimately not pushed down (a residual Filter handles
468/// it), and `Err` for an *invalid* restriction that must fail planning. The
469/// only `Err` case is an unsupported `token(...)` form (see
470/// `token_comparison_to_predicate`): a `token()` LHS always denotes a token
471/// restriction, so an unsupported form is a query error, never a silently
472/// dropped predicate (roborev FINDING 2). Bare-column comparisons still return
473/// `Ok(None)` when not pushable.
474fn comparison_to_sstable_predicate(
475    comp: &ComparisonExpression,
476) -> Result<Option<SSTablePredicate>> {
477    // The left side is either a bare column or a `token(col, ...)` call. A
478    // `token(...)` predicate constrains the partition-key token, not a stored
479    // column, so it gets a token predicate (Issue #955); anything else only
480    // supports the bare-column form (Issue #788's existing behaviour).
481    match &comp.left {
482        SelectExpression::Column(col_ref) => {
483            Ok(column_comparison_to_predicate(col_ref.column.clone(), comp))
484        }
485        SelectExpression::Function(func) if func.name.eq_ignore_ascii_case("token") => {
486            token_comparison_to_predicate(func, comp).map(Some)
487        }
488        _ => Ok(None),
489    }
490}
491
492/// Convert a bare-column comparison (`col <op> ...`) to a pushed-down predicate.
493fn column_comparison_to_predicate(
494    column: String,
495    comp: &ComparisonExpression,
496) -> Option<SSTablePredicate> {
497    use SSTableFilterOp as Op;
498    match (&comp.operator, &comp.right) {
499        (ComparisonOperator::Equal, ComparisonRightSide::Value(value_expr)) => Some(
500            SSTablePredicate::column(column, Op::Equal, vec![literal_value(value_expr)?]),
501        ),
502        (ComparisonOperator::In, ComparisonRightSide::ValueList(value_exprs)) => {
503            let values: Vec<Value> = value_exprs.iter().filter_map(literal_value).collect();
504            (!values.is_empty()).then(|| SSTablePredicate::column(column, Op::In, values))
505        }
506        (ComparisonOperator::Between, ComparisonRightSide::Range(start_expr, end_expr)) => {
507            let start = literal_value(start_expr)?;
508            let end = literal_value(end_expr)?;
509            Some(SSTablePredicate::column(
510                column,
511                Op::Range,
512                vec![start, end],
513            ))
514        }
515        // Single-bound clustering inequalities. Without these arms the operators
516        // fall through to `None` and the restriction is silently dropped, so the
517        // whole partition is returned instead of the requested slice (Issue #788).
518        (ComparisonOperator::GreaterThan, ComparisonRightSide::Value(v)) => Some(
519            SSTablePredicate::column(column, Op::Gt, vec![literal_value(v)?]),
520        ),
521        (ComparisonOperator::GreaterThanOrEqual, ComparisonRightSide::Value(v)) => Some(
522            SSTablePredicate::column(column, Op::Gte, vec![literal_value(v)?]),
523        ),
524        (ComparisonOperator::LessThan, ComparisonRightSide::Value(v)) => Some(
525            SSTablePredicate::column(column, Op::Lt, vec![literal_value(v)?]),
526        ),
527        (ComparisonOperator::LessThanOrEqual, ComparisonRightSide::Value(v)) => Some(
528            SSTablePredicate::column(column, Op::Lte, vec![literal_value(v)?]),
529        ),
530        _ => None,
531    }
532}
533
534/// Convert a `token(col, ...) <op> <bound>` comparison to a token predicate
535/// (Issue #955). The bound must be an integer literal (the i64 token value).
536/// `token(...) = ?` lowers to a token `Equal` predicate (an exact-token
537/// restriction that `evaluate_leaf` evaluates by hashing the row key).
538///
539/// Unsupported `token()` forms are a PLANNING ERROR rather than a dropped
540/// predicate (Issue #955 follow-up / roborev FINDING 2). Previously these
541/// returned `None`; when the same query carried another pushable predicate
542/// (e.g. `ck > 0`), the optimizer omitted the residual Filter step and the
543/// token restriction was SILENTLY IGNORED — so `token(pk) IN (...) AND ck > 0`
544/// returned all rows. Cassandra rejects `token() IN`/`BETWEEN` and non-literal
545/// RHS on a token restriction, so erroring is the correct behaviour. We only
546/// reject the unsupported token forms here; supported range/equality token
547/// restrictions still lower normally.
548fn token_comparison_to_predicate(
549    func: &FunctionCall,
550    comp: &ComparisonExpression,
551) -> Result<SSTablePredicate> {
552    use SSTableFilterOp as Op;
553    // Collect the partition-key column names the token() is computed over.
554    let mut token_columns = Vec::with_capacity(func.args.len());
555    for arg in &func.args {
556        match arg {
557            SelectExpression::Column(col_ref) => token_columns.push(col_ref.column.clone()),
558            other => {
559                return Err(Error::query_execution(format!(
560                    "token() argument must be a partition-key column; got {other:?}"
561                )));
562            }
563        }
564    }
565    if token_columns.is_empty() {
566        return Err(Error::query_execution(
567            "token() restriction requires at least one partition-key column argument".to_string(),
568        ));
569    }
570
571    let op = match &comp.operator {
572        ComparisonOperator::GreaterThan => Op::Gt,
573        ComparisonOperator::GreaterThanOrEqual => Op::Gte,
574        ComparisonOperator::LessThan => Op::Lt,
575        ComparisonOperator::LessThanOrEqual => Op::Lte,
576        // `token(pk) = ?` is an exact-token restriction. Lower it to a token
577        // `Equal` predicate so `evaluate_leaf`'s `Equal` arm hashes the row key
578        // and compares it to the bound (Issue #955 follow-up).
579        ComparisonOperator::Equal => Op::Equal,
580        // `token(pk) IN (...)`, `token(pk) BETWEEN ...`, and any other operator
581        // are not valid token restrictions. Reject rather than drop so the
582        // restriction is never silently ignored.
583        other => {
584            return Err(Error::query_execution(format!(
585                "unsupported token() restriction operator {other:?}; \
586                 token() supports only range bounds (<, <=, >, >=) and equality (=)"
587            )));
588        }
589    };
590    let ComparisonRightSide::Value(value_expr) = &comp.right else {
591        return Err(Error::query_execution(
592            "token() restriction requires a single integer token bound on the right-hand side"
593                .to_string(),
594        ));
595    };
596    // The token bound is an i64; the parser emits integer literals as BigInt.
597    let bound = match literal_value(value_expr) {
598        Some(Value::BigInt(n)) => Value::BigInt(n),
599        Some(Value::Integer(n)) => Value::BigInt(n as i64),
600        _ => {
601            return Err(Error::query_execution(
602                "token() restriction bound must be an integer token value".to_string(),
603            ));
604        }
605    };
606    Ok(SSTablePredicate::token(token_columns, op, vec![bound]))
607}
608
609fn literal_value(expr: &SelectExpression) -> Option<Value> {
610    match expr {
611        SelectExpression::Literal(value) => Some(value.clone()),
612        _ => None,
613    }
614}
615
616/// Columns the SSTable scan must READ: the SOURCE stored column names, NOT the
617/// SELECT output aliases.
618///
619/// Issue #1763: `build_row_from_scan` filters decoded cells by physical column
620/// name, so an aliased projection (`category AS cat`) MUST scan `category` —
621/// projecting `cat` would drop the cell (null value; the `Project` step / grouped
622/// dimension could not find it). Output naming is applied afterwards (`Project`
623/// step / metadata / `finalize_group`), all via `select_naming`. Aggregates name
624/// no stored cell — but their ARGUMENT columns ARE stored cells and MUST be
625/// scanned (issue #1952): the projection is the UNION of the projected/grouped
626/// dimension source columns AND every non-star aggregate argument source column.
627/// Omitting the latter meant `SELECT category, SUM(value) ... GROUP BY category`
628/// scanned only `category`, so `build_row_from_scan` filtered `value` out and the
629/// aggregate silently computed from missing inputs (SUM/AVG → 0/null, COUNT(col)
630/// → 0, MIN/MAX → null). `COUNT(*)` contributes no argument column, and an empty
631/// projection (`SELECT *`) still means scan-all.
632///
633/// The union ALSO includes every `GROUP BY` source column, whether or not it is
634/// projected in the SELECT clause (the #1952 regression fix). `build_group_key`
635/// reads each grouped column's cell from the scanned row; if a grouped column is
636/// NOT in the SELECT clause -- `SELECT SUM(value) FROM t GROUP BY category` --
637/// deriving the projection from the SELECT clause alone omitted `category`, so
638/// every row's group key read `Null` and ALL groups collapsed into one, silently
639/// returning wrong grouped aggregates. Scanning the GROUP BY columns
640/// unconditionally keeps the group key correct.
641///
642/// Finally — when the projection is NON-EMPTY (and therefore FILTERS decoded
643/// cells) — the union includes every column referenced by the WHERE and ORDER BY
644/// clauses (the second #1952 regression fix):
645///   * WHERE predicate columns: a pushed predicate is re-checked per row by the
646///     backstop `evaluate_predicates`, and a non-pushed predicate by the residual
647///     `Filter` step; BOTH read the column from the scanned row. Omitting them
648///     meant `SELECT SUM(value) FROM t WHERE category = 'A'` scanned only
649///     `["value"]`, so `category` was filtered out, the backstop saw a missing
650///     column (SQL `UNKNOWN`), and EVERY row was rejected — an empty result / a
651///     zero-or-null aggregate instead of the real filtered value. (Before #1952
652///     this query had an EMPTY projection = scan-all, so `category` was present;
653///     #1952 made the projection non-empty and reintroduced the filtering.)
654///   * ORDER BY columns: for a non-aggregate query the `Sort` step evaluates each
655///     ORDER BY expression against the scanned row, so an ORDER BY on a
656///     non-selected column would otherwise sort by a filtered-away `Null`. (In the
657///     aggregate case `Sort` runs after `Aggregate` on the result rows; the extra
658///     scanned cells are simply ignored, so unioning them is safe either way.)
659///
660/// HAVING columns are intentionally NOT unioned: the executor implements no
661/// HAVING step (nothing in `select_executor` reads `having_clause`), so HAVING
662/// columns are never read from the scanned row and adding them would be dead
663/// columns.
664///
665/// An EMPTY base projection (`SELECT *`, or `SELECT COUNT(*)` with no dimension)
666/// still means scan-all, which already supplies every column any step needs, so
667/// nothing is unioned in that case and the "empty = scan all" contract holds.
668fn extract_projection_columns(statement: &SelectStatement) -> Vec<String> {
669    match &statement.select_clause {
670        SelectClause::All => Vec::new(),
671        SelectClause::Columns(exprs) | SelectClause::Distinct(exprs) => {
672            let mut columns: Vec<String> = Vec::new();
673            let push_unique = |columns: &mut Vec<String>, col: String| {
674                if !columns.contains(&col) {
675                    columns.push(col);
676                }
677            };
678            for expr in exprs {
679                // Dimension / plain-column source (the SELECT output side is named
680                // later; the scan reads the SOURCE column).
681                if let Some((source, _)) = projection_source_and_output(expr) {
682                    push_unique(&mut columns, source);
683                }
684                // Aggregate argument source columns (the #1952 fix). Empty for
685                // `COUNT(*)` and non-aggregate expressions.
686                for source in aggregate_arg_source_columns(expr) {
687                    push_unique(&mut columns, source);
688                }
689            }
690            // GROUP BY source columns must ALWAYS be scanned so `build_group_key`
691            // can read them, even when a grouped column is not in the SELECT
692            // clause (the #1952 regression fix).
693            if let Some(group_by) = &statement.group_by {
694                for col in &group_by.columns {
695                    push_unique(&mut columns, col.column.clone());
696                }
697            }
698
699            // An empty base projection means scan-all: it already supplies every
700            // referenced column, so leave it empty. Only a NON-EMPTY projection
701            // (which filters decoded cells) needs the WHERE / ORDER BY columns
702            // unioned in, or those steps read a filtered-away column.
703            if columns.is_empty() {
704                return columns;
705            }
706
707            // WHERE predicate columns (pushed backstop + residual Filter both read
708            // from the scanned row).
709            if let Some(where_clause) = &statement.where_clause {
710                for col_ref in where_clause.get_column_refs() {
711                    push_unique(&mut columns, col_ref.column);
712                }
713            }
714            // ORDER BY columns (the non-aggregate `Sort` reads from the scanned
715            // row; harmless in the aggregate case).
716            if let Some(order_by) = &statement.order_by {
717                for item in &order_by.items {
718                    for col_ref in item.expression.get_column_refs() {
719                        push_unique(&mut columns, col_ref.column);
720                    }
721                }
722            }
723            columns
724        }
725    }
726}
727
728fn plan_aggregation(statement: &SelectStatement) -> AggregationPlan {
729    let group_by_columns: Vec<String> = statement
730        .group_by
731        .as_ref()
732        .map(|g| g.columns.iter().map(|col| col.column.clone()).collect())
733        .unwrap_or_default();
734
735    // Issue #1763 (grouped dimensions): map each grouped column to its SELECT
736    // OUTPUT name via the SAME `select_naming` source `get_result_columns` uses,
737    // so `finalize_group` emits the grouped value under the metadata column name.
738    // `col AS alias` yields `alias`; bare or not-projected yields the column name.
739    let mut output_for_source: HashMap<String, String> = HashMap::new();
740    if let SelectClause::Columns(exprs) | SelectClause::Distinct(exprs) = &statement.select_clause {
741        for expr in exprs {
742            if let Some((source, output)) = projection_source_and_output(expr) {
743                // First projection of a source column wins (matches metadata order).
744                output_for_source.entry(source).or_insert(output);
745            }
746        }
747    }
748    let group_by_output_names: Vec<String> = group_by_columns
749        .iter()
750        .map(|col| {
751            output_for_source
752                .get(col.as_str())
753                .cloned()
754                .unwrap_or_else(|| col.clone())
755        })
756        .collect();
757
758    let mut aggregates = Vec::new();
759    if let SelectClause::Columns(exprs) = &statement.select_clause {
760        for expr in exprs {
761            // Issue #1763: handle both bare `COUNT(*)` and aliased
762            // `COUNT(*) AS total`. The output name (`alias`) is derived by the
763            // SINGLE shared source `aggregate_output_name`, so the row value key
764            // emitted by `finalize_group` can never diverge from the result
765            // metadata column name built by `get_result_columns`.
766            let Some((agg, alias)) = unwrap_aggregate(expr).zip(aggregate_output_name(expr)) else {
767                continue;
768            };
769            let (column, _) = aggregate_column_and_alias(agg);
770            aggregates.push(AggregateComputation {
771                function: agg.function.clone(),
772                column,
773                alias,
774                distinct: agg.distinct,
775            });
776        }
777    }
778
779    AggregationPlan {
780        group_by_columns,
781        group_by_output_names,
782        aggregates,
783    }
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789    use crate::{platform::Platform, schema::SchemaManager, storage::StorageEngine, Config};
790    use tempfile::TempDir;
791
792    #[tokio::test]
793    async fn test_optimizer_creation() {
794        let temp_dir = TempDir::new().unwrap();
795        let config = Config::default();
796        let platform = Arc::new(Platform::new(&config).await.unwrap());
797        let storage = Arc::new(
798            StorageEngine::open(
799                temp_dir.path(),
800                &config,
801                platform.clone(),
802                #[cfg(feature = "state_machine")]
803                None,
804            )
805            .await
806            .unwrap(),
807        );
808        let schema = Arc::new(SchemaManager::new(temp_dir.path()).await.unwrap());
809        let optimizer = SelectOptimizer { schema, storage };
810        assert!(std::mem::size_of_val(&optimizer) > 0);
811    }
812
813    async fn make_optimizer() -> SelectOptimizer {
814        let temp_dir = TempDir::new().unwrap();
815        let config = Config::default();
816        let platform = Arc::new(Platform::new(&config).await.unwrap());
817        let storage = Arc::new(
818            StorageEngine::open(
819                temp_dir.path(),
820                &config,
821                platform.clone(),
822                #[cfg(feature = "state_machine")]
823                None,
824            )
825            .await
826            .unwrap(),
827        );
828        let schema = Arc::new(SchemaManager::new(temp_dir.path()).await.unwrap());
829        SelectOptimizer { schema, storage }
830    }
831
832    fn project_step_count(plan: &OptimizedQueryPlan) -> usize {
833        plan.execution_steps
834            .iter()
835            .filter(|s| matches!(s, ExecutionStep::Project { .. }))
836            .count()
837    }
838
839    /// Issue #1587 (E5): a bare-column SELECT is projected exactly ONCE — by the
840    /// SSTable scan — with no redundant `Project` step re-projecting every row.
841    /// "row-projection-passes" = the scan pass (always 1) + one per `Project`
842    /// step; it must be 1 for a bare-column select (was 2 on main) and stay 2
843    /// for an aliased projection, which reshapes/renames the row.
844    #[tokio::test]
845    async fn bare_column_select_projects_rows_once() {
846        let optimizer = make_optimizer().await;
847
848        let bare = crate::query::select_parser::parse_select("SELECT a, b, c FROM t").unwrap();
849        let plan = optimizer.optimize(bare).await.unwrap();
850        let projection_passes = 1 + project_step_count(&plan);
851        assert_eq!(
852            projection_passes, 1,
853            "issue #1587: a bare-column SELECT must project each row once (scan only), not twice"
854        );
855
856        // The scan step already carries exactly the selected columns, in order.
857        let scan_projection = plan
858            .execution_steps
859            .iter()
860            .find_map(|s| match s {
861                ExecutionStep::SSTableScan { projection, .. } => Some(projection.clone()),
862                _ => None,
863            })
864            .expect("plan has an SSTable scan");
865        assert_eq!(scan_projection, vec!["a", "b", "c"]);
866
867        // Contrast: an aliased projection reshapes the row → keeps its Project pass.
868        let mut aliased = crate::query::select_parser::parse_select("SELECT a FROM t").unwrap();
869        aliased.select_clause = SelectClause::Columns(vec![SelectExpression::Aliased(
870            Box::new(SelectExpression::Column(ColumnRef {
871                table: None,
872                column: "a".to_string(),
873            })),
874            "x".to_string(),
875        )]);
876        let aplan = optimizer.optimize(aliased).await.unwrap();
877        assert_eq!(
878            1 + project_step_count(&aplan),
879            2,
880            "an aliased projection must keep its Project pass"
881        );
882    }
883
884    /// Extract the SSTable-scan projection a parsed query plans to.
885    fn scan_projection_of(query: &str) -> Vec<String> {
886        let statement = crate::query::select_parser::parse_select(query)
887            .unwrap_or_else(|e| panic!("{query} must parse: {e}"));
888        extract_projection_columns(&statement)
889    }
890
891    /// #1952 REGRESSION (roborev HIGH, this round): a non-empty scan projection
892    /// must include WHERE predicate columns, or the per-row backstop
893    /// (`evaluate_predicates`) reads a filtered-away column and rejects every row.
894    /// `SELECT SUM(value) FROM t WHERE category = 'x'` must scan BOTH `value`
895    /// (the aggregate argument) AND `category` (the WHERE column).
896    #[test]
897    fn where_predicate_columns_are_scanned() {
898        let projection = scan_projection_of(
899            "SELECT SUM(value) FROM test_basic.multi_partition_table WHERE category = 'A'",
900        );
901        assert!(
902            projection.iter().any(|c| c == "value"),
903            "aggregate argument `value` must be scanned; got {projection:?}"
904        );
905        assert!(
906            projection.iter().any(|c| c == "category"),
907            "WHERE column `category` must be scanned so the predicate backstop can \
908             evaluate it; got {projection:?}"
909        );
910    }
911
912    /// The WHERE union covers columns referenced anywhere in the predicate tree
913    /// (AND/OR and both comparison operands), reusing `WhereExpression::get_column_refs`.
914    #[test]
915    fn where_columns_from_compound_predicate_are_scanned() {
916        let projection = scan_projection_of(
917            "SELECT SUM(value) FROM t WHERE category = 'A' AND (name = 'x' OR metadata = 'y')",
918        );
919        for col in ["value", "category", "name", "metadata"] {
920            assert!(
921                projection.iter().any(|c| c == col),
922                "referenced column `{col}` must be scanned; got {projection:?}"
923            );
924        }
925    }
926
927    /// ORDER BY columns are read from the scanned row by the non-aggregate `Sort`
928    /// step, so a non-selected ORDER BY column must be scanned too.
929    #[test]
930    fn order_by_columns_are_scanned() {
931        let projection = scan_projection_of("SELECT name FROM t ORDER BY value");
932        assert!(
933            projection.iter().any(|c| c == "name"),
934            "selected column `name` must be scanned; got {projection:?}"
935        );
936        assert!(
937            projection.iter().any(|c| c == "value"),
938            "ORDER BY column `value` must be scanned for the Sort step; got {projection:?}"
939        );
940    }
941
942    /// The "empty = scan all" contract holds: `SELECT *` and a bare `COUNT(*)`
943    /// with no dimension keep an EMPTY projection even with a WHERE clause (scan-all
944    /// already supplies every column), so nothing is spuriously unioned in.
945    #[test]
946    fn empty_projection_stays_empty_for_scan_all() {
947        assert!(
948            scan_projection_of("SELECT * FROM t WHERE category = 'A'").is_empty(),
949            "SELECT * must keep an empty (scan-all) projection"
950        );
951        assert!(
952            scan_projection_of("SELECT COUNT(*) FROM t WHERE category = 'A'").is_empty(),
953            "bare COUNT(*) with no dimension must keep an empty (scan-all) projection"
954        );
955    }
956
957    /// A bare-column SELECT whose WHERE references a NON-selected column must keep
958    /// its `Project` step (to trim the helper WHERE column out of the output),
959    /// while a bare-column SELECT with no extra columns still skips it (#1587).
960    #[tokio::test]
961    async fn bare_column_with_where_helper_keeps_project() {
962        let optimizer = make_optimizer().await;
963
964        // WHERE references `b`, which is NOT selected → scan projection is a
965        // superset of the selected columns → the Project step must be kept so `b`
966        // does not leak into the output.
967        let with_helper =
968            crate::query::select_parser::parse_select("SELECT a FROM t WHERE b = 1").unwrap();
969        let plan = optimizer.optimize(with_helper).await.unwrap();
970        assert_eq!(
971            project_step_count(&plan),
972            1,
973            "a bare-column SELECT with a non-selected WHERE column must keep its \
974             Project step to trim the helper column from the output"
975        );
976
977        // No extra columns → the #1587 skip still applies.
978        let no_helper =
979            crate::query::select_parser::parse_select("SELECT a, b FROM t WHERE a = 1").unwrap();
980        let plan = optimizer.optimize(no_helper).await.unwrap();
981        assert_eq!(
982            project_step_count(&plan),
983            0,
984            "a bare-column SELECT whose WHERE only references selected columns must \
985             still skip the redundant Project step (#1587)"
986        );
987    }
988
989    /// Build `<column> <op> <literal>` for predicate-conversion tests.
990    fn cmp(op: ComparisonOperator, column: &str, value: Value) -> ComparisonExpression {
991        ComparisonExpression {
992            left: SelectExpression::Column(ColumnRef {
993                table: None,
994                column: column.to_string(),
995            }),
996            operator: op,
997            right: ComparisonRightSide::Value(SelectExpression::Literal(value)),
998        }
999    }
1000
1001    /// Issue #788: clustering-key inequalities must convert to single-bound
1002    /// SSTable predicates so they are evaluated post-scan instead of dropped.
1003    #[test]
1004    fn inequality_operators_convert_to_single_bound_predicates() {
1005        let cases = [
1006            (ComparisonOperator::GreaterThan, SSTableFilterOp::Gt),
1007            (ComparisonOperator::GreaterThanOrEqual, SSTableFilterOp::Gte),
1008            (ComparisonOperator::LessThan, SSTableFilterOp::Lt),
1009            (ComparisonOperator::LessThanOrEqual, SSTableFilterOp::Lte),
1010        ];
1011        for (op, expected_op) in cases {
1012            let comp = cmp(op.clone(), "ck", Value::Integer(200));
1013            let predicate = comparison_to_sstable_predicate(&comp)
1014                .expect("conversion must not error")
1015                .unwrap_or_else(|| panic!("operator {op:?} must convert to a predicate"));
1016            assert_eq!(predicate.column, "ck");
1017            assert!(
1018                std::mem::discriminant(&predicate.operation)
1019                    == std::mem::discriminant(&expected_op),
1020                "operator {op:?} produced {:?}, expected {expected_op:?}",
1021                predicate.operation
1022            );
1023            assert_eq!(predicate.values, vec![Value::Integer(200)]);
1024        }
1025    }
1026
1027    /// Issue #788, end-to-end through the real parser: the exact query from the
1028    /// bug report must produce a plan that carries BOTH clustering inequalities
1029    /// alongside the partition equality. Before the fix only the `pk` equality
1030    /// survived `collect_sstable_predicates`, so the whole partition leaked.
1031    #[test]
1032    fn query_plan_carries_clustering_inequality_bounds() {
1033        use crate::query::select_parser::parse_select;
1034
1035        let statement = parse_select(
1036            "SELECT * FROM perf.wide_rows WHERE pk = 'p0000' AND ck >= 0 AND ck < 200",
1037        )
1038        .expect("issue #788 query must parse");
1039        let where_clause = statement
1040            .where_clause
1041            .expect("WHERE clause must be present");
1042
1043        let predicates = collect_sstable_predicates(&where_clause).expect("planning must succeed");
1044
1045        let has = |col: &str, want: &SSTableFilterOp| {
1046            predicates.iter().any(|p| {
1047                p.column == col
1048                    && std::mem::discriminant(&p.operation) == std::mem::discriminant(want)
1049            })
1050        };
1051
1052        assert!(
1053            has("pk", &SSTableFilterOp::Equal),
1054            "partition equality must be pushed; got {predicates:?}"
1055        );
1056        assert!(
1057            has("ck", &SSTableFilterOp::Gte),
1058            "Issue #788: `ck >= 0` must be pushed as Gte (was dropped); got {predicates:?}"
1059        );
1060        assert!(
1061            has("ck", &SSTableFilterOp::Lt),
1062            "Issue #788: `ck < 200` must be pushed as Lt (was dropped); got {predicates:?}"
1063        );
1064        assert_eq!(
1065            predicates.len(),
1066            3,
1067            "all three restrictions must be captured; got {predicates:?}"
1068        );
1069    }
1070
1071    /// Issue #955: `WHERE pk IN (1, 2, 3)` parses end-to-end and pushes down a
1072    /// single `In` predicate carrying all three literal values, so the executor
1073    /// can fan it out to targeted lookups.
1074    #[test]
1075    fn query_plan_carries_in_predicate() {
1076        use crate::query::select_parser::parse_select;
1077
1078        let statement =
1079            parse_select("SELECT * FROM ks.t WHERE pk IN (1, 2, 3)").expect("IN query must parse");
1080        let where_clause = statement.where_clause.expect("WHERE present");
1081        let predicates = collect_sstable_predicates(&where_clause).expect("planning must succeed");
1082
1083        assert_eq!(predicates.len(), 1, "one IN predicate; got {predicates:?}");
1084        let p = &predicates[0];
1085        assert_eq!(p.column, "pk");
1086        assert!(matches!(p.operation, SSTableFilterOp::In));
1087        assert!(!p.is_token());
1088        assert_eq!(
1089            p.values,
1090            vec![Value::BigInt(1), Value::BigInt(2), Value::BigInt(3)]
1091        );
1092    }
1093
1094    /// Issue #955: a `token(pk)` range restriction parses to typed token
1095    /// predicates (NOT bare-column predicates), carrying i64 bounds — including
1096    /// a negative lower bound (tokens span the full i64 range).
1097    #[test]
1098    fn query_plan_carries_token_range_predicate() {
1099        use crate::query::select_parser::parse_select;
1100
1101        let statement =
1102            parse_select("SELECT * FROM ks.t WHERE token(pk) >= -100 AND token(pk) < 5000")
1103                .expect("token-range query must parse");
1104        let where_clause = statement.where_clause.expect("WHERE present");
1105        let predicates = collect_sstable_predicates(&where_clause).expect("planning must succeed");
1106
1107        assert_eq!(predicates.len(), 2, "two token bounds; got {predicates:?}");
1108        assert!(
1109            predicates.iter().all(|p| p.is_token()),
1110            "both predicates must be token predicates; got {predicates:?}"
1111        );
1112        let lower = predicates
1113            .iter()
1114            .find(|p| matches!(p.operation, SSTableFilterOp::Gte))
1115            .expect("a Gte token bound");
1116        assert_eq!(
1117            lower.token_columns.as_deref(),
1118            Some(["pk".to_string()].as_slice())
1119        );
1120        assert_eq!(lower.values, vec![Value::BigInt(-100)]);
1121        let upper = predicates
1122            .iter()
1123            .find(|p| matches!(p.operation, SSTableFilterOp::Lt))
1124            .expect("a Lt token bound");
1125        assert_eq!(upper.values, vec![Value::BigInt(5000)]);
1126    }
1127
1128    /// FINDING 1 (Issue #955 follow-up): `token(pk) = <t>` must lower to a token
1129    /// `Equal` predicate — NOT be dropped. Previously it fell through to `None`,
1130    /// so when combined with another pushed predicate the residual Filter step
1131    /// was skipped and the exact-token restriction was silently ignored.
1132    #[test]
1133    fn token_equal_lowers_to_token_equal_predicate() {
1134        use crate::query::select_parser::parse_select;
1135
1136        let statement = parse_select("SELECT * FROM ks.t WHERE token(pk) = 4242")
1137            .expect("token-equal query must parse");
1138        let where_clause = statement.where_clause.expect("WHERE present");
1139        let predicates = collect_sstable_predicates(&where_clause).expect("planning must succeed");
1140
1141        assert_eq!(
1142            predicates.len(),
1143            1,
1144            "token(pk) = ? must produce exactly one predicate (not be dropped); got {predicates:?}"
1145        );
1146        let p = &predicates[0];
1147        assert!(p.is_token(), "must be a token predicate; got {p:?}");
1148        assert!(
1149            matches!(p.operation, SSTableFilterOp::Equal),
1150            "token(pk) = ? must lower to a token Equal op; got {:?}",
1151            p.operation
1152        );
1153        assert_eq!(
1154            p.token_columns.as_deref(),
1155            Some(["pk".to_string()].as_slice())
1156        );
1157        assert_eq!(p.values, vec![Value::BigInt(4242)]);
1158    }
1159
1160    /// FINDING 1: when `token(pk) = ?` is combined with another pushed predicate,
1161    /// BOTH must survive `collect_sstable_predicates`. The original bug dropped
1162    /// the token equality, and (because at least one predicate remained) the
1163    /// optimizer skipped the residual Filter, silently ignoring the token bound.
1164    #[test]
1165    fn token_equal_combined_with_other_predicate_keeps_both() {
1166        use crate::query::select_parser::parse_select;
1167
1168        let statement = parse_select("SELECT * FROM ks.t WHERE token(pk) = 7 AND ck > 0")
1169            .expect("combined token-equal query must parse");
1170        let where_clause = statement.where_clause.expect("WHERE present");
1171        let predicates = collect_sstable_predicates(&where_clause).expect("planning must succeed");
1172
1173        assert!(
1174            predicates
1175                .iter()
1176                .any(|p| p.is_token() && matches!(p.operation, SSTableFilterOp::Equal)),
1177            "the token(pk) = 7 restriction must be pushed as a token Equal; got {predicates:?}"
1178        );
1179        assert!(
1180            predicates.iter().any(|p| !p.is_token() && p.column == "ck"),
1181            "the ck > 0 restriction must also be pushed; got {predicates:?}"
1182        );
1183    }
1184
1185    /// roborev FINDING 2: `token(pk) IN (...)` is not a valid token restriction.
1186    /// It must surface a PLANNING ERROR rather than fall through to `None` (which
1187    /// would silently drop the restriction). Cassandra rejects `token() IN`.
1188    #[test]
1189    fn token_in_is_a_planning_error() {
1190        use crate::query::select_parser::parse_select;
1191
1192        let statement = parse_select("SELECT * FROM ks.t WHERE token(pk) IN (1, 2, 3)")
1193            .expect("token-IN query must parse");
1194        let where_clause = statement.where_clause.expect("WHERE present");
1195        let err = collect_sstable_predicates(&where_clause)
1196            .expect_err("token(pk) IN (...) must be rejected, not silently dropped");
1197        let msg = err.to_string();
1198        assert!(
1199            msg.contains("token()"),
1200            "error must explain the token() restriction; got: {msg}"
1201        );
1202    }
1203
1204    /// roborev FINDING 2: combined with another pushable predicate, an unsupported
1205    /// `token(pk) IN (...) AND ck > 0` previously dropped the token restriction and
1206    /// (because `ck > 0` remained) skipped the residual Filter — silently returning
1207    /// all rows. It must now be a planning error.
1208    #[test]
1209    fn token_in_combined_with_other_predicate_is_a_planning_error() {
1210        use crate::query::select_parser::parse_select;
1211
1212        let statement = parse_select("SELECT * FROM ks.t WHERE token(pk) IN (1, 2) AND ck > 0")
1213            .expect("combined token-IN query must parse");
1214        let where_clause = statement.where_clause.expect("WHERE present");
1215        assert!(
1216            collect_sstable_predicates(&where_clause).is_err(),
1217            "token(pk) IN (...) AND ck > 0 must error, not silently ignore the token restriction"
1218        );
1219    }
1220
1221    /// roborev FINDING 2: `token(pk) BETWEEN a AND b` is not a valid token
1222    /// restriction and must surface a planning error rather than be dropped.
1223    #[test]
1224    fn token_between_is_a_planning_error() {
1225        use crate::query::select_parser::parse_select;
1226
1227        let statement = parse_select("SELECT * FROM ks.t WHERE token(pk) BETWEEN 1 AND 9")
1228            .expect("token-BETWEEN query must parse");
1229        let where_clause = statement.where_clause.expect("WHERE present");
1230        let err = collect_sstable_predicates(&where_clause)
1231            .expect_err("token(pk) BETWEEN must be rejected, not silently dropped");
1232        assert!(
1233            err.to_string().contains("token()"),
1234            "error must explain the token() restriction; got: {err}"
1235        );
1236    }
1237
1238    /// roborev FINDING (whole-tree): an UNSUPPORTED token() form under `NOT`
1239    /// (`ck > 0 AND NOT token(pk) IN (1, 2)`) must be a PLANNING ERROR. The
1240    /// pushdown walk never descends NOT, so previously the token IN restriction
1241    /// was not validated; `ck > 0` was pushed, the residual Filter was omitted,
1242    /// and the invalid token restriction was silently ignored.
1243    #[test]
1244    fn token_in_under_not_is_a_planning_error() {
1245        use crate::query::select_parser::parse_select;
1246
1247        let statement = parse_select("SELECT * FROM ks.t WHERE ck > 0 AND NOT token(pk) IN (1, 2)")
1248            .expect("token-IN-under-NOT query must parse");
1249        let where_clause = statement.where_clause.expect("WHERE present");
1250        let err = validate_token_forms_whole_tree(&where_clause, true)
1251            .expect_err("token(pk) IN under NOT must be rejected, not silently dropped");
1252        assert!(
1253            err.to_string().contains("token()"),
1254            "error must explain the token() restriction; got: {err}"
1255        );
1256    }
1257
1258    /// roborev FINDING (whole-tree): an UNSUPPORTED token() form under `OR`
1259    /// (`token(pk) IN (...) OR ck = 3`) must be a planning error regardless of
1260    /// position.
1261    #[test]
1262    fn token_in_under_or_is_a_planning_error() {
1263        use crate::query::select_parser::parse_select;
1264
1265        let statement = parse_select("SELECT * FROM ks.t WHERE token(pk) IN (1, 2) OR ck = 3")
1266            .expect("token-IN-under-OR query must parse");
1267        let where_clause = statement.where_clause.expect("WHERE present");
1268        assert!(
1269            validate_token_forms_whole_tree(&where_clause, true).is_err(),
1270            "token(pk) IN (...) under OR must error, not silently ignore the token restriction"
1271        );
1272    }
1273
1274    /// roborev FINDING (whole-tree): `token(pk) BETWEEN a AND b` under `OR` must
1275    /// also be rejected wherever it appears.
1276    #[test]
1277    fn token_between_under_or_is_a_planning_error() {
1278        use crate::query::select_parser::parse_select;
1279
1280        let statement =
1281            parse_select("SELECT * FROM ks.t WHERE token(pk) BETWEEN 1 AND 9 OR ck = 3")
1282                .expect("token-BETWEEN-under-OR query must parse");
1283        let where_clause = statement.where_clause.expect("WHERE present");
1284        assert!(
1285            validate_token_forms_whole_tree(&where_clause, true).is_err(),
1286            "token() BETWEEN under OR must error"
1287        );
1288    }
1289
1290    /// roborev FINDING (whole-tree): a SUPPORTED token range under `OR`
1291    /// (`token(pk) > 5 OR ck = 3`) cannot be pushed down, and the row-level WHERE
1292    /// evaluator cannot compute a token, so it must be a CLEAR planning error
1293    /// rather than silently ignored. (Decision: reject; see
1294    /// `validate_token_forms_whole_tree` rationale.)
1295    #[test]
1296    fn supported_token_range_under_or_is_a_planning_error() {
1297        use crate::query::select_parser::parse_select;
1298
1299        let statement = parse_select("SELECT * FROM ks.t WHERE token(pk) > 5 OR ck = 3")
1300            .expect("token-range-under-OR query must parse");
1301        let where_clause = statement.where_clause.expect("WHERE present");
1302        let err = validate_token_forms_whole_tree(&where_clause, true).expect_err(
1303            "a supported token range under OR must error (cannot be pushed nor row-evaluated)",
1304        );
1305        let msg = err.to_string();
1306        assert!(
1307            msg.contains("token()") && msg.contains("OR/NOT"),
1308            "error must explain the OR/NOT limitation; got: {msg}"
1309        );
1310    }
1311
1312    /// roborev FINDING (whole-tree): a SUPPORTED token range under `NOT` must
1313    /// likewise be a clear planning error.
1314    #[test]
1315    fn supported_token_range_under_not_is_a_planning_error() {
1316        use crate::query::select_parser::parse_select;
1317
1318        let statement = parse_select("SELECT * FROM ks.t WHERE ck = 3 AND NOT token(pk) > 5")
1319            .expect("token-range-under-NOT query must parse");
1320        let where_clause = statement.where_clause.expect("WHERE present");
1321        assert!(
1322            validate_token_forms_whole_tree(&where_clause, true).is_err(),
1323            "a supported token range under NOT must error"
1324        );
1325    }
1326
1327    /// roborev FINDING (whole-tree) guard: top-level / AND-conjoined SUPPORTED
1328    /// token forms must STILL pass the whole-tree validation (they are pushed
1329    /// down by `collect_sstable_predicates`), so the pass must not over-reject.
1330    #[test]
1331    fn supported_token_forms_pass_whole_tree_validation() {
1332        use crate::query::select_parser::parse_select;
1333
1334        for q in [
1335            "SELECT * FROM ks.t WHERE token(pk) > 0",
1336            "SELECT * FROM ks.t WHERE token(pk) >= -100 AND token(pk) < 5000",
1337            "SELECT * FROM ks.t WHERE token(pk) = 4242",
1338            "SELECT * FROM ks.t WHERE token(pk) = 7 AND ck > 0",
1339            // Nested AND inside parentheses stays pushable.
1340            "SELECT * FROM ks.t WHERE (token(pk) > 0 AND ck > 1)",
1341        ] {
1342            let statement = parse_select(q).unwrap_or_else(|e| panic!("{q} must parse: {e}"));
1343            let where_clause = statement.where_clause.expect("WHERE present");
1344            validate_token_forms_whole_tree(&where_clause, true)
1345                .unwrap_or_else(|e| panic!("{q} must pass whole-tree validation: {e}"));
1346        }
1347    }
1348
1349    /// roborev FINDING 2 guard: supported token range/equality restrictions must
1350    /// STILL plan successfully after making unsupported forms an error.
1351    #[test]
1352    fn token_range_and_equality_still_plan() {
1353        use crate::query::select_parser::parse_select;
1354
1355        for q in [
1356            "SELECT * FROM ks.t WHERE token(pk) > 0",
1357            "SELECT * FROM ks.t WHERE token(pk) >= -100 AND token(pk) < 5000",
1358            "SELECT * FROM ks.t WHERE token(pk) = 4242",
1359            "SELECT * FROM ks.t WHERE token(pk) = 7 AND ck > 0",
1360        ] {
1361            let statement = parse_select(q).unwrap_or_else(|e| panic!("{q} must parse: {e}"));
1362            let where_clause = statement.where_clause.expect("WHERE present");
1363            let predicates = collect_sstable_predicates(&where_clause)
1364                .unwrap_or_else(|e| panic!("{q} must plan without error: {e}"));
1365            assert!(
1366                predicates.iter().any(|p| p.is_token()),
1367                "{q} must still push a token predicate; got {predicates:?}"
1368            );
1369        }
1370    }
1371}