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}