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