Skip to content

Commit

Permalink
[GraphQL][DotMove][2/n] Introduce typeByName (#18797)
Browse files Browse the repository at this point in the history
## Description 

Introduces `typeByName` that resolves a given type using the equivalent
dot move name.

## Test plan 

You can run `cargo test` for unit tests (parsing of types).

For e2e tests:

```
cargo nextest run --package sui-graphql-rpc --test dot_move_e2e --features pg_integration
```

## Stack

- #18774 
- #18770 


---

## Release notes

Check each box that your changes affect. If none of the boxes relate to
your changes, release notes aren't required.

For each box you select, include information after the relevant heading
that describes the impact of your changes that a user might notice and
any actions they must take to implement updates.

- [ ] Protocol: 
- [ ] Nodes (Validators and Full nodes): 
- [ ] Indexer: 
- [ ] JSON-RPC: 
- [ ] GraphQL: 
- [ ] CLI: 
- [ ] Rust SDK:
- [ ] REST API:
  • Loading branch information
manolisliolios authored Aug 29, 2024
1 parent 7e3e6f2 commit 2a75f36
Show file tree
Hide file tree
Showing 8 changed files with 285 additions and 4 deletions.
4 changes: 4 additions & 0 deletions crates/sui-graphql-rpc/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/sui-graphql-rpc/src/functional_group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions crates/sui-graphql-rpc/src/types/dot_move/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@

pub(crate) mod error;
pub(crate) mod named_move_package;
pub(crate) mod named_type;
pub(crate) mod on_chain;
231 changes: 231 additions & 0 deletions crates/sui-graphql-rpc/src/types/dot_move/named_type.rs
Original file line number Diff line number Diff line change
@@ -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<app@org::type::AnotherType, u64>`.
pub(crate) async fn query(
ctx: &Context<'_>,
name: &str,
checkpoint_viewed_at: u64,
) -> Result<TypeTag, Error> {
// 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::<Vec<_>>();

// 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<Vec<String>, Error> {
let mut names = vec![];
let struct_tag = VERSIONED_NAME_UNBOUND_REG.replace_all(name, |m: &regex::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<String, ObjectID>) -> Result<String, Error> {
let struct_tag_str = replace_all_result(
&VERSIONED_NAME_UNBOUND_REG,
type_name,
|m: &regex::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<String, Error>,
) -> Result<String, Error> {
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<String>,
}

#[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<u64>".to_string(),
expected_output: format!("{}<u64>", format_type("0x0", "::type::Type")),
expected_names: vec!["app@org".to_string()],
});

demo_data.push(DemoData {
input_type: "app@org::type::Type<another-app@org::type::AnotherType, u64>".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<another-app@org::type::AnotherType<even-more-nested@org::inner::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<another-app@org::type::AnotherType, u64",
"app@org/v11241--type::Type",
"app--org::type::Type",
"app",
"app@org::type::Type<another-app@org::type@::AnotherType, u64>",
"",
];

// 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
)
}
}
6 changes: 3 additions & 3 deletions crates/sui-graphql-rpc/src/types/dot_move/on_chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]+)*)",
Expand All @@ -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<Regex> =
Lazy::new(|| Regex::new(_VERSIONED_NAME_UNBOUND_REGEX).unwrap());
pub(crate) static VERSIONED_NAME_UNBOUND_REG: Lazy<Regex> =
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<Regex> =
Expand Down
9 changes: 9 additions & 0 deletions crates/sui-graphql-rpc/src/types/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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<MoveType> {
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,
Expand Down
33 changes: 32 additions & 1 deletion crates/sui-graphql-rpc/tests/dot_move_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 2a75f36

Please sign in to comment.