Skip to main content

cqlite_core/query/
writetime_ttl_validator.rs

1//! Schema-aware validation for `WRITETIME()` and `TTL()` select functions.
2//!
3//! Cassandra imposes the following restrictions on these metadata-retrieval functions:
4//!
5//! - **Partition-key columns**: not allowed — the error message mirrors Cassandra's:
6//!   `"Cannot use selection function writeTime on PRIMARY KEY part <col>"`
7//! - **Clustering columns**: same restriction as partition keys.
8//! - **Non-frozen collection columns** (`list<T>`, `set<T>`, `map<K,V>` that are *not*
9//!   `frozen<...>`): not allowed — multi-cell semantics mean there is no single
10//!   writetime/TTL for the whole column.  Frozen collections (single-cell) are OK.
11//!   The error mirrors Cassandra's:
12//!   `"Cannot use selection function writeTime on non-frozen <type>"`
13//! - **Unknown columns**: any column not found in the schema is an error.
14//!
15//! # Executor TODO (#692)
16//! This module is the **parser/planning layer only**.  Evaluation (actually reading
17//! `writetime` / `ttl` from SSTable cell metadata) is deferred to issue #692.
18
19use crate::{Error, Result};
20
21/// A parsed `WRITETIME()` or `TTL()` call, as seen during planning.
22///
23/// The caller supplies a slice of these (one per such item in the SELECT list)
24/// together with the schema columns.  Validation is stateless and re-entrant.
25#[derive(Debug, Clone, PartialEq)]
26pub enum WriteTimeTtlKind {
27    WriteTime,
28    Ttl,
29}
30
31impl WriteTimeTtlKind {
32    /// Cassandra spells the function name in lower-case in its error messages.
33    fn cassandra_fn_name(&self) -> &'static str {
34        match self {
35            WriteTimeTtlKind::WriteTime => "writeTime",
36            WriteTimeTtlKind::Ttl => "ttl",
37        }
38    }
39}
40
41/// Minimal column descriptor needed for validation.
42///
43/// The validator works with this lightweight struct so it is not tightly
44/// coupled to any particular schema representation.
45#[derive(Debug, Clone)]
46pub struct ColumnDescriptor {
47    /// Column name (case-insensitive matching is applied by the validator)
48    pub name: String,
49    /// CQL type string (e.g. `"text"`, `"list<int>"`, `"frozen<set<text>>"`)
50    pub type_str: String,
51    /// True when this column is part of the partition key
52    pub is_partition_key: bool,
53    /// True when this column is part of the clustering key
54    pub is_clustering_key: bool,
55}
56
57/// Validate a single `WRITETIME(col)` or `TTL(col)` call against the supplied
58/// schema columns.
59///
60/// Returns `Ok(())` when the call is valid, `Err(...)` with a Cassandra-shaped
61/// error message otherwise.
62///
63/// # Errors
64///
65/// - Unknown column: `Error::CqlParse` with column name.
66/// - Partition-key column: `Error::CqlParse` mirroring Cassandra's message.
67/// - Clustering column: `Error::CqlParse` mirroring Cassandra's message.
68/// - Non-frozen collection column: `Error::CqlParse` mirroring Cassandra's message.
69pub fn validate_writetime_ttl_call(
70    kind: &WriteTimeTtlKind,
71    column_name: &str,
72    columns: &[ColumnDescriptor],
73) -> Result<()> {
74    // Case-insensitive lookup (CQL identifiers are case-insensitive when unquoted).
75    let col = columns
76        .iter()
77        .find(|c| c.name.eq_ignore_ascii_case(column_name))
78        .ok_or_else(|| {
79            Error::cql_parse(format!(
80                "Undefined column name {} in selection clause",
81                column_name
82            ))
83        })?;
84
85    if col.is_partition_key || col.is_clustering_key {
86        return Err(Error::cql_parse(format!(
87            "Cannot use selection function {} on PRIMARY KEY part {}",
88            kind.cassandra_fn_name(),
89            col.name,
90        )));
91    }
92
93    if is_non_frozen_collection(&col.type_str) {
94        return Err(Error::cql_parse(format!(
95            "Cannot use selection function {} on non-frozen {}",
96            kind.cassandra_fn_name(),
97            col.type_str,
98        )));
99    }
100
101    Ok(())
102}
103
104/// Validate all `WRITETIME()`/`TTL()` calls in a SELECT list at once.
105///
106/// Returns the first error encountered, or `Ok(())` if all are valid.
107pub fn validate_all_writetime_ttl_calls(
108    calls: &[(WriteTimeTtlKind, String)],
109    columns: &[ColumnDescriptor],
110) -> Result<()> {
111    for (kind, column_name) in calls {
112        validate_writetime_ttl_call(kind, column_name, columns)?;
113    }
114    Ok(())
115}
116
117/// Build a `ColumnDescriptor` list from a `crate::schema::TableSchema`.
118///
119/// This bridge function keeps the validator decoupled from the schema crate.
120pub fn descriptors_from_table_schema(schema: &crate::schema::TableSchema) -> Vec<ColumnDescriptor> {
121    let pk_names: std::collections::HashSet<&str> = schema
122        .partition_keys
123        .iter()
124        .map(|k| k.name.as_str())
125        .collect();
126    let ck_names: std::collections::HashSet<&str> = schema
127        .clustering_keys
128        .iter()
129        .map(|k| k.name.as_str())
130        .collect();
131
132    schema
133        .columns
134        .iter()
135        .map(|col| ColumnDescriptor {
136            name: col.name.clone(),
137            type_str: col.data_type.clone(),
138            is_partition_key: pk_names.contains(col.name.as_str()),
139            is_clustering_key: ck_names.contains(col.name.as_str()),
140        })
141        .collect()
142}
143
144/// Returns `true` when `type_str` describes a non-frozen collection.
145///
146/// `frozen<list<...>>` / `frozen<set<...>>` / `frozen<map<...>>` are single-cell
147/// and are **allowed** with WRITETIME/TTL.  Plain `list<...>`, `set<...>`,
148/// `map<...>` are multi-cell and are **not** allowed.
149fn is_non_frozen_collection(type_str: &str) -> bool {
150    let t = type_str.trim().to_lowercase();
151    // A frozen wrapper at the top level means it is single-cell.
152    if t.starts_with("frozen<") {
153        return false;
154    }
155    t.starts_with("list<") || t.starts_with("set<") || t.starts_with("map<")
156}
157
158/// Convenience: extract `(WriteTimeTtlKind, column_name)` pairs from a parsed
159/// `SelectStatement`'s select clause.  Returns an empty vec for `SELECT *`.
160#[cfg(feature = "state_machine")]
161pub fn extract_writetime_ttl_calls(
162    stmt: &super::select_ast::SelectStatement,
163) -> Vec<(WriteTimeTtlKind, String)> {
164    use super::select_ast::{SelectClause, SelectExpression, WriteTimeTtlFunction};
165
166    let exprs = match &stmt.select_clause {
167        SelectClause::Columns(v) | SelectClause::Distinct(v) => v,
168        SelectClause::All => return vec![],
169    };
170
171    exprs
172        .iter()
173        .filter_map(|expr| {
174            let call = match expr {
175                SelectExpression::WriteTimeTtl(c) => c,
176                SelectExpression::Aliased(inner, _) => {
177                    if let SelectExpression::WriteTimeTtl(c) = inner.as_ref() {
178                        c
179                    } else {
180                        return None;
181                    }
182                }
183                _ => return None,
184            };
185            let kind = match call.function {
186                WriteTimeTtlFunction::WriteTime => WriteTimeTtlKind::WriteTime,
187                WriteTimeTtlFunction::Ttl => WriteTimeTtlKind::Ttl,
188            };
189            Some((kind, call.column.clone()))
190        })
191        .collect()
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    fn col(name: &str, type_str: &str, is_pk: bool, is_ck: bool) -> ColumnDescriptor {
199        ColumnDescriptor {
200            name: name.to_string(),
201            type_str: type_str.to_string(),
202            is_partition_key: is_pk,
203            is_clustering_key: is_ck,
204        }
205    }
206
207    fn basic_columns() -> Vec<ColumnDescriptor> {
208        vec![
209            col("user_id", "uuid", true, false),
210            col("bucket", "int", false, true),
211            col("name", "text", false, false),
212            col("scores", "list<int>", false, false),
213            col("tags", "set<text>", false, false),
214            col("meta", "map<text,text>", false, false),
215            col("frozen_scores", "frozen<list<int>>", false, false),
216            col("data", "blob", false, false),
217        ]
218    }
219
220    // --- Happy-path tests ---
221
222    #[test]
223    fn test_writetime_on_regular_column_is_valid() {
224        let cols = basic_columns();
225        let result = validate_writetime_ttl_call(&WriteTimeTtlKind::WriteTime, "name", &cols);
226        assert!(
227            result.is_ok(),
228            "WRITETIME on a plain text column should be valid"
229        );
230    }
231
232    #[test]
233    fn test_ttl_on_regular_column_is_valid() {
234        let cols = basic_columns();
235        let result = validate_writetime_ttl_call(&WriteTimeTtlKind::Ttl, "name", &cols);
236        assert!(result.is_ok(), "TTL on a plain text column should be valid");
237    }
238
239    #[test]
240    fn test_writetime_on_frozen_collection_is_valid() {
241        let cols = basic_columns();
242        let result =
243            validate_writetime_ttl_call(&WriteTimeTtlKind::WriteTime, "frozen_scores", &cols);
244        assert!(
245            result.is_ok(),
246            "WRITETIME on a frozen<list<int>> should be valid"
247        );
248    }
249
250    #[test]
251    fn test_writetime_on_blob_is_valid() {
252        let cols = basic_columns();
253        let result = validate_writetime_ttl_call(&WriteTimeTtlKind::WriteTime, "data", &cols);
254        assert!(result.is_ok());
255    }
256
257    // --- Partition-key rejection ---
258
259    #[test]
260    fn test_writetime_on_partition_key_is_rejected() {
261        let cols = basic_columns();
262        let err = validate_writetime_ttl_call(&WriteTimeTtlKind::WriteTime, "user_id", &cols)
263            .unwrap_err();
264        let msg = err.to_string();
265        assert!(
266            msg.contains("writeTime"),
267            "Error should name the function: {msg}"
268        );
269        assert!(
270            msg.contains("PRIMARY KEY"),
271            "Error should mention PRIMARY KEY: {msg}"
272        );
273        assert!(
274            msg.contains("user_id"),
275            "Error should name the column: {msg}"
276        );
277    }
278
279    #[test]
280    fn test_ttl_on_partition_key_is_rejected() {
281        let cols = basic_columns();
282        let err =
283            validate_writetime_ttl_call(&WriteTimeTtlKind::Ttl, "user_id", &cols).unwrap_err();
284        let msg = err.to_string();
285        assert!(msg.contains("ttl"), "Error should name the function: {msg}");
286        assert!(
287            msg.contains("PRIMARY KEY"),
288            "Error should mention PRIMARY KEY: {msg}"
289        );
290    }
291
292    // --- Clustering-key rejection ---
293
294    #[test]
295    fn test_writetime_on_clustering_key_is_rejected() {
296        let cols = basic_columns();
297        let err =
298            validate_writetime_ttl_call(&WriteTimeTtlKind::WriteTime, "bucket", &cols).unwrap_err();
299        let msg = err.to_string();
300        assert!(
301            msg.contains("PRIMARY KEY"),
302            "Clustering-key error should cite PRIMARY KEY: {msg}"
303        );
304        assert!(msg.contains("bucket"));
305    }
306
307    // --- Non-frozen collection rejection ---
308
309    #[test]
310    fn test_writetime_on_list_is_rejected() {
311        let cols = basic_columns();
312        let err =
313            validate_writetime_ttl_call(&WriteTimeTtlKind::WriteTime, "scores", &cols).unwrap_err();
314        let msg = err.to_string();
315        assert!(
316            msg.contains("non-frozen"),
317            "Error should cite non-frozen: {msg}"
318        );
319        assert!(
320            msg.contains("list<int>"),
321            "Error should include the type: {msg}"
322        );
323    }
324
325    #[test]
326    fn test_writetime_on_set_is_rejected() {
327        let cols = basic_columns();
328        let err =
329            validate_writetime_ttl_call(&WriteTimeTtlKind::WriteTime, "tags", &cols).unwrap_err();
330        let msg = err.to_string();
331        assert!(msg.contains("non-frozen"));
332    }
333
334    #[test]
335    fn test_writetime_on_map_is_rejected() {
336        let cols = basic_columns();
337        let err =
338            validate_writetime_ttl_call(&WriteTimeTtlKind::WriteTime, "meta", &cols).unwrap_err();
339        let msg = err.to_string();
340        assert!(msg.contains("non-frozen"));
341    }
342
343    #[test]
344    fn test_ttl_on_set_is_rejected() {
345        let cols = basic_columns();
346        let err = validate_writetime_ttl_call(&WriteTimeTtlKind::Ttl, "tags", &cols).unwrap_err();
347        let msg = err.to_string();
348        assert!(msg.contains("non-frozen"));
349        assert!(msg.contains("ttl"));
350    }
351
352    // --- Unknown-column rejection ---
353
354    #[test]
355    fn test_writetime_on_unknown_column_is_rejected() {
356        let cols = basic_columns();
357        let err =
358            validate_writetime_ttl_call(&WriteTimeTtlKind::WriteTime, "nonexistent_col", &cols)
359                .unwrap_err();
360        let msg = err.to_string();
361        assert!(
362            msg.contains("nonexistent_col"),
363            "Error should name the unknown column: {msg}"
364        );
365    }
366
367    // --- Case-insensitive column lookup ---
368
369    #[test]
370    fn test_column_lookup_is_case_insensitive() {
371        let cols = basic_columns();
372        // Schema stores "name" in lower-case; query uses "NAME".
373        let result = validate_writetime_ttl_call(&WriteTimeTtlKind::WriteTime, "NAME", &cols);
374        assert!(result.is_ok(), "Column lookup should be case-insensitive");
375    }
376
377    // --- Batch validation ---
378
379    #[test]
380    fn test_validate_all_calls_stops_at_first_error() {
381        let cols = basic_columns();
382        let calls = vec![
383            (WriteTimeTtlKind::WriteTime, "name".to_string()),
384            (WriteTimeTtlKind::Ttl, "user_id".to_string()), // PK — should error
385            (WriteTimeTtlKind::WriteTime, "data".to_string()),
386        ];
387        let result = validate_all_writetime_ttl_calls(&calls, &cols);
388        assert!(
389            result.is_err(),
390            "Batch validation should fail on the PK column"
391        );
392    }
393
394    #[test]
395    fn test_validate_all_calls_succeeds_when_all_valid() {
396        let cols = basic_columns();
397        let calls = vec![
398            (WriteTimeTtlKind::WriteTime, "name".to_string()),
399            (WriteTimeTtlKind::Ttl, "data".to_string()),
400        ];
401        assert!(validate_all_writetime_ttl_calls(&calls, &cols).is_ok());
402    }
403
404    // --- is_non_frozen_collection helper ---
405
406    #[test]
407    fn test_non_frozen_collection_detection() {
408        assert!(is_non_frozen_collection("list<int>"));
409        assert!(is_non_frozen_collection("set<text>"));
410        assert!(is_non_frozen_collection("map<text,int>"));
411        assert!(is_non_frozen_collection("LIST<INT>")); // case-insensitive
412        assert!(!is_non_frozen_collection("frozen<list<int>>"));
413        assert!(!is_non_frozen_collection("frozen<set<text>>"));
414        assert!(!is_non_frozen_collection("frozen<map<text,int>>"));
415        assert!(!is_non_frozen_collection("text"));
416        assert!(!is_non_frozen_collection("bigint"));
417        assert!(!is_non_frozen_collection("uuid"));
418    }
419}