Skip to main content

alizarin_core/
path_resolution.rs

1/// Path resolution for navigating dot-separated paths through a graph model.
2///
3/// Walks the graph's edge structure (parent→child) matching node aliases at each
4/// segment, then resolves the target node's nodegroup for tile lookup. This avoids
5/// full tree materialization — instead it goes straight from path to nodegroup to tiles.
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use crate::{is_node_single_cardinality, StaticNode, StaticNodegroup};
10
11// =============================================================================
12// Error type
13// =============================================================================
14
15/// Errors that can occur during path resolution
16#[derive(Debug, Clone)]
17pub enum PathError {
18    /// The path string was empty or contained only separators
19    EmptyPath,
20    /// No child of the current node matched the given alias segment
21    AliasNotFound {
22        segment: String,
23        parent_alias: Option<String>,
24    },
25    /// `_` was used on a node that is not a collector (no inner/outer split)
26    UnderscoreOnNonCollector { node_alias: String },
27    /// `*` was used on a single-cardinality node
28    StarOnSingleCardinality { node_alias: String },
29    /// The target node has no nodegroup_id
30    NoNodegroup { node_alias: String },
31    /// Model data (nodes, edges, etc.) was not available
32    ModelNotInitialized(String),
33    /// Tiles storage was not initialized on the wrapper
34    TilesNotInitialized,
35}
36
37impl std::fmt::Display for PathError {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            PathError::EmptyPath => write!(f, "Path is empty"),
41            PathError::AliasNotFound {
42                segment,
43                parent_alias,
44            } => {
45                if let Some(parent) = parent_alias {
46                    write!(
47                        f,
48                        "No child with alias '{}' found under '{}'",
49                        segment, parent
50                    )
51                } else {
52                    write!(f, "No child with alias '{}' found under root node", segment)
53                }
54            }
55            PathError::UnderscoreOnNonCollector { node_alias } => {
56                write!(
57                    f,
58                    "'_' used on node '{}' which is not a collector (no inner/outer split)",
59                    node_alias
60                )
61            }
62            PathError::StarOnSingleCardinality { node_alias } => {
63                write!(
64                    f,
65                    "'*' used on node '{}' which is single-cardinality",
66                    node_alias
67                )
68            }
69            PathError::NoNodegroup { node_alias } => {
70                write!(f, "Node '{}' has no nodegroup_id", node_alias)
71            }
72            PathError::ModelNotInitialized(msg) => {
73                write!(f, "Model not initialized: {}", msg)
74            }
75            PathError::TilesNotInitialized => write!(f, "Tiles not initialized"),
76        }
77    }
78}
79
80impl std::error::Error for PathError {}
81
82impl From<String> for PathError {
83    fn from(s: String) -> Self {
84        PathError::ModelNotInitialized(s)
85    }
86}
87
88// =============================================================================
89// Resolution result
90// =============================================================================
91
92/// The result of resolving a dot-separated path through the graph model.
93///
94/// Contains everything needed to build a PseudoList from the tile store:
95/// the target node, its nodegroup, the child node IDs (for PseudoValueCore),
96/// and cardinality.
97#[derive(Debug, Clone)]
98pub struct PathResolutionInfo {
99    /// The node at the end of the path
100    pub target_node: Arc<StaticNode>,
101    /// The nodegroup the target node belongs to
102    pub nodegroup_id: String,
103    /// The nodegroup of the parent (penultimate) node in the path, if any
104    pub parent_nodegroup_id: Option<String>,
105    /// Child node IDs of the target (from edges)
106    pub child_node_ids: Vec<String>,
107    /// Whether this node is single-cardinality
108    pub is_single: bool,
109}
110
111// =============================================================================
112// Path resolution algorithm
113// =============================================================================
114
115/// Walk the graph model following a dot-separated path of node aliases.
116///
117/// Starting from the root node, each segment of the path is matched against
118/// the aliases of children (via edges). Returns information about the target
119/// node sufficient for tile lookup and PseudoList construction.
120///
121/// # Arguments
122/// * `path` — dot-separated path, e.g. `"building.name"` or `".building.name"`
123/// * `root_node` — the graph's root (top) node
124/// * `nodes` — all nodes indexed by node ID
125/// * `edges` — parent_node_id → child_node_ids
126/// * `nodegroups` — nodegroup lookup (for cardinality checks)
127///
128/// # Errors
129/// Returns `PathError` if the path is empty, a segment doesn't match any child alias,
130/// or the target node has no nodegroup.
131pub fn resolve_path_segments(
132    path: &str,
133    root_node: &Arc<StaticNode>,
134    nodes: &HashMap<String, Arc<StaticNode>>,
135    edges: &HashMap<String, Vec<String>>,
136    nodegroups: Option<&HashMap<String, Arc<StaticNodegroup>>>,
137) -> Result<PathResolutionInfo, PathError> {
138    // Split on '.', filter out empty segments (handles leading '.')
139    let segments: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
140
141    if segments.is_empty() {
142        return Err(PathError::EmptyPath);
143    }
144
145    let mut current_node = Arc::clone(root_node);
146    let mut parent_nodegroup_id: Option<String> = None;
147
148    for segment in &segments {
149        // "_" navigates into the inner part of a collector's inner/outer split.
150        // At graph level this is a no-op, but validate the node is actually a collector.
151        if *segment == "_" {
152            if !current_node.is_collector {
153                return Err(PathError::UnderscoreOnNonCollector {
154                    node_alias: current_node.alias.clone().unwrap_or_default(),
155                });
156            }
157            continue;
158        }
159        // "*" asserts cardinality-N. Validate the node is not single-cardinality.
160        if *segment == "*" {
161            if is_node_single_cardinality(&current_node, nodegroups) {
162                return Err(PathError::StarOnSingleCardinality {
163                    node_alias: current_node.alias.clone().unwrap_or_default(),
164                });
165            }
166            continue;
167        }
168
169        // Get children of the current node
170        let child_ids = edges.get(&current_node.nodeid).cloned().unwrap_or_default();
171
172        // Find the child whose alias matches this segment
173        let matched = child_ids.iter().find_map(|child_id| {
174            nodes.get(child_id).and_then(|child_node| {
175                if child_node.alias.as_deref() == Some(segment) {
176                    Some(Arc::clone(child_node))
177                } else {
178                    None
179                }
180            })
181        });
182
183        parent_nodegroup_id = current_node.nodegroup_id.clone();
184        current_node = matched.ok_or_else(|| PathError::AliasNotFound {
185            segment: segment.to_string(),
186            parent_alias: current_node.alias.clone(),
187        })?;
188    }
189
190    // Resolve nodegroup
191    let nodegroup_id = current_node
192        .nodegroup_id
193        .clone()
194        .ok_or_else(|| PathError::NoNodegroup {
195            node_alias: current_node.alias.clone().unwrap_or_default(),
196        })?;
197
198    // Get child node IDs for PseudoValue construction
199    let child_node_ids = edges.get(&current_node.nodeid).cloned().unwrap_or_default();
200
201    // Determine cardinality
202    let is_single = is_node_single_cardinality(&current_node, nodegroups);
203
204    Ok(PathResolutionInfo {
205        target_node: current_node,
206        nodegroup_id,
207        parent_nodegroup_id,
208        child_node_ids,
209        is_single,
210    })
211}
212
213// =============================================================================
214// Tests
215// =============================================================================
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    /// Helper to create a StaticNode with the minimum required fields
222    fn make_node(
223        nodeid: &str,
224        alias: &str,
225        nodegroup_id: Option<&str>,
226        is_collector: bool,
227        istopnode: bool,
228    ) -> Arc<StaticNode> {
229        Arc::new(StaticNode {
230            nodeid: nodeid.to_string(),
231            name: alias.to_string(),
232            alias: Some(alias.to_string()),
233            datatype: "string".to_string(),
234            is_collector,
235            nodegroup_id: nodegroup_id.map(|s| s.to_string()),
236            graph_id: "test-graph".to_string(),
237            isrequired: false,
238            exportable: true,
239            sortorder: None,
240            config: HashMap::new(),
241            parentproperty: None,
242            ontologyclass: None,
243            description: None,
244            fieldname: None,
245            hascustomalias: false,
246            issearchable: false,
247            istopnode,
248            sourcebranchpublication_id: None,
249            source_identifier_id: None,
250            is_immutable: None,
251        })
252    }
253
254    /// Build a simple graph:
255    ///   root
256    ///   ├── building (ng: ng-building)
257    ///   │   ├── name (ng: ng-building, same nodegroup)
258    ///   │   └── address (ng: ng-address, different nodegroup)
259    ///   │       └── city (ng: ng-address, same nodegroup as address)
260    ///   └── status (ng: ng-status)
261    fn setup_graph() -> (
262        Arc<StaticNode>,
263        HashMap<String, Arc<StaticNode>>,
264        HashMap<String, Vec<String>>,
265        HashMap<String, Arc<StaticNodegroup>>,
266    ) {
267        let root = make_node("root-id", "root", Some("root-id"), false, true);
268        let building = make_node("building-id", "building", Some("ng-building"), false, false);
269        let name = make_node("name-id", "name", Some("ng-building"), false, false);
270        let address = make_node("address-id", "address", Some("ng-address"), true, false);
271        let city = make_node("city-id", "city", Some("ng-address"), false, false);
272        let status = make_node("status-id", "status", Some("ng-status"), false, false);
273
274        let mut nodes = HashMap::new();
275        for n in [&root, &building, &name, &address, &city, &status] {
276            nodes.insert(n.nodeid.clone(), Arc::clone(n));
277        }
278
279        let mut edges: HashMap<String, Vec<String>> = HashMap::new();
280        edges.insert(
281            "root-id".into(),
282            vec!["building-id".into(), "status-id".into()],
283        );
284        edges.insert(
285            "building-id".into(),
286            vec!["name-id".into(), "address-id".into()],
287        );
288        edges.insert("address-id".into(), vec!["city-id".into()]);
289
290        let mut nodegroups = HashMap::new();
291        let make_ng = |id: &str, cardinality: Option<&str>| {
292            Arc::new(StaticNodegroup {
293                nodegroupid: id.to_string(),
294                cardinality: cardinality.map(|s| s.to_string()),
295                legacygroupid: None,
296                parentnodegroup_id: None,
297                grouping_node_id: None,
298            })
299        };
300        nodegroups.insert("ng-building".into(), make_ng("ng-building", Some("1")));
301        nodegroups.insert("ng-address".into(), make_ng("ng-address", Some("n")));
302        nodegroups.insert("ng-status".into(), make_ng("ng-status", Some("1")));
303
304        (root, nodes, edges, nodegroups)
305    }
306
307    #[test]
308    fn test_single_segment_path() {
309        let (root, nodes, edges, nodegroups) = setup_graph();
310        let result =
311            resolve_path_segments("building", &root, &nodes, &edges, Some(&nodegroups)).unwrap();
312
313        assert_eq!(result.target_node.alias.as_deref(), Some("building"));
314        assert_eq!(result.nodegroup_id, "ng-building");
315        // building has children: name, address
316        assert_eq!(result.child_node_ids.len(), 2);
317    }
318
319    #[test]
320    fn test_multi_segment_path() {
321        let (root, nodes, edges, nodegroups) = setup_graph();
322        let result =
323            resolve_path_segments("building.name", &root, &nodes, &edges, Some(&nodegroups))
324                .unwrap();
325
326        assert_eq!(result.target_node.alias.as_deref(), Some("name"));
327        assert_eq!(result.nodegroup_id, "ng-building");
328        assert!(result.child_node_ids.is_empty());
329        assert!(result.is_single); // non-collector, nodegroup cardinality "1"
330    }
331
332    #[test]
333    fn test_cross_nodegroup_path() {
334        let (root, nodes, edges, nodegroups) = setup_graph();
335        let result = resolve_path_segments(
336            "building.address.city",
337            &root,
338            &nodes,
339            &edges,
340            Some(&nodegroups),
341        )
342        .unwrap();
343
344        assert_eq!(result.target_node.alias.as_deref(), Some("city"));
345        assert_eq!(result.nodegroup_id, "ng-address");
346    }
347
348    #[test]
349    fn test_leading_dot_stripped() {
350        let (root, nodes, edges, nodegroups) = setup_graph();
351        let result =
352            resolve_path_segments(".building.name", &root, &nodes, &edges, Some(&nodegroups))
353                .unwrap();
354
355        assert_eq!(result.target_node.alias.as_deref(), Some("name"));
356    }
357
358    #[test]
359    fn test_collector_is_not_single() {
360        let (root, nodes, edges, nodegroups) = setup_graph();
361        let result =
362            resolve_path_segments("building.address", &root, &nodes, &edges, Some(&nodegroups))
363                .unwrap();
364
365        assert_eq!(result.target_node.alias.as_deref(), Some("address"));
366        // address is a collector node → not single
367        assert!(!result.is_single);
368    }
369
370    #[test]
371    fn test_empty_path_error() {
372        let (root, nodes, edges, nodegroups) = setup_graph();
373        let err = resolve_path_segments("", &root, &nodes, &edges, Some(&nodegroups)).unwrap_err();
374        assert!(matches!(err, PathError::EmptyPath));
375    }
376
377    #[test]
378    fn test_only_dots_error() {
379        let (root, nodes, edges, nodegroups) = setup_graph();
380        let err =
381            resolve_path_segments("...", &root, &nodes, &edges, Some(&nodegroups)).unwrap_err();
382        assert!(matches!(err, PathError::EmptyPath));
383    }
384
385    #[test]
386    fn test_alias_not_found() {
387        let (root, nodes, edges, nodegroups) = setup_graph();
388        let err = resolve_path_segments(
389            "building.nonexistent",
390            &root,
391            &nodes,
392            &edges,
393            Some(&nodegroups),
394        )
395        .unwrap_err();
396        match err {
397            PathError::AliasNotFound {
398                segment,
399                parent_alias,
400            } => {
401                assert_eq!(segment, "nonexistent");
402                assert_eq!(parent_alias.as_deref(), Some("building"));
403            }
404            _ => panic!("Expected AliasNotFound, got {:?}", err),
405        }
406    }
407
408    #[test]
409    fn test_first_segment_not_found() {
410        let (root, nodes, edges, nodegroups) = setup_graph();
411        let err =
412            resolve_path_segments("unknown", &root, &nodes, &edges, Some(&nodegroups)).unwrap_err();
413        match err {
414            PathError::AliasNotFound {
415                segment,
416                parent_alias,
417            } => {
418                assert_eq!(segment, "unknown");
419                assert_eq!(parent_alias.as_deref(), Some("root"));
420            }
421            _ => panic!("Expected AliasNotFound, got {:?}", err),
422        }
423    }
424
425    #[test]
426    fn test_path_beyond_leaf_fails() {
427        let (root, nodes, edges, nodegroups) = setup_graph();
428        // "name" is a leaf — no children
429        let err = resolve_path_segments(
430            "building.name.extra",
431            &root,
432            &nodes,
433            &edges,
434            Some(&nodegroups),
435        )
436        .unwrap_err();
437        assert!(matches!(err, PathError::AliasNotFound { .. }));
438    }
439
440    #[test]
441    fn test_no_nodegroup_error() {
442        // Create a node without a nodegroup
443        let root = make_node("root-id", "root", Some("root-id"), false, true);
444        let child = Arc::new(StaticNode {
445            nodeid: "child-id".to_string(),
446            name: "child".to_string(),
447            alias: Some("child".to_string()),
448            datatype: "string".to_string(),
449            is_collector: false,
450            nodegroup_id: None, // No nodegroup!
451            graph_id: "test-graph".to_string(),
452            isrequired: false,
453            exportable: true,
454            sortorder: None,
455            config: HashMap::new(),
456            parentproperty: None,
457            ontologyclass: None,
458            description: None,
459            fieldname: None,
460            hascustomalias: false,
461            issearchable: false,
462            istopnode: false,
463            sourcebranchpublication_id: None,
464            source_identifier_id: None,
465            is_immutable: None,
466        });
467
468        let mut nodes = HashMap::new();
469        nodes.insert("root-id".into(), Arc::clone(&root));
470        nodes.insert("child-id".into(), Arc::clone(&child));
471
472        let mut edges = HashMap::new();
473        edges.insert("root-id".into(), vec!["child-id".into()]);
474
475        let err = resolve_path_segments("child", &root, &nodes, &edges, None).unwrap_err();
476        assert!(matches!(err, PathError::NoNodegroup { .. }));
477    }
478
479    // =========================================================================
480    // Tests for _ and * segment handling
481    // =========================================================================
482
483    #[test]
484    fn test_underscore_skipped_on_collector() {
485        let (root, nodes, edges, nodegroups) = setup_graph();
486        // address is a collector — "building.address._.city" should resolve to city
487        let result = resolve_path_segments(
488            "building.address._.city",
489            &root,
490            &nodes,
491            &edges,
492            Some(&nodegroups),
493        )
494        .unwrap();
495        assert_eq!(result.target_node.alias.as_deref(), Some("city"));
496        assert_eq!(result.nodegroup_id, "ng-address");
497    }
498
499    #[test]
500    fn test_underscore_errors_on_non_collector() {
501        let (root, nodes, edges, nodegroups) = setup_graph();
502        // building is NOT a collector — "building._.name" should error
503        let err =
504            resolve_path_segments("building._.name", &root, &nodes, &edges, Some(&nodegroups))
505                .unwrap_err();
506        assert!(matches!(err, PathError::UnderscoreOnNonCollector { .. }));
507    }
508
509    #[test]
510    fn test_star_skipped_on_multi_cardinality() {
511        let (root, nodes, edges, nodegroups) = setup_graph();
512        // address is a collector (multi-cardinality) — "building.address.*.city" should work
513        let result = resolve_path_segments(
514            "building.address.*.city",
515            &root,
516            &nodes,
517            &edges,
518            Some(&nodegroups),
519        )
520        .unwrap();
521        assert_eq!(result.target_node.alias.as_deref(), Some("city"));
522    }
523
524    #[test]
525    fn test_star_errors_on_single_cardinality() {
526        let (root, nodes, edges, nodegroups) = setup_graph();
527        // building has nodegroup cardinality "1" — "building.*.name" should error
528        let err =
529            resolve_path_segments("building.*.name", &root, &nodes, &edges, Some(&nodegroups))
530                .unwrap_err();
531        assert!(matches!(err, PathError::StarOnSingleCardinality { .. }));
532    }
533
534    #[test]
535    fn test_star_and_underscore_combined() {
536        let (root, nodes, edges, nodegroups) = setup_graph();
537        // address is collector + multi — both * and _ should be valid
538        let result = resolve_path_segments(
539            "building.address.*._.city",
540            &root,
541            &nodes,
542            &edges,
543            Some(&nodegroups),
544        )
545        .unwrap();
546        assert_eq!(result.target_node.alias.as_deref(), Some("city"));
547    }
548}