Skip to main content

cqlite_core/query/select_executor/
mod.rs

1//! CQL SELECT Query Executor for Direct SSTable Access
2//!
3//! This module implements the REVOLUTIONARY query executor that can run
4//! CQL SELECT statements directly on SSTable files without Cassandra.
5//!
6//! Features:
7//! - Direct SSTable file scanning with predicate pushdown
8//! - Streaming results for memory efficiency
9//! - Parallel execution across multiple SSTable files
10//! - Advanced aggregation with hash-based grouping
11//! - Collection operations (list[index], map['key'])
12//!
13//! ## Module layout
14//!
15//! The executor is split by responsibility (epic #1116):
16//! - [`value_ops`] — value comparison + arithmetic primitives,
17//! - [`predicate`] — SSTable leaf-predicate evaluation (public `evaluate_*`),
18//! - [`lookup`] — partition/clustering lookup classification,
19//! - [`aggregation`] — GROUP BY accumulation,
20//! - [`row_build`] — scan-row assembly (public `build_row_from_scan`),
21//! - [`writetime_ttl`] — WRITETIME/TTL projection + injectable clock,
22//! - [`execute`] — the three large `async` pipeline methods (`execute`,
23//!   `execute_streaming_background`, `execute_sstable_scan`) as an `impl`
24//!   continuation (issue #1174),
25//! - this `mod.rs` — the [`SelectExecutor`] orchestration and remaining
26//!   per-step helpers.
27//!
28//! ## Lint policy (issue #1590, E8)
29//!
30//! The read-path pipeline step helpers below are pure/synchronous — none awaits.
31//! Deny `clippy::unused_async` for this module (and its submodules) so a future
32//! edit cannot silently reintroduce the future/state-machine overhead of an
33//! `async fn` that never awaits (which also infects every caller with an
34//! `.await`). A crate-level deny is deliberately NOT used: `cqlite-core` still
35//! has many legitimately-flagged `async fn`s on its public surface (out of E8's
36//! scope), so the guard is scoped to the pipeline it protects.
37#![deny(clippy::unused_async)]
38
39mod aggregation;
40mod execute;
41mod lookup;
42mod predicate;
43mod row_build;
44mod value_ops;
45mod writetime_ttl;
46
47#[cfg(test)]
48pub(crate) mod test_support;
49
50use super::{
51    access_path::{AccessPath, FallbackReason},
52    result::{
53        cql_type_to_data_type, ColumnInfo, ProjectionFlags, QueryMetadata, QueryResult,
54        QueryResultIterator, QueryRow, StreamingConfig,
55    },
56    select_ast::*,
57    select_optimizer::{AggregationPlan, ExecutionStep, OptimizedQueryPlan, SSTablePredicate},
58};
59use crate::{
60    schema::{CqlType, SchemaManager, TableSchema},
61    storage::StorageEngine,
62    types::{RowKey, Value},
63    Error, Result, TableId,
64};
65use std::collections::HashMap;
66use std::sync::Arc;
67use tokio::sync::mpsc;
68
69use aggregation::{
70    build_group_key, finalize_group, find_or_init_group, update_aggregate, AggregationState,
71};
72use lookup::{
73    classify_partition_lookup, honest_targeted_path, sort_rows_by_token, PartitionLookupOutcome,
74};
75use row_build::{column_info_from_type_str, parse_cql_type_str, parse_table_id};
76use value_ops::{
77    compare_values_ordering, const_arithmetic, eval_arithmetic, try_compare_values, values_equal,
78};
79use writetime_ttl::{
80    evaluate_writetime_ttl, like_pattern_to_regex, select_has_writetime_ttl,
81    writetime_ttl_column_name, SystemClock,
82};
83
84// Public surface re-exports (kept identical to the pre-split module so
85// `query::mod`'s `pub use select_executor::{...}` resolves unchanged).
86pub use predicate::{evaluate_leaf, evaluate_predicates, LeafOutcome};
87pub use row_build::build_row_from_scan;
88pub use writetime_ttl::{FixedClock, NowSeconds};
89
90// `validate_token_predicates` is used by the executor's scan paths.
91use predicate::validate_token_predicates;
92
93// Test-only counter for the number of times a projected column NAME is derived
94// (issue #1584). A thread-local `Cell` (not a global atomic) so parallel test
95// binaries cannot pollute one another; `#[tokio::test]` uses the current-thread
96// runtime, so the future under test runs on the same thread that reads it.
97#[cfg(test)]
98thread_local! {
99    pub(crate) static PROJECTION_NAME_DERIVATIONS: std::cell::Cell<usize> =
100        const { std::cell::Cell::new(0) };
101}
102
103// Test-only counter for the number of times an ORDER BY sort-key expression is
104// evaluated (issue #1587, E5). With decorate-sort-undecorate this is exactly
105// `rows × order_by.items` (evaluated ONCE per row up front), never the
106// `O(n log n)` per-comparison evaluation the old comparator incurred. Same
107// thread-local rationale as `PROJECTION_NAME_DERIVATIONS`.
108#[cfg(test)]
109thread_local! {
110    pub(crate) static SORT_KEY_EVALUATIONS: std::cell::Cell<usize> =
111        const { std::cell::Cell::new(0) };
112}
113
114/// 128-bit digest of a partition key's raw bytes for PER PARTITION LIMIT
115/// bookkeeping (issue #1590, E8).
116///
117/// PER PARTITION LIMIT keys its per-partition counters on the partition. Keying
118/// a map (or an adjacent-boundary compare) on the raw `Vec<u8>` clones the key
119/// bytes for every row; using this fixed-size `u128` digest as the FAST outer
120/// lookup key makes the per-row work a heap-free hash of the bytes we already
121/// hold. The digest is Cassandra's own `murmur3_x64_128` (already used for token
122/// routing), so it is stable and well-distributed.
123///
124/// The digest is only a pre-check: both the batch counter map and the streaming
125/// boundary confirm an EXACT match on the raw partition-key bytes (stored ONCE
126/// per distinct partition, never cloned per row) before sharing a counter. So
127/// correctness does NOT depend on collision absence — two distinct partitions
128/// whose digests collide get separate counters and no valid row is dropped.
129pub(super) fn partition_key_digest(key_bytes: &[u8]) -> u128 {
130    let (h1, h2) = crate::util::cassandra_murmur3::cassandra_murmur3_x64_128(key_bytes);
131    ((h1 as u64 as u128) << 64) | (h2 as u64 as u128)
132}
133
134/// PER PARTITION LIMIT counter map for the batch path (issue #1590, E8).
135///
136/// The outer key is the fast 128-bit [`partition_key_digest`]; each bucket is a
137/// chain of `(raw_key_bytes, count)` entries disambiguated by EXACT byte
138/// equality. The chain is near-always length 1 — it grows only on a genuine
139/// digest collision between two DISTINCT partition keys — so the fast path is an
140/// `O(1)` digest hash plus a single byte compare, yet correctness never depends
141/// on collision absence.
142type PartitionCounts = HashMap<u128, Vec<(Vec<u8>, u64)>>;
143
144/// Admit one row under PER PARTITION LIMIT `count`, returning `true` when the
145/// row is within its partition's cap (and incrementing that partition's count).
146///
147/// `digest` is the fast outer lookup key; the raw `key_bytes` confirm the exact
148/// partition so a (vanishingly rare) digest collision between two distinct keys
149/// never shares a counter. The key's bytes are cloned at most ONCE — on the row
150/// that first opens the partition's counter — never per row.
151fn admit_partition_row(
152    counts: &mut PartitionCounts,
153    digest: u128,
154    key_bytes: &[u8],
155    count: u64,
156) -> bool {
157    let chain = counts.entry(digest).or_default();
158    let slot = match chain
159        .iter()
160        .position(|(bytes, _)| bytes.as_slice() == key_bytes)
161    {
162        Some(i) => &mut chain[i],
163        None => {
164            // The ONLY key-byte clone: once per distinct partition, not per row.
165            chain.push((key_bytes.to_vec(), 0));
166            let last = chain.len() - 1;
167            &mut chain[last]
168        }
169    };
170    if slot.1 < count {
171        slot.1 += 1;
172        true
173    } else {
174        false
175    }
176}
177
178/// Derive the output column name for a projected SELECT expression (issue #1584).
179///
180/// Hoisted out of the per-row loop and called ONCE per column per query, so name
181/// derivation is `O(columns)` rather than `O(rows × columns)`. The produced
182/// `Arc<str>` is `clone`d (a ref-count bump) into every row, preserving the exact
183/// output name — the materialized rows are byte-identical to the prior per-row
184/// `String` derivation.
185fn projected_column_name(expr: &SelectExpression, index: usize) -> std::sync::Arc<str> {
186    #[cfg(test)]
187    PROJECTION_NAME_DERIVATIONS.with(|c| c.set(c.get() + 1));
188    match expr {
189        // Issue #692: WriteTimeTtl expressions use Cassandra-convention names.
190        SelectExpression::Column(col_ref) => col_ref.column.as_str().into(),
191        SelectExpression::Aliased(_, alias) => alias.as_str().into(),
192        SelectExpression::WriteTimeTtl(call) => writetime_ttl_column_name(call).into(),
193        _ => format!("col_{index}").into(),
194    }
195}
196
197/// True when a `Project` expression RENAMES or EVALUATES its input rather than
198/// passing a stored column through unchanged (issue #1763).
199///
200/// A plain `Column` is emitted by the SSTable scan under the same name a
201/// `Project` would re-key it to, so streaming it directly matches the query
202/// metadata. Anything else — an alias (`category AS cat`), an aggregate, an
203/// arithmetic/function expression, or a literal — reshapes or renames the row,
204/// so the streaming path must materialize (fall back to execute-then-stream) to
205/// apply the `Project` and keep row value keys equal to the metadata names.
206fn project_expr_reshapes_row(expr: &SelectExpression) -> bool {
207    !matches!(expr, SelectExpression::Column(_))
208}
209
210/// True when a plain-column `Project`'s OUTPUT column SET differs from the
211/// SSTable scan projection's column SET — i.e. the `Project` must TRIM helper
212/// columns the broadened scan added (issue #1952 streaming follow-up).
213///
214/// #1952 widened the scan projection to the UNION of the selected columns and
215/// the WHERE / ORDER BY / GROUP BY / aggregate-argument helper columns, relying
216/// on the `Project` step to trim the non-selected helpers back out. The
217/// materialized path runs `Project`; the streaming producer
218/// (`execute_streaming_background`) IGNORES a plain-column `Project`, so without
219/// this check it would emit rows still carrying the helper columns
220/// (e.g. `SELECT a WHERE b = 1` scans `["a","b"]` and would leak `b`).
221///
222/// Column ORDER is intentionally irrelevant: streamed rows are keyed by name in
223/// a `HashMap`, and output ordering is carried by the query metadata, so only
224/// the SET matters — a `Project` that merely reorders (same set) can still
225/// stream directly with no perf regression. When there is no scan step
226/// (e.g. a constant query) there is nothing to trim.
227fn project_trims_scan_columns(
228    columns: &[SelectExpression],
229    scan_projection: Option<&[String]>,
230) -> bool {
231    let Some(scan) = scan_projection else {
232        return false;
233    };
234    let output: std::collections::HashSet<&str> = columns
235        .iter()
236        .filter_map(|e| match e {
237            SelectExpression::Column(c) => Some(c.column.as_str()),
238            _ => None,
239        })
240        .collect();
241    let scan_set: std::collections::HashSet<&str> = scan.iter().map(|s| s.as_str()).collect();
242    output != scan_set
243}
244
245/// Default row-count safety valve for the bare `SelectExecutor` constructors
246/// (issue #1582). Matches [`crate::config::QueryConfig::max_result_rows`]'s
247/// default; the query engine overrides it via
248/// [`SelectExecutor::with_max_result_rows`].
249const DEFAULT_MAX_RESULT_ROWS: usize = 1_000_000;
250
251/// SELECT query executor for SSTable-based storage
252pub struct SelectExecutor {
253    /// Schema manager for metadata
254    _schema: Arc<SchemaManager>,
255    /// Storage engine for SSTable access
256    storage: Arc<StorageEngine>,
257    /// Clock used for TTL "remaining seconds" computation (injectable for tests).
258    clock: Arc<dyn NowSeconds>,
259    /// Byte ceiling on a materialized result set (issue #1582 / D6).
260    ///
261    /// The materializing scan path accumulates a running estimate of the result
262    /// bytes and fails with [`Error::ResultTooLarge`] once this is exceeded.
263    /// Wired from [`crate::config::QueryConfig::max_result_bytes`] by the query
264    /// engine; defaults to [`crate::config::DEFAULT_MAX_RESULT_BYTES`] for the
265    /// bare constructors. Streaming queries are bounded by their channel buffer
266    /// and do not consult this budget.
267    max_result_bytes: usize,
268    /// Row-count safety valve on a materialized result set (issue #1582).
269    ///
270    /// Secondary to `max_result_bytes` (bytes are the correct memory unit), but
271    /// still load-bearing: the materializing scan fails once the collected row
272    /// count exceeds this valve. Wired from
273    /// [`crate::config::QueryConfig::max_result_rows`] by the query engine;
274    /// defaults to 1,000,000 for the bare constructors.
275    max_result_rows: usize,
276}
277
278impl std::fmt::Debug for SelectExecutor {
279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280        f.debug_struct("SelectExecutor")
281            .field("_schema", &self._schema)
282            .field("storage", &self.storage)
283            .finish_non_exhaustive()
284    }
285}
286
287/// Query execution context
288///
289/// Pure bookkeeping for an in-flight query. Only used internally; the public
290/// API surface is `SelectExecutor` itself.
291#[derive(Debug)]
292struct ExecutionContext {
293    /// Current table being queried
294    pub table_id: TableId,
295    /// Column metadata
296    pub columns: Vec<ColumnInfo>,
297    /// Row count processed so far
298    pub rows_processed: u64,
299    /// Rows examined by the SSTable-scan step ONLY (issue #1035). Distinct from
300    /// `rows_processed`, which is also bumped by the residual `Filter` step, so
301    /// this is the correct, non-double-counted source for the
302    /// `cqlite.query.rows_scanned` metric and span field.
303    pub scan_rows: u64,
304    /// Projection flags controlling opt-in metadata collection (Issue #692).
305    ///
306    /// Set to `include_cell_metadata = true` when any `WRITETIME` or `TTL`
307    /// select item is detected during planning so the reader can thread
308    /// per-cell write metadata.
309    pub projection_flags: ProjectionFlags,
310    /// Access path chosen by the SSTable-scan step for THIS query (Issue #960).
311    /// Per-query state, set where the scan step decides its path. The
312    /// result-attached `QueryMetadata.access_path` is read from here, NOT from
313    /// the process-global probe, so concurrent SELECTs cannot overwrite each
314    /// other's reported path between `record()` and the result build. The global
315    /// probe (`access_path::record/last`) remains for test assertions only.
316    pub access_path: Option<AccessPath>,
317    pub reverse_served: bool, // #1184: BIG reverse iterator produced DESC order; skip Sort.
318}
319
320impl SelectExecutor {
321    /// Create a new SELECT executor with a system (wall-clock) now source.
322    ///
323    /// Uses the default materialized-result byte budget
324    /// ([`crate::config::DEFAULT_MAX_RESULT_BYTES`]); call
325    /// [`SelectExecutor::with_max_result_bytes`] to override it (the query
326    /// engine wires it from [`crate::config::QueryConfig::max_result_bytes`]).
327    pub fn new(schema: Arc<SchemaManager>, storage: Arc<StorageEngine>) -> Self {
328        Self {
329            _schema: schema,
330            storage,
331            clock: Arc::new(SystemClock),
332            max_result_bytes: usize::try_from(crate::config::DEFAULT_MAX_RESULT_BYTES)
333                .unwrap_or(usize::MAX),
334            max_result_rows: DEFAULT_MAX_RESULT_ROWS,
335        }
336    }
337
338    /// Override the byte ceiling on a materialized result set (issue #1582).
339    ///
340    /// Builder-style: the query engine calls this with
341    /// [`crate::config::QueryConfig::max_result_bytes`] so the config knob is
342    /// load-bearing on the read path.
343    pub fn with_max_result_bytes(mut self, max_result_bytes: usize) -> Self {
344        self.max_result_bytes = max_result_bytes;
345        self
346    }
347
348    /// Override the row-count safety valve on a materialized result set (issue
349    /// #1582).
350    ///
351    /// Builder-style: the query engine calls this with
352    /// [`crate::config::QueryConfig::max_result_rows`] so the config knob is
353    /// load-bearing on the read path (not a hardcoded constant).
354    pub fn with_max_result_rows(mut self, max_result_rows: usize) -> Self {
355        self.max_result_rows = max_result_rows;
356        self
357    }
358
359    /// Create a SELECT executor with a custom clock (for deterministic tests).
360    #[cfg(test)]
361    pub fn with_clock(
362        schema: Arc<SchemaManager>,
363        storage: Arc<StorageEngine>,
364        clock: Arc<dyn NowSeconds>,
365    ) -> Self {
366        Self {
367            _schema: schema,
368            storage,
369            clock,
370            max_result_bytes: usize::try_from(crate::config::DEFAULT_MAX_RESULT_BYTES)
371                .unwrap_or(usize::MAX),
372            max_result_rows: DEFAULT_MAX_RESULT_ROWS,
373        }
374    }
375
376    /// Derive a bounded plan-family label for a SELECT, from the plan it executed
377    /// and the honest access path the SSTable-scan step actually took (issue #1035).
378    ///
379    /// The string is one of the `PlanType` `Debug` forms (`"TableScan"`,
380    /// `"PointLookup"`, `"RangeScan"`, `"Aggregation"`) so that
381    /// `QueryEngine::plan_type_label` maps it onto the same bounded metric/span
382    /// taxonomy the legacy executor already uses — keeping cardinality bounded and
383    /// the dimension consistent across surfaces. The access path is the same
384    /// per-query signal surfaced on `QueryMetadata.access_path` (epic #951/#960);
385    /// this never inspects query text or key values.
386    fn select_plan_family(plan: &OptimizedQueryPlan, access_path: Option<&AccessPath>) -> String {
387        // Aggregation dominates: a query that aggregates is reported as such
388        // regardless of how its underlying scan was served.
389        if plan.aggregation_plan.is_some() {
390            return "Aggregation".to_string();
391        }
392        match access_path {
393            Some(
394                AccessPath::PartitionLookup
395                | AccessPath::MultiPartitionLookup
396                | AccessPath::MetadataPartitionLookup
397                | AccessPath::StreamingPartitionLookup,
398            ) => "PointLookup".to_string(),
399            Some(AccessPath::ClusteringSlice) => "RangeScan".to_string(),
400            // Full scan, any documented fallback, or no recorded path (e.g. a plan
401            // with no scan step) is honestly a table scan.
402            Some(AccessPath::FullScan | AccessPath::FallbackFullScan { .. }) | None => {
403                "TableScan".to_string()
404            }
405        }
406    }
407
408    /// Build the bounded `PlanInfo` carried on a SELECT result so the engine's
409    /// single observability chokepoint can dimension `QUERY_DURATION`/`QUERY_ROWS`
410    /// and the parent span by a real plan family instead of `"unknown"`
411    /// (issue #1035). Only the bounded plan-family string and the chosen access
412    /// path (as the single "index used" entry) are recorded — never query text.
413    fn select_plan_info(
414        plan: &OptimizedQueryPlan,
415        access_path: Option<&AccessPath>,
416    ) -> crate::query::result::PlanInfo {
417        crate::query::result::PlanInfo {
418            plan_type: Self::select_plan_family(plan, access_path),
419            estimated_cost: 0.0,
420            actual_cost: 0.0,
421            indexes_used: access_path
422                .map(|p| vec![p.label().to_string()])
423                .unwrap_or_default(),
424            steps: vec![],
425            parallelization: None,
426        }
427    }
428
429    /// Execute an optimized query plan with streaming results (Issue #280)
430    ///
431    /// Instead of materializing all rows in memory, this method returns a
432    /// `QueryResultIterator` that yields rows incrementally via a bounded channel.
433    /// This enables memory-efficient processing of large result sets.
434    ///
435    /// # Memory Budget
436    ///
437    /// With default `StreamingConfig::buffer_size` of 1024 rows and ~1KB avg row size:
438    /// - Channel buffer: ~1MB in flight
439    /// - Background task: minimal overhead
440    /// - Total streaming overhead: ~1-2MB (well within 128MB target)
441    ///
442    /// # Limitations
443    ///
444    /// Currently supports:
445    /// - SSTableScan with predicates (streaming)
446    /// - Filter/Limit/Project (applied during scan)
447    ///
448    /// `LIMIT` (and `OFFSET`, when present in the plan) is enforced by the
449    /// streaming producer (`execute_streaming_background`): it skips `OFFSET`
450    /// matches and stops scanning once `count` rows have been sent, so a
451    /// `LIMIT N` query yields exactly `N` rows without materializing the rest
452    /// (Issue #581).
453    ///
454    /// For ORDER BY/GROUP BY/DISTINCT, falls back to full execution then streams results.
455    pub async fn execute_streaming(
456        &self,
457        plan: OptimizedQueryPlan,
458        config: StreamingConfig,
459    ) -> Result<QueryResultIterator> {
460        // Issue #960: clear the global access-path probe so a stale value from a
461        // previous query cannot satisfy a test assertion against this one.
462        crate::query::access_path::reset();
463
464        // Check if query requires full materialization (ORDER BY, GROUP BY, aggregates)
465        if self.requires_materialization(&plan) {
466            log::info!("Query requires materialization (ORDER BY/GROUP BY/aggregates), using execute-then-stream");
467            return self.execute_and_stream(plan, config).await;
468        }
469
470        let table_id = if let Some(ref from_clause) = plan.statement.from_clause {
471            self.extract_table_id(from_clause)?
472        } else {
473            // For queries without FROM clause (like SELECT 1), fall back to execute
474            return self.execute_and_stream(plan, config).await;
475        };
476
477        // Issue #1587 (E5): resolve the schema ONCE for the whole streaming query
478        // and share it (by reference here, and moved into the spawned task below)
479        // so column-metadata building, the pre-spawn token validation, and the
480        // background scan all reuse one `Arc<TableSchema>` instead of each
481        // re-locking the registry and deep-cloning a fresh schema (2–4× per query).
482        let query_schema: Option<Arc<TableSchema>> = self.resolve_table_schema(&table_id).await;
483
484        let columns = self.get_result_columns(&plan.statement, query_schema.as_deref())?;
485
486        // Create bounded channel for backpressure
487        let (tx, rx) = mpsc::channel(config.buffer_size);
488
489        // Determine execution steps
490        let execution_steps = if plan.execution_steps.is_empty() {
491            vec![ExecutionStep::SSTableScan {
492                table: table_id.clone(),
493                predicates: vec![],
494                projection: columns.iter().map(|c| c.name.clone()).collect(),
495            }]
496        } else {
497            plan.execution_steps.clone()
498        };
499
500        // FINDING 1 (roborev, Issue #955 follow-up): synchronous preconditions
501        // that should FAIL the query must be checked BEFORE spawning the
502        // streaming task. Errors raised inside `execute_streaming_background`
503        // are only logged by the spawn closure (the channel then closes), so the
504        // caller would receive an apparently-successful iterator that yields zero
505        // rows — silently hiding an invalid `token(...)` query. Validating here
506        // surfaces the error synchronously from `execute_streaming`, matching the
507        // materializing `execute()` path. Uses the already-resolved schema.
508        for step in &execution_steps {
509            if let ExecutionStep::SSTableScan { predicates, .. } = step {
510                validate_token_predicates(predicates, query_schema.as_deref())?;
511            }
512        }
513
514        // Clone what we need for the background task.
515        let storage = Arc::clone(&self.storage);
516        let buffer_size = config.buffer_size;
517
518        // Spawn background task to stream rows
519        tokio::spawn(async move {
520            if let Err(e) = Self::execute_streaming_background(
521                storage,
522                query_schema,
523                table_id,
524                execution_steps,
525                tx,
526                buffer_size,
527            )
528            .await
529            {
530                log::error!("Streaming execution error: {}", e);
531                // Error is logged; channel will close and consumer will see None
532            }
533        });
534
535        // Create metadata for the iterator
536        let metadata = QueryMetadata {
537            columns,
538            total_rows: None, // Unknown for streaming
539            plan_info: None,
540            performance: Default::default(),
541            warnings: vec![],
542            // Issue #960: the streaming scan runs in the spawned task above, so the
543            // access path is not yet recorded when this iterator is constructed.
544            // Streaming surfaces report the path via the global probe
545            // (`crate::query::access_path::last()`) after at least one row is
546            // pulled, not on the iterator metadata.
547            access_path: None,
548        };
549
550        Ok(QueryResultIterator::new(rx, metadata))
551    }
552
553    /// Check if query plan requires full materialization before streaming
554    fn requires_materialization(&self, plan: &OptimizedQueryPlan) -> bool {
555        // The SSTable scan projection = the source columns the scan keeps in each
556        // streamed row. A `Project` step whose output column SET differs from this
557        // scan set must TRIM helper columns (issue #1952), which the streaming
558        // producer cannot do — so such a plan must materialize.
559        let scan_projection: Option<&[String]> =
560            plan.execution_steps.iter().find_map(|step| match step {
561                ExecutionStep::SSTableScan { projection, .. } => Some(projection.as_slice()),
562                _ => None,
563            });
564
565        for step in &plan.execution_steps {
566            match step {
567                ExecutionStep::Sort { .. } => return true,
568                ExecutionStep::Aggregate { .. } => return true,
569                // The streaming producer (`execute_streaming_background`) IGNORES
570                // `Project`, so a `Project` step that changes the row's column SET
571                // or SHAPE must fall back to the materialized execute()-then-stream
572                // path (which applies the `Project`). Two disjoint cases:
573                //
574                //   * Issue #1763 — a RENAMING (`category AS cat`) or EVALUATING
575                //     (aggregate / arithmetic / literal / function) item reshapes
576                //     the row: streaming would key it by the SOURCE stored column
577                //     name, disagreeing with the SELECT-output query metadata.
578                //   * Issue #1952 (this follow-up) — a plain-column `Project` whose
579                //     OUTPUT set ≠ the scan projection set TRIMS the WHERE / ORDER
580                //     BY / GROUP BY / aggregate-arg helper columns the broadened
581                //     scan added; streaming would LEAK those helper columns.
582                //
583                // A `Project` of exactly the scan's plain columns (no reshape, no
584                // trim) keys each cell by the same name and can stream directly.
585                ExecutionStep::Project { columns }
586                    if columns.iter().any(project_expr_reshapes_row)
587                        || project_trims_scan_columns(columns, scan_projection) =>
588                {
589                    return true
590                }
591                _ => {}
592            }
593        }
594
595        // Check for DISTINCT
596        if matches!(plan.statement.select_clause, SelectClause::Distinct(_)) {
597            return true;
598        }
599
600        // Issue #693: WRITETIME()/TTL() expressions require full materialisation
601        // because the streaming background task only emits raw scan rows without
602        // applying the WRITETIME/TTL projection (cell metadata extraction and
603        // value computation).  Falling back to execute_and_stream ensures the
604        // complete execute() path runs, which correctly populates writetime(col)/
605        // ttl(col) keys in each row's values map.
606        select_has_writetime_ttl(&plan.statement)
607    }
608
609    /// Fallback: Execute query fully, then stream the results
610    async fn execute_and_stream(
611        &self,
612        plan: OptimizedQueryPlan,
613        config: StreamingConfig,
614    ) -> Result<QueryResultIterator> {
615        // Execute full query
616        let result = self.execute(plan).await?;
617
618        // Create channel to stream results
619        let (tx, rx) = mpsc::channel(config.buffer_size);
620
621        // Spawn task to send rows through channel
622        tokio::spawn(async move {
623            for row in result.rows {
624                if tx.send(Ok(row)).await.is_err() {
625                    break; // Consumer dropped
626                }
627            }
628            // Channel closes automatically when tx drops
629        });
630
631        Ok(QueryResultIterator::new(rx, result.metadata))
632    }
633
634    // `execute_streaming_background` and `execute_sstable_scan` live in the
635    // `execute` submodule alongside `execute` (issue #1174).
636
637    /// Execute filtering step
638    fn execute_filter(
639        &self,
640        rows: Vec<QueryRow>,
641        filter_expr: &WhereExpression,
642        context: &mut ExecutionContext,
643    ) -> Result<Vec<QueryRow>> {
644        let mut filtered_rows = Vec::new();
645
646        for row in rows {
647            if self.evaluate_where_expression(filter_expr, &row)? {
648                filtered_rows.push(row);
649            }
650            context.rows_processed += 1;
651        }
652
653        Ok(filtered_rows)
654    }
655
656    /// Evaluate WHERE expression against a row
657    fn evaluate_where_expression(&self, expr: &WhereExpression, row: &QueryRow) -> Result<bool> {
658        match expr {
659            WhereExpression::Comparison(comp) => self.evaluate_comparison(comp, row),
660            WhereExpression::And(exprs) => {
661                for expr in exprs {
662                    if !self.evaluate_where_expression(expr, row)? {
663                        return Ok(false);
664                    }
665                }
666                Ok(true)
667            }
668            WhereExpression::Or(exprs) => {
669                for expr in exprs {
670                    if self.evaluate_where_expression(expr, row)? {
671                        return Ok(true);
672                    }
673                }
674                Ok(false)
675            }
676            WhereExpression::Not(expr) => Ok(!self.evaluate_where_expression(expr, row)?),
677            WhereExpression::Parentheses(expr) => self.evaluate_where_expression(expr, row),
678        }
679    }
680
681    /// Evaluate comparison expression. Operators that need a single right
682    /// operand share one `evaluate` call; IN/LIKE/IS NULL fall through to
683    /// their custom branches.
684    fn evaluate_comparison(&self, comp: &ComparisonExpression, row: &QueryRow) -> Result<bool> {
685        use ComparisonOperator::*;
686
687        let left_value = self.evaluate_select_expression(&comp.left, row)?;
688
689        // Fast path for null tests, which ignore the right side.
690        match comp.operator {
691            IsNull => return Ok(left_value.is_null()),
692            IsNotNull => return Ok(!left_value.is_null()),
693            _ => {}
694        }
695
696        match (&comp.operator, &comp.right) {
697            (
698                op @ (Equal | NotEqual | LessThan | LessThanOrEqual | GreaterThan
699                | GreaterThanOrEqual),
700                ComparisonRightSide::Value(right_expr),
701            ) => {
702                let right_value = self.evaluate_select_expression(right_expr, row)?;
703                let result = match op {
704                    Equal => values_equal(&left_value, &right_value),
705                    NotEqual => !values_equal(&left_value, &right_value),
706                    LessThan => try_compare_values(&left_value, &right_value)?.is_lt(),
707                    LessThanOrEqual => try_compare_values(&left_value, &right_value)?.is_le(),
708                    GreaterThan => try_compare_values(&left_value, &right_value)?.is_gt(),
709                    GreaterThanOrEqual => try_compare_values(&left_value, &right_value)?.is_ge(),
710                    _ => unreachable!("guarded by outer match"),
711                };
712                Ok(result)
713            }
714            (In, ComparisonRightSide::ValueList(value_exprs)) => {
715                for value_expr in value_exprs {
716                    let value = self.evaluate_select_expression(value_expr, row)?;
717                    if left_value == value {
718                        return Ok(true);
719                    }
720                }
721                Ok(false)
722            }
723            (Like, ComparisonRightSide::Value(pattern_expr)) => {
724                let pattern = self.evaluate_select_expression(pattern_expr, row)?;
725                if let (Value::Text(text), Value::Text(pattern_str)) = (&left_value, &pattern) {
726                    Ok(self.match_like_pattern(text, pattern_str))
727                } else {
728                    Ok(false)
729                }
730            }
731            _ => Err(Error::query_execution(
732                "Unsupported comparison operator".to_string(),
733            )),
734        }
735    }
736
737    /// Evaluate SELECT expression against a row
738    fn evaluate_select_expression(&self, expr: &SelectExpression, row: &QueryRow) -> Result<Value> {
739        match expr {
740            SelectExpression::Column(col_ref) => row
741                .values
742                .get(col_ref.column.as_str())
743                .cloned()
744                .ok_or_else(|| {
745                    Error::query_execution(format!("Column not found: {}", col_ref.column))
746                }),
747            SelectExpression::Literal(value) => Ok(value.clone()),
748            // Issue #961: a `?` placeholder must be bound to a concrete value
749            // before execution. Reaching here means binding was skipped, which is
750            // an internal logic error rather than user input — report it instead
751            // of panicking.
752            SelectExpression::BindMarker(idx) => Err(Error::query_execution(format!(
753                "Unbound parameter placeholder ?{idx} reached execution; \
754                 parameters must be bound before the query runs"
755            ))),
756            SelectExpression::CollectionAccess(access) => {
757                self.evaluate_collection_access(access, row)
758            }
759            SelectExpression::Arithmetic(arith) => {
760                let left = self.evaluate_select_expression(&arith.left, row)?;
761                let right = self.evaluate_select_expression(&arith.right, row)?;
762                self.evaluate_arithmetic(&arith.operator, left, right)
763            }
764            SelectExpression::Aliased(expr, _) => self.evaluate_select_expression(expr, row),
765            SelectExpression::Aggregate(_) => {
766                // Aggregate expressions should not be evaluated at row level
767                // They should only be processed during the aggregation step
768                Err(Error::query_execution(
769                    "Aggregate expressions should be processed during aggregation step, not row evaluation".to_string(),
770                ))
771            }
772            SelectExpression::Function(_) => {
773                // Function expressions not yet implemented
774                Err(Error::query_execution(
775                    "Function expressions not yet implemented".to_string(),
776                ))
777            }
778            // Issue #692: evaluate WRITETIME(col) / TTL(col) against the per-cell
779            // metadata carrier threaded by the reader when `ProjectionFlags::include_cell_metadata`
780            // is set. Returns `Value::Null` when metadata is absent (e.g. no schema-aware
781            // read path or the column was a partition-key column with no cell header).
782            SelectExpression::WriteTimeTtl(call) => {
783                let now_secs = self.clock.now_seconds();
784                Ok(evaluate_writetime_ttl(call, row, now_secs))
785            }
786        }
787    }
788
789    /// Evaluate collection access operations (`list[idx]`, `map['key']`,
790    /// `value IN set_column`).
791    fn evaluate_collection_access(
792        &self,
793        access: &CollectionAccessExpression,
794        row: &QueryRow,
795    ) -> Result<Value> {
796        let lookup_column = |col: &ColumnRef| -> Result<&Value> {
797            row.values
798                .get(col.column.as_str())
799                .ok_or_else(|| Error::query_execution(format!("Column not found: {}", col.column)))
800        };
801
802        match access {
803            CollectionAccessExpression::ListIndex(col_ref, index_expr) => {
804                let list_value = lookup_column(col_ref)?;
805                let index_value = self.evaluate_select_expression(index_expr, row)?;
806
807                let (Value::List(list), Value::Integer(index)) = (list_value, &index_value) else {
808                    return Err(Error::query_execution("Invalid list access".to_string()));
809                };
810                if *index >= 0 && (*index as usize) < list.len() {
811                    Ok(list[*index as usize].clone())
812                } else {
813                    Ok(Value::Null)
814                }
815            }
816            CollectionAccessExpression::MapKey(col_ref, key_expr) => {
817                let map_value = lookup_column(col_ref)?;
818                let key_value = self.evaluate_select_expression(key_expr, row)?;
819
820                let Value::Map(map) = map_value else {
821                    return Err(Error::query_execution("Invalid map access".to_string()));
822                };
823                Ok(map
824                    .iter()
825                    .find(|(k, _)| *k == key_value)
826                    .map(|(_, v)| v.clone())
827                    .unwrap_or(Value::Null))
828            }
829            CollectionAccessExpression::SetContains(col_ref, value_expr) => {
830                let set_value = lookup_column(col_ref)?;
831                let test_value = self.evaluate_select_expression(value_expr, row)?;
832
833                let Value::Set(set) = set_value else {
834                    return Err(Error::query_execution(
835                        "Invalid set contains operation".to_string(),
836                    ));
837                };
838                Ok(Value::Boolean(set.contains(&test_value)))
839            }
840        }
841    }
842
843    /// Evaluate arithmetic expressions on a (left, op, right) triple.
844    ///
845    /// Runtime arithmetic supports same-type Integer or Float operands. Mixed
846    /// types or non-numeric operands return an error. (Constant-folding
847    /// arithmetic additionally accepts BigInt — see
848    /// `evaluate_constant_expression`.)
849    fn evaluate_arithmetic(
850        &self,
851        op: &ArithmeticOperator,
852        left: Value,
853        right: Value,
854    ) -> Result<Value> {
855        match (&left, &right) {
856            (Value::Integer(_), Value::Integer(_)) | (Value::Float(_), Value::Float(_)) => {
857                eval_arithmetic(op, left, right)
858            }
859            _ => Err(Error::query_execution(
860                "Incompatible types for arithmetic".to_string(),
861            )),
862        }
863    }
864
865    /// Simple LIKE pattern matching. The CQL pattern syntax (`%`, `_`) is
866    /// translated by `like_pattern_to_regex` before compilation.
867    fn match_like_pattern(&self, text: &str, pattern: &str) -> bool {
868        regex::Regex::new(&like_pattern_to_regex(pattern))
869            .map(|re| re.is_match(text))
870            .unwrap_or(false)
871    }
872
873    /// Execute sorting step
874    fn execute_sort(
875        &self,
876        mut rows: Vec<QueryRow>,
877        order_by: &OrderByClause,
878        _context: &mut ExecutionContext,
879    ) -> Result<Vec<QueryRow>> {
880        // Issue #1587 (E5): decorate-sort-undecorate. Evaluate each row's ORDER
881        // BY key expressions ONCE (O(rows × items) total), then sort by the
882        // precomputed keys. The comparator itself performs NO
883        // `evaluate_select_expression` and NO `Value` clones — it only compares
884        // already-materialized keys. This drops the O(n log n) per-comparison
885        // expression evaluation + `Value::clone` the previous comparator
886        // incurred. The ordering is byte-identical: the same
887        // `compare_values_ordering` runs on the same per-row values, in the same
888        // ascending/descending sense, item by item, and `sort_by` remains a
889        // stable sort (ties keep input order).
890        let mut decorated: Vec<(Vec<Value>, QueryRow)> = Vec::with_capacity(rows.len());
891        for row in rows.drain(..) {
892            let mut keys = Vec::with_capacity(order_by.items.len());
893            for item in &order_by.items {
894                #[cfg(test)]
895                SORT_KEY_EVALUATIONS.with(|c| c.set(c.get() + 1));
896                keys.push(
897                    self.evaluate_select_expression(&item.expression, &row)
898                        .unwrap_or(Value::Null),
899                );
900            }
901            decorated.push((keys, row));
902        }
903
904        decorated.sort_by(|(a_keys, _), (b_keys, _)| {
905            for (idx, item) in order_by.items.iter().enumerate() {
906                let ordering = match item.direction {
907                    SortDirection::Ascending => compare_values_ordering(&a_keys[idx], &b_keys[idx]),
908                    SortDirection::Descending => {
909                        compare_values_ordering(&b_keys[idx], &a_keys[idx])
910                    }
911                };
912                if !ordering.is_eq() {
913                    return ordering;
914                }
915            }
916            std::cmp::Ordering::Equal
917        });
918
919        Ok(decorated.into_iter().map(|(_, row)| row).collect())
920    }
921
922    /// Execute the aggregation step. Splits naturally into three phases:
923    /// build group key, accumulate per-aggregate state, then finalize each
924    /// group into a result row.
925    fn execute_aggregation(
926        &self,
927        rows: Vec<QueryRow>,
928        agg_plan: &AggregationPlan,
929        _context: &mut ExecutionContext,
930    ) -> Result<Vec<QueryRow>> {
931        const PER_ROW_MEMORY_ESTIMATE_BYTES: usize = 100;
932        const DEFAULT_AGGREGATION_MEMORY_LIMIT: usize = 512 * 1024 * 1024;
933
934        let mut agg_state = AggregationState {
935            groups: Vec::new(),
936            group_index: rustc_hash::FxHashMap::default(),
937            memory_usage_bytes: 0,
938            memory_limit_bytes: DEFAULT_AGGREGATION_MEMORY_LIMIT,
939        };
940
941        for row in rows {
942            let group_key = build_group_key(&row, &agg_plan.group_by_columns);
943            let group_index = find_or_init_group(&mut agg_state, group_key, &agg_plan.aggregates);
944            let group_aggregates = &mut agg_state.groups[group_index].1;
945
946            for (i, agg_comp) in agg_plan.aggregates.iter().enumerate() {
947                update_aggregate(&mut group_aggregates[i], agg_comp, &row);
948            }
949
950            agg_state.memory_usage_bytes += PER_ROW_MEMORY_ESTIMATE_BYTES;
951            if agg_state.memory_usage_bytes > agg_state.memory_limit_bytes {
952                return Err(Error::query_execution(
953                    "Aggregation memory limit exceeded".to_string(),
954                ));
955            }
956        }
957
958        let result_rows = agg_state
959            .groups
960            .into_iter()
961            .map(|(group_key, group_aggregates)| {
962                finalize_group(group_key, group_aggregates, agg_plan)
963            })
964            .collect();
965
966        Ok(result_rows)
967    }
968
969    /// Execute PER PARTITION LIMIT: keep at most `count` rows per partition,
970    /// preserving order (Issue #757). Counts are keyed on the partition rather
971    /// than tracking only the most recent partition, so the cap holds even when a
972    /// partition's rows are not contiguous — e.g. when an upstream `ORDER BY`
973    /// interleaves rows from different partitions (roborev job 38).
974    ///
975    /// Issue #1590 (E8): the counter map uses the partition-key 128-bit
976    /// [`partition_key_digest`] as a FAST outer lookup key, then confirms an
977    /// EXACT match on the raw key bytes (via [`admit_partition_row`]). No key
978    /// bytes are cloned per row — a partition's bytes are stored at most ONCE,
979    /// on the row that first opens its counter — and correctness stays
980    /// independent of digest collisions.
981    fn execute_per_partition_limit(rows: Vec<QueryRow>, count: u64) -> Vec<QueryRow> {
982        let mut out = Vec::with_capacity(rows.len());
983        let mut counts: PartitionCounts = HashMap::new();
984        for row in rows {
985            let digest = partition_key_digest(&row.key.0);
986            if admit_partition_row(&mut counts, digest, &row.key.0, count) {
987                out.push(row);
988            }
989        }
990        out
991    }
992
993    /// Execute limit step (apply OFFSET then truncate to LIMIT).
994    fn execute_limit(
995        &self,
996        mut rows: Vec<QueryRow>,
997        count: u64,
998        offset: Option<u64>,
999        _context: &mut ExecutionContext,
1000    ) -> Result<Vec<QueryRow>> {
1001        let start_index = offset.unwrap_or(0) as usize;
1002        let limit = count as usize;
1003        // Issue #1590 (E8): with no OFFSET, truncate in place — no allocation and
1004        // no element shift. With an OFFSET, apply it via `skip`/`take` instead of
1005        // `drain(..offset)`: `drain` memmoves every surviving element left, while
1006        // `skip` drops the prefix and yields the survivors without shifting. The
1007        // resulting rows are identical (same slice, same order); `skip` past the
1008        // end yields nothing, matching the old `start_index >= len` early return.
1009        if start_index == 0 {
1010            rows.truncate(limit);
1011            return Ok(rows);
1012        }
1013        Ok(rows.into_iter().skip(start_index).take(limit).collect())
1014    }
1015
1016    /// Execute projection step
1017    fn execute_projection(
1018        &self,
1019        rows: Vec<QueryRow>,
1020        columns: &[SelectExpression],
1021        _context: &mut ExecutionContext,
1022    ) -> Result<Vec<QueryRow>> {
1023        // Issue #1584: derive the projected output column names ONCE per query
1024        // (`O(columns)`), then only `Arc`-clone them into each row's value map
1025        // (a ref-count bump). Previously each name was re-derived — `String`
1026        // clone / `format!` — for every row × column (`O(rows × columns)`).
1027        // Cloning the hoisted `Arc<str>` preserves the exact output name, so the
1028        // materialized rows are byte-identical to the prior per-row derivation.
1029        let column_names: Vec<std::sync::Arc<str>> = columns
1030            .iter()
1031            .enumerate()
1032            .map(|(i, expr)| projected_column_name(expr, i))
1033            .collect();
1034
1035        let mut projected_rows = Vec::with_capacity(rows.len());
1036
1037        for row in rows {
1038            // Issue #1584: one sized allocation per row — no rehash growth.
1039            let mut projected_values: HashMap<std::sync::Arc<str>, Value> =
1040                HashMap::with_capacity(columns.len());
1041
1042            for (i, expr) in columns.iter().enumerate() {
1043                let value = self.evaluate_select_expression(expr, &row)?;
1044                projected_values.insert(column_names[i].clone(), value);
1045            }
1046
1047            projected_rows.push(QueryRow {
1048                values: projected_values,
1049                key: RowKey::new(vec![]),
1050                metadata: Default::default(),
1051                cell_metadata: None,
1052            });
1053        }
1054
1055        Ok(projected_rows)
1056    }
1057
1058    /// Trim a plain-column `Project` down to its selected columns WITHOUT
1059    /// reshaping the row (issue #1952 round-6 fix).
1060    ///
1061    /// The Project step's ONLY job for a plain-column SELECT (every expr is a
1062    /// bare `Column`) is to drop the helper columns the #1952 scan-widening added
1063    /// (WHERE / ORDER BY / GROUP BY / aggregate-argument columns that are not in
1064    /// the SELECT clause). It is NOT a reshape: a plain column is identity-named,
1065    /// so there are no new names to derive (#1763's alias/reshape naming does not
1066    /// apply) and no computed values to build.
1067    ///
1068    /// Unlike [`Self::execute_projection`] — which is correct for reshaping /
1069    /// computed rows but rebuilds each row with an EMPTY `RowKey` and errors on a
1070    /// selected cell that is absent from a sparse row — this preserves each row's
1071    /// real `key` (partition/clustering key), `metadata`, and `cell_metadata`
1072    /// unchanged, and simply omits any selected cell that a sparse row lacks
1073    /// (never an error). Preserving the key is the #1587-class contract downstream
1074    /// consumers (per-partition-limit boundary detection, dedup, ordering, callers
1075    /// inspecting `row.key`) rely on; before #1952 these bare-column selects
1076    /// skipped the Project entirely and streamed the RAW keyed row.
1077    ///
1078    /// Rows are name-keyed maps and output column ORDER is carried by the query
1079    /// metadata, so trimming = retaining the correct SET of named cells in place.
1080    fn trim_projection(
1081        &self,
1082        mut rows: Vec<QueryRow>,
1083        columns: &[SelectExpression],
1084    ) -> Vec<QueryRow> {
1085        // The selected plain-column source names. This method is only reached
1086        // when every projection expr is a plain `Column` (the caller gates on
1087        // `!columns.iter().any(project_expr_reshapes_row)`), so every expr
1088        // contributes its source name and none reshapes.
1089        let selected: std::collections::HashSet<&str> = columns
1090            .iter()
1091            .filter_map(|e| match e {
1092                SelectExpression::Column(c) => Some(c.column.as_str()),
1093                _ => None,
1094            })
1095            .collect();
1096
1097        for row in &mut rows {
1098            // Retain only the selected cells by source-name identity; key,
1099            // metadata, and cell_metadata are left untouched. A selected cell
1100            // absent from a sparse row is simply not present after the retain —
1101            // never an error.
1102            row.values
1103                .retain(|name, _| selected.contains(name.as_ref()));
1104        }
1105        rows
1106    }
1107
1108    /// Execute a query without FROM clause (constant expressions like SELECT 1)
1109    fn execute_constant_query(
1110        &self,
1111        statement: &SelectStatement,
1112        _context: &ExecutionContext,
1113    ) -> Result<QueryResult> {
1114        let mut values = HashMap::new();
1115        let mut columns = Vec::new();
1116
1117        match &statement.select_clause {
1118            SelectClause::All => {
1119                return Err(Error::query_execution(
1120                    "SELECT * requires a FROM clause".to_string(),
1121                ));
1122            }
1123            SelectClause::Columns(expressions) | SelectClause::Distinct(expressions) => {
1124                for (i, expr) in expressions.iter().enumerate() {
1125                    let (value, column_name) = self.evaluate_constant_expression(expr)?;
1126                    let key = column_name.unwrap_or_else(|| format!("column_{}", i));
1127                    values.insert(key.clone(), value);
1128                    columns.push(ColumnInfo {
1129                        name: key,
1130                        data_type: crate::types::DataType::Text, // Constant expressions have no schema type
1131                        nullable: true,
1132                        position: i,
1133                        table_name: None, // No table for constant expressions
1134                        cql_type: None,
1135                    });
1136                }
1137            }
1138        }
1139
1140        let row = QueryRow::with_values(RowKey::new(vec![1]), values);
1141
1142        Ok(QueryResult {
1143            rows: vec![row],
1144            rows_affected: 1, // Constant queries return 1 row
1145            execution_time_ms: 0,
1146            metadata: crate::query::result::QueryMetadata {
1147                columns,
1148                total_rows: Some(1),
1149                plan_info: None,
1150                performance: crate::query::result::PerformanceMetrics::default(),
1151                warnings: Vec::new(),
1152                // Constant queries (e.g. `SELECT 1`) touch no SSTable.
1153                access_path: None,
1154            },
1155        })
1156    }
1157
1158    /// Evaluate a constant expression (no table access needed).
1159    ///
1160    /// Accepts literals, aliases, and arithmetic over same-typed Integer,
1161    /// BigInt, or Float operands. Modulo is restricted to integers (matching
1162    /// the original behaviour). Error messages are kept verbatim from the
1163    /// legacy implementation so any callers asserting on them still pass.
1164    #[allow(clippy::only_used_in_recursion)]
1165    fn evaluate_constant_expression(
1166        &self,
1167        expr: &SelectExpression,
1168    ) -> Result<(Value, Option<String>)> {
1169        match expr {
1170            SelectExpression::Literal(value) => Ok((value.clone(), None)),
1171            SelectExpression::Aliased(inner_expr, alias) => {
1172                let (value, _) = self.evaluate_constant_expression(inner_expr)?;
1173                Ok((value, Some(alias.clone())))
1174            }
1175            SelectExpression::Arithmetic(arith) => {
1176                let (left_val, _) = self.evaluate_constant_expression(&arith.left)?;
1177                let (right_val, _) = self.evaluate_constant_expression(&arith.right)?;
1178                let result = const_arithmetic(&arith.operator, left_val, right_val)?;
1179                Ok((result, None))
1180            }
1181            _ => Err(Error::query_execution(
1182                "Expression type not supported in constant queries".to_string(),
1183            )),
1184        }
1185    }
1186
1187    /// Extract a `TableId` from a FROM clause. Cassandra CQL has no JOINs, so
1188    /// either form (bare table or aliased table) yields the same result.
1189    fn extract_table_id(&self, from_clause: &FromClause) -> Result<TableId> {
1190        match from_clause {
1191            FromClause::Table(table_id) | FromClause::TableAlias(table_id, _) => {
1192                Ok(table_id.clone())
1193            }
1194        }
1195    }
1196
1197    /// Resolve a table's schema ONCE per query into a shared `Arc<TableSchema>`
1198    /// (issue #1587, E5). Every downstream planning/execution step then borrows
1199    /// the SAME schema (`Arc::clone` is a ref-count bump) instead of re-taking the
1200    /// registry lock and deep-cloning a fresh `TableSchema` per step (which was
1201    /// 2–4 deep clones per query). The registry itself is untouched (AJ1/AJ2 own
1202    /// its shape) — this is purely query-side de-duplication.
1203    async fn resolve_table_schema(&self, table: &TableId) -> Option<Arc<TableSchema>> {
1204        let (keyspace, table_name) = parse_table_id(table);
1205        // The registry owns freshness (issue #1708): an expired entry that cannot
1206        // be refreshed surfaces as `Err`; schema resolution here is best-effort
1207        // (missing/unresolvable schema falls back to row-derived columns), so a
1208        // refresh error folds to `None` rather than aborting the query.
1209        self._schema
1210            .find_schema_by_table(&keyspace, &table_name)
1211            .await
1212            .ok()
1213            .flatten()
1214            .map(Arc::new)
1215    }
1216
1217    /// Build result-column metadata from an ALREADY-RESOLVED schema (issue #1587,
1218    /// E5). The caller resolves the schema once per query and passes it here, so
1219    /// this never re-clones it out of the registry.
1220    fn get_result_columns(
1221        &self,
1222        statement: &SelectStatement,
1223        schema: Option<&TableSchema>,
1224    ) -> Result<Vec<ColumnInfo>> {
1225        let mut columns = Vec::new();
1226
1227        match &statement.select_clause {
1228            SelectClause::All => {
1229                // For SELECT *, use the schema to get column names and CQL types.
1230                // This is needed for streaming mode where we can't wait for the first row.
1231                if let Some(ref from_clause) = statement.from_clause {
1232                    let table_id = self.extract_table_id(from_clause)?;
1233                    let (keyspace_opt, table_name) = parse_table_id(&table_id);
1234
1235                    if let Some(schema) = schema {
1236                        // Collect all schema columns (sorted alphabetically for determinism)
1237                        let mut schema_cols: Vec<&crate::schema::Column> =
1238                            schema.columns.iter().collect();
1239                        schema_cols.sort_by_key(|c| c.name.as_str());
1240
1241                        let keyspace_str = keyspace_opt.as_deref().unwrap_or("");
1242                        let table_name_str = format!("{}.{}", keyspace_str, table_name);
1243
1244                        for (idx, schema_col) in schema_cols.iter().enumerate() {
1245                            columns.push(column_info_from_type_str(
1246                                schema_col.name.clone(),
1247                                &schema_col.data_type,
1248                                idx,
1249                                Some(table_name_str.clone()),
1250                            ));
1251                        }
1252
1253                        log::debug!(
1254                            "SELECT * resolved {} columns from schema for {:?}.{}",
1255                            columns.len(),
1256                            keyspace_opt,
1257                            table_name
1258                        );
1259                    }
1260                    // If schema not found, columns stay empty - will be populated from first row at runtime
1261                }
1262            }
1263            SelectClause::Columns(exprs) | SelectClause::Distinct(exprs) => {
1264                // Issue #674: attach authoritative CQL types to explicitly projected
1265                // columns from the pre-resolved schema.
1266                let schema_opt = schema;
1267
1268                for (i, expr) in exprs.iter().enumerate() {
1269                    // Issue #692: WriteTimeTtl expressions produce fixed-schema output
1270                    // columns with Cassandra-convention names, independent of the table schema.
1271                    if let SelectExpression::WriteTimeTtl(call) = expr {
1272                        let col_name = writetime_ttl_column_name(call);
1273                        let (data_type, cql_type) = match call.function {
1274                            // WRITETIME returns bigint (µs since epoch)
1275                            WriteTimeTtlFunction::WriteTime => {
1276                                (crate::types::DataType::BigInt, Some(CqlType::BigInt))
1277                            }
1278                            // TTL returns int (remaining seconds)
1279                            WriteTimeTtlFunction::Ttl => {
1280                                (crate::types::DataType::Integer, Some(CqlType::Int))
1281                            }
1282                        };
1283                        let mut col_info = ColumnInfo {
1284                            name: col_name,
1285                            data_type,
1286                            nullable: true, // always nullable — absent cell → NULL
1287                            position: i,
1288                            table_name: None,
1289                            cql_type: None,
1290                        };
1291                        if let Some(ct) = cql_type {
1292                            col_info = col_info.with_cql_type(ct);
1293                        }
1294                        columns.push(col_info);
1295                        continue;
1296                    }
1297
1298                    // Issue #1763: name aggregate result columns via the SAME
1299                    // single source (`result_column_name` → `aggregate_output_name`)
1300                    // that keys the emitted row values in `finalize_group`, so
1301                    // metadata and row keys can never diverge (never `col_N`).
1302                    let column_name = crate::query::select_naming::result_column_name(expr, i);
1303
1304                    // Look up CQL type for this column in the schema (Issue #674).
1305                    let cql_type_opt = schema_opt.and_then(|schema| {
1306                        schema
1307                            .columns
1308                            .iter()
1309                            .find(|c| c.name == column_name)
1310                            .and_then(|c| parse_cql_type_str(&c.data_type))
1311                    });
1312                    let data_type = cql_type_opt
1313                        .as_ref()
1314                        .map(cql_type_to_data_type)
1315                        .unwrap_or(crate::types::DataType::Text);
1316
1317                    let mut col_info = ColumnInfo {
1318                        name: column_name,
1319                        data_type,
1320                        nullable: true,
1321                        position: i,
1322                        table_name: None,
1323                        cql_type: None,
1324                    };
1325                    if let Some(cql_type) = cql_type_opt {
1326                        col_info = col_info.with_cql_type(cql_type);
1327                    }
1328                    columns.push(col_info);
1329                }
1330            }
1331        }
1332
1333        Ok(columns)
1334    }
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339    use super::test_support::row_with_key;
1340    use super::*;
1341    use crate::query::result::{CellExpiration, CellWriteMetadata};
1342    use crate::{platform::Platform, Config};
1343    use tempfile::TempDir;
1344
1345    /// Issue #1587 (E5): a query resolves its table schema ONCE and shares it by
1346    /// `Arc`, so a single `execute()` deep-clones the `TableSchema` out of the
1347    /// registry exactly once — NOT once per planning/execution step (column
1348    /// metadata + scan + fallback each re-resolved before, giving 2–4 clones).
1349    /// Runs over empty storage so the count is deterministic and data-free: the
1350    /// SELECT-* column metadata AND the scan step both consume the pre-resolved
1351    /// schema.
1352    #[tokio::test]
1353    async fn execute_resolves_schema_once_per_query() {
1354        use crate::schema::TABLE_SCHEMA_CLONES;
1355
1356        let temp_dir = TempDir::new().unwrap();
1357        let config = Config::default();
1358        let platform = Arc::new(Platform::new(&config).await.unwrap());
1359        let storage = Arc::new(
1360            StorageEngine::open(
1361                temp_dir.path(),
1362                &config,
1363                platform.clone(),
1364                #[cfg(feature = "state_machine")]
1365                None,
1366            )
1367            .await
1368            .unwrap(),
1369        );
1370        let schema = Arc::new(SchemaManager::new(temp_dir.path()).await.unwrap());
1371        schema
1372            .parse_and_register_cql_schema(
1373                "CREATE TABLE ks.t (id int PRIMARY KEY, name text, age int)",
1374            )
1375            .await
1376            .expect("schema registers");
1377
1378        let executor = SelectExecutor::new(schema.clone(), storage.clone());
1379        let optimizer =
1380            crate::query::select_optimizer::SelectOptimizer::new(schema.clone(), storage.clone());
1381
1382        let statement = crate::query::select_parser::parse_select("SELECT * FROM ks.t").unwrap();
1383        // Optimization performs no schema resolution; reset the counter AFTER it.
1384        let plan = optimizer.optimize(statement).await.unwrap();
1385
1386        TABLE_SCHEMA_CLONES.with(|c| c.set(0));
1387        let _ = executor.execute(plan).await.expect("query executes");
1388        let clones = TABLE_SCHEMA_CLONES.with(|c| c.get());
1389
1390        assert_eq!(
1391            clones, 1,
1392            "issue #1587: a query must deep-clone its schema out of the registry once \
1393             (was 2–4: column-metadata + scan + fallback each re-resolved), got {clones}"
1394        );
1395    }
1396
1397    async fn create_test_executor() -> SelectExecutor {
1398        let temp_dir = TempDir::new().unwrap();
1399        let config = Config::default();
1400        let platform = Arc::new(Platform::new(&config).await.unwrap());
1401        let storage = Arc::new(
1402            StorageEngine::open(
1403                temp_dir.path(),
1404                &config,
1405                platform.clone(),
1406                #[cfg(feature = "state_machine")]
1407                None,
1408            )
1409            .await
1410            .unwrap(),
1411        );
1412        let schema = Arc::new(SchemaManager::new(temp_dir.path()).await.unwrap());
1413
1414        SelectExecutor::new(schema, storage)
1415    }
1416
1417    /// Create an executor with a fixed clock (deterministic TTL tests).
1418    async fn create_test_executor_with_clock(now_secs: i64) -> SelectExecutor {
1419        let temp_dir = TempDir::new().unwrap();
1420        let config = Config::default();
1421        let platform = Arc::new(Platform::new(&config).await.unwrap());
1422        let storage = Arc::new(
1423            StorageEngine::open(
1424                temp_dir.path(),
1425                &config,
1426                platform.clone(),
1427                #[cfg(feature = "state_machine")]
1428                None,
1429            )
1430            .await
1431            .unwrap(),
1432        );
1433        let schema = Arc::new(SchemaManager::new(temp_dir.path()).await.unwrap());
1434
1435        SelectExecutor::with_clock(schema, storage, Arc::new(FixedClock(now_secs)))
1436    }
1437
1438    /// Helper: build a QueryRow with a given column value and optional cell metadata.
1439    fn row_with_cell_meta(column: &str, value: Value, meta: Option<CellWriteMetadata>) -> QueryRow {
1440        let mut row = QueryRow::new(RowKey::new(vec![1]));
1441        row.set(column.to_string(), value);
1442        if let Some(m) = meta {
1443            row.insert_cell_metadata(column.to_string(), m);
1444        }
1445        row
1446    }
1447
1448    #[tokio::test]
1449    async fn test_like_pattern_matching() {
1450        let executor = create_test_executor().await;
1451
1452        assert!(executor.match_like_pattern("hello", "h%"));
1453        assert!(executor.match_like_pattern("hello", "%lo"));
1454        assert!(executor.match_like_pattern("hello", "h_llo"));
1455        assert!(!executor.match_like_pattern("hello", "h_l"));
1456    }
1457
1458    /// Regression (roborev job 38): in the batch path PER PARTITION LIMIT must
1459    /// cap per partition even when a partition's rows are NOT contiguous (e.g.
1460    /// after ORDER BY interleaves them). Counting must key on the partition, not
1461    /// just track the most recent one.
1462    #[test]
1463    fn per_partition_limit_caps_interleaved_partitions() {
1464        let a = b"A".as_slice();
1465        let b = b"B".as_slice();
1466        // Partition A appears 3 times but is split by a B row in the middle.
1467        let rows = vec![
1468            row_with_key(a),
1469            row_with_key(b),
1470            row_with_key(a),
1471            row_with_key(a),
1472            row_with_key(b),
1473        ];
1474        let out = SelectExecutor::execute_per_partition_limit(rows, 2);
1475        let count = |p: &[u8]| out.iter().filter(|r| r.key.0 == p).count();
1476        assert_eq!(
1477            count(a),
1478            2,
1479            "partition A must be capped at 2 despite interleaving"
1480        );
1481        assert_eq!(count(b), 2, "partition B has 2 rows, all kept");
1482        assert_eq!(out.len(), 4);
1483    }
1484
1485    /// Issue #1584: projected column NAMES are derived once per query
1486    /// (`O(columns)`), never per row (`O(rows × columns)`). Cloning the hoisted
1487    /// `Arc<str>` per row is permitted; re-deriving / formatting a name per row is
1488    /// the regression this pins. `#[tokio::test]` runs on the current-thread
1489    /// runtime and the derivation counter is thread-local, so this is immune to
1490    /// pollution from other tests running in parallel.
1491    #[tokio::test]
1492    async fn execute_projection_derives_names_once_per_query() {
1493        let executor = create_test_executor().await;
1494
1495        let columns = vec![
1496            SelectExpression::Column(ColumnRef {
1497                table: None,
1498                column: "a".to_string(),
1499            }),
1500            SelectExpression::Column(ColumnRef {
1501                table: None,
1502                column: "b".to_string(),
1503            }),
1504            SelectExpression::Column(ColumnRef {
1505                table: None,
1506                column: "c".to_string(),
1507            }),
1508        ];
1509        let num_cols = columns.len();
1510        let num_rows = 100usize;
1511
1512        let rows: Vec<QueryRow> = (0..num_rows)
1513            .map(|r| {
1514                let mut row = QueryRow::new(RowKey::new(vec![r as u8]));
1515                row.set("a", Value::Integer(r as i32));
1516                row.set("b", Value::Integer(r as i32));
1517                row.set("c", Value::Integer(r as i32));
1518                row
1519            })
1520            .collect();
1521
1522        let mut ctx = ExecutionContext {
1523            table_id: TableId::new("ks.t"),
1524            columns: Vec::new(),
1525            rows_processed: 0,
1526            scan_rows: 0,
1527            projection_flags: ProjectionFlags::default(),
1528            access_path: None,
1529            reverse_served: false,
1530        };
1531
1532        PROJECTION_NAME_DERIVATIONS.with(|c| c.set(0));
1533        let projected = executor
1534            .execute_projection(rows, &columns, &mut ctx)
1535            .expect("projection must succeed");
1536        let derivations = PROJECTION_NAME_DERIVATIONS.with(|c| c.get());
1537
1538        assert_eq!(projected.len(), num_rows, "one projected row per input row");
1539        assert_eq!(
1540            derivations,
1541            num_cols,
1542            "issue #1584: column names must be derived once per query \
1543             (O(cols) = {num_cols}), not per row (would be {})",
1544            num_rows * num_cols
1545        );
1546        // Output preserved byte-identically: names + values intact.
1547        assert_eq!(projected[0].values.get("a"), Some(&Value::Integer(0)));
1548        assert_eq!(projected[0].values.get("b"), Some(&Value::Integer(0)));
1549        assert_eq!(projected[0].values.get("c"), Some(&Value::Integer(0)));
1550        assert_eq!(projected[99].values.get("a"), Some(&Value::Integer(99)));
1551    }
1552
1553    /// Issue #1952 (round-6 HIGH): a plain-column `Project` that only TRIMS the
1554    /// #1952-widened helper columns must PRESERVE each row's real `RowKey`,
1555    /// `metadata`, and `cell_metadata`, and drop only the unselected helper
1556    /// columns. Pre-fix these bare-column selects routed through
1557    /// `execute_projection`, which rebuilt every row with an EMPTY `RowKey`
1558    /// (`vec![]`) — a #1587-class regression. This pins the key-preserving trim.
1559    #[tokio::test]
1560    async fn trim_projection_preserves_key_and_trims_helpers() {
1561        let executor = create_test_executor().await;
1562
1563        // SELECT a, b  (helper column `helper` was added to the scan by #1952).
1564        let columns = vec![
1565            SelectExpression::Column(ColumnRef {
1566                table: None,
1567                column: "a".to_string(),
1568            }),
1569            SelectExpression::Column(ColumnRef {
1570                table: None,
1571                column: "b".to_string(),
1572            }),
1573        ];
1574
1575        let mut row = QueryRow::new(RowKey::new(vec![7, 8, 9]));
1576        row.set("a", Value::Integer(1));
1577        row.set("b", Value::Integer(2));
1578        row.set("helper", Value::Integer(3));
1579        row.set_metadata(crate::query::result::RowMetadata {
1580            version: Some(42),
1581            ttl: None,
1582            tags: Default::default(),
1583        });
1584
1585        let out = executor.trim_projection(vec![row], &columns);
1586        assert_eq!(out.len(), 1);
1587        let r = &out[0];
1588
1589        // Key PRESERVED (the core regression): not the empty `vec![]`.
1590        assert_eq!(
1591            r.key.0,
1592            vec![7, 8, 9],
1593            "trim must preserve the real RowKey, not destroy it to vec![]"
1594        );
1595        // Row metadata preserved.
1596        assert_eq!(
1597            r.metadata.version,
1598            Some(42),
1599            "row metadata must be preserved"
1600        );
1601        // Selected columns kept, helper trimmed.
1602        assert_eq!(r.values.get("a"), Some(&Value::Integer(1)));
1603        assert_eq!(r.values.get("b"), Some(&Value::Integer(2)));
1604        assert!(
1605            !r.values.contains_key("helper"),
1606            "the unselected helper column must be trimmed"
1607        );
1608        let mut keys: Vec<&str> = r.values.keys().map(|k| k.as_ref()).collect();
1609        keys.sort_unstable();
1610        assert_eq!(keys, vec!["a", "b"], "only the selected columns remain");
1611    }
1612
1613    /// Issue #1952 (round-6 HIGH, second defect): a selected column ABSENT from a
1614    /// sparse row must be OMITTED by the trim, never an error. Pre-fix
1615    /// `execute_projection` called `evaluate_select_expression(Column)`, which
1616    /// returns `Err("Column not found: <col>")` for an absent cell — so a sparse
1617    /// row aborted the whole query. This pins the tolerant trim AND documents the
1618    /// contrasting pre-fix defect (`execute_projection` still errors on the same
1619    /// input), proving the regression is real.
1620    #[tokio::test]
1621    async fn trim_projection_tolerates_absent_selected_cell() {
1622        let executor = create_test_executor().await;
1623
1624        // SELECT a, b — but this row is sparse: it has `a` (and a helper) but no `b`.
1625        let columns = vec![
1626            SelectExpression::Column(ColumnRef {
1627                table: None,
1628                column: "a".to_string(),
1629            }),
1630            SelectExpression::Column(ColumnRef {
1631                table: None,
1632                column: "b".to_string(),
1633            }),
1634        ];
1635
1636        let build_row = || {
1637            let mut row = QueryRow::new(RowKey::new(vec![1]));
1638            row.set("a", Value::Integer(10));
1639            row.set("helper", Value::Integer(99));
1640            row
1641        };
1642
1643        // NEW trim path: no error; `b` simply omitted, `a` kept, helper trimmed,
1644        // key preserved.
1645        let out = executor.trim_projection(vec![build_row()], &columns);
1646        assert_eq!(out.len(), 1);
1647        let r = &out[0];
1648        assert_eq!(r.key.0, vec![1], "sparse row keeps its real key");
1649        assert_eq!(r.values.get("a"), Some(&Value::Integer(10)));
1650        assert!(
1651            !r.values.contains_key("b"),
1652            "absent selected cell `b` is omitted, not defaulted"
1653        );
1654        assert!(!r.values.contains_key("helper"), "helper trimmed");
1655
1656        // CONTRAST: the pre-fix path (`execute_projection`) ERRORS on the same
1657        // sparse input — this is exactly the defect the trim fixes.
1658        let mut ctx = ExecutionContext {
1659            table_id: TableId::new("ks.t"),
1660            columns: Vec::new(),
1661            rows_processed: 0,
1662            scan_rows: 0,
1663            projection_flags: ProjectionFlags::default(),
1664            access_path: None,
1665            reverse_served: false,
1666        };
1667        let err = executor
1668            .execute_projection(vec![build_row()], &columns, &mut ctx)
1669            .expect_err("pre-fix execute_projection must error on the absent cell");
1670        assert!(
1671            err.to_string().contains("Column not found"),
1672            "the pre-fix defect is a 'Column not found' error on a sparse row; got: {err}"
1673        );
1674    }
1675
1676    /// Issue #1590 (E8): `execute_limit` now applies OFFSET via `skip`/`take`
1677    /// (was `drain(..offset)` + `truncate`). This pins that the new path yields
1678    /// the SAME rows as the old drain/truncate reference across a matrix of
1679    /// (len, offset, limit) — including offset past the end, zero offset, and
1680    /// limit exceeding the remainder. No behavior change.
1681    #[tokio::test]
1682    async fn execute_limit_offset_matches_drain_truncate_reference() {
1683        let executor = create_test_executor().await;
1684        let mut ctx = ExecutionContext {
1685            table_id: TableId::new("ks.t"),
1686            columns: Vec::new(),
1687            rows_processed: 0,
1688            scan_rows: 0,
1689            projection_flags: ProjectionFlags::default(),
1690            access_path: None,
1691            reverse_served: false,
1692        };
1693
1694        let make = |len: usize| -> Vec<QueryRow> {
1695            (0..len)
1696                .map(|r| {
1697                    let mut row = QueryRow::new(RowKey::new(vec![r as u8]));
1698                    row.set("a", Value::Integer(r as i32));
1699                    row
1700                })
1701                .collect()
1702        };
1703        // The old in-place algorithm, kept verbatim as the parity oracle.
1704        let reference = |mut rows: Vec<QueryRow>, count: u64, offset: Option<u64>| {
1705            let start_index = offset.unwrap_or(0) as usize;
1706            if start_index >= rows.len() {
1707                return Vec::new();
1708            }
1709            rows.drain(..start_index);
1710            rows.truncate(count as usize);
1711            rows
1712        };
1713        let tags = |rows: &[QueryRow]| -> Vec<i32> {
1714            rows.iter()
1715                .map(|r| match r.values.get("a") {
1716                    Some(Value::Integer(v)) => *v,
1717                    _ => -1,
1718                })
1719                .collect()
1720        };
1721
1722        for len in [0usize, 1, 5, 10] {
1723            for offset in [
1724                None,
1725                Some(0u64),
1726                Some(1),
1727                Some(3),
1728                Some(9),
1729                Some(10),
1730                Some(50),
1731            ] {
1732                for count in [0u64, 1, 3, 10, 1000] {
1733                    let got = executor
1734                        .execute_limit(make(len), count, offset, &mut ctx)
1735                        .expect("limit must succeed");
1736                    let want = reference(make(len), count, offset);
1737                    assert_eq!(
1738                        tags(&got),
1739                        tags(&want),
1740                        "len={len} offset={offset:?} count={count}: skip/take must \
1741                         equal drain/truncate"
1742                    );
1743                }
1744            }
1745        }
1746    }
1747
1748    /// Issue #1590 (E8): PER PARTITION LIMIT now keys its per-partition counters
1749    /// on the partition-key 128-bit digest instead of a cloned `Vec<u8>` of the
1750    /// raw key bytes. Pin that the digest-keyed batch path yields the SAME rows
1751    /// as the raw-bytes reference — including NON-contiguous (interleaved)
1752    /// partitions, where the cap must still be enforced per distinct partition.
1753    #[test]
1754    fn per_partition_limit_digest_matches_raw_bytes_reference() {
1755        // Partition key bytes → row tag. Interleave partitions so the counter
1756        // cannot rely on contiguity (roborev job 38 invariant).
1757        let spec: [(&[u8], i32); 9] = [
1758            (b"pk-a", 0),
1759            (b"pk-b", 1),
1760            (b"pk-a", 2),
1761            (b"pk-c", 3),
1762            (b"pk-b", 4),
1763            (b"pk-a", 5),
1764            (b"pk-a", 6),
1765            (b"pk-c", 7),
1766            (b"pk-b", 8),
1767        ];
1768        let make = || -> Vec<QueryRow> {
1769            spec.iter()
1770                .map(|(pk, tag)| {
1771                    let mut row = QueryRow::new(RowKey::new(pk.to_vec()));
1772                    row.set("a", Value::Integer(*tag));
1773                    row
1774                })
1775                .collect()
1776        };
1777        // Old raw-`Vec<u8>`-keyed algorithm, kept verbatim as the parity oracle.
1778        let reference = |rows: Vec<QueryRow>, count: u64| -> Vec<QueryRow> {
1779            let mut out = Vec::with_capacity(rows.len());
1780            let mut counts: HashMap<Vec<u8>, u64> = HashMap::new();
1781            for row in rows {
1782                let seen = counts.entry(row.key.0.clone()).or_insert(0);
1783                if *seen < count {
1784                    *seen += 1;
1785                    out.push(row);
1786                }
1787            }
1788            out
1789        };
1790        let tags = |rows: &[QueryRow]| -> Vec<i32> {
1791            rows.iter()
1792                .map(|r| match r.values.get("a") {
1793                    Some(Value::Integer(v)) => *v,
1794                    _ => -1,
1795                })
1796                .collect()
1797        };
1798
1799        for count in [0u64, 1, 2, 3, 100] {
1800            let got = SelectExecutor::execute_per_partition_limit(make(), count);
1801            let want = reference(make(), count);
1802            assert_eq!(
1803                tags(&got),
1804                tags(&want),
1805                "count={count}: digest-keyed per-partition-limit must equal raw-bytes reference"
1806            );
1807        }
1808    }
1809
1810    /// Issue #1590 (E8, roborev fix #4 follow-up): PER PARTITION LIMIT uses the
1811    /// 128-bit digest only as a FAST outer key; it confirms EXACT partition-key
1812    /// bytes before sharing a counter. Correctness must NOT depend on
1813    /// collision absence. Real 128-bit digest collisions can't be produced, so
1814    /// drive [`admit_partition_row`] with a CONSTANT (forced-colliding) digest
1815    /// for two DISTINCT keys and assert each key keeps its OWN counter — i.e. no
1816    /// valid row is dropped when digests collide.
1817    #[test]
1818    fn per_partition_limit_exact_confirm_survives_digest_collision() {
1819        let mut counts: PartitionCounts = HashMap::new();
1820        // A single digest value shared by two genuinely distinct partitions.
1821        const COLLIDING: u128 = 0xDEAD_BEEF;
1822        let a = b"partition-a".as_slice();
1823        let b = b"partition-b".as_slice();
1824
1825        // cap = 1 per partition. A's first row opens A's counter.
1826        assert!(
1827            admit_partition_row(&mut counts, COLLIDING, a, 1),
1828            "A's first row is admitted"
1829        );
1830        // B collides on the digest but is a DISTINCT key: exact-byte confirm
1831        // gives it its OWN counter, so its first row is admitted (NOT dropped by
1832        // A's now-exhausted counter). This is the exact bug the finding flags.
1833        assert!(
1834            admit_partition_row(&mut counts, COLLIDING, b, 1),
1835            "B's first row is admitted despite the colliding digest (separate counter)"
1836        );
1837        // Each partition's cap of 1 is now independently exhausted.
1838        assert!(
1839            !admit_partition_row(&mut counts, COLLIDING, a, 1),
1840            "A's second row is capped"
1841        );
1842        assert!(
1843            !admit_partition_row(&mut counts, COLLIDING, b, 1),
1844            "B's second row is capped"
1845        );
1846        // Both distinct keys are chained under the one colliding digest bucket,
1847        // proving the exact-confirm path (not the digest) disambiguates them.
1848        assert_eq!(
1849            counts.get(&COLLIDING).map(Vec::len),
1850            Some(2),
1851            "both distinct keys are chained under the colliding digest"
1852        );
1853    }
1854
1855    /// Issue #1587 (E5): ORDER BY uses decorate-sort-undecorate, so each row's
1856    /// sort key is evaluated EXACTLY once (`O(rows × items)`), never inside the
1857    /// comparator (`O(n log n)` evaluations + a `Value::clone` per comparison).
1858    /// The counter pins the linear evaluation budget; the value assertions pin
1859    /// that the resulting order is byte-identical to the comparator sort.
1860    #[tokio::test]
1861    async fn execute_sort_evaluates_keys_once_per_row() {
1862        let executor = create_test_executor().await;
1863
1864        let num_rows = 64usize;
1865        // Reverse-sorted input so the sort must actually reorder every row (a
1866        // pre-sorted input could let some sorts short-circuit comparisons).
1867        let rows: Vec<QueryRow> = (0..num_rows)
1868            .rev()
1869            .map(|r| {
1870                let mut row = QueryRow::new(RowKey::new(vec![r as u8]));
1871                row.set("k", Value::Integer(r as i32));
1872                row
1873            })
1874            .collect();
1875
1876        let order_by = OrderByClause {
1877            items: vec![OrderByItem {
1878                expression: SelectExpression::Column(ColumnRef {
1879                    table: None,
1880                    column: "k".to_string(),
1881                }),
1882                direction: SortDirection::Ascending,
1883            }],
1884        };
1885
1886        let mut ctx = ExecutionContext {
1887            table_id: TableId::new("ks.t"),
1888            columns: Vec::new(),
1889            rows_processed: 0,
1890            scan_rows: 0,
1891            projection_flags: ProjectionFlags::default(),
1892            access_path: None,
1893            reverse_served: false,
1894        };
1895
1896        SORT_KEY_EVALUATIONS.with(|c| c.set(0));
1897        let sorted = executor
1898            .execute_sort(rows, &order_by, &mut ctx)
1899            .expect("sort must succeed");
1900        let evaluations = SORT_KEY_EVALUATIONS.with(|c| c.get());
1901
1902        // Exactly one evaluation per (row × order-by item). A comparator-based
1903        // sort would evaluate ~2 per pairwise comparison — strictly more than
1904        // `num_rows` for any non-trivial input (e.g. ~2·n·log2(n) ≫ n here).
1905        assert_eq!(
1906            evaluations, num_rows,
1907            "issue #1587: sort keys must be evaluated once per row (n = {num_rows}), \
1908             not O(n log n) times inside the comparator"
1909        );
1910
1911        // Ordering preserved byte-identically: ascending 0..num_rows.
1912        assert_eq!(sorted.len(), num_rows);
1913        for (i, row) in sorted.iter().enumerate() {
1914            assert_eq!(
1915                row.values.get("k"),
1916                Some(&Value::Integer(i as i32)),
1917                "row {i} must sort into ascending position"
1918            );
1919        }
1920    }
1921
1922    /// Issue #1587 (E5): the decorate-sort-undecorate refactor must PRESERVE the
1923    /// ORDER BY ordering for float keys — including NaN and signed zero — exactly
1924    /// as the pre-refactor per-comparison sort produced it. It reuses the shared
1925    /// `compare_values_ordering` comparator with a stable `sort_by`, so this test
1926    /// drives the decorate-sort path (via `execute_sort`) and asserts:
1927    ///
1928    ///   * with NaN present, the decorated output is order-IDENTICAL to a direct
1929    ///     reference sort over the same keys and comparator (ASC and DESC) — the
1930    ///     core preservation guarantee; and
1931    ///   * over a NaN-free float input (where the comparator IS a total order),
1932    ///     finite keys are correctly ordered and signed zeros (-0.0 vs +0.0, which
1933    ///     compare Equal) keep input order (stable).
1934    ///
1935    /// NOTE (pre-existing gaps, out of scope for #1587): `compare_values_ordering`
1936    /// compares floats via `f64::partial_cmp` (NaN → Equal, -0.0 == +0.0). So it
1937    /// does NOT reproduce Cassandra/Java float ordering (NaN sorted LAST,
1938    /// -0.0 < +0.0), and — because a NaN key makes the comparator a NON-total
1939    /// order — a NaN in the input leaves even the finite keys in an unspecified
1940    /// order. This test therefore pins the ordering the decorate-sort ACTUALLY
1941    /// preserves, not the (currently absent) Java semantics. Making ORDER BY match
1942    /// Cassandra float ordering is a separate behavior change; reported separately.
1943    #[tokio::test]
1944    async fn execute_sort_preserves_float_nan_signed_zero_ordering() {
1945        let executor = create_test_executor().await;
1946
1947        let make_rows = |inputs: &[(u8, f64)]| -> Vec<QueryRow> {
1948            inputs
1949                .iter()
1950                .map(|(tag, f)| {
1951                    let mut row = QueryRow::new(RowKey::new(vec![*tag]));
1952                    row.set("f", Value::Float(*f));
1953                    row
1954                })
1955                .collect()
1956        };
1957        let order_by = |dir: &SortDirection| OrderByClause {
1958            items: vec![OrderByItem {
1959                expression: SelectExpression::Column(ColumnRef {
1960                    table: None,
1961                    column: "f".to_string(),
1962                }),
1963                direction: dir.clone(),
1964            }],
1965        };
1966        let mut ctx = ExecutionContext {
1967            table_id: TableId::new("ks.t"),
1968            columns: Vec::new(),
1969            rows_processed: 0,
1970            scan_rows: 0,
1971            projection_flags: ProjectionFlags::default(),
1972            access_path: None,
1973            reverse_served: false,
1974        };
1975        let tags = |rows: &[QueryRow]| -> Vec<u8> { rows.iter().map(|r| r.key.0[0]).collect() };
1976
1977        // --- Part A: NaN present → decorate-sort is order-identical to reference.
1978        // Tags 2 (-0.0) and 4 (+0.0) compare Equal; NaN appears twice (tags 1, 6).
1979        let with_nan: [(u8, f64); 7] = [
1980            (0, 2.0),
1981            (1, f64::NAN),
1982            (2, -0.0),
1983            (3, 1.0),
1984            (4, 0.0),
1985            (5, -1.0),
1986            (6, f64::NAN),
1987        ];
1988        for dir in [SortDirection::Ascending, SortDirection::Descending] {
1989            let sorted = executor
1990                .execute_sort(make_rows(&with_nan), &order_by(&dir), &mut ctx)
1991                .expect("sort must succeed");
1992
1993            let mut reference = make_rows(&with_nan);
1994            reference.sort_by(|a, b| {
1995                let (ka, kb) = (
1996                    a.values.get("f").expect("key"),
1997                    b.values.get("f").expect("key"),
1998                );
1999                match dir {
2000                    SortDirection::Ascending => compare_values_ordering(ka, kb),
2001                    SortDirection::Descending => compare_values_ordering(kb, ka),
2002                }
2003            });
2004            assert_eq!(
2005                tags(&sorted),
2006                tags(&reference),
2007                "issue #1587: decorate-sort must be order-identical to the reference \
2008                 comparator sort for float/NaN keys ({dir:?})"
2009            );
2010        }
2011
2012        // --- Part B: NaN-free input → the comparator is a total order, so the
2013        // decorate-sort must produce correct finite ordering and stable signed
2014        // zeros. Tags 2 (-0.0) and 4 (+0.0) compare Equal (must keep input order).
2015        let no_nan: [(u8, f64); 5] = [(0, 2.0), (2, -0.0), (3, 1.0), (4, 0.0), (5, -1.0)];
2016        // Ascending: -1.0, then -0.0 (tag 2) then +0.0 (tag 4) [stable tie], 1.0, 2.0.
2017        let asc = executor
2018            .execute_sort(
2019                make_rows(&no_nan),
2020                &order_by(&SortDirection::Ascending),
2021                &mut ctx,
2022            )
2023            .expect("sort must succeed");
2024        assert_eq!(
2025            tags(&asc),
2026            vec![5, 2, 4, 3, 0],
2027            "ascending float order with stable signed zeros"
2028        );
2029        // Descending: 2.0, 1.0, then -0.0/+0.0 (stable tie: 2 before 4), -1.0.
2030        let desc = executor
2031            .execute_sort(
2032                make_rows(&no_nan),
2033                &order_by(&SortDirection::Descending),
2034                &mut ctx,
2035            )
2036            .expect("sort must succeed");
2037        assert_eq!(
2038            tags(&desc),
2039            vec![0, 3, 2, 4, 5],
2040            "descending float order with stable signed zeros"
2041        );
2042    }
2043
2044    /// The executor's `evaluate_select_expression` returns the correct value for
2045    /// a WRITETIME call when cell metadata is pre-attached to the row.
2046    #[tokio::test]
2047    async fn test_executor_evaluate_writetime_reads_cell_metadata() {
2048        let executor = create_test_executor_with_clock(0).await;
2049
2050        let write_ts = 1_700_000_000_000_000_i64;
2051        let row = row_with_cell_meta(
2052            "name",
2053            Value::Text("Carol".to_string()),
2054            Some(CellWriteMetadata {
2055                write_timestamp_micros: write_ts,
2056                expiration: None,
2057            }),
2058        );
2059
2060        let expr = SelectExpression::WriteTimeTtl(WriteTimeTtlCall {
2061            function: WriteTimeTtlFunction::WriteTime,
2062            column: "name".to_string(),
2063            alias: None,
2064        });
2065
2066        let result = executor.evaluate_select_expression(&expr, &row).unwrap();
2067        assert_eq!(result, Value::BigInt(write_ts));
2068    }
2069
2070    /// The executor's `evaluate_select_expression` returns NULL for WRITETIME
2071    /// when cell metadata is absent (the common case before the storage reader
2072    /// is updated to thread metadata).
2073    #[tokio::test]
2074    async fn test_executor_evaluate_writetime_null_when_no_metadata() {
2075        let executor = create_test_executor_with_clock(0).await;
2076
2077        // Row has the column value but no attached cell metadata.
2078        let row = row_with_cell_meta("name", Value::Text("Dave".to_string()), None);
2079
2080        let expr = SelectExpression::WriteTimeTtl(WriteTimeTtlCall {
2081            function: WriteTimeTtlFunction::WriteTime,
2082            column: "name".to_string(),
2083            alias: None,
2084        });
2085
2086        let result = executor.evaluate_select_expression(&expr, &row).unwrap();
2087        assert_eq!(result, Value::Null);
2088    }
2089
2090    /// The executor returns correct TTL using the injected fixed clock.
2091    #[tokio::test]
2092    async fn test_executor_evaluate_ttl_with_injected_clock() {
2093        // now = epoch 1000; cell expires at epoch 5000 → remaining = 4000s
2094        let now_secs: i64 = 1000;
2095        let executor = create_test_executor_with_clock(now_secs).await;
2096
2097        let row = row_with_cell_meta(
2098            "session",
2099            Value::Text("tok".to_string()),
2100            Some(CellWriteMetadata {
2101                write_timestamp_micros: 0,
2102                expiration: Some(CellExpiration {
2103                    ttl_seconds: 5000,
2104                    expires_at_seconds: 5000,
2105                }),
2106            }),
2107        );
2108
2109        let expr = SelectExpression::WriteTimeTtl(WriteTimeTtlCall {
2110            function: WriteTimeTtlFunction::Ttl,
2111            column: "session".to_string(),
2112            alias: None,
2113        });
2114
2115        let result = executor.evaluate_select_expression(&expr, &row).unwrap();
2116        assert_eq!(
2117            result,
2118            Value::Integer(4000),
2119            "TTL must use the injected clock, not the wall clock"
2120        );
2121    }
2122
2123    /// Expired cell: executor returns NULL via injected clock.
2124    #[tokio::test]
2125    async fn test_executor_evaluate_ttl_expired_cell_returns_null() {
2126        // now = epoch 9999; cell expired at epoch 100 → NULL
2127        let executor = create_test_executor_with_clock(9999).await;
2128
2129        let row = row_with_cell_meta(
2130            "cache",
2131            Value::Text("val".to_string()),
2132            Some(CellWriteMetadata {
2133                write_timestamp_micros: 0,
2134                expiration: Some(CellExpiration {
2135                    ttl_seconds: 100,
2136                    expires_at_seconds: 100,
2137                }),
2138            }),
2139        );
2140
2141        let expr = SelectExpression::WriteTimeTtl(WriteTimeTtlCall {
2142            function: WriteTimeTtlFunction::Ttl,
2143            column: "cache".to_string(),
2144            alias: None,
2145        });
2146
2147        let result = executor.evaluate_select_expression(&expr, &row).unwrap();
2148        assert_eq!(result, Value::Null, "Expired TTL cell must produce NULL");
2149    }
2150
2151    /// Column info for WRITETIME uses BigInt data type and bigint cql_type.
2152    #[tokio::test]
2153    async fn test_get_result_columns_writetime_has_bigint_type() {
2154        let executor = create_test_executor().await;
2155
2156        let stmt = SelectStatement {
2157            select_clause: SelectClause::Columns(vec![SelectExpression::WriteTimeTtl(
2158                WriteTimeTtlCall {
2159                    function: WriteTimeTtlFunction::WriteTime,
2160                    column: "name".to_string(),
2161                    alias: None,
2162                },
2163            )]),
2164            from_clause: None,
2165            where_clause: None,
2166            group_by: None,
2167            having_clause: None,
2168            order_by: None,
2169            limit: None,
2170            per_partition_limit: None,
2171            offset: None,
2172            allow_filtering: false,
2173        };
2174
2175        let cols = executor.get_result_columns(&stmt, None).unwrap();
2176        assert_eq!(cols.len(), 1);
2177        assert_eq!(cols[0].name, "writetime(name)");
2178        assert_eq!(cols[0].data_type, crate::types::DataType::BigInt);
2179        assert!(cols[0].nullable, "WRITETIME column must be nullable");
2180        assert_eq!(cols[0].cql_type, Some(CqlType::BigInt));
2181    }
2182
2183    /// Column info for TTL uses Integer data type and int cql_type.
2184    #[tokio::test]
2185    async fn test_get_result_columns_ttl_has_int_type() {
2186        let executor = create_test_executor().await;
2187
2188        let stmt = SelectStatement {
2189            select_clause: SelectClause::Columns(vec![SelectExpression::WriteTimeTtl(
2190                WriteTimeTtlCall {
2191                    function: WriteTimeTtlFunction::Ttl,
2192                    column: "score".to_string(),
2193                    alias: None,
2194                },
2195            )]),
2196            from_clause: None,
2197            where_clause: None,
2198            group_by: None,
2199            having_clause: None,
2200            order_by: None,
2201            limit: None,
2202            per_partition_limit: None,
2203            offset: None,
2204            allow_filtering: false,
2205        };
2206
2207        let cols = executor.get_result_columns(&stmt, None).unwrap();
2208        assert_eq!(cols.len(), 1);
2209        assert_eq!(cols[0].name, "ttl(score)");
2210        assert_eq!(cols[0].data_type, crate::types::DataType::Integer);
2211        assert!(cols[0].nullable, "TTL column must be nullable");
2212        assert_eq!(cols[0].cql_type, Some(CqlType::Int));
2213    }
2214
2215    /// Column name uses alias when provided, overriding convention.
2216    #[tokio::test]
2217    async fn test_get_result_columns_writetime_with_alias() {
2218        let executor = create_test_executor().await;
2219
2220        let stmt = SelectStatement {
2221            select_clause: SelectClause::Columns(vec![SelectExpression::WriteTimeTtl(
2222                WriteTimeTtlCall {
2223                    function: WriteTimeTtlFunction::WriteTime,
2224                    column: "name".to_string(),
2225                    alias: Some("wt".to_string()),
2226                },
2227            )]),
2228            from_clause: None,
2229            where_clause: None,
2230            group_by: None,
2231            having_clause: None,
2232            order_by: None,
2233            limit: None,
2234            per_partition_limit: None,
2235            offset: None,
2236            allow_filtering: false,
2237        };
2238
2239        let cols = executor.get_result_columns(&stmt, None).unwrap();
2240        assert_eq!(cols.len(), 1);
2241        assert_eq!(
2242            cols[0].name, "wt",
2243            "Alias must override Cassandra convention"
2244        );
2245    }
2246
2247    /// Build an optimized plan for `sql` against a fixed 4-column table, so
2248    /// `requires_materialization` can be asserted directly (issue #1952 streaming
2249    /// follow-up). The scan projection is the #1952 broadened union (selected +
2250    /// WHERE/ORDER BY/GROUP BY/agg-arg helper columns).
2251    async fn plan_for(sql: &str) -> OptimizedQueryPlan {
2252        let temp_dir = TempDir::new().unwrap();
2253        let config = Config::default();
2254        let platform = Arc::new(Platform::new(&config).await.unwrap());
2255        let storage = Arc::new(
2256            StorageEngine::open(
2257                temp_dir.path(),
2258                &config,
2259                platform.clone(),
2260                #[cfg(feature = "state_machine")]
2261                None,
2262            )
2263            .await
2264            .unwrap(),
2265        );
2266        let schema = Arc::new(SchemaManager::new(temp_dir.path()).await.unwrap());
2267        schema
2268            .parse_and_register_cql_schema(
2269                "CREATE TABLE ks.t (id int PRIMARY KEY, a int, b int, c int)",
2270            )
2271            .await
2272            .expect("schema registers");
2273        let optimizer =
2274            crate::query::select_optimizer::SelectOptimizer::new(schema.clone(), storage.clone());
2275        let statement = crate::query::select_parser::parse_select(sql).unwrap();
2276        optimizer.optimize(statement).await.unwrap()
2277    }
2278
2279    /// #1952 streaming follow-up: a plain-column `SELECT` filtered by an
2280    /// UNSELECTED WHERE column produces a scan projection (`a`,`b`) wider than the
2281    /// SELECT output (`a`). The `Project` step must TRIM `b`; the streaming
2282    /// producer ignores `Project`, so the plan MUST be routed through the
2283    /// materialized path. Pre-fix this returned `false` (streaming leaked `b`).
2284    #[tokio::test]
2285    async fn requires_materialization_for_where_helper_trim() {
2286        let plan = plan_for("SELECT a FROM ks.t WHERE b = 1").await;
2287        let executor = create_test_executor().await;
2288        assert!(
2289            executor.requires_materialization(&plan),
2290            "SELECT a WHERE b=1 scans [a,b] but outputs [a]; the Project TRIM of \
2291             the helper column `b` must force materialization (streaming ignores \
2292             Project and would leak `b`)"
2293        );
2294    }
2295
2296    /// #1952 streaming follow-up: an ORDER BY + WHERE query over UNSELECTED helper
2297    /// columns must materialize (the trim path is additionally guaranteed by the
2298    /// Sort step, but the projection-set check alone already forces it).
2299    #[tokio::test]
2300    async fn requires_materialization_for_order_by_helper_trim() {
2301        let plan = plan_for("SELECT a FROM ks.t WHERE b = 1 ORDER BY c").await;
2302        let executor = create_test_executor().await;
2303        assert!(
2304            executor.requires_materialization(&plan),
2305            "SELECT a WHERE b=1 ORDER BY c scans [a,b,c] but outputs [a]; must \
2306             materialize to trim helper columns b,c"
2307        );
2308    }
2309
2310    /// Regression: when the selected columns EXACTLY equal the scan projection
2311    /// (no helper columns, no reshape) the optimizer emits NO redundant `Project`
2312    /// and the plan streams directly — this must NOT be forced into
2313    /// materialization (no perf regression for the common case).
2314    #[tokio::test]
2315    async fn no_materialization_when_output_equals_scan() {
2316        // Both selected columns are exactly the scan set (WHERE column `a` is
2317        // already selected), so there is no helper column to trim.
2318        let plan = plan_for("SELECT a, b FROM ks.t WHERE a = 1").await;
2319        let executor = create_test_executor().await;
2320        assert!(
2321            !executor.requires_materialization(&plan),
2322            "SELECT a,b WHERE a=1 scans exactly [a,b] and outputs [a,b]; it must \
2323             stream directly without materialization"
2324        );
2325    }
2326
2327    /// Regression: a reordered SELECT whose WHERE column is already selected
2328    /// (`SELECT b, a WHERE a = 1`) has output set {a,b} == scan set {a,b}, so it
2329    /// streams directly — `project_trims_scan_columns` compares SETS (not order),
2330    /// so no false materialization is triggered.
2331    #[tokio::test]
2332    async fn no_materialization_for_reordered_select_no_helpers() {
2333        let plan = plan_for("SELECT b, a FROM ks.t WHERE a = 1").await;
2334        let executor = create_test_executor().await;
2335        assert!(
2336            !executor.requires_materialization(&plan),
2337            "a reordered select with no helper columns (same column set) must \
2338             stream directly"
2339        );
2340    }
2341}