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}