Parse subgraph schema data

2023-01-27

Introduction


With The Graph, you simply define entity types in schema.graphql, and Graph Node will generate top level fields for querying single instances and collections of that entity type.

-The Graph docs



In this lesson we’re building a program to parse a subgraph schema. To simplify development we’re going to store a schema.graphql file locally. Let’s use the contents of the Everest Subgraph schema to develop our program.

  • Everest subgraph IPFS URL: https://ipfs.io/ipfs/QmVsp1bC9rS3rf861cXgyvsqkpdsTXKSnS4729boXZvZyH


"""
Projects are the member type which the Everest list is curated for
"""
type Project @entity {
  "Required ID"
  id: ID!
  # From IPFS / off chain storage
  # None of these are required, since first the event DIDOwnerChanged creates the project
  # Then DIDAttributeChanged gets emitted, where we will see theses values filled in
  "The IPFS hash where all off-chain data is stored"
  ipfsHash: String
  "Project name"
  name: String
  "Project description"
  description: String
  "Project website"
  website: String
  "Project twitter handle"
  twitter: String
  "Project github URL"
  github: String
  "Project avatar"
  avatar: String
  "Project image"
  image: String
  "List of project categories"
  categories: [Category!]!
  "True if a representative of the company owns this project"
  isRepresentative: Boolean!
  "Time it was created at on the blockchain"
  createdAt: Int!
  "Time it was updated at on the blockchain"
  updatedAt: Int!

  # From smart contracts directly
  "Owner of this project"
  owner: User
  "Current challenge against this project"
  currentChallenge: Challenge
  "Past challenges agaisnt this project"
  pastChallenges: [Challenge!]
  "Challenges this project has created against other projects"
  createdChallenges: [Challenge!]
  "Time this project joined Everest"
  membershipStartTime: Int! # reputation = now - membershipStartTime.
  "List of all delegates of this project"
  delegates: [User!]
  "Total vote count"
  totalVotes: Int!
  "All votes a project has made"
  votes: [Vote!] @derivedFrom(field: "voter")
}

"""
Project Categories
"""
type Category @entity {
  "The ID is a lowercased name"
  id: ID!
  "The category description"
  description: String!
  "The IPFS hash of the category image"
  imageHash: String!
  "The Url of the category image"
  imageUrl: String!
  "The name of the category, case insensitive"
  name: String!
  "The name used for the front end"
  slug: String!
  "Projects with this category designation"
  projects: [Project!]!
  "The subcategories of this Category"
  subcategories: [Category!] @derivedFrom(field: "parentCategory")
  "Parent category of this category. Null if it is a top level category"
  parentCategory: Category
  "Time it was created in the Subgraph"
  createdAt: Int!
  "Number of projects in this category and all of its subcategories"
  projectCount: Int!
}

type Challenge @entity {
  "Challenge ID"
  id: ID!
  "IPFS hash where the description is stored"
  ipfsHash: String!
  "Challenge description"
  description: String
  "End time of the challenge"
  endTime: Int!
  "Votes yes to a challenge for removal of the project (in weight)"
  removeVotes: Int!
  "Voting no to a challenge for keeping the project  (in weight)"
  keepVotes: Int!
  "Project that is being challenged"
  project: Project # Can be null since projects get deleted upon challenge
  "Owner of the challenge, which is a project"
  owner: Project # Can be null since projects get deleted upon challenge
  "List of all created votes"
  votes: [Vote!] @derivedFrom(field: "challenge")
  # This is when the challenge is resolved, which is different from end time
  "True if the challenge has been resolved"
  resolved: Boolean!
  "Time challenge was created on the blockchain"
  createdAt: Int!
}

"""
Everest holds the global variables relevant to the dapp
"""
type Everest @entity {
  id: ID!
  "Owner of the Everest contract"
  owner: Bytes!
  "Approved token for Everest fees"
  approvedToken: Bytes!
  "Voting period for challenges"
  votingPeriodDuration: Int!
  "Challege deposit in DAI"
  challengeDeposit: BigInt!
  "Fee to apply to be in Everest"
  applicationFee: BigInt!
  "Address of everest"
  everestAddress: Bytes!
  "Address of the reserve bank"
  reserveBankAddress: Bytes!
  "Balance of the reserve bank (DAI)"
  reserveBankBalance: BigInt!
  "IPFS hash pointing to the categories"
  categories: Bytes!
  "IPFS hash pointing to the charter"
  charter: Bytes!
  "Time it was created on the blockchain"
  createdAt: Int!
  "Total count of projects created on Everest"
  projectCount: Int!
  "Projects that are currently in control by a representative"
  claimedProjects: Int!
  "Projects that are currently under challenge"
  challengedProjects: Int!
  "The amount of categories in Everest"
  categoriesCount: Int!
}

"""
A challenge vote
"""
type Vote @entity {
  "Concatenation of challenge ID and voter address"
  id: ID!
  "Project that voted on the challenge"
  voter: Project
  "Challenge the vote is for"
  challenge: Challenge!
  "Vote choice"
  choice: Choice!
  "Vote weight based on project reputation"
  weight: Int!
  "Time that vote was created on the blockchain"
  createdAt: Int!
}

"""
The Vote choice enum
"""
enum Choice {
  Null
  Yes
  No
}

