Skip to main content

velr_types/
render.rs

1use std::fmt::Write;
2
3use crate::error::{DecodeError, EncodeError};
4use crate::list::ListValue;
5use crate::property::{PropertyValue, PropertyValueRef};
6use crate::storage::StorageValueRef;
7use crate::tag;
8use crate::vector::{VectorElem, VectorValue};
9
10pub const BOOL_FALSE_SQLITE_BLOB_LITERAL: &str = "X'00'";
11pub const BOOL_TRUE_SQLITE_BLOB_LITERAL: &str = "X'01'";
12
13pub fn bool_blob_bytes(value: bool) -> &'static [u8] {
14    if value {
15        &[tag::BOOL_TRUE]
16    } else {
17        &[tag::BOOL_FALSE]
18    }
19}
20
21pub fn blob_sql_literal(bytes: &[u8]) -> String {
22    let mut out = String::with_capacity(3 + bytes.len() * 2);
23    out.push_str("X'");
24    for byte in bytes {
25        let _ = write!(out, "{byte:02X}");
26    }
27    out.push('\'');
28    out
29}
30
31pub fn storage_value_sql_literal(value: StorageValueRef<'_>) -> String {
32    match value {
33        StorageValueRef::Null => "NULL".to_string(),
34        StorageValueRef::Integer(v) => v.to_string(),
35        StorageValueRef::Real(v) => {
36            if v.is_finite() && v.fract() == 0.0 {
37                format!("{v:.1}")
38            } else {
39                v.to_string()
40            }
41        }
42        StorageValueRef::Text(text) => sql_quote(text),
43        StorageValueRef::Blob(bytes) => blob_sql_literal(bytes),
44    }
45}
46
47#[derive(Debug)]
48pub enum RenderError {
49    Decode(DecodeError),
50    Encode(EncodeError),
51    NonCanonical(&'static str),
52}
53
54impl std::fmt::Display for RenderError {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            Self::Decode(err) => err.fmt(f),
58            Self::Encode(err) => err.fmt(f),
59            Self::NonCanonical(msg) => write!(f, "non-canonical value: {msg}"),
60        }
61    }
62}
63
64impl std::error::Error for RenderError {}
65
66impl From<DecodeError> for RenderError {
67    fn from(value: DecodeError) -> Self {
68        Self::Decode(value)
69    }
70}
71
72impl From<EncodeError> for RenderError {
73    fn from(value: EncodeError) -> Self {
74        Self::Encode(value)
75    }
76}
77
78pub fn storage_value_to_json_text(value: StorageValueRef<'_>) -> Result<String, RenderError> {
79    match value {
80        StorageValueRef::Null => Ok("null".to_string()),
81        StorageValueRef::Integer(v) => Ok(v.to_string()),
82        StorageValueRef::Real(v) => {
83            if !v.is_finite() {
84                return Err(RenderError::NonCanonical(
85                    "non-finite floats are not canonical",
86                ));
87            }
88            let mut out = String::new();
89            push_json_real(&mut out, v);
90            Ok(out)
91        }
92        StorageValueRef::Text(text) => Ok(json_quote(text)),
93        StorageValueRef::Blob(blob) => blob_to_json_text(blob),
94    }
95}
96
97pub fn storage_value_to_display_text(value: StorageValueRef<'_>) -> Result<String, RenderError> {
98    match value {
99        StorageValueRef::Null => Ok("null".to_string()),
100        StorageValueRef::Integer(v) => Ok(v.to_string()),
101        StorageValueRef::Real(v) => {
102            if !v.is_finite() {
103                return Err(RenderError::NonCanonical(
104                    "non-finite floats are not canonical",
105                ));
106            }
107            let mut out = String::new();
108            push_json_f64(&mut out, v);
109            Ok(out)
110        }
111        StorageValueRef::Text(text) => Ok(text.to_string()),
112        StorageValueRef::Blob(blob) => blob_to_display_text(blob),
113    }
114}
115
116pub fn property_value_to_json_text(value: &PropertyValue) -> Result<String, RenderError> {
117    let mut out = String::new();
118    append_property_json(&mut out, value.as_ref())?;
119    Ok(out)
120}
121
122pub fn property_value_to_display_text(value: &PropertyValue) -> Result<String, RenderError> {
123    let mut out = String::new();
124    append_property_display(&mut out, value.as_ref())?;
125    Ok(out)
126}
127
128fn blob_to_json_text(blob: &[u8]) -> Result<String, RenderError> {
129    if let Some(text) = tagged_json_payload(blob)? {
130        return Ok(text);
131    }
132    if let Some(text) = tagged_temporal_payload(blob)? {
133        return Ok(json_quote(&text));
134    }
135
136    let value = crate::codec::decode_property_value(StorageValueRef::Blob(blob))?;
137    property_value_to_json_text(&value)
138}
139
140fn blob_to_display_text(blob: &[u8]) -> Result<String, RenderError> {
141    if let Some(text) = tagged_json_payload(blob)? {
142        return Ok(text);
143    }
144    if let Some(text) = tagged_temporal_payload(blob)? {
145        return Ok(text);
146    }
147
148    let value = crate::codec::decode_property_value(StorageValueRef::Blob(blob))?;
149    property_value_to_display_text(&value)
150}
151
152fn tagged_json_payload(blob: &[u8]) -> Result<Option<String>, RenderError> {
153    let Some(tag) = blob.first().copied() else {
154        return Ok(None);
155    };
156
157    let raw_json_payload = matches!(
158        tag,
159        tag::JSON | tag::MAP_JSON | tag::NODE_JSON | tag::RELATIONSHIP_JSON | tag::PATH_JSON
160    );
161    if !raw_json_payload {
162        return Ok(None);
163    }
164
165    let payload = std::str::from_utf8(&blob[1..]).map_err(|_| DecodeError::InvalidUtf8)?;
166    Ok(Some(payload.to_string()))
167}
168
169fn tagged_temporal_payload(blob: &[u8]) -> Result<Option<String>, RenderError> {
170    let Some(tag) = blob.first().copied() else {
171        return Ok(None);
172    };
173
174    let temporal_payload = matches!(
175        tag,
176        tag::DATE
177            | tag::LOCAL_TIME
178            | tag::ZONED_TIME
179            | tag::LOCAL_DATETIME
180            | tag::ZONED_DATETIME
181            | tag::DURATION
182    );
183    if !temporal_payload {
184        return Ok(None);
185    }
186
187    let payload = std::str::from_utf8(&blob[1..]).map_err(|_| DecodeError::InvalidUtf8)?;
188    Ok(Some(payload.to_string()))
189}
190
191fn append_property_json(out: &mut String, value: PropertyValueRef<'_>) -> Result<(), RenderError> {
192    if let Some(text) = crate::codec::temporal_value_text(value) {
193        push_json_string(out, &text);
194        return Ok(());
195    }
196
197    match value {
198        PropertyValueRef::Null => out.push_str("null"),
199        PropertyValueRef::Bool(v) => out.push_str(if v { "true" } else { "false" }),
200        PropertyValueRef::Integer(v) => {
201            let _ = write!(out, "{v}");
202        }
203        PropertyValueRef::Float(v) => {
204            if !v.is_finite() {
205                return Err(RenderError::NonCanonical(
206                    "non-finite floats are not canonical",
207                ));
208            }
209            push_json_real(out, v);
210        }
211        PropertyValueRef::String(v) => push_json_string(out, v),
212        PropertyValueRef::Date(_)
213        | PropertyValueRef::LocalTime(_)
214        | PropertyValueRef::ZonedTime(_)
215        | PropertyValueRef::LocalDateTime(_)
216        | PropertyValueRef::ZonedDateTime(_)
217        | PropertyValueRef::Duration(_) => unreachable!("temporal values are handled above"),
218        PropertyValueRef::Point(v) => out.push_str(&v.to_geojson_string()?),
219        PropertyValueRef::Geometry(v) => out.push_str(&v.to_geojson_string()?),
220        PropertyValueRef::Geography(v) => out.push_str(&v.to_geojson_string()?),
221        PropertyValueRef::List(v) => append_list_json(out, v)?,
222        PropertyValueRef::Vector(v) => append_vector_json(out, v),
223        PropertyValueRef::Bytes(v) => push_json_string(out, &hex_string(v)),
224    }
225    Ok(())
226}
227
228fn append_property_display(
229    out: &mut String,
230    value: PropertyValueRef<'_>,
231) -> Result<(), RenderError> {
232    if let Some(text) = crate::codec::temporal_value_text(value) {
233        out.push_str(&text);
234        return Ok(());
235    }
236
237    match value {
238        PropertyValueRef::Null => out.push_str("null"),
239        PropertyValueRef::Bool(v) => out.push_str(if v { "true" } else { "false" }),
240        PropertyValueRef::Integer(v) => {
241            let _ = write!(out, "{v}");
242        }
243        PropertyValueRef::Float(v) => {
244            if !v.is_finite() {
245                return Err(RenderError::NonCanonical(
246                    "non-finite floats are not canonical",
247                ));
248            }
249            push_json_f64(out, v);
250        }
251        PropertyValueRef::String(v) => out.push_str(v),
252        PropertyValueRef::Date(_)
253        | PropertyValueRef::LocalTime(_)
254        | PropertyValueRef::ZonedTime(_)
255        | PropertyValueRef::LocalDateTime(_)
256        | PropertyValueRef::ZonedDateTime(_)
257        | PropertyValueRef::Duration(_) => unreachable!("temporal values are handled above"),
258        PropertyValueRef::Point(v) => out.push_str(&v.to_geojson_string()?),
259        PropertyValueRef::Geometry(v) => out.push_str(&v.to_geojson_string()?),
260        PropertyValueRef::Geography(v) => out.push_str(&v.to_geojson_string()?),
261        PropertyValueRef::List(v) => append_list_json(out, v)?,
262        PropertyValueRef::Vector(v) => append_vector_json(out, v),
263        PropertyValueRef::Bytes(v) => out.push_str(&hex_string(v)),
264    }
265    Ok(())
266}
267
268fn append_list_json(out: &mut String, list: &ListValue) -> Result<(), RenderError> {
269    out.push('[');
270    for (idx, item) in list.iter().enumerate() {
271        if idx > 0 {
272            out.push(',');
273        }
274        append_property_json(out, item)?;
275    }
276    out.push(']');
277    Ok(())
278}
279
280fn append_vector_json(out: &mut String, vector: &VectorValue) {
281    out.push('[');
282    for (idx, item) in vector.iter().enumerate() {
283        if idx > 0 {
284            out.push(',');
285        }
286        match item {
287            VectorElem::I8(v) => {
288                let _ = write!(out, "{v}");
289            }
290            VectorElem::I16(v) => {
291                let _ = write!(out, "{v}");
292            }
293            VectorElem::I32(v) => {
294                let _ = write!(out, "{v}");
295            }
296            VectorElem::I64(v) => {
297                let _ = write!(out, "{v}");
298            }
299            VectorElem::F32(v) => push_json_real(out, v as f64),
300            VectorElem::F64(v) => push_json_real(out, v),
301        }
302    }
303    out.push(']');
304}
305
306fn push_json_real(out: &mut String, f: f64) {
307    if f == 0.0 && f.is_sign_negative() {
308        out.push_str("-0.0");
309    } else if f.fract() == 0.0 {
310        let _ = write!(out, "{f:.1}");
311    } else {
312        push_json_f64(out, f);
313    }
314}
315
316fn push_json_f64(out: &mut String, f: f64) {
317    let _ = write!(out, "{f}");
318}
319
320fn json_quote(text: &str) -> String {
321    let mut out = String::new();
322    push_json_string(&mut out, text);
323    out
324}
325
326fn sql_quote(text: &str) -> String {
327    format!("'{}'", text.replace('\'', "''"))
328}
329
330fn push_json_string(out: &mut String, text: &str) {
331    out.push('"');
332    for ch in text.chars() {
333        match ch {
334            '"' => out.push_str("\\\""),
335            '\\' => out.push_str("\\\\"),
336            '\u{08}' => out.push_str("\\b"),
337            '\u{0C}' => out.push_str("\\f"),
338            '\n' => out.push_str("\\n"),
339            '\r' => out.push_str("\\r"),
340            '\t' => out.push_str("\\t"),
341            c if c <= '\u{1F}' => {
342                let _ = write!(out, "\\u{:04X}", c as u32);
343            }
344            c => out.push(c),
345        }
346    }
347    out.push('"');
348}
349
350fn hex_string(bytes: &[u8]) -> String {
351    let mut out = String::with_capacity(2 + bytes.len() * 2);
352    out.push_str("0x");
353    for byte in bytes {
354        let _ = write!(out, "{byte:02X}");
355    }
356    out
357}
358
359#[cfg(test)]
360mod tests {
361    use crate::{
362        encode_property_value, DateValue, GeographyValue, GeometryValue, ListValue, LocalTimeValue,
363        PointValue, PropertyValue, StorageValue, StorageValueRef, VectorStorage, VectorType,
364        VectorValue, ZonedTimeValue,
365    };
366
367    use super::{
368        blob_sql_literal, bool_blob_bytes, property_value_to_display_text,
369        property_value_to_json_text, storage_value_sql_literal, storage_value_to_display_text,
370        storage_value_to_json_text, BOOL_FALSE_SQLITE_BLOB_LITERAL, BOOL_TRUE_SQLITE_BLOB_LITERAL,
371    };
372
373    #[test]
374    fn canonical_bool_helpers_match_contract() {
375        assert_eq!(bool_blob_bytes(true), &[crate::tag::BOOL_TRUE]);
376        assert_eq!(bool_blob_bytes(false), &[crate::tag::BOOL_FALSE]);
377        assert_eq!(BOOL_TRUE_SQLITE_BLOB_LITERAL, "X'01'");
378        assert_eq!(BOOL_FALSE_SQLITE_BLOB_LITERAL, "X'00'");
379        assert_eq!(blob_sql_literal(bool_blob_bytes(true)), "X'01'");
380    }
381
382    #[test]
383    fn storage_render_handles_native_scalars() {
384        assert_eq!(
385            storage_value_to_json_text(StorageValueRef::Integer(7)).unwrap(),
386            "7"
387        );
388        assert_eq!(
389            storage_value_to_json_text(StorageValueRef::Real(1.0)).unwrap(),
390            "1.0"
391        );
392        assert_eq!(
393            storage_value_to_json_text(StorageValueRef::Text("Ada")).unwrap(),
394            "\"Ada\""
395        );
396        assert_eq!(
397            storage_value_to_display_text(StorageValueRef::Text("Ada")).unwrap(),
398            "Ada"
399        );
400    }
401
402    #[test]
403    fn storage_render_handles_canonical_blobs() {
404        let list = PropertyValue::List(ListValue::Bool(vec![Some(true), None, Some(false)]));
405        let vector = PropertyValue::Vector(VectorValue {
406            coord_type: VectorType::Float64,
407            values: VectorStorage::F64(vec![1.5, 2.5]),
408        });
409        let date = PropertyValue::Date("2026-04-06".parse::<DateValue>().unwrap());
410        let point = PropertyValue::Point(
411            PointValue::from_geojson_str(r#"{"type":"Point","coordinates":[12.5,55.7]}"#).unwrap(),
412        );
413        let geometry =
414            PropertyValue::Geometry(GeometryValue::from_wkt_str("LINESTRING(0 0, 1 1)").unwrap());
415        let geography = PropertyValue::Geography(
416            GeographyValue::from_wkt_str("POLYGON((0 0, 1 0, 1 1, 0 0))").unwrap(),
417        );
418
419        for (value, expected_fragment) in [
420            (list, "[true,null,false]"),
421            (vector, "[1.5,2.5]"),
422            (date, "\"2026-04-06\""),
423            (point, "\"type\":\"Point\""),
424            (geometry, "\"type\":\"LineString\""),
425            (geography, "\"type\":\"Polygon\""),
426        ] {
427            let storage = encode_property_value(&value).unwrap();
428            let json = storage_value_to_json_text(storage.as_ref()).unwrap();
429            assert!(
430                json.contains(expected_fragment),
431                "json={json:?} expected fragment {expected_fragment:?}"
432            );
433        }
434    }
435
436    #[test]
437    fn storage_render_rejects_untagged_blob_payload() {
438        let err = storage_value_to_json_text(StorageValueRef::Blob(br#"[1,{"name":"Ada"}]"#))
439            .expect_err("untagged blob should be rejected");
440        assert!(
441            err.to_string().contains("unknown tag")
442                || err.to_string().contains("unexpected tag")
443                || err.to_string().contains("unsupported")
444                || err.to_string().contains("invalid"),
445            "unexpected error: {err}"
446        );
447    }
448
449    #[test]
450    fn property_render_handles_values() {
451        let list = PropertyValue::List(ListValue::Bool(vec![Some(true), None, Some(false)]));
452        let point = PropertyValue::Point(
453            PointValue::from_geojson_str(r#"{"type":"Point","coordinates":[12.5,55.7]}"#).unwrap(),
454        );
455
456        let rendered = property_value_to_json_text(&list).unwrap();
457        assert_eq!(rendered, "[true,null,false]");
458
459        let rendered = property_value_to_json_text(&PropertyValue::LocalTime(
460            LocalTimeValue::new(10, 35, 0, 0).unwrap(),
461        ))
462        .unwrap();
463        assert_eq!(rendered, "\"10:35\"");
464
465        let rendered = property_value_to_display_text(&PropertyValue::ZonedTime(
466            "10:35:00-08:00".parse::<ZonedTimeValue>().unwrap(),
467        ))
468        .unwrap();
469        assert_eq!(rendered, "10:35-08:00");
470
471        let rendered = property_value_to_display_text(&point).unwrap();
472        assert!(rendered.contains(r#""type":"Point""#));
473
474        let storage = StorageValue::Text("Ada".into());
475        assert_eq!(
476            storage_value_to_display_text(storage.as_ref()).unwrap(),
477            "Ada"
478        );
479    }
480
481    #[test]
482    fn storage_sql_literal_renders_all_storage_classes() {
483        assert_eq!(storage_value_sql_literal(StorageValueRef::Null), "NULL");
484        assert_eq!(storage_value_sql_literal(StorageValueRef::Integer(7)), "7");
485        assert_eq!(storage_value_sql_literal(StorageValueRef::Real(1.0)), "1.0");
486        assert_eq!(
487            storage_value_sql_literal(StorageValueRef::Text("Ada's")),
488            "'Ada''s'"
489        );
490        assert_eq!(
491            storage_value_sql_literal(StorageValueRef::Blob(&[0x01, 0xAB, 0x00])),
492            "X'01AB00'"
493        );
494    }
495}