Skip to main content

cqlite_core/query/
executor.rs

1//! Query executor for CQLite
2//!
3//! This module provides query execution capabilities for CQL queries.
4//! It includes:
5//!
6//! - Query plan execution
7//! - Parallel query processing
8//! - Result set construction
9//! - Index utilization
10
11// CQL (Cassandra Query Language) Reference:
12// https://cassandra.apache.org/doc/latest/cassandra/developing/cql/cql_singlefile.html
13//
14// This implements CQL v3.4.3+ for Apache Cassandra 5.0+
15// CQL is NOT SQL - it's a query language specifically designed for Cassandra's distributed architecture.
16
17use super::{
18    planner::{ExecutionStep, IndexSelection, QueryPlan, StepType},
19    ComparisonOperator, Condition,
20};
21use crate::{
22    schema::SchemaManager, storage::StorageEngine, Config, Error, Result, RowKey, ScanRow, TableId,
23    Value,
24};
25use std::cmp::Ordering;
26use std::collections::HashMap;
27use std::sync::Arc;
28use std::time::Instant;
29
30// Use QueryResult and QueryRow from result module
31pub use super::result::{QueryResult, QueryRow};
32
33/// Bounded buffer (rows) for the streaming table-scan drain (issue #1691).
34///
35/// The producer parks once this many rows are in flight, so live heap during a
36/// `TableScan` stays bounded by `buffer_size` regardless of result size. This
37/// replaces the retired `execute_parallel_table_scan`, whose unbounded crossbeam
38/// channel buffered the entire result set at once and whose N (default 4) workers
39/// each issued the *identical* full `storage.scan` (4 duplicate whole-table passes
40/// for one plan).
41const TABLE_SCAN_STREAM_BUFFER: usize = 1024;
42
43/// Query executor
44#[derive(Debug, Clone)]
45pub struct QueryExecutor {
46    /// Storage engine reference
47    storage: Arc<StorageEngine>,
48    /// Schema manager reference (unused currently but kept for future use)
49    _schema: Arc<SchemaManager>,
50    /// Configuration (kept for future use; surfaced to in-file tests)
51    _config: Config,
52}
53
54impl QueryExecutor {
55    /// Create a new query executor
56    pub fn new(storage: Arc<StorageEngine>, schema: Arc<SchemaManager>, config: &Config) -> Self {
57        Self {
58            storage,
59            _schema: schema,
60            _config: config.clone(),
61        }
62    }
63
64    /// Execute a query plan.
65    ///
66    /// Instrumented as `query.execute` (issue #1035) so that surfaces which reach
67    /// the legacy executor directly — notably prepared/parameterized statements
68    /// that bypass `QueryEngine::execute` — still root a query span tree under
69    /// which the per-branch sub-spans (`query.point_lookup`, `query.table_scan`,
70    /// …) and the read-path spans (issue #1034) nest. When invoked via
71    /// `QueryEngine::execute` this nests under that span. The bounded plan-type
72    /// attribute is recorded; the query text and key values never are.
73    #[tracing::instrument(
74        name = "query.execute",
75        skip_all,
76        fields(cqlite.query.plan_type = tracing::field::Empty),
77    )]
78    pub async fn execute(&self, plan: &QueryPlan) -> Result<QueryResult> {
79        let start_time = Instant::now();
80        tracing::Span::current().record(
81            crate::observability::catalog::attr::PLAN_TYPE,
82            Self::plan_type_label(&plan.plan_type),
83        );
84
85        // Classify the plan once so subsequent dispatch is a single match.
86        let has_insert_step = plan
87            .steps
88            .iter()
89            .any(|step| matches!(step.step_type, StepType::Insert));
90        let is_create_table =
91            plan.steps.is_empty() && plan.table.is_some() && plan.estimated_rows == 0;
92
93        let result = match plan.plan_type {
94            super::planner::PlanType::PointLookup => self.execute_point_lookup(plan).await,
95            super::planner::PlanType::IndexScan => self.execute_index_scan(plan).await,
96            super::planner::PlanType::RangeScan => self.execute_range_scan(plan).await,
97            super::planner::PlanType::TableScan if has_insert_step => {
98                #[cfg(feature = "experimental")]
99                {
100                    self.execute_insert_operation(plan).await
101                }
102                #[cfg(not(feature = "experimental"))]
103                {
104                    Err(Error::UnsupportedFormat(
105                        "INSERT operations require the 'experimental' feature. \
106                         Add 'experimental' to your Cargo.toml features."
107                            .to_string(),
108                    ))
109                }
110            }
111            super::planner::PlanType::TableScan if is_create_table => {
112                self.execute_create_table_operation(plan).await
113            }
114            super::planner::PlanType::TableScan => self.execute_table_scan(plan).await,
115            super::planner::PlanType::Join => self.execute_join(plan).await,
116            super::planner::PlanType::Aggregation => self.execute_aggregation(plan).await,
117            super::planner::PlanType::Subquery => self.execute_subquery(plan).await,
118        };
119
120        let mut query_result = result?;
121        let elapsed_ms = start_time.elapsed().as_millis() as u64;
122
123        query_result.execution_time_ms = elapsed_ms;
124        query_result.metadata.plan_info = Some(super::result::PlanInfo {
125            plan_type: format!("{:?}", plan.plan_type),
126            estimated_cost: plan.estimated_cost,
127            actual_cost: elapsed_ms as f64,
128            // Access path(s) the executor actually consulted (issue #760).
129            indexes_used: Self::indexes_used_for(plan),
130            steps: plan
131                .steps
132                .iter()
133                .map(|s| format!("{:?}", s.step_type))
134                .collect(),
135            parallelization: Self::parallelization_for(plan),
136        });
137        Ok(query_result)
138    }
139
140    // -- helpers ------------------------------------------------------------
141
142    /// Bounded, lower-snake label for a [`super::planner::PlanType`], used as a
143    /// span attribute (issue #1035). The value space is the closed `PlanType`
144    /// taxonomy, so it is safe as a telemetry dimension.
145    fn plan_type_label(plan_type: &super::planner::PlanType) -> &'static str {
146        use super::planner::PlanType;
147        match plan_type {
148            PlanType::TableScan => "table_scan",
149            PlanType::IndexScan => "index_scan",
150            PlanType::PointLookup => "point_lookup",
151            PlanType::RangeScan => "range_scan",
152            PlanType::Join => "join",
153            PlanType::Aggregation => "aggregation",
154            PlanType::Subquery => "subquery",
155        }
156    }
157
158    /// Report the access path(s) the executor *actually consulted* for `plan`,
159    /// for the `indexes_used` field of [`super::result::PlanInfo`] (issue #760,
160    /// Epic #756).
161    ///
162    /// # Truthfulness contract (no-heuristics spirit, issue #28)
163    ///
164    /// This mirrors the dispatch in [`Self::execute`] and reports only what the
165    /// executed code path genuinely does — never what the planner merely
166    /// preferred. The storage layer (`StorageEngine::get` / `scan`) does not yet
167    /// surface *which* on-disk structure (Index.db partition lookup, Summary.db
168    /// sampling, or BTI trie) resolved a partition, so we cannot distinguish
169    /// those sub-paths. We therefore report at the granularity we can prove:
170    ///
171    /// - **Point lookup** (`PointLookup`, and `IndexScan` over a `Primary` or
172    ///   `BloomFilter` index) calls `StorageEngine::get`, which resolves the
173    ///   partition through the partition index. We report the selected index's
174    ///   name (e.g. `"PRIMARY"`).
175    /// - **Sequential scan** (`TableScan`, `RangeScan`, and `IndexScan` over a
176    ///   `Secondary`/`Composite` index — these currently degrade to a full scan
177    ///   in the executor) reports the explicit marker `"scan"`.
178    ///
179    /// ## Scan-marker decision
180    ///
181    /// The issue allows either an empty list or an explicit marker for a full
182    /// scan; we pick the explicit **`"scan"`** marker. An empty list is
183    /// ambiguous (it cannot be told apart from "not yet recorded"), whereas an
184    /// explicit marker makes EXPLAIN-style output and bindings stats truthful
185    /// and self-describing.
186    /// Report the parallelization metadata for the *actually executed* path, for
187    /// the `parallelization` field of [`super::result::PlanInfo`].
188    ///
189    /// # Truthfulness contract (no-heuristics spirit, issue #28; issue #1691)
190    ///
191    /// A step's `parallelization.can_parallelize` reflects only what the *planner*
192    /// suggested, not what the executor did. Since issue #1691 the `TableScan`
193    /// path is served by a SINGLE bounded `scan_stream` pass
194    /// (`streaming_scan_rows`) rather than N parallel workers, so for that path we
195    /// report the truth: one thread, `effective: false`. Reporting the planner's
196    /// suggested thread count with `effective: true` here would be inaccurate.
197    ///
198    /// For any other plan type we still surface the planner's suggested thread
199    /// count with `effective: true` when a step opts into parallelization.
200    fn parallelization_for(plan: &QueryPlan) -> Option<super::result::ParallelizationInfo> {
201        use super::planner::PlanType;
202
203        let step = plan
204            .steps
205            .iter()
206            .find(|s| s.parallelization.can_parallelize)?;
207
208        // The table-scan path no longer forks parallel workers; it executes a
209        // single bounded streaming pass. Report that truthfully.
210        if matches!(plan.plan_type, PlanType::TableScan) {
211            return Some(super::result::ParallelizationInfo {
212                threads_used: 1,
213                effective: false,
214                partitions: Vec::new(),
215            });
216        }
217
218        Some(super::result::ParallelizationInfo {
219            threads_used: step.parallelization.suggested_threads,
220            effective: true,
221            partitions: Vec::new(),
222        })
223    }
224
225    fn indexes_used_for(plan: &QueryPlan) -> Vec<String> {
226        use super::planner::{IndexType, PlanType, StepType};
227
228        // The marker used for any path that walks rows sequentially.
229        let scan = || vec!["scan".to_string()];
230
231        // A TableScan plan can actually be an INSERT or a CREATE TABLE; those
232        // are dispatched away from `execute_table_scan` in `execute()` and never
233        // call `storage.scan`, so they have no access path to report (roborev
234        // job 40). Mirror that classification here.
235        let has_insert_step = plan
236            .steps
237            .iter()
238            .any(|step| matches!(step.step_type, StepType::Insert));
239        let is_create_table =
240            plan.steps.is_empty() && plan.table.is_some() && plan.estimated_rows == 0;
241        if matches!(plan.plan_type, PlanType::TableScan) && (has_insert_step || is_create_table) {
242            return Vec::new();
243        }
244
245        match plan.plan_type {
246            // Resolves a single partition via `StorageEngine::get`.
247            PlanType::PointLookup => match plan.selected_indexes.first() {
248                Some(idx) => vec![idx.index_name.clone()],
249                // No selected index recorded but we still did a partition
250                // lookup — report the generic primary-key path.
251                None => vec!["PRIMARY".to_string()],
252            },
253            // IndexScan dispatch depends on the index type: Primary/Bloom do a
254            // real point lookup; Secondary/Composite degrade to a full scan.
255            PlanType::IndexScan => match plan.selected_indexes.first() {
256                Some(idx) => match idx.index_type {
257                    IndexType::Primary | IndexType::BloomFilter => {
258                        vec![idx.index_name.clone()]
259                    }
260                    IndexType::Secondary | IndexType::Composite => scan(),
261                },
262                None => scan(),
263            },
264            // Sequential-scan paths.
265            PlanType::TableScan | PlanType::RangeScan => scan(),
266            // Placeholder executors return empty results without touching any
267            // index structure; report nothing rather than fabricate a path.
268            PlanType::Join | PlanType::Aggregation | PlanType::Subquery => Vec::new(),
269        }
270    }
271
272    /// Resolve `plan.table` or surface a uniform query-execution error.
273    fn require_table<'a>(&self, plan: &'a QueryPlan) -> Result<&'a TableId> {
274        plan.table
275            .as_ref()
276            .ok_or_else(|| Error::query_execution("Missing table in plan"))
277    }
278
279    /// Find the first condition matching `column` across all steps.
280    fn find_condition<'a>(steps: &'a [ExecutionStep], column: &str) -> Option<&'a Condition> {
281        steps
282            .iter()
283            .flat_map(|s| s.conditions.iter())
284            .find(|c| c.column == column)
285    }
286
287    /// Convert a `(key, data)` pair from `StorageEngine::scan` into rows.
288    fn scan_pairs_to_rows(&self, pairs: Vec<(RowKey, ScanRow)>) -> Result<Vec<QueryRow>> {
289        let mut rows = Vec::with_capacity(pairs.len());
290        for (row_key, row_data) in pairs {
291            rows.push(self.storage_data_to_query_row(row_data, &row_key)?);
292        }
293        Ok(rows)
294    }
295
296    /// Run a full table scan and materialize results.
297    async fn full_scan_rows(&self, table: &TableId) -> Result<Vec<QueryRow>> {
298        let scan_results = self.storage.scan(table, None, None, None, None).await?;
299        self.scan_pairs_to_rows(scan_results)
300    }
301
302    /// Look up a single row by the key derived from `condition`.
303    async fn point_lookup_rows(
304        &self,
305        table: &TableId,
306        condition: &Condition,
307    ) -> Result<Vec<QueryRow>> {
308        let row_key = self.condition_to_row_key(condition)?;
309        match self.storage.get(table, &row_key).await? {
310            Some(row_data) => Ok(vec![self.storage_data_to_query_row(row_data, &row_key)?]),
311            None => Ok(Vec::new()),
312        }
313    }
314
315    /// Wrap a row collection in a `QueryResult`. `execution_time_ms` is set by `execute()`.
316    fn make_result(rows: Vec<QueryRow>) -> QueryResult {
317        QueryResult::with_rows(rows)
318    }
319
320    // -- plan executors -----------------------------------------------------
321
322    /// Execute point lookup plan
323    #[tracing::instrument(name = "query.point_lookup", skip_all)]
324    async fn execute_point_lookup(&self, plan: &QueryPlan) -> Result<QueryResult> {
325        let table = self.require_table(plan)?;
326
327        // Find the lookup condition (first condition of the first step that has any).
328        let lookup_condition = plan
329            .steps
330            .iter()
331            .find_map(|step| step.conditions.first())
332            .ok_or_else(|| Error::query_execution("No lookup condition found"))?;
333
334        let row_key = self.condition_to_row_key(lookup_condition)?;
335
336        let mut rows = Vec::new();
337        if let Some(row_data) = self.storage.get(table, &row_key).await? {
338            rows.push(self.storage_data_to_query_row(row_data, &row_key)?);
339        }
340
341        Ok(Self::make_result(rows))
342    }
343
344    /// Execute index scan plan
345    #[tracing::instrument(name = "query.index_scan", skip_all)]
346    async fn execute_index_scan(&self, plan: &QueryPlan) -> Result<QueryResult> {
347        let table = self.require_table(plan)?;
348
349        let index_selection = plan
350            .selected_indexes
351            .first()
352            .ok_or_else(|| Error::query_execution("No index selected"))?;
353
354        let mut rows = match index_selection.index_type {
355            super::planner::IndexType::Secondary => {
356                self.execute_secondary_index_scan(table, index_selection, &plan.steps)
357                    .await?
358            }
359            super::planner::IndexType::BloomFilter => {
360                self.execute_bloom_filter_scan(table, index_selection, &plan.steps)
361                    .await?
362            }
363            super::planner::IndexType::Primary => {
364                self.execute_primary_index_scan(table, index_selection, &plan.steps)
365                    .await?
366            }
367            super::planner::IndexType::Composite => {
368                self.execute_composite_index_scan(table, index_selection, &plan.steps)
369                    .await?
370            }
371        };
372
373        rows = self.apply_execution_steps(rows, &plan.steps).await?;
374        Ok(Self::make_result(rows))
375    }
376
377    /// Execute range scan plan
378    #[tracing::instrument(name = "query.range_scan", skip_all)]
379    async fn execute_range_scan(&self, plan: &QueryPlan) -> Result<QueryResult> {
380        let table = self.require_table(plan)?;
381
382        // Range conditions are recognized by the planner; the storage engine is
383        // queried with no explicit bounds for now.
384        let mut rows = self.full_scan_rows(table).await?;
385        rows = self.apply_execution_steps(rows, &plan.steps).await?;
386        Ok(Self::make_result(rows))
387    }
388
389    /// Execute table scan plan
390    #[tracing::instrument(name = "query.table_scan", skip_all)]
391    async fn execute_table_scan(&self, plan: &QueryPlan) -> Result<QueryResult> {
392        let table = self.require_table(plan)?;
393
394        #[cfg(debug_assertions)]
395        tracing::debug!("executor: Scanning for table: {:?}", table.name());
396
397        // Issue #1691: a plan that requested parallelization is served by the
398        // SAME bounded streaming scan the SelectExecutor uses (one whole-table
399        // pass, bounded mpsc, `spawn_blocking` discipline inside `scan_stream`),
400        // NOT by the retired multi-worker duplicate-scan path.
401        let can_parallelize = plan
402            .steps
403            .iter()
404            .any(|step| step.parallelization.can_parallelize);
405
406        let mut rows = if can_parallelize {
407            self.streaming_scan_rows(table).await?
408        } else {
409            self.full_scan_rows(table).await?
410        };
411
412        rows = self.apply_execution_steps(rows, &plan.steps).await?;
413        Ok(Self::make_result(rows))
414    }
415
416    /// Execute join plan (placeholder)
417    async fn execute_join(&self, _plan: &QueryPlan) -> Result<QueryResult> {
418        Ok(QueryResult::new())
419    }
420
421    /// Execute aggregation plan (placeholder)
422    async fn execute_aggregation(&self, _plan: &QueryPlan) -> Result<QueryResult> {
423        Ok(QueryResult::new())
424    }
425
426    /// Execute subquery plan (placeholder)
427    async fn execute_subquery(&self, _plan: &QueryPlan) -> Result<QueryResult> {
428        Ok(QueryResult::new())
429    }
430
431    // -- index scans --------------------------------------------------------
432
433    /// Execute secondary index scan (currently a full scan; secondary index
434    /// support is tracked separately).
435    async fn execute_secondary_index_scan(
436        &self,
437        table: &TableId,
438        index_selection: &IndexSelection,
439        steps: &[ExecutionStep],
440    ) -> Result<Vec<QueryRow>> {
441        // Validate the index condition exists; the lookup itself is not yet wired up.
442        Self::find_condition(steps, &index_selection.columns[0])
443            .ok_or_else(|| Error::query_execution("No condition found for index"))?;
444        self.full_scan_rows(table).await
445    }
446
447    /// Execute bloom filter scan (degrades to a direct point lookup).
448    async fn execute_bloom_filter_scan(
449        &self,
450        table: &TableId,
451        index_selection: &IndexSelection,
452        steps: &[ExecutionStep],
453    ) -> Result<Vec<QueryRow>> {
454        let condition = Self::find_condition(steps, &index_selection.columns[0])
455            .ok_or_else(|| Error::query_execution("No condition found for bloom filter"))?;
456        self.point_lookup_rows(table, condition).await
457    }
458
459    /// Execute primary index scan (point lookup on the primary key).
460    async fn execute_primary_index_scan(
461        &self,
462        table: &TableId,
463        index_selection: &IndexSelection,
464        steps: &[ExecutionStep],
465    ) -> Result<Vec<QueryRow>> {
466        let condition = Self::find_condition(steps, &index_selection.columns[0])
467            .ok_or_else(|| Error::query_execution("No condition found for primary key"))?;
468        self.point_lookup_rows(table, condition).await
469    }
470
471    /// Execute composite index scan (currently a full scan; composite lookups
472    /// are tracked separately).
473    async fn execute_composite_index_scan(
474        &self,
475        table: &TableId,
476        _index_selection: &IndexSelection,
477        _steps: &[ExecutionStep],
478    ) -> Result<Vec<QueryRow>> {
479        self.full_scan_rows(table).await
480    }
481
482    // -- table scans --------------------------------------------------------
483
484    /// Stream a full table scan through the bounded streaming path and
485    /// materialize results (issue #1691).
486    ///
487    /// This is the retirement of `execute_parallel_table_scan`. It issues a
488    /// SINGLE whole-table pass via [`StorageEngine::scan_stream`] — the same
489    /// bounded-`mpsc`, `spawn_blocking` machinery the `SelectExecutor` streaming
490    /// path uses (issue #790) — instead of spawning N workers that each re-ran
491    /// the identical `storage.scan`. Live heap during production stays bounded by
492    /// [`TABLE_SCAN_STREAM_BUFFER`] rows: the reader parses one entry at a time
493    /// into the channel and parks when the consumer falls behind, replacing the
494    /// old unbounded `crossbeam` channel that held the entire result set at once.
495    #[tracing::instrument(name = "query.table_scan_stream", skip_all)]
496    async fn streaming_scan_rows(&self, table: &TableId) -> Result<Vec<QueryRow>> {
497        // Issue #1592: consume the BATCHED streaming surface so the reader wakes
498        // this task once per batch of rows, not once per row (the F2 win). Each
499        // channel item is a `Vec` of entries; flattening it yields the same rows
500        // in the same order as the per-row `scan_stream`.
501        let mut scan_stream = self
502            .storage
503            .scan_stream_batched(table, None, None, None, TABLE_SCAN_STREAM_BUFFER)
504            .await?;
505
506        let mut rows = Vec::new();
507        while let Some(batch) = scan_stream.recv().await {
508            for (row_key, row_data) in batch? {
509                rows.push(self.storage_data_to_query_row(row_data, &row_key)?);
510            }
511        }
512        Ok(rows)
513    }
514
515    // -- execution-step pipeline -------------------------------------------
516
517    /// Apply execution steps to result rows.
518    ///
519    /// Limit/Aggregate/Join/Insert/Scan are no-ops at this layer (handled
520    /// elsewhere or not yet implemented); only Filter/Sort/Project transform
521    /// the row stream.
522    async fn apply_execution_steps(
523        &self,
524        mut rows: Vec<QueryRow>,
525        steps: &[ExecutionStep],
526    ) -> Result<Vec<QueryRow>> {
527        for step in steps {
528            match step.step_type {
529                StepType::Filter => rows = self.apply_filter_step(rows, step)?,
530                StepType::Sort => rows = self.apply_sort_step(rows, step),
531                StepType::Project => rows = self.apply_project_step(rows, step),
532                // Limit is enforced higher up; the rest are placeholders.
533                StepType::Limit
534                | StepType::Aggregate
535                | StepType::Join
536                | StepType::Scan
537                | StepType::Insert => {}
538            }
539        }
540        Ok(rows)
541    }
542
543    /// Apply filter step
544    fn apply_filter_step(
545        &self,
546        rows: Vec<QueryRow>,
547        step: &ExecutionStep,
548    ) -> Result<Vec<QueryRow>> {
549        let mut filtered_rows = Vec::with_capacity(rows.len());
550        for row in rows {
551            let mut matches = true;
552            for condition in &step.conditions {
553                if !self.evaluate_condition(&row, condition)? {
554                    matches = false;
555                    break;
556                }
557            }
558            if matches {
559                filtered_rows.push(row);
560            }
561        }
562        Ok(filtered_rows)
563    }
564
565    /// Apply sort step
566    fn apply_sort_step(&self, mut rows: Vec<QueryRow>, step: &ExecutionStep) -> Vec<QueryRow> {
567        let Some(sort_column) = step.columns.first() else {
568            return rows;
569        };
570
571        rows.sort_by(|a, b| {
572            let a_val = a.values.get(sort_column.as_str()).unwrap_or(&Value::Null);
573            let b_val = b.values.get(sort_column.as_str()).unwrap_or(&Value::Null);
574            self.compare_values(a_val, b_val).unwrap_or(Ordering::Equal)
575        });
576        rows
577    }
578
579    /// Apply project step
580    fn apply_project_step(&self, rows: Vec<QueryRow>, step: &ExecutionStep) -> Vec<QueryRow> {
581        rows.into_iter()
582            .map(|row| {
583                let mut projected_values = HashMap::with_capacity(step.columns.len());
584                for column in &step.columns {
585                    if let Some(value) = row.values.get(column.as_str()) {
586                        projected_values.insert(column.clone(), value.clone());
587                    }
588                }
589                QueryRow::with_values(row.key, projected_values)
590            })
591            .collect()
592    }
593
594    // -- condition / value helpers -----------------------------------------
595
596    /// Evaluate a condition against a row
597    fn evaluate_condition(&self, row: &QueryRow, condition: &Condition) -> Result<bool> {
598        let row_value = row
599            .values
600            .get(condition.column.as_str())
601            .unwrap_or(&Value::Null);
602
603        match condition.operator {
604            ComparisonOperator::Equal => Ok(row_value == &condition.value),
605            ComparisonOperator::NotEqual => Ok(row_value != &condition.value),
606            ComparisonOperator::LessThan => Ok(self
607                .predicate_ordering(row_value, &condition.value)?
608                .is_some_and(|o| o == Ordering::Less)),
609            ComparisonOperator::LessThanOrEqual => Ok(self
610                .predicate_ordering(row_value, &condition.value)?
611                .is_some_and(|o| matches!(o, Ordering::Less | Ordering::Equal))),
612            ComparisonOperator::GreaterThan => Ok(self
613                .predicate_ordering(row_value, &condition.value)?
614                .is_some_and(|o| o == Ordering::Greater)),
615            ComparisonOperator::GreaterThanOrEqual => Ok(self
616                .predicate_ordering(row_value, &condition.value)?
617                .is_some_and(|o| matches!(o, Ordering::Greater | Ordering::Equal))),
618            // Simplified IN / NOT IN: treat as equality / inequality for now.
619            ComparisonOperator::In => Ok(row_value == &condition.value),
620            ComparisonOperator::NotIn => Ok(row_value != &condition.value),
621            ComparisonOperator::Like => match (row_value, &condition.value) {
622                (Value::Text(row_text), Value::Text(pattern)) => Ok(row_text.contains(pattern)),
623                _ => Ok(false),
624            },
625            ComparisonOperator::NotLike => match (row_value, &condition.value) {
626                (Value::Text(row_text), Value::Text(pattern)) => Ok(!row_text.contains(pattern)),
627                _ => Ok(true),
628            },
629        }
630    }
631
632    /// Ordering for WHERE-predicate inequalities (`<`, `<=`, `>`, `>=`), with
633    /// SQL three-valued logic for IEEE NaN (issue #2257 — the legacy-executor
634    /// sibling of the Trino-pushdown fix #2231).
635    ///
636    /// Returns `Ok(None)` — SQL UNKNOWN, so the caller DROPS the row — when
637    /// either operand is a NaN float (issue #2257, the legacy-executor sibling
638    /// of the Trino-pushdown fix #2231). Otherwise delegates to
639    /// [`Self::compare_values`] UNCHANGED.
640    ///
641    /// Deliberately does NOT route through `value_ops::try_compare_values_predicate`
642    /// for the non-NaN case: that shared helper also does cross-numeric
643    /// coercion (`Integer` vs `BigInt`/`Float` via `as_f64`, exact `i128` for
644    /// integrals) that this legacy path has never had — `compare_values` only
645    /// matches identical variants and `Err`s on any cross-numeric pair. Since
646    /// WHERE-clause literals commonly parse as `Value::Integer` regardless of
647    /// the column's actual CQL type, falling through to that coercion would
648    /// silently turn a previously-hard-`Err` query into a successful (and
649    /// unvalidated against real Cassandra semantics) comparison — a functional
650    /// widening this issue does not ask for. The fix is scoped to EXACTLY the
651    /// NaN-predicate leak: same-type/incomparable-type error semantics and
652    /// `Null`-vs-value ordering are untouched.
653    ///
654    /// This is for filter/`WHERE` evaluation ONLY. ORDER BY / sort
655    /// ([`Self::apply_sort_step`]) still uses [`Self::compare_values`] directly,
656    /// keeping the Cassandra NaN-greatest total order unchanged.
657    fn predicate_ordering(&self, a: &Value, b: &Value) -> Result<Option<Ordering>> {
658        use crate::query::select_executor::value_ops::is_nan_value;
659        if is_nan_value(a) || is_nan_value(b) {
660            return Ok(None);
661        }
662        self.compare_values(a, b).map(Some)
663    }
664
665    /// Compare two values
666    fn compare_values(&self, a: &Value, b: &Value) -> Result<Ordering> {
667        use crate::float_cmp::cassandra_double_cmp as dcmp;
668        use crate::float_cmp::cassandra_float_cmp as fcmp;
669        match (a, b) {
670            (Value::Integer(a), Value::Integer(b)) => Ok(a.cmp(b)),
671            (Value::Float(a), Value::Float(b)) => Ok(dcmp(*a, *b)), // Cassandra order #1870/#2010
672            (Value::Float32(a), Value::Float32(b)) => Ok(fcmp(*a, *b)), // Cassandra order #1870/#2010
673            (Value::Text(a), Value::Text(b)) => Ok(a.cmp(b)),
674            (Value::Boolean(a), Value::Boolean(b)) => Ok(a.cmp(b)),
675            // UUID/TIMEUUID (both Value::Uuid): byte-wise, as Cassandra orders.
676            (Value::Uuid(a), Value::Uuid(b)) => Ok(a.cmp(b)),
677            (Value::Null, Value::Null) => Ok(Ordering::Equal),
678            (Value::Null, _) => Ok(Ordering::Less),
679            (_, Value::Null) => Ok(Ordering::Greater),
680            _ => Err(Error::query_execution(
681                "Cannot compare values of different types",
682            )),
683        }
684    }
685
686    /// Convert a [`Value`] to the raw partition-key bytes used by [`RowKey`] and
687    /// the Index.db lookup table.
688    ///
689    /// The encoding follows the same contract as
690    /// [`PartitionKey::to_bytes`](crate::storage::write_engine::mutation::PartitionKey::to_bytes):
691    ///
692    /// - **Single-component keys** — raw value bytes (UUID = 16 bytes, Int = 4 BE
693    ///   bytes, Text = UTF-8, BigInt = 8 BE bytes, …).
694    /// - **Multi-component (composite) keys** — `[len: u16 BE][value bytes][0x00]`
695    ///   per component, including a trailing `0x00` after the final component.
696    ///   Pass a `Value::Tuple` whose elements are the ordered PK components.
697    fn value_to_row_key(&self, value: &Value) -> Result<RowKey> {
698        match value {
699            Value::Integer(i) => Ok(RowKey::new(i.to_be_bytes().to_vec())),
700            Value::Text(s) => Ok(RowKey::new(s.as_bytes().to_vec())),
701            Value::Float(f) => Ok(RowKey::new(f.to_be_bytes().to_vec())),
702            Value::Boolean(b) => Ok(RowKey::new(vec![u8::from(*b)])),
703            Value::Null => Ok(RowKey::new(vec![0])),
704            // UUID and TIMEUUID are both stored as 16 raw bytes (no framing).
705            // This matches PartitionKey::to_bytes single-component output for a UUID column.
706            Value::Uuid(bytes) => Ok(RowKey::new(bytes.to_vec())),
707            Value::BigInt(i) => Ok(RowKey::new(i.to_be_bytes().to_vec())),
708            // Multi-component (composite) partition key passed as a Tuple.
709            // Encoding: [len: u16 BE][value bytes][0x00] per component, identical to
710            // PartitionKey::to_bytes multi-component output (see mutation.rs ~line 256).
711            Value::Tuple(components) => {
712                let mut result = Vec::new();
713                for component in components {
714                    let raw = self.value_to_raw_pk_bytes(component)?;
715                    let len = raw.len();
716                    if len > u16::MAX as usize {
717                        return Err(Error::query_execution(
718                            "Composite partition key component too large",
719                        ));
720                    }
721                    result.extend_from_slice(&(len as u16).to_be_bytes());
722                    result.extend_from_slice(&raw);
723                    result.push(0x00);
724                }
725                Ok(RowKey::new(result))
726            }
727            _ => Err(Error::query_execution("Cannot convert value to row key")),
728        }
729    }
730
731    /// Serialize a single value to raw bytes suitable for inclusion in a
732    /// composite partition key component. Used by [`value_to_row_key`] for
733    /// `Value::Tuple` components.
734    fn value_to_raw_pk_bytes(&self, value: &Value) -> Result<Vec<u8>> {
735        match value {
736            Value::Integer(i) => Ok(i.to_be_bytes().to_vec()),
737            Value::Text(s) => Ok(s.as_bytes().to_vec()),
738            Value::Float(f) => Ok(f.to_be_bytes().to_vec()),
739            Value::Boolean(b) => Ok(vec![u8::from(*b)]),
740            Value::Null => Ok(Vec::new()),
741            Value::Uuid(bytes) => Ok(bytes.to_vec()),
742            Value::BigInt(i) => Ok(i.to_be_bytes().to_vec()),
743            _ => Err(Error::query_execution(
744                "Cannot serialize value as partition key component",
745            )),
746        }
747    }
748
749    /// Convert Condition to RowKey (consistent with INSERT)
750    fn condition_to_row_key(&self, condition: &Condition) -> Result<RowKey> {
751        // Match the key format used by INSERT for "id" columns.
752        if condition.column == "id" {
753            if let Value::Integer(id) = &condition.value {
754                return Ok(RowKey::new(format!("user_key_{}", id).into_bytes()));
755            }
756        }
757        self.value_to_row_key(&condition.value)
758    }
759
760    /// Convert storage data to query row
761    fn storage_data_to_query_row(&self, data: ScanRow, key: &RowKey) -> Result<QueryRow> {
762        use std::sync::Arc;
763        let mut values: HashMap<Arc<str>, Value> = HashMap::new();
764
765        // Storage path carries rows via the `ScanRow` carrier (issue #1334).
766        // * `Row` — decoded cells keyed by the interned `Arc<str>` column-name
767        //   handle; move the handle straight in (no `String` re-allocation).
768        // * `RawRow` — a raw undecoded fallback with no schema here; surface the
769        //   bytes as a single "data" blob, the exact pre-#1334 `other =>
770        //   insert("data", ..)` shape.
771        // * `Marker` (row tombstone / null) carries no columns.
772        match data {
773            ScanRow::Row(cells) => {
774                for (name, cell_value) in cells {
775                    values.insert(name, cell_value);
776                }
777            }
778            ScanRow::RawRow(bytes) => {
779                values.insert(Arc::from("data"), Value::Blob(bytes));
780            }
781            ScanRow::Marker(_) => {}
782        }
783
784        // If no values were extracted, surface the row key for visibility.
785        if values.is_empty() {
786            values.insert(Arc::from("id"), Value::Text(format!("{:?}", key)));
787        }
788
789        Ok(QueryRow::with_interned_values(key.clone(), values))
790    }
791
792    // -- experimental write paths ------------------------------------------
793
794    /// Execute INSERT operation
795    #[cfg(feature = "experimental")]
796    async fn execute_insert_operation(&self, plan: &QueryPlan) -> Result<QueryResult> {
797        let table_id = self
798            .require_table(plan)
799            .map_err(|_| Error::query_execution("No table specified in INSERT plan"))?;
800
801        let mut inserted_count: u64 = 0;
802
803        for step in &plan.steps {
804            if !matches!(step.step_type, StepType::Insert) {
805                continue;
806            }
807
808            // Default key uses the running insert index; an explicit "id"
809            // condition wins so SELECT and INSERT share the same key shape.
810            let mut key_value = format!("test_key_{}", inserted_count);
811            for condition in &step.conditions {
812                if condition.column == "id" {
813                    if let Value::Integer(id) = &condition.value {
814                        key_value = format!("user_key_{}", id);
815                        break;
816                    }
817                }
818            }
819
820            let row_key = RowKey::new(key_value.into_bytes());
821
822            // Build the row payload from step conditions (or seed defaults
823            // when the step carries none, for test compatibility).
824            let mut value_map: HashMap<String, Value> = step
825                .conditions
826                .iter()
827                .map(|c| (c.column.clone(), c.value.clone()))
828                .collect();
829
830            if value_map.is_empty() {
831                value_map.insert("id".to_string(), Value::Integer(inserted_count as i32 + 1));
832                value_map.insert(
833                    "name".to_string(),
834                    Value::Text(format!("TestUser{}", inserted_count + 1)),
835                );
836            }
837
838            let row_value = map_to_value(value_map);
839
840            self.storage.put(table_id, row_key, row_value).await?;
841            inserted_count += 1;
842        }
843
844        // No explicit INSERT steps — emit a single placeholder row to keep
845        // legacy tests passing.
846        if inserted_count == 0 {
847            let row_key = RowKey::new(b"default_test_key".to_vec());
848            let mut value_map = HashMap::new();
849            value_map.insert("id".to_string(), Value::Integer(1));
850            value_map.insert("name".to_string(), Value::Text("DefaultUser".to_string()));
851
852            self.storage
853                .put(table_id, row_key, map_to_value(value_map))
854                .await?;
855            inserted_count = 1;
856        }
857
858        Ok(QueryResult {
859            rows: vec![],
860            rows_affected: inserted_count,
861            execution_time_ms: 0,
862            metadata: super::result::QueryMetadata::default(),
863        })
864    }
865
866    /// Execute CREATE TABLE operation (placeholder — DDL isn't persisted yet).
867    async fn execute_create_table_operation(&self, _plan: &QueryPlan) -> Result<QueryResult> {
868        Ok(QueryResult {
869            rows: vec![],
870            rows_affected: 0,
871            execution_time_ms: 0,
872            metadata: super::result::QueryMetadata::default(),
873        })
874    }
875}
876
877/// Build a `Value::Map` from a string-keyed map for storage writes.
878#[cfg(feature = "experimental")]
879fn map_to_value(map: HashMap<String, Value>) -> Value {
880    Value::Map(map.into_iter().map(|(k, v)| (Value::Text(k), v)).collect())
881}
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886    use crate::Config;
887    use std::sync::Arc;
888    use tempfile::TempDir;
889
890    /// Construct a fresh executor against a temporary storage root.
891    async fn make_executor() -> (TempDir, QueryExecutor, Config) {
892        let temp_dir = TempDir::new().unwrap();
893        let config = Config::default();
894        let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
895        let storage = Arc::new(
896            crate::storage::StorageEngine::open(
897                temp_dir.path(),
898                &config,
899                platform,
900                #[cfg(feature = "state_machine")]
901                None,
902            )
903            .await
904            .unwrap(),
905        );
906        let schema = Arc::new(
907            crate::schema::SchemaManager::new(temp_dir.path())
908                .await
909                .unwrap(),
910        );
911        let executor = QueryExecutor::new(storage, schema, &config);
912        (temp_dir, executor, config)
913    }
914
915    #[tokio::test]
916    async fn test_query_executor_creation() {
917        let (_tmp, executor, config) = make_executor().await;
918        assert_eq!(
919            executor._config.query.query_parallelism,
920            config.query.query_parallelism
921        );
922    }
923
924    #[tokio::test]
925    async fn test_value_comparison() {
926        let (_tmp, executor, _) = make_executor().await;
927
928        let result = executor
929            .compare_values(&Value::Integer(10), &Value::Integer(20))
930            .unwrap();
931        assert_eq!(result, Ordering::Less);
932
933        let result = executor
934            .compare_values(
935                &Value::Text("apple".to_string()),
936                &Value::Text("banana".to_string()),
937            )
938            .unwrap();
939        assert_eq!(result, Ordering::Less);
940
941        // Issue #1870/#2010: ORDER BY on a float(f32) column must order via the
942        // Cassandra total comparator, not collapse to Equal (missing arm bug).
943        assert_eq!(
944            executor
945                .compare_values(&Value::Float32(1.0), &Value::Float32(2.0))
946                .unwrap(),
947            Ordering::Less
948        );
949        // Signed zeros are distinct; NaN sorts last and equals itself.
950        assert_eq!(
951            executor
952                .compare_values(&Value::Float32(-0.0), &Value::Float32(0.0))
953                .unwrap(),
954            Ordering::Less
955        );
956        assert_eq!(
957            executor
958                .compare_values(&Value::Float32(f32::NAN), &Value::Float32(1.0))
959                .unwrap(),
960            Ordering::Greater
961        );
962        assert_eq!(
963            executor
964                .compare_values(&Value::Float32(f32::NAN), &Value::Float32(f32::NAN))
965                .unwrap(),
966            Ordering::Equal
967        );
968    }
969
970    #[tokio::test]
971    async fn test_condition_evaluation() {
972        let (_tmp, executor, _) = make_executor().await;
973
974        let mut row_values = HashMap::new();
975        row_values.insert("id".to_string(), Value::Integer(1));
976        row_values.insert("name".to_string(), Value::Text("test".to_string()));
977        let row = QueryRow::with_values(RowKey::new(vec![1]), row_values);
978
979        let condition = Condition {
980            column: "id".to_string(),
981            operator: ComparisonOperator::Equal,
982            value: Value::Integer(1),
983        };
984        assert!(executor.evaluate_condition(&row, &condition).unwrap());
985
986        let condition = Condition {
987            column: "name".to_string(),
988            operator: ComparisonOperator::Like,
989            value: Value::Text("test".to_string()),
990        };
991        assert!(executor.evaluate_condition(&row, &condition).unwrap());
992    }
993
994    /// Issue #2257 (legacy-executor sibling of #2231): a NaN `double`/`float`
995    /// must NOT satisfy `>`/`>=`/`<`/`<=` under WHERE-predicate evaluation.
996    /// Historically `Gt`/`Gte` leaked the NaN row because `compare_values`
997    /// (correctly) sorts NaN as the GREATEST value for ORDER BY. All four
998    /// inequalities must now drop it; `=` was already false for NaN.
999    #[tokio::test]
1000    async fn evaluate_condition_drops_nan_for_all_relations() {
1001        let (_tmp, executor, _) = make_executor().await;
1002
1003        let row_of = |v: Value| {
1004            let mut m = HashMap::new();
1005            m.insert("d".to_string(), v);
1006            QueryRow::with_values(RowKey::new(vec![1]), m)
1007        };
1008        let cond = |op: ComparisonOperator, v: Value| Condition {
1009            column: "d".to_string(),
1010            operator: op,
1011            value: v,
1012        };
1013
1014        for nan in [Value::Float(f64::NAN), Value::Float32(f32::NAN)] {
1015            let bound = match nan {
1016                Value::Float(_) => Value::Float(1.5),
1017                _ => Value::Float32(1.5),
1018            };
1019            let row = row_of(nan.clone());
1020            use ComparisonOperator::*;
1021            for op in [
1022                GreaterThan,
1023                GreaterThanOrEqual,
1024                LessThan,
1025                LessThanOrEqual,
1026                Equal,
1027            ] {
1028                assert!(
1029                    !executor
1030                        .evaluate_condition(&row, &cond(op.clone(), bound.clone()))
1031                        .unwrap(),
1032                    "NaN must not satisfy {:?} (issue #2257)",
1033                    op
1034                );
1035            }
1036
1037            // Finite operands still evaluate normally on the same path.
1038            let two = match nan {
1039                Value::Float(_) => Value::Float(2.0),
1040                _ => Value::Float32(2.0),
1041            };
1042            let finite_row = row_of(two);
1043            assert!(executor
1044                .evaluate_condition(
1045                    &finite_row,
1046                    &cond(ComparisonOperator::GreaterThan, bound.clone())
1047                )
1048                .unwrap());
1049            assert!(!executor
1050                .evaluate_condition(&finite_row, &cond(ComparisonOperator::LessThan, bound))
1051                .unwrap());
1052        }
1053    }
1054
1055    /// Issue #2257 end-to-end (within the live legacy path): a filter step of
1056    /// `d > 1.5` — the reachable-from-`engine.rs` entry point routes through
1057    /// `execute()` → `apply_execution_steps` → `apply_filter_step` →
1058    /// `evaluate_condition` — must DROP the `d = NaN` row and keep the finite
1059    /// `d = 2.0` row. This is the `d > 1.5 OR name = 'zzz'` repro's load-bearing
1060    /// conjunct (the OR is combined above this per-condition evaluator).
1061    #[tokio::test]
1062    async fn apply_filter_step_drops_nan_row_for_gt() {
1063        use super::super::planner::{ParallelizationInfo, StepType};
1064        let (_tmp, executor, _) = make_executor().await;
1065
1066        let mk = |d: f64| {
1067            let mut m = HashMap::new();
1068            m.insert("d".to_string(), Value::Float(d));
1069            m.insert("name".to_string(), Value::Text("aaa".to_string()));
1070            QueryRow::with_values(RowKey::new(vec![1]), m)
1071        };
1072        let rows = vec![mk(f64::NAN), mk(2.0), mk(0.5)];
1073
1074        let step = ExecutionStep {
1075            step_type: StepType::Filter,
1076            columns: Vec::new(),
1077            conditions: vec![Condition {
1078                column: "d".to_string(),
1079                operator: ComparisonOperator::GreaterThan,
1080                value: Value::Float(1.5),
1081            }],
1082            cost: 0.0,
1083            parallelization: ParallelizationInfo {
1084                can_parallelize: false,
1085                suggested_threads: 1,
1086                partition_key: None,
1087            },
1088        };
1089
1090        let out = executor.apply_filter_step(rows, &step).unwrap();
1091        // Only the finite 2.0 row survives: NaN dropped (was leaked pre-fix),
1092        // 0.5 correctly excluded by `> 1.5`.
1093        assert_eq!(out.len(), 1, "only d=2.0 survives d > 1.5");
1094        assert!(matches!(out[0].values.get("d"), Some(Value::Float(x)) if *x == 2.0));
1095    }
1096
1097    /// Issue #2257 guardrail: the ORDER BY / sort comparator (`compare_values`,
1098    /// used by `apply_sort_step`) must be UNCHANGED — NaN still sorts GREATEST.
1099    /// The predicate fix is scoped to WHERE evaluation only.
1100    #[tokio::test]
1101    async fn sort_order_nan_greatest_unchanged() {
1102        let (_tmp, executor, _) = make_executor().await;
1103        assert_eq!(
1104            executor
1105                .compare_values(&Value::Float(f64::NAN), &Value::Float(1.5))
1106                .unwrap(),
1107            Ordering::Greater,
1108            "sort order still puts NaN last (unchanged by #2257)"
1109        );
1110        // But the predicate helper drops it.
1111        assert!(executor
1112            .predicate_ordering(&Value::Float(f64::NAN), &Value::Float(1.5))
1113            .unwrap()
1114            .is_none());
1115        // Null-vs-value ordering is preserved via the compare_values fallback
1116        // (predicate helper must not regress it to an error/Equal).
1117        assert_eq!(
1118            executor
1119                .predicate_ordering(&Value::Null, &Value::Integer(5))
1120                .unwrap(),
1121            Some(Ordering::Less),
1122            "Null < value ordering preserved (legacy semantics)"
1123        );
1124    }
1125
1126    /// Issue #2257 review-first finding (roborev/rust-reviewer, converging):
1127    /// `predicate_ordering` must NOT widen this legacy path's comparison
1128    /// semantics beyond the NaN-predicate fix. `compare_values` never coerced
1129    /// cross-numeric variants (`Integer` vs `BigInt`/`Float`) — it `Err`s for
1130    /// any pair that isn't identical-variant, `Null`, or a same-type numeric —
1131    /// and WHERE-clause literals commonly parse as `Value::Integer` regardless
1132    /// of the column's real CQL type, so this is a live case, not a corner.
1133    /// `predicate_ordering` must reproduce that exact `Err`, never silently
1134    /// coerce via the broader `value_ops` predicate comparator.
1135    #[tokio::test]
1136    async fn predicate_ordering_still_errs_on_cross_numeric_pairs() {
1137        let (_tmp, executor, _) = make_executor().await;
1138
1139        // Integer vs BigInt: pinned pre-#2257 behavior is a hard error, not a
1140        // silently-coerced numeric comparison.
1141        assert!(executor
1142            .predicate_ordering(&Value::Integer(5), &Value::BigInt(5))
1143            .is_err());
1144        // Integer vs Float: same — no coercion, must error exactly like
1145        // `compare_values` always did.
1146        assert!(executor
1147            .predicate_ordering(&Value::Integer(5), &Value::Float(5.0))
1148            .is_err());
1149
1150        // The identical pairs behave the same via evaluate_condition's `>`
1151        // arm: a query-execution error surfaces, it is not silently satisfied
1152        // or silently dropped.
1153        let mut m = HashMap::new();
1154        m.insert("bigcol".to_string(), Value::Integer(5));
1155        let row = QueryRow::with_values(RowKey::new(vec![1]), m);
1156        let cond = Condition {
1157            column: "bigcol".to_string(),
1158            operator: ComparisonOperator::GreaterThan,
1159            value: Value::BigInt(3),
1160        };
1161        assert!(executor.evaluate_condition(&row, &cond).is_err());
1162    }
1163
1164    // -- indexes_used access-path reporting (issue #760, Epic #756) --------
1165
1166    use super::super::planner::{IndexSelection, IndexType};
1167
1168    /// Build a minimal plan with the given type and selected indexes.
1169    fn plan_with(
1170        plan_type: super::super::planner::PlanType,
1171        selected_indexes: Vec<IndexSelection>,
1172    ) -> QueryPlan {
1173        QueryPlan {
1174            plan_type,
1175            table: None,
1176            estimated_cost: 0.0,
1177            estimated_rows: 0,
1178            selected_indexes,
1179            steps: Vec::new(),
1180            hints: super::super::planner::QueryHints::default(),
1181        }
1182    }
1183
1184    fn primary_index() -> IndexSelection {
1185        IndexSelection {
1186            index_name: "PRIMARY".to_string(),
1187            columns: vec!["id".to_string()],
1188            selectivity: 0.1,
1189            index_type: IndexType::Primary,
1190        }
1191    }
1192
1193    /// A point lookup resolves the partition via the partition index
1194    /// (Index.db / Summary.db) — it MUST report the index it used, not "scan".
1195    #[test]
1196    fn test_indexes_used_point_lookup_reports_partition_index() {
1197        let plan = plan_with(
1198            super::super::planner::PlanType::PointLookup,
1199            vec![primary_index()],
1200        );
1201        assert_eq!(QueryExecutor::indexes_used_for(&plan), vec!["PRIMARY"]);
1202    }
1203
1204    /// A full table scan reports the explicit "scan" marker (we picked the
1205    /// marker over an empty list so EXPLAIN output is unambiguous).
1206    #[test]
1207    fn test_indexes_used_table_scan_reports_scan_marker() {
1208        let plan = plan_with(super::super::planner::PlanType::TableScan, Vec::new());
1209        assert_eq!(QueryExecutor::indexes_used_for(&plan), vec!["scan"]);
1210    }
1211
1212    /// Regression (roborev job 40): a TableScan plan that is actually an INSERT
1213    /// or a CREATE TABLE never calls `storage.scan`, so it must NOT report the
1214    /// "scan" access path. `execute()` special-cases these before
1215    /// `execute_table_scan`; `indexes_used_for` must mirror that.
1216    #[test]
1217    fn test_indexes_used_insert_and_ddl_table_scan_report_no_scan() {
1218        use super::super::planner::{ParallelizationInfo, StepType};
1219
1220        // INSERT: a TableScan plan carrying an Insert step.
1221        let insert_step = ExecutionStep {
1222            step_type: StepType::Insert,
1223            columns: Vec::new(),
1224            conditions: Vec::new(),
1225            cost: 0.0,
1226            parallelization: ParallelizationInfo {
1227                can_parallelize: false,
1228                suggested_threads: 1,
1229                partition_key: None,
1230            },
1231        };
1232        let mut insert_plan = plan_with(super::super::planner::PlanType::TableScan, Vec::new());
1233        insert_plan.steps = vec![insert_step];
1234        assert!(
1235            QueryExecutor::indexes_used_for(&insert_plan).is_empty(),
1236            "INSERT must not report a scan access path"
1237        );
1238
1239        // CREATE TABLE: empty steps, a target table, zero estimated rows.
1240        let mut ddl_plan = plan_with(super::super::planner::PlanType::TableScan, Vec::new());
1241        ddl_plan.table = Some(TableId::new("t"));
1242        ddl_plan.estimated_rows = 0;
1243        assert!(
1244            QueryExecutor::indexes_used_for(&ddl_plan).is_empty(),
1245            "CREATE TABLE must not report a scan access path"
1246        );
1247    }
1248
1249    /// Range scans degrade to a sequential scan in the executor → "scan".
1250    #[test]
1251    fn test_indexes_used_range_scan_reports_scan_marker() {
1252        let plan = plan_with(super::super::planner::PlanType::RangeScan, Vec::new());
1253        assert_eq!(QueryExecutor::indexes_used_for(&plan), vec!["scan"]);
1254    }
1255
1256    /// IndexScan on a Primary/Bloom index does a real point lookup → report
1257    /// the index name. (These executor paths call `storage.get`.)
1258    #[test]
1259    fn test_indexes_used_index_scan_primary_reports_index() {
1260        let plan = plan_with(
1261            super::super::planner::PlanType::IndexScan,
1262            vec![primary_index()],
1263        );
1264        assert_eq!(QueryExecutor::indexes_used_for(&plan), vec!["PRIMARY"]);
1265    }
1266
1267    /// IndexScan on a Secondary index currently degrades to a full scan in the
1268    /// executor (the secondary lookup is not yet wired up). Report "scan" —
1269    /// reporting the index would be fabrication.
1270    #[test]
1271    fn test_indexes_used_index_scan_secondary_reports_scan() {
1272        let secondary = IndexSelection {
1273            index_name: "idx_name".to_string(),
1274            columns: vec!["name".to_string()],
1275            selectivity: 0.1,
1276            index_type: IndexType::Secondary,
1277        };
1278        let plan = plan_with(super::super::planner::PlanType::IndexScan, vec![secondary]);
1279        assert_eq!(QueryExecutor::indexes_used_for(&plan), vec!["scan"]);
1280    }
1281
1282    // -- parallelization metadata truthfulness (issue #1691) --------------
1283
1284    /// A step that the planner marked parallelizable.
1285    fn parallelizable_step() -> ExecutionStep {
1286        use super::super::planner::{ParallelizationInfo, StepType};
1287        ExecutionStep {
1288            step_type: StepType::Scan,
1289            columns: Vec::new(),
1290            conditions: Vec::new(),
1291            cost: 0.0,
1292            parallelization: ParallelizationInfo {
1293                can_parallelize: true,
1294                suggested_threads: 8,
1295                partition_key: None,
1296            },
1297        }
1298    }
1299
1300    /// Issue #1691: the TableScan path now runs through a SINGLE bounded
1301    /// `scan_stream` pass, not N parallel workers. Even when the planner
1302    /// suggested 8 threads, the reported metadata must be truthful:
1303    /// `threads_used == 1` and `effective == false`.
1304    #[test]
1305    fn test_parallelization_table_scan_reports_single_threaded() {
1306        let mut plan = plan_with(super::super::planner::PlanType::TableScan, Vec::new());
1307        plan.steps = vec![parallelizable_step()];
1308
1309        let info = QueryExecutor::parallelization_for(&plan)
1310            .expect("a parallelizable step should still yield metadata");
1311        assert_eq!(info.threads_used, 1);
1312        assert!(!info.effective);
1313        assert!(info.partitions.is_empty());
1314    }
1315
1316    /// A plan with no parallelizable step yields no parallelization metadata.
1317    #[test]
1318    fn test_parallelization_absent_when_no_step_parallelizes() {
1319        let plan = plan_with(super::super::planner::PlanType::TableScan, Vec::new());
1320        assert!(QueryExecutor::parallelization_for(&plan).is_none());
1321    }
1322
1323    /// Non-scan plan types still surface the planner's suggested thread count as
1324    /// effective — only the retired table-scan path is neutralized.
1325    #[test]
1326    fn test_parallelization_non_scan_reports_planner_threads() {
1327        let mut plan = plan_with(super::super::planner::PlanType::Aggregation, Vec::new());
1328        plan.steps = vec![parallelizable_step()];
1329
1330        let info = QueryExecutor::parallelization_for(&plan)
1331            .expect("a parallelizable step should yield metadata");
1332        assert_eq!(info.threads_used, 8);
1333        assert!(info.effective);
1334    }
1335
1336    #[tokio::test]
1337    async fn test_condition_to_row_key_mapping() {
1338        let (_tmp, executor, _) = make_executor().await;
1339
1340        let id_condition = Condition {
1341            column: "id".to_string(),
1342            operator: ComparisonOperator::Equal,
1343            value: Value::Integer(42),
1344        };
1345        let key = executor
1346            .condition_to_row_key(&id_condition)
1347            .expect("id condition key");
1348        assert_eq!(std::str::from_utf8(key.as_bytes()).unwrap(), "user_key_42");
1349
1350        let name_condition = Condition {
1351            column: "username".to_string(),
1352            operator: ComparisonOperator::Equal,
1353            value: Value::Text("carol".to_string()),
1354        };
1355        let key = executor
1356            .condition_to_row_key(&name_condition)
1357            .expect("fallback key");
1358        assert_eq!(key.as_bytes(), b"carol");
1359    }
1360
1361    /// Issue #1334 / roborev H1: the offset-read placeholder
1362    /// (`data_access::read_value_at_offset`) surfaces its raw bytes to
1363    /// SELECT/export through `get()` → `storage_data_to_query_row` as a single
1364    /// column keyed `"data"` — exactly the behaviour a bare `Value::Blob` had
1365    /// pre-#1334. The producer now emits explicit `ScanRow::RawRow` provenance;
1366    /// this pins that a `RawRow` keeps surfacing the value under `"data"`, while
1367    /// the equivalent `ScanRow::Marker` is SUPPRESSED (drops the blob to an
1368    /// id-only fallback) — the regression a `Marker` here would cause.
1369    #[tokio::test]
1370    async fn offset_read_row_surfaces_data_marker_is_suppressed() {
1371        let (_tmp, executor, _) = make_executor().await;
1372        let key = RowKey::new(vec![7]);
1373        let raw = vec![0xde, 0xad, 0xbe, 0xef];
1374
1375        // The fixed producer output: the raw fallback surfaces its bytes as "data".
1376        let live = ScanRow::RawRow(raw.clone());
1377        let row = executor
1378            .storage_data_to_query_row(live, &key)
1379            .expect("raw offset-read row must convert");
1380        assert_eq!(
1381            row.values.get("data"),
1382            Some(&Value::Blob(raw.clone())),
1383            "a live offset/indexed read must surface its raw value as the \"data\" column"
1384        );
1385
1386        // Marker (the pre-fix producer output): the blob is dropped; the row
1387        // falls back to an id-only shape with NO "data" column — proving a Marker
1388        // here would lose data that previously reached SELECT/export.
1389        let marker = ScanRow::Marker(Value::Blob(raw));
1390        let suppressed = executor
1391            .storage_data_to_query_row(marker, &key)
1392            .expect("marker row must still convert");
1393        assert!(
1394            !suppressed.values.contains_key("data"),
1395            "a Marker must NOT surface the raw blob (this is the suppression the fix avoids)"
1396        );
1397    }
1398
1399    // -- retirement of execute_parallel_table_scan (issue #1691) -----------
1400
1401    /// A `TableScan` plan whose step requested parallelization (`suggested_threads`
1402    /// = 4, mirroring the retired path's default worker count). It routes to
1403    /// `execute_table_scan` (no INSERT step, non-empty steps ⇒ not CREATE TABLE)
1404    /// and takes the `can_parallelize` branch.
1405    fn parallelizable_table_scan_plan() -> QueryPlan {
1406        use super::super::planner::{ParallelizationInfo, PlanType, QueryHints, StepType};
1407        QueryPlan {
1408            plan_type: PlanType::TableScan,
1409            table: Some(TableId::new("t")),
1410            estimated_cost: 0.0,
1411            estimated_rows: 1,
1412            selected_indexes: Vec::new(),
1413            steps: vec![ExecutionStep {
1414                step_type: StepType::Scan,
1415                columns: Vec::new(),
1416                conditions: Vec::new(),
1417                cost: 0.0,
1418                parallelization: ParallelizationInfo {
1419                    can_parallelize: true,
1420                    suggested_threads: 4,
1421                    partition_key: None,
1422                },
1423            }],
1424            hints: QueryHints::default(),
1425        }
1426    }
1427
1428    /// Issue #1691 (verification-first): the parallelizable `TableScan` branch must
1429    /// issue EXACTLY ONE whole-table scan pass. The retired
1430    /// `execute_parallel_table_scan` spawned `suggested_threads` (4) workers, each
1431    /// re-running the identical full `storage.scan` — four duplicate whole-table
1432    /// passes for a single plan. With the retirement, the branch routes through the
1433    /// bounded streaming `scan_stream`, a single pass.
1434    ///
1435    /// This is RED on the pre-fix routing (the counter reads 4) and GREEN after
1436    /// (reads 1); it needs no on-disk data because the counter observes scan
1437    /// *initiations*, which the retired path made 4× even over an empty table.
1438    /// The counter is thread-local and this is a current-thread `#[tokio::test]`,
1439    /// so the scan runs on this thread and other parallel tests cannot pollute it.
1440    #[tokio::test]
1441    async fn table_scan_parallel_branch_issues_one_whole_table_pass() {
1442        let (_tmp, executor, _) = make_executor().await;
1443        crate::storage::reset_table_scan_calls();
1444
1445        let plan = parallelizable_table_scan_plan();
1446        let _ = executor.execute(&plan).await.expect("table scan executes");
1447
1448        assert_eq!(
1449            crate::storage::table_scan_call_count(),
1450            1,
1451            "the parallelizable TableScan branch must issue exactly ONE whole-table \
1452             scan pass; the retired execute_parallel_table_scan issued one per worker (4×)"
1453        );
1454    }
1455
1456    /// The bounded streaming branch drains its results correctly (no rows dropped
1457    /// by the `scan_stream` backpressure discipline) and still issues one pass.
1458    /// Over an empty table this is the trivial-but-load-bearing lower bound: the
1459    /// branch returns an empty result set and never fans out into multiple scans.
1460    #[tokio::test]
1461    async fn streaming_scan_branch_returns_all_rows_bounded() {
1462        let (_tmp, executor, _) = make_executor().await;
1463        crate::storage::reset_table_scan_calls();
1464
1465        let plan = parallelizable_table_scan_plan();
1466        let result = executor.execute(&plan).await.expect("table scan executes");
1467
1468        assert!(result.rows.is_empty(), "empty table yields no rows");
1469        assert_eq!(
1470            crate::storage::table_scan_call_count(),
1471            1,
1472            "the streaming drain must not re-issue the scan"
1473        );
1474    }
1475}