diff --git a/crates/sui-graphql-rpc/schema.graphql b/crates/sui-graphql-rpc/schema.graphql index e3bdf84a77de4..714a789b0e17d 100644 --- a/crates/sui-graphql-rpc/schema.graphql +++ b/crates/sui-graphql-rpc/schema.graphql @@ -3351,6 +3351,10 @@ type Query { """ packageByName(name: String!): MovePackage """ + Fetch a type that includes dot move service names in it. + """ + typeByName(name: String!): MoveType! + """ The coin metadata associated with the given coin type. """ coinMetadata(coinType: String!): CoinMetadata diff --git a/crates/sui-graphql-rpc/src/functional_group.rs b/crates/sui-graphql-rpc/src/functional_group.rs index d7e96505c0e8f..6b4642b8f0f68 100644 --- a/crates/sui-graphql-rpc/src/functional_group.rs +++ b/crates/sui-graphql-rpc/src/functional_group.rs @@ -101,6 +101,7 @@ fn functional_groups() -> &'static BTreeMap<(&'static str, &'static str), Functi (("Query", "protocolConfig"), G::SystemState), (("Query", "resolveSuinsAddress"), G::NameService), (("Query", "packageByName"), G::MoveRegistry), + (("Query", "typeByName"), G::MoveRegistry), (("Subscription", "events"), G::Subscriptions), (("Subscription", "transactions"), G::Subscriptions), (("SystemStateSummary", "safeMode"), G::SystemState), diff --git a/crates/sui-graphql-rpc/src/types/dot_move/mod.rs b/crates/sui-graphql-rpc/src/types/dot_move/mod.rs index f0a922aa1023d..b988bd638e246 100644 --- a/crates/sui-graphql-rpc/src/types/dot_move/mod.rs +++ b/crates/sui-graphql-rpc/src/types/dot_move/mod.rs @@ -3,4 +3,5 @@ pub(crate) mod error; pub(crate) mod named_move_package; +pub(crate) mod named_type; pub(crate) mod on_chain; diff --git a/crates/sui-graphql-rpc/src/types/dot_move/named_type.rs b/crates/sui-graphql-rpc/src/types/dot_move/named_type.rs new file mode 100644 index 0000000000000..1b6a7ee226ee5 --- /dev/null +++ b/crates/sui-graphql-rpc/src/types/dot_move/named_type.rs @@ -0,0 +1,231 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashMap; +use std::str::FromStr; + +use async_graphql::Context; +use futures::future; +use regex::{Captures, Regex}; +use sui_types::{base_types::ObjectID, TypeTag}; + +use crate::error::Error; + +use super::{ + error::MoveRegistryError, named_move_package::NamedMovePackage, + on_chain::VERSIONED_NAME_UNBOUND_REG, +}; + +pub(crate) struct NamedType; + +impl NamedType { + /// Queries a type by the given name. + /// Name should be a valid type tag, with move names in it in the format `app@org::type::Type`. + /// For nested type params, we just follow the same pattern e.g. `app@org::type::Type`. + pub(crate) async fn query( + ctx: &Context<'_>, + name: &str, + checkpoint_viewed_at: u64, + ) -> Result { + // we do not de-duplicate the names here, as the dataloader will do this for us. + let names = Self::parse_names(name)?; + + // Gather all the requests to resolve the names. + let names_to_resolve = names + .iter() + .map(|x| NamedMovePackage::query(ctx, x, checkpoint_viewed_at)) + .collect::>(); + + // now we resolve all the names in parallel (data-loader will do the proper de-duplication / batching for us) + // also the `NamedMovePackage` query will re-validate the names (including max length, which is not checked on the regex). + let results = future::try_join_all(names_to_resolve).await?; + + // now let's create a hashmap with {name: MovePackage} + let mut name_package_id_mapping = HashMap::new(); + + for (name, result) in names.into_iter().zip(results.into_iter()) { + let Some(package) = result else { + return Err(Error::MoveNameRegistry(MoveRegistryError::NameNotFound( + name, + ))); + }; + name_package_id_mapping.insert(name, package.native.id()); + } + + let correct_type_tag: String = Self::replace_names(name, &name_package_id_mapping)?; + + TypeTag::from_str(&correct_type_tag).map_err(|e| Error::Client(format!("bad type: {e}"))) + } + + /// Is this already caught by the global limits? + /// This parser just extracts all names from a type tag, and returns them + /// We do not care about de-duplication, as the dataloader will do this for us. + /// The goal of replacing all of them with `0x0` is to make sure that the type tag is valid + /// so when replaced with the move name package addresses, it'll also be valid. + fn parse_names(name: &str) -> Result, Error> { + let mut names = vec![]; + let struct_tag = VERSIONED_NAME_UNBOUND_REG.replace_all(name, |m: ®ex::Captures| { + // SAFETY: we know that the regex will always have a match on position 0. + names.push(m.get(0).unwrap().as_str().to_string()); + "0x0".to_string() + }); + + // We attempt to parse the type_tag with these replacements, to make sure there are no other + // errors in the type tag (apart from the move names). That protects us from unnecessary + // queries to resolve .move names, for a type tag that will be invalid anyway. + TypeTag::from_str(&struct_tag).map_err(|e| Error::Client(format!("bad type: {e}")))?; + + Ok(names) + } + + /// This function replaces all the names in the type tag with their corresponding MovePackage address. + /// The names are guaranteed to be the same and exist (as long as this is called in sequence), + /// since we use the same parser to extract the names. + fn replace_names(type_name: &str, names: &HashMap) -> Result { + let struct_tag_str = replace_all_result( + &VERSIONED_NAME_UNBOUND_REG, + type_name, + |m: ®ex::Captures| { + // SAFETY: we know that the regex will have a match on position 0. + let name = m.get(0).unwrap().as_str(); + + // if we are misusing the function, and we cannot find the name in the hashmap, + // we return an empty string, which will make the type tag invalid. + if let Some(addr) = names.get(name) { + Ok(addr.to_string()) + } else { + Err(Error::MoveNameRegistry(MoveRegistryError::NameNotFound( + name.to_string(), + ))) + } + }, + )?; + + Ok(struct_tag_str.to_string()) + } +} + +/// Helper to replace all occurrences of a regex with a function that returns a string. +/// Used as a replacement of `regex`.replace_all(). +/// The only difference is that this function returns a Result, so we can handle errors. +fn replace_all_result( + re: &Regex, + haystack: &str, + replacement: impl Fn(&Captures) -> Result, +) -> Result { + let mut new = String::with_capacity(haystack.len()); + let mut last_match = 0; + for caps in re.captures_iter(haystack) { + // SAFETY: we know that the regex will have a match on position 0. + let m = caps.get(0).unwrap(); + new.push_str(&haystack[last_match..m.start()]); + new.push_str(&replacement(&caps)?); + last_match = m.end(); + } + new.push_str(&haystack[last_match..]); + Ok(new) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use sui_types::base_types::ObjectID; + + use super::NamedType; + + struct DemoData { + input_type: String, + expected_output: String, + expected_names: Vec, + } + + #[test] + fn parse_and_replace_type_successfully() { + let mut demo_data = vec![]; + + demo_data.push(DemoData { + input_type: "app@org::type::Type".to_string(), + expected_output: format_type("0x0", "::type::Type"), + expected_names: vec!["app@org".to_string()], + }); + + demo_data.push(DemoData { + input_type: "0xapp@org::type::Type".to_string(), + expected_output: format_type("0x0", "::type::Type"), + expected_names: vec!["0xapp@org".to_string()], + }); + + demo_data.push(DemoData { + input_type: "app@org::type::Type".to_string(), + expected_output: format!("{}", format_type("0x0", "::type::Type")), + expected_names: vec!["app@org".to_string()], + }); + + demo_data.push(DemoData { + input_type: "app@org::type::Type".to_string(), + expected_output: format!( + "{}<{}, u64>", + format_type("0x0", "::type::Type"), + format_type("0x1", "::type::AnotherType") + ), + expected_names: vec!["app@org".to_string(), "another-app@org".to_string()], + }); + + demo_data.push(DemoData { + input_type: "app@org::type::Type, 0x1::string::String>".to_string(), + expected_output: format!("{}<{}<{}>, 0x1::string::String>", format_type("0x0", "::type::Type"), format_type("0x1", "::type::AnotherType"), format_type("0x2", "::inner::Type")), + expected_names: vec![ + "app@org".to_string(), + "another-app@org".to_string(), + "even-more-nested@org".to_string(), + ], + }); + + for data in demo_data { + let names = NamedType::parse_names(&data.input_type).unwrap(); + assert_eq!(names, data.expected_names); + + let mut mapping = HashMap::new(); + + for (index, name) in data.expected_names.iter().enumerate() { + mapping.insert( + name.clone(), + ObjectID::from_hex_literal(&format!("0x{}", index)).unwrap(), + ); + } + + let replaced = NamedType::replace_names(&data.input_type, &mapping); + assert_eq!(replaced.unwrap(), data.expected_output); + } + } + + #[test] + fn parse_and_replace_type_errors() { + let types = vec![ + "--app@org::type::Type", + "app@org::type::Type<", + "app@org::type::Type", + "", + ]; + + // TODO: add snapshot tests for predictable errors. + for t in types { + assert!(NamedType::parse_names(t).is_err()); + } + } + + fn format_type(address: &str, rest: &str) -> String { + format!( + "{}{}", + ObjectID::from_hex_literal(address) + .unwrap() + .to_canonical_string(true), + rest + ) + } +} diff --git a/crates/sui-graphql-rpc/src/types/dot_move/on_chain.rs b/crates/sui-graphql-rpc/src/types/dot_move/on_chain.rs index bb01254468ca3..a5c1f4550fa60 100644 --- a/crates/sui-graphql-rpc/src/types/dot_move/on_chain.rs +++ b/crates/sui-graphql-rpc/src/types/dot_move/on_chain.rs @@ -31,7 +31,7 @@ const MAX_LABEL_LENGTH: usize = 63; /// /// The unbound regex can be used to search matches in a type tag. /// Use `VERSIONED_NAME_REGEX` for parsing a single name from a str. -const _VERSIONED_NAME_UNBOUND_REGEX: &str = concat!( +const VERSIONED_NAME_UNBOUND_REGEX: &str = concat!( "([a-z0-9]+(?:-[a-z0-9]+)*)", "@", "([a-z0-9]+(?:-[a-z0-9]+)*)", @@ -54,8 +54,8 @@ const VERSIONED_NAME_REGEX: &str = concat!( ); /// A regular expression that detects all possible dot move names in a type tag. -pub(crate) static _VERSIONED_NAME_UNBOUND_REG: Lazy = - Lazy::new(|| Regex::new(_VERSIONED_NAME_UNBOUND_REGEX).unwrap()); +pub(crate) static VERSIONED_NAME_UNBOUND_REG: Lazy = + Lazy::new(|| Regex::new(VERSIONED_NAME_UNBOUND_REGEX).unwrap()); /// A regular expression that detects a single name in the format `app@org/v1`. pub(crate) static VERSIONED_NAME_REG: Lazy = diff --git a/crates/sui-graphql-rpc/src/types/query.rs b/crates/sui-graphql-rpc/src/types/query.rs index e4f84229a72a4..704b27d0d0ed4 100644 --- a/crates/sui-graphql-rpc/src/types/query.rs +++ b/crates/sui-graphql-rpc/src/types/query.rs @@ -13,6 +13,7 @@ use sui_types::transaction::{TransactionData, TransactionKind}; use sui_types::{gas_coin::GAS, transaction::TransactionDataAPI, TypeTag}; use super::dot_move::named_move_package::NamedMovePackage; +use super::dot_move::named_type::NamedType; use super::move_package::{ self, MovePackage, MovePackageCheckpointFilter, MovePackageVersionFilter, }; @@ -554,6 +555,14 @@ impl Query { .extend() } + /// Fetch a type that includes dot move service names in it. + async fn type_by_name(&self, ctx: &Context<'_>, name: String) -> Result { + let Watermark { checkpoint, .. } = *ctx.data()?; + let type_tag = NamedType::query(ctx, &name, checkpoint).await?; + + Ok(MoveType::new(type_tag)) + } + /// The coin metadata associated with the given coin type. async fn coin_metadata( &self, diff --git a/crates/sui-graphql-rpc/tests/dot_move_e2e.rs b/crates/sui-graphql-rpc/tests/dot_move_e2e.rs index bfd1d7c9abff7..7fd8d388ed2d7 100644 --- a/crates/sui-graphql-rpc/tests/dot_move_e2e.rs +++ b/crates/sui-graphql-rpc/tests/dot_move_e2e.rs @@ -30,6 +30,9 @@ mod tests { const DEMO_PKG_V3: &str = "tests/dot_move/demo_v3/"; const DB_NAME: &str = "sui_graphql_rpc_e2e_tests"; + const DEMO_TYPE: &str = "::demo::V1Type"; + const DEMO_TYPE_V2: &str = "::demo::V2Type"; + const DEMO_TYPE_V3: &str = "::demo::V3Type"; #[derive(Clone, Debug)] struct UpgradeCap(ObjectID, SequenceNumber, ObjectDigest); @@ -119,12 +122,15 @@ mod tests { // Same query is used across both nodes, since we're testing on top of the same data, just with a different // lookup approach. let query = format!( - r#"{{ valid_latest: {}, v1: {}, v2: {}, v3: {}, v4: {} }}"#, + r#"{{ valid_latest: {}, v1: {}, v2: {}, v3: {}, v4: {}, v1_type: {}, v2_type: {}, v3_type: {} }}"#, name_query(&name), name_query(&format!("{}{}", &name, "/v1")), name_query(&format!("{}{}", &name, "/v2")), name_query(&format!("{}{}", &name, "/v3")), name_query(&format!("{}{}", &name, "/v4")), + type_query(&format!("{}{}", &name, DEMO_TYPE)), + type_query(&format!("{}{}", &name, DEMO_TYPE_V2)), + type_query(&format!("{}{}", &name, DEMO_TYPE_V3)), ); let internal_resolution = internal_client @@ -177,6 +183,27 @@ mod tests { query_result["data"]["v4"].is_null(), "V4 should not have been found" ); + + assert_eq!( + query_result["data"]["v1_type"]["layout"]["struct"]["type"] + .as_str() + .unwrap(), + format!("{}{}", v1, DEMO_TYPE) + ); + + assert_eq!( + query_result["data"]["v2_type"]["layout"]["struct"]["type"] + .as_str() + .unwrap(), + format!("{}{}", v2, DEMO_TYPE_V2) + ); + + assert_eq!( + query_result["data"]["v3_type"]["layout"]["struct"]["type"] + .as_str() + .unwrap(), + format!("{}{}", v3, DEMO_TYPE_V3) + ); } async fn init_dot_move_gql( @@ -457,6 +484,10 @@ mod tests { format!(r#"packageByName(name: "{}") {{ address, version }}"#, name) } + fn type_query(named_type: &str) -> String { + format!(r#"typeByName(name: "{}") {{ layout }}"#, named_type) + } + fn gql_default_config(db_name: &str, port: u16, prom_port: u16) -> ConnectionConfig { ConnectionConfig::ci_integration_test_cfg_with_db_name(db_name.to_string(), port, prom_port) } diff --git a/crates/sui-graphql-rpc/tests/snapshots/snapshot_tests__schema_sdl_export.snap b/crates/sui-graphql-rpc/tests/snapshots/snapshot_tests__schema_sdl_export.snap index b9edceaf2f16d..ccb32954fb403 100644 --- a/crates/sui-graphql-rpc/tests/snapshots/snapshot_tests__schema_sdl_export.snap +++ b/crates/sui-graphql-rpc/tests/snapshots/snapshot_tests__schema_sdl_export.snap @@ -3355,6 +3355,10 @@ type Query { """ packageByName(name: String!): MovePackage """ + Fetch a type that includes dot move service names in it. + """ + typeByName(name: String!): MoveType! + """ The coin metadata associated with the given coin type. """ coinMetadata(coinType: String!): CoinMetadata