Skip to content

Commit

Permalink
graph: Validate the id field of object types
Browse files Browse the repository at this point in the history
This hadn't been done before, though in practice it was probably never
violated (presumably since graph-cli enforces such rules, and because
trying to deploy such a schema would end badly)

The `examples/validate.rs` is a tool that makes it possible to bulk
validate subgraph schemas.
  • Loading branch information
lutter committed Nov 14, 2023
1 parent 15819a7 commit 6719e9c
Show file tree
Hide file tree
Showing 3 changed files with 176 additions and 2 deletions.
108 changes: 108 additions & 0 deletions graph/examples/validate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/// Validate subgraph schemas by parsing them into `InputSchema` and making
/// sure that they are valid
///
/// The input files must be in a particular format; that can be generated by
/// running this script against graph-node shard(s). Before running it,
/// change the `dbs` variable to list all databases against which it should
/// run.
///
/// ```
/// #! /bin/bash
///
/// read -r -d '' query <<EOF
/// \copy (select to_jsonb(a.*) from (select id, schema from subgraphs.subgraph_manifest) a) to '%s'
/// EOF
///
/// dbs="shard1 shard2 .."
///
/// dir=/var/tmp/schemas
/// mkdir -p $dir
///
/// for db in $dbs
/// do
/// echo "Dump $db"
/// q=$(printf "$query" "$dir/$db.json")
/// psql -qXt service=$db -c "$q"
/// done
///
/// ```
use graph::data::graphql::ext::DirectiveFinder;
use graph::data::graphql::DirectiveExt;
use graph::data::graphql::DocumentExt;
use graph::prelude::s;
use graph::prelude::DeploymentHash;
use graph::schema::InputSchema;
use graphql_parser::parse_schema;
use serde::Deserialize;
use std::env;
use std::fs::File;
use std::io::BufRead;
use std::io::BufReader;
use std::process::exit;

pub fn usage(msg: &str) -> ! {
println!("{}", msg);
println!("usage: validate schema.graphql ...");
println!("\nValidate subgraph schemas");
std::process::exit(1);
}

pub fn ensure<T, E: std::fmt::Display>(res: Result<T, E>, msg: &str) -> T {
match res {
Ok(ok) => ok,
Err(err) => {
eprintln!("{}:\n {}", msg, err);
exit(1)
}
}
}

fn subgraph_id(schema: &s::Document) -> DeploymentHash {
let id = schema
.get_object_type_definitions()
.first()
.and_then(|obj_type| obj_type.find_directive("subgraphId"))
.and_then(|dir| dir.argument("id"))
.and_then(|arg| match arg {
s::Value::String(s) => Some(s.to_owned()),
_ => None,
})
.unwrap_or("unknown".to_string());
DeploymentHash::new(id).expect("subgraph id is not a valid deployment hash")
}

#[derive(Deserialize)]
struct Entry {
id: i32,
schema: String,
}

pub fn main() {
// Allow fulltext search in schemas
std::env::set_var("GRAPH_ALLOW_NON_DETERMINISTIC_FULLTEXT_SEARCH", "true");

let args: Vec<String> = env::args().collect();
if args.len() < 2 {
usage("please provide a subgraph schema");
}
for arg in &args[1..] {
println!("Validating schemas from {arg}");
let file = File::open(arg).expect("file exists");
let rdr = BufReader::new(file);
for line in rdr.lines() {
let line = line.expect("invalid line").replace("\\\\", "\\");
let entry = serde_json::from_str::<Entry>(&line).expect("line is valid json");

let raw = &entry.schema;
let schema = ensure(
parse_schema(raw).map(|v| v.into_static()),
&format!("Failed to parse schema sgd{}", entry.id),
);
let id = subgraph_id(&schema);
match InputSchema::parse(raw, id.clone()) {
Ok(_) => println!("sgd{}[{}]: OK", entry.id, id),
Err(e) => println!("sgd{}[{}]: {}", entry.id, id, e),
}
}
}
}
66 changes: 64 additions & 2 deletions graph/src/schema/input_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,7 @@ mod validations {
graphql::{
ext::DirectiveFinder, DirectiveExt, DocumentExt, ObjectTypeExt, TypeExt, ValueExt,
},
store::{ValueType, ID},
store::{IdType, ValueType, ID},
},
prelude::s,
schema::{
Expand All @@ -564,6 +564,7 @@ mod validations {
.map(Result::unwrap_err)
.collect();

errors.append(&mut validate_id_types(schema));
errors.append(&mut validate_fields(schema));
errors.append(&mut validate_fulltext_directives(schema));

Expand Down Expand Up @@ -810,6 +811,29 @@ mod validations {
})
}

