Skip to main content

sui_graphql/client/
dynamic_fields.rs

1//! Dynamic field related convenience methods.
2
3use futures::Stream;
4use serde::Serialize;
5use sui_graphql_macros::Response;
6use sui_graphql_macros::graphql_query;
7use sui_sdk_types::Address;
8use sui_sdk_types::TypeTag;
9
10use super::Client;
11use crate::bcs::Bcs;
12use crate::error::Error;
13use crate::move_value::MoveObject;
14use crate::move_value::MoveValue;
15use crate::pagination::Page;
16use crate::pagination::PageInfo;
17use crate::pagination::paginate;
18
19/// The format to fetch for Move values.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Format {
22    /// JSON representation.
23    Json,
24    /// BCS (Binary Canonical Serialization) representation.
25    Bcs,
26}
27
28// ============================================================================
29// Request builders
30// ============================================================================
31
32/// Builder for listing dynamic fields on an object.
33pub struct DynamicFieldsRequest<'a> {
34    client: &'a Client,
35    parent: Address,
36    formats: Vec<Format>,
37}
38
39impl<'a> DynamicFieldsRequest<'a> {
40    /// Add a format to fetch. Can be called multiple times.
41    /// If not called, defaults to BCS.
42    pub fn format(mut self, f: Format) -> Self {
43        if !self.formats.contains(&f) {
44            self.formats.push(f);
45        }
46        self
47    }
48
49    /// Execute the request and return a stream of dynamic fields.
50    pub fn list(self) -> impl Stream<Item = Result<DynamicField, Error>> + 'a {
51        let client = self.client.clone();
52        let formats = self.formats;
53        let parent = self.parent;
54
55        paginate(move |cursor| {
56            let client = client.clone();
57            let formats = formats.clone();
58            async move {
59                client
60                    .fetch_dynamic_fields_page_with_formats(parent, cursor.as_deref(), &formats)
61                    .await
62            }
63        })
64    }
65}
66
67/// Builder for fetching a single dynamic field by name.
68pub struct DynamicFieldRequest<'a, N> {
69    client: &'a Client,
70    parent: Address,
71    name_type: TypeTag,
72    name: Bcs<N>,
73    field_type: DynamicFieldType,
74    formats: Vec<Format>,
75}
76
77impl<'a, N: Serialize> DynamicFieldRequest<'a, N> {
78    /// Add a format to fetch. Can be called multiple times.
79    /// If not called, defaults to BCS.
80    pub fn format(mut self, f: Format) -> Self {
81        if !self.formats.contains(&f) {
82            self.formats.push(f);
83        }
84        self
85    }
86
87    /// Execute the request and return the dynamic field if found.
88    pub async fn get(self) -> Result<Option<DynamicField>, Error> {
89        self.client
90            .fetch_single_dynamic_field(
91                self.parent,
92                self.name_type,
93                self.name,
94                self.field_type,
95                &self.formats,
96            )
97            .await
98    }
99}
100
101// ============================================================================
102// Dynamic field types
103// ============================================================================
104
105/// The type of a dynamic field.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107enum DynamicFieldType {
108    /// A regular dynamic field (value is wrapped, not accessible by ID).
109    Field,
110    /// A dynamic object field (child object remains accessible by ID).
111    Object,
112}
113
114/// The value of a dynamic field, dispatched by `__typename`.
115#[derive(Debug, Clone, Response)]
116#[response(root_type = "DynamicFieldValue")]
117pub enum DynamicFieldValue {
118    MoveValue(MoveValue),
119    MoveObject(MoveObject),
120}
121
122/// A dynamic field entry with its name and value.
123#[derive(Debug, Clone, Response)]
124#[response(root_type = "DynamicField")]
125#[non_exhaustive]
126pub struct DynamicField {
127    /// The field name (includes type_tag and optional json/bcs).
128    #[field(path = "name")]
129    pub name: MoveValue,
130    /// The field value (includes field_type and the underlying MoveValue).
131    #[field(path = "value")]
132    pub value: DynamicFieldValue,
133}
134
135impl Client {
136    /// Create a request builder for listing dynamic fields on an object.
137    pub fn dynamic_fields(&self, parent: Address) -> DynamicFieldsRequest<'_> {
138        DynamicFieldsRequest {
139            client: self,
140            parent,
141            formats: vec![],
142        }
143    }
144
145    /// Create a request builder for fetching a single dynamic field by name.
146    pub fn dynamic_field<N: Serialize>(
147        &self,
148        parent: Address,
149        name_type: TypeTag,
150        name: Bcs<N>,
151    ) -> DynamicFieldRequest<'_, N> {
152        DynamicFieldRequest {
153            client: self,
154            parent,
155            name_type,
156            name,
157            field_type: DynamicFieldType::Field,
158            formats: vec![],
159        }
160    }
161
162    /// Create a request builder for fetching a single dynamic object field by name.
163    pub fn dynamic_object_field<N: Serialize>(
164        &self,
165        parent: Address,
166        name_type: TypeTag,
167        name: Bcs<N>,
168    ) -> DynamicFieldRequest<'_, N> {
169        DynamicFieldRequest {
170            client: self,
171            parent,
172            name_type,
173            name,
174            field_type: DynamicFieldType::Object,
175            formats: vec![],
176        }
177    }
178
179    /// Fetch a page of dynamic fields with format selection.
180    async fn fetch_dynamic_fields_page_with_formats(
181        &self,
182        parent: Address,
183        cursor: Option<&str>,
184        formats: &[Format],
185    ) -> Result<Page<DynamicField>, Error> {
186        #[derive(Response)]
187        struct Response {
188            #[field(path = "object?.dynamicFields?.nodes?[]")]
189            nodes: Option<Vec<DynamicField>>,
190            #[field(path = "object?.dynamicFields?.pageInfo?")]
191            page_info: Option<PageInfo>,
192        }
193
194        const QUERY: &str = graphql_query!(
195            "fragment MoveValueFields on MoveValue {
196                type { repr }
197                json @include(if: $withJson)
198                bcs @include(if: $withBcs)
199            }
200            query($parent: SuiAddress!, $cursor: String, $withJson: Boolean!, $withBcs: Boolean!) {
201                object(address: $parent) {
202                    dynamicFields(after: $cursor) {
203                        nodes {
204                            name { ...MoveValueFields }
205                            value {
206                                __typename
207                                ... on MoveValue { ...MoveValueFields }
208                                ... on MoveObject {
209                                    address
210                                    contents { ...MoveValueFields }
211                                }
212                            }
213                        }
214                        pageInfo {
215                            hasNextPage
216                            endCursor
217                        }
218                    }
219                }
220            }"
221        );
222
223        let with_json = formats.contains(&Format::Json);
224        let with_bcs = formats.is_empty() || formats.contains(&Format::Bcs);
225        let variables = serde_json::json!({
226            "parent": parent,
227            "cursor": cursor,
228            "withJson": with_json,
229            "withBcs": with_bcs,
230        });
231
232        let response = self.query::<Response>(QUERY, variables).await?;
233
234        let Some(data) = response.into_data() else {
235            return Ok(Page::default());
236        };
237
238        let page_info = data.page_info.unwrap_or_default();
239
240        Ok(Page {
241            items: data.nodes.unwrap_or_default(),
242            has_next_page: page_info.has_next_page,
243            end_cursor: page_info.end_cursor,
244            ..Default::default()
245        })
246    }
247
248    /// Fetch a single dynamic field with format selection.
249    async fn fetch_single_dynamic_field<N: Serialize>(
250        &self,
251        parent: Address,
252        name_type: TypeTag,
253        name: Bcs<N>,
254        field_type: DynamicFieldType,
255        formats: &[Format],
256    ) -> Result<Option<DynamicField>, Error> {
257        #[derive(Response)]
258        struct DynamicFieldResponse {
259            #[field(path = "object?.dynamicField?")]
260            field: Option<DynamicField>,
261        }
262
263        #[derive(Response)]
264        struct DynamicObjectFieldResponse {
265            #[field(path = "object?.dynamicObjectField?")]
266            field: Option<DynamicField>,
267        }
268
269        const DYNAMIC_FIELD_QUERY: &str = graphql_query!(
270            "fragment MoveValueFields on MoveValue {
271                type { repr }
272                json @include(if: $withJson)
273                bcs @include(if: $withBcs)
274            }
275            query($parent: SuiAddress!, $name: DynamicFieldName!, $withJson: Boolean!, $withBcs: Boolean!) {
276                object(address: $parent) {
277                    dynamicField(name: $name) {
278                        name { ...MoveValueFields }
279                        value {
280                            ... on MoveValue { ...MoveValueFields }
281                            ... on MoveObject {
282                                contents { ...MoveValueFields }
283                            }
284                        }
285                    }
286                }
287            }"
288        );
289
290        const DYNAMIC_OBJECT_FIELD_QUERY: &str = graphql_query!(
291            "fragment MoveValueFields on MoveValue {
292                type { repr }
293                json @include(if: $withJson)
294                bcs @include(if: $withBcs)
295            }
296            query($parent: SuiAddress!, $name: DynamicFieldName!, $withJson: Boolean!, $withBcs: Boolean!) {
297                object(address: $parent) {
298                    dynamicObjectField(name: $name) {
299                        name { ...MoveValueFields }
300                        value {
301                            ... on MoveValue { ...MoveValueFields }
302                            ... on MoveObject {
303                                contents { ...MoveValueFields }
304                            }
305                        }
306                    }
307                }
308            }"
309        );
310
311        let with_json = formats.contains(&Format::Json);
312        let with_bcs = formats.is_empty() || formats.contains(&Format::Bcs);
313        let variables = serde_json::json!({
314            "parent": parent,
315            "name": {
316                "type": name_type.to_string(),
317                "bcs": name,
318            },
319            "withJson": with_json,
320            "withBcs": with_bcs,
321        });
322
323        match field_type {
324            DynamicFieldType::Field => {
325                let response = self
326                    .query::<DynamicFieldResponse>(DYNAMIC_FIELD_QUERY, variables)
327                    .await?;
328                Ok(response.into_data().and_then(|d| d.field))
329            }
330            DynamicFieldType::Object => {
331                let response = self
332                    .query::<DynamicObjectFieldResponse>(DYNAMIC_OBJECT_FIELD_QUERY, variables)
333                    .await?;
334                Ok(response.into_data().and_then(|d| d.field))
335            }
336        }
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use futures::StreamExt;
344    use std::pin::pin;
345    use sui_sdk_types::TypeTag;
346    use wiremock::Mock;
347    use wiremock::MockServer;
348    use wiremock::ResponseTemplate;
349    use wiremock::matchers::method;
350    use wiremock::matchers::path;
351
352    #[tokio::test]
353    async fn test_dynamic_fields_empty() {
354        let mock_server = MockServer::start().await;
355
356        Mock::given(method("POST"))
357            .and(path("/"))
358            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
359                "data": {
360                    "object": {
361                        "dynamicFields": {
362                            "nodes": [],
363                            "pageInfo": {
364                                "hasNextPage": false,
365                                "endCursor": null
366                            }
367                        }
368                    }
369                }
370            })))
371            .mount(&mock_server)
372            .await;
373
374        let client = Client::new(&mock_server.uri()).unwrap();
375
376        let parent: Address = "0x123".parse().unwrap();
377        let mut stream = pin!(client.dynamic_fields(parent).list());
378        let result = stream.next().await;
379        assert!(result.is_none());
380    }
381
382    #[tokio::test]
383    async fn test_dynamic_fields_with_json_format() {
384        let mock_server = MockServer::start().await;
385
386        Mock::given(method("POST"))
387            .and(path("/"))
388            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
389                "data": {
390                    "object": {
391                        "dynamicFields": {
392                            "nodes": [
393                                {
394                                    "name": {
395                                        "type": { "repr": "u64" },
396                                        "json": "123"
397                                    },
398                                    "value": {
399                                        "__typename": "MoveValue",
400                                        "type": { "repr": "0x2::coin::Coin<0x2::sui::SUI>" },
401                                        "json": { "balance": "1000" }
402                                    }
403                                },
404                                {
405                                    "name": {
406                                        "type": { "repr": "0x2::kiosk::Listing" },
407                                        "json": { "id": "0xabc" }
408                                    },
409                                    "value": {
410                                        "__typename": "MoveObject",
411                                        "address": "0x0000000000000000000000000000000000000000000000000000000000000def",
412                                        "contents": {
413                                            "type": { "repr": "0x2::kiosk::Item" },
414                                            "json": { "price": "500" }
415                                        }
416                                    }
417                                }
418                            ],
419                            "pageInfo": {
420                                "hasNextPage": false,
421                                "endCursor": null
422                            }
423                        }
424                    }
425                }
426            })))
427            .mount(&mock_server)
428            .await;
429
430        let client = Client::new(&mock_server.uri()).unwrap();
431
432        let parent: Address = "0x123".parse().unwrap();
433        let mut stream = pin!(client.dynamic_fields(parent).format(Format::Json).list());
434
435        // First field - MoveValue
436        let field1 = stream.next().await.unwrap().unwrap();
437        assert_eq!(field1.name.type_tag, TypeTag::U64);
438        assert!(field1.name.json.is_some());
439        assert!(field1.name.bcs.is_none()); // BCS not requested
440        assert!(matches!(field1.value, DynamicFieldValue::MoveValue(_)));
441
442        // Second field - MoveObject
443        let field2 = stream.next().await.unwrap().unwrap();
444        assert_eq!(
445            field2.name.type_tag,
446            "0x2::kiosk::Listing".parse::<TypeTag>().unwrap()
447        );
448        assert!(matches!(field2.value, DynamicFieldValue::MoveObject(_)));
449
450        // No more fields
451        assert!(stream.next().await.is_none());
452    }
453
454    #[tokio::test]
455    async fn test_dynamic_fields_with_default_bcs() {
456        let mock_server = MockServer::start().await;
457
458        Mock::given(method("POST"))
459            .and(path("/"))
460            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
461                "data": {
462                    "object": {
463                        "dynamicFields": {
464                            "nodes": [
465                                {
466                                    "name": {
467                                        "type": { "repr": "u64" },
468                                        "bcs": "ewAAAAAAAAA="
469                                    },
470                                    "value": {
471                                        "__typename": "MoveValue",
472                                        "type": { "repr": "bool" },
473                                        "bcs": "AQ=="
474                                    }
475                                }
476                            ],
477                            "pageInfo": {
478                                "hasNextPage": false,
479                                "endCursor": null
480                            }
481                        }
482                    }
483                }
484            })))
485            .mount(&mock_server)
486            .await;
487
488        let client = Client::new(&mock_server.uri()).unwrap();
489
490        let parent: Address = "0x123".parse().unwrap();
491        // Default - no format specified
492        let mut stream = pin!(client.dynamic_fields(parent).list());
493
494        let field = stream.next().await.unwrap().unwrap();
495        assert_eq!(field.name.type_tag, TypeTag::U64);
496        assert!(field.name.bcs.is_some());
497        assert!(field.name.json.is_none()); // JSON not requested
498    }
499
500    #[tokio::test]
501    async fn test_dynamic_fields_object_not_found() {
502        let mock_server = MockServer::start().await;
503
504        Mock::given(method("POST"))
505            .and(path("/"))
506            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
507                "data": {
508                    "object": null
509                }
510            })))
511            .mount(&mock_server)
512            .await;
513
514        let client = Client::new(&mock_server.uri()).unwrap();
515
516        let parent: Address = "0x999".parse().unwrap();
517        let mut stream = pin!(client.dynamic_fields(parent).list());
518        let result = stream.next().await;
519        assert!(result.is_none());
520    }
521
522    #[tokio::test]
523    async fn test_dynamic_field_fetch() {
524        let mock_server = MockServer::start().await;
525
526        Mock::given(method("POST"))
527            .and(path("/"))
528            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
529                "data": {
530                    "object": {
531                        "dynamicField": {
532                            "name": {
533                                "type": { "repr": "u64" },
534                                "json": "123",
535                                "bcs": "ewAAAAAAAAA="
536                            },
537                            "value": {
538                                "__typename": "MoveValue",
539                                "type": { "repr": "bool" },
540                                "json": true,
541                                "bcs": "AQ=="
542                            }
543                        }
544                    }
545                }
546            })))
547            .mount(&mock_server)
548            .await;
549
550        let client = Client::new(&mock_server.uri()).unwrap();
551        let parent: Address = "0x123".parse().unwrap();
552        let name_type: TypeTag = "u64".parse().unwrap();
553
554        let field = client
555            .dynamic_field(parent, name_type, Bcs(123u64))
556            .format(Format::Json)
557            .format(Format::Bcs)
558            .get()
559            .await
560            .unwrap();
561
562        assert!(field.is_some());
563        let field = field.unwrap();
564        assert_eq!(field.name.type_tag, TypeTag::U64);
565        assert!(field.name.json.is_some());
566        assert!(field.name.bcs.is_some());
567        assert!(matches!(field.value, DynamicFieldValue::MoveValue(_)));
568    }
569
570    #[tokio::test]
571    async fn test_dynamic_field_object_type() {
572        let mock_server = MockServer::start().await;
573
574        Mock::given(method("POST"))
575            .and(path("/"))
576            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
577                "data": {
578                    "object": {
579                        "dynamicObjectField": {
580                            "name": {
581                                "type": { "repr": "0x1::string::String" },
582                                "json": "my_key"
583                            },
584                            "value": {
585                                "__typename": "MoveObject",
586                                "address": "0x0000000000000000000000000000000000000000000000000000000000000abc",
587                                "contents": {
588                                    "type": { "repr": "0x2::coin::Coin<0x2::sui::SUI>" },
589                                    "json": { "balance": "1000" }
590                                }
591                            }
592                        }
593                    }
594                }
595            })))
596            .mount(&mock_server)
597            .await;
598
599        let client = Client::new(&mock_server.uri()).unwrap();
600        let parent: Address = "0x123".parse().unwrap();
601        let name_type: TypeTag = "0x1::string::String".parse().unwrap();
602
603        let field = client
604            .dynamic_object_field(parent, name_type, Bcs("my_key"))
605            .format(Format::Json)
606            .get()
607            .await
608            .unwrap();
609
610        assert!(field.is_some());
611        let field = field.unwrap();
612        let DynamicFieldValue::MoveObject(ref obj) = field.value else {
613            panic!("expected MoveObject variant");
614        };
615        assert_eq!(
616            obj.contents.type_tag,
617            "0x2::coin::Coin<0x2::sui::SUI>".parse::<TypeTag>().unwrap()
618        );
619    }
620
621    #[tokio::test]
622    async fn test_dynamic_field_not_found() {
623        let mock_server = MockServer::start().await;
624
625        Mock::given(method("POST"))
626            .and(path("/"))
627            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
628                "data": {
629                    "object": {
630                        "dynamicField": null
631                    }
632                }
633            })))
634            .mount(&mock_server)
635            .await;
636
637        let client = Client::new(&mock_server.uri()).unwrap();
638        let parent: Address = "0x123".parse().unwrap();
639        let name_type: TypeTag = "u64".parse().unwrap();
640
641        let field = client
642            .dynamic_field(parent, name_type, Bcs(999u64))
643            .get()
644            .await
645            .unwrap();
646
647        assert!(field.is_none());
648    }
649}