"""
A User of the Everest Dapp. A User is the owner or delegate of Projects. User info
is obtained from 3box
"""
type User @entity {
  "User ethereum address"
  id: ID!
  "Projects the user owns"
  projects: [Project!] @derivedFrom(field: "owner")
  "Projects the user is a delegate of"
  delegatorProjects: [Project!] @derivedFrom(field: "delegates")
  "The time the user was created in the Subgraph (not the blockchain)"
  createdAt: Int!
}

type _Schema_
  @fulltext(
    name: "projectSearch"
    language: en
    algorithm: rank
    include: [{ entity: "Project", fields: [{ name: "name" }, { name: "description" }] }]
  )



A parser is a software component that takes input data (frequently text) and builds a data structure – often some kind of parse tree, abstract syntax tree or other hierarchical structure, giving a structural representation of the input while checking for correct syntax.

-Wikipedia


Let’s get started!

Code

From your terminal/command line, create a new cargo project and open it with VSCode


cargo new parse_subgraph_schema
cd parse_subgraph_schema
code .



With VSCode now open, update Cargo.toml with the following dependencies (add this below [dependencies]) then save your changes


tokio = { version = "1.23.0", features = ["full"] }
graphql-parser = "0.4.0"



Create a file called schema.graphql, paste the contents of today’s data into it, then save



Open src/main.rs and add these use statements


use std::string::String;
use std::error::Error;

use graphql_parser::schema::parse_schema;
use graphql_parser::schema::Definition::{SchemaDefinition, TypeDefinition, TypeExtension, DirectiveDefinition};
use graphql_parser::schema::TypeDefinition::{Scalar, Object, Interface, Union, Enum, InputObject};
use graphql_parser::query::Type;



Next add these struct statements to main.rs


#[allow(non_snake_case)]
#[derive(Debug)]
struct DefintionResult<'a> {
    name: String,
    description: Option<String>,
    fields: Vec<DefinitionField<'a>>,
    values: Vec<DefinitionValue>,
}

#[allow(non_snake_case)]
#[derive(Debug)]
struct DefinitionField<'a> {
    name: String,
    description: Option<String>,
    field_type: Type<'a, String>,
}

#[allow(non_snake_case)]
#[derive(Debug)]
struct DefinitionValue {
    name: String,
    description: Option<String>,
}


The main aim of lifetimes is to prevent dangling references, which cause a program to reference data other than the data it’s intended to reference.

-The Rust Programming Language



Replace the main function body with this


#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    // Read schema.graphql
    let contents = std::fs::read_to_string("schema.graphql")
    .expect("Should have been able to read the file");
    
    // Parse schema
    let ast = parse_schema::<String>(&contents)?.to_owned();

    // Traverse type definition options with match
    let mut results: Vec<DefintionResult> = Vec::new();
    
    for definition in ast.definitions {
        match definition {
            TypeDefinition(Object(o)) => {
                let mut tmp_fields: Vec<DefinitionField> = Vec::new();
                for field in o.fields {
                    tmp_fields.push(
                        DefinitionField {
                            name: field.name,
                            description: field.description,
                            field_type: field.field_type,
                        }
                    );
                }
                let mut tmp_values: Vec<DefinitionValue> = Vec::new();
                tmp_values.push(
                    DefinitionValue {
                        name: "None".to_string(),
                        description: None,
                    }
                );
                results.push(
                    DefintionResult {
                        name: o.name,
                        description: o.description,
                        fields: tmp_fields,
                        values: tmp_values,
                    }
                );
            },
            TypeDefinition(Enum(e)) => {
                let mut tmp_values: Vec<DefinitionValue> = Vec::new();
                for value in e.values {
                    tmp_values.push(
                        DefinitionValue {
                            name: value.name,
                            description: value.description,
                        }
                    );
                }
                let mut tmp_fields: Vec<DefinitionField> = Vec::new();
                tmp_fields.push(
                    DefinitionField {
                        name: "None".to_string(),
                        description: None,
                        field_type: Type::NamedType("None".to_string()),
                    }
                );
                results.push(
                    DefintionResult {
                        name: e.name,
                        description: e.description,
                        fields: tmp_fields,
                        values: tmp_values,
                    }
                );
            },
            TypeDefinition(Scalar(s)) => {
                println!("Coming soon");
            },
            TypeDefinition(Interface(i)) => {
                println!("Coming soon");
            },
            TypeDefinition(Union(u)) => {
                println!("Coming soon");
            },
            TypeDefinition(InputObject(io)) => {
                println!("Coming soon");
            },
            SchemaDefinition(_) | TypeExtension(_) | DirectiveDefinition(_) => todo!(),
        }
    }

    // Print results
    for res in results {
        println!("{:?}", res);
        println!("");
    }

    Ok(())
}


That’s a lot to unpack. Let’s go section by section.


Read schema.graphql

Uses the std::fs module to read the contents of a file to a String.

Parse schema

Uses the graphql_parser library to parse String of file contents.

Traverse type definition options with match

Uses our custom structs and loops through schema AST definitions to populate results. Matches AST definitions based on TypeDefinition, populating results accordingly.

  • Some match branches only output a Coming soon message, so finish defining the branches as an extra learning challenge.

Print results

Uses println macro to output our results.



Save your changes then run the program from the integrated terminal in VSCode


cargo run