diff --git a/crates/torii/graphql/src/constants.rs b/crates/torii/graphql/src/constants.rs index 8bbfb7bbf7..0117ba18b8 100644 --- a/crates/torii/graphql/src/constants.rs +++ b/crates/torii/graphql/src/constants.rs @@ -21,6 +21,7 @@ pub const EVENT_TYPE_NAME: &str = "World__Event"; pub const SOCIAL_TYPE_NAME: &str = "World__Social"; pub const CONTENT_TYPE_NAME: &str = "World__Content"; pub const METADATA_TYPE_NAME: &str = "World__Metadata"; +pub const PAGE_INFO_TYPE_NAME: &str = "World__PageInfo"; pub const TRANSACTION_TYPE_NAME: &str = "World__Transaction"; pub const QUERY_TYPE_NAME: &str = "World__Query"; pub const SUBSCRIPTION_TYPE_NAME: &str = "World__Subscription"; diff --git a/crates/torii/graphql/src/object/connection/mod.rs b/crates/torii/graphql/src/object/connection/mod.rs index 44a8426cfa..63b796e31b 100644 --- a/crates/torii/graphql/src/object/connection/mod.rs +++ b/crates/torii/graphql/src/object/connection/mod.rs @@ -1,9 +1,13 @@ +use async_graphql::connection::PageInfo; +use async_graphql::dynamic::indexmap::IndexMap; use async_graphql::dynamic::{Field, InputValue, ResolverContext, TypeRef}; use async_graphql::{Error, Name, Value}; use sqlx::sqlite::SqliteRow; use sqlx::Row; +use self::page_info::PageInfoObject; use super::ObjectTrait; +use crate::constants::PAGE_INFO_TYPE_NAME; use crate::query::order::Order; use crate::query::value_mapping_from_row; use crate::types::{GraphqlType, TypeData, TypeMapping, ValueMapping}; @@ -37,6 +41,10 @@ impl ConnectionObject { TypeData::Simple(TypeRef::named_list(format!("{}Edge", type_name))), ), (Name::new("total_count"), TypeData::Simple(TypeRef::named_nn(TypeRef::INT))), + ( + Name::new("page_info"), + TypeData::Nested((TypeRef::named_nn(PAGE_INFO_TYPE_NAME), IndexMap::new())), + ), ]); Self { @@ -109,6 +117,7 @@ pub fn connection_output( id_column: &str, total_count: i64, is_external: bool, + page_info: PageInfo, ) -> sqlx::Result { let model_edges = data .iter() @@ -134,5 +143,6 @@ pub fn connection_output( Ok(ValueMapping::from([ (Name::new("total_count"), Value::from(total_count)), (Name::new("edges"), Value::List(model_edges?)), + (Name::new("page_info"), PageInfoObject::value(page_info)), ])) } diff --git a/crates/torii/graphql/src/object/connection/page_info.rs b/crates/torii/graphql/src/object/connection/page_info.rs index 6e44938a57..126f00f7aa 100644 --- a/crates/torii/graphql/src/object/connection/page_info.rs +++ b/crates/torii/graphql/src/object/connection/page_info.rs @@ -1,4 +1,7 @@ +use async_graphql::connection::PageInfo; +use async_graphql::dynamic::indexmap::IndexMap; use async_graphql::dynamic::Field; +use async_graphql::{Name, Value}; use crate::mapping::PAGE_INFO_TYPE_MAPPING; use crate::object::{ObjectTrait, TypeMapping}; @@ -26,3 +29,26 @@ impl ObjectTrait for PageInfoObject { None } } + +impl PageInfoObject { + pub fn value(page_info: PageInfo) -> Value { + Value::Object(IndexMap::from([ + (Name::new("has_previous_page"), Value::from(page_info.has_previous_page)), + (Name::new("has_next_page"), Value::from(page_info.has_next_page)), + ( + Name::new("start_cursor"), + match page_info.start_cursor { + Some(val) => Value::from(val), + None => Value::Null, + }, + ), + ( + Name::new("end_cursor"), + match page_info.end_cursor { + Some(val) => Value::from(val), + None => Value::Null, + }, + ), + ])) + } +} diff --git a/crates/torii/graphql/src/object/entity.rs b/crates/torii/graphql/src/object/entity.rs index c17f3555d6..4d9751573e 100644 --- a/crates/torii/graphql/src/object/entity.rs +++ b/crates/torii/graphql/src/object/entity.rs @@ -52,7 +52,7 @@ impl ObjectTrait for EntityObject { let connection = parse_connection_arguments(&ctx)?; let keys = parse_keys_argument(&ctx)?; let total_count = count_rows(&mut conn, ENTITY_TABLE, &keys, &None).await?; - let data = fetch_multiple_rows( + let (data, page_info) = fetch_multiple_rows( &mut conn, ENTITY_TABLE, EVENT_ID_COLUMN, @@ -60,6 +60,7 @@ impl ObjectTrait for EntityObject { &None, &None, &connection, + total_count, ) .await?; let results = connection_output( @@ -69,6 +70,7 @@ impl ObjectTrait for EntityObject { EVENT_ID_COLUMN, total_count, false, + page_info, )?; Ok(Some(Value::Object(results))) diff --git a/crates/torii/graphql/src/object/event.rs b/crates/torii/graphql/src/object/event.rs index dbbf0ec4bd..31b660c2b9 100644 --- a/crates/torii/graphql/src/object/event.rs +++ b/crates/torii/graphql/src/object/event.rs @@ -49,7 +49,7 @@ impl ObjectTrait for EventObject { let connection = parse_connection_arguments(&ctx)?; let keys = parse_keys_argument(&ctx)?; let total_count = count_rows(&mut conn, EVENT_TABLE, &keys, &None).await?; - let data = fetch_multiple_rows( + let (data, page_info) = fetch_multiple_rows( &mut conn, EVENT_TABLE, ID_COLUMN, @@ -57,6 +57,7 @@ impl ObjectTrait for EventObject { &None, &None, &connection, + total_count, ) .await?; let results = connection_output( @@ -66,6 +67,7 @@ impl ObjectTrait for EventObject { ID_COLUMN, total_count, false, + page_info, )?; Ok(Some(Value::Object(results))) diff --git a/crates/torii/graphql/src/object/metadata/mod.rs b/crates/torii/graphql/src/object/metadata/mod.rs index 6177e4d90d..4a00b2dd43 100644 --- a/crates/torii/graphql/src/object/metadata/mod.rs +++ b/crates/torii/graphql/src/object/metadata/mod.rs @@ -1,8 +1,10 @@ +use async_graphql::connection::PageInfo; use async_graphql::dynamic::{Field, FieldFuture, TypeRef}; use async_graphql::{Name, Value}; use sqlx::sqlite::SqliteRow; use sqlx::{Pool, Row, Sqlite}; +use super::connection::page_info::PageInfoObject; use super::connection::{connection_arguments, cursor, parse_connection_arguments}; use super::ObjectTrait; use crate::constants::{ @@ -54,7 +56,7 @@ impl ObjectTrait for MetadataObject { let mut conn = ctx.data::>()?.acquire().await?; let connection = parse_connection_arguments(&ctx)?; let total_count = count_rows(&mut conn, &table_name, &None, &None).await?; - let data = fetch_multiple_rows( + let (data, page_info) = fetch_multiple_rows( &mut conn, &table_name, ID_COLUMN, @@ -62,11 +64,13 @@ impl ObjectTrait for MetadataObject { &None, &None, &connection, + total_count, ) .await?; // convert json field to value_mapping expected by content object - let results = metadata_connection_output(&data, &type_mapping, total_count)?; + let results = + metadata_connection_output(&data, &type_mapping, total_count, page_info)?; Ok(Some(Value::Object(results))) }) @@ -85,6 +89,7 @@ fn metadata_connection_output( data: &[SqliteRow], types: &TypeMapping, total_count: i64, + page_info: PageInfo, ) -> sqlx::Result { let edges = data .iter() @@ -107,9 +112,10 @@ fn metadata_connection_output( value_mapping.insert(Name::new("content"), Value::Object(content)); - let mut edge = ValueMapping::new(); - edge.insert(Name::new("node"), Value::Object(value_mapping)); - edge.insert(Name::new("cursor"), Value::String(cursor)); + let edge = ValueMapping::from([ + (Name::new("node"), Value::Object(value_mapping)), + (Name::new("cursor"), Value::String(cursor)), + ]); Ok(Value::Object(edge)) }) @@ -118,6 +124,7 @@ fn metadata_connection_output( Ok(ValueMapping::from([ (Name::new("total_count"), Value::from(total_count)), (Name::new("edges"), Value::List(edges?)), + (Name::new("page_info"), PageInfoObject::value(page_info)), ])) } diff --git a/crates/torii/graphql/src/object/mod.rs b/crates/torii/graphql/src/object/mod.rs index 34e2700caa..494449654f 100644 --- a/crates/torii/graphql/src/object/mod.rs +++ b/crates/torii/graphql/src/object/mod.rs @@ -96,7 +96,7 @@ pub trait ObjectTrait: Send + Sync { let mut conn = ctx.data::>()?.acquire().await?; let connection = parse_connection_arguments(&ctx)?; let total_count = count_rows(&mut conn, &table_name, &None, &None).await?; - let data = fetch_multiple_rows( + let (data, page_info) = fetch_multiple_rows( &mut conn, &table_name, ID_COLUMN, @@ -104,6 +104,7 @@ pub trait ObjectTrait: Send + Sync { &None, &None, &connection, + total_count, ) .await?; let results = connection_output( @@ -113,6 +114,7 @@ pub trait ObjectTrait: Send + Sync { ID_COLUMN, total_count, false, + page_info, )?; Ok(Some(Value::Object(results))) diff --git a/crates/torii/graphql/src/object/model_data.rs b/crates/torii/graphql/src/object/model_data.rs index be2628d620..aadd7ec784 100644 --- a/crates/torii/graphql/src/object/model_data.rs +++ b/crates/torii/graphql/src/object/model_data.rs @@ -90,7 +90,8 @@ impl ObjectTrait for ModelDataObject { let connection = parse_connection_arguments(&ctx)?; let id_column = "event_id"; - let data = fetch_multiple_rows( + let total_count = count_rows(&mut conn, &type_name, &None, &filters).await?; + let (data, page_info) = fetch_multiple_rows( &mut conn, &type_name, id_column, @@ -98,12 +99,18 @@ impl ObjectTrait for ModelDataObject { &order, &filters, &connection, + total_count, ) .await?; - - let total_count = count_rows(&mut conn, &type_name, &None, &filters).await?; - let connection = - connection_output(&data, &type_mapping, &order, id_column, total_count, true)?; + let connection = connection_output( + &data, + &type_mapping, + &order, + id_column, + total_count, + true, + page_info, + )?; Ok(Some(Value::Object(connection))) }) diff --git a/crates/torii/graphql/src/query/data.rs b/crates/torii/graphql/src/query/data.rs index 20511846f3..de04e12e49 100644 --- a/crates/torii/graphql/src/query/data.rs +++ b/crates/torii/graphql/src/query/data.rs @@ -1,6 +1,7 @@ +use async_graphql::connection::PageInfo; use sqlx::pool::PoolConnection; use sqlx::sqlite::SqliteRow; -use sqlx::{Result, Sqlite}; +use sqlx::{Result, Row, Sqlite}; use super::filter::{Filter, FilterValue}; use super::order::{CursorDirection, Direction, Order}; @@ -34,6 +35,7 @@ pub async fn fetch_single_row( sqlx::query(&query).fetch_one(conn).await } +#[allow(clippy::too_many_arguments)] pub async fn fetch_multiple_rows( conn: &mut PoolConnection, table_name: &str, @@ -42,14 +44,17 @@ pub async fn fetch_multiple_rows( order: &Option, filters: &Option>, connection: &ConnectionArguments, -) -> Result> { + total_count: i64, +) -> Result<(Vec, PageInfo)> { let mut conditions = build_conditions(keys, filters); + let mut cursor_param = &connection.after; if let Some(after_cursor) = &connection.after { conditions.push(handle_cursor(after_cursor, order, CursorDirection::After, id_column)?); } if let Some(before_cursor) = &connection.before { + cursor_param = &connection.before; conditions.push(handle_cursor(before_cursor, order, CursorDirection::Before, id_column)?); } @@ -58,7 +63,18 @@ pub async fn fetch_multiple_rows( query.push_str(&format!(" WHERE {}", conditions.join(" AND "))); } - let limit = connection.first.or(connection.last).or(connection.limit).unwrap_or(DEFAULT_LIMIT); + let is_cursor_based = connection.first.or(connection.last).is_some() || cursor_param.is_some(); + + let data_limit = + connection.first.or(connection.last).or(connection.limit).unwrap_or(DEFAULT_LIMIT); + let limit = if is_cursor_based { + match &cursor_param { + Some(_) => data_limit + 2, + None => data_limit + 1, // prev page does not exist + } + } else { + data_limit + }; // NOTE: Order is determined by the `order` param if provided, otherwise it's inferred from the // `first` or `last` param. Explicit ordering take precedence @@ -89,7 +105,72 @@ pub async fn fetch_multiple_rows( query.push_str(&format!(" OFFSET {}", offset)); } - sqlx::query(&query).fetch_all(conn).await + let mut data = sqlx::query(&query).fetch_all(conn).await?; + let mut page_info = PageInfo { + has_previous_page: false, + has_next_page: false, + start_cursor: None, + end_cursor: None, + }; + + if data.is_empty() { + Ok((data, page_info)) + } else if is_cursor_based { + let order_field = match order { + Some(order) => format!("external_{}", order.field), + None => id_column.to_string(), + }; + + match cursor_param { + Some(cursor_query) => { + let first_cursor = cursor::encode( + &data[0].try_get::(id_column)?, + &data[0].try_get_unchecked::(&order_field)?, + ); + + if &first_cursor == cursor_query && data.len() != 1 { + data.remove(0); + page_info.has_previous_page = true; + } else { + data.pop(); + } + + if data.len() as u64 == limit - 1 { + page_info.has_next_page = true; + data.pop(); + } + } + None => { + if data.len() as u64 == limit { + page_info.has_next_page = true; + data.pop(); + } + } + } + + if !data.is_empty() { + page_info.start_cursor = Some(cursor::encode( + &data[0].try_get::(id_column)?, + &data[0].try_get_unchecked::(&order_field)?, + )); + page_info.end_cursor = Some(cursor::encode( + &data[data.len() - 1].try_get::(id_column)?, + &data[data.len() - 1].try_get_unchecked::(&order_field)?, + )); + } + + Ok((data, page_info)) + } else { + let offset = connection.offset.unwrap_or(0); + if 1 < offset && offset < total_count as u64 { + page_info.has_previous_page = true; + } + if limit + offset < total_count as u64 { + page_info.has_next_page = true; + } + + Ok((data, page_info)) + } } fn handle_cursor( diff --git a/crates/torii/graphql/src/query/order.rs b/crates/torii/graphql/src/query/order.rs index 7b5bb81271..d368d26525 100644 --- a/crates/torii/graphql/src/query/order.rs +++ b/crates/torii/graphql/src/query/order.rs @@ -15,8 +15,8 @@ pub struct Order { #[derive(AsRefStr, Debug, EnumString)] pub enum CursorDirection { - #[strum(serialize = "<")] + #[strum(serialize = "<=")] After, - #[strum(serialize = ">")] + #[strum(serialize = ">=")] Before, } diff --git a/crates/torii/graphql/src/tests/entities_test.rs b/crates/torii/graphql/src/tests/entities_test.rs index a2119ba986..cf2850268e 100644 --- a/crates/torii/graphql/src/tests/entities_test.rs +++ b/crates/torii/graphql/src/tests/entities_test.rs @@ -24,6 +24,12 @@ mod tests { model_names }} }} + page_info {{ + has_previous_page + has_next_page + start_cursor + end_cursor + }} }} }} "#, @@ -131,6 +137,11 @@ mod tests { assert_eq!(connection.edges.first().unwrap(), three); assert_eq!(connection.edges.last().unwrap(), four); + assert!(connection.page_info.has_previous_page); + assert!(connection.page_info.has_next_page); + assert_eq!(connection.page_info.start_cursor.unwrap(), three.cursor); + assert_eq!(connection.page_info.end_cursor.unwrap(), four.cursor); + let entities = entities_query(&schema, &format!("(first: 3, after: \"{}\")", three.cursor)).await; let connection: Connection = serde_json::from_value(entities).unwrap(); @@ -138,6 +149,11 @@ mod tests { assert_eq!(connection.edges.first().unwrap(), four); assert_eq!(connection.edges.last().unwrap(), six); + assert!(connection.page_info.has_previous_page); + assert!(connection.page_info.has_next_page); + assert_eq!(connection.page_info.start_cursor.unwrap(), four.cursor); + assert_eq!(connection.page_info.end_cursor.unwrap(), six.cursor); + // cursor based backward pagination let entities = entities_query(&schema, &format!("(last: 2, before: \"{}\")", seven.cursor)).await; @@ -146,6 +162,11 @@ mod tests { assert_eq!(connection.edges.first().unwrap(), six); assert_eq!(connection.edges.last().unwrap(), five); + assert!(connection.page_info.has_previous_page); + assert!(connection.page_info.has_next_page); + assert_eq!(connection.page_info.start_cursor.unwrap(), six.cursor); + assert_eq!(connection.page_info.end_cursor.unwrap(), five.cursor); + let entities = entities_query(&schema, &format!("(last: 3, before: \"{}\")", six.cursor)).await; let connection: Connection = serde_json::from_value(entities).unwrap(); @@ -153,6 +174,11 @@ mod tests { assert_eq!(connection.edges.first().unwrap(), five); assert_eq!(connection.edges.last().unwrap(), three); + assert!(connection.page_info.has_previous_page); + assert!(connection.page_info.has_next_page); + assert_eq!(connection.page_info.start_cursor.unwrap(), five.cursor); + assert_eq!(connection.page_info.end_cursor.unwrap(), three.cursor); + let empty_entities = entities_query( &schema, &format!( @@ -164,6 +190,11 @@ mod tests { let connection: Connection = serde_json::from_value(empty_entities).unwrap(); assert_eq!(connection.edges.len(), 0); + assert!(!connection.page_info.has_previous_page); + assert!(!connection.page_info.has_next_page); + assert_eq!(connection.page_info.start_cursor, None); + assert_eq!(connection.page_info.end_cursor, None); + // offset/limit based pagination let entities = entities_query(&schema, "(limit: 2)").await; let connection: Connection = serde_json::from_value(entities).unwrap(); @@ -171,16 +202,31 @@ mod tests { assert_eq!(connection.edges.first().unwrap(), one); assert_eq!(connection.edges.last().unwrap(), two); + assert!(!connection.page_info.has_previous_page); + assert!(connection.page_info.has_next_page); + assert_eq!(connection.page_info.start_cursor, None); + assert_eq!(connection.page_info.end_cursor, None); + let entities = entities_query(&schema, "(limit: 3, offset: 2)").await; let connection: Connection = serde_json::from_value(entities).unwrap(); assert_eq!(connection.edges.len(), 3); assert_eq!(connection.edges.first().unwrap(), three); assert_eq!(connection.edges.last().unwrap(), five); + assert!(connection.page_info.has_previous_page); + assert!(connection.page_info.has_next_page); + assert_eq!(connection.page_info.start_cursor, None); + assert_eq!(connection.page_info.end_cursor, None); + let empty_entities = entities_query(&schema, "(limit: 1, offset: 20)").await; let connection: Connection = serde_json::from_value(empty_entities).unwrap(); assert_eq!(connection.edges.len(), 0); + assert!(!connection.page_info.has_previous_page); + assert!(!connection.page_info.has_next_page); + assert_eq!(connection.page_info.start_cursor, None); + assert_eq!(connection.page_info.end_cursor, None); + // entity model union let id = poseidon_hash_many(&[FieldElement::ZERO]); let entity = entity_model_query(&schema, &id).await; diff --git a/crates/torii/graphql/src/tests/metadata_test.rs b/crates/torii/graphql/src/tests/metadata_test.rs index 63285035b9..d6490421ff 100644 --- a/crates/torii/graphql/src/tests/metadata_test.rs +++ b/crates/torii/graphql/src/tests/metadata_test.rs @@ -33,6 +33,12 @@ mod tests { } } } + page_info { + has_previous_page + has_next_page + start_cursor + end_cursor + } } } "#; diff --git a/crates/torii/graphql/src/tests/mod.rs b/crates/torii/graphql/src/tests/mod.rs index 50fb21229b..83c7a3839c 100644 --- a/crates/torii/graphql/src/tests/mod.rs +++ b/crates/torii/graphql/src/tests/mod.rs @@ -40,6 +40,7 @@ use crate::schema::build_schema; pub struct Connection { pub total_count: i64, pub edges: Vec>, + pub page_info: PageInfo, } #[derive(Deserialize, Debug, PartialEq)] @@ -55,6 +56,16 @@ pub struct Entity { pub created_at: Option, } +#[derive(Deserialize, Debug, PartialEq)] +// same as type from `async-graphql` but derive necessary traits +// https://docs.rs/async-graphql/6.0.10/async_graphql/types/connection/struct.PageInfo.html +pub struct PageInfo { + pub has_previous_page: bool, + pub has_next_page: bool, + pub start_cursor: Option, + pub end_cursor: Option, +} + #[derive(Deserialize, Debug)] pub struct Moves { pub __typename: String, diff --git a/crates/torii/graphql/src/tests/models_test.rs b/crates/torii/graphql/src/tests/models_test.rs index 0c6e3ca181..a27b97e314 100644 --- a/crates/torii/graphql/src/tests/models_test.rs +++ b/crates/torii/graphql/src/tests/models_test.rs @@ -58,6 +58,12 @@ mod tests { }} }} }} + page_info {{ + has_previous_page + has_next_page + start_cursor + end_cursor + }} }} }} "#,