Skip to main content

sui_graphql_macros/
path.rs

1//! Field path parsing for GraphQL response extraction.
2//!
3//! This module provides a single parser for field paths used in the Response macro.
4//! The parsed representation is used for both schema validation and code generation,
5//! ensuring consistency and avoiding duplicate parsing logic.
6
7/// A parsed field path.
8///
9/// Paths like `"data.nodes[].name"` are parsed into segments:
10/// ```text
11/// ParsedPath {
12///     segments: [
13///         PathSegment { field: "data",  is_nullable: false, list: None },
14///         PathSegment { field: "nodes", is_nullable: false, list: Some(List { elements_nullable: false }) },
15///         PathSegment { field: "name",  is_nullable: false, list: None },
16///     ]
17/// }
18/// ```
19///
20/// The alias syntax `alias:field` matches GraphQL alias responses where:
21/// - `alias` (before `:`) is the JSON key in the response
22/// - `field` (after `:`) is the real field name for schema validation
23///
24/// List fields must always use `[]` suffix explicitly (e.g., `nodes[]`, `tags[]`).
25///
26/// Null markers use `?` suffix:
27/// - `field?` — field value is nullable (null → `Ok(None)`)
28/// - `field?[]` — array itself is nullable
29/// - `field[]?` — array elements are nullable
30/// - `field?[]?` — both array and elements are nullable
31#[derive(Debug, Clone)]
32pub struct ParsedPath<'a> {
33    /// The original path string (for error messages)
34    pub raw: &'a str,
35    /// Parsed segments of the path
36    pub segments: Vec<PathSegment<'a>>,
37}
38
39/// List properties for a path segment that has `[]`.
40///
41/// The presence of this struct indicates the segment is a list field.
42#[derive(Debug, Clone, PartialEq)]
43pub struct List {
44    /// Whether elements of the list are nullable (`[]?` suffix).
45    /// When true, null elements within the array return `Ok(None)` per element.
46    pub elements_nullable: bool,
47}
48
49/// A single segment in a field path.
50///
51/// Segments can include an alias using `:` syntax for GraphQL aliases:
52/// - The alias (before `:`) is used for JSON extraction
53/// - The field name (after `:`) is used for schema validation
54///
55/// Null markers (`?`) control per-segment null tolerance:
56/// - `is_nullable`: the field value itself is nullable
57/// - `list.elements_nullable`: array elements are nullable
58#[derive(Debug, Clone)]
59pub struct PathSegment<'a> {
60    /// The field name (used for schema validation)
61    pub field: &'a str,
62    /// Optional alias (used for JSON extraction instead of field name)
63    pub alias: Option<&'a str>,
64    /// Whether this field value is nullable (`?` before `[]` or on non-list field).
65    /// When true, null at this field returns `Ok(None)` instead of an error.
66    pub is_nullable: bool,
67    /// List properties if this field has `[]` suffix. `None` means not a list.
68    pub list: Option<List>,
69}
70
71impl<'a> PathSegment<'a> {
72    /// Get the key to use for JSON extraction (alias if present, otherwise field name)
73    pub fn json_key(&self) -> &str {
74        self.alias.unwrap_or(self.field)
75    }
76
77    /// Whether this segment is a list field (has `[]` suffix).
78    pub fn is_list(&self) -> bool {
79        self.list.is_some()
80    }
81}
82
83impl<'a> ParsedPath<'a> {
84    /// Parse a path string into a structured representation.
85    ///
86    /// Returns `Err` if the path is empty or has invalid syntax.
87    ///
88    /// # Alias Syntax
89    ///
90    /// Use `alias:field` to handle GraphQL aliases where the JSON response
91    /// uses a different key than the schema field name.
92    ///
93    /// List fields must use `[]` suffix explicitly (e.g., `nodes[]`, `tags[]`).
94    ///
95    /// # Null Marker Syntax
96    ///
97    /// Use `?` to mark nullable segments:
98    /// - `field?` — field value is nullable
99    /// - `field?[]` — array is nullable
100    /// - `field[]?` — elements are nullable
101    /// - `field?[]?` — both array and elements are nullable
102    pub fn parse(path: &'a str) -> Result<Self, PathParseError<'a>> {
103        if path.is_empty() {
104            return Err(PathParseError::Empty);
105        }
106
107        let raw_segments: Vec<&str> = path.split('.').collect();
108
109        let mut segments = Vec::with_capacity(raw_segments.len());
110        for segment in raw_segments {
111            if segment.is_empty() {
112                return Err(PathParseError::EmptySegment { path });
113            }
114
115            // Split into field part and suffix (?, [], ?[], []?, ?[]?)
116            let suffix_start = segment.find(['?', '[']).unwrap_or(segment.len());
117            let (field_part, suffix) = segment.split_at(suffix_start);
118
119            let (is_nullable, list) = match suffix {
120                "" => (false, None),
121                "?" => (true, None),
122                "[]" => (
123                    false,
124                    Some(List {
125                        elements_nullable: false,
126                    }),
127                ),
128                "?[]" => (
129                    true,
130                    Some(List {
131                        elements_nullable: false,
132                    }),
133                ),
134                "[]?" => (
135                    false,
136                    Some(List {
137                        elements_nullable: true,
138                    }),
139                ),
140                "?[]?" => (
141                    true,
142                    Some(List {
143                        elements_nullable: true,
144                    }),
145                ),
146                _ => return Err(PathParseError::InvalidSuffix { path, suffix }),
147            };
148
149            // Check for alias syntax: alias:field
150            let (field, alias) = if let Some(colon_pos) = field_part.find(':') {
151                let alias = &field_part[..colon_pos];
152                let field = &field_part[colon_pos + 1..];
153                (field, Some(alias))
154            } else {
155                (field_part, None)
156            };
157
158            if field.is_empty() {
159                return Err(PathParseError::EmptySegment { path });
160            }
161
162            segments.push(PathSegment {
163                field,
164                alias,
165                is_nullable,
166                list,
167            });
168        }
169
170        Ok(ParsedPath {
171            raw: path,
172            segments,
173        })
174    }
175}
176
177/// Errors that can occur when parsing a path.
178#[derive(Debug, Clone)]
179pub enum PathParseError<'a> {
180    /// The path string is empty
181    Empty,
182    /// A segment in the path is empty (e.g., "foo..bar" or ".foo")
183    EmptySegment { path: &'a str },
184    /// Invalid suffix on a segment (e.g., "foo[?]", "foo??")
185    InvalidSuffix { path: &'a str, suffix: &'a str },
186}
187
188impl<'a> std::fmt::Display for PathParseError<'a> {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        match self {
191            PathParseError::Empty => write!(f, "Field path cannot be empty"),
192            PathParseError::EmptySegment { path } => {
193                write!(f, "Empty segment in path '{}'", path)
194            }
195            PathParseError::InvalidSuffix { path, suffix } => {
196                write!(
197                    f,
198                    "Invalid suffix '{}' in path '{}'. Valid suffixes: ?, [], ?[], []?, ?[]?",
199                    suffix, path
200                )
201            }
202        }
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_parse_simple_path() {
212        let path = ParsedPath::parse("object.address").unwrap();
213        assert_eq!(path.segments.len(), 2);
214        assert_eq!(path.segments[0].field, "object");
215        assert!(path.segments[0].alias.is_none());
216        assert!(!path.segments[0].is_nullable);
217        assert!(!path.segments[0].is_list());
218        assert_eq!(path.segments[1].field, "address");
219        assert!(!path.segments[1].is_list());
220    }
221
222    #[test]
223    fn test_parse_nested_path() {
224        let path = ParsedPath::parse("data.nodes.name").unwrap();
225        assert_eq!(path.segments.len(), 3);
226        assert_eq!(path.segments[0].field, "data");
227        assert!(!path.segments[0].is_list());
228        assert_eq!(path.segments[1].field, "nodes");
229        assert!(!path.segments[1].is_list());
230        assert_eq!(path.segments[2].field, "name");
231        assert!(!path.segments[2].is_list());
232    }
233
234    #[test]
235    fn test_parse_single_field() {
236        let path = ParsedPath::parse("chainIdentifier").unwrap();
237        assert_eq!(path.segments.len(), 1);
238        assert_eq!(path.segments[0].field, "chainIdentifier");
239        assert!(!path.segments[0].is_list());
240    }
241
242    #[test]
243    fn test_parse_with_alias() {
244        let path = ParsedPath::parse("epoch.firstCheckpoint:checkpoints.nodes[]").unwrap();
245        assert_eq!(path.segments.len(), 3);
246        assert_eq!(path.segments[0].field, "epoch");
247        assert!(path.segments[0].alias.is_none());
248        assert!(!path.segments[0].is_list());
249        assert_eq!(path.segments[1].field, "checkpoints");
250        assert_eq!(path.segments[1].alias, Some("firstCheckpoint"));
251        assert!(!path.segments[1].is_list());
252        assert_eq!(path.segments[2].field, "nodes");
253        assert!(path.segments[2].is_list());
254    }
255
256    #[test]
257    fn test_parse_array_with_alias() {
258        let path = ParsedPath::parse("myObjects:objects[]").unwrap();
259        assert_eq!(path.segments.len(), 1);
260        assert_eq!(path.segments[0].field, "objects");
261        assert_eq!(path.segments[0].alias, Some("myObjects"));
262        assert!(path.segments[0].is_list());
263    }
264
265    #[test]
266    fn test_json_key() {
267        let path = ParsedPath::parse("alias:field.normal").unwrap();
268        assert_eq!(path.segments[0].json_key(), "alias");
269        assert_eq!(path.segments[1].json_key(), "normal");
270    }
271
272    #[test]
273    fn test_parse_empty_error() {
274        let err = ParsedPath::parse("").unwrap_err();
275        assert!(matches!(err, PathParseError::Empty));
276    }
277
278    #[test]
279    fn test_parse_empty_segment_error() {
280        let err = ParsedPath::parse("foo..bar").unwrap_err();
281        assert!(matches!(err, PathParseError::EmptySegment { .. }));
282
283        let err = ParsedPath::parse(".foo").unwrap_err();
284        assert!(matches!(err, PathParseError::EmptySegment { .. }));
285    }
286
287    #[test]
288    fn test_parse_array_syntax() {
289        let path = ParsedPath::parse("items[].name").unwrap();
290        assert_eq!(path.segments.len(), 2);
291        assert_eq!(path.segments[0].field, "items");
292        assert!(path.segments[0].is_list());
293        assert_eq!(path.segments[1].field, "name");
294        assert!(!path.segments[1].is_list());
295    }
296
297    #[test]
298    fn test_parse_nested_arrays() {
299        let path = ParsedPath::parse("groups[].members[].name").unwrap();
300        assert_eq!(path.segments.len(), 3);
301        assert_eq!(path.segments[0].field, "groups");
302        assert!(path.segments[0].is_list());
303        assert_eq!(path.segments[1].field, "members");
304        assert!(path.segments[1].is_list());
305        assert_eq!(path.segments[2].field, "name");
306        assert!(!path.segments[2].is_list());
307    }
308
309    #[test]
310    fn test_parse_trailing_array() {
311        let path = ParsedPath::parse("items[].tags[]").unwrap();
312        assert_eq!(path.segments.len(), 2);
313        assert_eq!(path.segments[0].field, "items");
314        assert!(path.segments[0].is_list());
315        assert_eq!(path.segments[1].field, "tags");
316        assert!(path.segments[1].is_list());
317    }
318
319    #[test]
320    fn test_parse_empty_array_field_error() {
321        let err = ParsedPath::parse("[].name").unwrap_err();
322        assert!(matches!(err, PathParseError::EmptySegment { .. }));
323    }
324
325    // === Null marker parsing tests ===
326
327    #[test]
328    fn test_parse_nullable_field() {
329        let path = ParsedPath::parse("object?.address?").unwrap();
330        assert_eq!(path.segments.len(), 2);
331        assert!(path.segments[0].is_nullable);
332        assert!(!path.segments[0].is_list());
333        assert!(path.segments[1].is_nullable);
334        assert!(!path.segments[1].is_list());
335    }
336
337    #[test]
338    fn test_parse_nullable_array() {
339        // ?[] → array is nullable, elements are not
340        let path = ParsedPath::parse("items?[].name").unwrap();
341        assert!(path.segments[0].is_nullable);
342        assert!(path.segments[0].is_list());
343        assert!(!path.segments[0].list.as_ref().unwrap().elements_nullable);
344    }
345
346    #[test]
347    fn test_parse_elements_nullable() {
348        // []? → array is required, elements are nullable
349        let path = ParsedPath::parse("items[]?.name").unwrap();
350        assert!(!path.segments[0].is_nullable);
351        assert!(path.segments[0].is_list());
352        assert!(path.segments[0].list.as_ref().unwrap().elements_nullable);
353    }
354
355    #[test]
356    fn test_parse_both_nullable() {
357        // ?[]? → array is nullable, elements are nullable
358        let path = ParsedPath::parse("items?[]?.name").unwrap();
359        assert!(path.segments[0].is_nullable);
360        assert!(path.segments[0].is_list());
361        assert!(path.segments[0].list.as_ref().unwrap().elements_nullable);
362    }
363
364    #[test]
365    fn test_parse_nullable_with_alias() {
366        let path = ParsedPath::parse("myAddr:address?").unwrap();
367        assert_eq!(path.segments[0].field, "address");
368        assert_eq!(path.segments[0].alias, Some("myAddr"));
369        assert!(path.segments[0].is_nullable);
370    }
371
372    #[test]
373    fn test_parse_nullable_array_with_alias() {
374        let path = ParsedPath::parse("myItems:items?[]?.name").unwrap();
375        assert_eq!(path.segments[0].field, "items");
376        assert_eq!(path.segments[0].alias, Some("myItems"));
377        assert!(path.segments[0].is_nullable);
378        assert!(path.segments[0].list.as_ref().unwrap().elements_nullable);
379    }
380
381    #[test]
382    fn test_parse_mixed_nullable_path() {
383        let path = ParsedPath::parse("epoch?.checkpoints?.nodes?[].digest").unwrap();
384        assert_eq!(path.segments.len(), 4);
385        assert!(path.segments[0].is_nullable);
386        assert!(!path.segments[0].is_list());
387        assert!(path.segments[1].is_nullable);
388        assert!(!path.segments[1].is_list());
389        assert!(path.segments[2].is_nullable);
390        assert!(path.segments[2].is_list());
391        assert!(!path.segments[3].is_nullable);
392        assert!(!path.segments[3].is_list());
393    }
394
395    #[test]
396    fn test_parse_invalid_suffix_error() {
397        let err = ParsedPath::parse("foo[?].name").unwrap_err();
398        assert!(matches!(err, PathParseError::InvalidSuffix { .. }));
399    }
400}