Skip to main content

nodedb_types/value/
sql_literal.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Convert [`Value`] to deterministic, injection-safe SQL literal text.
4
5use super::core::Value;
6use crate::quote_literal;
7
8impl Value {
9    /// Convert this value into SQL literal text.
10    ///
11    /// This is total: every `Value` variant has an explicit representation and
12    /// every externally-derived string is emitted through [`quote_literal`].
13    pub fn to_sql_literal(&self) -> String {
14        match self {
15            Value::Null => "NULL".into(),
16            Value::Bool(value) => if *value { "TRUE" } else { "FALSE" }.into(),
17            Value::Integer(value) => value.to_string(),
18            Value::Float(value) => finite_float_literal(*value),
19            Value::String(value)
20            | Value::Uuid(value)
21            | Value::Ulid(value)
22            | Value::Regex(value) => quote_literal(value),
23            Value::Bytes(value) => quote_literal(&format!("\\x{}", hex::encode(value))),
24            Value::Array(values) | Value::Set(values) => array_literal(values),
25            Value::Object(values) => object_literal(values),
26            Value::DateTime(value) | Value::NaiveDateTime(value) => {
27                quote_literal(&value.to_string())
28            }
29            Value::Duration(value) => quote_literal(&value.to_string()),
30            Value::Decimal(value) => value.to_string(),
31            // Debug is a total, deterministic representation for this enum and
32            // avoids a fallible JSON serializer on this infallible public API.
33            Value::Geometry(value) => quote_literal(&format!("{value:?}")),
34            Value::Range {
35                start,
36                end,
37                inclusive,
38            } => {
39                let start = start
40                    .as_deref()
41                    .map_or_else(|| "unbounded".into(), Value::to_sql_literal);
42                let end = end
43                    .as_deref()
44                    .map_or_else(|| "unbounded".into(), Value::to_sql_literal);
45                quote_literal(&format!(
46                    "{start}{}{end}",
47                    if *inclusive { "..=" } else { ".." }
48                ))
49            }
50            Value::Record { table, id } => quote_literal(&format!("{table}:{id}")),
51            Value::ArrayCell(cell) => {
52                let system_time = cell
53                    .system_time
54                    .map_or_else(|| "NULL".into(), |value| value.to_string());
55                quote_literal(&format!(
56                    "ArrayCell(coords={}, attrs={}, system_time={system_time})",
57                    array_literal(&cell.coords),
58                    array_literal(&cell.attrs)
59                ))
60            }
61            Value::Vector(values) => {
62                let elements = values
63                    .iter()
64                    .map(|value| finite_float_literal(f64::from(*value)))
65                    .collect::<Vec<_>>();
66                format!("ARRAY[{}]", elements.join(", "))
67            }
68        }
69    }
70}
71
72fn finite_float_literal(value: f64) -> String {
73    if value.is_finite() {
74        value.to_string()
75    } else {
76        quote_literal(&value.to_string())
77    }
78}
79
80fn array_literal(values: &[Value]) -> String {
81    let values = values.iter().map(Value::to_sql_literal).collect::<Vec<_>>();
82    format!("ARRAY[{}]", values.join(", "))
83}
84
85fn object_literal(values: &std::collections::HashMap<String, Value>) -> String {
86    let mut entries = values.iter().collect::<Vec<_>>();
87    entries.sort_unstable_by_key(|(key, _)| *key);
88    let entries = entries
89        .into_iter()
90        .map(|(key, value)| format!("{key}: {}", value.to_sql_literal()))
91        .collect::<Vec<_>>();
92    quote_literal(&format!("{{{}}}", entries.join(", ")))
93}
94
95#[cfg(test)]
96mod tests {
97    use std::collections::HashMap;
98    use std::sync::Arc;
99
100    use super::*;
101    use crate::array_cell::ArrayCell;
102    use crate::geometry::Geometry;
103
104    #[test]
105    fn renders_every_value_variant_without_null_fallback() {
106        let values = vec![
107            Value::Null,
108            Value::Bool(true),
109            Value::Integer(-7),
110            Value::Float(1.5),
111            Value::String("O'Reilly\n雪".into()),
112            Value::Bytes(vec![0, 0xff]),
113            Value::Array(vec![Value::Integer(1)]),
114            Value::Object(HashMap::from([(
115                "key".into(),
116                Value::String("value".into()),
117            )])),
118            Value::Uuid("id'quoted".into()),
119            Value::Ulid("ulid'quoted".into()),
120            Value::DateTime(crate::datetime::NdbDateTime::from_micros(0)),
121            Value::NaiveDateTime(crate::datetime::NdbDateTime::from_micros(0)),
122            Value::Duration(crate::datetime::NdbDuration::from_micros(1)),
123            Value::Decimal(rust_decimal::Decimal::new(125, 2)),
124            Value::Geometry(Geometry::point(1.0, 2.0)),
125            Value::Set(vec![Value::Bool(false)]),
126            Value::Regex("a'b".into()),
127            Value::Range {
128                start: Some(Box::new(Value::Integer(1))),
129                end: None,
130                inclusive: true,
131            },
132            Value::Record {
133                table: "ta'ble".into(),
134                id: "id'雪".into(),
135            },
136            Value::ArrayCell(ArrayCell {
137                coords: vec![Value::Integer(1)],
138                attrs: vec![Value::String("a'b".into())],
139                system_time: Some(7),
140            }),
141            Value::Vector(Arc::from([1.0_f32, 2.0_f32])),
142        ];
143        for value in values {
144            let literal = value.to_sql_literal();
145            if !matches!(value, Value::Null) {
146                assert_ne!(
147                    literal, "NULL",
148                    "non-null variant must not fall back to NULL: {value:?}"
149                );
150            }
151        }
152    }
153
154    #[test]
155    fn string_derived_values_use_shared_quote_literal() {
156        let value = Value::Record {
157            table: "t'\n雪".into(),
158            id: "i'\r".into(),
159        };
160        assert_eq!(
161            Value::String("t'\n雪".into()).to_sql_literal(),
162            quote_literal("t'\n雪")
163        );
164        assert_eq!(value.to_sql_literal(), quote_literal("t'\n雪:i'\r"));
165        assert_eq!(
166            Value::Bytes(vec![0xab]).to_sql_literal(),
167            quote_literal("\\xab")
168        );
169    }
170
171    #[test]
172    fn nonfinite_floats_are_quoted_and_nested_object_is_sorted() {
173        assert_eq!(
174            Value::Float(f64::NAN).to_sql_literal(),
175            quote_literal("NaN")
176        );
177        assert_eq!(
178            Value::Float(f64::INFINITY).to_sql_literal(),
179            quote_literal("inf")
180        );
181        let object = Value::Object(HashMap::from([
182            ("z'key".into(), Value::Float(f64::NEG_INFINITY)),
183            ("a".into(), Value::Array(vec![Value::String("o'k".into())])),
184        ]));
185        assert_eq!(
186            object.to_sql_literal(),
187            quote_literal("{a: ARRAY['o''k'], z'key: '-inf'}")
188        );
189    }
190
191    #[test]
192    fn array_cell_nested_objects_are_deterministic() {
193        let left = HashMap::from([
194            ("z".to_string(), Value::Integer(2)),
195            ("a".to_string(), Value::String("x'y".into())),
196        ]);
197        let right = HashMap::from([
198            ("a".to_string(), Value::String("x'y".into())),
199            ("z".to_string(), Value::Integer(2)),
200        ]);
201        let render = |object| {
202            Value::ArrayCell(ArrayCell {
203                coords: vec![Value::Object(object)],
204                attrs: Vec::new(),
205                system_time: None,
206            })
207            .to_sql_literal()
208        };
209        assert_eq!(render(left), render(right));
210    }
211
212    #[test]
213    fn geometry_and_ranges_have_explicit_safe_representations() {
214        assert_eq!(
215            Value::Geometry(Geometry::point(1.0, 2.0)).to_sql_literal(),
216            quote_literal("Point { coordinates: [1.0, 2.0] }")
217        );
218        assert_eq!(
219            Value::Range {
220                start: Some(Box::new(Value::String("a'b".into()))),
221                end: Some(Box::new(Value::Integer(2))),
222                inclusive: false
223            }
224            .to_sql_literal(),
225            quote_literal("'a''b'..2")
226        );
227    }
228}