Skip to main content

lora_executor/
value.rs

1use lora_analyzer::symbols::VarId;
2use lora_store::{
3    LoraDate, LoraDateTime, LoraDuration, LoraLocalDateTime, LoraLocalTime, LoraPoint, LoraTime,
4    LoraVector, NodeId, PropertyValue, RelationshipId, VectorValues,
5};
6
7/// A materialised path: alternating node/relationship IDs.
8/// nodes.len() == rels.len() + 1
9#[derive(Debug, Clone, PartialEq)]
10pub struct LoraPath {
11    pub nodes: Vec<NodeId>,
12    pub rels: Vec<RelationshipId>,
13}
14use serde::ser::{SerializeMap, SerializeSeq};
15use serde::{Serialize, Serializer};
16use std::collections::BTreeMap;
17
18#[derive(Debug, Clone, PartialEq)]
19pub enum LoraValue {
20    Null,
21    Bool(bool),
22    Int(i64),
23    Float(f64),
24    String(String),
25    List(Vec<LoraValue>),
26    Map(BTreeMap<String, LoraValue>),
27    Node(NodeId),
28    Relationship(RelationshipId),
29    Path(LoraPath),
30    Date(LoraDate),
31    Time(LoraTime),
32    LocalTime(LoraLocalTime),
33    DateTime(LoraDateTime),
34    LocalDateTime(LoraLocalDateTime),
35    Duration(LoraDuration),
36    Point(LoraPoint),
37    Vector(LoraVector),
38}
39
40impl LoraValue {
41    pub fn is_truthy(&self) -> bool {
42        match self {
43            LoraValue::Null => false,
44            LoraValue::Bool(v) => *v,
45            _ => true,
46        }
47    }
48
49    pub fn as_i64(&self) -> Option<i64> {
50        match self {
51            LoraValue::Int(v) => Some(*v),
52            _ => None,
53        }
54    }
55
56    pub fn as_f64(&self) -> Option<f64> {
57        match self {
58            LoraValue::Int(v) => Some(*v as f64),
59            LoraValue::Float(v) => Some(*v),
60            _ => None,
61        }
62    }
63}
64
65impl Serialize for LoraValue {
66    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
67    where
68        S: Serializer,
69    {
70        match self {
71            LoraValue::Null => serializer.serialize_unit(),
72            LoraValue::Bool(v) => serializer.serialize_bool(*v),
73            LoraValue::Int(v) => serializer.serialize_i64(*v),
74            LoraValue::Float(v) => serializer.serialize_f64(*v),
75            LoraValue::String(v) => serializer.serialize_str(v),
76
77            LoraValue::List(values) => {
78                let mut seq = serializer.serialize_seq(Some(values.len()))?;
79                for value in values {
80                    seq.serialize_element(value)?;
81                }
82                seq.end()
83            }
84
85            LoraValue::Map(map) => {
86                let mut ser_map = serializer.serialize_map(Some(map.len()))?;
87                for (k, v) in map {
88                    ser_map.serialize_entry(k, v)?;
89                }
90                ser_map.end()
91            }
92
93            // These should ideally not reach output anymore if executor hydrates first.
94            LoraValue::Node(id) => {
95                let mut ser_map = serializer.serialize_map(Some(2))?;
96                ser_map.serialize_entry("kind", "node")?;
97                ser_map.serialize_entry("id", id)?;
98                ser_map.end()
99            }
100
101            LoraValue::Relationship(id) => {
102                let mut ser_map = serializer.serialize_map(Some(2))?;
103                ser_map.serialize_entry("kind", "relationship")?;
104                ser_map.serialize_entry("id", id)?;
105                ser_map.end()
106            }
107
108            LoraValue::Path(path) => {
109                let mut ser_map = serializer.serialize_map(Some(3))?;
110                ser_map.serialize_entry("kind", "path")?;
111                ser_map.serialize_entry("nodes", &path.nodes)?;
112                ser_map.serialize_entry("rels", &path.rels)?;
113                ser_map.end()
114            }
115
116            LoraValue::Date(d) => serializer.serialize_str(&d.to_string()),
117            LoraValue::Time(t) => serializer.serialize_str(&t.to_string()),
118            LoraValue::LocalTime(t) => serializer.serialize_str(&t.to_string()),
119            LoraValue::DateTime(dt) => serializer.serialize_str(&dt.to_string()),
120            LoraValue::LocalDateTime(dt) => serializer.serialize_str(&dt.to_string()),
121            LoraValue::Duration(dur) => serializer.serialize_str(&dur.to_string()),
122            LoraValue::Point(p) => {
123                let len = if p.z.is_some() { 4 } else { 3 };
124                let mut m = serializer.serialize_map(Some(len))?;
125                m.serialize_entry("srid", &p.srid)?;
126                m.serialize_entry("x", &p.x)?;
127                m.serialize_entry("y", &p.y)?;
128                if let Some(z) = p.z {
129                    m.serialize_entry("z", &z)?;
130                }
131                m.end()
132            }
133            LoraValue::Vector(v) => serialize_vector(serializer, v),
134        }
135    }
136}
137
138fn serialize_vector<S: Serializer>(serializer: S, v: &LoraVector) -> Result<S::Ok, S::Error> {
139    let mut m = serializer.serialize_map(Some(4))?;
140    m.serialize_entry("kind", "vector")?;
141    m.serialize_entry("dimension", &v.dimension)?;
142    m.serialize_entry("coordinateType", v.coordinate_type().as_str())?;
143    // Render values using the narrowest numeric type that fits the
144    // storage so downstream consumers (serde_json in particular) can
145    // surface integers vs. floats without losing information.
146    match &v.values {
147        VectorValues::Float64(values) => m.serialize_entry("values", values)?,
148        VectorValues::Float32(values) => {
149            let widened: Vec<f64> = values.iter().map(|x| *x as f64).collect();
150            m.serialize_entry("values", &widened)?;
151        }
152        VectorValues::Integer64(values) => m.serialize_entry("values", values)?,
153        VectorValues::Integer32(values) => {
154            let widened: Vec<i64> = values.iter().map(|x| *x as i64).collect();
155            m.serialize_entry("values", &widened)?;
156        }
157        VectorValues::Integer16(values) => {
158            let widened: Vec<i64> = values.iter().map(|x| *x as i64).collect();
159            m.serialize_entry("values", &widened)?;
160        }
161        VectorValues::Integer8(values) => {
162            let widened: Vec<i64> = values.iter().map(|x| *x as i64).collect();
163            m.serialize_entry("values", &widened)?;
164        }
165    }
166    m.end()
167}
168
169impl From<PropertyValue> for LoraValue {
170    fn from(value: PropertyValue) -> Self {
171        match value {
172            PropertyValue::Null => LoraValue::Null,
173            PropertyValue::Bool(v) => LoraValue::Bool(v),
174            PropertyValue::Int(v) => LoraValue::Int(v),
175            PropertyValue::Float(v) => LoraValue::Float(v),
176            PropertyValue::String(v) => LoraValue::String(v),
177            PropertyValue::List(values) => {
178                LoraValue::List(values.into_iter().map(LoraValue::from).collect())
179            }
180            PropertyValue::Map(map) => LoraValue::Map(
181                map.into_iter()
182                    .map(|(k, v)| (k, LoraValue::from(v)))
183                    .collect(),
184            ),
185            PropertyValue::Date(d) => LoraValue::Date(d),
186            PropertyValue::Time(t) => LoraValue::Time(t),
187            PropertyValue::LocalTime(t) => LoraValue::LocalTime(t),
188            PropertyValue::DateTime(dt) => LoraValue::DateTime(dt),
189            PropertyValue::LocalDateTime(dt) => LoraValue::LocalDateTime(dt),
190            PropertyValue::Duration(dur) => LoraValue::Duration(dur),
191            PropertyValue::Point(p) => LoraValue::Point(p),
192            PropertyValue::Vector(v) => LoraValue::Vector(v),
193        }
194    }
195}
196
197/// Build a `LoraValue` from a borrowed `PropertyValue` in a single walk. Lets
198/// callers that already hold `&PropertyValue` (property lookups on borrowed
199/// records) skip the `prop.clone().into()` double-traversal.
200impl From<&PropertyValue> for LoraValue {
201    fn from(value: &PropertyValue) -> Self {
202        match value {
203            PropertyValue::Null => LoraValue::Null,
204            PropertyValue::Bool(v) => LoraValue::Bool(*v),
205            PropertyValue::Int(v) => LoraValue::Int(*v),
206            PropertyValue::Float(v) => LoraValue::Float(*v),
207            PropertyValue::String(v) => LoraValue::String(v.clone()),
208            PropertyValue::List(values) => {
209                LoraValue::List(values.iter().map(LoraValue::from).collect())
210            }
211            PropertyValue::Map(map) => LoraValue::Map(
212                map.iter()
213                    .map(|(k, v)| (k.clone(), LoraValue::from(v)))
214                    .collect(),
215            ),
216            PropertyValue::Date(d) => LoraValue::Date(d.clone()),
217            PropertyValue::Time(t) => LoraValue::Time(t.clone()),
218            PropertyValue::LocalTime(t) => LoraValue::LocalTime(t.clone()),
219            PropertyValue::DateTime(dt) => LoraValue::DateTime(dt.clone()),
220            PropertyValue::LocalDateTime(dt) => LoraValue::LocalDateTime(dt.clone()),
221            PropertyValue::Duration(dur) => LoraValue::Duration(dur.clone()),
222            PropertyValue::Point(p) => LoraValue::Point(p.clone()),
223            PropertyValue::Vector(v) => LoraValue::Vector(v.clone()),
224        }
225    }
226}
227
228impl From<LoraValue> for PropertyValue {
229    fn from(value: LoraValue) -> Self {
230        match value {
231            LoraValue::Null => PropertyValue::Null,
232            LoraValue::Bool(v) => PropertyValue::Bool(v),
233            LoraValue::Int(v) => PropertyValue::Int(v),
234            LoraValue::Float(v) => PropertyValue::Float(v),
235            LoraValue::String(v) => PropertyValue::String(v),
236            LoraValue::List(values) => {
237                PropertyValue::List(values.into_iter().map(PropertyValue::from).collect())
238            }
239            LoraValue::Map(map) => PropertyValue::Map(
240                map.into_iter()
241                    .map(|(k, v)| (k, PropertyValue::from(v)))
242                    .collect(),
243            ),
244            LoraValue::Node(id) => PropertyValue::String(format!("node:{id}")),
245            LoraValue::Relationship(id) => PropertyValue::String(format!("rel:{id}")),
246            LoraValue::Path(_) => PropertyValue::Null,
247            LoraValue::Date(d) => PropertyValue::Date(d),
248            LoraValue::Time(t) => PropertyValue::Time(t),
249            LoraValue::LocalTime(t) => PropertyValue::LocalTime(t),
250            LoraValue::DateTime(dt) => PropertyValue::DateTime(dt),
251            LoraValue::LocalDateTime(dt) => PropertyValue::LocalDateTime(dt),
252            LoraValue::Duration(dur) => PropertyValue::Duration(dur),
253            LoraValue::Point(p) => PropertyValue::Point(p),
254            LoraValue::Vector(v) => PropertyValue::Vector(v),
255        }
256    }
257}
258
259/// Errors that can arise when converting a `LoraValue` into a
260/// `PropertyValue` for storage on a node or relationship.
261#[derive(Debug, Clone, PartialEq)]
262pub enum PropertyConversionError {
263    /// A list entry contained a VECTOR value. Vectors are first-class
264    /// properties themselves but they cannot be nested inside lists.
265    NestedVectorInList,
266    /// Produced when something that cannot appear on disk (e.g. a `Path`
267    /// value captured by mistake) is asked to be converted — surfaced so
268    /// callers can reject it instead of silently stringifying.
269    #[allow(dead_code)]
270    UnsupportedKind(&'static str),
271}
272
273impl std::fmt::Display for PropertyConversionError {
274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275        match self {
276            PropertyConversionError::NestedVectorInList => {
277                write!(f, "lists stored as properties cannot contain VECTOR values")
278            }
279            PropertyConversionError::UnsupportedKind(kind) => {
280                write!(f, "cannot store {kind} as a property")
281            }
282        }
283    }
284}
285
286impl std::error::Error for PropertyConversionError {}
287
288/// Fallible conversion used on every write path
289/// (`set_property_from_expr`, `overwrite_entity_target`,
290/// `mutate_entity_target`, `eval_properties_expr`, plus CREATE /
291/// MERGE). Rejects VECTOR values nested inside lists at any depth —
292/// everything else falls through to the infallible `From`
293/// implementation above. A top-level VECTOR property is always fine;
294/// only LISTs that directly contain a VECTOR entry are rejected.
295pub fn lora_value_to_property(value: LoraValue) -> Result<PropertyValue, PropertyConversionError> {
296    /// Visit every nested value and, whenever we cross a `List`, flag the
297    /// `Vector` entries it directly contains. We still recurse through
298    /// `Map` and other `List` values so a vector buried under
299    /// `{inner: [vector(...)]}` is caught too.
300    fn visit(value: &LoraValue, inside_list: bool) -> Result<(), PropertyConversionError> {
301        match value {
302            LoraValue::Vector(_) if inside_list => Err(PropertyConversionError::NestedVectorInList),
303            LoraValue::List(items) => {
304                for item in items {
305                    visit(item, true)?;
306                }
307                Ok(())
308            }
309            LoraValue::Map(m) => {
310                for v in m.values() {
311                    visit(v, inside_list)?;
312                }
313                Ok(())
314            }
315            _ => Ok(()),
316        }
317    }
318
319    visit(&value, false)?;
320    Ok(PropertyValue::from(value))
321}
322
323#[derive(Debug, Clone, PartialEq)]
324struct RowEntry {
325    /// `None` means "use the fallback `_{key}` lazily". This avoids allocating
326    /// a String for every anonymous variable on the insert hot path.
327    name: Option<String>,
328    value: LoraValue,
329}
330
331#[derive(Debug, Clone, Default, PartialEq)]
332pub struct Row {
333    values: BTreeMap<VarId, RowEntry>,
334}
335
336impl Serialize for Row {
337    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
338    where
339        S: Serializer,
340    {
341        let mut ser_map = serializer.serialize_map(Some(self.values.len()))?;
342        for (key, entry) in &self.values {
343            match &entry.name {
344                Some(name) => ser_map.serialize_entry(name.as_str(), &entry.value)?,
345                None => {
346                    let fallback = format!("_{key}");
347                    ser_map.serialize_entry(fallback.as_str(), &entry.value)?;
348                }
349            }
350        }
351        ser_map.end()
352    }
353}
354
355impl Row {
356    pub fn new() -> Self {
357        Self {
358            values: BTreeMap::new(),
359        }
360    }
361
362    pub fn get(&self, key: VarId) -> Option<&LoraValue> {
363        self.values.get(&key).map(|entry| &entry.value)
364    }
365
366    /// Returns the column name for `key`, generating the `_{key}` fallback
367    /// on demand for entries inserted without an explicit name.
368    pub fn get_name(&self, key: VarId) -> Option<String> {
369        self.values.get(&key).map(|entry| match &entry.name {
370            Some(n) => n.clone(),
371            None => format!("_{key}"),
372        })
373    }
374
375    pub fn insert(&mut self, key: VarId, value: LoraValue) {
376        // Preserve any previously-set explicit name when overwriting an entry;
377        // otherwise leave name as None so the fallback is produced lazily.
378        use std::collections::btree_map::Entry;
379        match self.values.entry(key) {
380            Entry::Occupied(mut e) => e.get_mut().value = value,
381            Entry::Vacant(e) => {
382                e.insert(RowEntry { name: None, value });
383            }
384        }
385    }
386
387    pub fn insert_named(&mut self, key: VarId, name: impl Into<String>, value: LoraValue) {
388        self.values.insert(
389            key,
390            RowEntry {
391                name: Some(name.into()),
392                value,
393            },
394        );
395    }
396
397    pub fn extend_from(&mut self, other: &Row) {
398        for (k, v) in &other.values {
399            self.values.insert(*k, v.clone());
400        }
401    }
402
403    pub fn iter(&self) -> impl Iterator<Item = (&VarId, &LoraValue)> {
404        self.values.iter().map(|(k, entry)| (k, &entry.value))
405    }
406
407    /// Iterate `(key, name, value)`. The name is a `Cow`: borrowed when an
408    /// explicit name was stored, and owned (lazily formatted as `_{key}`) for
409    /// entries inserted via the anonymous `insert()` path.
410    pub fn iter_named(
411        &self,
412    ) -> impl Iterator<Item = (&VarId, std::borrow::Cow<'_, str>, &LoraValue)> {
413        self.values.iter().map(|(k, entry)| {
414            let name: std::borrow::Cow<'_, str> = match &entry.name {
415                Some(n) => std::borrow::Cow::Borrowed(n.as_str()),
416                None => std::borrow::Cow::Owned(format!("_{k}")),
417            };
418            (k, name, &entry.value)
419        })
420    }
421
422    /// Consume the row and yield owned `(VarId, name, LoraValue)` triples.
423    /// Used by hydrate_row to avoid cloning values on the projection hot path.
424    pub fn into_iter_named(self) -> impl Iterator<Item = (VarId, String, LoraValue)> {
425        self.values.into_iter().map(|(k, entry)| {
426            (
427                k,
428                entry.name.unwrap_or_else(|| format!("_{k}")),
429                entry.value,
430            )
431        })
432    }
433
434    pub fn len(&self) -> usize {
435        self.values.len()
436    }
437
438    pub fn is_empty(&self) -> bool {
439        self.values.is_empty()
440    }
441
442    pub fn contains_key(&self, key: VarId) -> bool {
443        self.values.contains_key(&key)
444    }
445}
446
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448pub enum ResultFormat {
449    Rows,
450    RowArrays,
451    Graph,
452    Combined,
453}
454
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub struct ExecuteOptions {
457    pub format: ResultFormat,
458}
459
460impl Default for ExecuteOptions {
461    fn default() -> Self {
462        Self {
463            format: ResultFormat::Graph,
464        }
465    }
466}
467
468#[derive(Debug, Clone, Serialize)]
469#[serde(untagged)]
470pub enum QueryResult {
471    Rows(RowsResult),
472    RowArrays(RowArraysResult),
473    Graph(GraphResult),
474    Combined(CombinedResult),
475}
476
477#[derive(Debug, Clone, Serialize)]
478pub struct RowsResult {
479    pub rows: Vec<Row>,
480}
481
482#[derive(Debug, Clone, Serialize)]
483pub struct RowArraysResult {
484    pub columns: Vec<String>,
485    pub rows: Vec<Vec<LoraValue>>,
486}
487
488#[derive(Debug, Clone, Serialize)]
489pub struct GraphResult {
490    pub graph: HydratedGraph,
491}
492
493#[derive(Debug, Clone, Serialize)]
494pub struct CombinedResult {
495    pub columns: Vec<String>,
496    pub data: Vec<CombinedRow>,
497    pub graph: HydratedGraph,
498}
499
500#[derive(Debug, Clone, Serialize)]
501pub struct CombinedRow {
502    pub row: Vec<LoraValue>,
503}
504
505#[derive(Debug, Clone, Serialize, Default)]
506pub struct HydratedGraph {
507    pub nodes: Vec<HydratedNode>,
508    pub relationships: Vec<HydratedRelationship>,
509}
510
511#[derive(Debug, Clone, Serialize, PartialEq)]
512pub struct HydratedNode {
513    pub id: i64,
514    pub labels: Vec<String>,
515    pub properties: BTreeMap<String, LoraValue>,
516}
517
518#[derive(Debug, Clone, Serialize, PartialEq)]
519pub struct HydratedRelationship {
520    pub id: i64,
521    #[serde(rename = "startId")]
522    pub start_id: i64,
523    #[serde(rename = "endId")]
524    pub end_id: i64,
525    #[serde(rename = "type")]
526    pub rel_type: String,
527    pub properties: BTreeMap<String, LoraValue>,
528}
529
530pub fn project_rows(rows: Vec<Row>, options: ExecuteOptions) -> QueryResult {
531    match options.format {
532        ResultFormat::Rows => QueryResult::Rows(RowsResult { rows }),
533
534        ResultFormat::RowArrays => {
535            let columns = infer_columns(&rows);
536            let projected_rows = rows.iter().map(|row| row_to_array(row, &columns)).collect();
537
538            QueryResult::RowArrays(RowArraysResult {
539                columns,
540                rows: projected_rows,
541            })
542        }
543
544        ResultFormat::Graph => QueryResult::Graph(GraphResult {
545            graph: collect_hydrated_graph(&rows),
546        }),
547
548        ResultFormat::Combined => {
549            let columns = infer_columns(&rows);
550            let data = rows
551                .iter()
552                .map(|row| CombinedRow {
553                    row: row_to_array(row, &columns),
554                })
555                .collect();
556
557            QueryResult::Combined(CombinedResult {
558                columns,
559                data,
560                graph: collect_hydrated_graph(&rows),
561            })
562        }
563    }
564}
565
566fn infer_columns(rows: &[Row]) -> Vec<String> {
567    rows.first()
568        .map(|row| {
569            row.iter_named()
570                .map(|(_, name, _)| name.into_owned())
571                .collect::<Vec<_>>()
572        })
573        .unwrap_or_default()
574}
575
576fn row_to_array(row: &Row, columns: &[String]) -> Vec<LoraValue> {
577    // Row entry count is small; a linear scan per column avoids allocating
578    // owned names into an intermediate lookup map.
579    columns
580        .iter()
581        .map(|col| {
582            row.iter_named()
583                .find(|(_, name, _)| name.as_ref() == col.as_str())
584                .map(|(_, _, v)| v.clone())
585                .unwrap_or(LoraValue::Null)
586        })
587        .collect()
588}
589
590fn collect_hydrated_graph(rows: &[Row]) -> HydratedGraph {
591    let mut nodes = BTreeMap::<i64, HydratedNode>::new();
592    let mut relationships = BTreeMap::<i64, HydratedRelationship>::new();
593
594    for row in rows {
595        for (_, _, value) in row.iter_named() {
596            collect_graph_from_value(value, &mut nodes, &mut relationships);
597        }
598    }
599
600    HydratedGraph {
601        nodes: nodes.into_values().collect(),
602        relationships: relationships.into_values().collect(),
603    }
604}
605
606fn collect_graph_from_value(
607    value: &LoraValue,
608    nodes: &mut BTreeMap<i64, HydratedNode>,
609    relationships: &mut BTreeMap<i64, HydratedRelationship>,
610) {
611    match value {
612        LoraValue::List(values) => {
613            for value in values {
614                collect_graph_from_value(value, nodes, relationships);
615            }
616        }
617
618        LoraValue::Map(map) => {
619            if let Some(node) = try_as_hydrated_node(map) {
620                nodes.entry(node.id).or_insert(node);
621                return;
622            }
623
624            if let Some(rel) = try_as_hydrated_relationship(map) {
625                relationships.entry(rel.id).or_insert(rel);
626                return;
627            }
628
629            for value in map.values() {
630                collect_graph_from_value(value, nodes, relationships);
631            }
632        }
633
634        _ => {}
635    }
636}
637
638fn try_as_hydrated_node(map: &BTreeMap<String, LoraValue>) -> Option<HydratedNode> {
639    let id = match map.get("id")? {
640        LoraValue::Int(v) => *v,
641        _ => return None,
642    };
643
644    let labels = match map.get("labels")? {
645        LoraValue::List(values) => values
646            .iter()
647            .map(|v| match v {
648                LoraValue::String(s) => Some(s.clone()),
649                _ => None,
650            })
651            .collect::<Option<Vec<_>>>()?,
652        _ => return None,
653    };
654
655    let properties = match map.get("properties")? {
656        LoraValue::Map(props) => props.clone(),
657        _ => return None,
658    };
659
660    Some(HydratedNode {
661        id,
662        labels,
663        properties,
664    })
665}
666
667fn try_as_hydrated_relationship(map: &BTreeMap<String, LoraValue>) -> Option<HydratedRelationship> {
668    match map.get("kind") {
669        Some(LoraValue::String(kind)) if kind == "relationship" => {}
670        _ => return None,
671    }
672
673    let id = match map.get("id")? {
674        LoraValue::Int(v) => *v,
675        _ => return None,
676    };
677
678    let start_id = match map.get("startId").or_else(|| map.get("src"))? {
679        LoraValue::Int(v) => *v,
680        _ => return None,
681    };
682
683    let end_id = match map.get("endId").or_else(|| map.get("dst"))? {
684        LoraValue::Int(v) => *v,
685        _ => return None,
686    };
687
688    let rel_type = match map.get("type")? {
689        LoraValue::String(s) => s.clone(),
690        _ => return None,
691    };
692
693    let properties = match map.get("properties")? {
694        LoraValue::Map(props) => props.clone(),
695        _ => return None,
696    };
697
698    Some(HydratedRelationship {
699        id,
700        start_id,
701        end_id,
702        rel_type,
703        properties,
704    })
705}