diff --git a/src/Executor/Values.php b/src/Executor/Values.php index 2e12ce0..ef33c5a 100644 --- a/src/Executor/Values.php +++ b/src/Executor/Values.php @@ -94,7 +94,7 @@ class Values * @return array * @throws Error */ - public static function getArgumentValues($def, $node, $variableValues) + public static function getArgumentValues($def, $node, $variableValues = null) { $argDefs = $def->args; $argNodes = $node->arguments; diff --git a/src/Type/Definition/Directive.php b/src/Type/Definition/Directive.php index dda85c7..f1c8c55 100644 --- a/src/Type/Definition/Directive.php +++ b/src/Type/Definition/Directive.php @@ -107,7 +107,7 @@ class Directive new FieldArgument([ 'name' => 'if', 'type' => Type::nonNull(Type::boolean()), - 'description' => 'Skipped when true' + 'description' => 'Skipped when true.' ]) ] ]), diff --git a/src/Type/Introspection.php b/src/Type/Introspection.php index 2a8db04..c8fa398 100644 --- a/src/Type/Introspection.php +++ b/src/Type/Introspection.php @@ -235,8 +235,8 @@ EOD; 'description' => 'A GraphQL Schema defines the capabilities of a GraphQL ' . 'server. It exposes all available types and directives on ' . - 'the server, as well as the entry points for query and ' . - 'mutation operations.', + 'the server, as well as the entry points for query, mutation, and ' . + 'subscription operations.', 'fields' => [ 'types' => [ 'description' => 'A list of all types supported by this server.', @@ -288,7 +288,7 @@ EOD; 'name' => '__Directive', 'description' => 'A Directive provides a way to describe alternate runtime execution and ' . 'type validation behavior in a GraphQL document.' . - '\n\nIn some cases, you need to provide options to alter GraphQL’s ' . + "\n\nIn some cases, you need to provide options to alter GraphQL's " . 'execution behavior in ways field arguments will not suffice, such as ' . 'conditionally including or skipping a field. Directives provide this by ' . 'describing additional information to the executor.', @@ -664,7 +664,7 @@ EOD; if (!isset(self::$map['__TypeKind'])) { self::$map['__TypeKind'] = new EnumType([ 'name' => '__TypeKind', - 'description' => 'An enum describing what kind of type a given __Type is.', + 'description' => 'An enum describing what kind of type a given `__Type` is.', 'values' => [ 'SCALAR' => [ 'value' => TypeKind::SCALAR, diff --git a/src/Utils.php b/src/Utils.php index 7dae895..1507182 100644 --- a/src/Utils.php +++ b/src/Utils.php @@ -180,6 +180,14 @@ class Utils return $grouped; } + public static function keyValMap($traversable, callable $keyFn, callable $valFn) + { + return array_reduce($traversable, function ($map, $item) use ($keyFn, $valFn) { + $map[$keyFn($item)] = $valFn($item); + return $map; + }, []); + } + /** * @param $traversable * @param callable $predicate diff --git a/src/Utils/BuildSchema.php b/src/Utils/BuildSchema.php new file mode 100644 index 0000000..718d61f --- /dev/null +++ b/src/Utils/BuildSchema.php @@ -0,0 +1,530 @@ +kind == NodeKind::LIST_TYPE) { + return Type::listOf($this->buildWrappedType($innerType, $inputTypeNode->type)); + } + if ($inputTypeNode->kind == NodeKind::NON_NULL_TYPE) { + $wrappedType = $this->buildWrappedType($innerType, $inputTypeNode->type); + Utils::invariant(!($wrappedType instanceof NonNull), 'No nesting nonnull.'); + return Type::nonNull($wrappedType); + } + return $innerType; + } + + private function getNamedTypeNode(TypeNode $typeNode) + { + $namedType = $typeNode; + while ($namedType->kind === NodeKind::LIST_TYPE || $namedType->kind === NodeKind::NON_NULL_TYPE) { + $namedType = $namedType->type; + } + return $namedType; + } + + /** + * This takes the ast of a schema document produced by the parse function in + * GraphQL\Language\Parser. + * + * If no schema definition is provided, then it will look for types named Query + * and Mutation. + * + * Given that AST it constructs a GraphQLSchema. The resulting schema + * has no resolve methods, so execution will use default resolvers. + * + * @param DocumentNode $ast + * @return Schema + * @throws Error + */ + public static function buildAST(DocumentNode $ast) + { + $builder = new self($ast); + return $builder->buildSchema(); + } + + private $ast; + private $innerTypeMap; + private $nodeMap; + + public function __construct(DocumentNode $ast) + { + $this->ast = $ast; + } + + public function buildSchema() + { + $schemaDef = null; + $typeDefs = []; + $this->nodeMap = []; + $directiveDefs = []; + foreach ($this->ast->definitions as $d) { + switch ($d->kind) { + case NodeKind::SCHEMA_DEFINITION: + if ($schemaDef) { + throw new Error('Must provide only one schema definition.'); + } + $schemaDef = $d; + break; + case NodeKind::SCALAR_TYPE_DEFINITION: + case NodeKind::OBJECT_TYPE_DEFINITION: + case NodeKind::INTERFACE_TYPE_DEFINITION: + case NodeKind::ENUM_TYPE_DEFINITION: + case NodeKind::UNION_TYPE_DEFINITION: + case NodeKind::INPUT_OBJECT_TYPE_DEFINITION: + $typeDefs[] = $d; + $this->nodeMap[$d->name->value] = $d; + break; + case NodeKind::DIRECTIVE_DEFINITION: + $directiveDefs[] = $d; + break; + } + } + + $queryTypeName = null; + $mutationTypeName = null; + $subscriptionTypeName = null; + if ($schemaDef) { + foreach ($schemaDef->operationTypes as $operationType) { + $typeName = $operationType->type->name->value; + if ($operationType->operation === 'query') { + if ($queryTypeName) { + throw new Error('Must provide only one query type in schema.'); + } + if (!isset($this->nodeMap[$typeName])) { + throw new Error( + 'Specified query type "' . $typeName . '" not found in document.' + ); + } + $queryTypeName = $typeName; + } else if ($operationType->operation === 'mutation') { + if ($mutationTypeName) { + throw new Error('Must provide only one mutation type in schema.'); + } + if (!isset($this->nodeMap[$typeName])) { + throw new Error( + 'Specified mutation type "' . $typeName . '" not found in document.' + ); + } + $mutationTypeName = $typeName; + } else if ($operationType->operation === 'subscription') { + if ($subscriptionTypeName) { + throw new Error('Must provide only one subscription type in schema.'); + } + if (!isset($this->nodeMap[$typeName])) { + throw new Error( + 'Specified subscription type "' . $typeName . '" not found in document.' + ); + } + $subscriptionTypeName = $typeName; + } + } + } else { + if (isset($this->nodeMap['Query'])) { + $queryTypeName = 'Query'; + } + if (isset($this->nodeMap['Mutation'])) { + $mutationTypeName = 'Mutation'; + } + if (isset($this->nodeMap['Subscription'])) { + $subscriptionTypeName = 'Subscription'; + } + } + + if (!$queryTypeName) { + throw new Error( + 'Must provide schema definition with query type or a type named Query.' + ); + } + + $this->innerTypeMap = [ + 'String' => Type::string(), + 'Int' => Type::int(), + 'Float' => Type::float(), + 'Boolean' => Type::boolean(), + 'ID' => Type::id(), + '__Schema' => Introspection::_schema(), + '__Directive' => Introspection::_directive(), + '__DirectiveLocation' => Introspection::_directiveLocation(), + '__Type' => Introspection::_type(), + '__Field' => Introspection::_field(), + '__InputValue' => Introspection::_inputValue(), + '__EnumValue' => Introspection::_enumValue(), + '__TypeKind' => Introspection::_typeKind(), + ]; + + $types = array_map(function($def) { + return $this->typeDefNamed($def->name->value); + }, $typeDefs); + + $directives = array_map([$this, 'getDirective'], $directiveDefs); + + // If specified directives were not explicitly declared, add them. + $skip = array_reduce($directives, function($hasSkip, $directive) { + return $hasSkip || $directive->name == 'skip'; + }); + if (!$skip) { + $directives[] = Directive::skipDirective(); + } + + $include = array_reduce($directives, function($hasInclude, $directive) { + return $hasInclude || $directive->name == 'include'; + }); + if (!$include) { + $directives[] = Directive::includeDirective(); + } + + $deprecated = array_reduce($directives, function($hasDeprecated, $directive) { + return $hasDeprecated || $directive->name == 'deprecated'; + }); + if (!$deprecated) { + $directives[] = Directive::deprecatedDirective(); + } + + return new Schema([ + 'query' => $this->getObjectType($this->nodeMap[$queryTypeName]), + 'mutation' => $mutationTypeName ? + $this->getObjectType($this->nodeMap[$mutationTypeName]) : + null, + 'subscription' => $subscriptionTypeName ? + $this->getObjectType($this->nodeMap[$subscriptionTypeName]) : + null, + 'types' => $types, + 'directives' => $directives, + ]); + } + + private function getDirective(DirectiveDefinitionNode $directiveNode) + { + return new Directive([ + 'name' => $directiveNode->name->value, + 'description' => $this->getDescription($directiveNode), + 'locations' => array_map(function($node) { + return $node->value; + }, $directiveNode->locations), + 'args' => $directiveNode->arguments ? FieldArgument::createMap($this->makeInputValues($directiveNode->arguments)) : null, + ]); + } + + private function getObjectType(TypeDefinitionNode $typeNode) + { + $type = $this->typeDefNamed($typeNode->name->value); + Utils::invariant( + $type instanceof ObjectType, + 'AST must provide object type.' + ); + return $type; + } + + private function produceType(TypeNode $typeNode) + { + $typeName = $this->getNamedTypeNode($typeNode)->name->value; + $typeDef = $this->typeDefNamed($typeName); + return $this->buildWrappedType($typeDef, $typeNode); + } + + private function produceInputType(TypeNode $typeNode) + { + $type = $this->produceType($typeNode); + Utils::invariant(Type::isInputType($type), 'Expected Input type.'); + return $type; + } + + private function produceOutputType(TypeNode $typeNode) + { + $type = $this->produceType($typeNode); + Utils::invariant(Type::isOutputType($type), 'Expected Input type.'); + return $type; + } + + private function produceObjectType(TypeNode $typeNode) + { + $type = $this->produceType($typeNode); + Utils::invariant($type instanceof ObjectType, 'Expected Object type.'); + return $type; + } + + private function produceInterfaceType(TypeNode $typeNode) + { + $type = $this->produceType($typeNode); + Utils::invariant($type instanceof InterfaceType, 'Expected Input type.'); + return $type; + } + + private function typeDefNamed($typeName) + { + if (isset($this->innerTypeMap[$typeName])) { + return $this->innerTypeMap[$typeName]; + } + + if (!isset($this->nodeMap[$typeName])) { + throw new Error('Type "' . $typeName . '" not found in document.'); + } + + $innerTypeDef = $this->makeSchemaDef($this->nodeMap[$typeName]); + if (!$innerTypeDef) { + throw new Error("Nothing constructed for $typeName."); + } + $this->innerTypeMap[$typeName] = $innerTypeDef; + return $innerTypeDef; + } + + private function makeSchemaDef($def) + { + if (!$def) { + throw new Error('def must be defined.'); + } + switch ($def->kind) { + case NodeKind::OBJECT_TYPE_DEFINITION: + return $this->makeTypeDef($def); + case NodeKind::INTERFACE_TYPE_DEFINITION: + return $this->makeInterfaceDef($def); + case NodeKind::ENUM_TYPE_DEFINITION: + return $this->makeEnumDef($def); + case NodeKind::UNION_TYPE_DEFINITION: + return $this->makeUnionDef($def); + case NodeKind::SCALAR_TYPE_DEFINITION: + return $this->makeScalarDef($def); + case NodeKind::INPUT_OBJECT_TYPE_DEFINITION: + return $this->makeInputObjectDef($def); + default: + throw new Error("Type kind of {$def->kind} not supported."); + } + } + + private function makeTypeDef(ObjectTypeDefinitionNode $def) + { + $typeName = $def->name->value; + return new ObjectType([ + 'name' => $typeName, + 'description' => $this->getDescription($def), + 'fields' => function() use ($def) { return $this->makeFieldDefMap($def); }, + 'interfaces' => function() use ($def) { return $this->makeImplementedInterfaces($def); } + ]); + } + + private function makeFieldDefMap($def) + { + return Utils::keyValMap( + $def->fields, + function ($field) { + return $field->name->value; + }, + function($field) { + return [ + 'type' => $this->produceOutputType($field->type), + 'description' => $this->getDescription($field), + 'args' => $this->makeInputValues($field->arguments), + 'deprecationReason' => $this->getDeprecationReason($field->directives) + ]; + } + ); + } + + private function makeImplementedInterfaces(ObjectTypeDefinitionNode $def) + { + return isset($def->interfaces) ? array_map([$this, 'produceInterfaceType'], $def->interfaces) : null; + } + + private function makeInputValues($values) + { + return Utils::keyValMap( + $values, + function ($value) { + return $value->name->value; + }, + function($value) { + $type = $this->produceInputType($value->type); + $config = [ + 'name' => $value->name->value, + 'type' => $type, + 'description' => $this->getDescription($value) + ]; + if (isset($value->defaultValue)) { + $config['defaultValue'] = Utils\AST::valueFromAST($value->defaultValue, $type); + } + return $config; + } + ); + } + + private function makeInterfaceDef(InterfaceTypeDefinitionNode $def) + { + $typeName = $def->name->value; + return new InterfaceType([ + 'name' => $typeName, + 'description' => $this->getDescription($def), + 'fields' => function() use ($def) { return $this->makeFieldDefMap($def); }, + 'resolveType' => [$this, 'cannotExecuteSchema'] + ]); + } + + private function makeEnumDef(EnumTypeDefinitionNode $def) + { + return new EnumType([ + 'name' => $def->name->value, + 'description' => $this->getDescription($def), + 'values' => Utils::keyValMap( + $def->values, + function($enumValue) { + return $enumValue->name->value; + }, + function($enumValue) { + return [ + 'description' => $this->getDescription($enumValue), + 'deprecationReason' => $this->getDeprecationReason($enumValue->directives) + ]; + } + ) + ]); + } + + private function makeUnionDef(UnionTypeDefinitionNode $def) + { + return new UnionType([ + 'name' => $def->name->value, + 'description' => $this->getDescription($def), + 'types' => array_map([$this, 'produceObjectType'], $def->types), + 'resolveType' => [$this, 'cannotExecuteSchema'] + ]); + } + + private function makeScalarDef(ScalarTypeDefinitionNode $def) + { + return new CustomScalarType([ + 'name' => $def->name->value, + 'description' => $this->getDescription($def), + 'serialize' => function() { return false; }, + // Note: validation calls the parse functions to determine if a + // literal value is correct. Returning null would cause use of custom + // scalars to always fail validation. Returning false causes them to + // always pass validation. + 'parseValue' => function() { return false; }, + 'parseLiteral' => function() { return false; } + ]); + } + + private function makeInputObjectDef(InputObjectTypeDefinitionNode $def) + { + return new InputObjectType([ + 'name' => $def->name->value, + 'description' => $this->getDescription($def), + 'fields' => function() use ($def) { return $this->makeInputValues($def->fields); } + ]); + } + + private function getDeprecationReason($directives) + { + $deprecatedAST = $directives ? Utils::find( + $directives, + function($directive) { + return $directive->name->value === Directive::deprecatedDirective()->name; + } + ) : null; + if (!$deprecatedAST) { + return; + } + return Values::getArgumentValues( + Directive::deprecatedDirective(), + $deprecatedAST + )['reason']; + } + + /** + * Given an ast node, returns its string description based on a contiguous + * block full-line of comments preceding it. + */ + public function getDescription($node) + { + $loc = $node->loc; + if (!$loc) { + return; + } + $comments = []; + $minSpaces = null; + $token = $loc->startToken->prev; + while ( + $token && + $token->kind === Token::COMMENT && + $token->next && $token->prev && + $token->line + 1 === $token->next->line && + $token->line !== $token->prev->line + ) { + $value = $token->value; + $spaces = $this->leadingSpaces($value); + if ($minSpaces === null || $spaces < $minSpaces) { + $minSpaces = $spaces; + } + $comments[] = $value; + $token = $token->prev; + } + return implode("\n", array_map(function($comment) use ($minSpaces) { + return mb_substr(str_replace("\n", '', $comment), $minSpaces); + }, array_reverse($comments))); + } + + /** + * A helper function to build a GraphQLSchema directly from a source + * document. + * + * @param Source|string $source + * @return + */ + public static function build($source) + { + return self::buildAST(Parser::parse($source)); + } + + // Count the number of spaces on the starting side of a string. + private function leadingSpaces($str) + { + return strlen($str) - strlen(ltrim($str)); + } + + public function cannotExecuteSchema() { + throw new Error( + 'Generated Schema cannot use Interface or Union types for execution.' + ); + } + +} \ No newline at end of file diff --git a/src/Utils/SchemaPrinter.php b/src/Utils/SchemaPrinter.php new file mode 100644 index 0000000..91099d4 --- /dev/null +++ b/src/Utils/SchemaPrinter.php @@ -0,0 +1,304 @@ +getDirectives(), function($directive) use ($directiveFilter) { + return $directiveFilter($directive->name); + }); + $typeMap = $schema->getTypeMap(); + $types = array_filter(array_keys($typeMap), $typeFilter); + sort($types); + $types = array_map(function($typeName) use ($typeMap) { return $typeMap[$typeName]; }, $types); + + return implode("\n\n", array_filter(array_merge( + [self::printSchemaDefinition($schema)], + array_map('self::printDirective', $directives), + array_map('self::printType', $types) + ))) . "\n"; + } + + private static function printSchemaDefinition(Schema $schema) + { + if (self::isSchemaOfCommonNames($schema)) { + return; + } + + $operationTypes = []; + + $queryType = $schema->getQueryType(); + if ($queryType) { + $operationTypes[] = " query: {$queryType->name}"; + } + + $mutationType = $schema->getMutationType(); + if ($mutationType) { + $operationTypes[] = " mutation: {$mutationType->name}"; + } + + $subscriptionType = $schema->getSubscriptionType(); + if ($subscriptionType) { + $operationTypes[] = " subscription: {$subscriptionType->name}"; + } + + return "schema {\n" . implode("\n", $operationTypes) . "\n}"; + } + + /** + * GraphQL schema define root types for each type of operation. These types are + * the same as any other type and can be named in any manner, however there is + * a common naming convention: + * + * schema { + * query: Query + * mutation: Mutation + * } + * + * When using this naming convention, the schema description can be omitted. + */ + private static function isSchemaOfCommonNames(Schema $schema) + { + $queryType = $schema->getQueryType(); + if ($queryType && $queryType->name !== 'Query') { + return false; + } + + $mutationType = $schema->getMutationType(); + if ($mutationType && $mutationType->name !== 'Mutation') { + return false; + } + + $subscriptionType = $schema->getSubscriptionType(); + if ($subscriptionType && $subscriptionType->name !== 'Subscription') { + return false; + } + + return true; + } + + public static function printType(Type $type) + { + if ($type instanceof ScalarType) { + return self::printScalar($type); + } else if ($type instanceof ObjectType) { + return self::printObject($type); + } else if ($type instanceof InterfaceType) { + return self::printInterface($type); + } else if ($type instanceof UnionType) { + return self::printUnion($type); + } else if ($type instanceof EnumType) { + return self::printEnum($type); + } + Utils::invariant($type instanceof InputObjectType); + return self::printInputObject($type); + } + + private static function printScalar(ScalarType $type) + { + return self::printDescription($type) . "scalar {$type->name}"; + } + + private static function printObject(ObjectType $type) + { + $interfaces = $type->getInterfaces(); + $implementedInterfaces = !empty($interfaces) ? + ' implements ' . implode(', ', array_map(function($i) { + return $i->name; + }, $interfaces)) : ''; + return self::printDescription($type) . + "type {$type->name}$implementedInterfaces {\n" . + self::printFields($type) . "\n" . + "}"; + } + + private static function printInterface(InterfaceType $type) + { + return self::printDescription($type) . + "interface {$type->name} {\n" . + self::printFields($type) . "\n" . + "}"; + } + + private static function printUnion(UnionType $type) + { + return self::printDescription($type) . + "union {$type->name} = " . implode(" | ", $type->getTypes()); + } + + private static function printEnum(EnumType $type) + { + return self::printDescription($type) . + "enum {$type->name} {\n" . + self::printEnumValues($type->getValues()) . "\n" . + "}"; + } + + private static function printEnumValues($values) + { + return implode("\n", array_map(function($value, $i) { + return self::printDescription($value, ' ', !$i) . ' ' . + $value->name . self::printDeprecated($value); + }, $values, array_keys($values))); + } + + private static function printInputObject(InputObjectType $type) + { + $fields = array_values($type->getFields()); + return self::printDescription($type) . + "input {$type->name} {\n" . + implode("\n", array_map(function($f, $i) { + return self::printDescription($f, ' ', !$i) . ' ' . self::printInputValue($f); + }, $fields, array_keys($fields))) . "\n" . + "}"; + } + + private static function printFields($type) + { + $fields = array_values($type->getFields()); + return implode("\n", array_map(function($f, $i) { + return self::printDescription($f, ' ', !$i) . ' ' . + $f->name . self::printArgs($f->args, ' ') . ': ' . + (string) $f->getType() . self::printDeprecated($f); + }, $fields, array_keys($fields))); + } + + private static function printArgs($args, $indentation = '') + { + if (count($args) === 0) { + return ''; + } + + // If every arg does not have a description, print them on one line. + if (Utils::every($args, function($arg) { return empty($arg->description); })) { + return '(' . implode(', ', array_map('self::printInputValue', $args)) . ')'; + } + + return "(\n" . implode("\n", array_map(function($arg, $i) use ($indentation) { + return self::printDescription($arg, ' ' . $indentation, !$i) . ' ' . $indentation . + self::printInputValue($arg); + }, $args, array_keys($args))) . "\n" . $indentation . ')'; + } + + private static function printInputValue($arg) + { + $argDecl = $arg->name . ': ' . (string) $arg->getType(); + if ($arg->defaultValueExists()) { + $argDecl .= ' = ' . Printer::doPrint(AST::astFromValue($arg->defaultValue, $arg->getType())); + } + return $argDecl; + } + + private static function printDirective($directive) + { + return self::printDescription($directive) . + 'directive @' . $directive->name . self::printArgs($directive->args) . + ' on ' . implode(' | ', $directive->locations); + } + + private static function printDeprecated($fieldOrEnumVal) + { + $reason = $fieldOrEnumVal->deprecationReason; + if (empty($reason)) { + return ''; + } + if ($reason === '' || $reason === Directive::DEFAULT_DEPRECATION_REASON) { + return ' @deprecated'; + } + return ' @deprecated(reason: ' . + Printer::doPrint(AST::astFromValue($reason, Type::string())) . ')'; + } + + private static function printDescription($def, $indentation = '', $firstInBlock = true) + { + if (!$def->description) { + return ''; + } + $lines = explode("\n", $def->description); + $description = $indentation && !$firstInBlock ? "\n" : ''; + foreach ($lines as $line) { + if ($line === '') { + $description .= $indentation . "#\n"; + } else { + // For > 120 character long lines, cut at space boundaries into sublines + // of ~80 chars. + $sublines = self::breakLine($line, 120 - strlen($indentation)); + foreach ($sublines as $subline) { + $description .= $indentation . '# ' . $subline . "\n"; + } + } + } + return $description; + } + + private static function breakLine($line, $len) + { + if (strlen($line) < $len + 5) { + return [$line]; + } + preg_match_all("/((?: |^).{15," . ($len - 40) . "}(?= |$))/", $line, $parts); + $parts = $parts[0]; + return array_map(function($part) { + return trim($part); + }, $parts); + } +} \ No newline at end of file diff --git a/tests/Type/IntrospectionTest.php b/tests/Type/IntrospectionTest.php index ac24e46..8d3f4b9 100644 --- a/tests/Type/IntrospectionTest.php +++ b/tests/Type/IntrospectionTest.php @@ -1514,7 +1514,7 @@ class IntrospectionTest extends \PHPUnit_Framework_TestCase 'description' => 'A GraphQL Schema defines the capabilities of a ' . 'GraphQL server. It exposes all available types and ' . 'directives on the server, as well as the entry ' . - 'points for query and mutation operations.', + 'points for query, mutation, and subscription operations.', 'fields' => [ [ 'name' => 'types', @@ -1571,7 +1571,7 @@ class IntrospectionTest extends \PHPUnit_Framework_TestCase 'data' => [ 'typeKindType' => [ 'name' => '__TypeKind', - 'description' => 'An enum describing what kind of type a given __Type is.', + 'description' => 'An enum describing what kind of type a given `__Type` is.', 'enumValues' => [ [ 'description' => 'Indicates this type is a scalar.', diff --git a/tests/Utils/BuildSchemaTest.php b/tests/Utils/BuildSchemaTest.php new file mode 100644 index 0000000..5c2663a --- /dev/null +++ b/tests/Utils/BuildSchemaTest.php @@ -0,0 +1,899 @@ + 123]); + $this->assertEquals($result['data'], ['str' => 123]); + } + + /** + * @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::execute( + $schema, + '{ add(x: 34, y: 55) }', + [ + 'add' => function ($root, $args) { + return $args['x'] + $args['y']; + } + ] + ); + $this->assertEquals($result, ['data' => ['add' => 89]]); + } + + /** + * @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($output, $body); + } + + /** + * @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 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 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); + + $this->assertEquals($schema->getType('MyEnum')->getValues(), [ + new EnumValueDefinition([ + 'name' => 'VALUE', + 'description' => '', + 'deprecationReason' => null, + 'value' => 'VALUE' + ]), + new EnumValueDefinition([ + 'name' => 'OLD_VALUE', + 'description' => '', + 'deprecationReason' => 'No longer supported', + 'value' => 'OLD_VALUE' + ]), + new EnumValueDefinition([ + 'name' => 'OTHER_VALUE', + 'description' => '', + 'deprecationReason' => 'Terrible reasons', + 'value' => 'OTHER_VALUE' + ]) + ]); + + $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'); + } + + // Describe: Failures + + /** + * @it Requires a schema definition or Query type + */ + public function testRequiresSchemaDefinitionOrQueryType() + { + $this->setExpectedException('GraphQL\Error\Error', 'Must provide schema definition with query type or a type named Query.'); + $body = ' +type Hello { + bar: Bar +} +'; + $doc = Parser::parse($body); + BuildSchema::buildAST($doc); + } + + /** + * @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 Requires a query type + */ + public function testRequiresQueryType() + { + $this->setExpectedException('GraphQL\Error\Error', 'Must provide schema definition with query type or a type named Query.'); + $body = ' +schema { + mutation: 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); + BuildSchema::buildAST($doc); + } + + /** + * @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 { } +'; + $doc = Parser::parse($body); + BuildSchema::buildAST($doc); + } + + /** + * @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); + BuildSchema::buildAST($doc); + } + + /** + * @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); + } +} diff --git a/tests/Utils/SchemaPrinterTest.php b/tests/Utils/SchemaPrinterTest.php new file mode 100644 index 0000000..49314f0 --- /dev/null +++ b/tests/Utils/SchemaPrinterTest.php @@ -0,0 +1,850 @@ + 'Root', + 'fields' => [ + 'singleField' => $fieldConfig + ] + ]); + return $this->printForTest(new Schema(['query' => $root])); + } + + /** + * @it Prints String Field + */ + public function testPrintsStringField() + { + $output = $this->printSingleFieldSchema([ + 'type' => Type::string() + ]); + $this->assertEquals($output, ' +schema { + query: Root +} + +type Root { + singleField: String +} +'); + } + + /** + * @it Prints [String] Field + */ + public function testPrintArrayStringField() + { + $output = $this->printSingleFieldSchema([ + 'type' => Type::listOf(Type::string()) + ]); + $this->assertEquals($output, ' +schema { + query: Root +} + +type Root { + singleField: [String] +} +'); + } + + /** + * @it Prints String! Field + */ + public function testPrintNonNullStringField() + { + $output = $this->printSingleFieldSchema([ + 'type' => Type::nonNull(Type::string()) + ]); + $this->assertEquals($output, ' +schema { + query: Root +} + +type Root { + singleField: String! +} +'); + } + + /** + * @it Prints [String]! Field + */ + public function testPrintNonNullArrayStringField() + { + $output = $this->printSingleFieldSchema([ + 'type' => Type::nonNull(Type::listOf(Type::string())) + ]); + $this->assertEquals($output, ' +schema { + query: Root +} + +type Root { + singleField: [String]! +} +'); + } + + /** + * @it Prints [String!] Field + */ + public function testPrintArrayNonNullStringField() + { + $output = $this->printSingleFieldSchema([ + 'type' => Type::listOf(Type::nonNull(Type::string())) + ]); + $this->assertEquals($output, ' +schema { + query: Root +} + +type Root { + singleField: [String!] +} +'); + } + + /** + * @it Prints [String!]! Field + */ + public function testPrintNonNullArrayNonNullStringField() + { + $output = $this->printSingleFieldSchema([ + 'type' => Type::nonNull(Type::listOf(Type::nonNull(Type::string()))) + ]); + $this->assertEquals($output, ' +schema { + query: Root +} + +type Root { + singleField: [String!]! +} +'); + } + + /** + * @it Prints Object Field + */ + public function testPrintObjectField() + { + $fooType = new ObjectType([ + 'name' => 'Foo', + 'fields' => ['str' => ['type' => Type::string()]] + ]); + + $root = new ObjectType([ + 'name' => 'Root', + 'fields' => ['foo' => ['type' => $fooType]] + ]); + + $schema = new Schema(['query' => $root]); + $output = $this->printForTest($schema); + $this->assertEquals($output, ' +schema { + query: Root +} + +type Foo { + str: String +} + +type Root { + foo: Foo +} +'); + } + + /** + * @it Prints String Field With Int Arg + */ + public function testPrintsStringFieldWithIntArg() + { + $output = $this->printSingleFieldSchema([ + 'type' => Type::string(), + 'args' => ['argOne' => ['type' => Type::int()]] + ]); + $this->assertEquals($output, ' +schema { + query: Root +} + +type Root { + singleField(argOne: Int): String +} +'); + } + + /** + * @it Prints String Field With Int Arg With Default + */ + public function testPrintsStringFieldWithIntArgWithDefault() + { + $output = $this->printSingleFieldSchema([ + 'type' => Type::string(), + 'args' => ['argOne' => ['type' => Type::int(), 'defaultValue' => 2]] + ]); + $this->assertEquals($output, ' +schema { + query: Root +} + +type Root { + singleField(argOne: Int = 2): String +} +'); + } + + /** + * @it Prints String Field With Int Arg With Default Null + */ + public function testPrintsStringFieldWithIntArgWithDefaultNull() + { + $output = $this->printSingleFieldSchema([ + 'type' => Type::string(), + 'args' => ['argOne' => ['type' => Type::int(), 'defaultValue' => null]] + ]); + $this->assertEquals($output, ' +schema { + query: Root +} + +type Root { + singleField(argOne: Int = null): String +} +'); + } + + /** + * @it Prints String Field With Int! Arg + */ + public function testPrintsStringFieldWithNonNullIntArg() + { + $output = $this->printSingleFieldSchema([ + 'type' => Type::string(), + 'args' => ['argOne' => ['type' => Type::nonNull(Type::int())]] + ]); + $this->assertEquals($output, ' +schema { + query: Root +} + +type Root { + singleField(argOne: Int!): String +} +'); + } + + /** + * @it Prints String Field With Multiple Args + */ + public function testPrintsStringFieldWithMultipleArgs() + { + $output = $this->printSingleFieldSchema([ + 'type' => Type::string(), + 'args' => [ + 'argOne' => ['type' => Type::int()], + 'argTwo' => ['type' => Type::string()] + ] + ]); + $this->assertEquals($output, ' +schema { + query: Root +} + +type Root { + singleField(argOne: Int, argTwo: String): String +} +'); + } + + /** + * @it Prints String Field With Multiple Args, First is Default + */ + public function testPrintsStringFieldWithMultipleArgsFirstIsDefault() + { + $output = $this->printSingleFieldSchema([ + 'type' => Type::string(), + 'args' => [ + 'argOne' => ['type' => Type::int(), 'defaultValue' => 1], + 'argTwo' => ['type' => Type::string()], + 'argThree' => ['type' => Type::boolean()] + ] + ]); + $this->assertEquals($output, ' +schema { + query: Root +} + +type Root { + singleField(argOne: Int = 1, argTwo: String, argThree: Boolean): String +} +'); + } + + /** + * @it Prints String Field With Multiple Args, Second is Default + */ + public function testPrintsStringFieldWithMultipleArgsSecondIsDefault() + { + $output = $this->printSingleFieldSchema([ + 'type' => Type::string(), + 'args' => [ + 'argOne' => ['type' => Type::int()], + 'argTwo' => ['type' => Type::string(), 'defaultValue' => 'foo'], + 'argThree' => ['type' => Type::boolean()] + ] + ]); + $this->assertEquals($output, ' +schema { + query: Root +} + +type Root { + singleField(argOne: Int, argTwo: String = "foo", argThree: Boolean): String +} +'); + } + + /** + * @it Prints String Field With Multiple Args, Last is Default + */ + public function testPrintsStringFieldWithMultipleArgsLastIsDefault() + { + $output = $this->printSingleFieldSchema([ + 'type' => Type::string(), + 'args' => [ + 'argOne' => ['type' => Type::int()], + 'argTwo' => ['type' => Type::string()], + 'argThree' => ['type' => Type::boolean(), 'defaultValue' => false] + ] + ]); + $this->assertEquals($output, ' +schema { + query: Root +} + +type Root { + singleField(argOne: Int, argTwo: String, argThree: Boolean = false): String +} +'); + } + + /** + * @it Print Interface + */ + public function testPrintInterface() + { + $fooType = new InterfaceType([ + 'name' => 'Foo', + 'resolveType' => function() { return null; }, + 'fields' => ['str' => ['type' => Type::string()]] + ]); + + $barType = new ObjectType([ + 'name' => 'Bar', + 'fields' => ['str' => ['type' => Type::string()]], + 'interfaces' => [$fooType] + ]); + + $root = new ObjectType([ + 'name' => 'Root', + 'fields' => ['bar' => ['type' => $barType]] + ]); + + $schema = new Schema([ + 'query' => $root, + 'types' => [$barType] + ]); + $output = $this->printForTest($schema); + $this->assertEquals($output, ' +schema { + query: Root +} + +type Bar implements Foo { + str: String +} + +interface Foo { + str: String +} + +type Root { + bar: Bar +} +'); + } + + /** + * @it Print Multiple Interface + */ + public function testPrintMultipleInterface() + { + $fooType = new InterfaceType([ + 'name' => 'Foo', + 'resolveType' => function() { return null; }, + 'fields' => ['str' => ['type' => Type::string()]] + ]); + + $baazType = new InterfaceType([ + 'name' => 'Baaz', + 'resolveType' => function() { return null; }, + 'fields' => ['int' => ['type' => Type::int()]] + ]); + + $barType = new ObjectType([ + 'name' => 'Bar', + 'fields' => [ + 'str' => ['type' => Type::string()], + 'int' => ['type' => Type::int()] + ], + 'interfaces' => [$fooType, $baazType] + ]); + + $root = new ObjectType([ + 'name' => 'Root', + 'fields' => ['bar' => ['type' => $barType]] + ]); + + $schema = new Schema([ + 'query' => $root, + 'types' => [$barType] + ]); + $output = $this->printForTest($schema); + $this->assertEquals($output, ' +schema { + query: Root +} + +interface Baaz { + int: Int +} + +type Bar implements Foo, Baaz { + str: String + int: Int +} + +interface Foo { + str: String +} + +type Root { + bar: Bar +} +'); + } + + /** + * @it Print Unions + */ + public function testPrintUnions() + { + $fooType = new ObjectType([ + 'name' => 'Foo', + 'fields' => ['bool' => ['type' => Type::boolean()]] + ]); + + $barType = new ObjectType([ + 'name' => 'Bar', + 'fields' => ['str' => ['type' => Type::string()]] + ]); + + $singleUnion = new UnionType([ + 'name' => 'SingleUnion', + 'resolveType' => function() { return null; }, + 'types' => [$fooType] + ]); + + $multipleUnion = new UnionType([ + 'name' => 'MultipleUnion', + 'resolveType' => function() { return null; }, + 'types' => [$fooType, $barType] + ]); + + $root = new ObjectType([ + 'name' => 'Root', + 'fields' => [ + 'single' => ['type' => $singleUnion], + 'multiple' => ['type' => $multipleUnion] + ] + ]); + + $schema = new Schema(['query' => $root]); + $output = $this->printForTest($schema); + $this->assertEquals($output, ' +schema { + query: Root +} + +type Bar { + str: String +} + +type Foo { + bool: Boolean +} + +union MultipleUnion = Foo | Bar + +type Root { + single: SingleUnion + multiple: MultipleUnion +} + +union SingleUnion = Foo +'); + } + + /** + * @it Print Input Type + */ + public function testInputType() + { + $inputType = new InputObjectType([ + 'name' => 'InputType', + 'fields' => ['int' => ['type' => Type::int()]] + ]); + + $root = new ObjectType([ + 'name' => 'Root', + 'fields' => [ + 'str' => [ + 'type' => Type::string(), + 'args' => ['argOne' => ['type' => $inputType]] + ] + ] + ]); + + $schema = new Schema(['query' => $root]); + $output = $this->printForTest($schema); + $this->assertEquals($output, ' +schema { + query: Root +} + +input InputType { + int: Int +} + +type Root { + str(argOne: InputType): String +} +'); + } + + /** + * @it Custom Scalar + */ + public function testCustomScalar() + { + $oddType = new CustomScalarType([ + 'name' => 'Odd', + 'serialize' => function($value) { + return $value % 2 === 1 ? $value : null; + } + ]); + + $root = new ObjectType([ + 'name' => 'Root', + 'fields' => [ + 'odd' => ['type' => $oddType] + ] + ]); + + $schema = new Schema(['query' => $root]); + $output = $this->printForTest($schema); + $this->assertEquals($output, ' +schema { + query: Root +} + +scalar Odd + +type Root { + odd: Odd +} +'); + } + + /** + * @it Enum + */ + public function testEnum() + { + $RGBType = new EnumType([ + 'name' => 'RGB', + 'values' => [ + 'RED' => ['value' => 0], + 'GREEN' => ['value' => 1], + 'BLUE' => ['value' => 2] + ] + ]); + + $root = new ObjectType([ + 'name' => 'Root', + 'fields' => [ + 'rgb' => ['type' => $RGBType] + ] + ]); + + $schema = new Schema(['query' => $root]); + $output = $this->printForTest($schema); + $this->assertEquals($output, ' +schema { + query: Root +} + +enum RGB { + RED + GREEN + BLUE +} + +type Root { + rgb: RGB +} +'); + } + + /** + * @it Print Introspection Schema + */ + public function testPrintIntrospectionSchema() + { + $root = new ObjectType([ + 'name' => 'Root', + 'fields' => [ + 'onlyField' => ['type' => Type::string()] + ] + ]); + + $schema = new Schema(['query' => $root]); + $output = SchemaPrinter::printIntrosepctionSchema($schema); + $introspectionSchema = <<<'EOT' +schema { + query: Root +} + +# Directs the executor to include this field or fragment only when the `if` argument is true. +directive @include( + # Included when true. + if: Boolean! +) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +# Directs the executor to skip this field or fragment when the `if` argument is true. +directive @skip( + # Skipped when true. + if: Boolean! +) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +# Marks an element of a GraphQL schema as no longer supported. +directive @deprecated( + # Explains why this element was deprecated, usually also including a suggestion + # for how to access supported similar data. Formatted in + # [Markdown](https://daringfireball.net/projects/markdown/). + reason: String = "No longer supported" +) on FIELD_DEFINITION | ENUM_VALUE + +# A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. +# +# In some cases, you need to provide options to alter GraphQL's execution behavior +# in ways field arguments will not suffice, such as conditionally including or +# skipping a field. Directives provide this by describing additional information +# to the executor. +type __Directive { + name: String! + description: String + locations: [__DirectiveLocation!]! + args: [__InputValue!]! + onOperation: Boolean! @deprecated(reason: "Use `locations`.") + onFragment: Boolean! @deprecated(reason: "Use `locations`.") + onField: Boolean! @deprecated(reason: "Use `locations`.") +} + +# A Directive can be adjacent to many parts of the GraphQL language, a +# __DirectiveLocation describes one such possible adjacencies. +enum __DirectiveLocation { + # Location adjacent to a query operation. + QUERY + + # Location adjacent to a mutation operation. + MUTATION + + # Location adjacent to a subscription operation. + SUBSCRIPTION + + # Location adjacent to a field. + FIELD + + # Location adjacent to a fragment definition. + FRAGMENT_DEFINITION + + # Location adjacent to a fragment spread. + FRAGMENT_SPREAD + + # Location adjacent to an inline fragment. + INLINE_FRAGMENT + + # Location adjacent to a schema definition. + SCHEMA + + # Location adjacent to a scalar definition. + SCALAR + + # Location adjacent to an object type definition. + OBJECT + + # Location adjacent to a field definition. + FIELD_DEFINITION + + # Location adjacent to an argument definition. + ARGUMENT_DEFINITION + + # Location adjacent to an interface definition. + INTERFACE + + # Location adjacent to a union definition. + UNION + + # Location adjacent to an enum definition. + ENUM + + # Location adjacent to an enum value definition. + ENUM_VALUE + + # Location adjacent to an input object type definition. + INPUT_OBJECT + + # Location adjacent to an input object field definition. + INPUT_FIELD_DEFINITION +} + +# One possible value for a given Enum. Enum values are unique values, not a +# placeholder for a string or numeric value. However an Enum value is returned in +# a JSON response as a string. +type __EnumValue { + name: String! + description: String + isDeprecated: Boolean! + deprecationReason: String +} + +# Object and Interface types are described by a list of Fields, each of which has +# a name, potentially a list of arguments, and a return type. +type __Field { + name: String! + description: String + args: [__InputValue!]! + type: __Type! + isDeprecated: Boolean! + deprecationReason: String +} + +# Arguments provided to Fields or Directives and the input fields of an +# InputObject are represented as Input Values which describe their type and +# optionally a default value. +type __InputValue { + name: String! + description: String + type: __Type! + + # A GraphQL-formatted string representing the default value for this input value. + defaultValue: String +} + +# A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all +# available types and directives on the server, as well as the entry points for +# query, mutation, and subscription operations. +type __Schema { + # A list of all types supported by this server. + types: [__Type!]! + + # The type that query operations will be rooted at. + queryType: __Type! + + # If this server supports mutation, the type that mutation operations will be rooted at. + mutationType: __Type + + # If this server support subscription, the type that subscription operations will be rooted at. + subscriptionType: __Type + + # A list of all directives supported by this server. + directives: [__Directive!]! +} + +# The fundamental unit of any GraphQL Schema is the type. There are many kinds of +# types in GraphQL as represented by the `__TypeKind` enum. +# +# Depending on the kind of a type, certain fields describe information about that +# type. Scalar types provide no information beyond a name and description, while +# Enum types provide their values. Object and Interface types provide the fields +# they describe. Abstract types, Union and Interface, provide the Object types +# possible at runtime. List and NonNull types compose other types. +type __Type { + kind: __TypeKind! + name: String + description: String + fields(includeDeprecated: Boolean = false): [__Field!] + interfaces: [__Type!] + possibleTypes: [__Type!] + enumValues(includeDeprecated: Boolean = false): [__EnumValue!] + inputFields: [__InputValue!] + ofType: __Type +} + +# An enum describing what kind of type a given `__Type` is. +enum __TypeKind { + # Indicates this type is a scalar. + SCALAR + + # Indicates this type is an object. `fields` and `interfaces` are valid fields. + OBJECT + + # Indicates this type is an interface. `fields` and `possibleTypes` are valid fields. + INTERFACE + + # Indicates this type is a union. `possibleTypes` is a valid field. + UNION + + # Indicates this type is an enum. `enumValues` is a valid field. + ENUM + + # Indicates this type is an input object. `inputFields` is a valid field. + INPUT_OBJECT + + # Indicates this type is a list. `ofType` is a valid field. + LIST + + # Indicates this type is a non-null. `ofType` is a valid field. + NON_NULL +} + +EOT; + $this->assertEquals($output, $introspectionSchema); + } +} \ No newline at end of file