Skip to main content

velr_types/codec/
property.rs

1use crate::error::{DecodeError, EncodeError};
2use crate::list::ListValue;
3use crate::property::{PropertyValue, PropertyValueRef};
4use crate::spatial::{GeographyValue, GeometryValue, PointValue};
5use crate::storage::{StorageValue, StorageValueRef};
6use crate::tag;
7use crate::temporal::{LocalDateTimeValue, LocalTimeValue, ZonedDateTimeValue, ZonedTimeValue};
8use crate::vector::VectorValue;
9use std::str::FromStr;
10
11pub fn encode_property_value(value: &PropertyValue) -> Result<StorageValue, EncodeError> {
12    match value {
13        PropertyValue::Null => Ok(StorageValue::Null),
14        PropertyValue::Bool(false) => Ok(StorageValue::Blob(vec![tag::BOOL_FALSE])),
15        PropertyValue::Bool(true) => Ok(StorageValue::Blob(vec![tag::BOOL_TRUE])),
16        PropertyValue::Integer(v) => Ok(StorageValue::Integer(*v)),
17        PropertyValue::Float(v) if v.is_finite() => Ok(StorageValue::Real(*v)),
18        PropertyValue::Float(_) => Err(EncodeError::NonCanonicalValue(
19            "non-finite floats are not canonical",
20        )),
21        PropertyValue::String(v) => Ok(StorageValue::Text(v.clone())),
22        PropertyValue::Date(v) => encode_text_blob(tag::DATE, &v.to_string()),
23        PropertyValue::LocalTime(v) => encode_text_blob(tag::LOCAL_TIME, &local_time_text(v)),
24        PropertyValue::ZonedTime(v) => encode_text_blob(tag::ZONED_TIME, &zoned_time_text(v)),
25        PropertyValue::LocalDateTime(v) => {
26            encode_text_blob(tag::LOCAL_DATETIME, &local_datetime_text(v))
27        }
28        PropertyValue::ZonedDateTime(v) => {
29            encode_text_blob(tag::ZONED_DATETIME, &zoned_datetime_text(v))
30        }
31        PropertyValue::Duration(v) => encode_text_blob(tag::DURATION, &v.to_string()),
32        PropertyValue::Point(v) => Ok(StorageValue::Blob(v.encode_blob()?)),
33        PropertyValue::Geometry(v) => Ok(StorageValue::Blob(v.encode_blob()?)),
34        PropertyValue::Geography(v) => Ok(StorageValue::Blob(v.encode_blob()?)),
35        PropertyValue::List(v) => Ok(StorageValue::Blob(v.encode_blob()?)),
36        PropertyValue::Vector(v) => Ok(StorageValue::Blob(v.encode_blob()?)),
37        PropertyValue::Bytes(v) => {
38            let mut out = Vec::with_capacity(v.len() + 1);
39            out.push(tag::BINARY);
40            out.extend_from_slice(v);
41            Ok(StorageValue::Blob(out))
42        }
43    }
44}
45
46pub fn decode_property_value(value: StorageValueRef<'_>) -> Result<PropertyValue, DecodeError> {
47    match value {
48        StorageValueRef::Null => Ok(PropertyValue::Null),
49        StorageValueRef::Integer(v) => Ok(PropertyValue::Integer(v)),
50        StorageValueRef::Real(v) => Ok(PropertyValue::Float(v)),
51        StorageValueRef::Text(v) => Ok(PropertyValue::String(v.to_string())),
52        StorageValueRef::Blob(blob) => decode_property_blob(blob),
53    }
54}
55
56fn encode_text_blob(tag: u8, value: &str) -> Result<StorageValue, EncodeError> {
57    let mut out = Vec::with_capacity(1 + value.len());
58    out.push(tag);
59    out.extend_from_slice(value.as_bytes());
60    Ok(StorageValue::Blob(out))
61}
62
63pub(crate) fn temporal_value_text(value: PropertyValueRef<'_>) -> Option<String> {
64    match value {
65        PropertyValueRef::Date(v) => Some(v.to_string()),
66        PropertyValueRef::LocalTime(v) => Some(local_time_text(v)),
67        PropertyValueRef::ZonedTime(v) => Some(zoned_time_text(v)),
68        PropertyValueRef::LocalDateTime(v) => Some(local_datetime_text(v)),
69        PropertyValueRef::ZonedDateTime(v) => Some(zoned_datetime_text(v)),
70        PropertyValueRef::Duration(v) => Some(v.to_string()),
71        _ => None,
72    }
73}
74
75fn local_time_text(value: &LocalTimeValue) -> String {
76    let mut out = format!("{:02}:{:02}", value.hour(), value.minute());
77    if value.second() != 0 || value.nanos() != 0 {
78        out.push_str(&format!(":{:02}", value.second()));
79        push_fraction(&mut out, value.nanos());
80    }
81    out
82}
83
84fn zoned_time_text(value: &ZonedTimeValue) -> String {
85    format!("{}{}", local_time_text(&value.time), value.offset)
86}
87
88fn local_datetime_text(value: &LocalDateTimeValue) -> String {
89    format!("{}T{}", value.date(), local_time_text(&value.time()))
90}
91
92fn zoned_datetime_text(value: &ZonedDateTimeValue) -> String {
93    let mut out = format!("{}{}", local_datetime_text(&value.datetime), value.offset);
94    if let Some(zone_id) = &value.zone_id {
95        out.push('[');
96        out.push_str(zone_id);
97        out.push(']');
98    }
99    out
100}
101
102fn push_fraction(out: &mut String, nanos: u32) {
103    if nanos == 0 {
104        return;
105    }
106
107    let mut frac = format!("{nanos:09}");
108    while frac.ends_with('0') {
109        frac.pop();
110    }
111    out.push('.');
112    out.push_str(&frac);
113}
114
115fn decode_property_blob(blob: &[u8]) -> Result<PropertyValue, DecodeError> {
116    let Some(tag) = blob.first().copied() else {
117        return Err(DecodeError::EmptyInput);
118    };
119    match tag {
120        tag::BOOL_FALSE => Ok(PropertyValue::Bool(false)),
121        tag::BOOL_TRUE => Ok(PropertyValue::Bool(true)),
122        tag::BINARY => Ok(PropertyValue::Bytes(blob[1..].to_vec())),
123        tag::DATE => Ok(PropertyValue::Date(parse_temporal(&blob[1..])?)),
124        tag::LOCAL_TIME => Ok(PropertyValue::LocalTime(parse_temporal(&blob[1..])?)),
125        tag::ZONED_TIME => Ok(PropertyValue::ZonedTime(parse_temporal(&blob[1..])?)),
126        tag::LOCAL_DATETIME => Ok(PropertyValue::LocalDateTime(parse_temporal(&blob[1..])?)),
127        tag::ZONED_DATETIME => Ok(PropertyValue::ZonedDateTime(parse_temporal(&blob[1..])?)),
128        tag::DURATION => Ok(PropertyValue::Duration(parse_temporal(&blob[1..])?)),
129        tag::POINT => Ok(PropertyValue::Point(PointValue::decode_blob(blob)?)),
130        tag::GEOMETRY => Ok(PropertyValue::Geometry(GeometryValue::decode_blob(blob)?)),
131        tag::GEOGRAPHY => Ok(PropertyValue::Geography(GeographyValue::decode_blob(blob)?)),
132        tag::LIST_JSON
133        | tag::STRING_LIST_JSON
134        | tag::BOOL_LIST
135        | tag::INT64_LIST
136        | tag::FLOAT64_LIST => Ok(PropertyValue::List(ListValue::decode_blob(blob)?)),
137        tag::VECTOR => Ok(PropertyValue::Vector(VectorValue::decode_blob(blob)?)),
138        actual => Err(DecodeError::UnexpectedTag {
139            expected: "property tag",
140            actual,
141        }),
142    }
143}
144
145fn parse_temporal<T>(payload: &[u8]) -> Result<T, DecodeError>
146where
147    T: FromStr<Err = DecodeError>,
148{
149    let text = std::str::from_utf8(payload).map_err(|_| DecodeError::InvalidUtf8)?;
150    text.parse()
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::list::ListValue;
157    use crate::spatial::{
158        GeographyValue, GeometryShape, GeometryValue, LineStringValue, LinearRingValue, PointValue,
159        PolygonValue, Position,
160    };
161    use crate::storage::StorageValue;
162    use crate::temporal::{
163        DateValue, DurationValue, LocalDateTimeValue, LocalTimeValue, ZonedDateTimeValue,
164        ZonedTimeValue,
165    };
166    use crate::vector::{VectorStorage, VectorType, VectorValue};
167
168    fn assert_roundtrip(value: PropertyValue) {
169        let decoded =
170            decode_property_value(encode_property_value(&value).unwrap().as_ref()).unwrap();
171        assert_eq!(decoded, value);
172    }
173
174    fn sample_xyz_ring() -> LinearRingValue {
175        LinearRingValue::new(vec![
176            Position::Xyz {
177                x: 0.0,
178                y: 0.0,
179                z: 1.0,
180            },
181            Position::Xyz {
182                x: 4.0,
183                y: 0.0,
184                z: 1.0,
185            },
186            Position::Xyz {
187                x: 4.0,
188                y: 4.0,
189                z: 1.0,
190            },
191            Position::Xyz {
192                x: 0.0,
193                y: 0.0,
194                z: 1.0,
195            },
196        ])
197        .unwrap()
198    }
199
200    fn sample_supported_shapes() -> Vec<GeometryShape> {
201        vec![
202            GeometryShape::Point(Position::Xyz {
203                x: 12.5,
204                y: 55.7,
205                z: 9.0,
206            }),
207            GeometryShape::LineString(
208                LineStringValue::new(vec![
209                    Position::Xyz {
210                        x: 12.5,
211                        y: 55.7,
212                        z: 9.0,
213                    },
214                    Position::Xyz {
215                        x: 13.1,
216                        y: 56.0,
217                        z: 10.5,
218                    },
219                ])
220                .unwrap(),
221            ),
222            GeometryShape::Polygon(PolygonValue::new(vec![sample_xyz_ring()]).unwrap()),
223            GeometryShape::MultiPoint(vec![
224                Position::Xyz {
225                    x: 1.0,
226                    y: 2.0,
227                    z: 3.0,
228                },
229                Position::Xyz {
230                    x: 4.0,
231                    y: 5.0,
232                    z: 6.0,
233                },
234            ]),
235            GeometryShape::MultiLineString(vec![
236                LineStringValue::new(vec![
237                    Position::Xyz {
238                        x: 0.0,
239                        y: 0.0,
240                        z: 1.0,
241                    },
242                    Position::Xyz {
243                        x: 1.0,
244                        y: 1.0,
245                        z: 1.0,
246                    },
247                ])
248                .unwrap(),
249                LineStringValue::new(vec![
250                    Position::Xyz {
251                        x: 2.0,
252                        y: 2.0,
253                        z: 2.0,
254                    },
255                    Position::Xyz {
256                        x: 3.0,
257                        y: 3.0,
258                        z: 2.0,
259                    },
260                ])
261                .unwrap(),
262            ]),
263            GeometryShape::MultiPolygon(vec![
264                PolygonValue::new(vec![sample_xyz_ring()]).unwrap(),
265                PolygonValue::new(vec![sample_xyz_ring()]).unwrap(),
266            ]),
267            GeometryShape::GeometryCollection(vec![
268                GeometryShape::Point(Position::Xyz {
269                    x: 12.5,
270                    y: 55.7,
271                    z: 9.0,
272                }),
273                GeometryShape::LineString(
274                    LineStringValue::new(vec![
275                        Position::Xyz {
276                            x: 12.5,
277                            y: 55.7,
278                            z: 9.0,
279                        },
280                        Position::Xyz {
281                            x: 13.1,
282                            y: 56.0,
283                            z: 10.5,
284                        },
285                    ])
286                    .unwrap(),
287                ),
288            ]),
289        ]
290    }
291
292    #[test]
293    fn roundtrip_null() {
294        let storage = encode_property_value(&PropertyValue::Null).unwrap();
295        assert_eq!(storage, StorageValue::Null);
296        let decoded = decode_property_value(storage.as_ref()).unwrap();
297        assert_eq!(decoded, PropertyValue::Null);
298    }
299
300    #[test]
301    fn roundtrip_bool() {
302        let storage = encode_property_value(&PropertyValue::Bool(true)).unwrap();
303        assert_eq!(storage, StorageValue::Blob(vec![tag::BOOL_TRUE]));
304        let decoded = decode_property_value(storage.as_ref()).unwrap();
305        assert_eq!(decoded, PropertyValue::Bool(true));
306    }
307
308    #[test]
309    fn roundtrip_integer() {
310        let storage = encode_property_value(&PropertyValue::Integer(42)).unwrap();
311        assert_eq!(storage, StorageValue::Integer(42));
312        let decoded = decode_property_value(storage.as_ref()).unwrap();
313        assert_eq!(decoded, PropertyValue::Integer(42));
314    }
315
316    #[test]
317    fn roundtrip_float() {
318        let storage = encode_property_value(&PropertyValue::Float(42.5)).unwrap();
319        assert_eq!(storage, StorageValue::Real(42.5));
320        let decoded = decode_property_value(storage.as_ref()).unwrap();
321        assert_eq!(decoded, PropertyValue::Float(42.5));
322    }
323
324    #[test]
325    fn roundtrip_string() {
326        let storage = encode_property_value(&PropertyValue::String("hello".into())).unwrap();
327        assert_eq!(storage, StorageValue::Text("hello".into()));
328        let decoded = decode_property_value(storage.as_ref()).unwrap();
329        assert_eq!(decoded, PropertyValue::String("hello".into()));
330    }
331
332    #[test]
333    fn roundtrip_date() {
334        assert_roundtrip(PropertyValue::Date(DateValue::new(2026, 4, 6).unwrap()));
335    }
336
337    #[test]
338    fn roundtrip_local_time_with_fraction() {
339        assert_roundtrip(PropertyValue::LocalTime(
340            LocalTimeValue::new(21, 40, 32, 142_000_000).unwrap(),
341        ));
342    }
343
344    #[test]
345    fn temporal_storage_omits_zero_seconds() {
346        let storage = encode_property_value(&PropertyValue::LocalTime(
347            LocalTimeValue::new(10, 35, 0, 0).unwrap(),
348        ))
349        .unwrap();
350        assert_eq!(storage, StorageValue::Blob(b"\x1110:35".to_vec()));
351
352        let storage = encode_property_value(&PropertyValue::ZonedTime(
353            "10:35:00-08:00".parse::<ZonedTimeValue>().unwrap(),
354        ))
355        .unwrap();
356        assert_eq!(storage, StorageValue::Blob(b"\x1210:35-08:00".to_vec()));
357    }
358
359    #[test]
360    fn roundtrip_zoned_time() {
361        assert_roundtrip(PropertyValue::ZonedTime(
362            "12:00:00+01:00".parse::<ZonedTimeValue>().unwrap(),
363        ));
364    }
365
366    #[test]
367    fn roundtrip_local_datetime() {
368        assert_roundtrip(PropertyValue::LocalDateTime(
369            LocalDateTimeValue::new(2026, 4, 6, 12, 0, 1, 120_000_000).unwrap(),
370        ));
371    }
372
373    #[test]
374    fn roundtrip_zoned_datetime_offset_only() {
375        assert_roundtrip(PropertyValue::ZonedDateTime(
376            "2026-04-06T12:00:00+01:00"
377                .parse::<ZonedDateTimeValue>()
378                .unwrap(),
379        ));
380    }
381
382    #[cfg(any(feature = "tzdb-system", feature = "tzdb-bundled"))]
383    #[test]
384    fn roundtrip_zoned_datetime_named_zone() {
385        assert_roundtrip(PropertyValue::ZonedDateTime(
386            "2026-04-06T12:00:00+01:00[Europe/Lisbon]"
387                .parse::<ZonedDateTimeValue>()
388                .unwrap(),
389        ));
390    }
391
392    #[test]
393    fn roundtrip_duration() {
394        assert_roundtrip(PropertyValue::Duration(
395            "P2M10DT2H30M".parse::<DurationValue>().unwrap(),
396        ));
397    }
398
399    #[test]
400    fn roundtrip_string_list_json() {
401        assert_roundtrip(PropertyValue::List(ListValue::String(vec![
402            Some("Ada".into()),
403            None,
404            Some("Bob".into()),
405        ])));
406    }
407
408    #[test]
409    fn roundtrip_string_list_json_with_escapes_and_unicode() {
410        assert_roundtrip(PropertyValue::List(ListValue::String(vec![
411            Some("Ada \"Lovelace\"".into()),
412            Some("line\\break".into()),
413            Some("Lisboa €".into()),
414            None,
415        ])));
416    }
417
418    #[test]
419    fn roundtrip_bool_list() {
420        assert_roundtrip(PropertyValue::List(ListValue::Bool(vec![
421            Some(true),
422            None,
423            Some(false),
424            Some(true),
425        ])));
426    }
427
428    #[test]
429    fn roundtrip_bool_list_without_nulls() {
430        assert_roundtrip(PropertyValue::List(ListValue::Bool(vec![
431            Some(true),
432            Some(false),
433            Some(true),
434            Some(false),
435            Some(true),
436            Some(true),
437            Some(false),
438            Some(false),
439            Some(true),
440        ])));
441    }
442
443    #[test]
444    fn roundtrip_bool_list_crosses_bitmap_byte_boundary() {
445        assert_roundtrip(PropertyValue::List(ListValue::Bool(vec![
446            None,
447            Some(true),
448            Some(false),
449            Some(true),
450            Some(false),
451            Some(false),
452            Some(true),
453            None,
454            None,
455        ])));
456    }
457
458    #[test]
459    fn roundtrip_empty_bool_list() {
460        assert_roundtrip(PropertyValue::List(ListValue::Bool(vec![])));
461    }
462
463    #[test]
464    fn roundtrip_int_list() {
465        assert_roundtrip(PropertyValue::List(ListValue::Int64(vec![
466            Some(1),
467            None,
468            Some(2),
469            Some(4),
470        ])));
471    }
472
473    #[test]
474    fn roundtrip_int_list_without_nulls() {
475        assert_roundtrip(PropertyValue::List(ListValue::Int64(vec![
476            Some(-9),
477            Some(-1),
478            Some(0),
479            Some(1),
480            Some(2),
481            Some(3),
482            Some(5),
483            Some(8),
484            Some(13),
485        ])));
486    }
487
488    #[test]
489    fn roundtrip_int_list_crosses_bitmap_byte_boundary() {
490        assert_roundtrip(PropertyValue::List(ListValue::Int64(vec![
491            Some(-9),
492            None,
493            Some(0),
494            Some(1),
495            Some(2),
496            Some(3),
497            Some(5),
498            None,
499            Some(13),
500        ])));
501    }
502
503    #[test]
504    fn roundtrip_empty_int_list() {
505        assert_roundtrip(PropertyValue::List(ListValue::Int64(vec![])));
506    }
507
508    #[test]
509    fn roundtrip_float_list() {
510        assert_roundtrip(PropertyValue::List(ListValue::Float64(vec![
511            Some(1.5),
512            None,
513            Some(2.25),
514        ])));
515    }
516
517    #[test]
518    fn roundtrip_float_list_without_nulls() {
519        assert_roundtrip(PropertyValue::List(ListValue::Float64(vec![
520            Some(-9.5),
521            Some(-1.25),
522            Some(0.0),
523            Some(1.0),
524            Some(2.0),
525            Some(3.5),
526            Some(5.75),
527            Some(8.0),
528            Some(13.125),
529        ])));
530    }
531
532    #[test]
533    fn roundtrip_float_list_crosses_bitmap_byte_boundary() {
534        assert_roundtrip(PropertyValue::List(ListValue::Float64(vec![
535            Some(-9.5),
536            None,
537            Some(0.0),
538            Some(1.0),
539            Some(2.25),
540            Some(3.5),
541            Some(5.75),
542            None,
543            Some(13.125),
544        ])));
545    }
546
547    #[test]
548    fn roundtrip_empty_float_list() {
549        assert_roundtrip(PropertyValue::List(ListValue::Float64(vec![])));
550    }
551
552    #[test]
553    fn roundtrip_generic_list() {
554        assert_roundtrip(PropertyValue::List(ListValue::Generic(vec![
555            PropertyValue::Integer(1),
556            PropertyValue::Bool(true),
557            PropertyValue::String("x".into()),
558            PropertyValue::List(ListValue::Generic(vec![PropertyValue::Integer(2)])),
559        ])));
560    }
561
562    #[test]
563    fn roundtrip_generic_list_with_null_and_escaped_strings() {
564        assert_roundtrip(PropertyValue::List(ListValue::Generic(vec![
565            PropertyValue::Null,
566            PropertyValue::String("Ada \"Lovelace\"".into()),
567            PropertyValue::String("slash\\\\path".into()),
568            PropertyValue::String("Lisboa €".into()),
569            PropertyValue::List(ListValue::Generic(vec![
570                PropertyValue::Null,
571                PropertyValue::String("nested".into()),
572            ])),
573        ])));
574    }
575
576    #[test]
577    fn generic_list_rejects_temporal_value() {
578        let value = PropertyValue::List(ListValue::Generic(vec![PropertyValue::Date(
579            DateValue::new(2026, 4, 6).unwrap(),
580        )]));
581        assert!(encode_property_value(&value).is_err());
582    }
583
584    #[test]
585    fn roundtrip_vector() {
586        assert_roundtrip(PropertyValue::Vector(VectorValue {
587            coord_type: VectorType::Float32,
588            values: VectorStorage::F32(vec![1.05, 0.123, 5.0]),
589        }));
590    }
591
592    #[test]
593    fn roundtrip_all_vector_storage_types() {
594        let cases = vec![
595            PropertyValue::Vector(VectorValue {
596                coord_type: VectorType::Integer8,
597                values: VectorStorage::I8(vec![-3, 0, 12]),
598            }),
599            PropertyValue::Vector(VectorValue {
600                coord_type: VectorType::Integer16,
601                values: VectorStorage::I16(vec![-300, 0, 1200]),
602            }),
603            PropertyValue::Vector(VectorValue {
604                coord_type: VectorType::Integer32,
605                values: VectorStorage::I32(vec![-30_000, 0, 120_000]),
606            }),
607            PropertyValue::Vector(VectorValue {
608                coord_type: VectorType::Integer64,
609                values: VectorStorage::I64(vec![-3_000_000, 0, 12_000_000]),
610            }),
611            PropertyValue::Vector(VectorValue {
612                coord_type: VectorType::Float64,
613                values: VectorStorage::F64(vec![1.25, -5.5, 0.0]),
614            }),
615        ];
616        for case in cases {
617            assert_roundtrip(case);
618        }
619    }
620
621    #[test]
622    fn roundtrip_bytes() {
623        assert_roundtrip(PropertyValue::Bytes(vec![0x00, 0x01, 0xfe, 0xff]));
624    }
625
626    #[test]
627    fn roundtrip_point() {
628        assert_roundtrip(PropertyValue::Point(PointValue {
629            srid: 4326,
630            position: Position::Xy { x: 12.5, y: 55.7 },
631        }));
632    }
633
634    #[test]
635    fn roundtrip_point_xyz() {
636        assert_roundtrip(PropertyValue::Point(PointValue {
637            srid: 4326,
638            position: Position::Xyz {
639                x: 12.5,
640                y: 55.7,
641                z: 9.0,
642            },
643        }));
644    }
645
646    #[test]
647    fn roundtrip_geometry_family() {
648        for shape in sample_supported_shapes() {
649            assert_roundtrip(PropertyValue::Geometry(GeometryValue { srid: 4326, shape }));
650        }
651    }
652
653    #[test]
654    fn roundtrip_geography_family() {
655        for shape in sample_supported_shapes() {
656            assert_roundtrip(PropertyValue::Geography(GeographyValue {
657                srid: 4326,
658                shape,
659            }));
660        }
661    }
662}