Skip to main content

iota_sdk_graphql_client/
streams.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2025 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5use std::{
6    future::Future,
7    pin::Pin,
8    task::{Context, Poll},
9};
10
11use futures::Stream;
12
13use crate::{
14    error,
15    pagination::{Direction, Page, PaginationFilter},
16    query_types::PageInfo,
17};
18
19/// A stream that yields items from a paginated query with support for
20/// bidirectional pagination.
21pub struct PageStream<T, F, Fut> {
22    query_fn: F,
23    direction: Direction,
24    limit: Option<i32>,
25    start_cursor: Option<String>,
26    current_page: Option<(PageInfo, std::vec::IntoIter<T>)>,
27    current_future: Option<Pin<Box<Fut>>>,
28    finished: bool,
29}
30
31impl<T, F, Fut> PageStream<T, F, Fut> {
32    pub fn new(query_fn: F, pagination: PaginationFilter) -> Self {
33        Self {
34            query_fn,
35            direction: pagination.direction,
36            limit: pagination.limit,
37            start_cursor: pagination.cursor,
38            current_page: None,
39            current_future: None,
40            finished: false,
41        }
42    }
43}
44
45impl<T, F, Fut> Stream for PageStream<T, F, Fut>
46where
47    T: Clone + Unpin,
48    F: Fn(PaginationFilter) -> Fut,
49    F: Unpin,
50    Fut: Future<Output = Result<Page<T>, error::GraphQLError>>,
51{
52    type Item = Result<T, error::GraphQLError>;
53
54    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
55        if self.finished {
56            return Poll::Ready(None);
57        }
58
59        loop {
60            let direction = self.direction.clone();
61            // If we have a current page, return the next item
62            if let Some((page_info, iter)) = &mut self.current_page {
63                if let Some(item) = iter.next() {
64                    return Poll::Ready(Some(Ok(item)));
65                }
66
67                let should_continue = match direction {
68                    Direction::Forward => page_info.has_next_page,
69                    Direction::Backward => page_info.has_previous_page,
70                };
71                if !should_continue {
72                    self.finished = true;
73                    return Poll::Ready(None);
74                }
75            }
76
77            // Get cursor from current page
78            let current_cursor = self
79                .current_page
80                .as_ref()
81                .and_then(|(page_info, _iter)| match self.direction {
82                    Direction::Forward => page_info
83                        .has_next_page
84                        .then(|| page_info.end_cursor.clone()),
85                    Direction::Backward => page_info
86                        .has_previous_page
87                        .then(|| page_info.start_cursor.clone()),
88                })
89                .flatten();
90
91            // If there's no future yet, create one
92            if self.current_future.is_none() {
93                let current_cursor = if self.current_page.is_none() {
94                    self.start_cursor.take()
95                } else {
96                    current_cursor
97                };
98                let filter = PaginationFilter {
99                    direction: self.direction.clone(),
100                    cursor: current_cursor,
101                    limit: self.limit,
102                };
103                let future = (self.query_fn)(filter);
104                self.current_future = Some(Box::pin(future));
105            }
106
107            // Poll the future
108            match self.current_future.as_mut().unwrap().as_mut().poll(cx) {
109                Poll::Ready(Ok(page)) => {
110                    self.current_future = None;
111
112                    if page.is_empty() {
113                        self.finished = true;
114                        return Poll::Ready(None);
115                    }
116
117                    let (page_info, data) = page.into_parts();
118                    // For backward pagination, we need to reverse the items
119                    let iter = match self.direction {
120                        Direction::Forward => data.into_iter(),
121                        Direction::Backward => {
122                            let mut vec = data;
123                            vec.reverse();
124                            vec.into_iter()
125                        }
126                    };
127                    self.current_page = Some((page_info, iter));
128                }
129                Poll::Ready(Err(e)) => {
130                    self.finished = true;
131                    self.current_future = None;
132                    return Poll::Ready(Some(Err(e)));
133                }
134                Poll::Pending => return Poll::Pending,
135            }
136        }
137    }
138}
139
140/// Creates a new `PageStream` for a paginated query.
141pub fn stream_paginated_query<T, F, Fut>(
142    query_fn: F,
143    pagination: PaginationFilter,
144) -> PageStream<T, F, Fut>
145where
146    F: Fn(PaginationFilter) -> Fut,
147    Fut: Future<Output = Result<Page<T>, error::GraphQLError>>,
148{
149    PageStream::new(query_fn, pagination)
150}
151
152#[cfg(all(test, not(target_arch = "wasm32")))]
153mod tests {
154    use futures::StreamExt;
155    use iota_types::Address;
156
157    use super::*;
158    use crate::{
159        error::GraphQLResult,
160        test_utils::{
161            assert_backward_page, assert_forward_page, backward_page, forward_page, sent_variables,
162        },
163    };
164
165    macro_rules! first_request_of {
166        ($operation:expr, $query:expr) => {
167            first_request_of!($operation, $query, backward_page())
168        };
169        ($operation:expr, $query:expr, $page:expr) => {
170            sent_variables($operation, |client| async move {
171                let _ = $query(client).pagination($page).stream().next().await;
172            })
173            .await
174        };
175    }
176
177    fn page(items: Vec<i32>, has_more: bool, cursor: Option<&str>) -> Page<i32> {
178        Page::new(
179            PageInfo {
180                has_previous_page: has_more,
181                has_next_page: has_more,
182                start_cursor: cursor.map(|c| format!("{c}-backward")),
183                end_cursor: cursor.map(|c| format!("{c}-forward")),
184            },
185            items,
186        )
187    }
188
189    /// Stream `pages` in order and return the items and the requests made.
190    async fn run(
191        pagination: PaginationFilter,
192        pages: Vec<GraphQLResult<Page<i32>>>,
193    ) -> (Vec<GraphQLResult<i32>>, Vec<PaginationFilter>) {
194        let pages = std::sync::Mutex::new(pages.into_iter());
195        let requests = std::sync::Mutex::new(Vec::new());
196        let items = stream_paginated_query(
197            |filter: PaginationFilter| {
198                requests.lock().unwrap().push(filter);
199                let page = pages.lock().unwrap().next().expect("no page left");
200                async move { page }
201            },
202            pagination,
203        )
204        .collect()
205        .await;
206        (items, requests.into_inner().unwrap())
207    }
208
209    fn values(items: Vec<GraphQLResult<i32>>) -> Vec<i32> {
210        items.into_iter().map(Result::unwrap).collect()
211    }
212
213    #[tokio::test]
214    async fn forward_pages_continue_from_the_end_cursor_with_the_same_limit() {
215        let pagination = PaginationFilter {
216            direction: Direction::Forward,
217            cursor: Some("start".to_owned()),
218            limit: Some(3),
219        };
220        let (items, requests) = run(
221            pagination,
222            vec![
223                Ok(page(vec![1, 2], true, Some("second"))),
224                Ok(page(vec![3], false, None)),
225            ],
226        )
227        .await;
228
229        assert_eq!(values(items), [1, 2, 3]);
230        assert_eq!(requests.len(), 2);
231        assert_eq!(requests[0].cursor.as_deref(), Some("start"));
232        assert_eq!(requests[1].cursor.as_deref(), Some("second-forward"));
233        assert!(requests.iter().all(|r| r.limit == Some(3)));
234        assert!(
235            requests
236                .iter()
237                .all(|r| matches!(r.direction, Direction::Forward))
238        );
239    }
240
241    #[tokio::test]
242    async fn backward_pages_continue_from_the_start_cursor_newest_first() {
243        let pagination = PaginationFilter {
244            direction: Direction::Backward,
245            cursor: Some("start".to_owned()),
246            limit: Some(3),
247        };
248        let (items, requests) = run(
249            pagination,
250            vec![
251                Ok(page(vec![3, 4], true, Some("second"))),
252                Ok(page(vec![1, 2], false, None)),
253            ],
254        )
255        .await;
256
257        assert_eq!(values(items), [4, 3, 2, 1]);
258        assert_eq!(requests.len(), 2);
259        assert_eq!(requests[0].cursor.as_deref(), Some("start"));
260        assert_eq!(requests[1].cursor.as_deref(), Some("second-backward"));
261        assert!(requests.iter().all(|r| r.limit == Some(3)));
262        assert!(
263            requests
264                .iter()
265                .all(|r| matches!(r.direction, Direction::Backward))
266        );
267    }
268
269    #[tokio::test]
270    async fn an_empty_page_ends_the_stream() {
271        let (items, requests) = run(
272            PaginationFilter::default(),
273            vec![Ok(page(Vec::new(), true, Some("next")))],
274        )
275        .await;
276
277        assert!(items.is_empty());
278        assert_eq!(requests.len(), 1);
279    }
280
281    #[tokio::test]
282    async fn an_error_is_yielded_once_and_ends_the_stream() {
283        let (items, requests) = run(
284            PaginationFilter::default(),
285            vec![Err(crate::GraphQLError::Timeout)],
286        )
287        .await;
288
289        assert!(matches!(
290            items.as_slice(),
291            [Err(crate::GraphQLError::Timeout)]
292        ));
293        assert_eq!(requests.len(), 1);
294    }
295
296    #[tokio::test]
297    async fn every_list_query_streams_from_its_pagination() {
298        assert_backward_page(&first_request_of!(
299            "CheckpointsQueryFragment",
300            |c: crate::GraphQLClient| c.checkpoints()
301        ));
302        assert_forward_page(&first_request_of!(
303            "CheckpointsQueryFragment",
304            |c: crate::GraphQLClient| c.checkpoints(),
305            forward_page()
306        ));
307        assert_backward_page(&first_request_of!(
308            "ObjectsQueryFragment",
309            |c: crate::GraphQLClient| c.coins(Address::STD)
310        ));
311        assert_forward_page(&first_request_of!(
312            "ObjectsQueryFragment",
313            |c: crate::GraphQLClient| c.coins(Address::STD),
314            forward_page()
315        ));
316        assert_backward_page(&first_request_of!(
317            "ObjectsQueryFragment",
318            |c: crate::GraphQLClient| c.gas_coins(Address::STD)
319        ));
320        assert_forward_page(&first_request_of!(
321            "ObjectsQueryFragment",
322            |c: crate::GraphQLClient| c.gas_coins(Address::STD),
323            forward_page()
324        ));
325        assert_backward_page(&first_request_of!(
326            "DynamicFieldsOwnerQueryFragment",
327            |c: crate::GraphQLClient| c.dynamic_fields(Address::STD)
328        ));
329        assert_forward_page(&first_request_of!(
330            "DynamicFieldsOwnerQueryFragment",
331            |c: crate::GraphQLClient| c.dynamic_fields(Address::STD),
332            forward_page()
333        ));
334        assert_backward_page(&first_request_of!(
335            "EventsQueryFragment",
336            |c: crate::GraphQLClient| c.events()
337        ));
338        assert_forward_page(&first_request_of!(
339            "EventsQueryFragment",
340            |c: crate::GraphQLClient| c.events(),
341            forward_page()
342        ));
343        assert_backward_page(&first_request_of!(
344            "IotaNamesAddressRegistrationsQueryFragment",
345            |c: crate::GraphQLClient| c.iota_names_registrations(Address::STD)
346        ));
347        assert_forward_page(&first_request_of!(
348            "IotaNamesAddressRegistrationsQueryFragment",
349            |c: crate::GraphQLClient| c.iota_names_registrations(Address::STD),
350            forward_page()
351        ));
352        assert_backward_page(&first_request_of!(
353            "ActiveValidatorsQueryFragment",
354            |c: crate::GraphQLClient| c.active_validators()
355        ));
356        assert_forward_page(&first_request_of!(
357            "ActiveValidatorsQueryFragment",
358            |c: crate::GraphQLClient| c.active_validators(),
359            forward_page()
360        ));
361        assert_backward_page(&first_request_of!(
362            "ObjectsQueryFragment",
363            |c: crate::GraphQLClient| c.objects()
364        ));
365        assert_forward_page(&first_request_of!(
366            "ObjectsQueryFragment",
367            |c: crate::GraphQLClient| c.objects(),
368            forward_page()
369        ));
370        assert_backward_page(&first_request_of!(
371            "PackageVersionsQueryFragment",
372            |c: crate::GraphQLClient| c.package_versions(Address::FRAMEWORK)
373        ));
374        assert_forward_page(&first_request_of!(
375            "PackageVersionsQueryFragment",
376            |c: crate::GraphQLClient| c.package_versions(Address::FRAMEWORK),
377            forward_page()
378        ));
379        assert_backward_page(&first_request_of!(
380            "PackagesQueryFragment",
381            |c: crate::GraphQLClient| c.packages()
382        ));
383        assert_forward_page(&first_request_of!(
384            "PackagesQueryFragment",
385            |c: crate::GraphQLClient| c.packages(),
386            forward_page()
387        ));
388        assert_backward_page(&first_request_of!(
389            "TransactionBlocksQueryFragment",
390            |c: crate::GraphQLClient| c.transactions()
391        ));
392        assert_forward_page(&first_request_of!(
393            "TransactionBlocksQueryFragment",
394            |c: crate::GraphQLClient| c.transactions(),
395            forward_page()
396        ));
397        assert_backward_page(&first_request_of!(
398            "AddressTransactionsQueryFragment",
399            |c: crate::GraphQLClient| c.address_transactions(Address::STD)
400        ));
401        assert_forward_page(&first_request_of!(
402            "AddressTransactionsQueryFragment",
403            |c: crate::GraphQLClient| c.address_transactions(Address::STD),
404            forward_page()
405        ));
406        assert_backward_page(&first_request_of!(
407            "TransactionBlocksEffectsQueryFragment",
408            |c: crate::GraphQLClient| c.transactions_effects()
409        ));
410        assert_forward_page(&first_request_of!(
411            "TransactionBlocksEffectsQueryFragment",
412            |c: crate::GraphQLClient| c.transactions_effects(),
413            forward_page()
414        ));
415        assert_backward_page(&first_request_of!(
416            "TransactionBlocksWithEffectsQueryFragment",
417            |c: crate::GraphQLClient| c.transactions_data_effects()
418        ));
419        assert_forward_page(&first_request_of!(
420            "TransactionBlocksWithEffectsQueryFragment",
421            |c: crate::GraphQLClient| c.transactions_data_effects(),
422            forward_page()
423        ));
424    }
425
426    #[cfg(feature = "move-types")]
427    #[tokio::test]
428    async fn move_objects_streams_from_its_pagination() {
429        use iota_move_types::iota_framework::{coin::Coin, iota::IOTA};
430
431        assert_backward_page(&first_request_of!(
432            "ObjectsQueryFragment",
433            |c: crate::GraphQLClient| c.move_objects::<Coin<IOTA>>()
434        ));
435        assert_forward_page(&first_request_of!(
436            "ObjectsQueryFragment",
437            |c: crate::GraphQLClient| c.move_objects::<Coin<IOTA>>(),
438            forward_page()
439        ));
440    }
441}