@graphprotocol/graph-cli
Version:
CLI for building for and deploying to The Graph
956 lines (877 loc) • 27.4 kB
JavaScript
const fs = require('fs')
const graphql = require('graphql/language')
// Builtin scalar types
const BUILTIN_SCALAR_TYPES = [
'Boolean',
'Int',
'BigDecimal',
'String',
'BigInt',
'Bytes',
'ID',
]
// Type suggestions for common mistakes
const TYPE_SUGGESTIONS = [
['Address', 'Bytes'],
['address', 'Bytes'],
['Account', 'String'],
['account', 'String'],
['AccountId', 'String'],
['AccountID', 'String'],
['accountId', 'String'],
['accountid', 'String'],
['bytes', 'Bytes'],
['string', 'String'],
['bool', 'Boolean'],
['boolean', 'Boolean'],
['Bool', 'Boolean'],
['float', 'BigDecimal'],
['Float', 'BigDecimal'],
['int', 'Int'],
['uint', 'BigInt'],
['owner', 'String'],
['Owner', 'String'],
[/^(u|uint)(8|16|24)$/, 'Int'],
[/^(i|int)(8|16|24|32)$/, 'Int'],
[/^(u|uint)32$/, 'BigInt'],
[
/^(u|uint|i|int)(40|48|56|64|72|80|88|96|104|112|120|128|136|144|152|160|168|176|184|192|200|208|216|224|232|240|248|256)$/,
'BigInt',
],
]
// As a convention, the type _Schema_ is reserved to define imports on.
const RESERVED_TYPE = '_Schema_'
/**
* Returns a GraphQL type suggestion for a given input type.
* Returns `undefined` if no suggestion is available for the type.
*/
const typeSuggestion = typeName =>
TYPE_SUGGESTIONS.filter(([pattern, _]) => {
return typeof pattern === 'string' ? pattern === typeName : typeName.match(pattern)
}).map(([_, suggestion]) => suggestion)[0]
const loadSchema = filename => {
try {
return fs.readFileSync(filename, 'utf-8')
} catch (e) {
throw new Error(`Failed to load GraphQL schema: ${e}`)
}
}
const parseSchema = doc => {
try {
return graphql.parse(doc)
} catch (e) {
throw new Error(`Invalid GraphQL schema: ${e}`)
}
}
const validateEntityDirective = def =>
def.directives.find(directive => directive.name.value === 'entity')
? []
: [
{
loc: def.loc,
entity: def.name.value,
message: `Defined without @entity directive`,
},
]
const validateEntityID = def => {
let idField = def.fields.find(field => field.name.value === 'id')
if (idField === undefined) {
return [
{
loc: def.loc,
entity: def.name.value,
message: `Missing field: id: ID!`,
},
]
}
if (
idField.type.kind === 'NonNullType' &&
idField.type.type.kind === 'NamedType' &&
(idField.type.type.name.value === 'ID' ||
idField.type.type.name.value === 'Bytes' ||
idField.type.type.name.value === 'String')
) {
return []
} else {
return [
{
loc: idField.loc,
entity: def.name.value,
message: `Field 'id': Entity ids must be of type Bytes! or String!`,
},
]
}
}
const validateListFieldType = (def, field) =>
field.type.kind === 'NonNullType' &&
field.type.kind === 'ListType' &&
field.type.type.kind !== 'NonNullType'
? [
{
loc: field.loc,
entity: def.name.value,
message: `\
Field '${field.name.value}':
Field has type [${field.type.type.name.value}]! but
must have type [${field.type.type.name.value}!]!
Reason: Lists with null elements are not supported.`,
},
]
: field.type.kind === 'ListType' && field.type.type.kind !== 'NonNullType'
? [
{
loc: field.loc,
entity: def.name.value,
message: `\
Field '${field.name.value}':
Field has type [${field.type.type.name.value}] but
must have type [${field.type.type.name.value}!]
Reason: Lists with null elements are not supported.`,
},
]
: []
const unwrapType = type => {
let innerTypeFromList = listType =>
listType.type.kind === 'NonNullType'
? innerTypeFromNonNull(listType.type)
: listType.type
let innerTypeFromNonNull = nonNullType =>
nonNullType.type.kind === 'ListType'
? innerTypeFromList(nonNullType.type)
: nonNullType.type
// Obtain the inner-most type from the field
return type.kind === 'NonNullType'
? innerTypeFromNonNull(type)
: type.kind === 'ListType'
? innerTypeFromList(type)
: type
}
const gatherLocalTypes = defs =>
defs
.filter(
def =>
def.kind === 'ObjectTypeDefinition' ||
def.kind === 'EnumTypeDefinition' ||
def.kind === 'InterfaceTypeDefinition',
)
.map(def => def.name.value)
const gatherImportedTypes = defs =>
defs
.filter(
def =>
def.kind === 'ObjectTypeDefinition' &&
def.name.value == RESERVED_TYPE &&
def.directives.find(
directive =>
directive.name.value == 'import' &&
directive.arguments.find(argument => argument.name.value == 'types'),
),
)
.map(def =>
def.directives
.filter(
directive =>
directive.name.value == 'import' &&
directive.arguments.find(argument => argument.name.value == 'types'),
)
.map(imp =>
imp.arguments.find(
argument =>
argument.name.value == 'types' && argument.value.kind == 'ListValue',
),
)
.map(arg =>
arg.value.values.map(type =>
type.kind == 'StringValue'
? type.value
: type.kind == 'ObjectValue' &&
type.fields.find(
field => field.name.value == 'as' && field.value.kind == 'StringValue',
)
? type.fields.find(field => field.name.value == 'as').value.value
: undefined,
),
),
)
.reduce(
(flattened, types_args) =>
flattened.concat(
types_args.reduce((flattened, types_arg) => {
types_arg.forEach(type => (type ? flattened.push(type) : undefined))
return flattened
}, []),
),
[],
)
const entityTypeByName = (defs, name) =>
defs
.filter(
def =>
def.kind === 'InterfaceTypeDefinition' ||
(def.kind === 'ObjectTypeDefinition' &&
def.directives.find(directive => directive.name.value === 'entity')),
)
.find(def => def.name.value === name)
const fieldTargetEntityName = field => unwrapType(field.type).name.value
const fieldTargetEntity = (defs, field) =>
entityTypeByName(defs, fieldTargetEntityName(field))
const validateInnerFieldType = (defs, def, field) => {
let innerType = unwrapType(field.type)
// Get the name of the type
let typeName = innerType.name.value
// Look up a possible suggestion for the type to catch common mistakes
let suggestion = typeSuggestion(typeName)
// Collect all types that we can use here: built-ins + entities + enums + interfaces
let availableTypes = List.of(
...BUILTIN_SCALAR_TYPES,
...gatherLocalTypes(defs),
...gatherImportedTypes(defs),
)
// Check whether the type name is available, otherwise return an error
return availableTypes.includes(typeName)
? []
: [
{
loc: field.loc,
entity: def.name.value,
message: `\
Field '${field.name.value}': \
Unknown type '${typeName}'.${
suggestion !== undefined ? ` Did you mean '${suggestion}'?` : ''
}`,
},
]
}
const validateEntityFieldType = (defs, def, field) =>
List.of(
...validateListFieldType(def, field),
...validateInnerFieldType(defs, def, field),
)
const validateEntityFieldArguments = (defs, def, field) =>
field.arguments.length > 0
? [
{
loc: field.loc,
entity: def.name.value,
message: `\
Field '${field.name.value}': \
Field arguments are not supported.`,
},
]
: []
const entityFieldExists = (entityDef, name) =>
entityDef.fields.find(field => field.name.value === name) !== undefined
const validateDerivedFromDirective = (defs, def, field, directive) => {
// Validate that there is a `field` argument and nothing else
if (directive.arguments.length !== 1 || directive.arguments[0].name.value !== 'field') {
return [
{
loc: directive.loc,
entity: def.name.value,
message: `\
Field '${field.name.value}': \
@derivedFrom directive must have a 'field' argument`,
},
]
}
// Validate that the "field" argument value is a string
if (directive.arguments[0].value.kind !== 'StringValue') {
return [
{
loc: directive.loc,
entity: def.name.value,
message: `\
Field '${field.name.value}': \
Value of the @derivedFrom 'field' argument must be a string`,
},
]
}
let targetEntity = fieldTargetEntity(defs, field)
if (targetEntity === undefined) {
// This is handled in `validateInnerFieldType` but if we don't catch
// the undefined case here, the code below will throw, as it assumes
// the target entity exists
return []
}
let derivedFromField = targetEntity.fields.find(
field => field.name.value === directive.arguments[0].value.value,
)
if (derivedFromField === undefined) {
return [
{
loc: directive.loc,
entity: def.name.value,
message: `\
Field '${field.name.value}': \
@derivedFrom field '${directive.arguments[0].value.value}' \
does not exist on type '${targetEntity.name.value}'`,
},
]
}
let backrefTypeName = unwrapType(derivedFromField.type)
let backRefEntity = entityTypeByName(defs, backrefTypeName.name.value)
// The field we are deriving from must either have type 'def' or one of the
// interface types that 'def' is implementing
if (
!backRefEntity ||
(backRefEntity.name.value !== def.name.value &&
!def.interfaces.find(intf => intf.name.value === backRefEntity.name.value))
) {
return [
{
loc: directive.loc,
entity: def.name.value,
message: `\
Field '${field.name.value}': \
@derivedFrom field '${directive.arguments[0].value.value}' \
on type '${targetEntity.name.value}' must have the type \
'${def.name.value}', '${def.name.value}!', '[${def.name.value}!]!', \
or one of the interface types that '${def.name.value}' implements`,
},
]
}
return []
}
const validateEntityFieldDirective = (defs, def, field, directive) =>
directive.name.value === 'derivedFrom'
? validateDerivedFromDirective(defs, def, field, directive)
: []
const validateEntityFieldDirectives = (defs, def, field) =>
field.directives.reduce(
(errors, directive) =>
errors.concat(validateEntityFieldDirective(defs, def, field, directive)),
[],
)
const validateEntityFields = (defs, def) =>
def.fields.reduce(
(errors, field) =>
errors
.concat(validateEntityFieldType(defs, def, field))
.concat(validateEntityFieldArguments(defs, def, field))
.concat(validateEntityFieldDirectives(defs, def, field)),
[],
)
const validateNoImportDirective = def =>
def.directives.find(directive => directive.name.value == 'import')
? [
{
loc: def.name.loc,
entity: def.name.value,
message: `@import directive only allowed on '${RESERVED_TYPE}' type`,
},
]
: []
const validateNoFulltext = def =>
def.directives.find(directive => directive.name.value == 'fulltext')
? [
{
loc: def.name.loc,
entity: def.name.value,
message: `@fulltext directive only allowed on '${RESERVED_TYPE}' type`,
},
]
: []
const validateFulltextFields = (def, directive) => {
return directive.arguments.reduce((errors, argument) => {
return errors.concat(
['name', 'language', 'algorithm', 'include'].includes(argument.name.value)
? []
: [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `found invalid argument: '${argument.name.value}', @fulltext directives only allow 'name', 'language', 'algorithm', and 'includes' arguments`,
},
],
)
}, [])
}
const validateFulltextName = (def, directive) => {
let name = directive.arguments.find(argument => argument.name.value == 'name')
return name
? validateFulltextArgumentName(def, directive, name)
: [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `@fulltext argument 'name' must be specified`,
},
]
}
const validateFulltextArgumentName = (def, directive, argument) => {
return argument.value.kind != 'StringValue'
? [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `@fulltext argument 'name' must be a string`,
},
]
: []
}
const fulltextDirectiveName = directive => {
let arg = directive.arguments.find(argument => argument.name.value == 'name')
return arg ? arg.value.value : 'Other'
}
const validateFulltextLanguage = (def, directive) => {
let language = directive.arguments.find(argument => argument.name.value == 'language')
return language
? validateFulltextArgumentLanguage(def, directive, language)
: [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `@fulltext argument 'language' must be specified`,
},
]
}
const validateFulltextArgumentLanguage = (def, directive, argument) => {
let languages = [
'simple',
'da',
'nl',
'en',
'fi',
'fr',
'de',
'hu',
'it',
'no',
'pt',
'ro',
'ru',
'es',
'sv',
'tr',
]
if (argument.value.kind != 'EnumValue') {
return [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `@fulltext 'language' value must be one of: ${languages.join(', ')}`,
},
]
} else if (!languages.includes(argument.value.value)) {
return [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `@fulltext directive 'language' value must be one of: ${languages.join(
', ',
)}`,
},
]
} else {
return []
}
}
const validateFulltextAlgorithm = (def, directive) => {
let algorithm = directive.arguments.find(argument => argument.name.value == 'algorithm')
return algorithm
? validateFulltextArgumentAlgorithm(def, directive, algorithm)
: [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `@fulltext argument 'algorithm' must be specified`,
},
]
}
const validateFulltextArgumentAlgorithm = (def, directive, argument) => {
if (argument.value.kind != 'EnumValue') {
return [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `@fulltext argument 'algorithm' must be one of: rank, proximityRank`,
},
]
} else if (!['rank', 'proximityRank'].includes(argument.value.value)) {
return [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `@fulltext 'algorithm' value, '${argument.value.value}', must be one of: rank, proximityRank`,
},
]
} else {
return List([])
}
}
const validateFulltextInclude = (def, directive) => {
let include = directive.arguments.find(argument => argument.name.value == 'include')
if (include) {
if (include.value.kind != 'ListValue') {
return [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `@fulltext argument 'include' must be a list`,
},
]
}
return include.value.values.reduce(
(errors, type) =>
errors.concat(validateFulltextArgumentInclude(def, directive, type)),
[],
)
} else {
return [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `@fulltext argument 'include' must be specified`,
},
]
}
}
const validateFulltextArgumentInclude = (def, directive, argument) => {
if (argument.kind != 'ObjectValue') {
return [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `@fulltext argument 'include' must have the form '[{entity: "entityName", fields: [{name: "fieldName"}, ...]} ...]`,
},
]
}
if (argument.fields.length != 2) {
return [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `@fulltext argument include must have two fields, 'entity' and 'fields'`,
},
]
}
return argument.fields.reduce(
(errors, field) =>
errors.concat(validateFulltextArgumentIncludeFields(def, directive, field)),
List([]),
)
}
const validateFulltextArgumentIncludeFields = (def, directive, field) => {
if (!['entity', 'fields'].includes(field.name.value)) {
return [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `@fulltext argument 'include > ${field.name.value}' must be be one of: entity, fields`,
},
]
}
if (field.name.value == 'entity' && field.value.kind != 'StringValue') {
return [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `@fulltext argument 'include > entity' must be the name of an entity in the schema enclosed in double quotes`,
},
]
} else if (field.name.value == 'fields' && field.value.kind != 'ListValue') {
return [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `@fulltext argument 'include > fields' must be a list`,
},
]
} else if (field.name.value == 'fields' && field.value.kind == 'ListValue') {
return field.value.values.reduce(
(errors, field) =>
errors.concat(
validateFulltextArgumentIncludeFieldsObjects(def, directive, field),
),
List([]),
)
} else {
return List([])
}
}
const validateFulltextArgumentIncludeFieldsObjects = (def, directive, argument) => {
if (argument.kind != 'ObjectValue') {
return [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `@fulltext argument 'include > fields' must have the form '[{ name: "fieldName" }, ...]`,
},
]
} else {
return argument.fields.reduce(
(errors, field) =>
errors.concat(
validateFulltextArgumentIncludeArgumentFieldsObject(def, directive, field),
),
[],
)
}
}
const validateFulltextArgumentIncludeArgumentFieldsObject = (def, directive, field) => {
if (!['name'].includes(field.name.value)) {
return [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `@fulltext argument 'include > fields' has invalid member '${field.name.value}', must be one of: name`,
},
]
} else if (field.name.value == 'name' && field.value.kind != 'StringValue') {
return [
{
loc: directive.name.loc,
entity: def.name.value,
directive: fulltextDirectiveName(directive),
message: `@fulltext argument 'include > fields > name' must be the name of an entity field enclosed in double quotes`,
},
]
} else {
return List([])
}
}
const importDirectiveTypeValidators = {
StringValue: (_def, _directive, _type) => [],
ObjectValue: (def, directive, type) => {
let errors = []
if (type.fields.length != 2) {
return errors.push({
loc: directive.name.loc,
entity: def.name.value,
message: `Import must be one of "Name" or { name: "Name", as: "Alias" }`,
})
}
return type.fields.reduce((errors, field) => {
if (!['name', 'as'].includes(field.name.value)) {
return errors.push({
loc: directive.name.loc,
entity: def.name.value,
message: `@import field '${field.name.value}' invalid, may only be one of: name, as`,
})
}
if (field.value.kind != 'StringValue') {
return errors.push({
loc: directive.name.loc,
entity: def.name.value,
message: `@import fields [name, as] must be strings`,
})
}
return errors
}, errors)
},
}
const validateImportDirectiveType = (def, directive, type) => {
return importDirectiveTypeValidators[type.kind]
? importDirectiveTypeValidators[type.kind](def, directive, type)
: [
{
loc: directive.name.loc,
entity: def.name.value,
message: `Import must be one of "Name" or { name: "Name", as: "Alias" }`,
},
]
}
const validateImportDirectiveArgumentTypes = (def, directive, argument) => {
if (argument.value.kind != 'ListValue') {
return [
{
loc: directive.name.loc,
entity: def.name.value,
message: `@import argument 'types' must be an list`,
},
]
}
return argument.value.values.reduce(
(errors, type) => errors.concat(validateImportDirectiveType(def, directive, type)),
[],
)
}
const validateImportDirectiveArgumentFrom = (def, directive, argument) => {
if (argument.value.kind != 'ObjectValue') {
return [
{
loc: directive.name.loc,
entity: def.name.value,
message: `@import argument 'from' must be an object`,
},
]
}
if (argument.value.fields.length != 1) {
return [
{
loc: directive.name.loc,
entity: def.name.value,
message: `@import argument 'from' must have an 'id' or 'name' field`,
},
]
}
return argument.value.fields.reduce((errors, field) => {
if (!['name', 'id'].includes(field.name.value)) {
return errors.push({
loc: field.name.loc,
entity: def.name.value,
message: `@import argument 'from' must be one of { name: "Name" } or { id: "ID" }`,
})
}
if (field.value.kind != 'StringValue') {
return errors.push({
loc: field.name.loc,
entity: def.name.value,
message: `@import argument 'from' must be one of { name: "Name" } or { id: "ID" } with string values`,
})
}
return errors
}, [])
}
const validateImportDirectiveFields = (def, directive) => {
return directive.arguments.reduce((errors, argument) => {
return errors.concat(
['types', 'from'].includes(argument.name.value)
? []
: [
{
loc: directive.name.loc,
entity: def.name.value,
message: `found invalid argument: '${argument.name.value}', @import directives only allow 'types' and 'from' arguments`,
},
],
)
}, [])
}
const validateImportDirectiveTypes = (def, directive) => {
let types = directive.arguments.find(argument => argument.name.value == 'types')
return types
? validateImportDirectiveArgumentTypes(def, directive, types)
: [
{
loc: directive.name.loc,
entity: def.name.value,
message: `@import argument 'types' must be specified`,
},
]
}
const validateImportDirectiveFrom = (def, directive) => {
let from = directive.arguments.find(argument => argument.name.value == 'from')
return from
? validateImportDirectiveArgumentFrom(def, directive, from)
: [
{
loc: directive.name.loc,
entity: def.name.value,
message: `@import argument 'from' must be specified`,
},
]
}
const validateImportDirective = (def, directive) =>
List.of(
...validateImportDirectiveFields(def, directive),
...validateImportDirectiveTypes(def, directive),
...validateImportDirectiveFrom(def, directive),
)
const validateFulltext = (def, directive) =>
List.of(
...validateFulltextFields(def, directive),
...validateFulltextName(def, directive),
...validateFulltextLanguage(def, directive),
...validateFulltextAlgorithm(def, directive),
...validateFulltextInclude(def, directive),
)
const validateSubgraphSchemaDirective = (def, directive) => {
if (directive.name.value == 'import') {
return validateImportDirective(def, directive)
} else if (directive.name.value == 'fulltext') {
return validateFulltext(def, directive)
} else {
return [
{
loc: directive.name.loc,
entity: def.name.value,
message: `${RESERVED_TYPE} type only allows @import and @fulltext directives`,
},
]
}
}
const validateSubgraphSchemaDirectives = def =>
def.directives.reduce(
(errors, directive) => errors.concat(validateSubgraphSchemaDirective(def, directive)),
[],
)
const validateTypeHasNoFields = def =>
def.fields.length
? [
{
loc: def.name.loc,
entity: def.name.value,
message: `${def.name.value} type is not allowed any fields by convention`,
},
]
: []
const validateAtLeastOneExtensionField = def => []
const typeDefinitionValidators = {
ObjectTypeDefinition: (defs, def) =>
def.name && def.name.value == RESERVED_TYPE
? List.of(...validateSubgraphSchemaDirectives(def), ...validateTypeHasNoFields(def))
: List.of(
...validateEntityDirective(def),
...validateEntityID(def),
...validateEntityFields(defs, def),
...validateNoImportDirective(def),
...validateNoFulltext(def),
),
ObjectTypeExtension: (_defs, def) => validateAtLeastOneExtensionField(def),
}
const validateTypeDefinition = (defs, def) =>
typeDefinitionValidators[def.kind] !== undefined
? typeDefinitionValidators[def.kind](defs, def)
: []
const validateTypeDefinitions = defs =>
defs.reduce((errors, def) => errors.concat(validateTypeDefinition(defs, def)), [])
const validateNamingCollisionsInTypes = types => {
let seen = Set()
let conflicting = Set()
return types.reduce((errors, type) => {
if (seen.has(type) && !conflicting.has(type)) {
errors = errors.push({
loc: { start: 1, end: 1 },
entity: type,
message: `Type '${type}' is defined more than once`,
})
conflicting = conflicting.add(type)
} else {
seen = seen.add(type)
}
return errors
}, [])
}
const validateNamingCollisions = (local, imported) =>
validateNamingCollisionsInTypes(local.concat(imported))
const validateSchema = filename => {
let doc = loadSchema(filename)
let schema = parseSchema(doc)
return List.of(
...validateTypeDefinitions(schema.definitions),
...validateNamingCollisions(
gatherLocalTypes(schema.definitions),
gatherImportedTypes(schema.definitions),
),
)
}
module.exports = { typeSuggestion, validateSchema }