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