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