Skip to content

Commit 5e359a3

Browse files
committed
sui-graphql-rust-sdk: add chain info methods with alias support [8/n]
1 parent 3274457 commit 5e359a3

File tree

9 files changed

+519
-44
lines changed

9 files changed

+519
-44
lines changed

crates/sui-graphql-macros/src/lib.rs

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -158,29 +158,57 @@ fn derive_query_response_impl(input: DeriveInput) -> Result<TokenStream2, syn::E
158158

159159
/// A segment in a field path.
160160
///
161-
/// Paths like `"data.nodes[].name"` are parsed into segments:
162-
/// - `PathSegment { field: "data", is_array: false }`
163-
/// - `PathSegment { field: "nodes", is_array: true }`
164-
/// - `PathSegment { field: "name", is_array: false }`
161+
/// Segments can include an alias using `@` syntax for GraphQL aliases:
162+
/// - The field name (before `@`) is used for schema validation
163+
/// - The alias (after `@`) is used for JSON extraction
164+
///
165+
/// Examples:
166+
/// - `"data.nodes[].name"` parses to:
167+
/// - `{ field: "data", alias: None, is_array: false }`
168+
/// - `{ field: "nodes", alias: None, is_array: true }`
169+
/// - `{ field: "name", alias: None, is_array: false }`
170+
///
171+
/// - `"[email protected][].sequenceNumber"` parses to:
172+
/// - `{ field: "epoch", alias: None, is_array: false }`
173+
/// - `{ field: "checkpoints", alias: Some("firstCheckpoints"), is_array: false }`
174+
/// - `{ field: "nodes", alias: None, is_array: true }`
175+
/// - `{ field: "sequenceNumber", alias: None, is_array: false }`
165176
struct PathSegment<'a> {
166-
/// The field name to access
177+
/// The field name (used for schema validation)
167178
field: &'a str,
179+
/// Optional alias (used for JSON extraction instead of field name)
180+
alias: Option<&'a str>,
168181
/// Whether this is an array field (ends with `[]`)
169182
is_array: bool,
170183
}
171184

172185
/// Parse a path string into segments.
173186
///
174-
/// Each dot-separated part becomes a `PathSegment`. If it ends with `[]`, it's an array field.
187+
/// Each dot-separated part is parsed for:
188+
/// - Array suffix `[]` (e.g., `nodes[]`)
189+
/// - Alias syntax `@` (e.g., `checkpoints@firstCheckpoints`)
175190
fn parse_path(path: &str) -> Vec<PathSegment<'_>> {
176191
path.split('.')
177192
.map(|segment| {
178-
let (field, is_array) = if let Some(stripped) = segment.strip_suffix("[]") {
193+
// Check for array suffix first
194+
let (segment, is_array) = if let Some(stripped) = segment.strip_suffix("[]") {
179195
(stripped, true)
180196
} else {
181197
(segment, false)
182198
};
183-
PathSegment { field, is_array }
199+
200+
// Check for alias syntax: field@alias
201+
let (field, alias) = if let Some(at_pos) = segment.find('@') {
202+
(&segment[..at_pos], Some(&segment[at_pos + 1..]))
203+
} else {
204+
(segment, None)
205+
};
206+
207+
PathSegment {
208+
field,
209+
alias,
210+
is_array,
211+
}
184212
})
185213
.collect()
186214
}
@@ -205,6 +233,7 @@ fn generate_field_extraction(path: &str, field_ident: &syn::Ident) -> TokenStrea
205233

206234
/// Recursively generate extraction code by traversing path segments.
207235
///
236+
/// For JSON extraction, uses the alias if present, otherwise uses the field name.
208237
/// Returns code that evaluates to `Result<T, String>` (caller adds `?` to unwrap).
209238
///
210239
/// ## Example: Simple path `"object.address"`
@@ -242,20 +271,23 @@ fn generate_from_segments(full_path: &str, segments: &[PathSegment<'_>]) -> Toke
242271
};
243272
}
244273

245-
let segment = &segments[0];
246-
let name = segment.field;
247274
let rest = generate_from_segments(full_path, &segments[1..]);
275+
let segment = &segments[0];
276+
277+
// Use alias for JSON extraction if present, otherwise use field name
278+
let json_key = segment.alias.unwrap_or(segment.field);
248279

