Skip to main content

lora_executor/
value.rs

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