graphql-php/tests/Utils/BuildSchemaTest.php
Daniel Tschinder 06c6c4bd97 Validate schema root types and directives
This moves validation out of GraphQLSchema's constructor (but not yet from other type constructors), which is responsible for root type validation and interface implementation checking.

Reduces time to construct GraphQLSchema significantly, shifting the time to validation.

This also allows for much looser rules within the schema builders, which implicitly validate while trying to adhere to flow types. Instead we use any casts to loosen the rules to defer that to validation where errors can be richer.

This also loosens the rule that a schema can only be constructed if it has a query type, moving that to validation as well. That makes flow typing slightly less nice, but allows for incremental schema building which is valuable

ref: graphql/graphql-js#1124
2018-02-13 10:42:35 +01:00

1144 lines
23 KiB
PHP

<?php
namespace GraphQL\Tests\Utils;
use GraphQL\GraphQL;
use GraphQL\Language\AST\EnumTypeDefinitionNode;
use GraphQL\Language\AST\InterfaceTypeDefinitionNode;
use GraphQL\Language\AST\ObjectTypeDefinitionNode;
use GraphQL\Language\Parser;
use GraphQL\Language\Printer;
use GraphQL\Type\Definition\EnumType;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Utils\BuildSchema;
use GraphQL\Utils\SchemaPrinter;
use GraphQL\Type\Definition\Directive;
class BuildSchemaTest extends \PHPUnit_Framework_TestCase
{
// Describe: Schema Builder
private function cycleOutput($body, $options = [])
{
$ast = Parser::parse($body);
$schema = BuildSchema::buildAST($ast, $options);
return "\n" . SchemaPrinter::doPrint($schema, $options);
}
/**
* @it can use built schema for limited execution
*/
public function testUseBuiltSchemaForLimitedExecution()
{
$schema = BuildSchema::buildAST(Parser::parse('
schema { query: Query }
type Query {
str: String
}
'));
$result = GraphQL::executeQuery($schema, '{ str }', ['str' => 123]);
$this->assertEquals(['str' => 123], $result->toArray(true)['data']);
}
/**
* @it can build a schema directly from the source
*/
public function testBuildSchemaDirectlyFromSource()
{
$schema = BuildSchema::build("
schema { query: Query }
type Query {
add(x: Int, y: Int): Int
}
");
$result = GraphQL::executeQuery(
$schema,
'{ add(x: 34, y: 55) }',
[
'add' => function ($root, $args) {
return $args['x'] + $args['y'];
}
]
);
$this->assertEquals(['data' => ['add' => 89]], $result->toArray(true));
}
/**
* @it Simple Type
*/
public function testSimpleType()
{
$body = '
schema {
query: HelloScalars
}
type HelloScalars {
str: String
int: Int
float: Float
id: ID
bool: Boolean
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it With directives
*/
public function testWithDirectives()
{
$body = '
schema {
query: Hello
}
directive @foo(arg: Int) on FIELD
type Hello {
str: String
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it Supports descriptions
*/
public function testSupportsDescriptions()
{
$body = '
schema {
query: Hello
}
"""This is a directive"""
directive @foo(
"""It has an argument"""
arg: Int
) on FIELD
"""With an enum"""
enum Color {
RED
"""Not a creative color"""
GREEN
BLUE
}
"""What a great type"""
type Hello {
"""And a field to boot"""
str: String
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($body, $output);
}
/**
* @it Supports descriptions
*/
public function testSupportsOptionForCommentDescriptions()
{
$body = '
schema {
query: Hello
}
# This is a directive
directive @foo(
# It has an argument
arg: Int
) on FIELD
# With an enum
enum Color {
RED
# Not a creative color
GREEN
BLUE
}
# What a great type
type Hello {
# And a field to boot
str: String
}
';
$output = $this->cycleOutput($body, [ 'commentDescriptions' => true ]);
$this->assertEquals($body, $output);
}
/**
* @it Maintains @skip & @include
*/
public function testMaintainsSkipAndInclude()
{
$body = '
schema {
query: Hello
}
type Hello {
str: String
}
';
$schema = BuildSchema::buildAST(Parser::parse($body));
$this->assertEquals(count($schema->getDirectives()), 3);
$this->assertEquals($schema->getDirective('skip'), Directive::skipDirective());
$this->assertEquals($schema->getDirective('include'), Directive::includeDirective());
$this->assertEquals($schema->getDirective('deprecated'), Directive::deprecatedDirective());
}
/**
* @it Overriding directives excludes specified
*/
public function testOverridingDirectivesExcludesSpecified()
{
$body = '
schema {
query: Hello
}
directive @skip on FIELD
directive @include on FIELD
directive @deprecated on FIELD_DEFINITION
type Hello {
str: String
}
';
$schema = BuildSchema::buildAST(Parser::parse($body));
$this->assertEquals(count($schema->getDirectives()), 3);
$this->assertNotEquals($schema->getDirective('skip'), Directive::skipDirective());
$this->assertNotEquals($schema->getDirective('include'), Directive::includeDirective());
$this->assertNotEquals($schema->getDirective('deprecated'), Directive::deprecatedDirective());
}
/**
* @it Type modifiers
*/
public function testTypeModifiers()
{
$body = '
schema {
query: HelloScalars
}
type HelloScalars {
nonNullStr: String!
listOfStrs: [String]
listOfNonNullStrs: [String!]
nonNullListOfStrs: [String]!
nonNullListOfNonNullStrs: [String!]!
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it Recursive type
*/
public function testRecursiveType()
{
$body = '
schema {
query: Recurse
}
type Recurse {
str: String
recurse: Recurse
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it Two types circular
*/
public function testTwoTypesCircular()
{
$body = '
schema {
query: TypeOne
}
type TypeOne {
str: String
typeTwo: TypeTwo
}
type TypeTwo {
str: String
typeOne: TypeOne
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it Single argument field
*/
public function testSingleArgumentField()
{
$body = '
schema {
query: Hello
}
type Hello {
str(int: Int): String
floatToStr(float: Float): String
idToStr(id: ID): String
booleanToStr(bool: Boolean): String
strToStr(bool: String): String
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it Simple type with multiple arguments
*/
public function testSimpleTypeWithMultipleArguments()
{
$body = '
schema {
query: Hello
}
type Hello {
str(int: Int, bool: Boolean): String
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it Simple type with interface
*/
public function testSimpleTypeWithInterface()
{
$body = '
schema {
query: Hello
}
type Hello implements WorldInterface {
str: String
}
interface WorldInterface {
str: String
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it Simple output enum
*/
public function testSimpleOutputEnum()
{
$body = '
schema {
query: OutputEnumRoot
}
enum Hello {
WORLD
}
type OutputEnumRoot {
hello: Hello
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it Multiple value enum
*/
public function testMultipleValueEnum()
{
$body = '
schema {
query: OutputEnumRoot
}
enum Hello {
WO
RLD
}
type OutputEnumRoot {
hello: Hello
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it Simple Union
*/
public function testSimpleUnion()
{
$body = '
schema {
query: Root
}
union Hello = World
type Root {
hello: Hello
}
type World {
str: String
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it Multiple Union
*/
public function testMultipleUnion()
{
$body = '
schema {
query: Root
}
union Hello = WorldOne | WorldTwo
type Root {
hello: Hello
}
type WorldOne {
str: String
}
type WorldTwo {
str: String
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it Specifying Union type using __typename
*/
public function testSpecifyingUnionTypeUsingTypename()
{
$schema = BuildSchema::buildAST(Parser::parse('
schema {
query: Root
}
type Root {
fruits: [Fruit]
}
union Fruit = Apple | Banana
type Apple {
color: String
}
type Banana {
length: Int
}
'));
$query = '
{
fruits {
... on Apple {
color
}
... on Banana {
length
}
}
}
';
$root = [
'fruits' => [
[
'color' => 'green',
'__typename' => 'Apple',
],
[
'length' => 5,
'__typename' => 'Banana',
]
]
];
$expected = [
'data' => [
'fruits' => [
['color' => 'green'],
['length' => 5],
]
]
];
$result = GraphQL::executeQuery($schema, $query, $root);
$this->assertEquals($expected, $result->toArray(true));
}
/**
* @it Specifying Interface type using __typename
*/
public function testSpecifyingInterfaceUsingTypename()
{
$schema = BuildSchema::buildAST(Parser::parse('
schema {
query: Root
}
type Root {
characters: [Character]
}
interface Character {
name: String!
}
type Human implements Character {
name: String!
totalCredits: Int
}
type Droid implements Character {
name: String!
primaryFunction: String
}
'));
$query = '
{
characters {
name
... on Human {
totalCredits
}
... on Droid {
primaryFunction
}
}
}
';
$root = [
'characters' => [
[
'name' => 'Han Solo',
'totalCredits' => 10,
'__typename' => 'Human',
],
[
'name' => 'R2-D2',
'primaryFunction' => 'Astromech',
'__typename' => 'Droid',
]
]
];
$expected = [
'data' => [
'characters' => [
['name' => 'Han Solo', 'totalCredits' => 10],
['name' => 'R2-D2', 'primaryFunction' => 'Astromech'],
]
]
];
$result = GraphQL::executeQuery($schema, $query, $root);
$this->assertEquals($expected, $result->toArray(true));
}
/**
* @it CustomScalar
*/
public function testCustomScalar()
{
$body = '
schema {
query: Root
}
scalar CustomScalar
type Root {
customScalar: CustomScalar
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it CustomScalar
*/
public function testInputObject()
{
$body = '
schema {
query: Root
}
input Input {
int: Int
}
type Root {
field(in: Input): String
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it Simple argument field with default
*/
public function testSimpleArgumentFieldWithDefault()
{
$body = '
schema {
query: Hello
}
type Hello {
str(int: Int = 2): String
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it Custom scalar argument field with default
*/
public function testCustomScalarArgumentFieldWithDefault()
{
$body = '
schema {
query: Hello
}
scalar CustomScalar
type Hello {
str(int: CustomScalar = 2): String
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it Simple type with mutation
*/
public function testSimpleTypeWithMutation()
{
$body = '
schema {
query: HelloScalars
mutation: Mutation
}
type HelloScalars {
str: String
int: Int
bool: Boolean
}
type Mutation {
addHelloScalars(str: String, int: Int, bool: Boolean): HelloScalars
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it Simple type with subscription
*/
public function testSimpleTypeWithSubscription()
{
$body = '
schema {
query: HelloScalars
subscription: Subscription
}
type HelloScalars {
str: String
int: Int
bool: Boolean
}
type Subscription {
subscribeHelloScalars(str: String, int: Int, bool: Boolean): HelloScalars
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it Unreferenced type implementing referenced interface
*/
public function testUnreferencedTypeImplementingReferencedInterface()
{
$body = '
type Concrete implements Iface {
key: String
}
interface Iface {
key: String
}
type Query {
iface: Iface
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it Unreferenced type implementing referenced union
*/
public function testUnreferencedTypeImplementingReferencedUnion()
{
$body = '
type Concrete {
key: String
}
type Query {
union: Union
}
union Union = Concrete
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
}
/**
* @it Supports @deprecated
*/
public function testSupportsDeprecated()
{
$body = '
enum MyEnum {
VALUE
OLD_VALUE @deprecated
OTHER_VALUE @deprecated(reason: "Terrible reasons")
}
type Query {
field1: String @deprecated
field2: Int @deprecated(reason: "Because I said so")
enum: MyEnum
}
';
$output = $this->cycleOutput($body);
$this->assertEquals($output, $body);
$ast = Parser::parse($body);
$schema = BuildSchema::buildAST($ast);
/** @var EnumType $myEnum */
$myEnum = $schema->getType('MyEnum');
$value = $myEnum->getValue('VALUE');
$this->assertFalse($value->isDeprecated());
$oldValue = $myEnum->getValue('OLD_VALUE');
$this->assertTrue($oldValue->isDeprecated());
$this->assertEquals('No longer supported', $oldValue->deprecationReason);
$otherValue = $myEnum->getValue('OTHER_VALUE');
$this->assertTrue($otherValue->isDeprecated());
$this->assertEquals('Terrible reasons', $otherValue->deprecationReason);
$rootFields = $schema->getType('Query')->getFields();
$this->assertEquals($rootFields['field1']->isDeprecated(), true);
$this->assertEquals($rootFields['field1']->deprecationReason, 'No longer supported');
$this->assertEquals($rootFields['field2']->isDeprecated(), true);
$this->assertEquals($rootFields['field2']->deprecationReason, 'Because I said so');
}
/**
* @it Correctly assign AST nodes
*/
public function testCorrectlyAssignASTNodes()
{
$schema = BuildSchema::build('
schema {
query: Query
}
type Query {
testField(testArg: TestInput): TestUnion
}
input TestInput {
testInputField: TestEnum
}
enum TestEnum {
TEST_VALUE
}
union TestUnion = TestType
interface TestInterface {
interfaceField: String
}
type TestType implements TestInterface {
interfaceField: String
}
directive @test(arg: Int) on FIELD
');
/** @var ObjectType $query */
$query = $schema->getType('Query');
$testInput = $schema->getType('TestInput');
$testEnum = $schema->getType('TestEnum');
$testUnion = $schema->getType('TestUnion');
$testInterface = $schema->getType('TestInterface');
$testType = $schema->getType('TestType');
$testDirective = $schema->getDirective('test');
$restoredIDL = SchemaPrinter::doPrint(BuildSchema::build(
Printer::doPrint($schema->getAstNode()) . "\n" .
Printer::doPrint($query->astNode) . "\n" .
Printer::doPrint($testInput->astNode) . "\n" .
Printer::doPrint($testEnum->astNode) . "\n" .
Printer::doPrint($testUnion->astNode) . "\n" .
Printer::doPrint($testInterface->astNode) . "\n" .
Printer::doPrint($testType->astNode) . "\n" .
Printer::doPrint($testDirective->astNode)
));
$this->assertEquals($restoredIDL, SchemaPrinter::doPrint($schema));
$testField = $query->getField('testField');
$this->assertEquals('testField(testArg: TestInput): TestUnion', Printer::doPrint($testField->astNode));
$this->assertEquals('testArg: TestInput', Printer::doPrint($testField->args[0]->astNode));
$this->assertEquals('testInputField: TestEnum', Printer::doPrint($testInput->getField('testInputField')->astNode));
$this->assertEquals('TEST_VALUE', Printer::doPrint($testEnum->getValue('TEST_VALUE')->astNode));
$this->assertEquals('interfaceField: String', Printer::doPrint($testInterface->getField('interfaceField')->astNode));
$this->assertEquals('interfaceField: String', Printer::doPrint($testType->getField('interfaceField')->astNode));
$this->assertEquals('arg: Int', Printer::doPrint($testDirective->args[0]->astNode));
}
// Describe: Failures
/**
* @it Allows only a single schema definition
*/
public function testAllowsOnlySingleSchemaDefinition()
{
$this->setExpectedException('GraphQL\Error\Error', 'Must provide only one schema definition.');
$body = '
schema {
query: Hello
}
schema {
query: Hello
}
type Hello {
bar: Bar
}
';
$doc = Parser::parse($body);
BuildSchema::buildAST($doc);
}
/**
* @it Allows only a single query type
*/
public function testAllowsOnlySingleQueryType()
{
$this->setExpectedException('GraphQL\Error\Error', 'Must provide only one query type in schema.');
$body = '
schema {
query: Hello
query: Yellow
}
type Hello {
bar: Bar
}
type Yellow {
isColor: Boolean
}
';
$doc = Parser::parse($body);
BuildSchema::buildAST($doc);
}
/**
* @it Allows only a single mutation type
*/
public function testAllowsOnlySingleMutationType()
{
$this->setExpectedException('GraphQL\Error\Error', 'Must provide only one mutation type in schema.');
$body = '
schema {
query: Hello
mutation: Hello
mutation: Yellow
}
type Hello {
bar: Bar
}
type Yellow {
isColor: Boolean
}
';
$doc = Parser::parse($body);
BuildSchema::buildAST($doc);
}
/**
* @it Allows only a single subscription type
*/
public function testAllowsOnlySingleSubscriptionType()
{
$this->setExpectedException('GraphQL\Error\Error', 'Must provide only one subscription type in schema.');
$body = '
schema {
query: Hello
subscription: Hello
subscription: Yellow
}
type Hello {
bar: Bar
}
type Yellow {
isColor: Boolean
}
';
$doc = Parser::parse($body);
BuildSchema::buildAST($doc);
}
/**
* @it Unknown type referenced
*/
public function testUnknownTypeReferenced()
{
$this->setExpectedException('GraphQL\Error\Error', 'Type "Bar" not found in document.');
$body = '
schema {
query: Hello
}
type Hello {
bar: Bar
}
';
$doc = Parser::parse($body);
$schema = BuildSchema::buildAST($doc);
$schema->getTypeMap();
}
/**
* @it Unknown type in interface list
*/
public function testUnknownTypeInInterfaceList()
{
$this->setExpectedException('GraphQL\Error\Error', 'Type "Bar" not found in document.');
$body = '
schema {
query: Hello
}
type Hello implements Bar {
field: String
}
';
$doc = Parser::parse($body);
$schema = BuildSchema::buildAST($doc);
$schema->getTypeMap();
}
/**
* @it Unknown type in union list
*/
public function testUnknownTypeInUnionList()
{
$this->setExpectedException('GraphQL\Error\Error', 'Type "Bar" not found in document.');
$body = '
schema {
query: Hello
}
union TestUnion = Bar
type Hello { testUnion: TestUnion }
';
$doc = Parser::parse($body);
$schema = BuildSchema::buildAST($doc);
$schema->getTypeMap();
}
/**
* @it Unknown query type
*/
public function testUnknownQueryType()
{
$this->setExpectedException('GraphQL\Error\Error', 'Specified query type "Wat" not found in document.');
$body = '
schema {
query: Wat
}
type Hello {
str: String
}
';
$doc = Parser::parse($body);
BuildSchema::buildAST($doc);
}
/**
* @it Unknown mutation type
*/
public function testUnknownMutationType()
{
$this->setExpectedException('GraphQL\Error\Error', 'Specified mutation type "Wat" not found in document.');
$body = '
schema {
query: Hello
mutation: Wat
}
type Hello {
str: String
}
';
$doc = Parser::parse($body);
BuildSchema::buildAST($doc);
}
/**
* @it Unknown subscription type
*/
public function testUnknownSubscriptionType()
{
$this->setExpectedException('GraphQL\Error\Error', 'Specified subscription type "Awesome" not found in document.');
$body = '
schema {
query: Hello
mutation: Wat
subscription: Awesome
}
type Hello {
str: String
}
type Wat {
str: String
}
';
$doc = Parser::parse($body);
BuildSchema::buildAST($doc);
}
/**
* @it Does not consider operation names
*/
public function testDoesNotConsiderOperationNames()
{
$this->setExpectedException('GraphQL\Error\Error', 'Specified query type "Foo" not found in document.');
$body = '
schema {
query: Foo
}
query Foo { field }
';
$doc = Parser::parse($body);
BuildSchema::buildAST($doc);
}
/**
* @it Does not consider fragment names
*/
public function testDoesNotConsiderFragmentNames()
{
$this->setExpectedException('GraphQL\Error\Error', 'Specified query type "Foo" not found in document.');
$body = '
schema {
query: Foo
}
fragment Foo on Type { field }
';
$doc = Parser::parse($body);
BuildSchema::buildAST($doc);
}
/**
* @it Forbids duplicate type definitions
*/
public function testForbidsDuplicateTypeDefinitions()
{
$body = '
schema {
query: Repeated
}
type Repeated {
id: Int
}
type Repeated {
id: String
}
';
$doc = Parser::parse($body);
$this->setExpectedException('GraphQL\Error\Error', 'Type "Repeated" was defined more than once.');
BuildSchema::buildAST($doc);
}
}