Skip to main content

cqlite_core/query/select_executor/
predicate.rs

1//! SSTable leaf-predicate evaluation for the SELECT executor.
2//!
3//! [`evaluate_leaf`] and [`evaluate_predicates`] are part of the public surface
4//! (re-exported via `query::mod`) so the Arrow Flight server applies identical
5//! predicate-pushdown semantics. [`validate_token_predicates`] enforces that a
6//! `token(...)` restriction names the full partition key in declared order.
7
8use super::super::result::QueryRow;
9use super::super::select_optimizer::SSTablePredicate;
10use super::value_ops::{compare_values_ordering, values_equal};
11use crate::{types::Value, Error, Result};
12
13/// Three-valued (SQL Kleene) outcome of evaluating a single leaf predicate
14/// against one row.
15///
16/// `Unknown` is produced when the predicate references a column that is absent
17/// from the row or whose value is `Null` — i.e. a SQL `NULL` operand. Callers
18/// that only need pure-`AND` rejection (the historical [`evaluate_predicates`])
19/// treat `Unknown` and `False` identically; callers that evaluate `OR`/`NOT`
20/// (the Flight nested-predicate evaluator, issue #834) must distinguish them to
21/// match SQL `WHERE` semantics.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum LeafOutcome {
24    /// The predicate is definitely satisfied.
25    True,
26    /// The predicate is definitely not satisfied.
27    False,
28    /// The predicate's column is missing or `NULL` (SQL `UNKNOWN`).
29    Unknown,
30}
31
32/// Evaluate a single SSTable leaf predicate against one `QueryRow` with SQL
33/// three-valued (Kleene) semantics.
34///
35/// Returns [`LeafOutcome::Unknown`] when the predicate's column is absent from
36/// the row or its value is `Null`; otherwise a definite `True`/`False` from the
37/// typed comparison. This is the finer-grained primitive underlying both
38/// [`evaluate_predicates`] (pure AND, where `Unknown` rejects like `False`) and
39/// the Flight nested-predicate evaluator (issue #834), so all three paths share
40/// one copy of the comparison logic (`values_equal` / `compare_values_ordering`).
41///
42/// `IN` and `Prefix` follow `evaluate_predicates`: `IN` is membership over the
43/// value list, `Prefix` is a text `starts_with`. `Range` (two-bound) is included
44/// for completeness even though Flight lowers single bounds to `Gt`/`Lt`/etc.
45/// `BloomFilter` is always `True` (checked upstream).
46pub fn evaluate_leaf(row: &QueryRow, predicate: &SSTablePredicate) -> LeafOutcome {
47    use super::super::select_optimizer::SSTableFilterOp;
48
49    // Issue #955: a `token(pk)` predicate constrains the partition-key token, not
50    // a stored column. Compute the Murmur3 token of the row's raw partition key
51    // (`row.key`) using the same partitioner the rest of the codebase uses, then
52    // compare against the i64 bound. An empty key cannot be hashed meaningfully
53    // (a synthesised/aggregate row); treat it as Unknown so it is rejected.
54    if predicate.is_token() {
55        if row.key.0.is_empty() {
56            return LeafOutcome::Unknown;
57        }
58        let row_token = crate::util::cassandra_murmur3::cassandra_murmur3_token(&row.key.0);
59        let Some(Value::BigInt(bound)) = predicate.values.first() else {
60            return LeafOutcome::Unknown;
61        };
62        let matches = match &predicate.operation {
63            SSTableFilterOp::Gt => row_token > *bound,
64            SSTableFilterOp::Gte => row_token >= *bound,
65            SSTableFilterOp::Lt => row_token < *bound,
66            SSTableFilterOp::Lte => row_token <= *bound,
67            SSTableFilterOp::Equal => row_token == *bound,
68            // token() only produces inequality/equality predicates upstream.
69            _ => return LeafOutcome::Unknown,
70        };
71        return if matches {
72            LeafOutcome::True
73        } else {
74            LeafOutcome::False
75        };
76    }
77
78    let column_value = match row.values.get(predicate.column.as_str()) {
79        // A SQL `NULL` operand (absent column or explicit `Null`) is `UNKNOWN`.
80        None | Some(Value::Null) => return LeafOutcome::Unknown,
81        Some(v) => v,
82    };
83    let matches = match &predicate.operation {
84        SSTableFilterOp::Equal => predicate
85            .values
86            .first()
87            .is_some_and(|v| values_equal(column_value, v)),
88        // Membership uses the same coercing equality as `Equal` so a pushed-down
89        // `IN` operand that lowers to a wider numeric type (e.g. `Integer`) still
90        // matches a narrow column value (`TinyInt`/`SmallInt`/`Float32`).
91        SSTableFilterOp::In => predicate
92            .values
93            .iter()
94            .any(|v| values_equal(column_value, v)),
95        SSTableFilterOp::Range => {
96            if predicate.values.len() < 2 {
97                false
98            } else {
99                let lo = &predicate.values[0];
100                let hi = &predicate.values[1];
101                compare_values_ordering(column_value, lo).is_ge()
102                    && compare_values_ordering(column_value, hi).is_le()
103            }
104        }
105        // Single-bound clustering inequalities (Issue #788). A missing bound
106        // rejects the row, mirroring the `Range` len-guard above.
107        SSTableFilterOp::Gt => predicate
108            .values
109            .first()
110            .is_some_and(|b| compare_values_ordering(column_value, b).is_gt()),
111        SSTableFilterOp::Gte => predicate
112            .values
113            .first()
114            .is_some_and(|b| compare_values_ordering(column_value, b).is_ge()),
115        SSTableFilterOp::Lt => predicate
116            .values
117            .first()
118            .is_some_and(|b| compare_values_ordering(column_value, b).is_lt()),
119        SSTableFilterOp::Lte => predicate
120            .values
121            .first()
122            .is_some_and(|b| compare_values_ordering(column_value, b).is_le()),
123        SSTableFilterOp::Prefix => matches!(
124            (column_value, predicate.values.first()),
125            (Value::Text(s), Some(Value::Text(p))) if s.starts_with(p)
126        ),
127        SSTableFilterOp::BloomFilter => true, // already checked upstream
128    };
129    if matches {
130        LeafOutcome::True
131    } else {
132        LeafOutcome::False
133    }
134}
135
136/// Evaluate the SSTable predicate set against a single `QueryRow`.
137///
138/// Returns `Ok(true)` only if every predicate is satisfied. A missing column
139/// causes the row to be rejected.
140///
141/// Exposed publicly so the Arrow Flight server can apply identical predicate
142/// pushdown semantics to its merged rows (output parity with SELECT).
143///
144/// Implemented in terms of [`evaluate_leaf`]: under pure `AND`, both
145/// [`LeafOutcome::False`] and [`LeafOutcome::Unknown`] reject the row, so this
146/// preserves the historical "missing column → false" behaviour exactly.
147pub fn evaluate_predicates(row: &QueryRow, predicates: &[SSTablePredicate]) -> Result<bool> {
148    for predicate in predicates {
149        if evaluate_leaf(row, predicate) != LeafOutcome::True {
150            return Ok(false);
151        }
152    }
153    Ok(true)
154}
155
156/// Validate that every `token(...)` predicate's argument columns match the
157/// table's FULL partition-key column list, in declared order (Issue #955
158/// follow-up, FINDING 2).
159///
160/// `evaluate_leaf` evaluates a token predicate by hashing the row's *raw
161/// partition key* — it does NOT recompute a token over the columns named in the
162/// predicate. So a `token(col, ...)` whose columns are not exactly the partition
163/// key (a non-pk column, a strict subset, or a reordering) would be evaluated
164/// against the real partition-key token, silently returning rows for a different
165/// expression than the user wrote.
166///
167/// Cassandra rejects `token()` on anything other than the full partition key in
168/// declared order, so we do the same: reject with a clear error rather than
169/// trust `token_columns`. With no schema we cannot know the partition key, so we
170/// also reject (we cannot prove the columns are correct).
171pub(super) fn validate_token_predicates(
172    predicates: &[SSTablePredicate],
173    schema: Option<&crate::schema::TableSchema>,
174) -> Result<()> {
175    let token_predicates: Vec<&SSTablePredicate> =
176        predicates.iter().filter(|p| p.is_token()).collect();
177    if token_predicates.is_empty() {
178        return Ok(());
179    }
180
181    let Some(schema) = schema else {
182        return Err(Error::query_execution(
183            "token() restriction requires a known table schema to validate its argument \
184             against the partition key"
185                .to_string(),
186        ));
187    };
188
189    // The partition key in declared order (positions are 0-based and dense).
190    let mut pk_cols: Vec<&crate::schema::KeyColumn> = schema.partition_keys.iter().collect();
191    pk_cols.sort_by_key(|c| c.position);
192    let expected: Vec<&str> = pk_cols.iter().map(|c| c.name.as_str()).collect();
193
194    for predicate in token_predicates {
195        let cols = predicate.token_columns.as_deref().unwrap_or(&[]);
196        let matches = cols.len() == expected.len()
197            && cols
198                .iter()
199                .zip(expected.iter())
200                .all(|(got, want)| got == want);
201        if !matches {
202            return Err(Error::query_execution(format!(
203                "token() must be applied to the entire partition key in declared order \
204                 ({}); got token({})",
205                expected.join(", "),
206                cols.join(", "),
207            )));
208        }
209    }
210    Ok(())
211}
212
213#[cfg(test)]
214mod tests {
215    use super::super::super::select_optimizer::{SSTableFilterOp, SSTablePredicate};
216    use super::super::test_support::{
217        composite_pk_schema, row_with_int, row_with_key, single_pk_schema,
218    };
219    use super::*;
220    use crate::types::RowKey;
221
222    /// Issue #955: `evaluate_leaf` on a token predicate hashes the row's raw
223    /// partition key with the canonical Murmur3 partitioner and compares against
224    /// the i64 bound — matching Cassandra's token inclusivity (`>` exclusive,
225    /// `>=` inclusive, etc.).
226    #[test]
227    fn evaluate_leaf_token_predicate_filters_by_token() {
228        let key = b"some-partition-key".to_vec();
229        let token = crate::util::cassandra_murmur3::cassandra_murmur3_token(&key);
230        let row = row_with_key(&key);
231
232        // token >= exact token → included; token > exact token → excluded.
233        let gte = SSTablePredicate::token(
234            vec!["id".to_string()],
235            SSTableFilterOp::Gte,
236            vec![Value::BigInt(token)],
237        );
238        assert_eq!(evaluate_leaf(&row, &gte), LeafOutcome::True);
239
240        let gt = SSTablePredicate::token(
241            vec!["id".to_string()],
242            SSTableFilterOp::Gt,
243            vec![Value::BigInt(token)],
244        );
245        assert_eq!(evaluate_leaf(&row, &gt), LeafOutcome::False);
246
247        // A bound above the row's token excludes it under `>=`.
248        let gte_above = SSTablePredicate::token(
249            vec!["id".to_string()],
250            SSTableFilterOp::Gte,
251            vec![Value::BigInt(token.saturating_add(1))],
252        );
253        assert_eq!(evaluate_leaf(&row, &gte_above), LeafOutcome::False);
254
255        // An empty key (synthesised row) is Unknown, never spuriously matched.
256        let empty = row_with_key(&[]);
257        assert_eq!(evaluate_leaf(&empty, &gte), LeafOutcome::Unknown);
258    }
259
260    /// Issue #788: each clustering-key inequality op must include/exclude rows on
261    /// the correct side of its bound when evaluated post-scan.
262    #[test]
263    fn inequality_predicates_apply_single_bound() {
264        let bound =
265            |op: SSTableFilterOp| SSTablePredicate::column("ck", op, vec![Value::Integer(200)]);
266        let eval = |op: SSTableFilterOp, ck: i64| {
267            evaluate_predicates(&row_with_int("ck", ck), std::slice::from_ref(&bound(op))).unwrap()
268        };
269
270        // ck > 200
271        assert!(!eval(SSTableFilterOp::Gt, 200));
272        assert!(eval(SSTableFilterOp::Gt, 201));
273        // ck >= 200
274        assert!(eval(SSTableFilterOp::Gte, 200));
275        assert!(!eval(SSTableFilterOp::Gte, 199));
276        // ck < 200
277        assert!(eval(SSTableFilterOp::Lt, 199));
278        assert!(!eval(SSTableFilterOp::Lt, 200));
279        // ck <= 200
280        assert!(eval(SSTableFilterOp::Lte, 200));
281        assert!(!eval(SSTableFilterOp::Lte, 201));
282    }
283
284    /// Issue #834: `evaluate_leaf` distinguishes the three SQL truth values.
285    /// A present, comparable value yields True/False; an absent column or an
286    /// explicit `Null` value yields Unknown so OR/NOT callers get SQL semantics.
287    #[test]
288    fn evaluate_leaf_is_three_valued() {
289        let gt200 = SSTablePredicate::column("ck", SSTableFilterOp::Gt, vec![Value::Integer(200)]);
290
291        // Present value → definite True/False.
292        assert_eq!(
293            evaluate_leaf(&row_with_int("ck", 201), &gt200),
294            LeafOutcome::True
295        );
296        assert_eq!(
297            evaluate_leaf(&row_with_int("ck", 200), &gt200),
298            LeafOutcome::False
299        );
300
301        // Absent column → Unknown (not False).
302        assert_eq!(
303            evaluate_leaf(&row_with_int("other", 999), &gt200),
304            LeafOutcome::Unknown
305        );
306
307        // Explicit Null value → Unknown.
308        let mut values: std::collections::HashMap<std::sync::Arc<str>, Value> =
309            std::collections::HashMap::new();
310        values.insert("ck".into(), Value::Null);
311        let null_row = QueryRow {
312            values,
313            key: RowKey::new(Vec::new()),
314            metadata: Default::default(),
315            cell_metadata: None,
316        };
317        assert_eq!(evaluate_leaf(&null_row, &gt200), LeafOutcome::Unknown);
318    }
319
320    /// Pushed-down `IN` operands lower to wide numeric types (`Integer`), but
321    /// the row value for a CQL `tinyint`/`smallint`/`float` column is a narrow
322    /// variant. Membership must coerce (like `Equal`) so the match still holds.
323    #[test]
324    fn evaluate_leaf_in_coerces_narrow_numeric_columns() {
325        // Operands as they arrive from a Flight ticket (wide types).
326        let in_pred = SSTablePredicate::column(
327            "v",
328            SSTableFilterOp::In,
329            vec![Value::Integer(7), Value::Integer(9)],
330        );
331
332        let row_of = |val: Value| {
333            let mut values: std::collections::HashMap<std::sync::Arc<str>, Value> =
334                std::collections::HashMap::new();
335            values.insert("v".into(), val);
336            QueryRow {
337                values,
338                key: RowKey::new(Vec::new()),
339                metadata: Default::default(),
340                cell_metadata: None,
341            }
342        };
343
344        // Narrow column values must still match the wide IN operands.
345        assert_eq!(
346            evaluate_leaf(&row_of(Value::TinyInt(7)), &in_pred),
347            LeafOutcome::True
348        );
349        assert_eq!(
350            evaluate_leaf(&row_of(Value::SmallInt(9)), &in_pred),
351            LeafOutcome::True
352        );
353        assert_eq!(
354            evaluate_leaf(&row_of(Value::Float32(7.0)), &in_pred),
355            LeafOutcome::True
356        );
357        // A non-member narrow value is still False (not Unknown).
358        assert_eq!(
359            evaluate_leaf(&row_of(Value::TinyInt(8)), &in_pred),
360            LeafOutcome::False
361        );
362    }
363
364    /// Issue #788: the `pk = ? AND ck >= 0 AND ck < 200` shape — a two-bound AND
365    /// set — must include `[0, 199]` and exclude `200`/`1000`, reproducing the
366    /// 200-row slice the issue expects (previously the whole partition leaked).
367    #[test]
368    fn two_bound_inequality_slice_selects_half_open_range() {
369        let predicates = vec![
370            SSTablePredicate::column("ck", SSTableFilterOp::Gte, vec![Value::Integer(0)]),
371            SSTablePredicate::column("ck", SSTableFilterOp::Lt, vec![Value::Integer(200)]),
372        ];
373        let in_slice = |ck: i64| evaluate_predicates(&row_with_int("ck", ck), &predicates).unwrap();
374
375        assert!(in_slice(0), "lower bound is inclusive");
376        assert!(in_slice(199), "last row in [0, 200) is included");
377        assert!(!in_slice(200), "upper bound is exclusive");
378        assert!(!in_slice(1000), "rows past the slice are excluded");
379        assert!(!in_slice(-1), "rows below the slice are excluded");
380    }
381
382    /// FINDING 2: a `token(...)` over the FULL partition key in declared order is
383    /// accepted (validation passes), so the existing token fast-path/evaluation
384    /// behaviour is preserved.
385    #[test]
386    fn validate_token_predicates_accepts_full_key_in_order() {
387        let schema = composite_pk_schema(("a", "int"), ("b", "int"));
388        let pred = SSTablePredicate::token(
389            vec!["a".to_string(), "b".to_string()],
390            SSTableFilterOp::Gt,
391            vec![Value::BigInt(0)],
392        );
393        assert!(
394            validate_token_predicates(std::slice::from_ref(&pred), Some(&schema)).is_ok(),
395            "token(a, b) over the full partition key in declared order must be accepted",
396        );
397
398        // Single-column key: token(id) is the full key.
399        let single = single_pk_schema("id", "int");
400        let pred_single = SSTablePredicate::token(
401            vec!["id".to_string()],
402            SSTableFilterOp::Gte,
403            vec![Value::BigInt(0)],
404        );
405        assert!(
406            validate_token_predicates(std::slice::from_ref(&pred_single), Some(&single)).is_ok(),
407            "token(id) over a single-column partition key must be accepted",
408        );
409    }
410
411    /// FINDING 2: `token(non_pk_col)` must be REJECTED — `evaluate_leaf` would
412    /// otherwise hash the real partition key and silently return rows for a
413    /// different expression than the user wrote.
414    #[test]
415    fn validate_token_predicates_rejects_non_pk_column() {
416        let schema = single_pk_schema("id", "int");
417        let pred = SSTablePredicate::token(
418            vec!["not_the_pk".to_string()],
419            SSTableFilterOp::Gt,
420            vec![Value::BigInt(0)],
421        );
422        assert!(
423            validate_token_predicates(std::slice::from_ref(&pred), Some(&schema)).is_err(),
424            "token(non_pk_col) must be rejected, not evaluated against the real pk token",
425        );
426    }
427
428    /// FINDING 2: `token(b, a)` on a `(a, b)` key — right columns, WRONG order —
429    /// must be rejected (Cassandra requires declared order).
430    #[test]
431    fn validate_token_predicates_rejects_reordered_composite() {
432        let schema = composite_pk_schema(("a", "int"), ("b", "int"));
433        let reordered = SSTablePredicate::token(
434            vec!["b".to_string(), "a".to_string()],
435            SSTableFilterOp::Lt,
436            vec![Value::BigInt(0)],
437        );
438        assert!(
439            validate_token_predicates(std::slice::from_ref(&reordered), Some(&schema)).is_err(),
440            "token(b, a) on a (a, b) key must be rejected (wrong order)",
441        );
442
443        // A strict subset (missing the second column) is also rejected.
444        let subset = SSTablePredicate::token(
445            vec!["a".to_string()],
446            SSTableFilterOp::Lt,
447            vec![Value::BigInt(0)],
448        );
449        assert!(
450            validate_token_predicates(std::slice::from_ref(&subset), Some(&schema)).is_err(),
451            "token(a) on a (a, b) key must be rejected (partial key)",
452        );
453    }
454
455    /// FINDING 2: with no schema we cannot prove the token columns are the
456    /// partition key, so any token predicate is rejected (never trusted).
457    #[test]
458    fn validate_token_predicates_rejects_without_schema() {
459        let pred = SSTablePredicate::token(
460            vec!["id".to_string()],
461            SSTableFilterOp::Gt,
462            vec![Value::BigInt(0)],
463        );
464        assert!(
465            validate_token_predicates(std::slice::from_ref(&pred), None).is_err(),
466            "a token predicate with no schema must be rejected",
467        );
468        // Ordinary column predicates are unaffected by token validation.
469        let col = SSTablePredicate::column("id", SSTableFilterOp::Equal, vec![Value::Integer(1)]);
470        assert!(
471            validate_token_predicates(std::slice::from_ref(&col), None).is_ok(),
472            "non-token predicates must pass token validation even without a schema",
473        );
474    }
475}