Skip to main content

opcua_types/
relative_path.rs

1// OPCUA for Rust
2// SPDX-License-Identifier: MPL-2.0
3// Copyright (C) 2017-2024 Adam Lock
4
5//! Contains functions used for making relative paths from / to strings, as per OPC UA Part 4, Appendix A
6//!
7//! Functions are implemented on the `RelativePath` and `RelativePathElement` structs where
8//! there are most useful.
9
10use std::sync::LazyLock;
11
12use regex::Regex;
13use thiserror::Error;
14use tracing::error;
15
16use crate::{
17    node_id::{Identifier, NodeId},
18    qualified_name::QualifiedName,
19    string::UAString,
20    ReferenceTypeId, RelativePath, RelativePathElement,
21};
22
23impl RelativePath {
24    /// The maximum size in chars of any path element.
25    const MAX_TOKEN_LEN: usize = 256;
26    /// The maximum number of elements in total.
27    const MAX_ELEMENTS: usize = 32;
28
29    /// Converts a string into a relative path. Caller must supply a `node_resolver` which will
30    /// be used to look up nodes from their browse name. The function will reject strings
31    /// that look unusually long or contain too many elements.
32    pub fn from_str<CB>(path: &str, node_resolver: &CB) -> Result<RelativePath, RelativePathError>
33    where
34        CB: Fn(u16, &str) -> Option<NodeId>,
35    {
36        let mut elements: Vec<RelativePathElement> = Vec::new();
37
38        // This loop will break the string up into path segments. For each segment it will
39        // then parse it into a relative path element. When the string is successfully parsed,
40        // the elements will be returned.
41        let mut escaped_char = false;
42        let mut token = String::with_capacity(path.len());
43        for c in path.chars() {
44            if escaped_char {
45                token.push(c);
46                escaped_char = false;
47            } else {
48                // Parse the
49                match c {
50                    '&' => {
51                        // The next character is escaped and part of the token
52                        escaped_char = true;
53                    }
54                    // We have reached the start of a token and need to process the previous one
55                    '/' | '.' | '<' if !token.is_empty() => {
56                        if elements.len() == Self::MAX_ELEMENTS {
57                            break;
58                        }
59                        elements.push(RelativePathElement::from_str(&token, node_resolver)?);
60                        token.clear();
61                    }
62                    _ => {}
63                }
64                token.push(c);
65            }
66            if token.len() > Self::MAX_TOKEN_LEN {
67                error!("Path segment seems unusually long and has been rejected");
68                return Err(RelativePathError::PathSegmentTooLong);
69            }
70        }
71
72        if !token.is_empty() {
73            if elements.len() == Self::MAX_ELEMENTS {
74                error!("Number of elements in relative path is too long, rejecting it");
75                return Err(RelativePathError::TooManyElements);
76            }
77            elements.push(RelativePathElement::from_str(&token, node_resolver)?);
78        }
79
80        Ok(RelativePath {
81            elements: Some(elements),
82        })
83    }
84}
85
86impl From<&[QualifiedName]> for RelativePath {
87    fn from(value: &[QualifiedName]) -> Self {
88        let elements = value
89            .iter()
90            .map(|qn| RelativePathElement {
91                reference_type_id: ReferenceTypeId::HierarchicalReferences.into(),
92                is_inverse: false,
93                include_subtypes: true,
94                target_name: qn.clone(),
95            })
96            .collect();
97        Self {
98            elements: Some(elements),
99        }
100    }
101}
102// Cannot use
103//impl<T: AsRef<str>> TryFrom<T> for RelativePath {
104// for some strange reasons so implementing all thee manually here
105//
106impl TryFrom<&str> for RelativePath {
107    type Error = RelativePathError;
108
109    fn try_from(value: &str) -> Result<Self, Self::Error> {
110        RelativePath::from_str(value, &RelativePathElement::default_node_resolver)
111    }
112}
113
114impl TryFrom<&String> for RelativePath {
115    type Error = RelativePathError;
116
117    fn try_from(value: &String) -> Result<Self, Self::Error> {
118        RelativePath::from_str(value, &RelativePathElement::default_node_resolver)
119    }
120}
121
122impl TryFrom<String> for RelativePath {
123    type Error = RelativePathError;
124
125    fn try_from(value: String) -> Result<Self, Self::Error> {
126        RelativePath::from_str(&value, &RelativePathElement::default_node_resolver)
127    }
128}
129impl<'a> From<&'a RelativePathElement> for String {
130    fn from(element: &'a RelativePathElement) -> String {
131        let mut result = element
132            .relative_path_reference_type(&RelativePathElement::default_browse_name_resolver);
133        if !element.target_name.name.is_null() {
134            let always_use_namespace = true;
135            let target_browse_name = escape_browse_name(element.target_name.name.as_ref());
136            if always_use_namespace || element.target_name.namespace_index > 0 {
137                result.push_str(&format!(
138                    "{}:{}",
139                    element.target_name.namespace_index, target_browse_name
140                ));
141            } else {
142                result.push_str(&target_browse_name);
143            }
144        }
145        result
146    }
147}
148
149impl RelativePathElement {
150    /// This is the default node resolver that attempts to resolve a browse name onto a
151    /// reference type id. The default implementation resides in the types module so it
152    /// doesn't have access to the address space.
153    ///
154    /// Therefore it makes a best guess by testing the browse name against the standard reference
155    /// types and if fails to match it will produce a node id from the namespace and browse name.
156    pub fn default_node_resolver(namespace: u16, browse_name: &str) -> Option<NodeId> {
157        let node_id = if namespace == 0 {
158            match browse_name {
159                "References" => ReferenceTypeId::References.into(),
160                "NonHierarchicalReferences" => ReferenceTypeId::NonHierarchicalReferences.into(),
161                "HierarchicalReferences" => ReferenceTypeId::HierarchicalReferences.into(),
162                "HasChild" => ReferenceTypeId::HasChild.into(),
163                "Organizes" => ReferenceTypeId::Organizes.into(),
164                "HasEventSource" => ReferenceTypeId::HasEventSource.into(),
165                "HasModellingRule" => ReferenceTypeId::HasModellingRule.into(),
166                "HasEncoding" => ReferenceTypeId::HasEncoding.into(),
167                "HasDescription" => ReferenceTypeId::HasDescription.into(),
168                "HasTypeDefinition" => ReferenceTypeId::HasTypeDefinition.into(),
169                "GeneratesEvent" => ReferenceTypeId::GeneratesEvent.into(),
170                "Aggregates" => ReferenceTypeId::Aggregates.into(),
171                "HasSubtype" => ReferenceTypeId::HasSubtype.into(),
172                "HasProperty" => ReferenceTypeId::HasProperty.into(),
173                "HasComponent" => ReferenceTypeId::HasComponent.into(),
174                "HasNotifier" => ReferenceTypeId::HasNotifier.into(),
175                "HasOrderedComponent" => ReferenceTypeId::HasOrderedComponent.into(),
176                "FromState" => ReferenceTypeId::FromState.into(),
177                "ToState" => ReferenceTypeId::ToState.into(),
178                "HasCause" => ReferenceTypeId::HasCause.into(),
179                "HasEffect" => ReferenceTypeId::HasEffect.into(),
180                "HasHistoricalConfiguration" => ReferenceTypeId::HasHistoricalConfiguration.into(),
181                "HasSubStateMachine" => ReferenceTypeId::HasSubStateMachine.into(),
182                "AlwaysGeneratesEvent" => ReferenceTypeId::AlwaysGeneratesEvent.into(),
183                "HasTrueSubState" => ReferenceTypeId::HasTrueSubState.into(),
184                "HasFalseSubState" => ReferenceTypeId::HasFalseSubState.into(),
185                "HasCondition" => ReferenceTypeId::HasCondition.into(),
186                _ => NodeId::new(0, UAString::from(browse_name)),
187            }
188        } else {
189            NodeId::new(namespace, UAString::from(browse_name))
190        };
191        Some(node_id)
192    }
193
194    fn id_from_reference_type(id: u32) -> Option<String> {
195        // This syntax is horrible - it casts the u32 into an enum if it can
196        Some(
197            match id {
198                id if id == ReferenceTypeId::References as u32 => "References",
199                id if id == ReferenceTypeId::NonHierarchicalReferences as u32 => {
200                    "NonHierarchicalReferences"
201                }
202                id if id == ReferenceTypeId::HierarchicalReferences as u32 => {
203                    "HierarchicalReferences"
204                }
205                id if id == ReferenceTypeId::HasChild as u32 => "HasChild",
206                id if id == ReferenceTypeId::Organizes as u32 => "Organizes",
207                id if id == ReferenceTypeId::HasEventSource as u32 => "HasEventSource",
208                id if id == ReferenceTypeId::HasModellingRule as u32 => "HasModellingRule",
209                id if id == ReferenceTypeId::HasEncoding as u32 => "HasEncoding",
210                id if id == ReferenceTypeId::HasDescription as u32 => "HasDescription",
211                id if id == ReferenceTypeId::HasTypeDefinition as u32 => "HasTypeDefinition",
212                id if id == ReferenceTypeId::GeneratesEvent as u32 => "GeneratesEvent",
213                id if id == ReferenceTypeId::Aggregates as u32 => "Aggregates",
214                id if id == ReferenceTypeId::HasSubtype as u32 => "HasSubtype",
215                id if id == ReferenceTypeId::HasProperty as u32 => "HasProperty",
216                id if id == ReferenceTypeId::HasComponent as u32 => "HasComponent",
217                id if id == ReferenceTypeId::HasNotifier as u32 => "HasNotifier",
218                id if id == ReferenceTypeId::HasOrderedComponent as u32 => "HasOrderedComponent",
219                id if id == ReferenceTypeId::FromState as u32 => "FromState",
220                id if id == ReferenceTypeId::ToState as u32 => "ToState",
221                id if id == ReferenceTypeId::HasCause as u32 => "HasCause",
222                id if id == ReferenceTypeId::HasEffect as u32 => "HasEffect",
223                id if id == ReferenceTypeId::HasHistoricalConfiguration as u32 => {
224                    "HasHistoricalConfiguration"
225                }
226                id if id == ReferenceTypeId::HasSubStateMachine as u32 => "HasSubStateMachine",
227                id if id == ReferenceTypeId::AlwaysGeneratesEvent as u32 => "AlwaysGeneratesEvent",
228                id if id == ReferenceTypeId::HasTrueSubState as u32 => "HasTrueSubState",
229                id if id == ReferenceTypeId::HasFalseSubState as u32 => "HasFalseSubState",
230                id if id == ReferenceTypeId::HasCondition as u32 => "HasCondition",
231                _ => return None,
232            }
233            .to_string(),
234        )
235    }
236
237    fn default_browse_name_resolver(node_id: &NodeId) -> Option<String> {
238        match &node_id.identifier {
239            Identifier::String(browse_name) => Some(browse_name.as_ref().to_string()),
240            Identifier::Numeric(id) => {
241                if node_id.namespace == 0 {
242                    Self::id_from_reference_type(*id)
243                } else {
244                    None
245                }
246            }
247            _ => None,
248        }
249    }
250
251    /// Parse a relative path element according to the OPC UA Part 4 Appendix A BNF
252    ///
253    /// `<relative-path> ::= <reference-type> <browse-name> [relative-path]`
254    /// `<reference-type> ::= '/' | '.' | '<' ['#'] ['!'] <browse-name> '>'`
255    /// `<browse-name> ::= [<namespace-index> ':'] <name>`
256    /// `<namespace-index> ::= <digit> [<digit>]`
257    /// `<digit> ::= '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'`
258    /// `<name> ::= (<name-char> | '&' <reserved-char>) [<name>]`
259    /// `<reserved-char> ::= '/' | '.' | '<' | '>' | ':' | '#' | '!' | '&'`
260    /// `<name-char> ::= All valid characters for a String (see Part 3) excluding reserved-chars.`
261    ///
262    /// # Examples
263    ///
264    /// * `/foo`
265    /// * `/0:foo`
266    /// * `.bar`
267    /// * `<0:HasEncoding>bar`
268    /// * `<!NonHierarchicalReferences>foo`
269    /// * `<#!2:MyReftype>2:blah`
270    ///
271    pub fn from_str<CB>(
272        path: &str,
273        node_resolver: &CB,
274    ) -> Result<RelativePathElement, RelativePathError>
275    where
276        CB: Fn(u16, &str) -> Option<NodeId>,
277    {
278        static RE: LazyLock<Regex> = LazyLock::new(|| {
279            Regex::new(r"(?P<reftype>/|\.|(<(?P<flags>#|!|#!)?((?P<nsidx>[0-9]+):)?(?P<name>[^#!].*)>))(?P<target>.*)").unwrap()
280        });
281
282        // NOTE: This could be more safely done with a parser library, e.g. nom.
283
284        if let Some(captures) = RE.captures(path) {
285            let target_name = target_name(captures.name("target").unwrap().as_str())?;
286
287            let reference_type = captures.name("reftype").unwrap();
288            let (reference_type_id, include_subtypes, is_inverse) = match reference_type.as_str() {
289                "/" => (ReferenceTypeId::HierarchicalReferences.into(), true, false),
290                "." => (ReferenceTypeId::Aggregates.into(), true, false),
291                _ => {
292                    let (include_subtypes, is_inverse) = if let Some(flags) = captures.name("flags")
293                    {
294                        match flags.as_str() {
295                            "#" => (false, false),
296                            "!" => (true, true),
297                            "#!" => (false, true),
298                            _ => panic!("Error in regular expression for flags"),
299                        }
300                    } else {
301                        (true, false)
302                    };
303
304                    let browse_name = captures.name("name").unwrap().as_str();
305
306                    // Process the token as a reference type
307                    let reference_type_id = if let Some(namespace) = captures.name("nsidx") {
308                        let namespace = namespace.as_str();
309                        if namespace == "0" || namespace.is_empty() {
310                            node_resolver(0, browse_name)
311                        } else if let Ok(namespace) = namespace.parse::<u16>() {
312                            node_resolver(namespace, browse_name)
313                        } else {
314                            error!("Namespace {} is out of range", namespace);
315                            return Err(RelativePathError::NamespaceOutOfRange);
316                        }
317                    } else {
318                        node_resolver(0, browse_name)
319                    };
320                    if reference_type_id.is_none() {
321                        error!(
322                            "Supplied node resolver was unable to resolve a reference type from {}",
323                            path
324                        );
325                        return Err(RelativePathError::UnresolvedReferenceType);
326                    }
327                    (reference_type_id.unwrap(), include_subtypes, is_inverse)
328                }
329            };
330            Ok(RelativePathElement {
331                reference_type_id,
332                is_inverse,
333                include_subtypes,
334                target_name,
335            })
336        } else {
337            error!("Path {} does not match a relative path", path);
338            Err(RelativePathError::NoMatch)
339        }
340    }
341
342    /// Constructs a string representation of the reference type in the relative path.
343    /// This code assumes that the reference type's node id has a string identifier and that
344    /// the string identifier is the same as the browse name.
345    pub(crate) fn relative_path_reference_type<CB>(&self, browse_name_resolver: &CB) -> String
346    where
347        CB: Fn(&NodeId) -> Option<String>,
348    {
349        let browse_name = browse_name_resolver(&self.reference_type_id).unwrap();
350        let mut result = String::with_capacity(1024);
351        // Common references will come out as '/' or '.'
352        if self.include_subtypes && !self.is_inverse {
353            if self.reference_type_id == ReferenceTypeId::HierarchicalReferences {
354                result.push('/');
355            } else if self.reference_type_id == ReferenceTypeId::Aggregates {
356                result.push('.');
357            }
358        };
359        // Other kinds of reference are built as a string
360        if result.is_empty() {
361            result.push('<');
362            if !self.include_subtypes {
363                result.push('#');
364            }
365            if self.is_inverse {
366                result.push('!');
367            }
368
369            let browse_name = escape_browse_name(browse_name.as_ref());
370            if self.reference_type_id.namespace != 0 {
371                result.push_str(&format!(
372                    "{}:{}",
373                    self.reference_type_id.namespace, browse_name
374                ));
375            } else {
376                result.push_str(&browse_name);
377            }
378            result.push('>');
379        }
380
381        result
382    }
383}
384
385/// Error returned from parsing a relative path.
386#[allow(missing_docs)]
387#[derive(Error, Debug)]
388pub enum RelativePathError {
389    #[error("Namespace is out of range of a u16.")]
390    NamespaceOutOfRange,
391    #[error("Supplied node resolver was unable to resolve a reference type.")]
392    UnresolvedReferenceType,
393    #[error("Path does not match a relative path.")]
394    NoMatch,
395    #[error("Path segment is unusually long and has been rejected.")]
396    PathSegmentTooLong,
397    #[error("Number of elements in relative path is too large.")]
398    TooManyElements,
399}
400
401impl<'a> From<&'a RelativePath> for String {
402    fn from(path: &'a RelativePath) -> String {
403        if let Some(ref elements) = path.elements {
404            let mut result = String::with_capacity(1024);
405            for e in elements.iter() {
406                result.push_str(String::from(e).as_ref());
407            }
408            result
409        } else {
410            String::new()
411        }
412    }
413}
414
415/// Reserved characters in the browse name which must be escaped with a &
416const BROWSE_NAME_RESERVED_CHARS: &str = "&/.<>:#!";
417
418/// Escapes reserved characters in the browse name
419fn escape_browse_name(name: &str) -> String {
420    let mut result = String::from(name);
421    BROWSE_NAME_RESERVED_CHARS.chars().for_each(|c| {
422        result = result.replace(c, &format!("&{c}"));
423    });
424    result
425}
426
427/// Unescapes reserved characters in the browse name
428fn unescape_browse_name(name: &str) -> String {
429    let mut result = String::from(name);
430    BROWSE_NAME_RESERVED_CHARS.chars().for_each(|c| {
431        result = result.replace(&format!("&{c}"), &c.to_string());
432    });
433    result
434}
435
436/// Parse a target name into a qualified name. The name is either `nsidx:name` or just
437/// `name`, where `nsidx` is a numeric index and `name` may contain escaped reserved chars.
438///
439/// # Examples
440///
441/// * 0:foo
442/// * bar
443///
444fn target_name(target_name: &str) -> Result<QualifiedName, RelativePathError> {
445    static RE: LazyLock<Regex> =
446        LazyLock::new(|| Regex::new(r"((?P<nsidx>[0-9+]):)?(?P<name>.*)").unwrap());
447    if let Some(captures) = RE.captures(target_name) {
448        let namespace = if let Some(namespace) = captures.name("nsidx") {
449            if let Ok(namespace) = namespace.as_str().parse::<u16>() {
450                namespace
451            } else {
452                error!(
453                    "Namespace {} for target name is out of range",
454                    namespace.as_str()
455                );
456                return Err(RelativePathError::NamespaceOutOfRange);
457            }
458        } else {
459            0
460        };
461        let name = if let Some(name) = captures.name("name") {
462            let name = name.as_str();
463            if name.is_empty() {
464                UAString::null()
465            } else {
466                UAString::from(unescape_browse_name(name))
467            }
468        } else {
469            UAString::null()
470        };
471        Ok(QualifiedName::new(namespace, name))
472    } else {
473        Ok(QualifiedName::null())
474    }
475}
476
477/// Test that escaping of browse names works as expected in each direction
478#[test]
479fn test_escape_browse_name() {
480    [
481        ("", ""),
482        ("Hello World", "Hello World"),
483        ("Hello &World", "Hello &&World"),
484        ("Hello &&World", "Hello &&&&World"),
485        ("Block.Output", "Block&.Output"),
486        ("/Name_1", "&/Name_1"),
487        (".Name_2", "&.Name_2"),
488        (":Name_3", "&:Name_3"),
489        ("&Name_4", "&&Name_4"),
490    ]
491    .iter()
492    .for_each(|n| {
493        let original = n.0.to_string();
494        let escaped = n.1.to_string();
495        assert_eq!(escaped, escape_browse_name(&original));
496        assert_eq!(unescape_browse_name(&escaped), original);
497    });
498}
499
500/// Test that given a relative path element that it can be converted to/from a string
501/// and a RelativePathElement type
502#[test]
503fn test_relative_path_element() {
504    use crate::qualified_name::QualifiedName;
505
506    [
507        (
508            RelativePathElement {
509                reference_type_id: ReferenceTypeId::HierarchicalReferences.into(),
510                is_inverse: false,
511                include_subtypes: true,
512                target_name: QualifiedName::new(0, "foo1"),
513            },
514            "/0:foo1",
515        ),
516        (
517            RelativePathElement {
518                reference_type_id: ReferenceTypeId::HierarchicalReferences.into(),
519                is_inverse: false,
520                include_subtypes: true,
521                target_name: QualifiedName::new(0, ".foo2"),
522            },
523            "/0:&.foo2",
524        ),
525        (
526            RelativePathElement {
527                reference_type_id: ReferenceTypeId::HierarchicalReferences.into(),
528                is_inverse: true,
529                include_subtypes: true,
530                target_name: QualifiedName::new(2, "foo3"),
531            },
532            "<!HierarchicalReferences>2:foo3",
533        ),
534        (
535            RelativePathElement {
536                reference_type_id: ReferenceTypeId::HierarchicalReferences.into(),
537                is_inverse: true,
538                include_subtypes: false,
539                target_name: QualifiedName::new(0, "foo4"),
540            },
541            "<#!HierarchicalReferences>0:foo4",
542        ),
543        (
544            RelativePathElement {
545                reference_type_id: ReferenceTypeId::Aggregates.into(),
546                is_inverse: false,
547                include_subtypes: true,
548                target_name: QualifiedName::new(0, "foo5"),
549            },
550            ".0:foo5",
551        ),
552        (
553            RelativePathElement {
554                reference_type_id: ReferenceTypeId::HasHistoricalConfiguration.into(),
555                is_inverse: false,
556                include_subtypes: true,
557                target_name: QualifiedName::new(0, "foo6"),
558            },
559            "<HasHistoricalConfiguration>0:foo6",
560        ),
561    ]
562    .iter()
563    .for_each(|n| {
564        let element = &n.0;
565        let expected = n.1.to_string();
566
567        // Compare string to expected
568        let actual = String::from(element);
569        assert_eq!(expected, actual);
570
571        // Turn string back to element, compare to original element
572        let actual =
573            RelativePathElement::from_str(&actual, &RelativePathElement::default_node_resolver)
574                .unwrap();
575        assert_eq!(*element, actual);
576    });
577}
578
579/// Test that the given entire relative path, that it can be converted to/from a string
580/// and a RelativePath type.
581#[test]
582fn test_relative_path() {
583    use crate::qualified_name::QualifiedName;
584
585    // Samples are from OPC UA Part 4 Appendix A
586    let tests = vec![
587        (
588            vec![RelativePathElement {
589                reference_type_id: ReferenceTypeId::HierarchicalReferences.into(),
590                is_inverse: false,
591                include_subtypes: true,
592                target_name: QualifiedName::new(2, "Block.Output"),
593            }],
594            "/2:Block&.Output",
595        ),
596        (
597            vec![
598                RelativePathElement {
599                    reference_type_id: ReferenceTypeId::HierarchicalReferences.into(),
600                    is_inverse: false,
601                    include_subtypes: true,
602                    target_name: QualifiedName::new(3, "Truck"),
603                },
604                RelativePathElement {
605                    reference_type_id: ReferenceTypeId::Aggregates.into(),
606                    is_inverse: false,
607                    include_subtypes: true,
608                    target_name: QualifiedName::new(0, "NodeVersion"),
609                },
610            ],
611            "/3:Truck.0:NodeVersion",
612        ),
613        (
614            vec![
615                RelativePathElement {
616                    reference_type_id: NodeId::new(1, "ConnectedTo"),
617                    is_inverse: false,
618                    include_subtypes: true,
619                    target_name: QualifiedName::new(1, "Boiler"),
620                },
621                RelativePathElement {
622                    reference_type_id: ReferenceTypeId::HierarchicalReferences.into(),
623                    is_inverse: false,
624                    include_subtypes: true,
625                    target_name: QualifiedName::new(1, "HeatSensor"),
626                },
627            ],
628            "<1:ConnectedTo>1:Boiler/1:HeatSensor",
629        ),
630        (
631            vec![
632                RelativePathElement {
633                    reference_type_id: NodeId::new(1, "ConnectedTo"),
634                    is_inverse: false,
635                    include_subtypes: true,
636                    target_name: QualifiedName::new(1, "Boiler"),
637                },
638                RelativePathElement {
639                    reference_type_id: ReferenceTypeId::HierarchicalReferences.into(),
640                    is_inverse: false,
641                    include_subtypes: true,
642                    target_name: QualifiedName::null(),
643                },
644            ],
645            "<1:ConnectedTo>1:Boiler/",
646        ),
647        (
648            vec![RelativePathElement {
649                reference_type_id: ReferenceTypeId::HasChild.into(),
650                is_inverse: false,
651                include_subtypes: true,
652                target_name: QualifiedName::new(2, "Wheel"),
653            }],
654            "<HasChild>2:Wheel",
655        ),
656        (
657            vec![RelativePathElement {
658                reference_type_id: ReferenceTypeId::HasChild.into(),
659                is_inverse: true,
660                include_subtypes: true,
661                target_name: QualifiedName::new(0, "Truck"),
662            }],
663            "<!HasChild>0:Truck",
664        ),
665        (
666            vec![RelativePathElement {
667                reference_type_id: ReferenceTypeId::HasChild.into(),
668                is_inverse: false,
669                include_subtypes: true,
670                target_name: QualifiedName::null(),
671            }],
672            "<HasChild>",
673        ),
674    ];
675
676    tests.into_iter().for_each(|n| {
677        let relative_path = RelativePath {
678            elements: Some(n.0),
679        };
680        let expected = n.1.to_string();
681
682        // Convert path to string, compare to expected
683        let actual = String::from(&relative_path);
684        assert_eq!(expected, actual);
685
686        // Turn string back to element, compare to original path
687        let actual =
688            RelativePath::from_str(&actual, &RelativePathElement::default_node_resolver).unwrap();
689        assert_eq!(relative_path, actual);
690    });
691}