249280
if segment.is_array {
281+
// Array field: check for null, then iterate and collect into Option<Vec>
250282
quote! {
251283
{
252-
let field_value = current.get(#name)
253-
.ok_or_else(|| format!("missing field '{}' in path '{}'", #name, #full_path))?;
284+
let field_value = current.get(#json_key)
285+
.ok_or_else(|| format!("missing field '{}' in path '{}'", #json_key, #full_path))?;
254286
if field_value.is_null() {
255287
Ok(None)
256288
} else {
257289
let array = field_value.as_array()
258-
.ok_or_else(|| format!("expected array at '{}' in path '{}'", #name, #full_path))?;
290+
.ok_or_else(|| format!("expected array at '{}' in path '{}'", #json_key, #full_path))?;
259291
array.iter()
260292
.map(|current| { #rest })
261293
.collect::<Result<Vec<_>, String>>()
@@ -265,17 +297,15 @@ fn generate_from_segments(full_path: &str, segments: &[PathSegment<'_>]) -> Toke
265297
}
266298
} else {
267299
quote! {
268-
{
269-
let current = current.get(#name)
270-
.ok_or_else(|| format!("missing field '{}' in path '{}'", #name, #full_path))?;
271-
// If null, skip remaining navigation and let serde handle it
272-
// (returns Ok(None) for Option<T>, error for non-Option)
273-
if current.is_null() {
274-
serde_json::from_value(current.clone())
275-
.map_err(|e| format!("failed to deserialize '{}': {}", #full_path, e))
276-
} else {
277-
#rest
278-
}
300+
let current = current.get(#json_key)
301+
.ok_or_else(|| format!("missing field '{}' in path '{}'", #json_key, #full_path))?;
302+
// If null, skip remaining navigation and let serde handle it
303+
// (returns Ok(None) for Option<T>, error for non-Option)
304+
if current.is_null() {
305+
serde_json::from_value(current.clone())
306+
.map_err(|e| format!("failed to deserialize '{}': {}", #full_path, e))
307+
} else {
308+
#rest
279309
}
280310
}
281311
}

crates/sui-graphql-macros/src/validation.rs

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ use crate::schema::Schema;
1212
/// - Validates that `objects` is a list type
1313
/// - Validates fields after `[]` against the list element type
1414
///
15+
/// For aliased paths like `"[email protected][]"`:
16+
/// - Strips the alias (after `@`) and validates using the real field name
17+
/// - The alias is only used for JSON extraction, not schema validation
18+
///
1519
/// Returns the GraphQL type name of the final field.
1620
pub fn validate_path(schema: &Schema, path: &str, span: &syn::Ident) -> Result<String, syn::Error> {
1721
if path.is_empty() {
@@ -23,13 +27,26 @@ pub fn validate_path(schema: &Schema, path: &str, span: &syn::Ident) -> Result<S
2327
let mut current_type = "Query".to_string();
2428

2529
for (i, segment) in segments.iter().enumerate() {
26-
// Handle array iteration: "field[]"
27-
if let Some(field_name) = segment.strip_suffix("[]") {
28-
// Look up the field
29-
let field = schema.get_field(&current_type, field_name).ok_or_else(|| {
30-
field_not_found_error(schema, &current_type, field_name, path, span)
31-
})?;
30+
// Handle array iteration: "field[]" or "field@alias[]"
31+
let (segment, is_array) = if let Some(stripped) = segment.strip_suffix("[]") {
32+
(stripped, true)
33+
} else {
34+
(*segment, false)
35+
};
36+
37+
// Strip alias if present: "field@alias" -> "field"
38+
let field_name = if let Some(at_pos) = segment.find('@') {
39+
&segment[..at_pos]
40+
} else {
41+
segment
42+
};
43+
44+
// Look up the field
45+
let field = schema
46+
.get_field(&current_type, field_name)
47+
.ok_or_else(|| field_not_found_error(schema, &current_type, field_name, path, span))?;
3248

49+
if is_array {
3350
// Verify it's a list type
3451
if !field.is_list {
3552
return Err(syn::Error::new_spanned(
@@ -40,22 +57,14 @@ pub fn validate_path(schema: &Schema, path: &str, span: &syn::Ident) -> Result<S
4057
),
4158
));
4259
}
60+
}
4361

44-
// Continue with the element type
45-
current_type = field.type_name.clone();
46-
} else {
47-
// Regular field access
48-
let field = schema
49-
.get_field(&current_type, segment)
50-
.ok_or_else(|| field_not_found_error(schema, &current_type, segment, path, span))?;
51-
52-
// If this is the last segment, return the type
53-
if i == segments.len() - 1 {
54-
return Ok(field.type_name.clone());
55-
}
56-
57-
current_type = field.type_name.clone();
62+
// If this is the last segment, return the type
63+
if i == segments.len() - 1 {
64+
return Ok(field.type_name.clone());
5865
}
66+
67+
current_type = field.type_name.clone();
5968
}
6069

6170
// Should not reach here since we return in the loop for the last segment
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
use sui_graphql_macros::Response;
2+
3+
/// Test that alias syntax validates the real field name (before @) against the schema.
4+
/// "nonExistentField" doesn't exist on Epoch type, so this should fail.
5+
#[derive(Response)]
6+
struct InvalidAlias {
7+
#[field(path = "[email protected][].sequenceNumber")]
8+
data: Option<Vec<u64>>,
9+
}
10+
11+
fn main() {}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
error: Field 'nonExistentField' not found on type 'Epoch' in path '[email protected][].sequenceNumber'. Available fields: checkpoints, coinDenyList, endTimestamp, epochId, fundInflow, fundOutflow, fundSize, liveObjectSetDigest, netInflow, protocolConfigs, referenceGasPrice, safeMode, startTimestamp, storageFund, systemPackages, systemParameters, systemStakeSubsidy, systemStateVersion, totalCheckpoints, totalGasFees, totalStakeRewards, totalStakeSubsidies, totalTransactions, transactions, validatorSet
2+
--> tests/compile/schema_validation_alias_invalid_field.rs:8:5
3+
|
4+
8 | data: Option<Vec<u64>>,
5+
| ^^^^
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
use sui_graphql_macros::Response;
2+
3+
/// Test that alias syntax `field@alias` works correctly.
4+
/// The field name (before @) is validated against the schema,
5+
/// while the alias (after @) is used for JSON extraction.
6+
#[derive(Response)]
7+
struct EpochCheckpoints {
8+
// "checkpoints" is the real field name validated against schema
9+
// "firstCheckpoint" is the alias used for JSON extraction
10+
#[field(path = "[email protected][].sequenceNumber")]
11+
first_checkpoint_seq: Option<Vec<u64>>,
12+
}
13+
14+
fn main() {
15+
let json = serde_json::json!({
16+
"epoch": {
17+
"firstCheckpoint": {
18+
"nodes": [
19+
{ "sequenceNumber": 1000 },
20+
{ "sequenceNumber": 1001 }
21+
]
22+
}
23+
}
24+
});
25+
26+
let data = EpochCheckpoints::from_value(json).unwrap();
27+
assert_eq!(data.first_checkpoint_seq, Some(vec![1000, 1001]));
28+
}

crates/sui-graphql-macros/tests/compile_tests.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ fn compile_tests() {
55
// Tests that should pass
66
t.pass("tests/compile/basic_extraction.rs");
77
t.pass("tests/compile/skip_validation.rs");
8+
t.pass("tests/compile/schema_validation_with_alias.rs");
89

910
// Tests that should fail with expected errors
1011
t.compile_fail("tests/compile/missing_field_path_attr.rs");
@@ -20,4 +21,5 @@ fn compile_tests() {
2021
t.compile_fail("tests/compile/schema_validation_field_after_list_suggestion.rs");
2122
t.compile_fail("tests/compile/schema_validation_array_on_non_list.rs");
2223
t.compile_fail("tests/compile/schema_validation_field_after_list_not_found.rs");
24+
t.compile_fail("tests/compile/schema_validation_alias_invalid_field.rs");
2325
}

0 commit comments

Comments
 (0)