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.as_str(), condition.value.as_str()) {
622                (Some(row_text), Some(pattern)) => Ok(row_text.contains(pattern)),
623                _ => Ok(false),
624            },
625            ComparisonOperator::NotLike => match (row_value.as_str(), condition.value.as_str()) {
626                (Some(row_text), Some(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.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.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                // Issue #1644 (D2): this row is about to be pushed into the
775                // materialized `Vec<QueryRow>` result the caller retains for
776                // however long it likes — a retention boundary. Compact any
777                // borrowed value whose backing is a shared/oversized decoded
778                // chunk into a tight standalone allocation before it escapes
779                // the scan window's lifetime (`into_owned` is a no-op for an
780                // already-tight/owned value, e.g. every non-Bytes variant).
781                for (name, cell_value) in cells {
782                    values.insert(name, cell_value.into_owned());
783                }
784            }
785            ScanRow::RawRow(bytes) => {
786                values.insert(Arc::from("data"), Value::Blob(bytes.into()));
787            }
788            ScanRow::Marker(_) => {}
789        }
790
791        // If no values were extracted, surface the row key for visibility.
792        if values.is_empty() {
793            values.insert(Arc::from("id"), Value::text(format!("{:?}", key)));
794        }
795
796        Ok(QueryRow::with_interned_values(key.clone(), values))
797    }
798
799    // -- experimental write paths ------------------------------------------
800
801    /// Execute INSERT operation
802    #[cfg(feature = "experimental")]
803    async fn execute_insert_operation(&self, plan: &QueryPlan) -> Result<QueryResult> {
804        let table_id = self
805            .require_table(plan)
806            .map_err(|_| Error::query_execution("No table specified in INSERT plan"))?;
807
808        let mut inserted_count: u64 = 0;
809
810        for step in &plan.steps {
811            if !matches!(step.step_type, StepType::Insert) {
812                continue;
813            }
814
815            // Default key uses the running insert index; an explicit "id"
816            // condition wins so SELECT and INSERT share the same key shape.
817            let mut key_value = format!("test_key_{}", inserted_count);
818            for condition in &step.conditions {
819                if condition.column == "id" {
820                    if let Value::Integer(id) = &condition.value {
821                        key_value = format!("user_key_{}", id);
822                        break;
823                    }
824                }
825            }
826
827            let row_key = RowKey::new(key_value.into_bytes());
828
829            // Build the row payload from step conditions (or seed defaults
830            // when the step carries none, for test compatibility).
831            let mut value_map: HashMap<String, Value> = step
832                .conditions
833                .iter()
834                .map(|c| (c.column.clone(), c.value.clone()))
835                .collect();
836
837            if value_map.is_empty() {
838                value_map.insert("id".to_string(), Value::Integer(inserted_count as i32 + 1));
839                value_map.insert(
840                    "name".to_string(),
841                    Value::text(format!("TestUser{}", inserted_count + 1)),
842                );
843            }
844
845            let row_value = map_to_value(value_map);
846
847            self.storage.put(table_id, row_key, row_value).await?;
848            inserted_count += 1;
849        }
850
851        // No explicit INSERT steps — emit a single placeholder row to keep
852        // legacy tests passing.
853        if inserted_count == 0 {
854            let row_key = RowKey::new(b"default_test_key".to_vec());
855            let mut value_map = HashMap::new();
856            value_map.insert("id".to_string(), Value::Integer(1));
857            value_map.insert("name".to_string(), Value::text("DefaultUser".to_string()));
858
859            self.storage
860                .put(table_id, row_key, map_to_value(value_map))
861                .await?;
862            inserted_count = 1;
863        }
864
865        Ok(QueryResult {
866            rows: vec![],
867            rows_affected: inserted_count,
868            execution_time_ms: 0,
869            metadata: super::result::QueryMetadata::default(),
870        })
871    }
872
873    /// Execute CREATE TABLE operation (placeholder — DDL isn't persisted yet).
874    async fn execute_create_table_operation(&self, _plan: &QueryPlan) -> Result<QueryResult> {
875        Ok(QueryResult {
876            rows: vec![],
877            rows_affected: 0,
878            execution_time_ms: 0,
879            metadata: super::result::QueryMetadata::default(),
880        })
881    }
882}
883
884/// Build a `Value::Map` from a string-keyed map for storage writes.
885#[cfg(feature = "experimental")]
886fn map_to_value(map: HashMap<String, Value>) -> Value {
887    Value::Map(
888        map.into_iter()
889            .map(|(k, v)| (Value::Text(k.into()), v))
890            .collect(),
891    )
892}
893
894#[cfg(test)]
895mod tests {
896    use super::*;
897    use crate::Config;
898    use std::sync::Arc;
899    use tempfile::TempDir;
900
901    /// Construct a fresh executor against a temporary storage root.
902    async fn make_executor() -> (TempDir, QueryExecutor, Config) {
903        let temp_dir = TempDir::new().unwrap();
904        let config = Config::default();
905        let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
906        let storage = Arc::new(
907            crate::storage::StorageEngine::open(
908                temp_dir.path(),
909                &config,
910                platform,
911                #[cfg(feature = "state_machine")]
912                None,
913            )
914            .await
915            .unwrap(),
916        );
917        let schema = Arc::new(
918            crate::schema::SchemaManager::new(temp_dir.path())
919                .await
920                .unwrap(),
921        );
922        let executor = QueryExecutor::new(storage, schema, &config);
923        (temp_dir, executor, config)
924    }
925
926    #[tokio::test]
927    async fn test_query_executor_creation() {
928        let (_tmp, executor, config) = make_executor().await;
929        assert_eq!(
930            executor._config.query.query_parallelism,
931            config.query.query_parallelism
932        );
933    }
934
935    #[tokio::test]
936    async fn test_value_comparison() {
937        let (_tmp, executor, _) = make_executor().await;
938
939        let result = executor
940            .compare_values(&Value::Integer(10), &Value::Integer(20))
941            .unwrap();
942        assert_eq!(result, Ordering::Less);
943
944        let result = executor
945            .compare_values(
946                &Value::text("apple".to_string()),
947                &Value::text("banana".to_string()),
948            )
949            .unwrap();
950        assert_eq!(result, Ordering::Less);
951
952        // Issue #1870/#2010: ORDER BY on a float(f32) column must order via the
953        // Cassandra total comparator, not collapse to Equal (missing arm bug).
954        assert_eq!(
955            executor
956                .compare_values(&Value::Float32(1.0), &Value::Float32(2.0))
957                .unwrap(),
958            Ordering::Less
959        );
960        // Signed zeros are distinct; NaN sorts last and equals itself.
961        assert_eq!(
962            executor
963                .compare_values(&Value::Float32(-0.0), &Value::Float32(0.0))
964                .unwrap(),
965            Ordering::Less
966        );
967        assert_eq!(
968            executor
969                .compare_values(&Value::Float32(f32::NAN), &Value::Float32(1.0))
970                .unwrap(),
971            Ordering::Greater
972        );
973        assert_eq!(
974            executor
975                .compare_values(&Value::Float32(f32::NAN), &Value::Float32(f32::NAN))
976                .unwrap(),
977            Ordering::Equal
978        );
979    }
980
981    #[tokio::test]
982    async fn test_condition_evaluation() {
983        let (_tmp, executor, _) = make_executor().await;
984
985        let mut row_values = HashMap::new();
986        row_values.insert("id".to_string(), Value::Integer(1));
987        row_values.insert("name".to_string(), Value::text("test".to_string()));
988        let row = QueryRow::with_values(RowKey::new(vec![1]), row_values);
989
990        let condition = Condition {
991            column: "id".to_string(),
992            operator: ComparisonOperator::Equal,
993            value: Value::Integer(1),
994        };
995        assert!(executor.evaluate_condition(&row, &condition).unwrap());
996
997        let condition = Condition {
998            column: "name".to_string(),
999            operator: ComparisonOperator::Like,
1000            value: Value::text("test".to_string()),
1001        };
1002        assert!(executor.evaluate_condition(&row, &condition).unwrap());
1003    }
1004
1005    /// Issue #2257 (legacy-executor sibling of #2231): a NaN `double`/`float`
1006    /// must NOT satisfy `>`/`>=`/`<`/`<=` under WHERE-predicate evaluation.
1007    /// Historically `Gt`/`Gte` leaked the NaN row because `compare_values`
1008    /// (correctly) sorts NaN as the GREATEST value for ORDER BY. All four
1009    /// inequalities must now drop it; `=` was already false for NaN.
1010    #[tokio::test]
1011    async fn evaluate_condition_drops_nan_for_all_relations() {
1012        let (_tmp, executor, _) = make_executor().await;
1013
1014        let row_of = |v: Value| {
1015            let mut m = HashMap::new();
1016            m.insert("d".to_string(), v);
1017            QueryRow::with_values(RowKey::new(vec![1]), m)
1018        };
1019        let cond = |op: ComparisonOperator, v: Value| Condition {
1020            column: "d".to_string(),
1021            operator: op,
1022            value: v,
1023        };
1024
1025        for nan in [Value::Float(f64::NAN), Value::Float32(f32::NAN)] {
1026            let bound = match nan {
1027                Value::Float(_) => Value::Float(1.5),
1028                _ => Value::Float32(1.5),
1029            };
1030            let row = row_of(nan.clone());
1031            use ComparisonOperator::*;
1032            for op in [
1033                GreaterThan,
1034                GreaterThanOrEqual,
1035                LessThan,
1036                LessThanOrEqual,
1037                Equal,
1038            ] {
1039                assert!(
1040                    !executor
1041                        .evaluate_condition(&row, &cond(op.clone(), bound.clone()))
1042                        .unwrap(),
1043                    "NaN must not satisfy {:?} (issue #2257)",
1044                    op
1045                );
1046            }
1047
1048            // Finite operands still evaluate normally on the same path.
1049            let two = match nan {
1050                Value::Float(_) => Value::Float(2.0),
1051                _ => Value::Float32(2.0),
1052            };
1053            let finite_row = row_of(two);
1054            assert!(executor
1055                .evaluate_condition(
1056                    &finite_row,
1057                    &cond(ComparisonOperator::GreaterThan, bound.clone())
1058                )
1059                .unwrap());
1060            assert!(!executor
1061                .evaluate_condition(&finite_row, &cond(ComparisonOperator::LessThan, bound))
1062                .unwrap());
1063        }
1064    }
1065
1066    /// Issue #2257 end-to-end (within the live legacy path): a filter step of
1067    /// `d > 1.5` — the reachable-from-`engine.rs` entry point routes through
1068    /// `execute()` → `apply_execution_steps` → `apply_filter_step` →
1069    /// `evaluate_condition` — must DROP the `d = NaN` row and keep the finite
1070    /// `d = 2.0` row. This is the `d > 1.5 OR name = 'zzz'` repro's load-bearing
1071    /// conjunct (the OR is combined above this per-condition evaluator).
1072    #[tokio::test]
1073    async fn apply_filter_step_drops_nan_row_for_gt() {
1074        use super::super::planner::{ParallelizationInfo, StepType};
1075        let (_tmp, executor, _) = make_executor().await;
1076
1077        let mk = |d: f64| {
1078            let mut m = HashMap::new();
1079            m.insert("d".to_string(), Value::Float(d));
1080            m.insert("name".to_string(), Value::text("aaa".to_string()));
1081            QueryRow::with_values(RowKey::new(vec![1]), m)
1082        };
1083        let rows = vec![mk(f64::NAN), mk(2.0), mk(0.5)];
1084
1085        let step = ExecutionStep {
1086            step_type: StepType::Filter,
1087            columns: Vec::new(),
1088            conditions: vec![Condition {
1089                column: "d".to_string(),
1090                operator: ComparisonOperator::GreaterThan,
1091                value: Value::Float(1.5),
1092            }],
1093            cost: 0.0,
1094            parallelization: ParallelizationInfo {
1095                can_parallelize: false,
1096                suggested_threads: 1,
1097                partition_key: None,
1098            },
1099        };
1100
1101        let out = executor.apply_filter_step(rows, &step).unwrap();
1102        // Only the finite 2.0 row survives: NaN dropped (was leaked pre-fix),
1103        // 0.5 correctly excluded by `> 1.5`.
1104        assert_eq!(out.len(), 1, "only d=2.0 survives d > 1.5");
1105        assert!(matches!(out[0].values.get("d"), Some(Value::Float(x)) if *x == 2.0));
1106    }
1107
1108    /// Issue #2257 guardrail: the ORDER BY / sort comparator (`compare_values`,
1109    /// used by `apply_sort_step`) must be UNCHANGED — NaN still sorts GREATEST.
1110    /// The predicate fix is scoped to WHERE evaluation only.
1111    #[tokio::test]
1112    async fn sort_order_nan_greatest_unchanged() {
1113        let (_tmp, executor, _) = make_executor().await;
1114        assert_eq!(
1115            executor
1116                .compare_values(&Value::Float(f64::NAN), &Value::Float(1.5))
1117                .unwrap(),
1118            Ordering::Greater,
1119            "sort order still puts NaN last (unchanged by #2257)"
1120        );
1121        // But the predicate helper drops it.
1122        assert!(executor
1123            .predicate_ordering(&Value::Float(f64::NAN), &Value::Float(1.5))
1124            .unwrap()
1125            .is_none());
1126        // Null-vs-value ordering is preserved via the compare_values fallback
1127        // (predicate helper must not regress it to an error/Equal).
1128        assert_eq!(
1129            executor
1130                .predicate_ordering(&Value::Null, &Value::Integer(5))
1131                .unwrap(),
1132            Some(Ordering::Less),
1133            "Null < value ordering preserved (legacy semantics)"
1134        );
1135    }
1136
1137    /// Issue #2257 review-first finding (roborev/rust-reviewer, converging):
1138    /// `predicate_ordering` must NOT widen this legacy path's comparison
1139    /// semantics beyond the NaN-predicate fix. `compare_values` never coerced
1140    /// cross-numeric variants (`Integer` vs `BigInt`/`Float`) — it `Err`s for
1141    /// any pair that isn't identical-variant, `Null`, or a same-type numeric —
1142    /// and WHERE-clause literals commonly parse as `Value::Integer` regardless
1143    /// of the column's real CQL type, so this is a live case, not a corner.
1144    /// `predicate_ordering` must reproduce that exact `Err`, never silently
1145    /// coerce via the broader `value_ops` predicate comparator.
1146    #[tokio::test]
1147    async fn predicate_ordering_still_errs_on_cross_numeric_pairs() {
1148        let (_tmp, executor, _) = make_executor().await;
1149
1150        // Integer vs BigInt: pinned pre-#2257 behavior is a hard error, not a
1151        // silently-coerced numeric comparison.
1152        assert!(executor
1153            .predicate_ordering(&Value::Integer(5), &Value::BigInt(5))
1154            .is_err());
1155        // Integer vs Float: same — no coercion, must error exactly like
1156        // `compare_values` always did.
1157        assert!(executor
1158            .predicate_ordering(&Value::Integer(5), &Value::Float(5.0))
1159            .is_err());
1160
1161        // The identical pairs behave the same via evaluate_condition's `>`
1162        // arm: a query-execution error surfaces, it is not silently satisfied
1163        // or silently dropped.
1164        let mut m = HashMap::new();
1165        m.insert("bigcol".to_string(), Value::Integer(5));
1166        let row = QueryRow::with_values(RowKey::new(vec![1]), m);
1167        let cond = Condition {
1168            column: "bigcol".to_string(),
1169            operator: ComparisonOperator::GreaterThan,
1170            value: Value::BigInt(3),
1171        };
1172        assert!(executor.evaluate_condition(&row, &cond).is_err());
1173    }
1174
1175    // -- indexes_used access-path reporting (issue #760, Epic #756) --------
1176
1177    use super::super::planner::{IndexSelection, IndexType};
1178
1179    /// Build a minimal plan with the given type and selected indexes.
1180    fn plan_with(
1181        plan_type: super::super::planner::PlanType,
1182        selected_indexes: Vec<IndexSelection>,
1183    ) -> QueryPlan {
1184        QueryPlan {
1185            plan_type,
1186            table: None,
1187            estimated_cost: 0.0,
1188            estimated_rows: 0,
1189            selected_indexes,
1190            steps: Vec::new(),
1191            hints: super::super::planner::QueryHints::default(),
1192        }
1193    }
1194
1195    fn primary_index() -> IndexSelection {
1196        IndexSelection {
1197            index_name: "PRIMARY".to_string(),
1198            columns: vec!["id".to_string()],
1199            selectivity: 0.1,
1200            index_type: IndexType::Primary,
1201        }
1202    }
1203
1204    /// A point lookup resolves the partition via the partition index
1205    /// (Index.db / Summary.db) — it MUST report the index it used, not "scan".
1206    #[test]
1207    fn test_indexes_used_point_lookup_reports_partition_index() {
1208        let plan = plan_with(
1209            super::super::planner::PlanType::PointLookup,
1210            vec![primary_index()],
1211        );
1212        assert_eq!(QueryExecutor::indexes_used_for(&plan), vec!["PRIMARY"]);
1213    }
1214
1215    /// A full table scan reports the explicit "scan" marker (we picked the
1216    /// marker over an empty list so EXPLAIN output is unambiguous).
1217    #[test]
1218    fn test_indexes_used_table_scan_reports_scan_marker() {
1219        let plan = plan_with(super::super::planner::PlanType::TableScan, Vec::new());
1220        assert_eq!(QueryExecutor::indexes_used_for(&plan), vec!["scan"]);
1221    }
1222
1223    /// Regression (roborev job 40): a TableScan plan that is actually an INSERT
1224    /// or a CREATE TABLE never calls `storage.scan`, so it must NOT report the
1225    /// "scan" access path. `execute()` special-cases these before
1226    /// `execute_table_scan`; `indexes_used_for` must mirror that.
1227    #[test]
1228    fn test_indexes_used_insert_and_ddl_table_scan_report_no_scan() {
1229        use super::super::planner::{ParallelizationInfo, StepType};
1230
1231        // INSERT: a TableScan plan carrying an Insert step.
1232        let insert_step = ExecutionStep {
1233            step_type: StepType::Insert,
1234            columns: Vec::new(),
1235            conditions: Vec::new(),
1236            cost: 0.0,
1237            parallelization: ParallelizationInfo {
1238                can_parallelize: false,
1239                suggested_threads: 1,
1240                partition_key: None,
1241            },
1242        };
1243        let mut insert_plan = plan_with(super::super::planner::PlanType::TableScan, Vec::new());
1244        insert_plan.steps = vec![insert_step];
1245        assert!(
1246            QueryExecutor::indexes_used_for(&insert_plan).is_empty(),
1247            "INSERT must not report a scan access path"
1248        );
1249
1250        // CREATE TABLE: empty steps, a target table, zero estimated rows.
1251        let mut ddl_plan = plan_with(super::super::planner::PlanType::TableScan, Vec::new());
1252        ddl_plan.table = Some(TableId::new("t"));
1253        ddl_plan.estimated_rows = 0;
1254        assert!(
1255            QueryExecutor::indexes_used_for(&ddl_plan).is_empty(),
1256            "CREATE TABLE must not report a scan access path"
1257        );
1258    }
1259
1260    /// Range scans degrade to a sequential scan in the executor → "scan".
1261    #[test]
1262    fn test_indexes_used_range_scan_reports_scan_marker() {
1263        let plan = plan_with(super::super::planner::PlanType::RangeScan, Vec::new());
1264        assert_eq!(QueryExecutor::indexes_used_for(&plan), vec!["scan"]);
1265    }
1266
1267    /// IndexScan on a Primary/Bloom index does a real point lookup → report
1268    /// the index name. (These executor paths call `storage.get`.)
1269    #[test]
1270    fn test_indexes_used_index_scan_primary_reports_index() {
1271        let plan = plan_with(
1272            super::super::planner::PlanType::IndexScan,
1273            vec![primary_index()],
1274        );
1275        assert_eq!(QueryExecutor::indexes_used_for(&plan), vec!["PRIMARY"]);
1276    }
1277
1278    /// IndexScan on a Secondary index currently degrades to a full scan in the
1279    /// executor (the secondary lookup is not yet wired up). Report "scan" —
1280    /// reporting the index would be fabrication.
1281    #[test]
1282    fn test_indexes_used_index_scan_secondary_reports_scan() {
1283        let secondary = IndexSelection {
1284            index_name: "idx_name".to_string(),
1285            columns: vec!["name".to_string()],
1286            selectivity: 0.1,
1287            index_type: IndexType::Secondary,
1288        };
1289        let plan = plan_with(super::super::planner::PlanType::IndexScan, vec![secondary]);
1290        assert_eq!(QueryExecutor::indexes_used_for(&plan), vec!["scan"]);
1291    }
1292
1293    // -- parallelization metadata truthfulness (issue #1691) --------------
1294
1295    /// A step that the planner marked parallelizable.
1296    fn parallelizable_step() -> ExecutionStep {
1297        use super::super::planner::{ParallelizationInfo, StepType};
1298        ExecutionStep {
1299            step_type: StepType::Scan,
1300            columns: Vec::new(),
1301            conditions: Vec::new(),
1302            cost: 0.0,
1303            parallelization: ParallelizationInfo {
1304                can_parallelize: true,
1305                suggested_threads: 8,
1306                partition_key: None,
1307            },
1308        }
1309    }
1310
1311    /// Issue #1691: the TableScan path now runs through a SINGLE bounded
1312    /// `scan_stream` pass, not N parallel workers. Even when the planner
1313    /// suggested 8 threads, the reported metadata must be truthful:
1314    /// `threads_used == 1` and `effective == false`.
1315    #[test]
1316    fn test_parallelization_table_scan_reports_single_threaded() {
1317        let mut plan = plan_with(super::super::planner::PlanType::TableScan, Vec::new());
1318        plan.steps = vec![parallelizable_step()];
1319
1320        let info = QueryExecutor::parallelization_for(&plan)
1321            .expect("a parallelizable step should still yield metadata");
1322        assert_eq!(info.threads_used, 1);
1323        assert!(!info.effective);
1324        assert!(info.partitions.is_empty());
1325    }
1326
1327    /// A plan with no parallelizable step yields no parallelization metadata.
1328    #[test]
1329    fn test_parallelization_absent_when_no_step_parallelizes() {
1330        let plan = plan_with(super::super::planner::PlanType::TableScan, Vec::new());
1331        assert!(QueryExecutor::parallelization_for(&plan).is_none());
1332    }
1333
1334    /// Non-scan plan types still surface the planner's suggested thread count as
1335    /// effective — only the retired table-scan path is neutralized.
1336    #[test]
1337    fn test_parallelization_non_scan_reports_planner_threads() {
1338        let mut plan = plan_with(super::super::planner::PlanType::Aggregation, Vec::new());
1339        plan.steps = vec![parallelizable_step()];
1340
1341        let info = QueryExecutor::parallelization_for(&plan)
1342            .expect("a parallelizable step should yield metadata");
1343        assert_eq!(info.threads_used, 8);
1344        assert!(info.effective);
1345    }
1346
1347    #[tokio::test]
1348    async fn test_condition_to_row_key_mapping() {
1349        let (_tmp, executor, _) = make_executor().await;
1350
1351        let id_condition = Condition {
1352            column: "id".to_string(),
1353            operator: ComparisonOperator::Equal,
1354            value: Value::Integer(42),
1355        };
1356        let key = executor
1357            .condition_to_row_key(&id_condition)
1358            .expect("id condition key");
1359        assert_eq!(std::str::from_utf8(key.as_bytes()).unwrap(), "user_key_42");
1360
1361        let name_condition = Condition {
1362            column: "username".to_string(),
1363            operator: ComparisonOperator::Equal,
1364            value: Value::text("carol".to_string()),
1365        };
1366        let key = executor
1367            .condition_to_row_key(&name_condition)
1368            .expect("fallback key");
1369        assert_eq!(key.as_bytes(), b"carol");
1370    }
1371
1372    /// Issue #1334 / roborev H1: the offset-read placeholder
1373    /// (`data_access::read_value_at_offset`) surfaces its raw bytes to
1374    /// SELECT/export through `get()` → `storage_data_to_query_row` as a single
1375    /// column keyed `"data"` — exactly the behaviour a bare `Value::Blob` had
1376    /// pre-#1334. The producer now emits explicit `ScanRow::RawRow` provenance;
1377    /// this pins that a `RawRow` keeps surfacing the value under `"data"`, while
1378    /// the equivalent `ScanRow::Marker` is SUPPRESSED (drops the blob to an
1379    /// id-only fallback) — the regression a `Marker` here would cause.
1380    #[tokio::test]
1381    async fn offset_read_row_surfaces_data_marker_is_suppressed() {
1382        let (_tmp, executor, _) = make_executor().await;
1383        let key = RowKey::new(vec![7]);
1384        let raw = vec![0xde, 0xad, 0xbe, 0xef];
1385
1386        // The fixed producer output: the raw fallback surfaces its bytes as "data".
1387        let live = ScanRow::RawRow(raw.clone());
1388        let row = executor
1389            .storage_data_to_query_row(live, &key)
1390            .expect("raw offset-read row must convert");
1391        assert_eq!(
1392            row.values.get("data"),
1393            Some(&Value::blob(raw.clone())),
1394            "a live offset/indexed read must surface its raw value as the \"data\" column"
1395        );
1396
1397        // Marker (the pre-fix producer output): the blob is dropped; the row
1398        // falls back to an id-only shape with NO "data" column — proving a Marker
1399        // here would lose data that previously reached SELECT/export.
1400        let marker = ScanRow::Marker(Value::Blob(raw.into()));
1401        let suppressed = executor
1402            .storage_data_to_query_row(marker, &key)
1403            .expect("marker row must still convert");
1404        assert!(
1405            !suppressed.values.contains_key("data"),
1406            "a Marker must NOT surface the raw blob (this is the suppression the fix avoids)"
1407        );
1408    }
1409
1410    // -- retirement of execute_parallel_table_scan (issue #1691) -----------
1411
1412    /// A `TableScan` plan whose step requested parallelization (`suggested_threads`
1413    /// = 4, mirroring the retired path's default worker count). It routes to
1414    /// `execute_table_scan` (no INSERT step, non-empty steps ⇒ not CREATE TABLE)
1415    /// and takes the `can_parallelize` branch.
1416    fn parallelizable_table_scan_plan() -> QueryPlan {
1417        use super::super::planner::{ParallelizationInfo, PlanType, QueryHints, StepType};
1418        QueryPlan {
1419            plan_type: PlanType::TableScan,
1420            table: Some(TableId::new("t")),
1421            estimated_cost: 0.0,
1422            estimated_rows: 1,
1423            selected_indexes: Vec::new(),
1424            steps: vec![ExecutionStep {
1425                step_type: StepType::Scan,
1426                columns: Vec::new(),
1427                conditions: Vec::new(),
1428                cost: 0.0,
1429                parallelization: ParallelizationInfo {
1430                    can_parallelize: true,
1431                    suggested_threads: 4,
1432                    partition_key: None,
1433                },
1434            }],
1435            hints: QueryHints::default(),
1436        }
1437    }
1438
1439    /// Issue #1691 (verification-first): the parallelizable `TableScan` branch must
1440    /// issue EXACTLY ONE whole-table scan pass. The retired
1441    /// `execute_parallel_table_scan` spawned `suggested_threads` (4) workers, each
1442    /// re-running the identical full `storage.scan` — four duplicate whole-table
1443    /// passes for a single plan. With the retirement, the branch routes through the
1444    /// bounded streaming `scan_stream`, a single pass.
1445    ///
1446    /// This is RED on the pre-fix routing (the counter reads 4) and GREEN after
1447    /// (reads 1); it needs no on-disk data because the counter observes scan
1448    /// *initiations*, which the retired path made 4× even over an empty table.
1449    /// The counter is thread-local and this is a current-thread `#[tokio::test]`,
1450    /// so the scan runs on this thread and other parallel tests cannot pollute it.
1451    #[tokio::test]
1452    async fn table_scan_parallel_branch_issues_one_whole_table_pass() {
1453        let (_tmp, executor, _) = make_executor().await;
1454        crate::storage::reset_table_scan_calls();
1455
1456        let plan = parallelizable_table_scan_plan();
1457        let _ = executor.execute(&plan).await.expect("table scan executes");
1458
1459        assert_eq!(
1460            crate::storage::table_scan_call_count(),
1461            1,
1462            "the parallelizable TableScan branch must issue exactly ONE whole-table \
1463             scan pass; the retired execute_parallel_table_scan issued one per worker (4×)"
1464        );
1465    }
1466
1467    /// The bounded streaming branch drains its results correctly (no rows dropped
1468    /// by the `scan_stream` backpressure discipline) and still issues one pass.
1469    /// Over an empty table this is the trivial-but-load-bearing lower bound: the
1470    /// branch returns an empty result set and never fans out into multiple scans.
1471    #[tokio::test]
1472    async fn streaming_scan_branch_returns_all_rows_bounded() {
1473        let (_tmp, executor, _) = make_executor().await;
1474        crate::storage::reset_table_scan_calls();
1475
1476        let plan = parallelizable_table_scan_plan();
1477        let result = executor.execute(&plan).await.expect("table scan executes");
1478
1479        assert!(result.rows.is_empty(), "empty table yields no rows");
1480        assert_eq!(
1481            crate::storage::table_scan_call_count(),
1482            1,
1483            "the streaming drain must not re-issue the scan"
1484        );
1485    }
1486}