/// 1. All object types besides `_Schema_` must have an id field
/// 2. The id field must be recognized by IdType
fn validate_id_types(schema: &Schema) -> Vec<SchemaValidationError> {
schema
.document
.get_object_type_definitions()
.into_iter()
.filter(|obj_type| obj_type.name.ne(SCHEMA_TYPE_NAME))
.fold(vec![], |mut errors, object_type| {
match object_type.field(&*ID) {
None => errors.push(SchemaValidationError::IdFieldMissing(
object_type.name.clone(),
)),
Some(_) => {
if let Err(e) = IdType::try_from(object_type) {
errors.push(SchemaValidationError::IllegalIdType(e.to_string()));
}
}
}
errors
})
}

/// Checks if the schema is using types that are reserved
/// by `graph-node`
fn validate_reserved_types_usage(schema: &Schema) -> Result<(), SchemaValidationError> {
Expand Down Expand Up @@ -1065,6 +1089,38 @@ mod validations {

use super::*;

fn parse(schema: &str) -> Schema {
let hash = DeploymentHash::new("test").unwrap();
Schema::parse(schema, hash).unwrap()
}

#[test]
fn object_types_have_id() {
const NO_ID: &str = "type User @entity { name: String! }";
const ID_BIGINT: &str = "type User @entity { id: BigInt! }";
const INTF_NO_ID: &str = "interface Person { name: String! }";
const ROOT_SCHEMA: &str = "type _Schema_";

let res = validate(&parse(NO_ID));
assert_eq!(
res,
Err(vec![SchemaValidationError::IdFieldMissing(
"User".to_string()
)])
);

let res = validate(&parse(ID_BIGINT));
let errs = res.unwrap_err();
assert_eq!(1, errs.len());
assert!(matches!(errs[0], SchemaValidationError::IllegalIdType(_)));

let res = validate(&parse(INTF_NO_ID));
assert_eq!(Ok(()), res);

let res = validate(&parse(ROOT_SCHEMA));
assert_eq!(Ok(()), res);
}

#[test]
fn interface_implementations_id_type() {
fn check_schema(bar_id: &str, baz_id: &str, ok: bool) {
Expand Down Expand Up @@ -1238,7 +1294,10 @@ type A @entity {
let dummy_hash = DeploymentHash::new("dummy").unwrap();

for reserved_type in reserved_types {
let schema = format!("type {} @entity {{ _: Boolean }}\n", reserved_type);
let schema = format!(
"type {} @entity {{ id: String! _: Boolean }}\n",
reserved_type
);

let schema = Schema::parse(&schema, dummy_hash.clone()).unwrap();

Expand All @@ -1256,12 +1315,15 @@ type A @entity {
fn test_reserved_filter_and_group_by_types_validation() {
const SCHEMA: &str = r#"
type Gravatar @entity {
id: String!
_: Boolean
}
type Gravatar_filter @entity {
id: String!
_: Boolean
}
type Gravatar_orderBy @entity {
id: String!
_: Boolean
}
"#;
Expand Down
4 changes: 4 additions & 0 deletions graph/src/schema/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ pub enum SchemaValidationError {
FulltextIncludedFieldMissingRequiredProperty,
#[error("Fulltext entity field, {0}, not found or not a string")]
FulltextIncludedFieldInvalid(String),
#[error("Type {0} is missing an `id` field")]
IdFieldMissing(String),
#[error("{0}")]
IllegalIdType(String),
}

/// A validated and preprocessed GraphQL schema for a subgraph.
Expand Down

0 comments on commit 6719e9c

Please sign in to comment.