Skip to main content

uni_query/query/executor/
result_normalizer.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! Result normalization - converts internal representations to user-facing types.
5//!
6//! Enforces type system invariants:
7//! - All nodes must be Value::Node (not Value::Map with _vid/_labels)
8//! - All edges must be Value::Edge (not Value::Map with _eid/_type)
9//! - All paths must be Value::Path
10//! - No internal fields exposed in user-facing results
11
12use crate::types::{Edge, Node, Path, Value};
13use anyhow::{Result, anyhow};
14use std::collections::HashMap;
15use uni_common::core::id::{Eid, Vid};
16
17pub struct ResultNormalizer;
18
19impl ResultNormalizer {
20    /// Normalize a complete row of results.
21    pub fn normalize_row(row: HashMap<String, Value>) -> Result<HashMap<String, Value>> {
22        row.into_iter()
23            .map(|(k, v)| Ok((k, Self::normalize_value(v)?)))
24            .collect()
25    }
26
27    /// Recursively normalize a single value.
28    pub fn normalize_value(value: Value) -> Result<Value> {
29        match value {
30            Value::List(items) => {
31                let normalized: Result<Vec<_>> =
32                    items.into_iter().map(Self::normalize_value).collect();
33                Ok(Value::List(normalized?))
34            }
35
36            Value::Map(map) => {
37                // Check if this map represents a path, node, or edge (order matters: path first)
38                if Self::is_path_map(&map) {
39                    Self::map_to_path(map)
40                } else if Self::is_node_map(&map) {
41                    Self::map_to_node(map)
42                } else if Self::is_edge_map(&map) {
43                    Self::map_to_edge(map)
44                } else {
45                    let normalized: Result<HashMap<_, _>> = map
46                        .into_iter()
47                        .map(|(k, v)| Ok((k, Self::normalize_value(v)?)))
48                        .collect();
49                    Ok(Value::Map(normalized?))
50                }
51            }
52
53            // Already proper graph types or primitives - pass through unchanged
54            _ => Ok(value),
55        }
56    }
57
58    /// Normalize a property value without structural conversion.
59    ///
60    /// Recursively processes nested lists and maps but does NOT convert maps to
61    /// Node/Edge/Path structures. This prevents user data containing keys like
62    /// `_vid` or `_eid` from being incorrectly converted.
63    fn normalize_property_value(value: Value) -> Value {
64        match value {
65            Value::List(items) => Value::List(
66                items
67                    .into_iter()
68                    .map(Self::normalize_property_value)
69                    .collect(),
70            ),
71            Value::Map(map) => Value::Map(
72                map.into_iter()
73                    .map(|(k, v)| (k, Self::normalize_property_value(v)))
74                    .collect(),
75            ),
76            other => other,
77        }
78    }
79
80    /// Check if map represents a node.
81    ///
82    /// Detection is intentionally lenient for top-level result values. Property values
83    /// inside nodes/edges use `normalize_property_value` instead, which skips this check.
84    fn is_node_map(map: &HashMap<String, Value>) -> bool {
85        map.contains_key("_vid") || (map.contains_key("_id") && map.contains_key("label"))
86    }
87
88    /// Check if map represents an edge.
89    ///
90    /// Detection is intentionally lenient for top-level result values. Property values
91    /// inside nodes/edges use `normalize_property_value` instead, which skips this check.
92    fn is_edge_map(map: &HashMap<String, Value>) -> bool {
93        map.contains_key("_eid")
94            || (map.contains_key("_id") && map.contains_key("_src") && map.contains_key("_dst"))
95    }
96
97    /// Check if map represents a path (has "nodes" and "relationships" or "edges").
98    fn is_path_map(map: &HashMap<String, Value>) -> bool {
99        map.contains_key("nodes")
100            && (map.contains_key("relationships") || map.contains_key("edges"))
101    }
102
103    /// Extract a u64 ID from a Value (Int or parseable String).
104    fn value_to_u64(value: &Value) -> Option<u64> {
105        match value {
106            Value::Int(i) => u64::try_from(*i).ok(),
107            Value::String(s) => s.parse().ok(),
108            _ => None,
109        }
110    }
111
112    /// Extract a string from a Value.
113    fn value_to_string(value: &Value) -> Option<String> {
114        if let Value::String(s) = value {
115            Some(s.clone())
116        } else {
117            None
118        }
119    }
120
121    /// Returns `true` if the key is a user-facing property (not an internal or reserved field).
122    fn is_user_property(key: &str) -> bool {
123        !key.starts_with('_')
124            && key != "properties"
125            && key != "label"
126            && key != "type"
127            && key != "overflow_json"
128    }
129
130    /// Extract properties from a dedicated "properties" field (if present) or from inline fields.
131    ///
132    /// This handles two property storage formats:
133    /// 1. A "properties" field containing LargeBinary (JSON) or a Map
134    /// 2. Inline fields in the map (non-underscore fields)
135    fn extract_properties_from_field_or_inline(
136        map: &HashMap<String, Value>,
137    ) -> HashMap<String, Value> {
138        // First try to extract from a dedicated "properties" field
139        if let Some(props_value) = map.get("properties") {
140            match props_value {
141                // Properties stored as a Map
142                Value::Map(m) => {
143                    return Self::prune_null_properties(
144                        m.iter()
145                            .map(|(k, v)| (k.clone(), Self::normalize_property_value(v.clone())))
146                            .collect(),
147                    );
148                }
149                // Properties stored as Bytes (JSON serialized)
150                Value::Bytes(bytes) => {
151                    if let Ok(props) =
152                        serde_json::from_slice::<HashMap<String, serde_json::Value>>(bytes)
153                    {
154                        return Self::prune_null_properties(
155                            props
156                                .into_iter()
157                                .map(|(k, v)| (k, Self::json_value_to_value(v)))
158                                .collect(),
159                        );
160                    }
161                }
162                _ => {}
163            }
164        }
165
166        // Expand _all_props JSONB blob (used by traverse and schemaless scan paths).
167        // _all_props is decoded from JSONB to Value::Map by arrow_to_value.
168        if let Some(Value::Map(all_props)) = map.get("_all_props") {
169            let mut properties: HashMap<String, Value> = all_props
170                .iter()
171                .map(|(k, v)| (k.clone(), Self::normalize_property_value(v.clone())))
172                .collect();
173            // Merge any inline non-internal properties (schema-defined props loaded as columns)
174            for (k, v) in map.iter() {
175                if Self::is_user_property(k) {
176                    properties
177                        .entry(k.clone())
178                        .or_insert_with(|| Self::normalize_property_value(v.clone()));
179                }
180            }
181            return Self::prune_null_properties(properties);
182        }
183
184        // Fall back to extracting inline properties (excluding internal and reserved fields)
185        Self::prune_null_properties(
186            map.iter()
187                .filter(|(k, _)| Self::is_user_property(k))
188                .map(|(k, v)| (k.clone(), Self::normalize_property_value(v.clone())))
189                .collect(),
190        )
191    }
192
193    /// Remove properties with null values from user-facing entity property maps.
194    fn prune_null_properties(mut properties: HashMap<String, Value>) -> HashMap<String, Value> {
195        properties.retain(|_, v| !v.is_null());
196        properties
197    }
198
199    /// Convert a serde_json::Value to our Value type.
200    fn json_value_to_value(json: serde_json::Value) -> Value {
201        match json {
202            serde_json::Value::Null => Value::Null,
203            serde_json::Value::Bool(b) => Value::Bool(b),
204            serde_json::Value::Number(n) => n
205                .as_i64()
206                .map(Value::Int)
207                .or_else(|| n.as_f64().map(Value::Float))
208                .unwrap_or_else(|| Value::String(n.to_string())),
209            serde_json::Value::String(s) => Value::String(s),
210            serde_json::Value::Array(arr) => {
211                Value::List(arr.into_iter().map(Self::json_value_to_value).collect())
212            }
213            serde_json::Value::Object(obj) => Value::Map(
214                obj.into_iter()
215                    .map(|(k, v)| (k, Self::json_value_to_value(v)))
216                    .collect(),
217            ),
218        }
219    }
220
221    /// Convert map to Node, extracting properties and stripping internal fields.
222    fn map_to_node(map: HashMap<String, Value>) -> Result<Value> {
223        let vid = map
224            .get("_vid")
225            .or_else(|| map.get("_id"))
226            .and_then(Self::value_to_u64)
227            .map(Vid::new)
228            .ok_or_else(|| anyhow!("Missing or invalid _vid in node map"))?;
229
230        let labels = if let Some(Value::List(label_list)) = map.get("_labels") {
231            label_list
232                .iter()
233                .filter_map(|v| {
234                    if let Value::String(s) = v {
235                        Some(s.clone())
236                    } else {
237                        None
238                    }
239                })
240                .collect()
241        } else if let Some(Value::String(s)) = map.get("_labels") {
242            // Single string fallback for backwards compat within same session
243            if s.is_empty() {
244                vec![]
245            } else {
246                vec![s.clone()]
247            }
248        } else {
249            Vec::new()
250        };
251
252        // Try to extract properties from a dedicated "properties" field (LargeBinary/JSON)
253        // If not present or not parseable, fall back to extracting from inline fields
254        let properties = Self::extract_properties_from_field_or_inline(&map);
255
256        Ok(Value::Node(Node {
257            vid,
258            labels,
259            properties,
260        }))
261    }
262
263    /// Convert map to Edge, extracting properties and stripping internal fields.
264    fn map_to_edge(map: HashMap<String, Value>) -> Result<Value> {
265        let eid = map
266            .get("_eid")
267            .or_else(|| map.get("_id"))
268            .and_then(Self::value_to_u64)
269            .map(Eid::new)
270            .ok_or_else(|| anyhow!("Missing or invalid _eid in edge map"))?;
271
272        // Prefer _type_name (string) over _type (numeric ID) for user-facing output
273        let edge_type = ["_type_name", "_type", "type"]
274            .iter()
275            .find_map(|key| map.get(*key).and_then(Self::value_to_string))
276            .filter(|s| !s.is_empty())
277            .unwrap_or_default();
278
279        let src = map
280            .get("_src")
281            .and_then(Self::value_to_u64)
282            .map(Vid::new)
283            .ok_or_else(|| anyhow!("Missing _src in edge map"))?;
284
285        let dst = map
286            .get("_dst")
287            .and_then(Self::value_to_u64)
288            .map(Vid::new)
289            .ok_or_else(|| anyhow!("Missing _dst in edge map"))?;
290
291        // Try to extract properties from a dedicated "properties" field (LargeBinary/JSON)
292        // If not present or not parseable, fall back to extracting from inline fields
293        let properties = Self::extract_properties_from_field_or_inline(&map);
294
295        Ok(Value::Edge(Edge {
296            eid,
297            edge_type,
298            src,
299            dst,
300            properties,
301        }))
302    }
303
304    /// Convert map to Path, handling both "relationships" and "edges" keys.
305    fn map_to_path(mut map: HashMap<String, Value>) -> Result<Value> {
306        let nodes = Self::extract_path_nodes(
307            map.remove("nodes")
308                .ok_or_else(|| anyhow!("Missing nodes in path map"))?,
309        )?;
310
311        let edges = Self::extract_path_edges(
312            map.remove("relationships")
313                .or_else(|| map.remove("edges"))
314                .ok_or_else(|| anyhow!("Missing relationships/edges in path map"))?,
315        )?;
316
317        Ok(Value::Path(Path { nodes, edges }))
318    }
319
320    /// Extract a list of graph entities from a path component.
321    ///
322    /// `extract_native` pulls the entity from its native Value variant (e.g., `Value::Node`).
323    /// `convert_map` converts a Map representation to the entity type.
324    /// `type_name` is used in error messages (e.g., "node", "edge").
325    fn extract_path_elements<T>(
326        value: Value,
327        extract_native: fn(Value) -> Option<T>,
328        convert_map: fn(HashMap<String, Value>) -> Result<Value>,
329        type_name: &str,
330    ) -> Result<Vec<T>> {
331        let Value::List(items) = value else {
332            return Err(anyhow!("Path {} must be a list", type_name));
333        };
334
335        items
336            .into_iter()
337            .map(|item| match item {
338                Value::Map(m) => extract_native(convert_map(m)?)
339                    .ok_or_else(|| anyhow!("Failed to convert map to {} in path", type_name)),
340                other => extract_native(other)
341                    .ok_or_else(|| anyhow!("Invalid {} type in path list", type_name)),
342            })
343            .collect()
344    }
345
346    /// Extract nodes from a path's nodes list.
347    fn extract_path_nodes(value: Value) -> Result<Vec<Node>> {
348        Self::extract_path_elements(
349            value,
350            |v| {
351                if let Value::Node(n) = v {
352                    Some(n)
353                } else {
354                    None
355                }
356            },
357            Self::map_to_node,
358            "nodes",
359        )
360    }
361
362    /// Extract edges from a path's relationships/edges list.
363    fn extract_path_edges(value: Value) -> Result<Vec<Edge>> {
364        Self::extract_path_elements(
365            value,
366            |v| {
367                if let Value::Edge(e) = v {
368                    Some(e)
369                } else {
370                    None
371                }
372            },
373            Self::map_to_edge,
374            "edges",
375        )
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn test_normalize_node_map() {
385        let mut map = HashMap::new();
386        map.insert("_vid".to_string(), Value::Int(123));
387        map.insert(
388            "_labels".to_string(),
389            Value::List(vec![Value::String("Person".to_string())]),
390        );
391        map.insert("name".to_string(), Value::String("Alice".to_string()));
392        map.insert("age".to_string(), Value::Int(30));
393
394        let result = ResultNormalizer::normalize_value(Value::Map(map)).unwrap();
395
396        match result {
397            Value::Node(node) => {
398                assert_eq!(node.vid.as_u64(), 123);
399                assert_eq!(node.labels, vec!["Person".to_string()]);
400                assert_eq!(
401                    node.properties.get("name"),
402                    Some(&Value::String("Alice".to_string()))
403                );
404                assert_eq!(node.properties.get("age"), Some(&Value::Int(30)));
405                // Internal fields should be stripped
406                assert!(!node.properties.contains_key("_vid"));
407                assert!(!node.properties.contains_key("_labels"));
408            }
409            _ => panic!("Expected Node variant"),
410        }
411    }
412
413    #[test]
414    fn test_normalize_edge_map() {
415        let mut map = HashMap::new();
416        map.insert("_eid".to_string(), Value::Int(456));
417        map.insert("_type".to_string(), Value::String("KNOWS".to_string()));
418        map.insert("_src".to_string(), Value::Int(123));
419        map.insert("_dst".to_string(), Value::Int(789));
420        map.insert("since".to_string(), Value::Int(2020));
421
422        let result = ResultNormalizer::normalize_value(Value::Map(map)).unwrap();
423
424        match result {
425            Value::Edge(edge) => {
426                assert_eq!(edge.eid.as_u64(), 456);
427                assert_eq!(edge.edge_type, "KNOWS");
428                assert_eq!(edge.src.as_u64(), 123);
429                assert_eq!(edge.dst.as_u64(), 789);
430                assert_eq!(edge.properties.get("since"), Some(&Value::Int(2020)));
431                // Internal fields should be stripped
432                assert!(!edge.properties.contains_key("_eid"));
433                assert!(!edge.properties.contains_key("_type"));
434            }
435            _ => panic!("Expected Edge variant"),
436        }
437    }
438
439    #[test]
440    fn test_normalize_nested_structures() {
441        let mut inner_map = HashMap::new();
442        inner_map.insert("_vid".to_string(), Value::Int(100));
443        inner_map.insert(
444            "_labels".to_string(),
445            Value::List(vec![Value::String("Node".to_string())]),
446        );
447
448        let list = vec![Value::Map(inner_map.clone()), Value::Int(42)];
449
450        let result = ResultNormalizer::normalize_value(Value::List(list)).unwrap();
451
452        match result {
453            Value::List(items) => {
454                assert_eq!(items.len(), 2);
455                assert!(matches!(items[0], Value::Node(_)));
456                assert_eq!(items[1], Value::Int(42));
457            }
458            _ => panic!("Expected List variant"),
459        }
460    }
461
462    #[test]
463    fn test_normalize_regular_map() {
464        let mut map = HashMap::new();
465        map.insert("key1".to_string(), Value::String("value1".to_string()));
466        map.insert("key2".to_string(), Value::Int(42));
467
468        let result = ResultNormalizer::normalize_value(Value::Map(map)).unwrap();
469
470        match result {
471            Value::Map(m) => {
472                assert_eq!(m.get("key1"), Some(&Value::String("value1".to_string())));
473                assert_eq!(m.get("key2"), Some(&Value::Int(42)));
474            }
475            _ => panic!("Expected Map variant for regular map"),
476        }
477    }
478
479    #[test]
480    fn test_normalize_row() {
481        let mut node_map = HashMap::new();
482        node_map.insert("_vid".to_string(), Value::Int(123));
483        node_map.insert(
484            "_labels".to_string(),
485            Value::List(vec![Value::String("Person".to_string())]),
486        );
487        node_map.insert("name".to_string(), Value::String("Alice".to_string()));
488
489        let mut row = HashMap::new();
490        row.insert("n".to_string(), Value::Map(node_map));
491        row.insert("count".to_string(), Value::Int(5));
492
493        let result = ResultNormalizer::normalize_row(row).unwrap();
494
495        assert!(matches!(result.get("n"), Some(Value::Node(_))));
496        assert_eq!(result.get("count"), Some(&Value::Int(5)));
497    }
498
499    #[test]
500    fn test_map_with_vid_at_top_level_becomes_node() {
501        // At top level, a map with _vid is detected as a node
502        // (even without _labels - labels defaults to empty vec)
503        let mut map = HashMap::new();
504        map.insert("_vid".to_string(), Value::Int(123));
505        map.insert("name".to_string(), Value::String("test".to_string()));
506
507        let result = ResultNormalizer::normalize_value(Value::Map(map)).unwrap();
508
509        match result {
510            Value::Node(node) => {
511                assert_eq!(node.vid.as_u64(), 123);
512                assert!(node.labels.is_empty()); // Default empty labels
513                assert_eq!(
514                    node.properties.get("name"),
515                    Some(&Value::String("test".to_string()))
516                );
517            }
518            _ => panic!("Expected Node variant, got {:?}", result),
519        }
520    }
521
522    #[test]
523    fn test_normalize_node_with_nested_map_containing_vid_key() {
524        // Regression test: user property containing _vid key should NOT be
525        // converted to a Node
526        let mut nested = HashMap::new();
527        nested.insert("_vid".to_string(), Value::String("user-data".to_string()));
528        nested.insert("other".to_string(), Value::Int(42));
529
530        let mut node_map = HashMap::new();
531        node_map.insert("_vid".to_string(), Value::Int(123));
532        node_map.insert(
533            "_labels".to_string(),
534            Value::List(vec![Value::String("Person".to_string())]),
535        );
536        node_map.insert("metadata".to_string(), Value::Map(nested));
537
538        let result = ResultNormalizer::normalize_value(Value::Map(node_map)).unwrap();
539
540        match result {
541            Value::Node(node) => {
542                assert_eq!(node.vid.as_u64(), 123);
543                assert_eq!(node.labels, vec!["Person".to_string()]);
544                // The nested map should remain a Map, NOT become a Node
545                match node.properties.get("metadata") {
546                    Some(Value::Map(m)) => {
547                        assert_eq!(m.get("_vid"), Some(&Value::String("user-data".to_string())));
548                        assert_eq!(m.get("other"), Some(&Value::Int(42)));
549                    }
550                    other => panic!("Expected metadata to be Map, got {:?}", other),
551                }
552            }
553            _ => panic!("Expected Node variant"),
554        }
555    }
556
557    #[test]
558    fn test_normalize_edge_with_nested_map_containing_eid_key() {
559        // Regression test: user property containing _eid key should NOT be
560        // converted to an Edge
561        let mut nested = HashMap::new();
562        nested.insert("_eid".to_string(), Value::String("ref-123".to_string()));
563
564        let mut edge_map = HashMap::new();
565        edge_map.insert("_eid".to_string(), Value::Int(456));
566        edge_map.insert("_type".to_string(), Value::String("KNOWS".to_string()));
567        edge_map.insert("_src".to_string(), Value::Int(123));
568        edge_map.insert("_dst".to_string(), Value::Int(789));
569        edge_map.insert("reference".to_string(), Value::Map(nested));
570
571        let result = ResultNormalizer::normalize_value(Value::Map(edge_map)).unwrap();
572
573        match result {
574            Value::Edge(edge) => {
575                assert_eq!(edge.eid.as_u64(), 456);
576                // The nested map should remain a Map, NOT become an Edge
577                match edge.properties.get("reference") {
578                    Some(Value::Map(m)) => {
579                        assert_eq!(m.get("_eid"), Some(&Value::String("ref-123".to_string())));
580                    }
581                    other => panic!("Expected reference to be Map, got {:?}", other),
582                }
583            }
584            _ => panic!("Expected Edge variant"),
585        }
586    }
587
588    #[test]
589    fn test_normalize_node_prunes_null_properties() {
590        let mut map = HashMap::new();
591        map.insert("_vid".to_string(), Value::Int(1));
592        map.insert(
593            "_labels".to_string(),
594            Value::List(vec![Value::String("Person".to_string())]),
595        );
596        map.insert("name".to_string(), Value::String("Alice".to_string()));
597        map.insert("age".to_string(), Value::Null);
598
599        let result = ResultNormalizer::normalize_value(Value::Map(map)).unwrap();
600        let Value::Node(node) = result else {
601            panic!("Expected Node variant");
602        };
603
604        assert_eq!(
605            node.properties.get("name"),
606            Some(&Value::String("Alice".to_string()))
607        );
608        assert!(!node.properties.contains_key("age"));
609    }
610
611    #[test]
612    fn test_normalize_edge_prunes_null_properties_from_all_props_and_inline() {
613        let mut all_props = HashMap::new();
614        all_props.insert("since".to_string(), Value::Null);
615        all_props.insert("weight".to_string(), Value::Int(7));
616
617        let mut edge_map = HashMap::new();
618        edge_map.insert("_eid".to_string(), Value::Int(10));
619        edge_map.insert("_type".to_string(), Value::String("REL".to_string()));
620        edge_map.insert("_src".to_string(), Value::Int(1));
621        edge_map.insert("_dst".to_string(), Value::Int(2));
622        edge_map.insert("_all_props".to_string(), Value::Map(all_props));
623        edge_map.insert("name".to_string(), Value::Null);
624
625        let result = ResultNormalizer::normalize_value(Value::Map(edge_map)).unwrap();
626        let Value::Edge(edge) = result else {
627            panic!("Expected Edge variant");
628        };
629
630        assert_eq!(edge.properties.get("weight"), Some(&Value::Int(7)));
631        assert!(!edge.properties.contains_key("since"));
632        assert!(!edge.properties.contains_key("name"));
633    }
634}