Skip to main content

nodedb_lite/query/
coerce.rs

1//! Shared SQL → `nodedb_types::Value` coercion used by every engine DML
2//! dispatcher (strict, columnar, timeseries, …).
3//!
4//! The coercion table is single-sourced here so adding a new `ColumnType`
5//! variant or a new literal-shape rule lights up across every engine in one
6//! edit instead of being copy-pasted into each `*_dml.rs`.
7
8use std::collections::HashMap;
9
10use nodedb_sql::types::SqlValue;
11use nodedb_types::columnar::{ColumnDef, ColumnType};
12use nodedb_types::datetime::NdbDateTime;
13use nodedb_types::value::Value;
14
15use crate::error::LiteError;
16pub(crate) use crate::query::filter_convert::sql_value_to_value;
17
18/// Build a `Vec<Value>` in schema column order from a `(name, SqlValue)` pair list.
19///
20/// Columns absent from `pairs` default to `Value::Null`. Each provided value is
21/// coerced to the schema column type via [`coerce_sql_value`].
22pub fn build_row(
23    pairs: &[(String, SqlValue)],
24    columns: &[ColumnDef],
25) -> Result<Vec<Value>, LiteError> {
26    let pair_map: HashMap<&str, &SqlValue> = pairs.iter().map(|(k, v)| (k.as_str(), v)).collect();
27
28    let mut values = Vec::with_capacity(columns.len());
29    for col in columns {
30        let value = match pair_map.get(col.name.as_str()).copied() {
31            Some(v) => coerce_sql_value(v, &col.column_type)?,
32            None => Value::Null,
33        };
34        values.push(value);
35    }
36    Ok(values)
37}
38
39/// Coerce a `SqlValue` to a `Value` matching the target column type.
40///
41/// A column type without a dedicated rule takes the untyped conversion from
42/// `sql_value_to_value`, which keeps SELECT and unconstrained literal
43/// contexts working. A literal no instant can be read from is refused for a
44/// timestamp column, never stored as NULL.
45pub fn coerce_sql_value(v: &SqlValue, col_type: &ColumnType) -> Result<Value, LiteError> {
46    if matches!(v, SqlValue::Null) {
47        return Ok(Value::Null);
48    }
49    match col_type {
50        ColumnType::Timestamp => coerce_instant(v, col_type).map(Value::NaiveDateTime),
51        ColumnType::Timestamptz => coerce_instant(v, col_type).map(Value::DateTime),
52        ColumnType::Int64 => match v {
53            SqlValue::Int(i) => Ok(Value::Integer(*i)),
54            other => sql_value_to_value(other),
55        },
56        ColumnType::Float64 => match v {
57            SqlValue::Float(f) => Ok(Value::Float(*f)),
58            SqlValue::Int(i) => Ok(Value::Float(*i as f64)),
59            other => sql_value_to_value(other),
60        },
61        ColumnType::String => match v {
62            SqlValue::String(s) => Ok(Value::String(s.clone())),
63            other => sql_value_to_value(other),
64        },
65        ColumnType::Uuid => match v {
66            SqlValue::String(s) => Ok(Value::Uuid(s.clone())),
67            other => sql_value_to_value(other),
68        },
69        ColumnType::Bool => match v {
70            SqlValue::Bool(b) => Ok(Value::Bool(*b)),
71            other => sql_value_to_value(other),
72        },
73        ColumnType::Decimal(_) => match v {
74            SqlValue::Decimal(d) => Ok(Value::Decimal(*d)),
75            SqlValue::Int(i) => Ok(Value::Decimal(rust_decimal::Decimal::from(*i))),
76            SqlValue::String(s) => Ok(Value::String(s.clone())),
77            other => sql_value_to_value(other),
78        },
79        ColumnType::Bytes
80        | ColumnType::SystemTimestamp
81        | ColumnType::Geometry
82        | ColumnType::Vector(_)
83        | ColumnType::SparseVector
84        | ColumnType::Json
85        | ColumnType::Ulid
86        | ColumnType::Duration
87        | ColumnType::Array => sql_value_to_value(v),
88        other => Err(LiteError::Unsupported {
89            detail: format!("column type {other} has no write coercion rule in Lite"),
90        }),
91    }
92}
93
94/// Coerce an evaluated `Value` to the target column type by the rules
95/// [`coerce_sql_value`] applies to a literal of the same value.
96///
97/// A value no SQL literal spells (an array, object, vector, geometry, or
98/// identifier type) passes through. The strict engine checks it against the
99/// column type.
100pub fn coerce_value(v: Value, col_type: &ColumnType) -> Result<Value, LiteError> {
101    let literal = match &v {
102        Value::Null => return Ok(Value::Null),
103        Value::Integer(i) => SqlValue::Int(*i),
104        Value::Float(f) => SqlValue::Float(*f),
105        Value::Decimal(d) => SqlValue::Decimal(*d),
106        Value::String(s) => SqlValue::String(s.clone()),
107        Value::Bool(b) => SqlValue::Bool(*b),
108        Value::Bytes(b) => SqlValue::Bytes(b.clone()),
109        Value::NaiveDateTime(at) => SqlValue::Timestamp(*at),
110        Value::DateTime(at) => SqlValue::Timestamptz(*at),
111        _ => return Ok(v),
112    };
113    coerce_sql_value(&literal, col_type)
114}
115
116/// The instant a literal denotes under a timestamp column, mirroring the
117/// planner's write-side rule: a typed instant keeps its value whichever tag
118/// it carries, text parses as ISO-8601, and a numeric literal is epoch
119/// milliseconds with a fractional one contributing its integer part.
120fn coerce_instant(v: &SqlValue, col_type: &ColumnType) -> Result<NdbDateTime, LiteError> {
121    let refused = |literal: String| LiteError::BadRequest {
122        detail: format!("{literal} is not representable as {col_type}"),
123    };
124    match v {
125        SqlValue::Timestamp(at) | SqlValue::Timestamptz(at) => Ok(*at),
126        SqlValue::String(s) => NdbDateTime::parse(s).ok_or_else(|| refused(format!("'{s}'"))),
127        SqlValue::Int(millis) => {
128            NdbDateTime::from_millis(*millis).map_err(|_| refused(millis.to_string()))
129        }
130        SqlValue::Float(f) => {
131            let whole = f.trunc();
132            if !whole.is_finite() || whole < i64::MIN as f64 || whole >= i64::MAX as f64 {
133                return Err(refused(f.to_string()));
134            }
135            NdbDateTime::from_millis(whole as i64).map_err(|_| refused(f.to_string()))
136        }
137        SqlValue::Decimal(d) => d
138            .trunc()
139            .to_string()
140            .parse::<i64>()
141            .ok()
142            .and_then(|millis| NdbDateTime::from_millis(millis).ok())
143            .ok_or_else(|| refused(d.to_string())),
144        SqlValue::Null => Err(refused("NULL".to_string())),
145        SqlValue::Bool(b) => Err(refused(b.to_string())),
146        SqlValue::Bytes(_) => Err(refused("a byte string".to_string())),
147        SqlValue::Array(_) => Err(refused("an array".to_string())),
148    }
149}
150
151/// Render a `SqlValue` as the textual primary-key form used by `parse_pk_value`.
152pub fn sql_value_to_string(v: &SqlValue) -> String {
153    match v {
154        SqlValue::String(s) => s.clone(),
155        SqlValue::Int(i) => i.to_string(),
156        SqlValue::Float(f) => f.to_string(),
157        SqlValue::Bool(b) => b.to_string(),
158        SqlValue::Decimal(d) => d.to_string(),
159        SqlValue::Timestamp(at) | SqlValue::Timestamptz(at) => at.to_iso8601(),
160        SqlValue::Null | SqlValue::Bytes(_) | SqlValue::Array(_) => String::new(),
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    /// `2024-01-01T00:00:00Z` in epoch microseconds.
169    const NEW_YEAR_MICROS: i64 = 1_704_067_200_000_000;
170
171    fn at() -> NdbDateTime {
172        NdbDateTime::from_micros(NEW_YEAR_MICROS)
173    }
174
175    /// The planner's typed instant lands in a timestamp column under the
176    /// column's own kind, whichever tag the literal carries.
177    #[test]
178    fn typed_instant_lands_in_timestamp_column() {
179        assert_eq!(
180            coerce_sql_value(&SqlValue::Timestamp(at()), &ColumnType::Timestamp).expect("coerce"),
181            Value::NaiveDateTime(at())
182        );
183        assert_eq!(
184            coerce_sql_value(&SqlValue::Timestamptz(at()), &ColumnType::Timestamp).expect("coerce"),
185            Value::NaiveDateTime(at())
186        );
187        assert_eq!(
188            coerce_sql_value(&SqlValue::Timestamp(at()), &ColumnType::Timestamptz).expect("coerce"),
189            Value::DateTime(at())
190        );
191    }
192
193    /// Text and numeric literals read as ISO-8601 and epoch milliseconds.
194    #[test]
195    fn text_and_millis_read_as_instants() {
196        assert_eq!(
197            coerce_sql_value(
198                &SqlValue::String("2024-01-01 00:00:00".into()),
199                &ColumnType::Timestamp
200            )
201            .expect("coerce"),
202            Value::NaiveDateTime(at())
203        );
204        assert_eq!(
205            coerce_sql_value(
206                &SqlValue::Int(NEW_YEAR_MICROS / 1_000),
207                &ColumnType::Timestamptz
208            )
209            .expect("coerce"),
210            Value::DateTime(at())
211        );
212        assert_eq!(
213            coerce_sql_value(
214                &SqlValue::Float(NEW_YEAR_MICROS as f64 / 1_000.0 + 0.7),
215                &ColumnType::Timestamp
216            )
217            .expect("coerce"),
218            Value::NaiveDateTime(at())
219        );
220    }
221
222    /// A literal no instant can be read from is refused, never stored as NULL.
223    #[test]
224    fn unreadable_instant_is_refused() {
225        for bad in [
226            SqlValue::String("not a date".into()),
227            SqlValue::Bool(true),
228            SqlValue::Int(i64::MAX),
229        ] {
230            let err = coerce_sql_value(&bad, &ColumnType::Timestamp).expect_err("refused");
231            assert!(matches!(err, LiteError::BadRequest { .. }), "{err:?}");
232        }
233        assert_eq!(
234            coerce_sql_value(&SqlValue::Null, &ColumnType::Timestamp).expect("null"),
235            Value::Null
236        );
237    }
238
239    /// A planner-fitted decimal lands in a decimal column exactly, with its
240    /// scale intact, never through `f64`.
241    #[test]
242    fn decimal_column_stores_the_exact_decimal() {
243        let typmod = nodedb_types::columnar::DecimalTypmod::new(5, 2).expect("typmod");
244        let exact = rust_decimal::Decimal::new(101, 2);
245        assert_eq!(
246            coerce_sql_value(
247                &SqlValue::Decimal(exact),
248                &ColumnType::Decimal(Some(typmod))
249            )
250            .expect("coerce"),
251            Value::Decimal(exact)
252        );
253        assert_eq!(
254            coerce_sql_value(&SqlValue::Int(7), &ColumnType::Decimal(None)).expect("coerce"),
255            Value::Decimal(rust_decimal::Decimal::from(7))
256        );
257    }
258
259    /// `build_row` places a typed instant under its column and leaves absent
260    /// columns NULL.
261    #[test]
262    fn build_row_orders_by_schema() {
263        let columns = vec![
264            ColumnDef::required("id", ColumnType::Int64),
265            ColumnDef::required("ts", ColumnType::Timestamp),
266            ColumnDef::nullable("value", ColumnType::Float64),
267        ];
268        let pairs = vec![
269            ("ts".to_string(), SqlValue::Timestamp(at())),
270            ("id".to_string(), SqlValue::Int(1)),
271        ];
272        let row = build_row(&pairs, &columns).expect("row");
273        assert_eq!(
274            row,
275            vec![Value::Integer(1), Value::NaiveDateTime(at()), Value::Null]
276        );
277    }
278
279    /// An evaluated value takes the coercion a literal of the same value
280    /// takes, and a value no literal spells passes through.
281    #[test]
282    fn evaluated_value_coerces_like_its_literal() {
283        assert_eq!(
284            coerce_value(Value::Integer(3), &ColumnType::Float64).expect("coerce"),
285            Value::Float(3.0)
286        );
287        assert_eq!(
288            coerce_value(Value::String("a-b".into()), &ColumnType::Uuid).expect("coerce"),
289            Value::Uuid("a-b".into())
290        );
291        let array = Value::Array(vec![Value::Float(1.0)]);
292        assert_eq!(
293            coerce_value(array.clone(), &ColumnType::Array).expect("coerce"),
294            array
295        );
296    }
297}