Skip to main content

nodedb_sql/planner/
predicate_coerce.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Coerce the literals a predicate compares against a declared `TIMESTAMP` /
4//! `TIMESTAMPTZ` column into typed instants, once, at plan time.
5//!
6//! # Why this exists
7//!
8//! A time literal in a `WHERE`, `ON`, or `WHEN` clause reaches the planner as
9//! whatever the user typed: `at > 1583402400000`, `at > '2020-03-05
10//! 10:00:00'`, `at > TIMESTAMP '...'`. The write side already resolves the
11//! same three spellings to one typed instant through
12//! [`super::declared_type_coerce::coerce_value`], so every engine stores an
13//! instant. Left as typed, a numeric read literal is an `Integer` the Data
14//! Plane compares against an instant: the pair has no defined equality or
15//! order, so `=` never matches and `>=` either matches nothing or, on a path
16//! that guessed a unit, matches by accident.
17//!
18//! Resolving the literal here, against the declared column type the scope
19//! already carries, means the Data Plane only ever compares instants with
20//! instants. It also puts the type error where PostgreSQL puts it: `WHERE at
21//! = true` fails the statement naming the column, instead of scanning every
22//! row to match none.
23//!
24//! # Scope
25//!
26//! Only `TIMESTAMP` / `TIMESTAMPTZ` columns are coerced. Every other declared
27//! type is left as written: the Data Plane's coerced comparison already
28//! reads `"5"` against `5` and `1.5` against `1`, so no read literal for
29//! those types is lost the way a bare integer against an instant is.
30//!
31//! The primary-key column is exempt, mirroring the write side: the engines
32//! derive a row's identity from the literal's own rendering on both the
33//! write and the read side, and re-typing one side would make the row
34//! unfindable by the other.
35//!
36//! A column the scope does not declare (a schemaless field, a computed
37//! alias, an aggregate output) is left untouched: there is no declared type
38//! to coerce to.
39
40use crate::error::Result;
41use crate::resolver::columns::TableScope;
42use crate::types::{BinaryOp, ColumnInfo, SqlDataType, SqlExpr, SqlValue, UnaryOp};
43
44use super::declared_type_coerce::coerce_value;
45
46/// Coerce one literal compared against `column` by the read-side rule.
47///
48/// The one rule for a literal a predicate compares against a declared
49/// column: an instant column (`TIMESTAMP` / `TIMESTAMPTZ`) types the literal
50/// through [`coerce_value`], the primary-key column and every other declared
51/// type keep the literal as written. `WHERE` clauses reach it through
52/// [`coerce_predicate_literals`]; row-level-security policy compilation calls
53/// it directly, so a policy literal and a query literal on the same column
54/// resolve to the same typed instant.
55///
56/// Errors name the column and the literal when the literal carries no
57/// instant.
58pub fn coerce_read_literal(column: &ColumnInfo, value: SqlValue) -> Result<SqlValue> {
59    if column.is_primary_key || !is_instant(&column.data_type) {
60        return Ok(value);
61    }
62    coerce_value(&column.name, value, &column.data_type)
63}
64
65/// Coerce, in place, every literal `expr` compares against a declared
66/// instant column.
67///
68/// Walks `AND` / `OR` / `NOT` down to each predicate and handles the shapes
69/// that pair one column with literals: `column <op> literal` (either
70/// orientation), `column BETWEEN literal AND literal`, and `column IN
71/// (literal, …)`. A literal of a kind that carries no instant (a boolean,
72/// bytes, an array) is a typed error naming the column.
73pub(crate) fn coerce_predicate_literals(expr: &mut SqlExpr, scope: &TableScope) -> Result<()> {
74    match expr {
75        SqlExpr::BinaryOp { left, op, right } => match op {
76            BinaryOp::And | BinaryOp::Or => {
77                coerce_predicate_literals(left, scope)?;
78                coerce_predicate_literals(right, scope)
79            }
80            BinaryOp::Eq
81            | BinaryOp::Ne
82            | BinaryOp::Gt
83            | BinaryOp::Ge
84            | BinaryOp::Lt
85            | BinaryOp::Le => match (left.as_mut(), right.as_mut()) {
86                (SqlExpr::Column { table, name }, SqlExpr::Literal(value))
87                | (SqlExpr::Literal(value), SqlExpr::Column { table, name }) => {
88                    coerce_literal(scope, table.as_deref(), name, value)
89                }
90                // Column-vs-column, computed operands, subqueries: no literal
91                // to coerce.
92                _ => Ok(()),
93            },
94            BinaryOp::Add
95            | BinaryOp::Sub
96            | BinaryOp::Mul
97            | BinaryOp::Div
98            | BinaryOp::Mod
99            | BinaryOp::Concat => Ok(()),
100        },
101        SqlExpr::UnaryOp {
102            op: UnaryOp::Not,
103            expr,
104        } => coerce_predicate_literals(expr, scope),
105        SqlExpr::Between {
106            expr, low, high, ..
107        } => {
108            let SqlExpr::Column { table, name } = expr.as_ref() else {
109                return Ok(());
110            };
111            for bound in [low.as_mut(), high.as_mut()] {
112                if let SqlExpr::Literal(value) = bound {
113                    coerce_literal(scope, table.as_deref(), name, value)?;
114                }
115            }
116            Ok(())
117        }
118        SqlExpr::InList { expr, list, .. } => {
119            let SqlExpr::Column { table, name } = expr.as_ref() else {
120                return Ok(());
121            };
122            for element in list.iter_mut() {
123                if let SqlExpr::Literal(value) = element {
124                    coerce_literal(scope, table.as_deref(), name, value)?;
125                }
126            }
127            Ok(())
128        }
129        SqlExpr::UnaryOp {
130            op: UnaryOp::Neg, ..
131        }
132        | SqlExpr::Column { .. }
133        | SqlExpr::Literal(_)
134        | SqlExpr::Function { .. }
135        | SqlExpr::Case { .. }
136        | SqlExpr::Cast { .. }
137        | SqlExpr::Subquery(_)
138        | SqlExpr::Wildcard
139        | SqlExpr::IsNull { .. }
140        | SqlExpr::Like { .. }
141        | SqlExpr::ArrayLiteral(_) => Ok(()),
142    }
143}
144
145/// Coerce one literal compared against the column `table.name` names, when
146/// the scope declares that column as an instant.
147fn coerce_literal(
148    scope: &TableScope,
149    table: Option<&str>,
150    name: &str,
151    value: &mut SqlValue,
152) -> Result<()> {
153    let Some(column) = scope.declared_column(table, name) else {
154        return Ok(());
155    };
156    let taken = std::mem::replace(value, SqlValue::Null);
157    *value = coerce_read_literal(column, taken)?;
158    Ok(())
159}
160
161/// Whether a declared type stores a typed instant.
162fn is_instant(declared: &SqlDataType) -> bool {
163    match declared {
164        SqlDataType::Timestamp | SqlDataType::Timestamptz => true,
165        SqlDataType::Int64
166        | SqlDataType::Float64
167        | SqlDataType::String
168        | SqlDataType::Bool
169        | SqlDataType::Bytes
170        | SqlDataType::Decimal(_)
171        | SqlDataType::Uuid
172        | SqlDataType::Vector(_)
173        | SqlDataType::Geometry
174        | SqlDataType::Json
175        | SqlDataType::Unknown => false,
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::resolver::columns::ResolvedTable;
183    use crate::types::{CollectionInfo, ColumnInfo, EngineType};
184    use nodedb_types::datetime::NdbDateTime;
185
186    /// `2020-03-05T10:00:00Z` as epoch milliseconds.
187    const EARLY_MS: i64 = 1_583_402_400_000;
188
189    fn early() -> NdbDateTime {
190        NdbDateTime::from_micros(EARLY_MS * 1_000)
191    }
192
193    fn column(name: &str, data_type: SqlDataType, is_primary_key: bool) -> ColumnInfo {
194        ColumnInfo {
195            name: name.to_string(),
196            data_type,
197            nullable: true,
198            is_primary_key,
199            default: None,
200            raw_type: None,
201            int_width: None,
202            float_width: None,
203        }
204    }
205
206    /// `t (id TEXT PRIMARY KEY, at TIMESTAMP, at_tz TIMESTAMPTZ, n BIGINT,
207    /// pk_at TIMESTAMP PRIMARY KEY)` under alias `a`.
208    fn scope() -> TableScope {
209        let info = CollectionInfo {
210            name: "t".into(),
211            engine: EngineType::DocumentStrict,
212            columns: vec![
213                column("id", SqlDataType::String, true),
214                column("at", SqlDataType::Timestamp, false),
215                column("at_tz", SqlDataType::Timestamptz, false),
216                column("n", SqlDataType::Int64, false),
217                column("pk_at", SqlDataType::Timestamp, true),
218            ],
219            primary_key: Some("id".into()),
220            has_auto_tier: false,
221            indexes: Vec::new(),
222            bitemporal: false,
223            primary: nodedb_types::PrimaryEngine::Document,
224            vector_primary: None,
225            partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed,
226            open_schema: CollectionInfo::open_schema_for(EngineType::DocumentStrict),
227        };
228        TableScope::single(ResolvedTable {
229            name: info.name.clone(),
230            alias: Some("a".into()),
231            info,
232        })
233        .expect("single-relation scope")
234    }
235
236    fn col(table: Option<&str>, name: &str) -> SqlExpr {
237        SqlExpr::Column {
238            table: table.map(str::to_string),
239            name: name.into(),
240        }
241    }
242
243    fn lit(value: SqlValue) -> SqlExpr {
244        SqlExpr::Literal(value)
245    }
246
247    fn cmp(left: SqlExpr, op: BinaryOp, right: SqlExpr) -> SqlExpr {
248        SqlExpr::BinaryOp {
249            left: Box::new(left),
250            op,
251            right: Box::new(right),
252        }
253    }
254
255    fn literal_of(expr: &SqlExpr) -> &SqlValue {
256        match expr {
257            SqlExpr::Literal(v) => v,
258            other => panic!("expected a literal, got {other:?}"),
259        }
260    }
261
262    fn coerced(mut expr: SqlExpr) -> SqlExpr {
263        coerce_predicate_literals(&mut expr, &scope()).expect("predicate coerces");
264        expr
265    }
266
267    #[test]
268    fn column_op_literal_coerces_a_numeric_literal_to_the_declared_instant() {
269        let expr = coerced(cmp(
270            col(None, "at"),
271            BinaryOp::Gt,
272            lit(SqlValue::Int(EARLY_MS)),
273        ));
274        let SqlExpr::BinaryOp { right, .. } = &expr else {
275            panic!("shape preserved");
276        };
277        assert_eq!(literal_of(right), &SqlValue::Timestamp(early()));
278    }
279
280    #[test]
281    fn literal_op_column_is_coerced_in_the_mirrored_orientation() {
282        let expr = coerced(cmp(
283            lit(SqlValue::String("2020-03-05 10:00:00".into())),
284            BinaryOp::Lt,
285            col(Some("a"), "at_tz"),
286        ));
287        let SqlExpr::BinaryOp { left, .. } = &expr else {
288            panic!("shape preserved");
289        };
290        assert_eq!(literal_of(left), &SqlValue::Timestamptz(early()));
291    }
292
293    #[test]
294    fn between_coerces_both_bounds() {
295        let expr = coerced(SqlExpr::Between {
296            expr: Box::new(col(None, "at")),
297            low: Box::new(lit(SqlValue::Int(EARLY_MS))),
298            high: Box::new(lit(SqlValue::String("2020-03-05T11:00:00Z".into()))),
299            negated: false,
300        });
301        let SqlExpr::Between { low, high, .. } = &expr else {
302            panic!("shape preserved");
303        };
304        assert_eq!(literal_of(low), &SqlValue::Timestamp(early()));
305        assert_eq!(
306            literal_of(high),
307            &SqlValue::Timestamp(NdbDateTime::from_micros((EARLY_MS + 3_600_000) * 1_000))
308        );
309    }
310
311    #[test]
312    fn in_list_coerces_every_literal_element() {
313        let expr = coerced(SqlExpr::InList {
314            expr: Box::new(col(None, "at")),
315            list: vec![
316                lit(SqlValue::Int(EARLY_MS)),
317                lit(SqlValue::String("2020-03-05 10:00:00".into())),
318            ],
319            negated: true,
320        });
321        let SqlExpr::InList { list, .. } = &expr else {
322            panic!("shape preserved");
323        };
324        for element in list {
325            assert_eq!(literal_of(element), &SqlValue::Timestamp(early()));
326        }
327    }
328
329    #[test]
330    fn nested_and_or_not_reach_every_predicate() {
331        let expr = coerced(SqlExpr::UnaryOp {
332            op: UnaryOp::Not,
333            expr: Box::new(cmp(
334                cmp(col(None, "at"), BinaryOp::Ge, lit(SqlValue::Int(EARLY_MS))),
335                BinaryOp::Or,
336                cmp(
337                    cmp(col(None, "n"), BinaryOp::Eq, lit(SqlValue::Int(1))),
338                    BinaryOp::And,
339                    cmp(
340                        col(None, "at_tz"),
341                        BinaryOp::Le,
342                        lit(SqlValue::Int(EARLY_MS)),
343                    ),
344                ),
345            )),
346        });
347        let SqlExpr::UnaryOp { expr, .. } = &expr else {
348            panic!("shape preserved");
349        };
350        let SqlExpr::BinaryOp { left, right, .. } = expr.as_ref() else {
351            panic!("shape preserved");
352        };
353        let SqlExpr::BinaryOp { right: at_lit, .. } = left.as_ref() else {
354            panic!("shape preserved");
355        };
356        assert_eq!(literal_of(at_lit), &SqlValue::Timestamp(early()));
357        let SqlExpr::BinaryOp {
358            left: n_pred,
359            right: tz_pred,
360            ..
361        } = right.as_ref()
362        else {
363            panic!("shape preserved");
364        };
365        let SqlExpr::BinaryOp { right: n_lit, .. } = n_pred.as_ref() else {
366            panic!("shape preserved");
367        };
368        assert_eq!(literal_of(n_lit), &SqlValue::Int(1), "BIGINT is untouched");
369        let SqlExpr::BinaryOp { right: tz_lit, .. } = tz_pred.as_ref() else {
370            panic!("shape preserved");
371        };
372        assert_eq!(literal_of(tz_lit), &SqlValue::Timestamptz(early()));
373    }
374
375    #[test]
376    fn a_column_the_scope_does_not_declare_is_left_untouched() {
377        let expr = coerced(cmp(
378            col(None, "created"),
379            BinaryOp::Eq,
380            lit(SqlValue::Int(EARLY_MS)),
381        ));
382        let SqlExpr::BinaryOp { right, .. } = &expr else {
383            panic!("shape preserved");
384        };
385        assert_eq!(literal_of(right), &SqlValue::Int(EARLY_MS));
386    }
387
388    #[test]
389    fn a_qualifier_naming_another_relation_is_left_untouched() {
390        let expr = coerced(cmp(
391            col(Some("other"), "at"),
392            BinaryOp::Eq,
393            lit(SqlValue::Int(EARLY_MS)),
394        ));
395        let SqlExpr::BinaryOp { right, .. } = &expr else {
396            panic!("shape preserved");
397        };
398        assert_eq!(literal_of(right), &SqlValue::Int(EARLY_MS));
399    }
400
401    #[test]
402    fn the_primary_key_column_is_exempt() {
403        let expr = coerced(cmp(
404            col(None, "pk_at"),
405            BinaryOp::Eq,
406            lit(SqlValue::Int(EARLY_MS)),
407        ));
408        let SqlExpr::BinaryOp { right, .. } = &expr else {
409            panic!("shape preserved");
410        };
411        assert_eq!(literal_of(right), &SqlValue::Int(EARLY_MS));
412    }
413
414    #[test]
415    fn column_vs_column_and_null_are_left_alone() {
416        let expr = coerced(cmp(
417            cmp(col(None, "at"), BinaryOp::Lt, col(None, "at_tz")),
418            BinaryOp::And,
419            cmp(col(None, "at"), BinaryOp::Eq, lit(SqlValue::Null)),
420        ));
421        let SqlExpr::BinaryOp { right, .. } = &expr else {
422            panic!("shape preserved");
423        };
424        let SqlExpr::BinaryOp {
425            right: null_lit, ..
426        } = right.as_ref()
427        else {
428            panic!("shape preserved");
429        };
430        assert_eq!(literal_of(null_lit), &SqlValue::Null);
431    }
432
433    #[test]
434    fn a_boolean_literal_is_a_typed_error_naming_the_column() {
435        let mut expr = cmp(col(None, "at"), BinaryOp::Eq, lit(SqlValue::Bool(true)));
436        let err = coerce_predicate_literals(&mut expr, &scope())
437            .expect_err("a boolean carries no instant");
438        let detail = err.to_string();
439        assert!(
440            detail.contains("at") && detail.contains("a boolean"),
441            "error must name the column and the kind: {detail}"
442        );
443    }
444
445    #[test]
446    fn unparseable_text_is_a_typed_error_naming_the_column_and_literal() {
447        let mut expr = SqlExpr::InList {
448            expr: Box::new(col(None, "at")),
449            list: vec![lit(SqlValue::String("not a date".into()))],
450            negated: false,
451        };
452        let err = coerce_predicate_literals(&mut expr, &scope())
453            .expect_err("text that spells no instant is refused");
454        let detail = err.to_string();
455        assert!(
456            detail.contains("at") && detail.contains("not a date"),
457            "error must name the column and the literal: {detail}"
458        );
459    }
460}