diff --git a/crates/torii/graphql/src/constants.rs b/crates/torii/graphql/src/constants.rs index 0117ba18b8..bf08ddd028 100644 --- a/crates/torii/graphql/src/constants.rs +++ b/crates/torii/graphql/src/constants.rs @@ -25,6 +25,8 @@ 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"; +pub const MODEL_ORDER_TYPE_NAME: &str = "World__ModelOrder"; +pub const MODEL_ORDER_FIELD_TYPE_NAME: &str = "World__ModelOrderField"; // objects' single and plural names pub const ENTITY_NAMES: (&str, &str) = ("entity", "entities"); @@ -34,3 +36,8 @@ pub const SOCIAL_NAMES: (&str, &str) = ("social", "socials"); pub const CONTENT_NAMES: (&str, &str) = ("content", "contents"); pub const METADATA_NAMES: (&str, &str) = ("metadata", "metadatas"); pub const TRANSACTION_NAMES: (&str, &str) = ("transaction", "transactions"); + +// misc +pub const ORDER_DIR_TYPE_NAME: &str = "OrderDirection"; +pub const ORDER_ASC: &str = "ASC"; +pub const ORDER_DESC: &str = "DESC"; diff --git a/crates/torii/graphql/src/object/connection/mod.rs b/crates/torii/graphql/src/object/connection/mod.rs index 7785188785..d43d4a1461 100644 --- a/crates/torii/graphql/src/object/connection/mod.rs +++ b/crates/torii/graphql/src/object/connection/mod.rs @@ -123,10 +123,15 @@ pub fn connection_output( .iter() .map(|row| { let order_field = match order { - Some(order) => format!("external_{}", order.field), + Some(order) => { + if is_external { + format!("external_{}", order.field) + } else { + order.field.to_string() + } + } None => id_column.to_string(), }; - let primary_order = row.try_get::(id_column)?; let secondary_order = row.try_get_unchecked::(&order_field)?; let cursor = cursor::encode(&primary_order, &secondary_order); diff --git a/crates/torii/graphql/src/object/inputs/order_input.rs b/crates/torii/graphql/src/object/inputs/order_input.rs index 2c388cc116..d7386200ce 100644 --- a/crates/torii/graphql/src/object/inputs/order_input.rs +++ b/crates/torii/graphql/src/object/inputs/order_input.rs @@ -1,6 +1,7 @@ use async_graphql::dynamic::{Enum, Field, InputObject, InputValue, ResolverContext, TypeRef}; use super::InputObjectTrait; +use crate::constants::{ORDER_ASC, ORDER_DESC, ORDER_DIR_TYPE_NAME}; use crate::object::TypeMapping; use crate::query::order::{Direction, Order}; @@ -27,7 +28,7 @@ impl InputObjectTrait for OrderInputObject { fn input_object(&self) -> InputObject { // direction and field values are required (not null) InputObject::new(self.type_name()) - .field(InputValue::new("direction", TypeRef::named_nn("OrderDirection"))) + .field(InputValue::new("direction", TypeRef::named_nn(ORDER_DIR_TYPE_NAME))) .field(InputValue::new( "field", TypeRef::named_nn(format!("{}Field", self.type_name())), @@ -36,7 +37,7 @@ impl InputObjectTrait for OrderInputObject { fn enum_objects(&self) -> Option> { // Direction enum has only two members ASC and DESC - let direction = Enum::new("OrderDirection").item("ASC").item("DESC"); + let direction = Enum::new(ORDER_DIR_TYPE_NAME).item(ORDER_ASC).item(ORDER_DESC); // Field Order enum consist of all members of a model let field_order = self @@ -45,7 +46,6 @@ impl InputObjectTrait for OrderInputObject { .fold(Enum::new(format!("{}Field", self.type_name())), |acc, (ty_name, _)| { acc.item(ty_name.to_uppercase()) }); - Some(vec![direction, field_order]) } } diff --git a/crates/torii/graphql/src/object/mod.rs b/crates/torii/graphql/src/object/mod.rs index 494449654f..bb82e96de8 100644 --- a/crates/torii/graphql/src/object/mod.rs +++ b/crates/torii/graphql/src/object/mod.rs @@ -169,7 +169,6 @@ pub trait ObjectTrait: Send + Sync { object = object.field(field); } } - vec![object] } } diff --git a/crates/torii/graphql/src/object/model.rs b/crates/torii/graphql/src/object/model.rs index 402bfe7935..59bd4cc89d 100644 --- a/crates/torii/graphql/src/object/model.rs +++ b/crates/torii/graphql/src/object/model.rs @@ -1,13 +1,26 @@ use async_graphql::dynamic::indexmap::IndexMap; -use async_graphql::dynamic::{InputValue, SubscriptionField, SubscriptionFieldFuture, TypeRef}; +use async_graphql::dynamic::{ + Enum, Field, FieldFuture, InputObject, InputValue, SubscriptionField, SubscriptionFieldFuture, + TypeRef, +}; use async_graphql::{Name, Value}; +use sqlx::{Pool, Sqlite}; use tokio_stream::StreamExt; use torii_core::simple_broker::SimpleBroker; use torii_core::types::Model; +use super::connection::{connection_arguments, connection_output, parse_connection_arguments}; +use super::inputs::order_input::parse_order_argument; use super::{ObjectTrait, TypeMapping, ValueMapping}; -use crate::constants::{MODEL_NAMES, MODEL_TABLE, MODEL_TYPE_NAME}; +use crate::constants::{ + ID_COLUMN, MODEL_NAMES, MODEL_ORDER_FIELD_TYPE_NAME, MODEL_ORDER_TYPE_NAME, MODEL_TABLE, + MODEL_TYPE_NAME, ORDER_ASC, ORDER_DESC, ORDER_DIR_TYPE_NAME, +}; use crate::mapping::MODEL_TYPE_MAPPING; +use crate::query::data::{count_rows, fetch_multiple_rows}; + +const ORDER_BY_NAME: &str = "NAME"; +const ORDER_BY_HASH: &str = "CLASS_HASH"; pub struct ModelObject; @@ -28,6 +41,70 @@ impl ObjectTrait for ModelObject { Some(MODEL_TABLE) } + fn input_objects(&self) -> Option> { + let order_input = InputObject::new(MODEL_ORDER_TYPE_NAME) + .field(InputValue::new("direction", TypeRef::named_nn(ORDER_DIR_TYPE_NAME))) + .field(InputValue::new("field", TypeRef::named_nn(MODEL_ORDER_FIELD_TYPE_NAME))); + + Some(vec![order_input]) + } + + fn enum_objects(&self) -> Option> { + let direction = Enum::new(ORDER_DIR_TYPE_NAME).item(ORDER_ASC).item(ORDER_DESC); + let field_order = + Enum::new(MODEL_ORDER_FIELD_TYPE_NAME).item(ORDER_BY_NAME).item(ORDER_BY_HASH); + + Some(vec![direction, field_order]) + } + + fn resolve_many(&self) -> Option { + let type_mapping = self.type_mapping().clone(); + let table_name = self.table_name().unwrap().to_string(); + + let mut field = Field::new( + self.name().1, + TypeRef::named(format!("{}Connection", self.type_name())), + move |ctx| { + let type_mapping = type_mapping.clone(); + let table_name = table_name.to_string(); + + FieldFuture::new(async move { + let mut conn = ctx.data::>()?.acquire().await?; + let order = parse_order_argument(&ctx); + let connection = parse_connection_arguments(&ctx)?; + let total_count = count_rows(&mut conn, &table_name, &None, &None).await?; + let (data, page_info) = fetch_multiple_rows( + &mut conn, + &table_name, + ID_COLUMN, + &None, + &order, + &None, + &connection, + total_count, + ) + .await?; + let results = connection_output( + &data, + &type_mapping, + &order, + ID_COLUMN, + total_count, + false, + page_info, + )?; + + Ok(Some(Value::Object(results))) + }) + }, + ); + + field = connection_arguments(field); + field = field.argument(InputValue::new("order", TypeRef::named(MODEL_ORDER_TYPE_NAME))); + + Some(field) + } + fn subscriptions(&self) -> Option> { Some(vec![ SubscriptionField::new("modelRegistered", TypeRef::named_nn(self.type_name()), |ctx| { diff --git a/crates/torii/graphql/src/query/data.rs b/crates/torii/graphql/src/query/data.rs index aee42dd64f..44876dd390 100644 --- a/crates/torii/graphql/src/query/data.rs +++ b/crates/torii/graphql/src/query/data.rs @@ -4,7 +4,7 @@ use sqlx::{Result, Row, SqliteConnection}; use super::filter::{Filter, FilterValue}; use super::order::{CursorDirection, Direction, Order}; -use crate::constants::DEFAULT_LIMIT; +use crate::constants::{DEFAULT_LIMIT, MODEL_TABLE}; use crate::object::connection::{cursor, ConnectionArguments}; pub async fn count_rows( @@ -85,7 +85,10 @@ pub async fn fetch_multiple_rows( // `first` or `last` param. Explicit ordering take precedence match order { Some(order) => { - let column_name = format!("external_{}", order.field); + let mut column_name = order.field.clone(); + if table_name != MODEL_TABLE { + column_name = format!("external_{}", column_name); + } query.push_str(&format!( " ORDER BY {column_name} {}, {id_column} {} LIMIT {limit}", order.direction.as_ref(), @@ -125,7 +128,6 @@ pub async fn fetch_multiple_rows( Some(order) => format!("external_{}", order.field), None => id_column.to_string(), }; - match cursor_param { Some(cursor_query) => { let first_cursor = cursor::encode( diff --git a/crates/torii/graphql/src/tests/mod.rs b/crates/torii/graphql/src/tests/mod.rs index ec5582828c..9d2c71e16f 100644 --- a/crates/torii/graphql/src/tests/mod.rs +++ b/crates/torii/graphql/src/tests/mod.rs @@ -32,6 +32,7 @@ use torii_core::sql::Sql; mod entities_test; mod metadata_test; +mod models_ordering_test; mod models_test; mod subscription_test; @@ -69,6 +70,16 @@ pub struct PageInfo { pub end_cursor: Option, } +#[derive(Deserialize, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WorldModel { + pub id: String, + pub name: String, + pub class_hash: String, + pub transaction_hash: String, + pub created_at: String, +} + #[derive(Deserialize, Debug, PartialEq)] pub struct Record { pub __typename: String, diff --git a/crates/torii/graphql/src/tests/models_ordering_test.rs b/crates/torii/graphql/src/tests/models_ordering_test.rs new file mode 100644 index 0000000000..3ac52d1ff9 --- /dev/null +++ b/crates/torii/graphql/src/tests/models_ordering_test.rs @@ -0,0 +1,74 @@ +#[cfg(test)] +mod tests { + use anyhow::Result; + use async_graphql::dynamic::Schema; + use serde_json::Value; + + use crate::schema::build_schema; + use crate::tests::{run_graphql_query, spinup_types_test, Connection, WorldModel}; + + async fn world_model_query(schema: &Schema, arg: &str) -> Value { + let query = format!( + r#" + {{ + models {} {{ + totalCount + edges {{ + cursor + node {{ + id + name + classHash + transactionHash + createdAt + }} + }} + pageInfo{{ + startCursor + hasPreviousPage + hasNextPage + startCursor + endCursor + }} + }} + }} + "#, + arg, + ); + + let result = run_graphql_query(schema, &query).await; + result.get("models").ok_or("models not found").unwrap().clone() + } + + // End to end test spins up a test sequencer and deploys types-test project, this takes a while + // to run so combine all related tests into one + #[tokio::test(flavor = "multi_thread")] + async fn models_ordering_test() -> Result<()> { + let pool = spinup_types_test().await?; + let schema = build_schema(&pool).await.unwrap(); + + // default params, test entity relationship, test nested types + let world_model = world_model_query(&schema, "").await; + let connection: Connection = serde_json::from_value(world_model).unwrap(); + let first_model = connection.edges.first().unwrap(); + let second_model = connection.edges.get(1).unwrap(); + let last_model = connection.edges.get(2).unwrap(); + assert_eq!(&first_model.node.name, "Subrecord"); + assert_eq!(&second_model.node.name, "RecordSibling"); + assert_eq!(&last_model.node.name, "Record"); + + // *** ORDER TESTING *** + + // order on name string ASC (number) + let world_model = + world_model_query(&schema, "(order: {field: NAME, direction: ASC})").await; + let connection: Connection = serde_json::from_value(world_model).unwrap(); + let first_model = connection.edges.first().unwrap(); + let second_model = connection.edges.get(1).unwrap(); + let last_model = connection.edges.get(2).unwrap(); + assert_eq!(&first_model.node.name, "Record"); + assert_eq!(&second_model.node.name, "RecordSibling"); + assert_eq!(&last_model.node.name, "Subrecord"); + Ok(()) + } +}