diff --git a/UPGRADE.md b/UPGRADE.md index 50b4518..8126010 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,4 +1,49 @@ -## Upgrade v0.10.x > dev-master +## Upgrade v0.11.x > dev-master + +### Breaking: Descriptions in comments are not used as descriptions by default anymore +Descriptions now need to be inside Strings or BlockStrings in order to be picked up as +description. If you want to keep the old behaviour you can supply the option `commentDescriptions` +to BuildSchema::buildAST(), BuildSchema::build() or Printer::doPrint(). + +Here is the official way now to define descriptions in the graphQL language: + +Old: + +```graphql +# Description +type Dog { + ... +} +``` + +New: + +```graphql +"Description" +type Dog { + ... +} + +""" +Long Description +""" +type Dog { + ... +} +``` + +### Breaking: Custom types need to return `Utils::undefined()` or throw on invalid value +As null might be a valid value custom types need to return now `Utils::undefined()` or throw an +Exception inside `parseLiteral()`, `parseValue()` and `serialize()`. + +Returning null from any of these methods will now be treated as valid result. + +### Breaking: TypeConfigDecorator was removed from BuildSchema +TypeConfigDecorator was used as second argument in `BuildSchema::build()` and `BuildSchema::buildAST()` to +enable generated schemas with Unions or Interfaces to be used for resolving. This was fixed in a more +generalised approach so that the TypeConfigDecorator is not needed anymore and can be removed. + +The concrete Types are now resolved based on the `__typename` field. ### Possibly Breaking: AST to array serialization excludes nulls Most users won't be affected. It *may* affect you only if you do your own manipulations diff --git a/docs/reference.md b/docs/reference.md index 7cdc37f..e7bba29 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -171,7 +171,7 @@ static function float() ```php /** * @api - * @param ObjectType|InterfaceType|UnionType|ScalarType|InputObjectType|EnumType|ListOfType|NonNull $wrappedType + * @param Type|ObjectType|InterfaceType|UnionType|ScalarType|InputObjectType|EnumType|ListOfType|NonNull $wrappedType * @return ListOfType */ static function listOf($wrappedType) @@ -231,6 +231,15 @@ static function isCompositeType($type) static function isAbstractType($type) ``` +```php +/** + * @api + * @param Type $type + * @return bool + */ +static function isType($type) +``` + ```php /** * @api @@ -374,28 +383,28 @@ public $variableValues; */ function getFieldSelection($depth = 0) ``` -# GraphQL\Type\Definition\DirectiveLocation +# GraphQL\Language\DirectiveLocation List of available directive locations **Class Constants:** ```php -const IFACE = "INTERFACE"; -const SUBSCRIPTION = "SUBSCRIPTION"; -const FRAGMENT_SPREAD = "FRAGMENT_SPREAD"; const QUERY = "QUERY"; const MUTATION = "MUTATION"; +const SUBSCRIPTION = "SUBSCRIPTION"; +const FIELD = "FIELD"; const FRAGMENT_DEFINITION = "FRAGMENT_DEFINITION"; -const INPUT_OBJECT = "INPUT_OBJECT"; +const FRAGMENT_SPREAD = "FRAGMENT_SPREAD"; const INLINE_FRAGMENT = "INLINE_FRAGMENT"; -const UNION = "UNION"; +const SCHEMA = "SCHEMA"; const SCALAR = "SCALAR"; +const OBJECT = "OBJECT"; const FIELD_DEFINITION = "FIELD_DEFINITION"; const ARGUMENT_DEFINITION = "ARGUMENT_DEFINITION"; +const IFACE = "INTERFACE"; +const UNION = "UNION"; const ENUM = "ENUM"; -const OBJECT = "OBJECT"; const ENUM_VALUE = "ENUM_VALUE"; -const FIELD = "FIELD"; -const SCHEMA = "SCHEMA"; +const INPUT_OBJECT = "INPUT_OBJECT"; const INPUT_FIELD_DEFINITION = "INPUT_FIELD_DEFINITION"; ``` @@ -431,7 +440,7 @@ static function create(array $options = []) * @param ObjectType $query * @return SchemaConfig */ -function setQuery(GraphQL\Type\Definition\ObjectType $query) +function setQuery($query) ``` ```php @@ -440,7 +449,7 @@ function setQuery(GraphQL\Type\Definition\ObjectType $query) * @param ObjectType $mutation * @return SchemaConfig */ -function setMutation(GraphQL\Type\Definition\ObjectType $mutation) +function setMutation($mutation) ``` ```php @@ -449,7 +458,7 @@ function setMutation(GraphQL\Type\Definition\ObjectType $mutation) * @param ObjectType $subscription * @return SchemaConfig */ -function setSubscription(GraphQL\Type\Definition\ObjectType $subscription) +function setSubscription($subscription) ``` ```php @@ -670,6 +679,18 @@ function getDirectives() function getDirective($name) ``` +```php +/** + * Validates schema. + * + * This operation requires full schema scan. Do not use in production environment. + * + * @api + * @return InvariantViolation[]|Error[] + */ +function validate() +``` + ```php /** * Validates schema. @@ -697,10 +718,25 @@ Parses string containing GraphQL query or [type definition](type-system/type-lan * in the source that they correspond to. This configuration flag * disables that behavior for performance or testing.) * + * experimentalFragmentVariables: boolean, + * (If enabled, the parser will understand and parse variable definitions + * contained in a fragment definition. They'll be represented in the + * `variableDefinitions` field of the FragmentDefinitionNode. + * + * The syntax is identical to normal, query-defined variables. For example: + * + * fragment A($var: Boolean = false) on T { + * ... + * } + * + * Note: this feature is experimental and may change or be removed in the + * future.) + * * @api * @param Source|string $source * @param array $options * @return DocumentNode + * @throws SyntaxError */ static function parse($source, array $options = []) ``` @@ -936,7 +972,12 @@ const UNION_TYPE_DEFINITION = "UnionTypeDefinition"; const ENUM_TYPE_DEFINITION = "EnumTypeDefinition"; const ENUM_VALUE_DEFINITION = "EnumValueDefinition"; const INPUT_OBJECT_TYPE_DEFINITION = "InputObjectTypeDefinition"; -const TYPE_EXTENSION_DEFINITION = "TypeExtensionDefinition"; +const SCALAR_TYPE_EXTENSION = "ScalarTypeExtension"; +const OBJECT_TYPE_EXTENSION = "ObjectTypeExtension"; +const INTERFACE_TYPE_EXTENSION = "InterfaceTypeExtension"; +const UNION_TYPE_EXTENSION = "UnionTypeExtension"; +const ENUM_TYPE_EXTENSION = "EnumTypeExtension"; +const INPUT_OBJECT_TYPE_EXTENSION = "InputObjectTypeExtension"; const DIRECTIVE_DEFINITION = "DirectiveDefinition"; ``` @@ -1319,7 +1360,6 @@ Also it is possible to override warning handler (which is **trigger_error()** by **Class Constants:** ```php -const WARNING_NAME = 1; const WARNING_ASSIGN = 2; const WARNING_CONFIG = 4; const WARNING_FULL_SCHEMA_SCAN = 8; @@ -1352,7 +1392,7 @@ static function setWarningHandler(callable $warningHandler = null) * @api * @param bool|int $suppress */ -static function suppress($suppress = false) +static function suppress($suppress = true) ``` ```php @@ -1367,7 +1407,7 @@ static function suppress($suppress = false) * @api * @param bool|int $enable */ -static function enable($enable = false) +static function enable($enable = true) ``` # GraphQL\Error\ClientAware This interface is used for [default error formatting](error-handling.md). @@ -1697,7 +1737,7 @@ function setPersistentQueryLoader(callable $persistentQueryLoader) * @param bool|int $set * @return $this */ -function setDebug($set = false) +function setDebug($set = true) ``` ```php @@ -1927,13 +1967,19 @@ See [section in docs](type-system/type-language.md) for details. * Given that AST it constructs a GraphQL\Type\Schema. The resulting schema * has no resolve methods, so execution will use default resolvers. * + * Accepts options as a second argument: + * + * - commentDescriptions: + * Provide true to use preceding comments as the description. + * + * * @api * @param DocumentNode $ast - * @param callable $typeConfigDecorator + * @param array $options * @return Schema * @throws Error */ -static function buildAST(GraphQL\Language\AST\DocumentNode $ast, callable $typeConfigDecorator = null) +static function buildAST(GraphQL\Language\AST\DocumentNode $ast, array $options = []) ``` ```php @@ -1943,10 +1989,10 @@ static function buildAST(GraphQL\Language\AST\DocumentNode $ast, callable $typeC * * @api * @param DocumentNode|Source|string $source - * @param callable $typeConfigDecorator + * @param array $options * @return Schema */ -static function build($source, callable $typeConfigDecorator = null) +static function build($source, array $options = []) ``` # GraphQL\Utils\AST Various utilities dealing with AST @@ -2049,6 +2095,32 @@ static function astFromValue($value, GraphQL\Type\Definition\InputType $type) static function valueFromAST($valueNode, GraphQL\Type\Definition\InputType $type, $variables = null) ``` +```php +/** + * Produces a PHP value given a GraphQL Value AST. + * + * Unlike `valueFromAST()`, no type is provided. The resulting JavaScript value + * will reflect the provided GraphQL value AST. + * + * | GraphQL Value | PHP Value | + * | -------------------- | ------------- | + * | Input Object | Assoc Array | + * | List | Array | + * | Boolean | Boolean | + * | String | String | + * | Int / Float | Int / Float | + * | Enum | Mixed | + * | Null | null | + * + * @api + * @param Node $valueNode + * @param array|null $variables + * @return mixed + * @throws \Exception + */ +static function valueFromASTUntyped($valueNode, array $variables = null) +``` + ```php /** * Returns type definition for given AST Type node @@ -2057,7 +2129,7 @@ static function valueFromAST($valueNode, GraphQL\Type\Definition\InputType $type * @param Schema $schema * @param NamedTypeNode|ListTypeNode|NonNullTypeNode $inputTypeNode * @return Type - * @throws InvariantViolation + * @throws \Exception */ static function typeFromAST(GraphQL\Type\Schema $schema, $inputTypeNode) ``` @@ -2079,11 +2151,15 @@ Given an instance of Schema, prints it in GraphQL type language. **Class Methods:** ```php /** + * Accepts options as a second argument: + * + * - commentDescriptions: + * Provide true to use preceding comments as the description. * @api * @param Schema $schema * @return string */ -static function doPrint(GraphQL\Type\Schema $schema) +static function doPrint(GraphQL\Type\Schema $schema, array $options = []) ``` ```php @@ -2092,5 +2168,5 @@ static function doPrint(GraphQL\Type\Schema $schema) * @param Schema $schema * @return string */ -static function printIntrosepctionSchema(GraphQL\Type\Schema $schema) +static function printIntrosepctionSchema(GraphQL\Type\Schema $schema, array $options = []) ``` diff --git a/docs/type-system/directives.md b/docs/type-system/directives.md index 6b12588..454ce4c 100644 --- a/docs/type-system/directives.md +++ b/docs/type-system/directives.md @@ -35,9 +35,9 @@ In **graphql-php** custom directive is an instance of `GraphQL\Type\Definition\D ```php kind, [$ast]); + if (!($valueNode instanceof StringValueNode)) { + throw new Error('Query error: Can only parse strings got: ' . $valueNode->kind, [$valueNode]); } - if (!is_string($ast->value) || !filter_var($ast->value, FILTER_VALIDATE_URL)) { - throw new Error('Query error: Not a valid URL', [$ast]); + if (!is_string($valueNode->value) || !filter_var($valueNode->value, FILTER_VALIDATE_URL)) { + throw new Error('Query error: Not a valid URL', [$valueNode]); } - return $ast->value; + return $valueNode->value; } } diff --git a/src/Error/Error.php b/src/Error/Error.php index 1e6f3f8..be2c9b5 100644 --- a/src/Error/Error.php +++ b/src/Error/Error.php @@ -1,6 +1,7 @@ getMessage(); @@ -102,6 +112,7 @@ class Error extends \Exception implements \JsonSerializable, ClientAware $nodes = $error->nodes ?: $nodes; $source = $error->source; $positions = $error->positions; + $extensions = $error->extensions; } else if ($error instanceof \Exception || $error instanceof \Throwable) { $message = $error->getMessage(); $originalError = $error; @@ -115,7 +126,8 @@ class Error extends \Exception implements \JsonSerializable, ClientAware $source, $positions, $path, - $originalError + $originalError, + $extensions ); } @@ -131,11 +143,12 @@ class Error extends \Exception implements \JsonSerializable, ClientAware /** * @param string $message - * @param array|null $nodes + * @param array|Node|null $nodes * @param Source $source * @param array|null $positions * @param array|null $path * @param \Throwable $previous + * @param array $extensions */ public function __construct( $message, @@ -143,19 +156,28 @@ class Error extends \Exception implements \JsonSerializable, ClientAware Source $source = null, $positions = null, $path = null, - $previous = null + $previous = null, + array $extensions = [] ) { parent::__construct($message, 0, $previous); + // Compute list of blame nodes. if ($nodes instanceof \Traversable) { $nodes = iterator_to_array($nodes); + } else if ($nodes && !is_array($nodes)) { + $nodes = [$nodes]; } $this->nodes = $nodes; $this->source = $source; $this->positions = $positions; $this->path = $path; + $this->extensions = $extensions ?: ( + $previous && $previous instanceof self + ? $previous->extensions + : [] + ); if ($previous instanceof ClientAware) { $this->isClientSafe = $previous->isClientSafe(); @@ -235,11 +257,18 @@ class Error extends \Exception implements \JsonSerializable, ClientAware if (null === $this->locations) { $positions = $this->getPositions(); $source = $this->getSource(); + $nodes = $this->nodes; if ($positions && $source) { $this->locations = array_map(function ($pos) use ($source) { return $source->getLocation($pos); }, $positions); + } else if ($nodes) { + $this->locations = array_filter(array_map(function ($node) { + if ($node->loc) { + return $node->loc->source->getLocation($node->loc->start); + } + }, $nodes)); } else { $this->locations = []; } @@ -248,6 +277,14 @@ class Error extends \Exception implements \JsonSerializable, ClientAware return $this->locations; } + /** + * @return array|Node[]|null + */ + public function getNodes() + { + return $this->nodes; + } + /** * Returns an array describing the path from the root value to the field which produced this error. * Only included for execution errors. @@ -260,6 +297,14 @@ class Error extends \Exception implements \JsonSerializable, ClientAware return $this->path; } + /** + * @return array + */ + public function getExtensions() + { + return $this->extensions; + } + /** * Returns array representation of error suitable for serialization * @@ -272,6 +317,10 @@ class Error extends \Exception implements \JsonSerializable, ClientAware 'message' => $this->getMessage() ]; + if ($this->getExtensions()) { + $arr = array_merge($this->getExtensions(), $arr); + } + $locations = Utils::map($this->getLocations(), function(SourceLocation $loc) { return $loc->toSerializableArray(); }); @@ -297,4 +346,12 @@ class Error extends \Exception implements \JsonSerializable, ClientAware { return $this->toSerializableArray(); } + + /** + * @return string + */ + public function __toString() + { + return FormattedError::printError($this); + } } diff --git a/src/Error/FormattedError.php b/src/Error/FormattedError.php index c7c63c4..82bb0cb 100644 --- a/src/Error/FormattedError.php +++ b/src/Error/FormattedError.php @@ -1,6 +1,8 @@ nodes) { + /** @var Node $node */ + foreach($error->nodes as $node) { + if ($node->loc) { + $printedLocations[] = self::highlightSourceAtLocation( + $node->loc->source, + $node->loc->source->getLocation($node->loc->start) + ); + } + } + } else if ($error->getSource() && $error->getLocations()) { + $source = $error->getSource(); + foreach($error->getLocations() as $location) { + $printedLocations[] = self::highlightSourceAtLocation($source, $location); + } + } + + return !$printedLocations + ? $error->getMessage() + : join("\n\n", array_merge([$error->getMessage()], $printedLocations)) . "\n"; + } + + /** + * Render a helpful description of the location of the error in the GraphQL + * Source document. + * + * @param Source $source + * @param SourceLocation $location + * @return string + */ + private static function highlightSourceAtLocation(Source $source, SourceLocation $location) + { + $line = $location->line; + $lineOffset = $source->locationOffset->line - 1; + $columnOffset = self::getColumnOffset($source, $location); + $contextLine = $line + $lineOffset; + $contextColumn = $location->column + $columnOffset; + $prevLineNum = (string) ($contextLine - 1); + $lineNum = (string) $contextLine; + $nextLineNum = (string) ($contextLine + 1); + $padLen = strlen($nextLineNum); + $lines = preg_split('/\r\n|[\n\r]/', $source->body); + + $lines[0] = self::whitespace($source->locationOffset->column - 1) . $lines[0]; + + $outputLines = [ + "{$source->name} ($contextLine:$contextColumn)", + $line >= 2 ? (self::lpad($padLen, $prevLineNum) . ': ' . $lines[$line - 2]) : null, + self::lpad($padLen, $lineNum) . ': ' . $lines[$line - 1], + self::whitespace(2 + $padLen + $contextColumn - 1) . '^', + $line < count($lines)? self::lpad($padLen, $nextLineNum) . ': ' . $lines[$line] : null + ]; + + return join("\n", array_filter($outputLines)); + } + + /** + * @param Source $source + * @param SourceLocation $location + * @return int + */ + private static function getColumnOffset(Source $source, SourceLocation $location) + { + return $location->line === 1 ? $source->locationOffset->column - 1 : 0; + } + + /** + * @param int $len + * @return string + */ + private static function whitespace($len) { + return str_repeat(' ', $len); + } + + /** + * @param int $len + * @return string + */ + private static function lpad($len, $str) { + return self::whitespace($len - mb_strlen($str)) . $str; + } + /** * Standard GraphQL error formatter. Converts any exception to array * conforming to GraphQL spec. @@ -66,6 +160,10 @@ class FormattedError } if ($e instanceof Error) { + if ($e->getExtensions()) { + $formattedError = array_merge($e->getExtensions(), $formattedError); + } + $locations = Utils::map($e->getLocations(), function(SourceLocation $loc) { return $loc->toSerializableArray(); }); diff --git a/src/Error/SyntaxError.php b/src/Error/SyntaxError.php index 7f9bd5e..ee17ab5 100644 --- a/src/Error/SyntaxError.php +++ b/src/Error/SyntaxError.php @@ -2,7 +2,6 @@ namespace GraphQL\Error; use GraphQL\Language\Source; -use GraphQL\Language\SourceLocation; class SyntaxError extends Error { @@ -13,59 +12,11 @@ class SyntaxError extends Error */ public function __construct(Source $source, $position, $description) { - $location = $source->getLocation($position); - $line = $location->line + $source->locationOffset->line - 1; - $columnOffset = self::getColumnOffset($source, $location); - $column = $location->column + $columnOffset; - - $syntaxError = - "Syntax Error {$source->name} ({$line}:{$column}) $description\n" . - "\n". - self::highlightSourceAtLocation($source, $location); - - parent::__construct($syntaxError, null, $source, [$position]); + parent::__construct( + "Syntax Error: $description", + null, + $source, + [$position] + ); } - - /** - * @param Source $source - * @param SourceLocation $location - * @return string - */ - public static function highlightSourceAtLocation(Source $source, SourceLocation $location) - { - $line = $location->line; - $lineOffset = $source->locationOffset->line - 1; - $columnOffset = self::getColumnOffset($source, $location); - - $contextLine = $line + $lineOffset; - $prevLineNum = (string) ($contextLine - 1); - $lineNum = (string) $contextLine; - $nextLineNum = (string) ($contextLine + 1); - $padLen = mb_strlen($nextLineNum, 'UTF-8'); - - $unicodeChars = json_decode('"\u2028\u2029"'); // Quick hack to get js-compatible representation of these chars - $lines = preg_split('/\r\n|[\n\r' . $unicodeChars . ']/su', $source->body); - - $whitespace = function ($len) { - return str_repeat(' ', $len); - }; - - $lpad = function ($len, $str) { - return str_pad($str, $len - mb_strlen($str, 'UTF-8') + 1, ' ', STR_PAD_LEFT); - }; - - $lines[0] = $whitespace($source->locationOffset->column - 1) . $lines[0]; - - return - ($line >= 2 ? $lpad($padLen, $prevLineNum) . ': ' . $lines[$line - 2] . "\n" : '') . - ($lpad($padLen, $lineNum) . ': ' . $lines[$line - 1] . "\n") . - ($whitespace(2 + $padLen + $location->column - 1 + $columnOffset) . "^\n") . - ($line < count($lines) ? $lpad($padLen, $nextLineNum) . ': ' . $lines[$line] . "\n" : ''); - } - - public static function getColumnOffset(Source $source, SourceLocation $location) - { - return $location->line === 1 ? $source->locationOffset->column - 1 : 0; - } - } diff --git a/src/Error/Warning.php b/src/Error/Warning.php index fa6a666..4bdbf66 100644 --- a/src/Error/Warning.php +++ b/src/Error/Warning.php @@ -9,7 +9,6 @@ namespace GraphQL\Error; */ final class Warning { - const WARNING_NAME = 1; const WARNING_ASSIGN = 2; const WARNING_CONFIG = 4; const WARNING_FULL_SCHEMA_SCAN = 8; diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index 998038d..fac9553 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -100,9 +100,16 @@ class Executor { // TODO: deprecate (just always use SyncAdapter here) and have `promiseToExecute()` for other cases $promiseAdapter = self::getPromiseAdapter(); - - $result = self::promiseToExecute($promiseAdapter, $schema, $ast, $rootValue, $contextValue, - $variableValues, $operationName, $fieldResolver); + $result = self::promiseToExecute( + $promiseAdapter, + $schema, + $ast, + $rootValue, + $contextValue, + $variableValues, + $operationName, + $fieldResolver + ); // Wait for promised results when using sync promises if ($promiseAdapter instanceof SyncPromiseAdapter) { @@ -140,11 +147,19 @@ class Executor callable $fieldResolver = null ) { - try { - $exeContext = self::buildExecutionContext($schema, $ast, $rootValue, $contextValue, $variableValues, - $operationName, $fieldResolver, $promiseAdapter); - } catch (Error $e) { - return $promiseAdapter->createFulfilled(new ExecutionResult(null, [$e])); + $exeContext = self::buildExecutionContext( + $schema, + $ast, + $rootValue, + $contextValue, + $variableValues, + $operationName, + $fieldResolver, + $promiseAdapter + ); + + if (is_array($exeContext)) { + return $promiseAdapter->createFulfilled(new ExecutionResult(null, $exeContext)); } $executor = new self($exeContext); @@ -159,13 +174,12 @@ class Executor * @param DocumentNode $documentNode * @param $rootValue * @param $contextValue - * @param $rawVariableValues + * @param array|\Traversable $rawVariableValues * @param string $operationName * @param callable $fieldResolver * @param PromiseAdapter $promiseAdapter * - * @return ExecutionContext - * @throws Error + * @return ExecutionContext|Error[] */ private static function buildExecutionContext( Schema $schema, @@ -178,30 +192,17 @@ class Executor PromiseAdapter $promiseAdapter = null ) { - if (null !== $rawVariableValues) { - Utils::invariant( - is_array($rawVariableValues) || $rawVariableValues instanceof \ArrayAccess, - "Variable values are expected to be array or instance of ArrayAccess, got " . Utils::getVariableType($rawVariableValues) - ); - } - if (null !== $operationName) { - Utils::invariant( - is_string($operationName), - "Operation name is supposed to be string, got " . Utils::getVariableType($operationName) - ); - } - $errors = []; $fragments = []; + /** @var OperationDefinitionNode $operation */ $operation = null; + $hasMultipleAssumedOperations = false; foreach ($documentNode->definitions as $definition) { switch ($definition->kind) { case NodeKind::OPERATION_DEFINITION: if (!$operationName && $operation) { - throw new Error( - 'Must provide operation name if query contains multiple operations.' - ); + $hasMultipleAssumedOperations = true; } if (!$operationName || (isset($definition->name) && $definition->name->value === $operationName)) { @@ -211,29 +212,45 @@ class Executor case NodeKind::FRAGMENT_DEFINITION: $fragments[$definition->name->value] = $definition; break; - default: - throw new Error( - "GraphQL cannot execute a request containing a {$definition->kind}.", - [$definition] - ); } } if (!$operation) { if ($operationName) { - throw new Error("Unknown operation named \"$operationName\"."); + $errors[] = new Error("Unknown operation named \"$operationName\"."); } else { - throw new Error('Must provide an operation.'); + $errors[] = new Error('Must provide an operation.'); + } + } else if ($hasMultipleAssumedOperations) { + $errors[] = new Error( + 'Must provide operation name if query contains multiple operations.' + ); + + } + + $variableValues = null; + if ($operation) { + $coercedVariableValues = Values::getVariableValues( + $schema, + $operation->variableDefinitions ?: [], + $rawVariableValues ?: [] + ); + + if ($coercedVariableValues['errors']) { + $errors = array_merge($errors, $coercedVariableValues['errors']); + } else { + $variableValues = $coercedVariableValues['coerced']; } } - $variableValues = Values::getVariableValues( - $schema, - $operation->variableDefinitions ?: [], - $rawVariableValues ?: [] - ); + if ($errors) { + return $errors; + } - $exeContext = new ExecutionContext( + Utils::invariant($operation, 'Has operation if no errors.'); + Utils::invariant($variableValues !== null, 'Has variables if no errors.'); + + return new ExecutionContext( $schema, $fragments, $rootValue, @@ -244,7 +261,6 @@ class Executor $fieldResolver ?: self::$defaultFieldResolver, $promiseAdapter ?: self::getPromiseAdapter() ); - return $exeContext; } /** @@ -338,7 +354,6 @@ class Executor } } - /** * Extracts the root type of the operation from the schema. * @@ -351,12 +366,19 @@ class Executor { switch ($operation->operation) { case 'query': - return $schema->getQueryType(); + $queryType = $schema->getQueryType(); + if (!$queryType) { + throw new Error( + 'Schema does not define the required query root type.', + [$operation] + ); + } + return $queryType; case 'mutation': $mutationType = $schema->getMutationType(); if (!$mutationType) { throw new Error( - 'Schema is not configured for mutations', + 'Schema is not configured for mutations.', [$operation] ); } @@ -365,14 +387,14 @@ class Executor $subscriptionType = $schema->getSubscriptionType(); if (!$subscriptionType) { throw new Error( - 'Schema is not configured for subscriptions', + 'Schema is not configured for subscriptions.', [ $operation ] ); } return $subscriptionType; default: throw new Error( - 'Can only execute queries, mutations and subscriptions', + 'Can only execute queries, mutations and subscriptions.', [$operation] ); } @@ -1053,15 +1075,6 @@ class Executor $runtimeType = $returnType->resolveType($result, $exeContext->contextValue, $info); if (null === $runtimeType) { - if ($returnType instanceof InterfaceType && $info->schema->getConfig()->typeLoader) { - Warning::warnOnce( - "GraphQL Interface Type `{$returnType->name}` returned `null` from it`s `resolveType` function ". - 'for value: ' . Utils::printSafe($result) . '. Switching to slow resolution method using `isTypeOf` ' . - 'of all possible implementations. It requires full schema scan and degrades query performance significantly. '. - ' Make sure your `resolveType` always returns valid implementation or throws.', - Warning::WARNING_FULL_SCHEMA_SCAN - ); - } $runtimeType = self::defaultTypeResolver($result, $exeContext->contextValue, $info, $returnType); } @@ -1122,9 +1135,11 @@ class Executor if (!$runtimeType instanceof ObjectType) { throw new InvariantViolation( - "Abstract type {$returnType} must resolve to an Object type at runtime " . - "for field {$info->parentType}.{$info->fieldName} with " . - 'value "' . Utils::printSafe($result) . '", received "'. Utils::printSafe($runtimeType) . '".' + "Abstract type {$returnType} must resolve to an Object type at " . + "runtime for field {$info->parentType}.{$info->fieldName} with " . + 'value "' . Utils::printSafe($result) . '", received "'. Utils::printSafe($runtimeType) . '".' . + 'Either the ' . $returnType . ' type should provide a "resolveType" ' . + 'function or each possible types should provide an "isTypeOf" function.' ); } @@ -1194,7 +1209,7 @@ class Executor { $serializedResult = $returnType->serialize($result); - if ($serializedResult === null) { + if (Utils::isInvalid($serializedResult)) { throw new InvariantViolation( 'Expected a value of type "'. Utils::printSafe($returnType) . '" but received: ' . Utils::printSafe($result) ); @@ -1307,7 +1322,12 @@ class Executor /** * If a resolveType function is not given, then a default resolve behavior is - * used which tests each possible type for the abstract type by calling + * used which attempts two strategies: + * + * First, See if the provided value has a `__typename` field defined, if so, use + * that value as name of the resolved type. + * + * Otherwise, test each possible type for the abstract type by calling * isTypeOf for the object being coerced, returning the first type that matches. * * @param $value @@ -1318,6 +1338,27 @@ class Executor */ private function defaultTypeResolver($value, $context, ResolveInfo $info, AbstractType $abstractType) { + // First, look for `__typename`. + if ( + $value !== null && + is_array($value) && + isset($value['__typename']) && + is_string($value['__typename']) + ) { + return $value['__typename']; + } + + if ($abstractType instanceof InterfaceType && $info->schema->getConfig()->typeLoader) { + Warning::warnOnce( + "GraphQL Interface Type `{$abstractType->name}` returned `null` from it`s `resolveType` function ". + 'for value: ' . Utils::printSafe($value) . '. Switching to slow resolution method using `isTypeOf` ' . + 'of all possible implementations. It requires full schema scan and degrades query performance significantly. '. + ' Make sure your `resolveType` always returns valid implementation or throws.', + Warning::WARNING_FULL_SCHEMA_SCAN + ); + } + + // Otherwise, test each possible type. $possibleTypes = $info->schema->getPossibleTypes($abstractType); $promisedIsTypeOfResults = []; diff --git a/src/Executor/Values.php b/src/Executor/Values.php index 06c9532..c8353c4 100644 --- a/src/Executor/Values.php +++ b/src/Executor/Values.php @@ -1,9 +1,7 @@ variable->name->value; - $varType = TypeInfo::typeFromAST($schema, $definitionNode->type); + foreach ($varDefNodes as $varDefNode) { + $varName = $varDefNode->variable->name->value; + /** @var InputType|Type $varType */ + $varType = TypeInfo::typeFromAST($schema, $varDefNode->type); if (!Type::isInputType($varType)) { - throw new Error( - 'Variable "$'.$varName.'" expected value of type ' . - '"' . Printer::doPrint($definitionNode->type) . '" which cannot be used as an input type.', - [$definitionNode->type] + $errors[] = new Error( + "Variable \"\$$varName\" expected value of type " . + '"' . Printer::doPrint($varDefNode->type) . '" which cannot be used as an input type.', + [$varDefNode->type] ); - } - - if (!array_key_exists($varName, $inputs)) { - $defaultValue = $definitionNode->defaultValue; - if ($defaultValue) { - $coercedValues[$varName] = AST::valueFromAST($defaultValue, $varType); - } - if ($varType instanceof NonNull) { - throw new Error( - 'Variable "$'.$varName .'" of required type ' . - '"'. Utils::printSafe($varType) . '" was not provided.', - [$definitionNode] - ); - } } else { - $value = $inputs[$varName]; - $errors = self::isValidPHPValue($value, $varType); - if (!empty($errors)) { - $message = "\n" . implode("\n", $errors); - throw new Error( - 'Variable "$' . $varName . '" got invalid value ' . - json_encode($value) . '.' . $message, - [$definitionNode] - ); - } + if (!array_key_exists($varName, $inputs)) { + if ($varType instanceof NonNull) { + $errors[] = new Error( + "Variable \"\$$varName\" of required type " . + "\"{$varType}\" was not provided.", + [$varDefNode] + ); + } else if ($varDefNode->defaultValue) { + $coercedValues[$varName] = AST::valueFromAST($varDefNode->defaultValue, $varType); + } + } else { + $value = $inputs[$varName]; + $coerced = Value::coerceValue($value, $varType, $varDefNode); + /** @var Error[] $coercionErrors */ + $coercionErrors = $coerced['errors']; + if ($coercionErrors) { + $messagePrelude = "Variable \"\$$varName\" got invalid value " . Utils::printSafeJson($value) . '; '; - $coercedValue = self::coerceValue($varType, $value); - Utils::invariant($coercedValue !== Utils::undefined(), 'Should have reported error.'); - $coercedValues[$varName] = $coercedValue; + foreach($coercionErrors as $error) { + $errors[] = new Error( + $messagePrelude . $error->getMessage(), + $error->getNodes(), + $error->getSource(), + $error->getPositions(), + $error->getPath(), + $error, + $error->getExtensions() + ); + } + } else { + $coercedValues[$varName] = $coerced['value']; + } + } } } - return $coercedValues; + return ['errors' => $errors, 'coerced' => $errors ? null : $coercedValues]; } /** @@ -109,7 +115,6 @@ class Values } $coercedValues = []; - $undefined = Utils::undefined(); /** @var ArgumentNode[] $argNodeMap */ $argNodeMap = $argNodes ? Utils::keyMap($argNodes, function (ArgumentNode $arg) { @@ -152,11 +157,12 @@ class Values } else { $valueNode = $argumentNode->value; $coercedValue = AST::valueFromAST($valueNode, $argType, $variableValues); - if ($coercedValue === $undefined) { - $errors = DocumentValidator::isValidLiteralValue($argType, $valueNode); - $message = !empty($errors) ? ("\n" . implode("\n", $errors)) : ''; + if (Utils::isInvalid($coercedValue)) { + // Note: ValuesOfCorrectType validation should catch this before + // execution. This is a runtime check to ensure execution does not + // continue with an invalid argument value. throw new Error( - 'Argument "' . $name . '" got invalid value ' . Printer::doPrint($valueNode) . '.' . $message, + 'Argument "' . $name . '" has invalid value ' . Printer::doPrint($valueNode) . '.', [ $argumentNode->value ] ); } @@ -207,179 +213,16 @@ class Values } /** - * Given a PHP value and a GraphQL type, determine if the value will be - * accepted for that type. This is primarily useful for validating the - * runtime values of query variables. - * + * @deprecated as of 0.12 (Use coerceValue() directly for richer information) * @param $value * @param InputType $type * @return array */ public static function isValidPHPValue($value, InputType $type) { - // A value must be provided if the type is non-null. - if ($type instanceof NonNull) { - if (null === $value) { - return ['Expected "' . Utils::printSafe($type) . '", found null.']; - } - return self::isValidPHPValue($value, $type->getWrappedType()); - } - - if (null === $value) { - return []; - } - - // Lists accept a non-list value as a list of one. - if ($type instanceof ListOfType) { - $itemType = $type->getWrappedType(); - if (is_array($value)) { - $tmp = []; - foreach ($value as $index => $item) { - $errors = self::isValidPHPValue($item, $itemType); - $tmp = array_merge($tmp, Utils::map($errors, function ($error) use ($index) { - return "In element #$index: $error"; - })); - } - return $tmp; - } - return self::isValidPHPValue($value, $itemType); - } - - // Input objects check each defined field. - if ($type instanceof InputObjectType) { - if (!is_object($value) && !is_array($value)) { - return ["Expected \"{$type->name}\", found not an object."]; - } - $fields = $type->getFields(); - $errors = []; - - // Ensure every provided field is defined. - $props = is_object($value) ? get_object_vars($value) : $value; - foreach ($props as $providedField => $tmp) { - if (!isset($fields[$providedField])) { - $errors[] = "In field \"{$providedField}\": Unknown field."; - } - } - - // Ensure every defined field is valid. - foreach ($fields as $fieldName => $tmp) { - $newErrors = self::isValidPHPValue(isset($value[$fieldName]) ? $value[$fieldName] : null, $fields[$fieldName]->getType()); - $errors = array_merge( - $errors, - Utils::map($newErrors, function ($error) use ($fieldName) { - return "In field \"{$fieldName}\": {$error}"; - }) - ); - } - return $errors; - } - - if ($type instanceof LeafType) { - try { - // Scalar/Enum input checks to ensure the type can parse the value to - // a non-null value. - $parseResult = $type->parseValue($value); - if (null === $parseResult && !$type->isValidValue($value)) { - $v = Utils::printSafeJson($value); - return [ - "Expected type \"{$type->name}\", found $v." - ]; - } - return []; - } catch (\Exception $e) { - return [ - "Expected type \"{$type->name}\", found " . Utils::printSafeJson($value) . ': ' . - $e->getMessage() - ]; - } catch (\Throwable $e) { - return [ - "Expected type \"{$type->name}\", found " . Utils::printSafeJson($value) . ': ' . - $e->getMessage() - ]; - } - } - - throw new InvariantViolation('Must be input type'); - } - - /** - * Given a type and any value, return a runtime value coerced to match the type. - */ - private static function coerceValue(Type $type, $value) - { - $undefined = Utils::undefined(); - if ($value === $undefined) { - return $undefined; - } - - if ($type instanceof NonNull) { - if ($value === null) { - // Intentionally return no value. - return $undefined; - } - return self::coerceValue($type->getWrappedType(), $value); - } - - if (null === $value) { - return null; - } - - if ($type instanceof ListOfType) { - $itemType = $type->getWrappedType(); - if (is_array($value) || $value instanceof \Traversable) { - $coercedValues = []; - foreach ($value as $item) { - $itemValue = self::coerceValue($itemType, $item); - if ($undefined === $itemValue) { - // Intentionally return no value. - return $undefined; - } - $coercedValues[] = $itemValue; - } - return $coercedValues; - } else { - $coercedValue = self::coerceValue($itemType, $value); - if ($coercedValue === $undefined) { - // Intentionally return no value. - return $undefined; - } - return [$coercedValue]; - } - } - - if ($type instanceof InputObjectType) { - $coercedObj = []; - $fields = $type->getFields(); - foreach ($fields as $fieldName => $field) { - if (!array_key_exists($fieldName, $value)) { - if ($field->defaultValueExists()) { - $coercedObj[$fieldName] = $field->defaultValue; - } else if ($field->getType() instanceof NonNull) { - // Intentionally return no value. - return $undefined; - } - continue; - } - $fieldValue = self::coerceValue($field->getType(), $value[$fieldName]); - if ($fieldValue === $undefined) { - // Intentionally return no value. - return $undefined; - } - $coercedObj[$fieldName] = $fieldValue; - } - return $coercedObj; - } - - if ($type instanceof LeafType) { - $parsed = $type->parseValue($value); - if (null === $parsed) { - // null or invalid values represent a failure to parse correctly, - // in which case no value is returned. - return $undefined; - } - return $parsed; - } - - throw new InvariantViolation('Must be input type'); + $errors = Value::coerceValue($value, $type)['errors']; + return $errors + ? array_map(function(/*\Throwable */$error) { return $error->getMessage(); }, $errors) + : []; } } diff --git a/src/Language/AST/DefinitionNode.php b/src/Language/AST/DefinitionNode.php index f5a6c28..4c099cc 100644 --- a/src/Language/AST/DefinitionNode.php +++ b/src/Language/AST/DefinitionNode.php @@ -4,8 +4,8 @@ namespace GraphQL\Language\AST; interface DefinitionNode { /** - * export type DefinitionNode = OperationDefinitionNode - * | FragmentDefinitionNode - * | TypeSystemDefinitionNode // experimental non-spec addition. + * export type DefinitionNode = + * | ExecutableDefinitionNode + * | TypeSystemDefinitionNode; // experimental non-spec addition. */ } diff --git a/src/Language/AST/DirectiveDefinitionNode.php b/src/Language/AST/DirectiveDefinitionNode.php index 1e80084..84b649b 100644 --- a/src/Language/AST/DirectiveDefinitionNode.php +++ b/src/Language/AST/DirectiveDefinitionNode.php @@ -22,4 +22,9 @@ class DirectiveDefinitionNode extends Node implements TypeSystemDefinitionNode * @var NameNode[] */ public $locations; + + /** + * @var StringValueNode|null + */ + public $description; } diff --git a/src/Language/AST/EnumTypeDefinitionNode.php b/src/Language/AST/EnumTypeDefinitionNode.php index 3d1113c..fc8eb66 100644 --- a/src/Language/AST/EnumTypeDefinitionNode.php +++ b/src/Language/AST/EnumTypeDefinitionNode.php @@ -19,12 +19,12 @@ class EnumTypeDefinitionNode extends Node implements TypeDefinitionNode public $directives; /** - * @var EnumValueDefinitionNode[] + * @var EnumValueDefinitionNode[]|null|NodeList */ public $values; /** - * @var string + * @var StringValueNode|null */ public $description; } diff --git a/src/Language/AST/EnumTypeExtensionNode.php b/src/Language/AST/EnumTypeExtensionNode.php new file mode 100644 index 0000000..5e2417d --- /dev/null +++ b/src/Language/AST/EnumTypeExtensionNode.php @@ -0,0 +1,25 @@ +InputObjectTypeDefinitionNode::class, // Type Extensions - NodeKind::TYPE_EXTENSION_DEFINITION => TypeExtensionDefinitionNode::class, + NodeKind::SCALAR_TYPE_EXTENSION => ScalarTypeExtensionNode::class, + NodeKind::OBJECT_TYPE_EXTENSION => ObjectTypeExtensionNode::class, + NodeKind::INTERFACE_TYPE_EXTENSION => InterfaceTypeExtensionNode::class, + NodeKind::UNION_TYPE_EXTENSION => UnionTypeExtensionNode::class, + NodeKind::ENUM_TYPE_EXTENSION => EnumTypeExtensionNode::class, + NodeKind::INPUT_OBJECT_TYPE_EXTENSION => InputObjectTypeExtensionNode::class, // Directive Definitions NodeKind::DIRECTIVE_DEFINITION => DirectiveDefinitionNode::class diff --git a/src/Language/AST/ObjectTypeDefinitionNode.php b/src/Language/AST/ObjectTypeDefinitionNode.php index 82d77c4..b2c6b1b 100644 --- a/src/Language/AST/ObjectTypeDefinitionNode.php +++ b/src/Language/AST/ObjectTypeDefinitionNode.php @@ -19,17 +19,17 @@ class ObjectTypeDefinitionNode extends Node implements TypeDefinitionNode public $interfaces = []; /** - * @var DirectiveNode[] + * @var DirectiveNode[]|null */ public $directives; /** - * @var FieldDefinitionNode[] + * @var FieldDefinitionNode[]|null */ public $fields; /** - * @var string + * @var StringValueNode|null */ public $description; } diff --git a/src/Language/AST/ObjectTypeExtensionNode.php b/src/Language/AST/ObjectTypeExtensionNode.php new file mode 100644 index 0000000..c8eab0a --- /dev/null +++ b/src/Language/AST/ObjectTypeExtensionNode.php @@ -0,0 +1,30 @@ + self::QUERY, + self::MUTATION => self::MUTATION, + self::SUBSCRIPTION => self::SUBSCRIPTION, + self::FIELD => self::FIELD, + self::FRAGMENT_DEFINITION => self::FRAGMENT_DEFINITION, + self::FRAGMENT_SPREAD => self::FRAGMENT_SPREAD, + self::INLINE_FRAGMENT => self::INLINE_FRAGMENT, + self::SCHEMA => self::SCHEMA, + self::SCALAR => self::SCALAR, + self::OBJECT => self::OBJECT, + self::FIELD_DEFINITION => self::FIELD_DEFINITION, + self::ARGUMENT_DEFINITION => self::ARGUMENT_DEFINITION, + self::IFACE => self::IFACE, + self::UNION => self::UNION, + self::ENUM => self::ENUM, + self::ENUM_VALUE => self::ENUM_VALUE, + self::INPUT_OBJECT => self::INPUT_OBJECT, + self::INPUT_FIELD_DEFINITION => self::INPUT_FIELD_DEFINITION, + ]; + + /** + * @param string $name + * @return bool + */ + public static function has($name) + { + return isset(self::$locations[$name]); + } +} diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php index 6c1bc82..5ea7992 100644 --- a/src/Language/Lexer.php +++ b/src/Language/Lexer.php @@ -3,6 +3,7 @@ namespace GraphQL\Language; use GraphQL\Error\SyntaxError; use GraphQL\Utils\Utils; +use GraphQL\Utils\BlockString; /** * A Lexer is a stateful stream generator in that every time @@ -91,13 +92,18 @@ class Lexer */ public function advance() { - $token = $this->lastToken = $this->token; + $this->lastToken = $this->token; + $token = $this->token = $this->lookahead(); + return $token; + } + public function lookahead() + { + $token = $this->token; if ($token->kind !== Token::EOF) { do { - $token = $token->next = $this->readToken($token); + $token = $token->next ?: ($token->next = $this->readToken($token)); } while ($token->kind === Token::COMMENT); - $this->token = $token; } return $token; } @@ -201,7 +207,15 @@ class Lexer ->readNumber($line, $col, $prev); // " case 34: - return $this->moveStringCursor(-1, -1 * $bytes) + list(,$nextCode) = $this->readChar(); + list(,$nextNextCode) = $this->moveStringCursor(1, 1)->readChar(); + + if ($nextCode === 34 && $nextNextCode === 34) { + return $this->moveStringCursor(-2, (-1 * $bytes) - 1) + ->readBlockString($line, $col, $prev); + } + + return $this->moveStringCursor(-2, (-1 * $bytes) - 1) ->readString($line, $col, $prev); } @@ -370,12 +384,28 @@ class Lexer $value = ''; while ( - $code && + $code !== null && // not LineTerminator - $code !== 10 && $code !== 13 && - // not Quote (") - $code !== 34 + $code !== 10 && $code !== 13 ) { + // Closing Quote (") + if ($code === 34) { + $value .= $chunk; + + // Skip quote + $this->moveStringCursor(1, 1); + + return new Token( + Token::STRING, + $start, + $this->position, + $line, + $col, + $prev, + $value + ); + } + $this->assertValidStringCharacterCode($code, $this->position); $this->moveStringCursor(1, $bytes); @@ -421,27 +451,83 @@ class Lexer list ($char, $code, $bytes) = $this->readChar(); } - if ($code !== 34) { - throw new SyntaxError( - $this->source, - $this->position, - 'Unterminated string.' - ); + throw new SyntaxError( + $this->source, + $this->position, + 'Unterminated string.' + ); + } + + /** + * Reads a block string token from the source file. + * + * """("?"?(\\"""|\\(?!=""")|[^"\\]))*""" + */ + private function readBlockString($line, $col, Token $prev) + { + $start = $this->position; + + // Skip leading quotes and read first string char: + list ($char, $code, $bytes) = $this->moveStringCursor(3, 3)->readChar(); + + $chunk = ''; + $value = ''; + + while ($code !== null) { + // Closing Triple-Quote (""") + if ($code === 34) { + // Move 2 quotes + list(,$nextCode) = $this->moveStringCursor(1, 1)->readChar(); + list(,$nextNextCode) = $this->moveStringCursor(1, 1)->readChar(); + + if ($nextCode === 34 && $nextNextCode === 34) { + $value .= $chunk; + + $this->moveStringCursor(1, 1); + + return new Token( + Token::BLOCK_STRING, + $start, + $this->position, + $line, + $col, + $prev, + BlockString::value($value) + ); + } else { + // move cursor back to before the first quote + $this->moveStringCursor(-2, -2); + } + } + + $this->assertValidBlockStringCharacterCode($code, $this->position); + $this->moveStringCursor(1, $bytes); + + list(,$nextCode) = $this->readChar(); + list(,$nextNextCode) = $this->moveStringCursor(1, 1)->readChar(); + list(,$nextNextNextCode) = $this->moveStringCursor(1, 1)->readChar(); + + // Escape Triple-Quote (\""") + if ($code === 92 && + $nextCode === 34 && + $nextNextCode === 34 && + $nextNextNextCode === 34 + ) { + $this->moveStringCursor(1, 1); + $value .= $chunk . '"""'; + $chunk = ''; + } else { + $this->moveStringCursor(-2, -2); + $chunk .= $char; + } + + list ($char, $code, $bytes) = $this->readChar(); } - $value .= $chunk; - - // Skip trailing quote: - $this->moveStringCursor(1, 1); - - return new Token( - Token::STRING, - $start, + throw new SyntaxError( + $this->source, $this->position, - $line, - $col, - $prev, - $value + 'Unterminated string.' ); } @@ -457,6 +543,18 @@ class Lexer } } + private function assertValidBlockStringCharacterCode($code, $position) + { + // SourceCharacter + if ($code < 0x0020 && $code !== 0x0009 && $code !== 0x000A && $code !== 0x000D) { + throw new SyntaxError( + $this->source, + $position, + 'Invalid character within String: ' . Utils::printCharCode($code) + ); + } + } + /** * Reads from body starting at startPosition until it finds a non-whitespace * or commented character, then places cursor to the position of that character. @@ -537,7 +635,7 @@ class Lexer $byteStreamPosition = $this->byteStreamPosition; } - $code = 0; + $code = null; $utf8char = ''; $bytes = 0; $positionOffset = 0; diff --git a/src/Language/Parser.php b/src/Language/Parser.php index 2a7c7f0..08481b6 100644 --- a/src/Language/Parser.php +++ b/src/Language/Parser.php @@ -4,11 +4,15 @@ namespace GraphQL\Language; use GraphQL\Language\AST\ArgumentNode; use GraphQL\Language\AST\DirectiveDefinitionNode; use GraphQL\Language\AST\EnumTypeDefinitionNode; +use GraphQL\Language\AST\EnumTypeExtensionNode; use GraphQL\Language\AST\EnumValueDefinitionNode; +use GraphQL\Language\AST\ExecutableDefinitionNode; use GraphQL\Language\AST\FieldDefinitionNode; use GraphQL\Language\AST\InputObjectTypeDefinitionNode; +use GraphQL\Language\AST\InputObjectTypeExtensionNode; use GraphQL\Language\AST\InputValueDefinitionNode; use GraphQL\Language\AST\InterfaceTypeDefinitionNode; +use GraphQL\Language\AST\InterfaceTypeExtensionNode; use GraphQL\Language\AST\ListValueNode; use GraphQL\Language\AST\BooleanValueNode; use GraphQL\Language\AST\DirectiveNode; @@ -33,12 +37,15 @@ use GraphQL\Language\AST\ObjectValueNode; use GraphQL\Language\AST\OperationDefinitionNode; use GraphQL\Language\AST\OperationTypeDefinitionNode; use GraphQL\Language\AST\ScalarTypeDefinitionNode; +use GraphQL\Language\AST\ScalarTypeExtensionNode; use GraphQL\Language\AST\SchemaDefinitionNode; use GraphQL\Language\AST\SelectionSetNode; use GraphQL\Language\AST\StringValueNode; -use GraphQL\Language\AST\TypeExtensionDefinitionNode; +use GraphQL\Language\AST\ObjectTypeExtensionNode; +use GraphQL\Language\AST\TypeExtensionNode; use GraphQL\Language\AST\TypeSystemDefinitionNode; use GraphQL\Language\AST\UnionTypeDefinitionNode; +use GraphQL\Language\AST\UnionTypeExtensionNode; use GraphQL\Language\AST\VariableNode; use GraphQL\Language\AST\VariableDefinitionNode; use GraphQL\Error\SyntaxError; @@ -59,10 +66,25 @@ class Parser * in the source that they correspond to. This configuration flag * disables that behavior for performance or testing.) * + * experimentalFragmentVariables: boolean, + * (If enabled, the parser will understand and parse variable definitions + * contained in a fragment definition. They'll be represented in the + * `variableDefinitions` field of the FragmentDefinitionNode. + * + * The syntax is identical to normal, query-defined variables. For example: + * + * fragment A($var: Boolean = false) on T { + * ... + * } + * + * Note: this feature is experimental and may change or be removed in the + * future.) + * * @api * @param Source|string $source * @param array $options * @return DocumentNode + * @throws SyntaxError */ public static function parse($source, array $options = []) { @@ -321,26 +343,20 @@ class Parser } /** - * @return OperationDefinitionNode|FragmentDefinitionNode|TypeSystemDefinitionNode + * @return ExecutableDefinitionNode|TypeSystemDefinitionNode * @throws SyntaxError */ function parseDefinition() { - if ($this->peek(Token::BRACE_L)) { - return $this->parseOperationDefinition(); - } - if ($this->peek(Token::NAME)) { switch ($this->lexer->token->value) { case 'query': case 'mutation': case 'subscription': - return $this->parseOperationDefinition(); - case 'fragment': - return $this->parseFragmentDefinition(); + return $this->parseExecutableDefinition(); - // Note: the Type System IDL is an experimental non-spec addition. + // Note: The schema definition language is an experimental addition. case 'schema': case 'scalar': case 'type': @@ -350,8 +366,37 @@ class Parser case 'input': case 'extend': case 'directive': + // Note: The schema definition language is an experimental addition. return $this->parseTypeSystemDefinition(); } + } else if ($this->peek(Token::BRACE_L)) { + return $this->parseExecutableDefinition(); + } else if ($this->peekDescription()) { + // Note: The schema definition language is an experimental addition. + return $this->parseTypeSystemDefinition(); + } + + throw $this->unexpected(); + } + + /** + * @return ExecutableDefinitionNode + * @throws SyntaxError + */ + function parseExecutableDefinition() + { + if ($this->peek(Token::NAME)) { + switch ($this->lexer->token->value) { + case 'query': + case 'mutation': + case 'subscription': + return $this->parseOperationDefinition(); + + case 'fragment': + return $this->parseFragmentDefinition(); + } + } else if ($this->peek(Token::BRACE_L)) { + return $this->parseOperationDefinition(); } throw $this->unexpected(); @@ -370,7 +415,7 @@ class Parser return new OperationDefinitionNode([ 'operation' => 'query', 'name' => null, - 'variableDefinitions' => null, + 'variableDefinitions' => new NodeList([]), 'directives' => new NodeList([]), 'selectionSet' => $this->parseSelectionSet(), 'loc' => $this->loc($start) @@ -388,7 +433,7 @@ class Parser 'operation' => $operation, 'name' => $name, 'variableDefinitions' => $this->parseVariableDefinitions(), - 'directives' => $this->parseDirectives(), + 'directives' => $this->parseDirectives(false), 'selectionSet' => $this->parseSelectionSet(), 'loc' => $this->loc($start) ]); @@ -404,7 +449,6 @@ class Parser switch ($operationToken->value) { case 'query': return 'query'; case 'mutation': return 'mutation'; - // Note: subscription is an experimental non-spec addition. case 'subscription': return 'subscription'; } @@ -490,6 +534,7 @@ class Parser /** * @return FieldNode + * @throws SyntaxError */ function parseField() { @@ -507,20 +552,23 @@ class Parser return new FieldNode([ 'alias' => $alias, 'name' => $name, - 'arguments' => $this->parseArguments(), - 'directives' => $this->parseDirectives(), + 'arguments' => $this->parseArguments(false), + 'directives' => $this->parseDirectives(false), 'selectionSet' => $this->peek(Token::BRACE_L) ? $this->parseSelectionSet() : null, 'loc' => $this->loc($start) ]); } /** + * @param bool $isConst * @return ArgumentNode[]|NodeList + * @throws SyntaxError */ - function parseArguments() + function parseArguments($isConst) { + $item = $isConst ? 'parseConstArgument' : 'parseArgument'; return $this->peek(Token::PAREN_L) ? - $this->many(Token::PAREN_L, [$this, 'parseArgument'], Token::PAREN_R) : + $this->many(Token::PAREN_L, [$this, $item], Token::PAREN_R) : new NodeList([]); } @@ -543,6 +591,25 @@ class Parser ]); } + /** + * @return ArgumentNode + * @throws SyntaxError + */ + function parseConstArgument() + { + $start = $this->lexer->token; + $name = $this->parseName(); + + $this->expect(Token::COLON); + $value = $this->parseConstValue(); + + return new ArgumentNode([ + 'name' => $name, + 'value' => $value, + 'loc' => $this->loc($start) + ]); + } + // Implements the parsing rules in the Fragments section. /** @@ -557,7 +624,7 @@ class Parser if ($this->peek(Token::NAME) && $this->lexer->token->value !== 'on') { return new FragmentSpreadNode([ 'name' => $this->parseFragmentName(), - 'directives' => $this->parseDirectives(), + 'directives' => $this->parseDirectives(false), 'loc' => $this->loc($start) ]); } @@ -570,7 +637,7 @@ class Parser return new InlineFragmentNode([ 'typeCondition' => $typeCondition, - 'directives' => $this->parseDirectives(), + 'directives' => $this->parseDirectives(false), 'selectionSet' => $this->parseSelectionSet(), 'loc' => $this->loc($start) ]); @@ -586,13 +653,21 @@ class Parser $this->expectKeyword('fragment'); $name = $this->parseFragmentName(); + + // Experimental support for defining variables within fragments changes + // the grammar of FragmentDefinition: + // - fragment FragmentName VariableDefinitions? on TypeCondition Directives? SelectionSet + $variableDefinitions = null; + if (isset($this->lexer->options['experimentalFragmentVariables'])) { + $variableDefinitions = $this->parseVariableDefinitions(); + } $this->expectKeyword('on'); $typeCondition = $this->parseNamedType(); - return new FragmentDefinitionNode([ 'name' => $name, + 'variableDefinitions' => $variableDefinitions, 'typeCondition' => $typeCondition, - 'directives' => $this->parseDirectives(), + 'directives' => $this->parseDirectives(false), 'selectionSet' => $this->parseSelectionSet(), 'loc' => $this->loc($start) ]); @@ -655,11 +730,8 @@ class Parser 'loc' => $this->loc($token) ]); case Token::STRING: - $this->lexer->advance(); - return new StringValueNode([ - 'value' => $token->value, - 'loc' => $this->loc($token) - ]); + case Token::BLOCK_STRING: + return $this->parseStringLiteral(); case Token::NAME: if ($token->value === 'true' || $token->value === 'false') { $this->lexer->advance(); @@ -690,6 +762,20 @@ class Parser throw $this->unexpected(); } + /** + * @return StringValueNode + */ + function parseStringLiteral() { + $token = $this->lexer->token; + $this->lexer->advance(); + + return new StringValueNode([ + 'value' => $token->value, + 'block' => $token->kind === Token::BLOCK_STRING, + 'loc' => $this->loc($token) + ]); + } + /** * @return BooleanValueNode|EnumValueNode|FloatValueNode|IntValueNode|StringValueNode|VariableNode * @throws SyntaxError @@ -760,28 +846,31 @@ class Parser // Implements the parsing rules in the Directives section. /** + * @param bool $isConst * @return DirectiveNode[]|NodeList + * @throws SyntaxError */ - function parseDirectives() + function parseDirectives($isConst) { $directives = []; while ($this->peek(Token::AT)) { - $directives[] = $this->parseDirective(); + $directives[] = $this->parseDirective($isConst); } return new NodeList($directives); } /** + * @param bool $isConst * @return DirectiveNode * @throws SyntaxError */ - function parseDirective() + function parseDirective($isConst) { $start = $this->lexer->token; $this->expect(Token::AT); return new DirectiveNode([ 'name' => $this->parseName(), - 'arguments' => $this->parseArguments(), + 'arguments' => $this->parseArguments($isConst), 'loc' => $this->loc($start) ]); } @@ -834,7 +923,7 @@ class Parser * TypeSystemDefinition : * - SchemaDefinition * - TypeDefinition - * - TypeExtensionDefinition + * - TypeExtension * - DirectiveDefinition * * TypeDefinition : @@ -850,8 +939,13 @@ class Parser */ function parseTypeSystemDefinition() { - if ($this->peek(Token::NAME)) { - switch ($this->lexer->token->value) { + // Many definitions begin with a description and require a lookahead. + $keywordToken = $this->peekDescription() + ? $this->lexer->lookahead() + : $this->lexer->token; + + if ($keywordToken->kind === Token::NAME) { + switch ($keywordToken->value) { case 'schema': return $this->parseSchemaDefinition(); case 'scalar': return $this->parseScalarTypeDefinition(); case 'type': return $this->parseObjectTypeDefinition(); @@ -859,12 +953,28 @@ class Parser case 'union': return $this->parseUnionTypeDefinition(); case 'enum': return $this->parseEnumTypeDefinition(); case 'input': return $this->parseInputObjectTypeDefinition(); - case 'extend': return $this->parseTypeExtensionDefinition(); + case 'extend': return $this->parseTypeExtension(); case 'directive': return $this->parseDirectiveDefinition(); } } - throw $this->unexpected(); + throw $this->unexpected($keywordToken); + } + + /** + * @return bool + */ + function peekDescription() { + return $this->peek(Token::STRING) || $this->peek(Token::BLOCK_STRING); + } + + /** + * @return StringValueNode|null + */ + function parseDescription() { + if ($this->peekDescription()) { + return $this->parseStringLiteral(); + } } /** @@ -875,7 +985,7 @@ class Parser { $start = $this->lexer->token; $this->expectKeyword('schema'); - $directives = $this->parseDirectives(); + $directives = $this->parseDirectives(true); $operationTypes = $this->many( Token::BRACE_L, @@ -892,6 +1002,7 @@ class Parser /** * @return OperationTypeDefinitionNode + * @throws SyntaxError */ function parseOperationTypeDefinition() { @@ -914,11 +1025,10 @@ class Parser function parseScalarTypeDefinition() { $start = $this->lexer->token; + $description = $this->parseDescription(); $this->expectKeyword('scalar'); $name = $this->parseName(); - $directives = $this->parseDirectives(); - - $description = $this->getDescriptionFromAdjacentCommentTokens($start); + $directives = $this->parseDirectives(true); return new ScalarTypeDefinitionNode([ 'name' => $name, @@ -935,18 +1045,12 @@ class Parser function parseObjectTypeDefinition() { $start = $this->lexer->token; + $description = $this->parseDescription(); $this->expectKeyword('type'); $name = $this->parseName(); $interfaces = $this->parseImplementsInterfaces(); - $directives = $this->parseDirectives(); - - $fields = $this->any( - Token::BRACE_L, - [$this, 'parseFieldDefinition'], - Token::BRACE_R - ); - - $description = $this->getDescriptionFromAdjacentCommentTokens($start); + $directives = $this->parseDirectives(true); + $fields = $this->parseFieldsDefinition(); return new ObjectTypeDefinitionNode([ 'name' => $name, @@ -973,6 +1077,21 @@ class Parser return $types; } + /** + * @return FieldDefinitionNode[]|NodeList + * @throws SyntaxError + */ + function parseFieldsDefinition() + { + return $this->peek(Token::BRACE_L) + ? $this->many( + Token::BRACE_L, + [$this, 'parseFieldDefinition'], + Token::BRACE_R + ) + : new NodeList([]); + } + /** * @return FieldDefinitionNode * @throws SyntaxError @@ -980,13 +1099,12 @@ class Parser function parseFieldDefinition() { $start = $this->lexer->token; + $description = $this->parseDescription(); $name = $this->parseName(); $args = $this->parseArgumentDefs(); $this->expect(Token::COLON); $type = $this->parseTypeReference(); - $directives = $this->parseDirectives(); - - $description = $this->getDescriptionFromAdjacentCommentTokens($start); + $directives = $this->parseDirectives(true); return new FieldDefinitionNode([ 'name' => $name, @@ -1000,11 +1118,12 @@ class Parser /** * @return InputValueDefinitionNode[]|NodeList + * @throws SyntaxError */ function parseArgumentDefs() { if (!$this->peek(Token::PAREN_L)) { - return []; + return new NodeList([]); } return $this->many(Token::PAREN_L, [$this, 'parseInputValueDef'], Token::PAREN_R); } @@ -1016,6 +1135,7 @@ class Parser function parseInputValueDef() { $start = $this->lexer->token; + $description = $this->parseDescription(); $name = $this->parseName(); $this->expect(Token::COLON); $type = $this->parseTypeReference(); @@ -1023,8 +1143,7 @@ class Parser if ($this->skip(Token::EQUALS)) { $defaultValue = $this->parseConstValue(); } - $directives = $this->parseDirectives(); - $description = $this->getDescriptionFromAdjacentCommentTokens($start); + $directives = $this->parseDirectives(true); return new InputValueDefinitionNode([ 'name' => $name, 'type' => $type, @@ -1042,16 +1161,11 @@ class Parser function parseInterfaceTypeDefinition() { $start = $this->lexer->token; + $description = $this->parseDescription(); $this->expectKeyword('interface'); $name = $this->parseName(); - $directives = $this->parseDirectives(); - $fields = $this->any( - Token::BRACE_L, - [$this, 'parseFieldDefinition'], - Token::BRACE_R - ); - - $description = $this->getDescriptionFromAdjacentCommentTokens($start); + $directives = $this->parseDirectives(true); + $fields = $this->parseFieldsDefinition(); return new InterfaceTypeDefinitionNode([ 'name' => $name, @@ -1069,13 +1183,11 @@ class Parser function parseUnionTypeDefinition() { $start = $this->lexer->token; + $description = $this->parseDescription(); $this->expectKeyword('union'); $name = $this->parseName(); - $directives = $this->parseDirectives(); - $this->expect(Token::EQUALS); - $types = $this->parseUnionMembers(); - - $description = $this->getDescriptionFromAdjacentCommentTokens($start); + $directives = $this->parseDirectives(true); + $types = $this->parseMemberTypesDefinition(); return new UnionTypeDefinitionNode([ 'name' => $name, @@ -1087,22 +1199,23 @@ class Parser } /** - * UnionMembers : + * MemberTypes : * - `|`? NamedType - * - UnionMembers | NamedType + * - MemberTypes | NamedType * * @return NamedTypeNode[] */ - function parseUnionMembers() + function parseMemberTypesDefinition() { - // Optional leading pipe - $this->skip(Token::PIPE); - $members = []; - - do { - $members[] = $this->parseNamedType(); - } while ($this->skip(Token::PIPE)); - return $members; + $types = []; + if ($this->skip(Token::EQUALS)) { + // Optional leading pipe + $this->skip(Token::PIPE); + do { + $types[] = $this->parseNamedType(); + } while ($this->skip(Token::PIPE)); + } + return $types; } /** @@ -1112,16 +1225,11 @@ class Parser function parseEnumTypeDefinition() { $start = $this->lexer->token; + $description = $this->parseDescription(); $this->expectKeyword('enum'); $name = $this->parseName(); - $directives = $this->parseDirectives(); - $values = $this->many( - Token::BRACE_L, - [$this, 'parseEnumValueDefinition'], - Token::BRACE_R - ); - - $description = $this->getDescriptionFromAdjacentCommentTokens($start); + $directives = $this->parseDirectives(true); + $values = $this->parseEnumValuesDefinition(); return new EnumTypeDefinitionNode([ 'name' => $name, @@ -1132,16 +1240,31 @@ class Parser ]); } + /** + * @return EnumValueDefinitionNode[]|NodeList + * @throws SyntaxError + */ + function parseEnumValuesDefinition() + { + return $this->peek(Token::BRACE_L) + ? $this->many( + Token::BRACE_L, + [$this, 'parseEnumValueDefinition'], + Token::BRACE_R + ) + : new NodeList([]); + } + /** * @return EnumValueDefinitionNode + * @throws SyntaxError */ function parseEnumValueDefinition() { $start = $this->lexer->token; + $description = $this->parseDescription(); $name = $this->parseName(); - $directives = $this->parseDirectives(); - - $description = $this->getDescriptionFromAdjacentCommentTokens($start); + $directives = $this->parseDirectives(true); return new EnumValueDefinitionNode([ 'name' => $name, @@ -1158,16 +1281,11 @@ class Parser function parseInputObjectTypeDefinition() { $start = $this->lexer->token; + $description = $this->parseDescription(); $this->expectKeyword('input'); $name = $this->parseName(); - $directives = $this->parseDirectives(); - $fields = $this->any( - Token::BRACE_L, - [$this, 'parseInputValueDef'], - Token::BRACE_R - ); - - $description = $this->getDescriptionFromAdjacentCommentTokens($start); + $directives = $this->parseDirectives(true); + $fields = $this->parseInputFieldsDefinition(); return new InputObjectTypeDefinitionNode([ 'name' => $name, @@ -1179,17 +1297,206 @@ class Parser } /** - * @return TypeExtensionDefinitionNode + * @return InputValueDefinitionNode[]|NodeList * @throws SyntaxError */ - function parseTypeExtensionDefinition() + function parseInputFieldsDefinition() { + return $this->peek(Token::BRACE_L) + ? $this->many( + Token::BRACE_L, + [$this, 'parseInputValueDef'], + Token::BRACE_R + ) + : new NodeList([]); + } + + /** + * TypeExtension : + * - ScalarTypeExtension + * - ObjectTypeExtension + * - InterfaceTypeExtension + * - UnionTypeExtension + * - EnumTypeExtension + * - InputObjectTypeDefinition + * + * @return TypeExtensionNode + * @throws SyntaxError + */ + function parseTypeExtension() { + $keywordToken = $this->lexer->lookahead(); + + if ($keywordToken->kind === Token::NAME) { + switch ($keywordToken->value) { + case 'scalar': + return $this->parseScalarTypeExtension(); + case 'type': + return $this->parseObjectTypeExtension(); + case 'interface': + return $this->parseInterfaceTypeExtension(); + case 'union': + return $this->parseUnionTypeExtension(); + case 'enum': + return $this->parseEnumTypeExtension(); + case 'input': + return $this->parseInputObjectTypeExtension(); + } + } + + throw $this->unexpected($keywordToken); + } + + /** + * @return ScalarTypeExtensionNode + * @throws SyntaxError + */ + function parseScalarTypeExtension() { $start = $this->lexer->token; $this->expectKeyword('extend'); - $definition = $this->parseObjectTypeDefinition(); + $this->expectKeyword('scalar'); + $name = $this->parseName(); + $directives = $this->parseDirectives(true); + if (count($directives) === 0) { + throw $this->unexpected(); + } - return new TypeExtensionDefinitionNode([ - 'definition' => $definition, + return new ScalarTypeExtensionNode([ + 'name' => $name, + 'directives' => $directives, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return ObjectTypeExtensionNode + * @throws SyntaxError + */ + function parseObjectTypeExtension() { + $start = $this->lexer->token; + $this->expectKeyword('extend'); + $this->expectKeyword('type'); + $name = $this->parseName(); + $interfaces = $this->parseImplementsInterfaces(); + $directives = $this->parseDirectives(true); + $fields = $this->parseFieldsDefinition(); + + if ( + !$interfaces && + count($directives) === 0 && + count($fields) === 0 + ) { + throw $this->unexpected(); + } + + return new ObjectTypeExtensionNode([ + 'name' => $name, + 'interfaces' => $interfaces, + 'directives' => $directives, + 'fields' => $fields, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return InterfaceTypeExtensionNode + * @throws SyntaxError + */ + function parseInterfaceTypeExtension() { + $start = $this->lexer->token; + $this->expectKeyword('extend'); + $this->expectKeyword('interface'); + $name = $this->parseName(); + $directives = $this->parseDirectives(true); + $fields = $this->parseFieldsDefinition(); + if ( + count($directives) === 0 && + count($fields) === 0 + ) { + throw $this->unexpected(); + } + + return new InterfaceTypeExtensionNode([ + 'name' => $name, + 'directives' => $directives, + 'fields' => $fields, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return UnionTypeExtensionNode + * @throws SyntaxError + */ + function parseUnionTypeExtension() { + $start = $this->lexer->token; + $this->expectKeyword('extend'); + $this->expectKeyword('union'); + $name = $this->parseName(); + $directives = $this->parseDirectives(true); + $types = $this->parseMemberTypesDefinition(); + if ( + count($directives) === 0 && + !$types + ) { + throw $this->unexpected(); + } + + return new UnionTypeExtensionNode([ + 'name' => $name, + 'directives' => $directives, + 'types' => $types, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return EnumTypeExtensionNode + * @throws SyntaxError + */ + function parseEnumTypeExtension() { + $start = $this->lexer->token; + $this->expectKeyword('extend'); + $this->expectKeyword('enum'); + $name = $this->parseName(); + $directives = $this->parseDirectives(true); + $values = $this->parseEnumValuesDefinition(); + if ( + count($directives) === 0 && + count($values) === 0 + ) { + throw $this->unexpected(); + } + + return new EnumTypeExtensionNode([ + 'name' => $name, + 'directives' => $directives, + 'values' => $values, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return InputObjectTypeExtensionNode + * @throws SyntaxError + */ + function parseInputObjectTypeExtension() { + $start = $this->lexer->token; + $this->expectKeyword('extend'); + $this->expectKeyword('input'); + $name = $this->parseName(); + $directives = $this->parseDirectives(true); + $fields = $this->parseInputFieldsDefinition(); + if ( + count($directives) === 0 && + count($fields) === 0 + ) { + throw $this->unexpected(); + } + + return new InputObjectTypeExtensionNode([ + 'name' => $name, + 'directives' => $directives, + 'fields' => $fields, 'loc' => $this->loc($start) ]); } @@ -1204,6 +1511,7 @@ class Parser function parseDirectiveDefinition() { $start = $this->lexer->token; + $description = $this->parseDescription(); $this->expectKeyword('directive'); $this->expect(Token::AT); $name = $this->parseName(); @@ -1215,12 +1523,14 @@ class Parser 'name' => $name, 'arguments' => $args, 'locations' => $locations, - 'loc' => $this->loc($start) + 'loc' => $this->loc($start), + 'description' => $description ]); } /** * @return NameNode[] + * @throws SyntaxError */ function parseDirectiveLocations() { @@ -1228,32 +1538,23 @@ class Parser $this->skip(Token::PIPE); $locations = []; do { - $locations[] = $this->parseName(); + $locations[] = $this->parseDirectiveLocation(); } while ($this->skip(Token::PIPE)); return $locations; } /** - * @param Token $nameToken - * @return null|string + * @return NameNode + * @throws SyntaxError */ - private function getDescriptionFromAdjacentCommentTokens(Token $nameToken) + function parseDirectiveLocation() { - $description = null; - - $currentToken = $nameToken; - $previousToken = $currentToken->prev; - - while ($previousToken->kind == Token::COMMENT - && ($previousToken->line + 1) == $currentToken->line - ) { - $description = $previousToken->value . $description; - - // walk the tokens backwards until no longer adjacent comments - $currentToken = $previousToken; - $previousToken = $currentToken->prev; + $start = $this->lexer->token; + $name = $this->parseName(); + if (DirectiveLocation::has($name->value)) { + return $name; } - return $description; + throw $this->unexpected($start); } } diff --git a/src/Language/Printer.php b/src/Language/Printer.php index 7e7336b..ed25c66 100644 --- a/src/Language/Printer.php +++ b/src/Language/Printer.php @@ -4,11 +4,14 @@ namespace GraphQL\Language; use GraphQL\Language\AST\ArgumentNode; use GraphQL\Language\AST\DirectiveDefinitionNode; use GraphQL\Language\AST\EnumTypeDefinitionNode; +use GraphQL\Language\AST\EnumTypeExtensionNode; use GraphQL\Language\AST\EnumValueDefinitionNode; use GraphQL\Language\AST\FieldDefinitionNode; use GraphQL\Language\AST\InputObjectTypeDefinitionNode; +use GraphQL\Language\AST\InputObjectTypeExtensionNode; use GraphQL\Language\AST\InputValueDefinitionNode; use GraphQL\Language\AST\InterfaceTypeDefinitionNode; +use GraphQL\Language\AST\InterfaceTypeExtensionNode; use GraphQL\Language\AST\ListValueNode; use GraphQL\Language\AST\BooleanValueNode; use GraphQL\Language\AST\DirectiveNode; @@ -32,11 +35,13 @@ use GraphQL\Language\AST\ObjectValueNode; use GraphQL\Language\AST\OperationDefinitionNode; use GraphQL\Language\AST\OperationTypeDefinitionNode; use GraphQL\Language\AST\ScalarTypeDefinitionNode; +use GraphQL\Language\AST\ScalarTypeExtensionNode; use GraphQL\Language\AST\SchemaDefinitionNode; use GraphQL\Language\AST\SelectionSetNode; use GraphQL\Language\AST\StringValueNode; -use GraphQL\Language\AST\TypeExtensionDefinitionNode; +use GraphQL\Language\AST\ObjectTypeExtensionNode; use GraphQL\Language\AST\UnionTypeDefinitionNode; +use GraphQL\Language\AST\UnionTypeExtensionNode; use GraphQL\Language\AST\VariableDefinitionNode; use GraphQL\Utils\Utils; @@ -126,7 +131,11 @@ class Printer ], ' '); }, NodeKind::FRAGMENT_DEFINITION => function(FragmentDefinitionNode $node) { - return "fragment {$node->name} on {$node->typeCondition} " + // Note: fragment variable definitions are experimental and may be changed + // or removed in the future. + return "fragment {$node->name}" + . $this->wrap('(', $this->join($node->variableDefinitions, ', '), ')') + . " on {$node->typeCondition} " . $this->wrap('', $this->join($node->directives, ' '), ' ') . $node->selectionSet; }, @@ -138,7 +147,10 @@ class Printer NodeKind::FLOAT => function(FloatValueNode $node) { return $node->value; }, - NodeKind::STRING => function(StringValueNode $node) { + NodeKind::STRING => function(StringValueNode $node, $key) { + if ($node->block) { + return $this->printBlockString($node->value, $key === 'description'); + } return json_encode($node->value); }, NodeKind::BOOLEAN => function(BooleanValueNode $node) { @@ -189,74 +201,150 @@ class Printer }, NodeKind::SCALAR_TYPE_DEFINITION => function(ScalarTypeDefinitionNode $def) { - return $this->join(['scalar', $def->name, $this->join($def->directives, ' ')], ' '); + return $this->join([ + $def->description, + $this->join(['scalar', $def->name, $this->join($def->directives, ' ')], ' ') + ], "\n"); }, NodeKind::OBJECT_TYPE_DEFINITION => function(ObjectTypeDefinitionNode $def) { return $this->join([ - 'type', - $def->name, - $this->wrap('implements ', $this->join($def->interfaces, ', ')), - $this->join($def->directives, ' '), - $this->block($def->fields) - ], ' '); + $def->description, + $this->join([ + 'type', + $def->name, + $this->wrap('implements ', $this->join($def->interfaces, ', ')), + $this->join($def->directives, ' '), + $this->block($def->fields) + ], ' ') + ], "\n"); }, NodeKind::FIELD_DEFINITION => function(FieldDefinitionNode $def) { - return $def->name + return $this->join([ + $def->description, + $def->name . $this->wrap('(', $this->join($def->arguments, ', '), ')') . ': ' . $def->type - . $this->wrap(' ', $this->join($def->directives, ' ')); + . $this->wrap(' ', $this->join($def->directives, ' ')) + ], "\n"); }, NodeKind::INPUT_VALUE_DEFINITION => function(InputValueDefinitionNode $def) { return $this->join([ - $def->name . ': ' . $def->type, - $this->wrap('= ', $def->defaultValue), - $this->join($def->directives, ' ') - ], ' '); + $def->description, + $this->join([ + $def->name . ': ' . $def->type, + $this->wrap('= ', $def->defaultValue), + $this->join($def->directives, ' ') + ], ' ') + ], "\n"); }, NodeKind::INTERFACE_TYPE_DEFINITION => function(InterfaceTypeDefinitionNode $def) { return $this->join([ - 'interface', - $def->name, - $this->join($def->directives, ' '), - $this->block($def->fields) - ], ' '); + $def->description, + $this->join([ + 'interface', + $def->name, + $this->join($def->directives, ' '), + $this->block($def->fields) + ], ' ') + ], "\n"); }, NodeKind::UNION_TYPE_DEFINITION => function(UnionTypeDefinitionNode $def) { return $this->join([ - 'union', - $def->name, - $this->join($def->directives, ' '), - '= ' . $this->join($def->types, ' | ') - ], ' '); + $def->description, + $this->join([ + 'union', + $def->name, + $this->join($def->directives, ' '), + $def->types + ? '= ' . $this->join($def->types, ' | ') + : '' + ], ' ') + ], "\n"); }, NodeKind::ENUM_TYPE_DEFINITION => function(EnumTypeDefinitionNode $def) { return $this->join([ - 'enum', - $def->name, - $this->join($def->directives, ' '), - $this->block($def->values) - ], ' '); + $def->description, + $this->join([ + 'enum', + $def->name, + $this->join($def->directives, ' '), + $this->block($def->values) + ], ' ') + ], "\n"); }, NodeKind::ENUM_VALUE_DEFINITION => function(EnumValueDefinitionNode $def) { return $this->join([ - $def->name, - $this->join($def->directives, ' ') - ], ' '); + $def->description, + $this->join([$def->name, $this->join($def->directives, ' ')], ' ') + ], "\n"); }, NodeKind::INPUT_OBJECT_TYPE_DEFINITION => function(InputObjectTypeDefinitionNode $def) { return $this->join([ - 'input', + $def->description, + $this->join([ + 'input', + $def->name, + $this->join($def->directives, ' '), + $this->block($def->fields) + ], ' ') + ], "\n"); + }, + NodeKind::SCALAR_TYPE_EXTENSION => function(ScalarTypeExtensionNode $def) { + return $this->join([ + 'extend scalar', $def->name, $this->join($def->directives, ' '), - $this->block($def->fields) ], ' '); }, - NodeKind::TYPE_EXTENSION_DEFINITION => function(TypeExtensionDefinitionNode $def) { - return "extend {$def->definition}"; + NodeKind::OBJECT_TYPE_EXTENSION => function(ObjectTypeExtensionNode $def) { + return $this->join([ + 'extend type', + $def->name, + $this->wrap('implements ', $this->join($def->interfaces, ', ')), + $this->join($def->directives, ' '), + $this->block($def->fields), + ], ' '); + }, + NodeKind::INTERFACE_TYPE_EXTENSION => function(InterfaceTypeExtensionNode $def) { + return $this->join([ + 'extend interface', + $def->name, + $this->join($def->directives, ' '), + $this->block($def->fields), + ], ' '); + }, + NodeKind::UNION_TYPE_EXTENSION => function(UnionTypeExtensionNode $def) { + return $this->join([ + 'extend union', + $def->name, + $this->join($def->directives, ' '), + $def->types + ? '= ' . $this->join($def->types, ' | ') + : '' + ], ' '); + }, + NodeKind::ENUM_TYPE_EXTENSION => function(EnumTypeExtensionNode $def) { + return $this->join([ + 'extend enum', + $def->name, + $this->join($def->directives, ' '), + $this->block($def->values), + ], ' '); + }, + NodeKind::INPUT_OBJECT_TYPE_EXTENSION => function(InputObjectTypeExtensionNode $def) { + return $this->join([ + 'extend input', + $def->name, + $this->join($def->directives, ' '), + $this->block($def->fields), + ], ' '); }, NodeKind::DIRECTIVE_DEFINITION => function(DirectiveDefinitionNode $def) { - return 'directive @' . $def->name . $this->wrap('(', $this->join($def->arguments, ', '), ')') - . ' on ' . $this->join($def->locations, ' | '); + return $this->join([ + $def->description, + 'directive @' . $def->name . $this->wrap('(', $this->join($def->arguments, ', '), ')') + . ' on ' . $this->join($def->locations, ' | ') + ], "\n"); } ] ]); @@ -277,12 +365,14 @@ class Printer */ public function block($array) { - return $array && $this->length($array) ? $this->indent("{\n" . $this->join($array, "\n")) . "\n}" : '{}'; + return ($array && $this->length($array)) + ? "{\n" . $this->indent($this->join($array, "\n")) . "\n}" + : ''; } public function indent($maybeString) { - return $maybeString ? str_replace("\n", "\n ", $maybeString) : ''; + return $maybeString ? ' ' . str_replace("\n", "\n ", $maybeString) : ''; } public function manyList($start, $list, $separator, $end) @@ -307,4 +397,16 @@ class Printer ) : ''; } + + /** + * Print a block string in the indented block form by adding a leading and + * trailing blank line. However, if a block string starts with whitespace and is + * a single-line, adding a leading blank line would strip that whitespace. + */ + private function printBlockString($value, $isDescription) { + $escaped = str_replace('"""', '\\"""', $value); + return (($value[0] === ' ' || $value[0] === "\t") && strpos($value, "\n") === false) + ? ('"""' . preg_replace('/"$/', "\"\n", $escaped) . '"""') + : ("\"\"\"\n" . ($isDescription ? $escaped : $this->indent($escaped)) . "\n\"\"\""); + } } diff --git a/src/Language/Source.php b/src/Language/Source.php index 29fd0fe..899d450 100644 --- a/src/Language/Source.php +++ b/src/Language/Source.php @@ -39,8 +39,8 @@ class Source * be "Foo.graphql" and location to be `{ line: 40, column: 0 }`. * line and column in locationOffset are 1-indexed * - * @param $body - * @param null $name + * @param string $body + * @param string|null $name * @param SourceLocation|null $location */ public function __construct($body, $name = null, SourceLocation $location = null) @@ -52,7 +52,7 @@ class Source $this->body = $body; $this->length = mb_strlen($body, 'UTF-8'); - $this->name = $name ?: 'GraphQL'; + $this->name = $name ?: 'GraphQL request'; $this->locationOffset = $location ?: new SourceLocation(1, 1); Utils::invariant( diff --git a/src/Language/Token.php b/src/Language/Token.php index f908a5d..f98d686 100644 --- a/src/Language/Token.php +++ b/src/Language/Token.php @@ -27,6 +27,7 @@ class Token const INT = 'Int'; const FLOAT = 'Float'; const STRING = 'String'; + const BLOCK_STRING = 'BlockString'; const COMMENT = 'Comment'; /** @@ -57,6 +58,7 @@ class Token $description[self::INT] = 'Int'; $description[self::FLOAT] = 'Float'; $description[self::STRING] = 'String'; + $description[self::BLOCK_STRING] = 'BlockString'; $description[self::COMMENT] = 'Comment'; return $description[$kind]; diff --git a/src/Language/Visitor.php b/src/Language/Visitor.php index 9fadc8c..ab20d1e 100644 --- a/src/Language/Visitor.php +++ b/src/Language/Visitor.php @@ -115,7 +115,15 @@ class Visitor NodeKind::ARGUMENT => ['name', 'value'], NodeKind::FRAGMENT_SPREAD => ['name', 'directives'], NodeKind::INLINE_FRAGMENT => ['typeCondition', 'directives', 'selectionSet'], - NodeKind::FRAGMENT_DEFINITION => ['name', 'typeCondition', 'directives', 'selectionSet'], + NodeKind::FRAGMENT_DEFINITION => [ + 'name', + // Note: fragment variable definitions are experimental and may be changed + // or removed in the future. + 'variableDefinitions', + 'typeCondition', + 'directives', + 'selectionSet' + ], NodeKind::INT => [], NodeKind::FLOAT => [], @@ -133,17 +141,24 @@ class Visitor NodeKind::SCHEMA_DEFINITION => ['directives', 'operationTypes'], NodeKind::OPERATION_TYPE_DEFINITION => ['type'], - NodeKind::SCALAR_TYPE_DEFINITION => ['name', 'directives'], - NodeKind::OBJECT_TYPE_DEFINITION => ['name', 'interfaces', 'directives', 'fields'], - NodeKind::FIELD_DEFINITION => ['name', 'arguments', 'type', 'directives'], - NodeKind::INPUT_VALUE_DEFINITION => ['name', 'type', 'defaultValue', 'directives'], - NodeKind::INTERFACE_TYPE_DEFINITION => [ 'name', 'directives', 'fields' ], - NodeKind::UNION_TYPE_DEFINITION => [ 'name', 'directives', 'types' ], - NodeKind::ENUM_TYPE_DEFINITION => [ 'name', 'directives', 'values' ], - NodeKind::ENUM_VALUE_DEFINITION => [ 'name', 'directives' ], - NodeKind::INPUT_OBJECT_TYPE_DEFINITION => [ 'name', 'directives', 'fields' ], - NodeKind::TYPE_EXTENSION_DEFINITION => [ 'definition' ], - NodeKind::DIRECTIVE_DEFINITION => [ 'name', 'arguments', 'locations' ] + NodeKind::SCALAR_TYPE_DEFINITION => ['description', 'name', 'directives'], + NodeKind::OBJECT_TYPE_DEFINITION => ['description', 'name', 'interfaces', 'directives', 'fields'], + NodeKind::FIELD_DEFINITION => ['description', 'name', 'arguments', 'type', 'directives'], + NodeKind::INPUT_VALUE_DEFINITION => ['description', 'name', 'type', 'defaultValue', 'directives'], + NodeKind::INTERFACE_TYPE_DEFINITION => ['description', 'name', 'directives', 'fields'], + NodeKind::UNION_TYPE_DEFINITION => ['description', 'name', 'directives', 'types'], + NodeKind::ENUM_TYPE_DEFINITION => ['description', 'name', 'directives', 'values'], + NodeKind::ENUM_VALUE_DEFINITION => ['description', 'name', 'directives'], + NodeKind::INPUT_OBJECT_TYPE_DEFINITION => ['description', 'name', 'directives', 'fields'], + + NodeKind::SCALAR_TYPE_EXTENSION => ['name', 'directives'], + NodeKind::OBJECT_TYPE_EXTENSION => ['name', 'interfaces', 'directives', 'fields'], + NodeKind::INTERFACE_TYPE_EXTENSION => ['name', 'directives', 'fields'], + NodeKind::UNION_TYPE_EXTENSION => ['name', 'directives', 'types'], + NodeKind::ENUM_TYPE_EXTENSION => ['name', 'directives', 'values'], + NodeKind::INPUT_OBJECT_TYPE_EXTENSION => ['name', 'directives', 'fields'], + + NodeKind::DIRECTIVE_DEFINITION => ['description', 'name', 'arguments', 'locations'] ]; /** @@ -180,7 +195,7 @@ class Visitor $isEdited = $isLeaving && count($edits) !== 0; if ($isLeaving) { - $key = count($ancestors) === 0 ? $UNDEFINED : array_pop($path); + $key = !$ancestors ? $UNDEFINED : $path[count($path) - 1]; $node = $parent; $parent = array_pop($ancestors); @@ -277,7 +292,9 @@ class Visitor $edits[] = [$key, $node]; } - if (!$isLeaving) { + if ($isLeaving) { + array_pop($path); + } else { $stack = [ 'inArray' => $inArray, 'index' => $index, diff --git a/src/Server.php b/src/Server.php index 3350b1f..16d1f05 100644 --- a/src/Server.php +++ b/src/Server.php @@ -472,6 +472,7 @@ class Server { try { $schema = $this->getSchema(); + $schema->assertValid(); } catch (InvariantViolation $e) { throw new InvariantViolation("Cannot validate, schema contains errors: {$e->getMessage()}", null, $e); } diff --git a/src/Type/Definition/BooleanType.php b/src/Type/Definition/BooleanType.php index 64746a9..2a1adc7 100644 --- a/src/Type/Definition/BooleanType.php +++ b/src/Type/Definition/BooleanType.php @@ -2,6 +2,7 @@ namespace GraphQL\Type\Definition; use GraphQL\Language\AST\BooleanValueNode; +use GraphQL\Utils\Utils; /** * Class BooleanType @@ -34,18 +35,19 @@ class BooleanType extends ScalarType */ public function parseValue($value) { - return is_bool($value) ? $value : null; + return is_bool($value) ? $value : Utils::undefined(); } /** - * @param $ast + * @param $valueNode + * @param array|null $variables * @return bool|null */ - public function parseLiteral($ast) + public function parseLiteral($valueNode, array $variables = null) { - if ($ast instanceof BooleanValueNode) { - return (bool) $ast->value; + if ($valueNode instanceof BooleanValueNode) { + return (bool) $valueNode->value; } - return null; + return Utils::undefined(); } } diff --git a/src/Type/Definition/CustomScalarType.php b/src/Type/Definition/CustomScalarType.php index 49bc8be..14e1d53 100644 --- a/src/Type/Definition/CustomScalarType.php +++ b/src/Type/Definition/CustomScalarType.php @@ -1,6 +1,7 @@ config['parseValue'])) { return call_user_func($this->config['parseValue'], $value); } else { - return null; + return $value; } } /** * @param $valueNode + * @param array|null $variables * @return mixed */ - public function parseLiteral(/* GraphQL\Language\AST\ValueNode */ $valueNode) + public function parseLiteral(/* GraphQL\Language\AST\ValueNode */ $valueNode, array $variables = null) { if (isset($this->config['parseLiteral'])) { - return call_user_func($this->config['parseLiteral'], $valueNode); + return call_user_func($this->config['parseLiteral'], $valueNode, $variables); } else { - return null; + return AST::valueFromASTUntyped($valueNode, $variables); } } diff --git a/src/Type/Definition/Directive.php b/src/Type/Definition/Directive.php index 546f02d..b3b9a1a 100644 --- a/src/Type/Definition/Directive.php +++ b/src/Type/Definition/Directive.php @@ -2,6 +2,8 @@ namespace GraphQL\Type\Definition; use GraphQL\Language\AST\DirectiveDefinitionNode; +use GraphQL\Language\DirectiveLocation; +use GraphQL\Utils\Utils; /** * Class Directive @@ -18,35 +20,6 @@ class Directive // Schema Definitions - - /** - * @var array - * @deprecated as of 8.0 (use DirectiveLocation constants directly) - */ - public static $directiveLocations = [ - // Operations: - DirectiveLocation::QUERY => DirectiveLocation::QUERY, - DirectiveLocation::MUTATION => DirectiveLocation::MUTATION, - DirectiveLocation::SUBSCRIPTION => DirectiveLocation::SUBSCRIPTION, - DirectiveLocation::FIELD => DirectiveLocation::FIELD, - DirectiveLocation::FRAGMENT_DEFINITION => DirectiveLocation::FRAGMENT_DEFINITION, - DirectiveLocation::FRAGMENT_SPREAD => DirectiveLocation::FRAGMENT_SPREAD, - DirectiveLocation::INLINE_FRAGMENT => DirectiveLocation::INLINE_FRAGMENT, - - // Schema Definitions - DirectiveLocation::SCHEMA => DirectiveLocation::SCHEMA, - DirectiveLocation::SCALAR => DirectiveLocation::SCALAR, - DirectiveLocation::OBJECT => DirectiveLocation::OBJECT, - DirectiveLocation::FIELD_DEFINITION => DirectiveLocation::FIELD_DEFINITION, - DirectiveLocation::ARGUMENT_DEFINITION => DirectiveLocation::ARGUMENT_DEFINITION, - DirectiveLocation::IFACE => DirectiveLocation::IFACE, - DirectiveLocation::UNION => DirectiveLocation::UNION, - DirectiveLocation::ENUM => DirectiveLocation::ENUM, - DirectiveLocation::ENUM_VALUE => DirectiveLocation::ENUM_VALUE, - DirectiveLocation::INPUT_OBJECT => DirectiveLocation::INPUT_OBJECT, - DirectiveLocation::INPUT_FIELD_DEFINITION => DirectiveLocation::INPUT_FIELD_DEFINITION - ]; - /** * @return Directive */ @@ -74,6 +47,15 @@ class Directive return $internal['deprecated']; } + /** + * @param Directive $directive + * @return bool + */ + public static function isSpecifiedDirective(Directive $directive) + { + return in_array($directive->name, array_keys(self::getInternalDirectives())); + } + /** * @return array */ @@ -178,6 +160,9 @@ class Directive foreach ($config as $key => $value) { $this->{$key} = $value; } + + Utils::invariant($this->name, 'Directive must be named.'); + Utils::invariant(is_array($this->locations), 'Must provide locations for directive.'); $this->config = $config; } } diff --git a/src/Type/Definition/DirectiveLocation.php b/src/Type/Definition/DirectiveLocation.php index fee4d7a..a4bc719 100644 --- a/src/Type/Definition/DirectiveLocation.php +++ b/src/Type/Definition/DirectiveLocation.php @@ -1,27 +1,17 @@ tryInferName(); } - Utils::assertValidName($config['name'], !empty($config['isIntrospection'])); + Utils::invariant(is_string($config['name']), 'Must provide name.'); Config::validate($config, [ 'name' => Config::NAME | Config::REQUIRED, @@ -108,25 +108,11 @@ class EnumType extends Type implements InputType, OutputType, LeafType public function serialize($value) { $lookup = $this->getValueLookup(); - return isset($lookup[$value]) ? $lookup[$value]->name : null; - } + if (isset($lookup[$value])) { + return $lookup[$value]->name; + } - /** - * @param string $value - * @return bool - */ - public function isValidValue($value) - { - return is_string($value) && $this->getNameLookup()->offsetExists($value); - } - - /** - * @param $valueNode - * @return bool - */ - public function isValidLiteral($valueNode) - { - return $valueNode instanceof EnumValueNode && $this->getNameLookup()->offsetExists($valueNode->value); + return Utils::undefined(); } /** @@ -136,14 +122,15 @@ class EnumType extends Type implements InputType, OutputType, LeafType public function parseValue($value) { $lookup = $this->getNameLookup(); - return isset($lookup[$value]) ? $lookup[$value]->value : null; + return isset($lookup[$value]) ? $lookup[$value]->value : Utils::undefined(); } /** * @param $value + * @param array|null $variables * @return null */ - public function parseLiteral($value) + public function parseLiteral($value, array $variables = null) { if ($value instanceof EnumValueNode) { $lookup = $this->getNameLookup(); @@ -201,24 +188,7 @@ class EnumType extends Type implements InputType, OutputType, LeafType ); $values = $this->getValues(); - - Utils::invariant( - !empty($values), - "{$this->name} values must be not empty." - ); foreach ($values as $value) { - try { - Utils::assertValidName($value->name); - } catch (InvariantViolation $e) { - throw new InvariantViolation( - "{$this->name} has value with invalid name: " . - Utils::printSafe($value->name) . " ({$e->getMessage()})" - ); - } - Utils::invariant( - !in_array($value->name, ['true', 'false', 'null']), - "{$this->name}: \"{$value->name}\" can not be used as an Enum value." - ); Utils::invariant( !isset($value->config['isDeprecated']), "{$this->name}.{$value->name} should provide \"deprecationReason\" instead of \"isDeprecated\"." diff --git a/src/Type/Definition/FloatType.php b/src/Type/Definition/FloatType.php index 092f258..e6391de 100644 --- a/src/Type/Definition/FloatType.php +++ b/src/Type/Definition/FloatType.php @@ -2,7 +2,6 @@ namespace GraphQL\Type\Definition; use GraphQL\Error\Error; -use GraphQL\Error\InvariantViolation; use GraphQL\Language\AST\FloatValueNode; use GraphQL\Language\AST\IntValueNode; use GraphQL\Utils\Utils; @@ -29,39 +28,50 @@ values as specified by /** * @param mixed $value * @return float|null + * @throws Error */ public function serialize($value) { - if (is_numeric($value) || $value === true || $value === false) { - return (float) $value; - } - - if ($value === '') { - $err = 'Float cannot represent non numeric value: (empty string)'; - } else { - $err = sprintf('Float cannot represent non numeric value: %s', Utils::printSafe($value)); - } - throw new InvariantViolation($err); + return $this->coerceFloat($value); } /** * @param mixed $value * @return float|null + * @throws Error */ public function parseValue($value) { - return (is_numeric($value) && !is_string($value)) ? (float) $value : null; + return $this->coerceFloat($value); } /** - * @param $ast + * @param $valueNode + * @param array|null $variables * @return float|null */ - public function parseLiteral($ast) + public function parseLiteral($valueNode, array $variables = null) { - if ($ast instanceof FloatValueNode || $ast instanceof IntValueNode) { - return (float) $ast->value; + if ($valueNode instanceof FloatValueNode || $valueNode instanceof IntValueNode) { + return (float) $valueNode->value; } - return null; + return Utils::undefined(); + } + + private function coerceFloat($value) { + if ($value === '') { + throw new Error( + 'Float cannot represent non numeric value: (empty string)' + ); + } + + if (!is_numeric($value) && $value !== true && $value !== false) { + throw new Error( + 'Float cannot represent non numeric value: ' . + Utils::printSafe($value) + ); + } + + return (float) $value; } } diff --git a/src/Type/Definition/IDType.php b/src/Type/Definition/IDType.php index 5912973..d60a3a1 100644 --- a/src/Type/Definition/IDType.php +++ b/src/Type/Definition/IDType.php @@ -2,7 +2,6 @@ namespace GraphQL\Type\Definition; use GraphQL\Error\Error; -use GraphQL\Error\InvariantViolation; use GraphQL\Language\AST\IntValueNode; use GraphQL\Language\AST\StringValueNode; use GraphQL\Utils\Utils; @@ -44,7 +43,7 @@ When expected as an input type, any string (such as `"4"`) or integer return 'null'; } if (!is_scalar($value) && (!is_object($value) || !method_exists($value, '__toString'))) { - throw new InvariantViolation("ID type cannot represent non scalar value: " . Utils::printSafe($value)); + throw new Error("ID type cannot represent non scalar value: " . Utils::printSafe($value)); } return (string) $value; } @@ -55,18 +54,19 @@ When expected as an input type, any string (such as `"4"`) or integer */ public function parseValue($value) { - return (is_string($value) || is_int($value)) ? (string) $value : null; + return (is_string($value) || is_int($value)) ? (string) $value : Utils::undefined(); } /** * @param $ast + * @param array|null $variables * @return null|string */ - public function parseLiteral($ast) + public function parseLiteral($valueNode, array $variables = null) { - if ($ast instanceof StringValueNode || $ast instanceof IntValueNode) { - return $ast->value; + if ($valueNode instanceof StringValueNode || $valueNode instanceof IntValueNode) { + return $valueNode->value; } - return null; + return Utils::undefined(); } } diff --git a/src/Type/Definition/InputObjectType.php b/src/Type/Definition/InputObjectType.php index d795630..a3661e6 100644 --- a/src/Type/Definition/InputObjectType.php +++ b/src/Type/Definition/InputObjectType.php @@ -9,7 +9,7 @@ use GraphQL\Utils\Utils; * Class InputObjectType * @package GraphQL\Type\Definition */ -class InputObjectType extends Type implements InputType +class InputObjectType extends Type implements InputType, NamedType { /** * @var InputObjectField[] @@ -31,7 +31,7 @@ class InputObjectType extends Type implements InputType $config['name'] = $this->tryInferName(); } - Utils::assertValidName($config['name']); + Utils::invariant(is_string($config['name']), 'Must provide name.'); Config::validate($config, [ 'name' => Config::NAME | Config::REQUIRED, @@ -91,41 +91,4 @@ class InputObjectType extends Type implements InputType Utils::invariant(isset($this->fields[$name]), "Field '%s' is not defined for type '%s'", $name, $this->name); return $this->fields[$name]; } - - /** - * @throws InvariantViolation - */ - public function assertValid() - { - parent::assertValid(); - - $fields = $this->getFields(); - - Utils::invariant( - !empty($fields), - "{$this->name} fields must not be empty" - ); - - foreach ($fields as $field) { - try { - Utils::assertValidName($field->name); - } catch (InvariantViolation $e) { - throw new InvariantViolation("{$this->name}.{$field->name}: {$e->getMessage()}"); - } - - $fieldType = $field->type; - if ($fieldType instanceof WrappingType) { - $fieldType = $fieldType->getWrappedType(true); - } - Utils::invariant( - $fieldType instanceof InputType, - "{$this->name}.{$field->name} field type must be Input Type but got: %s.", - Utils::printSafe($field->type) - ); - Utils::invariant( - !isset($field->config['resolve']), - "{$this->name}.{$field->name} field type has a resolve property, but Input Types cannot define resolvers." - ); - } - } } diff --git a/src/Type/Definition/InputType.php b/src/Type/Definition/InputType.php index 7f90039..b2c3830 100644 --- a/src/Type/Definition/InputType.php +++ b/src/Type/Definition/InputType.php @@ -3,11 +3,16 @@ namespace GraphQL\Type\Definition; /* export type GraphQLInputType = - GraphQLScalarType | - GraphQLEnumType | - GraphQLInputObjectType | - GraphQLList | - GraphQLNonNull; + | GraphQLScalarType + | GraphQLEnumType + | GraphQLInputObjectType + | GraphQLList + | GraphQLNonNull< + | GraphQLScalarType + | GraphQLEnumType + | GraphQLInputObjectType + | GraphQLList, + >; */ interface InputType { diff --git a/src/Type/Definition/IntType.php b/src/Type/Definition/IntType.php index 4473ce5..0e401cf 100644 --- a/src/Type/Definition/IntType.php +++ b/src/Type/Definition/IntType.php @@ -2,7 +2,6 @@ namespace GraphQL\Type\Definition; use GraphQL\Error\Error; -use GraphQL\Error\InvariantViolation; use GraphQL\Language\AST\IntValueNode; use GraphQL\Utils\Utils; @@ -35,63 +34,60 @@ values. Int can represent values between -(2^31) and 2^31 - 1. '; /** * @param mixed $value * @return int|null + * @throws Error */ public function serialize($value) { - if ($value === '') { - throw new InvariantViolation('Int cannot represent non 32-bit signed integer value: (empty string)'); - } - if (false === $value || true === $value) { - return (int) $value; - } - if (!is_numeric($value) || $value > self::MAX_INT || $value < self::MIN_INT) { - throw new InvariantViolation(sprintf( - 'Int cannot represent non 32-bit signed integer value: %s', - Utils::printSafe($value) - )); - } - $num = (float) $value; - - // The GraphQL specification does not allow serializing non-integer values - // as Int to avoid accidental data loss. - // Examples: 1.0 == 1; 1.1 != 1, etc - if ($num != (int) $value) { - // Additionally account for scientific notation (i.e. 1e3), because (float)'1e3' is 1000, but (int)'1e3' is 1 - $trimmed = floor($num); - if ($trimmed !== $num) { - throw new InvariantViolation(sprintf( - 'Int cannot represent non-integer value: %s', - Utils::printSafe($value) - )); - } - } - return (int) $value; + return $this->coerceInt($value); } /** * @param mixed $value * @return int|null + * @throws Error */ public function parseValue($value) { - // Below is a fix against PHP bug where (in some combinations of OSs and versions) - // boundary values are treated as "double" vs "integer" and failing is_int() check - $isInt = is_int($value) || $value === self::MIN_INT || $value === self::MAX_INT; - return $isInt && $value <= self::MAX_INT && $value >= self::MIN_INT ? $value : null; + return $this->coerceInt($value); } /** - * @param $ast + * @param $valueNode + * @param array|null $variables * @return int|null */ - public function parseLiteral($ast) + public function parseLiteral($valueNode, array $variables = null) { - if ($ast instanceof IntValueNode) { - $val = (int) $ast->value; - if ($ast->value === (string) $val && self::MIN_INT <= $val && $val <= self::MAX_INT) { + if ($valueNode instanceof IntValueNode) { + $val = (int) $valueNode->value; + if ($valueNode->value === (string) $val && self::MIN_INT <= $val && $val <= self::MAX_INT) { return $val; } } - return null; + return Utils::undefined(); + } + + private function coerceInt($value) { + if ($value === '') { + throw new Error( + 'Int cannot represent non 32-bit signed integer value: (empty string)' + ); + } + + $num = floatval($value); + if (!is_numeric($value) && !is_bool($value) || $num > self::MAX_INT || $num < self::MIN_INT) { + throw new Error( + 'Int cannot represent non 32-bit signed integer value: ' . + Utils::printSafe($value) + ); + } + $int = intval($num); + if ($int != $num) { + throw new Error( + 'Int cannot represent non-integer value: ' . + Utils::printSafe($value) + ); + } + return $int; } } diff --git a/src/Type/Definition/InterfaceType.php b/src/Type/Definition/InterfaceType.php index 9d532ec..3d57f88 100644 --- a/src/Type/Definition/InterfaceType.php +++ b/src/Type/Definition/InterfaceType.php @@ -3,14 +3,29 @@ namespace GraphQL\Type\Definition; use GraphQL\Error\InvariantViolation; use GraphQL\Language\AST\InterfaceTypeDefinitionNode; +use GraphQL\Language\AST\InterfaceTypeExtensionNode; use GraphQL\Utils\Utils; /** * Class InterfaceType * @package GraphQL\Type\Definition */ -class InterfaceType extends Type implements AbstractType, OutputType, CompositeType +class InterfaceType extends Type implements AbstractType, OutputType, CompositeType, NamedType { + /** + * @param mixed $type + * @return self + */ + public static function assertInterfaceType($type) + { + Utils::invariant( + $type instanceof self, + 'Expected ' . Utils::printSafe($type) . ' to be a GraphQL Interface type.' + ); + + return $type; + } + /** * @var FieldDefinition[] */ @@ -21,6 +36,11 @@ class InterfaceType extends Type implements AbstractType, OutputType, CompositeT */ public $astNode; + /** + * @var InterfaceTypeExtensionNode[] + */ + public $extensionASTNodes; + /** * InterfaceType constructor. * @param array $config @@ -31,7 +51,7 @@ class InterfaceType extends Type implements AbstractType, OutputType, CompositeT $config['name'] = $this->tryInferName(); } - Utils::assertValidName($config['name']); + Utils::invariant(is_string($config['name']), 'Must provide name.'); Config::validate($config, [ 'name' => Config::NAME, @@ -46,6 +66,7 @@ class InterfaceType extends Type implements AbstractType, OutputType, CompositeT $this->name = $config['name']; $this->description = isset($config['description']) ? $config['description'] : null; $this->astNode = isset($config['astNode']) ? $config['astNode'] : null; + $this->extensionASTNodes = isset($config['extensionASTNodes']) ? $config['extensionASTNodes'] : null; $this->config = $config; } @@ -99,23 +120,9 @@ class InterfaceType extends Type implements AbstractType, OutputType, CompositeT { parent::assertValid(); - $fields = $this->getFields(); - Utils::invariant( !isset($this->config['resolveType']) || is_callable($this->config['resolveType']), "{$this->name} must provide \"resolveType\" as a function." ); - - Utils::invariant( - !empty($fields), - "{$this->name} fields must not be empty" - ); - - foreach ($fields as $field) { - $field->assertValid($this); - foreach ($field->args as $arg) { - $arg->assertValid($field, $this); - } - } } } diff --git a/src/Type/Definition/LeafType.php b/src/Type/Definition/LeafType.php index a0bd30f..9569b59 100644 --- a/src/Type/Definition/LeafType.php +++ b/src/Type/Definition/LeafType.php @@ -1,6 +1,8 @@ ofType = $type; + $this->ofType = Type::assertType($type); } /** diff --git a/src/Type/Definition/NamedType.php b/src/Type/Definition/NamedType.php new file mode 100644 index 0000000..be9681a --- /dev/null +++ b/src/Type/Definition/NamedType.php @@ -0,0 +1,15 @@ +ofType = $type; + $this->ofType = self::assertNullableType($type); } /** * @param bool $recurse - * @return ObjectType|InterfaceType|UnionType|ScalarType|InputObjectType|EnumType + * @return ObjectType|InterfaceType|UnionType|ScalarType|InputObjectType|EnumType|ListOfType * @throws InvariantViolation */ public function getWrappedType($recurse = false) { $type = $this->ofType; - - Utils::invariant( - !($type instanceof NonNull), - 'Cannot nest NonNull inside NonNull' - ); - return ($recurse && $type instanceof WrappingType) ? $type->getWrappedType($recurse) : $type; } diff --git a/src/Type/Definition/ObjectType.php b/src/Type/Definition/ObjectType.php index 43d2355..9f01483 100644 --- a/src/Type/Definition/ObjectType.php +++ b/src/Type/Definition/ObjectType.php @@ -3,7 +3,7 @@ namespace GraphQL\Type\Definition; use GraphQL\Error\InvariantViolation; use GraphQL\Language\AST\ObjectTypeDefinitionNode; -use GraphQL\Language\AST\TypeExtensionDefinitionNode; +use GraphQL\Language\AST\ObjectTypeExtensionNode; use GraphQL\Utils\Utils; @@ -47,8 +47,22 @@ use GraphQL\Utils\Utils; * ]); * */ -class ObjectType extends Type implements OutputType, CompositeType +class ObjectType extends Type implements OutputType, CompositeType, NamedType { + /** + * @param mixed $type + * @return self + */ + public static function assertObjectType($type) + { + Utils::invariant( + $type instanceof self, + 'Expected ' . Utils::printSafe($type) . ' to be a GraphQL Object type.' + ); + + return $type; + } + /** * @var FieldDefinition[] */ @@ -70,7 +84,7 @@ class ObjectType extends Type implements OutputType, CompositeType public $astNode; /** - * @var TypeExtensionDefinitionNode[] + * @var ObjectTypeExtensionNode[] */ public $extensionASTNodes; @@ -89,7 +103,7 @@ class ObjectType extends Type implements OutputType, CompositeType $config['name'] = $this->tryInferName(); } - Utils::assertValidName($config['name'], !empty($config['isIntrospection'])); + Utils::invariant(is_string($config['name']), 'Must provide name.'); // Note: this validation is disabled by default, because it is resource-consuming // TODO: add bin/validate script to check if schema is valid during development @@ -152,13 +166,13 @@ class ObjectType extends Type implements OutputType, CompositeType $interfaces = isset($this->config['interfaces']) ? $this->config['interfaces'] : []; $interfaces = is_callable($interfaces) ? call_user_func($interfaces) : $interfaces; - if (!is_array($interfaces)) { + if ($interfaces && !is_array($interfaces)) { throw new InvariantViolation( "{$this->name} interfaces must be an Array or a callable which returns an Array." ); } - $this->interfaces = $interfaces; + $this->interfaces = $interfaces ?: []; } return $this->interfaces; } @@ -214,41 +228,5 @@ class ObjectType extends Type implements OutputType, CompositeType !isset($this->config['isTypeOf']) || is_callable($this->config['isTypeOf']), "{$this->name} must provide 'isTypeOf' as a function" ); - - // getFields() and getInterfaceMap() will do structural validation - $fields = $this->getFields(); - Utils::invariant( - !empty($fields), - "{$this->name} fields must not be empty" - ); - foreach ($fields as $field) { - $field->assertValid($this); - foreach ($field->args as $arg) { - $arg->assertValid($field, $this); - } - } - - $implemented = []; - foreach ($this->getInterfaces() as $iface) { - Utils::invariant( - $iface instanceof InterfaceType, - "{$this->name} may only implement Interface types, it cannot implement %s.", - Utils::printSafe($iface) - ); - Utils::invariant( - !isset($implemented[$iface->name]), - "{$this->name} may declare it implements {$iface->name} only once." - ); - $implemented[$iface->name] = true; - if (!isset($iface->config['resolveType'])) { - Utils::invariant( - isset($this->config['isTypeOf']), - "Interface Type {$iface->name} does not provide a \"resolveType\" " . - "function and implementing Type {$this->name} does not provide a " . - '"isTypeOf" function. There is no way to resolve this implementing ' . - 'type during execution.' - ); - } - } } } diff --git a/src/Type/Definition/ScalarType.php b/src/Type/Definition/ScalarType.php index 1af7001..eee6bb0 100644 --- a/src/Type/Definition/ScalarType.php +++ b/src/Type/Definition/ScalarType.php @@ -22,7 +22,7 @@ use GraphQL\Utils\Utils; * } * } */ -abstract class ScalarType extends Type implements OutputType, InputType, LeafType +abstract class ScalarType extends Type implements OutputType, InputType, LeafType, NamedType { /** * @var ScalarTypeDefinitionNode|null @@ -36,30 +36,6 @@ abstract class ScalarType extends Type implements OutputType, InputType, LeafTyp $this->astNode = isset($config['astNode']) ? $config['astNode'] : null; $this->config = $config; - Utils::assertValidName($this->name); - } - - /** - * Determines if an internal value is valid for this type. - * Equivalent to checking for if the parsedValue is nullish. - * - * @param $value - * @return bool - */ - public function isValidValue($value) - { - return null !== $this->parseValue($value); - } - - /** - * Determines if an internal value is valid for this type. - * Equivalent to checking for if the parsedLiteral is nullish. - * - * @param $valueNode - * @return bool - */ - public function isValidLiteral($valueNode) - { - return null !== $this->parseLiteral($valueNode); + Utils::invariant(is_string($this->name), 'Must provide name.'); } } diff --git a/src/Type/Definition/StringType.php b/src/Type/Definition/StringType.php index 0e0784b..a17bdcc 100644 --- a/src/Type/Definition/StringType.php +++ b/src/Type/Definition/StringType.php @@ -2,7 +2,6 @@ namespace GraphQL\Type\Definition; use GraphQL\Error\Error; -use GraphQL\Error\InvariantViolation; use GraphQL\Language\AST\StringValueNode; use GraphQL\Utils\Utils; @@ -28,6 +27,7 @@ represent free-form human-readable text.'; /** * @param mixed $value * @return mixed|string + * @throws Error */ public function serialize($value) { @@ -41,29 +41,42 @@ represent free-form human-readable text.'; return 'null'; } if (!is_scalar($value)) { - throw new InvariantViolation("String cannot represent non scalar value: " . Utils::printSafe($value)); + throw new Error("String cannot represent non scalar value: " . Utils::printSafe($value)); } - return (string) $value; + return $this->coerceString($value); } /** * @param mixed $value * @return string + * @throws Error */ public function parseValue($value) { - return is_string($value) ? $value : null; + return $this->coerceString($value); } /** - * @param $ast + * @param $valueNode + * @param array|null $variables * @return null|string */ - public function parseLiteral($ast) + public function parseLiteral($valueNode, array $variables = null) { - if ($ast instanceof StringValueNode) { - return $ast->value; + if ($valueNode instanceof StringValueNode) { + return $valueNode->value; } - return null; + return Utils::undefined(); + } + + private function coerceString($value) { + if (is_array($value)) { + throw new Error( + 'String cannot represent an array value: ' . + Utils::printSafe($value) + ); + } + + return (string) $value; } } diff --git a/src/Type/Definition/Type.php b/src/Type/Definition/Type.php index 2b2c83f..5dd6b93 100644 --- a/src/Type/Definition/Type.php +++ b/src/Type/Definition/Type.php @@ -2,7 +2,12 @@ namespace GraphQL\Type\Definition; use GraphQL\Error\InvariantViolation; +use GraphQL\Language\AST\ListType; +use GraphQL\Language\AST\NamedType; +use GraphQL\Language\AST\NonNullType; use GraphQL\Language\AST\TypeDefinitionNode; +use GraphQL\Type\Introspection; +use GraphQL\Utils\Utils; /** * Registry of standard GraphQL types @@ -23,6 +28,11 @@ abstract class Type implements \JsonSerializable */ private static $internalTypes; + /** + * @var array + */ + private static $builtInTypes; + /** * @api * @return IDType @@ -70,7 +80,7 @@ abstract class Type implements \JsonSerializable /** * @api - * @param ObjectType|InterfaceType|UnionType|ScalarType|InputObjectType|EnumType|ListOfType|NonNull $wrappedType + * @param Type|ObjectType|InterfaceType|UnionType|ScalarType|InputObjectType|EnumType|ListOfType|NonNull $wrappedType * @return ListOfType */ public static function listOf($wrappedType) @@ -107,6 +117,8 @@ abstract class Type implements \JsonSerializable } /** + * Returns all builtin scalar types + * * @return Type[] */ public static function getInternalTypes() @@ -114,6 +126,34 @@ abstract class Type implements \JsonSerializable return self::getInternalType(); } + /** + * Returns all builtin in types including base scalar and + * introspection types + * + * @return Type[] + */ + public static function getAllBuiltInTypes() + { + if (null === self::$builtInTypes) { + self::$builtInTypes = array_merge( + Introspection::getTypes(), + self::getInternalTypes() + ); + } + return self::$builtInTypes; + } + + /** + * Checks if the type is a builtin type + * + * @param Type $type + * @return bool + */ + public static function isBuiltInType(Type $type) + { + return in_array($type->name, array_keys(self::getAllBuiltInTypes())); + } + /** * @api * @param Type $type @@ -121,8 +161,11 @@ abstract class Type implements \JsonSerializable */ public static function isInputType($type) { - $nakedType = self::getNamedType($type); - return $nakedType instanceof InputType; + return $type instanceof InputType && + ( + !$type instanceof WrappingType || + self::getNamedType($type) instanceof InputType + ); } /** @@ -132,8 +175,11 @@ abstract class Type implements \JsonSerializable */ public static function isOutputType($type) { - $nakedType = self::getNamedType($type); - return $nakedType instanceof OutputType; + return $type instanceof OutputType && + ( + !$type instanceof WrappingType || + self::getNamedType($type) instanceof OutputType + ); } /** @@ -166,6 +212,39 @@ abstract class Type implements \JsonSerializable return $type instanceof AbstractType; } + /** + * @api + * @param Type $type + * @return bool + */ + public static function isType($type) + { + return ( + $type instanceof ScalarType || + $type instanceof ObjectType || + $type instanceof InterfaceType || + $type instanceof UnionType || + $type instanceof EnumType || + $type instanceof InputObjectType || + $type instanceof ListOfType || + $type instanceof NonNull + ); + } + + /** + * @param mixed $type + * @return mixed + */ + public static function assertType($type) + { + Utils::invariant( + self::isType($type), + 'Expected ' . Utils::printSafe($type) . ' to be a GraphQL type.' + ); + + return $type; + } + /** * @api * @param Type $type @@ -238,6 +317,7 @@ abstract class Type implements \JsonSerializable */ public function assertValid() { + Utils::assertValidName($this->name); } /** diff --git a/src/Type/Definition/UnionType.php b/src/Type/Definition/UnionType.php index 49855b7..f9f4863 100644 --- a/src/Type/Definition/UnionType.php +++ b/src/Type/Definition/UnionType.php @@ -9,7 +9,7 @@ use GraphQL\Utils\Utils; * Class UnionType * @package GraphQL\Type\Definition */ -class UnionType extends Type implements AbstractType, OutputType, CompositeType +class UnionType extends Type implements AbstractType, OutputType, CompositeType, NamedType { /** * @var UnionTypeDefinitionNode @@ -36,7 +36,7 @@ class UnionType extends Type implements AbstractType, OutputType, CompositeType $config['name'] = $this->tryInferName(); } - Utils::assertValidName($config['name']); + Utils::invariant(is_string($config['name']), 'Must provide name.'); Config::validate($config, [ 'name' => Config::NAME | Config::REQUIRED, @@ -81,7 +81,8 @@ class UnionType extends Type implements AbstractType, OutputType, CompositeType if (!is_array($types)) { throw new InvariantViolation( - "{$this->name} types must be an Array or a callable which returns an Array." + "Must provide Array of types or a callable which returns " . + "such an array for Union {$this->name}" ); } @@ -133,40 +134,11 @@ class UnionType extends Type implements AbstractType, OutputType, CompositeType { parent::assertValid(); - $types = $this->getTypes(); - Utils::invariant( - !empty($types), - "{$this->name} types must not be empty" - ); - if (isset($this->config['resolveType'])) { Utils::invariant( is_callable($this->config['resolveType']), "{$this->name} must provide \"resolveType\" as a function." ); } - - $includedTypeNames = []; - foreach ($types as $objType) { - Utils::invariant( - $objType instanceof ObjectType, - "{$this->name} may only contain Object types, it cannot contain: %s.", - Utils::printSafe($objType) - ); - Utils::invariant( - !isset($includedTypeNames[$objType->name]), - "{$this->name} can include {$objType->name} type only once." - ); - $includedTypeNames[$objType->name] = true; - if (!isset($this->config['resolveType'])) { - Utils::invariant( - isset($objType->config['isTypeOf']) && is_callable($objType->config['isTypeOf']), - "Union type \"{$this->name}\" does not provide a \"resolveType\" " . - "function and possible type \"{$objType->name}\" does not provide an " . - '"isTypeOf" function. There is no way to resolve this possible type ' . - 'during execution.' - ); - } - } } } diff --git a/src/Type/Introspection.php b/src/Type/Introspection.php index 7f0734d..4fd41ca 100644 --- a/src/Type/Introspection.php +++ b/src/Type/Introspection.php @@ -1,10 +1,9 @@ boolean]).', E_USER_DEPRECATED); + $descriptions = $options; + } else { + $descriptions = !array_key_exists('descriptions', $options) || $options['descriptions'] === true; + } + $descriptionField = $descriptions ? 'description' : ''; + + return <<name, array_keys(self::getTypes())); + } + public static function _schema() { if (!isset(self::$map['__Schema'])) { @@ -593,7 +526,7 @@ EOD; ], 'type' => [ 'type' => Type::nonNull(self::_type()), - 'resolve' => function ($field) { + 'resolve' => function (FieldDefinition $field) { return $field->getType(); } ], diff --git a/src/Type/Schema.php b/src/Type/Schema.php index b4ec795..5bda972 100644 --- a/src/Type/Schema.php +++ b/src/Type/Schema.php @@ -1,18 +1,16 @@ $subscriptionType ]; } + if (is_array($config)) { $config = SchemaConfig::create($config); } - Utils::invariant( - $config instanceof SchemaConfig, - 'Schema constructor expects instance of GraphQL\Type\SchemaConfig or an array with keys: %s; but got: %s', - implode(', ', [ - 'query', - 'mutation', - 'subscription', - 'types', - 'directives', - 'typeLoader' - ]), - Utils::getVariableType($config) - ); - - Utils::invariant( - $config->query instanceof ObjectType, - "Schema query must be Object Type but got: " . Utils::getVariableType($config->query) - ); + // If this schema was built from a source known to be valid, then it may be + // marked with assumeValid to avoid an additional type system validation. + if ($config->getAssumeValid()) { + $this->validationErrors = []; + } else { + // Otherwise check for common mistakes during construction to produce + // clear and early error messages. + Utils::invariant( + $config instanceof SchemaConfig, + 'Schema constructor expects instance of GraphQL\Type\SchemaConfig or an array with keys: %s; but got: %s', + implode(', ', [ + 'query', + 'mutation', + 'subscription', + 'types', + 'directives', + 'typeLoader' + ]), + Utils::getVariableType($config) + ); + Utils::invariant( + !$config->types || is_array($config->types) || is_callable($config->types), + "\"types\" must be array or callable if provided but got: " . Utils::getVariableType($config->types) + ); + Utils::invariant( + !$config->directives || is_array($config->directives), + "\"directives\" must be Array if provided but got: " . Utils::getVariableType($config->directives) + ); + } $this->config = $config; - $this->resolvedTypes[$config->query->name] = $config->query; - + if ($config->query) { + $this->resolvedTypes[$config->query->name] = $config->query; + } if ($config->mutation) { $this->resolvedTypes[$config->mutation->name] = $config->mutation; } @@ -208,7 +224,11 @@ class Schema public function getType($name) { if (!isset($this->resolvedTypes[$name])) { - $this->resolvedTypes[$name] = $this->loadType($name); + $type = $this->loadType($name); + if (!$type) { + return null; + } + $this->resolvedTypes[$name] = $type; } return $this->resolvedTypes[$name]; } @@ -393,6 +413,32 @@ class Schema return isset($typeMap[$typeName]) ? $typeMap[$typeName] : null; } + /** + * Validates schema. + * + * This operation requires full schema scan. Do not use in production environment. + * + * @api + * @return InvariantViolation[]|Error[] + */ + public function validate() { + // If this Schema has already been validated, return the previous results. + if ($this->validationErrors !== null) { + return $this->validationErrors; + } + // Validate the schema, producing a list of errors. + $context = new SchemaValidationContext($this); + $context->validateRootTypes(); + $context->validateDirectives(); + $context->validateTypes(); + + // Persist the results of validation before returning to ensure validation + // does not run multiple times for this schema. + $this->validationErrors = $context->getErrors(); + + return $this->validationErrors; + } + /** * Validates schema. * @@ -403,18 +449,13 @@ class Schema */ public function assertValid() { - foreach ($this->config->getDirectives() as $index => $directive) { - Utils::invariant( - $directive instanceof Directive, - "Each entry of \"directives\" option of Schema config must be an instance of %s but entry at position %d is %s.", - Directive::class, - $index, - Utils::printSafe($directive) - ); + $errors = $this->validate(); + + if ($errors) { + throw new InvariantViolation(implode("\n\n", $this->validationErrors)); } $internalTypes = Type::getInternalTypes() + Introspection::getTypes(); - foreach ($this->getTypeMap() as $name => $type) { if (isset($internalTypes[$name])) { continue ; @@ -422,22 +463,6 @@ class Schema $type->assertValid(); - if ($type instanceof AbstractType) { - $possibleTypes = $this->getPossibleTypes($type); - - Utils::invariant( - !empty($possibleTypes), - "Could not find possible implementing types for {$type->name} " . - 'in schema. Check that schema.types is defined and is an array of ' . - 'all possible types in the schema.' - ); - - } else if ($type instanceof ObjectType) { - foreach ($type->getInterfaces() as $iface) { - $this->assertImplementsIntarface($type, $iface); - } - } - // Make sure type loader returns the same instance as registered in other places of schema if ($this->config->typeLoader) { Utils::invariant( @@ -448,74 +473,4 @@ class Schema } } } - - private function assertImplementsIntarface(ObjectType $object, InterfaceType $iface) - { - $objectFieldMap = $object->getFields(); - $ifaceFieldMap = $iface->getFields(); - - // Assert each interface field is implemented. - foreach ($ifaceFieldMap as $fieldName => $ifaceField) { - - // Assert interface field exists on object. - Utils::invariant( - isset($objectFieldMap[$fieldName]), - "{$iface->name} expects field \"{$fieldName}\" but {$object->name} does not provide it" - ); - - $objectField = $objectFieldMap[$fieldName]; - - // Assert interface field type is satisfied by object field type, by being - // a valid subtype. (covariant) - Utils::invariant( - TypeComparators::isTypeSubTypeOf($this, $objectField->getType(), $ifaceField->getType()), - "{$iface->name}.{$fieldName} expects type \"{$ifaceField->getType()}\" " . - "but " . - "{$object->name}.${fieldName} provides type \"{$objectField->getType()}\"" - ); - - // Assert each interface field arg is implemented. - foreach ($ifaceField->args as $ifaceArg) { - $argName = $ifaceArg->name; - - /** @var FieldArgument $objectArg */ - $objectArg = Utils::find($objectField->args, function(FieldArgument $arg) use ($argName) { - return $arg->name === $argName; - }); - - // Assert interface field arg exists on object field. - Utils::invariant( - $objectArg, - "{$iface->name}.{$fieldName} expects argument \"{$argName}\" but ". - "{$object->name}.{$fieldName} does not provide it." - ); - - // Assert interface field arg type matches object field arg type. - // (invariant) - Utils::invariant( - TypeComparators::isEqualType($ifaceArg->getType(), $objectArg->getType()), - "{$iface->name}.{$fieldName}({$argName}:) expects type " . - "\"{$ifaceArg->getType()->name}\" but " . - "{$object->name}.{$fieldName}({$argName}:) provides type " . - "\"{$objectArg->getType()->name}\"." - ); - - // Assert additional arguments must not be required. - foreach ($objectField->args as $objectArg) { - $argName = $objectArg->name; - $ifaceArg = Utils::find($ifaceField->args, function(FieldArgument $arg) use ($argName) { - return $arg->name === $argName; - }); - if (!$ifaceArg) { - Utils::invariant( - !($objectArg->getType() instanceof NonNull), - "{$object->name}.{$fieldName}({$argName}:) is of required type " . - "\"{$objectArg->getType()}\" but is not also provided by the " . - "interface {$iface->name}.{$fieldName}." - ); - } - } - } - } - } } diff --git a/src/Type/SchemaConfig.php b/src/Type/SchemaConfig.php index 2b03c37..5905a94 100644 --- a/src/Type/SchemaConfig.php +++ b/src/Type/SchemaConfig.php @@ -58,6 +58,11 @@ class SchemaConfig */ public $astNode; + /** + * @var bool + */ + public $assumeValid; + /** * Converts an array of options to instance of SchemaConfig * (or just returns empty config when array is not passed). @@ -72,47 +77,22 @@ class SchemaConfig if (!empty($options)) { if (isset($options['query'])) { - Utils::invariant( - $options['query'] instanceof ObjectType, - 'Schema query must be Object Type if provided but got: %s', - Utils::printSafe($options['query']) - ); $config->setQuery($options['query']); } if (isset($options['mutation'])) { - Utils::invariant( - $options['mutation'] instanceof ObjectType, - 'Schema mutation must be Object Type if provided but got: %s', - Utils::printSafe($options['mutation']) - ); $config->setMutation($options['mutation']); } if (isset($options['subscription'])) { - Utils::invariant( - $options['subscription'] instanceof ObjectType, - 'Schema subscription must be Object Type if provided but got: %s', - Utils::printSafe($options['subscription']) - ); $config->setSubscription($options['subscription']); } if (isset($options['types'])) { - Utils::invariant( - is_array($options['types']) || is_callable($options['types']), - 'Schema types must be array or callable if provided but got: %s', - Utils::printSafe($options['types']) - ); $config->setTypes($options['types']); } if (isset($options['directives'])) { - Utils::invariant( - is_array($options['directives']), - 'Schema directives must be array if provided but got: %s', - Utils::printSafe($options['directives']) - ); $config->setDirectives($options['directives']); } @@ -140,13 +120,12 @@ class SchemaConfig } if (isset($options['astNode'])) { - Utils::invariant( - $options['astNode'] instanceof SchemaDefinitionNode, - 'Schema astNode must be an instance of SchemaDefinitionNode but got: %s', - Utils::printSafe($options['typeLoader']) - ); $config->setAstNode($options['astNode']); } + + if (isset($options['assumeValid'])) { + $config->setAssumeValid((bool) $options['assumeValid']); + } } return $config; @@ -175,7 +154,7 @@ class SchemaConfig * @param ObjectType $query * @return SchemaConfig */ - public function setQuery(ObjectType $query) + public function setQuery($query) { $this->query = $query; return $this; @@ -186,7 +165,7 @@ class SchemaConfig * @param ObjectType $mutation * @return SchemaConfig */ - public function setMutation(ObjectType $mutation) + public function setMutation($mutation) { $this->mutation = $mutation; return $this; @@ -197,7 +176,7 @@ class SchemaConfig * @param ObjectType $subscription * @return SchemaConfig */ - public function setSubscription(ObjectType $subscription) + public function setSubscription($subscription) { $this->subscription = $subscription; return $this; @@ -236,6 +215,16 @@ class SchemaConfig return $this; } + /** + * @param bool $assumeValid + * @return SchemaConfig + */ + public function setAssumeValid($assumeValid) + { + $this->assumeValid = $assumeValid; + return $this; + } + /** * @api * @return ObjectType @@ -289,4 +278,12 @@ class SchemaConfig { return $this->typeLoader; } + + /** + * @return bool + */ + public function getAssumeValid() + { + return $this->assumeValid; + } } diff --git a/src/Type/SchemaValidationContext.php b/src/Type/SchemaValidationContext.php new file mode 100644 index 0000000..7b89871 --- /dev/null +++ b/src/Type/SchemaValidationContext.php @@ -0,0 +1,728 @@ +schema = $schema; + } + + /** + * @return Error[] + */ + public function getErrors() { + return $this->errors; + } + + public function validateRootTypes() { + $queryType = $this->schema->getQueryType(); + if (!$queryType) { + $this->reportError( + 'Query root type must be provided.', + $this->schema->getAstNode() + ); + } else if (!$queryType instanceof ObjectType) { + $this->reportError( + 'Query root type must be Object type, it cannot be ' . Utils::printSafe($queryType) . '.', + $this->getOperationTypeNode($queryType, 'query') + ); + } + + $mutationType = $this->schema->getMutationType(); + if ($mutationType && !$mutationType instanceof ObjectType) { + $this->reportError( + 'Mutation root type must be Object type if provided, it cannot be ' . Utils::printSafe($mutationType) . '.', + $this->getOperationTypeNode($mutationType, 'mutation') + ); + } + + $subscriptionType = $this->schema->getSubscriptionType(); + if ($subscriptionType && !$subscriptionType instanceof ObjectType) { + $this->reportError( + 'Subscription root type must be Object type if provided, it cannot be ' . Utils::printSafe($subscriptionType) . '.', + $this->getOperationTypeNode($subscriptionType, 'subscription') + ); + } + } + + /** + * @param Type $type + * @param string $operation + * + * @return TypeNode|TypeDefinitionNode + */ + private function getOperationTypeNode($type, $operation) + { + $astNode = $this->schema->getAstNode(); + + $operationTypeNode = null; + if ($astNode instanceof SchemaDefinitionNode) { + $operationTypeNode = null; + + foreach($astNode->operationTypes as $operationType) { + if ($operationType->operation === $operation) { + $operationTypeNode = $operationType; + break; + } + } + } + + return $operationTypeNode ? $operationTypeNode->type : ($type ? $type->astNode : null); + } + + public function validateDirectives() + { + $directives = $this->schema->getDirectives(); + foreach($directives as $directive) { + // Ensure all directives are in fact GraphQL directives. + if (!$directive instanceof Directive) { + $this->reportError( + "Expected directive but got: " . Utils::printSafe($directive) . '.', + is_object($directive) ? $directive->astNode : null + ); + continue; + } + + // Ensure they are named correctly. + $this->validateName($directive); + + // TODO: Ensure proper locations. + + $argNames = []; + foreach ($directive->args as $arg) { + $argName = $arg->name; + + // Ensure they are named correctly. + $this->validateName($directive); + + if (isset($argNames[$argName])) { + $this->reportError( + "Argument @{$directive->name}({$argName}:) can only be defined once.", + $this->getAllDirectiveArgNodes($directive, $argName) + ); + continue; + } + + $argNames[$argName] = true; + + // Ensure the type is an input type. + if (!Type::isInputType($arg->getType())) { + $this->reportError( + "The type of @{$directive->name}({$argName}:) must be Input Type " . + 'but got: ' . Utils::printSafe($arg->getType()) . '.', + $this->getDirectiveArgTypeNode($directive, $argName) + ); + } + } + } + } + + /** + * @param Type|Directive|FieldDefinition|EnumValueDefinition|InputObjectField $node + */ + private function validateName($node) + { + // Ensure names are valid, however introspection types opt out. + $error = Utils::isValidNameError($node->name, $node->astNode); + if ($error && !Introspection::isIntrospectionType($node)) { + $this->addError($error); + } + } + + public function validateTypes() + { + $typeMap = $this->schema->getTypeMap(); + foreach($typeMap as $typeName => $type) { + // Ensure all provided types are in fact GraphQL type. + if (!$type instanceof NamedType) { + $this->reportError( + "Expected GraphQL named type but got: " . Utils::printSafe($type) . '.', + is_object($type) ? $type->astNode : null + ); + continue; + } + + $this->validateName($type); + + if ($type instanceof ObjectType) { + // Ensure fields are valid + $this->validateFields($type); + + // Ensure objects implement the interfaces they claim to. + $this->validateObjectInterfaces($type); + } else if ($type instanceof InterfaceType) { + // Ensure fields are valid. + $this->validateFields($type); + } else if ($type instanceof UnionType) { + // Ensure Unions include valid member types. + $this->validateUnionMembers($type); + } else if ($type instanceof EnumType) { + // Ensure Enums have valid values. + $this->validateEnumValues($type); + } else if ($type instanceof InputObjectType) { + // Ensure Input Object fields are valid. + $this->validateInputFields($type); + } + } + } + + /** + * @param ObjectType|InterfaceType $type + */ + private function validateFields($type) { + $fieldMap = $type->getFields(); + + // Objects and Interfaces both must define one or more fields. + if (!$fieldMap) { + $this->reportError( + "Type {$type->name} must define one or more fields.", + $this->getAllObjectOrInterfaceNodes($type) + ); + } + + foreach ($fieldMap as $fieldName => $field) { + // Ensure they are named correctly. + $this->validateName($field); + + // Ensure they were defined at most once. + $fieldNodes = $this->getAllFieldNodes($type, $fieldName); + if ($fieldNodes && count($fieldNodes) > 1) { + $this->reportError( + "Field {$type->name}.{$fieldName} can only be defined once.", + $fieldNodes + ); + continue; + } + + // Ensure the type is an output type + if (!Type::isOutputType($field->getType())) { + $this->reportError( + "The type of {$type->name}.{$fieldName} must be Output Type " . + 'but got: ' . Utils::printSafe($field->getType()) . '.', + $this->getFieldTypeNode($type, $fieldName) + ); + } + + // Ensure the arguments are valid + $argNames = []; + foreach($field->args as $arg) { + $argName = $arg->name; + + // Ensure they are named correctly. + $this->validateName($arg); + + if (isset($argNames[$argName])) { + $this->reportError( + "Field argument {$type->name}.{$fieldName}({$argName}:) can only " . + 'be defined once.', + $this->getAllFieldArgNodes($type, $fieldName, $argName) + ); + } + $argNames[$argName] = true; + + // Ensure the type is an input type + if (!Type::isInputType($arg->getType())) { + $this->reportError( + "The type of {$type->name}.{$fieldName}({$argName}:) must be Input " . + 'Type but got: '. Utils::printSafe($arg->getType()) . '.', + $this->getFieldArgTypeNode($type, $fieldName, $argName) + ); + } + } + } + } + + private function validateObjectInterfaces(ObjectType $object) { + $implementedTypeNames = []; + foreach($object->getInterfaces() as $iface) { + if (isset($implementedTypeNames[$iface->name])) { + $this->reportError( + "Type {$object->name} can only implement {$iface->name} once.", + $this->getAllImplementsInterfaceNodes($object, $iface) + ); + continue; + } + $implementedTypeNames[$iface->name] = true; + $this->validateObjectImplementsInterface($object, $iface); + } + } + + /** + * @param ObjectType $object + * @param InterfaceType $iface + */ + private function validateObjectImplementsInterface(ObjectType $object, $iface) + { + if (!$iface instanceof InterfaceType) { + $this->reportError( + "Type {$object->name} must only implement Interface types, " . + "it cannot implement ". Utils::printSafe($iface) . ".", + $this->getImplementsInterfaceNode($object, $iface) + ); + return; + } + + $objectFieldMap = $object->getFields(); + $ifaceFieldMap = $iface->getFields(); + + // Assert each interface field is implemented. + foreach ($ifaceFieldMap as $fieldName => $ifaceField) { + $objectField = array_key_exists($fieldName, $objectFieldMap) + ? $objectFieldMap[$fieldName] + : null; + + // Assert interface field exists on object. + if (!$objectField) { + $this->reportError( + "Interface field {$iface->name}.{$fieldName} expected but " . + "{$object->name} does not provide it.", + [$this->getFieldNode($iface, $fieldName), $object->astNode] + ); + continue; + } + + // Assert interface field type is satisfied by object field type, by being + // a valid subtype. (covariant) + if ( + !TypeComparators::isTypeSubTypeOf( + $this->schema, + $objectField->getType(), + $ifaceField->getType() + ) + ) { + $this->reportError( + "Interface field {$iface->name}.{$fieldName} expects type ". + "{$ifaceField->getType()} but {$object->name}.{$fieldName} " . + "is type " . Utils::printSafe($objectField->getType()) . ".", + [ + $this->getFieldTypeNode($iface, $fieldName), + $this->getFieldTypeNode($object, $fieldName), + ] + ); + } + + // Assert each interface field arg is implemented. + foreach($ifaceField->args as $ifaceArg) { + $argName = $ifaceArg->name; + $objectArg = null; + + foreach($objectField->args as $arg) { + if ($arg->name === $argName) { + $objectArg = $arg; + break; + } + } + + // Assert interface field arg exists on object field. + if (!$objectArg) { + $this->reportError( + "Interface field argument {$iface->name}.{$fieldName}({$argName}:) " . + "expected but {$object->name}.{$fieldName} does not provide it.", + [ + $this->getFieldArgNode($iface, $fieldName, $argName), + $this->getFieldNode($object, $fieldName), + ] + ); + continue; + } + + // Assert interface field arg type matches object field arg type. + // (invariant) + // TODO: change to contravariant? + if (!TypeComparators::isEqualType($ifaceArg->getType(), $objectArg->getType())) { + $this->reportError( + "Interface field argument {$iface->name}.{$fieldName}({$argName}:) ". + "expects type " . Utils::printSafe($ifaceArg->getType()) . " but " . + "{$object->name}.{$fieldName}({$argName}:) is type " . + Utils::printSafe($objectArg->getType()) . ".", + [ + $this->getFieldArgTypeNode($iface, $fieldName, $argName), + $this->getFieldArgTypeNode($object, $fieldName, $argName), + ] + ); + } + + // TODO: validate default values? + } + + // Assert additional arguments must not be required. + foreach($objectField->args as $objectArg) { + $argName = $objectArg->name; + $ifaceArg = null; + + foreach($ifaceField->args as $arg) { + if ($arg->name === $argName) { + $ifaceArg = $arg; + break; + } + } + + if (!$ifaceArg && $objectArg->getType() instanceof NonNull) { + $this->reportError( + "Object field argument {$object->name}.{$fieldName}({$argName}:) " . + "is of required type " . Utils::printSafe($objectArg->getType()) . " but is not also " . + "provided by the Interface field {$iface->name}.{$fieldName}.", + [ + $this->getFieldArgTypeNode($object, $fieldName, $argName), + $this->getFieldNode($iface, $fieldName), + ] + ); + } + } + } + } + + private function validateUnionMembers(UnionType $union) + { + $memberTypes = $union->getTypes(); + + if (!$memberTypes) { + $this->reportError( + "Union type {$union->name} must define one or more member types.", + $union->astNode + ); + } + + $includedTypeNames = []; + + foreach($memberTypes as $memberType) { + if (isset($includedTypeNames[$memberType->name])) { + $this->reportError( + "Union type {$union->name} can only include type ". + "{$memberType->name} once.", + $this->getUnionMemberTypeNodes($union, $memberType->name) + ); + continue; + } + $includedTypeNames[$memberType->name] = true; + if (!$memberType instanceof ObjectType) { + $this->reportError( + "Union type {$union->name} can only include Object types, ". + "it cannot include " . Utils::printSafe($memberType) . ".", + $this->getUnionMemberTypeNodes($union, Utils::printSafe($memberType)) + ); + } + } + } + + private function validateEnumValues(EnumType $enumType) + { + $enumValues = $enumType->getValues(); + + if (!$enumValues) { + $this->reportError( + "Enum type {$enumType->name} must define one or more values.", + $enumType->astNode + ); + } + + foreach($enumValues as $enumValue) { + $valueName = $enumValue->name; + + // Ensure no duplicates + $allNodes = $this->getEnumValueNodes($enumType, $valueName); + if ($allNodes && count($allNodes) > 1) { + $this->reportError( + "Enum type {$enumType->name} can include value {$valueName} only once.", + $allNodes + ); + } + + // Ensure valid name. + $this->validateName($enumValue); + if ($valueName === 'true' || $valueName === 'false' || $valueName === 'null') { + $this->reportError( + "Enum type {$enumType->name} cannot include value: {$valueName}.", + $enumValue->astNode + ); + } + } + } + + private function validateInputFields(InputObjectType $inputObj) + { + $fieldMap = $inputObj->getFields(); + + if (!$fieldMap) { + $this->reportError( + "Input Object type {$inputObj->name} must define one or more fields.", + $inputObj->astNode + ); + } + + // Ensure the arguments are valid + foreach ($fieldMap as $fieldName => $field) { + // Ensure they are named correctly. + $this->validateName($field); + + // TODO: Ensure they are unique per field. + + // Ensure the type is an input type + if (!Type::isInputType($field->getType())) { + $this->reportError( + "The type of {$inputObj->name}.{$fieldName} must be Input Type " . + "but got: " . Utils::printSafe($field->getType()) . ".", + $field->astNode ? $field->astNode->type : null + ); + } + } + } + + /** + * @param ObjectType|InterfaceType $type + * @return ObjectTypeDefinitionNode[]|ObjectTypeExtensionNode[]|InterfaceTypeDefinitionNode[]|InterfaceTypeExtensionNode[] + */ + private function getAllObjectOrInterfaceNodes($type) + { + return $type->astNode + ? ($type->extensionASTNodes + ? array_merge([$type->astNode], $type->extensionASTNodes) + : [$type->astNode]) + : ($type->extensionASTNodes ?: []); + } + + /** + * @param ObjectType $type + * @param InterfaceType $iface + * @return NamedTypeNode|null + */ + private function getImplementsInterfaceNode(ObjectType $type, $iface) + { + $nodes = $this->getAllImplementsInterfaceNodes($type, $iface); + return $nodes && isset($nodes[0]) ? $nodes[0] : null; + } + + /** + * @param ObjectType $type + * @param InterfaceType $iface + * @return NamedTypeNode[] + */ + private function getAllImplementsInterfaceNodes(ObjectType $type, $iface) + { + $implementsNodes = []; + $astNodes = $this->getAllObjectOrInterfaceNodes($type); + + foreach($astNodes as $astNode) { + if ($astNode && $astNode->interfaces) { + foreach($astNode->interfaces as $node) { + if ($node->name->value === $iface->name) { + $implementsNodes[] = $node; + } + } + } + } + + return $implementsNodes; + } + + /** + * @param ObjectType|InterfaceType $type + * @param string $fieldName + * @return FieldDefinitionNode|null + */ + private function getFieldNode($type, $fieldName) + { + $nodes = $this->getAllFieldNodes($type, $fieldName); + return $nodes && isset($nodes[0]) ? $nodes[0] : null; + } + + /** + * @param ObjectType|InterfaceType $type + * @param string $fieldName + * @return FieldDefinitionNode[] + */ + private function getAllFieldNodes($type, $fieldName) + { + $fieldNodes = []; + $astNodes = $this->getAllObjectOrInterfaceNodes($type); + foreach($astNodes as $astNode) { + if ($astNode && $astNode->fields) { + foreach($astNode->fields as $node) { + if ($node->name->value === $fieldName) { + $fieldNodes[] = $node; + } + } + } + } + return $fieldNodes; + } + + /** + * @param ObjectType|InterfaceType $type + * @param string $fieldName + * @return TypeNode|null + */ + private function getFieldTypeNode($type, $fieldName) + { + $fieldNode = $this->getFieldNode($type, $fieldName); + return $fieldNode ? $fieldNode->type : null; + } + + /** + * @param ObjectType|InterfaceType $type + * @param string $fieldName + * @param string $argName + * @return InputValueDefinitionNode|null + */ + private function getFieldArgNode($type, $fieldName, $argName) + { + $nodes = $this->getAllFieldArgNodes($type, $fieldName, $argName); + return $nodes && isset($nodes[0]) ? $nodes[0] : null; + } + + /** + * @param ObjectType|InterfaceType $type + * @param string $fieldName + * @param string $argName + * @return InputValueDefinitionNode[] + */ + private function getAllFieldArgNodes($type, $fieldName, $argName) + { + $argNodes = []; + $fieldNode = $this->getFieldNode($type, $fieldName); + if ($fieldNode && $fieldNode->arguments) { + foreach ($fieldNode->arguments as $node) { + if ($node->name->value === $argName) { + $argNodes[] = $node; + } + } + } + return $argNodes; + } + + /** + * @param ObjectType|InterfaceType $type + * @param string $fieldName + * @param string $argName + * @return TypeNode|null + */ + private function getFieldArgTypeNode($type, $fieldName, $argName) + { + $fieldArgNode = $this->getFieldArgNode($type, $fieldName, $argName); + return $fieldArgNode ? $fieldArgNode->type : null; + } + + /** + * @param Directive $directive + * @param string $argName + * @return InputValueDefinitionNode[] + */ + private function getAllDirectiveArgNodes(Directive $directive, $argName) + { + $argNodes = []; + $directiveNode = $directive->astNode; + if ($directiveNode && $directiveNode->arguments) { + foreach($directiveNode->arguments as $node) { + if ($node->name->value === $argName) { + $argNodes[] = $node; + } + } + } + + return $argNodes; + } + + /** + * @param Directive $directive + * @param string $argName + * @return TypeNode|null + */ + private function getDirectiveArgTypeNode(Directive $directive, $argName) + { + $argNode = $this->getAllDirectiveArgNodes($directive, $argName)[0]; + return $argNode ? $argNode->type : null; + } + + /** + * @param UnionType $union + * @param string $typeName + * @return NamedTypeNode[] + */ + private function getUnionMemberTypeNodes(UnionType $union, $typeName) + { + if ($union->astNode && $union->astNode->types) { + return array_filter( + $union->astNode->types, + function (NamedTypeNode $value) use ($typeName) { + return $value->name->value === $typeName; + } + ); + } + return $union->astNode ? + $union->astNode->types : null; + } + + /** + * @param EnumType $enum + * @param string $valueName + * @return EnumValueDefinitionNode[] + */ + private function getEnumValueNodes(EnumType $enum, $valueName) + { + if ($enum->astNode && $enum->astNode->values) { + return array_filter( + iterator_to_array($enum->astNode->values), + function (EnumValueDefinitionNode $value) use ($valueName) { + return $value->name->value === $valueName; + } + ); + } + return $enum->astNode ? + $enum->astNode->values : null; + } + + /** + * @param string $message + * @param array|Node|TypeNode|TypeDefinitionNode $nodes + */ + private function reportError($message, $nodes = null) { + $nodes = array_filter($nodes && is_array($nodes) ? $nodes : [$nodes]); + $this->addError(new Error($message, $nodes)); + } + + /** + * @param Error $error + */ + private function addError($error) { + $this->errors[] = $error; + } +} diff --git a/src/Utils.php b/src/Utils.php index b73186f..feabcf0 100644 --- a/src/Utils.php +++ b/src/Utils.php @@ -6,6 +6,9 @@ trigger_error( E_USER_DEPRECATED ); +/** + * @deprecated Use GraphQL\Utils\Utils + */ class Utils extends \GraphQL\Utils\Utils { } diff --git a/src/Utils/AST.php b/src/Utils/AST.php index bc3a0e4..9a18915 100644 --- a/src/Utils/AST.php +++ b/src/Utils/AST.php @@ -1,6 +1,7 @@ $fieldNodes]); } - // Since value is an internally represented value, it must be serialized - // to an externally represented value before converting into an AST. - if ($type instanceof LeafType) { + if ($type instanceof ScalarType || $type instanceof EnumType) { + // Since value is an internally represented value, it must be serialized + // to an externally represented value before converting into an AST. $serialized = $type->serialize($value); - } else { - throw new InvariantViolation("Must provide Input Type, cannot use: " . Utils::printSafe($type)); - } + if (null === $serialized || Utils::isInvalid($serialized)) { + return null; + } - if (null === $serialized) { - return null; - } - - // Others serialize based on their corresponding PHP scalar types. - if (is_bool($serialized)) { - return new BooleanValueNode(['value' => $serialized]); - } - if (is_int($serialized)) { - return new IntValueNode(['value' => $serialized]); - } - if (is_float($serialized)) { - if ((int) $serialized == $serialized) { + // Others serialize based on their corresponding PHP scalar types. + if (is_bool($serialized)) { + return new BooleanValueNode(['value' => $serialized]); + } + if (is_int($serialized)) { return new IntValueNode(['value' => $serialized]); } - return new FloatValueNode(['value' => $serialized]); - } - if (is_string($serialized)) { - // Enum types use Enum literals. - if ($type instanceof EnumType) { - return new EnumValueNode(['value' => $serialized]); + if (is_float($serialized)) { + if ((int) $serialized == $serialized) { + return new IntValueNode(['value' => $serialized]); + } + return new FloatValueNode(['value' => $serialized]); + } + if (is_string($serialized)) { + // Enum types use Enum literals. + if ($type instanceof EnumType) { + return new EnumValueNode(['value' => $serialized]); + } + + // ID types can use Int literals. + $asInt = (int) $serialized; + if ($type instanceof IDType && (string) $asInt === $serialized) { + return new IntValueNode(['value' => $serialized]); + } + + // Use json_encode, which uses the same string encoding as GraphQL, + // then remove the quotes. + return new StringValueNode([ + 'value' => substr(json_encode($serialized), 1, -1) + ]); } - // ID types can use Int literals. - $asInt = (int) $serialized; - if ($type instanceof IDType && (string) $asInt === $serialized) { - return new IntValueNode(['value' => $serialized]); - } - - // Use json_encode, which uses the same string encoding as GraphQL, - // then remove the quotes. - return new StringValueNode([ - 'value' => substr(json_encode($serialized), 1, -1) - ]); + throw new InvariantViolation('Cannot convert value to AST: ' . Utils::printSafe($serialized)); } - throw new InvariantViolation('Cannot convert value to AST: ' . Utils::printSafe($serialized)); + throw new Error('Unknown type: ' . Utils::printSafe($type) . '.'); } /** @@ -383,19 +383,100 @@ class AST return $coercedObj; } - if ($type instanceof LeafType) { - $parsed = $type->parseLiteral($valueNode); - - if (null === $parsed && !$type->isValidLiteral($valueNode)) { - // Invalid values represent a failure to parse correctly, in which case - // no value is returned. + if ($type instanceof EnumType) { + if (!$valueNode instanceof EnumValueNode) { + return $undefined; + } + $enumValue = $type->getValue($valueNode->value); + if (!$enumValue) { return $undefined; } - return $parsed; + return $enumValue->value; } - throw new InvariantViolation('Must be input type'); + if ($type instanceof ScalarType) { + // Scalars fulfill parsing a literal value via parseLiteral(). + // Invalid values represent a failure to parse correctly, in which case + // no value is returned. + try { + $result = $type->parseLiteral($valueNode, $variables); + } catch (\Exception $error) { + return $undefined; + } catch (\Throwable $error) { + return $undefined; + } + + if (Utils::isInvalid($result)) { + return $undefined; + } + + return $result; + } + + throw new Error('Unknown type: ' . Utils::printSafe($type) . '.'); + } + + /** + * Produces a PHP value given a GraphQL Value AST. + * + * Unlike `valueFromAST()`, no type is provided. The resulting JavaScript value + * will reflect the provided GraphQL value AST. + * + * | GraphQL Value | PHP Value | + * | -------------------- | ------------- | + * | Input Object | Assoc Array | + * | List | Array | + * | Boolean | Boolean | + * | String | String | + * | Int / Float | Int / Float | + * | Enum | Mixed | + * | Null | null | + * + * @api + * @param Node $valueNode + * @param array|null $variables + * @return mixed + * @throws \Exception + */ + public static function valueFromASTUntyped($valueNode, array $variables = null) { + switch (true) { + case $valueNode instanceof NullValueNode: + return null; + case $valueNode instanceof IntValueNode: + return intval($valueNode->value, 10); + case $valueNode instanceof FloatValueNode: + return floatval($valueNode->value); + case $valueNode instanceof StringValueNode: + case $valueNode instanceof EnumValueNode: + case $valueNode instanceof BooleanValueNode: + return $valueNode->value; + case $valueNode instanceof ListValueNode: + return array_map( + function($node) use ($variables) { + return self::valueFromASTUntyped($node, $variables); + }, + iterator_to_array($valueNode->values) + ); + case $valueNode instanceof ObjectValueNode: + return array_combine( + array_map( + function($field) { return $field->name->value; }, + iterator_to_array($valueNode->fields) + ), + array_map( + function($field) use ($variables) { return self::valueFromASTUntyped($field->value, $variables); }, + iterator_to_array($valueNode->fields) + ) + ); + case $valueNode instanceof VariableNode: + $variableName = $valueNode->name->value; + return ($variables && isset($variables[$variableName]) && !Utils::isInvalid($variables[$variableName])) + ? $variables[$variableName] + : null; + } + + throw new Error('Unexpected value kind: ' . $valueNode->kind . '.'); } /** @@ -405,7 +486,7 @@ class AST * @param Schema $schema * @param NamedTypeNode|ListTypeNode|NonNullTypeNode $inputTypeNode * @return Type - * @throws InvariantViolation + * @throws \Exception */ public static function typeFromAST(Schema $schema, $inputTypeNode) { @@ -417,9 +498,11 @@ class AST $innerType = self::typeFromAST($schema, $inputTypeNode->type); return $innerType ? new NonNull($innerType) : null; } + if ($inputTypeNode instanceof NamedTypeNode) { + return $schema->getType($inputTypeNode->name->value); + } - Utils::invariant($inputTypeNode && $inputTypeNode instanceof NamedTypeNode, 'Must be a named type'); - return $schema->getType($inputTypeNode->name->value); + throw new Error('Unexpected type kind: ' . $inputTypeNode->kind . '.'); } /** diff --git a/src/Utils/ASTDefinitionBuilder.php b/src/Utils/ASTDefinitionBuilder.php new file mode 100644 index 0000000..36c8ec8 --- /dev/null +++ b/src/Utils/ASTDefinitionBuilder.php @@ -0,0 +1,455 @@ +typeDefintionsMap = $typeDefintionsMap; + $this->typeConfigDecorator = $typeConfigDecorator; + $this->options = $options; + $this->resolveType = $resolveType; + + $this->cache = Type::getAllBuiltInTypes(); + } + + /** + * @param Type $innerType + * @param TypeNode|ListTypeNode|NonNullTypeNode $inputTypeNode + * @return Type + */ + private function buildWrappedType(Type $innerType, TypeNode $inputTypeNode) + { + if ($inputTypeNode->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); + return Type::nonNull(NonNull::assertNullableType($wrappedType)); + } + return $innerType; + } + + /** + * @param TypeNode|ListTypeNode|NonNullTypeNode $typeNode + * @return TypeNode + */ + private function getNamedTypeNode(TypeNode $typeNode) + { + $namedType = $typeNode; + while ($namedType->kind === NodeKind::LIST_TYPE || $namedType->kind === NodeKind::NON_NULL_TYPE) { + $namedType = $namedType->type; + } + return $namedType; + } + + /** + * @param string $typeName + * @param NamedTypeNode|null $typeNode + * @return Type + * @throws Error + */ + private function internalBuildType($typeName, $typeNode = null) { + if (!isset($this->cache[$typeName])) { + if (isset($this->typeDefintionsMap[$typeName])) { + $type = $this->makeSchemaDef($this->typeDefintionsMap[$typeName]); + if ($this->typeConfigDecorator) { + $fn = $this->typeConfigDecorator; + try { + $config = $fn($type->config, $this->typeDefintionsMap[$typeName], $this->typeDefintionsMap); + } catch (\Exception $e) { + throw new Error( + "Type config decorator passed to " . (static::class) . " threw an error " . + "when building $typeName type: {$e->getMessage()}", + null, + null, + null, + null, + $e + ); + } catch (\Throwable $e) { + throw new Error( + "Type config decorator passed to " . (static::class) . " threw an error " . + "when building $typeName type: {$e->getMessage()}", + null, + null, + null, + null, + $e + ); + } + if (!is_array($config) || isset($config[0])) { + throw new Error( + "Type config decorator passed to " . (static::class) . " is expected to return an array, but got " . + Utils::getVariableType($config) + ); + } + $type = $this->makeSchemaDefFromConfig($this->typeDefintionsMap[$typeName], $config); + } + $this->cache[$typeName] = $type; + } else { + $fn = $this->resolveType; + $this->cache[$typeName] = $fn($typeName, $typeNode); + } + } + + return $this->cache[$typeName]; + } + + /** + * @param string|NamedTypeNode $ref + * @return Type + * @throws Error + */ + public function buildType($ref) + { + if (is_string($ref)) { + return $this->internalBuildType($ref); + } + + return $this->internalBuildType($ref->name->value, $ref); + } + + /** + * @param TypeNode $typeNode + * @return Type|InputType + * @throws Error + */ + private function internalBuildWrappedType(TypeNode $typeNode) + { + $typeDef = $this->buildType($this->getNamedTypeNode($typeNode)); + return $this->buildWrappedType($typeDef, $typeNode); + } + + public function buildDirective(DirectiveDefinitionNode $directiveNode) + { + return new Directive([ + 'name' => $directiveNode->name->value, + 'description' => $this->getDescription($directiveNode), + 'locations' => Utils::map($directiveNode->locations, function ($node) { + return $node->value; + }), + 'args' => $directiveNode->arguments ? FieldArgument::createMap($this->makeInputValues($directiveNode->arguments)) : null, + 'astNode' => $directiveNode, + ]); + } + + public function buildField(FieldDefinitionNode $field) + { + return [ + // Note: While this could make assertions to get the correctly typed + // value, that would throw immediately while type system validation + // with validateSchema() will produce more actionable results. + 'type' => $this->internalBuildWrappedType($field->type), + 'description' => $this->getDescription($field), + 'args' => $field->arguments ? $this->makeInputValues($field->arguments) : null, + 'deprecationReason' => $this->getDeprecationReason($field), + 'astNode' => $field, + ]; + } + + 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 makeSchemaDefFromConfig($def, array $config) + { + if (!$def) { + throw new Error('def must be defined.'); + } + switch ($def->kind) { + case NodeKind::OBJECT_TYPE_DEFINITION: + return new ObjectType($config); + case NodeKind::INTERFACE_TYPE_DEFINITION: + return new InterfaceType($config); + case NodeKind::ENUM_TYPE_DEFINITION: + return new EnumType($config); + case NodeKind::UNION_TYPE_DEFINITION: + return new UnionType($config); + case NodeKind::SCALAR_TYPE_DEFINITION: + return new CustomScalarType($config); + case NodeKind::INPUT_OBJECT_TYPE_DEFINITION: + return new InputObjectType($config); + 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); + }, + 'astNode' => $def + ]); + } + + private function makeFieldDefMap($def) + { + return $def->fields + ? Utils::keyValMap( + $def->fields, + function ($field) { + return $field->name->value; + }, + function ($field) { + return $this->buildField($field); + } + ) + : []; + } + + private function makeImplementedInterfaces(ObjectTypeDefinitionNode $def) + { + if ($def->interfaces) { + // Note: While this could make early assertions to get the correctly + // typed values, that would throw immediately while type system + // validation with validateSchema() will produce more actionable results. + return Utils::map($def->interfaces, function ($iface) { + return $this->buildType($iface); + }); + } + return null; + } + + private function makeInputValues($values) + { + return Utils::keyValMap( + $values, + function ($value) { + return $value->name->value; + }, + function ($value) { + // Note: While this could make assertions to get the correctly typed + // value, that would throw immediately while type system validation + // with validateSchema() will produce more actionable results. + $type = $this->internalBuildWrappedType($value->type); + $config = [ + 'name' => $value->name->value, + 'type' => $type, + 'description' => $this->getDescription($value), + 'astNode' => $value + ]; + if (isset($value->defaultValue)) { + $config['defaultValue'] = 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); + }, + 'astNode' => $def + ]); + } + + private function makeEnumDef(EnumTypeDefinitionNode $def) + { + return new EnumType([ + 'name' => $def->name->value, + 'description' => $this->getDescription($def), + 'values' => $def->values + ? Utils::keyValMap( + $def->values, + function ($enumValue) { + return $enumValue->name->value; + }, + function ($enumValue) { + return [ + 'description' => $this->getDescription($enumValue), + 'deprecationReason' => $this->getDeprecationReason($enumValue), + 'astNode' => $enumValue + ]; + } + ) + : [], + 'astNode' => $def, + ]); + } + + private function makeUnionDef(UnionTypeDefinitionNode $def) + { + return new UnionType([ + 'name' => $def->name->value, + 'description' => $this->getDescription($def), + // Note: While this could make assertions to get the correctly typed + // values below, that would throw immediately while type system + // validation with validateSchema() will produce more actionable results. + 'types' => $def->types + ? Utils::map($def->types, function ($typeNode) { + return $this->buildType($typeNode); + }): + [], + 'astNode' => $def, + ]); + } + + private function makeScalarDef(ScalarTypeDefinitionNode $def) + { + return new CustomScalarType([ + 'name' => $def->name->value, + 'description' => $this->getDescription($def), + 'astNode' => $def, + 'serialize' => function($value) { + return $value; + }, + ]); + } + + private function makeInputObjectDef(InputObjectTypeDefinitionNode $def) + { + return new InputObjectType([ + 'name' => $def->name->value, + 'description' => $this->getDescription($def), + 'fields' => function () use ($def) { + return $def->fields + ? $this->makeInputValues($def->fields) + : []; + }, + 'astNode' => $def, + ]); + } + + /** + * Given a collection of directives, returns the string value for the + * deprecation reason. + * + * @param EnumValueDefinitionNode | FieldDefinitionNode $node + * @return string + */ + private function getDeprecationReason($node) + { + $deprecated = Values::getDirectiveValues(Directive::deprecatedDirective(), $node); + return isset($deprecated['reason']) ? $deprecated['reason'] : null; + } + + /** + * Given an ast node, returns its string description. + */ + private function getDescription($node) + { + if ($node->description) { + return $node->description->value; + } + if (isset($this->options['commentDescriptions'])) { + $rawValue = $this->getLeadingCommentBlock($node); + if ($rawValue !== null) { + return BlockString::value("\n" . $rawValue); + } + } + + return null; + } + + private function getLeadingCommentBlock($node) + { + $loc = $node->loc; + if (!$loc || !$loc->startToken) { + return null; + } + $comments = []; + $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; + $comments[] = $value; + $token = $token->prev; + } + + return implode("\n", array_reverse($comments)); + } +} diff --git a/src/Utils/BlockString.php b/src/Utils/BlockString.php new file mode 100644 index 0000000..eac943d --- /dev/null +++ b/src/Utils/BlockString.php @@ -0,0 +1,61 @@ + 0 && trim($lines[0], " \t") === '') { + array_shift($lines); + } + while (count($lines) > 0 && trim($lines[count($lines) - 1], " \t") === '') { + array_pop($lines); + } + + // Return a string of the lines joined with U+000A. + return implode("\n", $lines); + } + + private static function leadingWhitespace($str) { + $i = 0; + while ($i < mb_strlen($str) && ($str[$i] === ' ' || $str[$i] === '\t')) { + $i++; + } + + return $i; + } +} \ No newline at end of file diff --git a/src/Utils/BuildSchema.php b/src/Utils/BuildSchema.php index d75a11f..18e169d 100644 --- a/src/Utils/BuildSchema.php +++ b/src/Utils/BuildSchema.php @@ -2,35 +2,13 @@ namespace GraphQL\Utils; use GraphQL\Error\Error; -use GraphQL\Executor\Values; -use GraphQL\Language\AST\DirectiveDefinitionNode; use GraphQL\Language\AST\DocumentNode; -use GraphQL\Language\AST\EnumTypeDefinitionNode; -use GraphQL\Language\AST\EnumValueDefinitionNode; -use GraphQL\Language\AST\FieldDefinitionNode; -use GraphQL\Language\AST\InputObjectTypeDefinitionNode; -use GraphQL\Language\AST\InterfaceTypeDefinitionNode; use GraphQL\Language\AST\NodeKind; -use GraphQL\Language\AST\ObjectTypeDefinitionNode; -use GraphQL\Language\AST\ScalarTypeDefinitionNode; -use GraphQL\Language\AST\TypeDefinitionNode; -use GraphQL\Language\AST\TypeNode; -use GraphQL\Language\AST\UnionTypeDefinitionNode; +use GraphQL\Language\AST\SchemaDefinitionNode; use GraphQL\Language\Parser; use GraphQL\Language\Source; -use GraphQL\Language\Token; use GraphQL\Type\Schema; use GraphQL\Type\Definition\Directive; -use GraphQL\Type\Definition\EnumType; -use GraphQL\Type\Definition\InputObjectType; -use GraphQL\Type\Definition\InterfaceType; -use GraphQL\Type\Definition\FieldArgument; -use GraphQL\Type\Definition\NonNull; -use GraphQL\Type\Definition\ObjectType; -use GraphQL\Type\Definition\CustomScalarType; -use GraphQL\Type\Definition\Type; -use GraphQL\Type\Definition\UnionType; -use GraphQL\Type\Introspection; /** * Build instance of `GraphQL\Type\Schema` out of type language definition (string or parsed AST) @@ -38,33 +16,6 @@ use GraphQL\Type\Introspection; */ class BuildSchema { - /** - * @param Type $innerType - * @param TypeNode $inputTypeNode - * @return Type - */ - private function buildWrappedType(Type $innerType, TypeNode $inputTypeNode) - { - if ($inputTypeNode->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. @@ -75,33 +26,40 @@ class BuildSchema * Given that AST it constructs a GraphQL\Type\Schema. The resulting schema * has no resolve methods, so execution will use default resolvers. * + * Accepts options as a third argument: + * + * - commentDescriptions: + * Provide true to use preceding comments as the description. + * + * * @api * @param DocumentNode $ast * @param callable $typeConfigDecorator + * @param array $options * @return Schema * @throws Error */ - public static function buildAST(DocumentNode $ast, callable $typeConfigDecorator = null) + public static function buildAST(DocumentNode $ast, callable $typeConfigDecorator = null, array $options = []) { - $builder = new self($ast, $typeConfigDecorator); + $builder = new self($ast, $typeConfigDecorator, $options); return $builder->buildSchema(); } private $ast; - private $innerTypeMap; private $nodeMap; private $typeConfigDecorator; - private $loadedTypeDefs; + private $options; - public function __construct(DocumentNode $ast, callable $typeConfigDecorator = null) + public function __construct(DocumentNode $ast, callable $typeConfigDecorator = null, array $options = []) { $this->ast = $ast; $this->typeConfigDecorator = $typeConfigDecorator; - $this->loadedTypeDefs = []; + $this->options = $options; } - + public function buildSchema() { + /** @var SchemaDefinitionNode $schemaDef */ $schemaDef = null; $typeDefs = []; $this->nodeMap = []; @@ -133,121 +91,70 @@ class BuildSchema } } - $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'; - } - } + $operationTypes = $schemaDef + ? $this->getOperationTypes($schemaDef) + : [ + 'query' => isset($this->nodeMap['Query']) ? 'Query' : null, + 'mutation' => isset($this->nodeMap['Mutation']) ? 'Mutation' : null, + 'subscription' => isset($this->nodeMap['Subscription']) ? 'Subscription' : null, + ]; - if (!$queryTypeName) { - throw new Error( - 'Must provide schema definition with query type or a type named Query.' - ); - } + $defintionBuilder = new ASTDefinitionBuilder( + $this->nodeMap, + $this->options, + function($typeName) { throw new Error('Type "'. $typeName . '" not found in document.'); }, + $this->typeConfigDecorator + ); - $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(), - ]; - - $directives = array_map([$this, 'getDirective'], $directiveDefs); + $directives = array_map(function($def) use ($defintionBuilder) { + return $defintionBuilder->buildDirective($def); + }, $directiveDefs); // If specified directives were not explicitly declared, add them. - $skip = array_reduce($directives, function($hasSkip, $directive) { + $skip = array_reduce($directives, function ($hasSkip, $directive) { return $hasSkip || $directive->name == 'skip'; }); if (!$skip) { $directives[] = Directive::skipDirective(); } - $include = array_reduce($directives, function($hasInclude, $directive) { + $include = array_reduce($directives, function ($hasInclude, $directive) { return $hasInclude || $directive->name == 'include'; }); if (!$include) { $directives[] = Directive::includeDirective(); } - $deprecated = array_reduce($directives, function($hasDeprecated, $directive) { + $deprecated = array_reduce($directives, function ($hasDeprecated, $directive) { return $hasDeprecated || $directive->name == 'deprecated'; }); if (!$deprecated) { $directives[] = Directive::deprecatedDirective(); } + // Note: While this could make early assertions to get the correctly + // typed values below, that would throw immediately while type system + // validation with validateSchema() will produce more actionable results. + $schema = new Schema([ - 'query' => $this->getObjectType($this->nodeMap[$queryTypeName]), - 'mutation' => $mutationTypeName ? - $this->getObjectType($this->nodeMap[$mutationTypeName]) : - null, - 'subscription' => $subscriptionTypeName ? - $this->getObjectType($this->nodeMap[$subscriptionTypeName]) : - null, - 'typeLoader' => function($name) { - return $this->typeDefNamed($name); + 'query' => isset($operationTypes['query']) + ? $defintionBuilder->buildType($operationTypes['query']) + : null, + 'mutation' => isset($operationTypes['mutation']) + ? $defintionBuilder->buildType($operationTypes['mutation']) + : null, + 'subscription' => isset($operationTypes['subscription']) + ? $defintionBuilder->buildType($operationTypes['subscription']) + : null, + 'typeLoader' => function ($name) use ($defintionBuilder) { + return $defintionBuilder->buildType($name); }, 'directives' => $directives, 'astNode' => $schemaDef, - 'types' => function() { + 'types' => function () use ($defintionBuilder) { $types = []; foreach ($this->nodeMap as $name => $def) { - if (!isset($this->loadedTypeDefs[$name])) { - $types[] = $this->typeDefNamed($def->name->value); - } + $types[] = $defintionBuilder->buildType($def->name->value); } return $types; } @@ -256,392 +163,46 @@ class BuildSchema return $schema; } - private function getDirective(DirectiveDefinitionNode $directiveNode) - { - return new Directive([ - 'name' => $directiveNode->name->value, - 'description' => $this->getDescription($directiveNode), - 'locations' => Utils::map($directiveNode->locations, function($node) { - return $node->value; - }), - 'args' => $directiveNode->arguments ? FieldArgument::createMap($this->makeInputValues($directiveNode->arguments)) : null, - 'astNode' => $directiveNode - ]); - } - - 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 Interface 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.'); - } - - $this->loadedTypeDefs[$typeName] = true; - - $config = $this->makeSchemaDefConfig($this->nodeMap[$typeName]); - - if ($this->typeConfigDecorator) { - $fn = $this->typeConfigDecorator; - try { - $config = $fn($config, $this->nodeMap[$typeName], $this->nodeMap); - } catch (\Exception $e) { - throw new Error( - "Type config decorator passed to " . (static::class) . " threw an error ". - "when building $typeName type: {$e->getMessage()}", - null, - null, - null, - null, - $e - ); - } catch (\Throwable $e) { - throw new Error( - "Type config decorator passed to " . (static::class) . " threw an error ". - "when building $typeName type: {$e->getMessage()}", - null, - null, - null, - null, - $e - ); - } - if (!is_array($config) || isset($config[0])) { - throw new Error( - "Type config decorator passed to " . (static::class) . " is expected to return an array, but got ". - Utils::getVariableType($config) - ); - } - } - - $innerTypeDef = $this->makeSchemaDef($this->nodeMap[$typeName], $config); - - if (!$innerTypeDef) { - throw new Error("Nothing constructed for $typeName."); - } - $this->innerTypeMap[$typeName] = $innerTypeDef; - return $innerTypeDef; - } - - private function makeSchemaDefConfig($def) - { - if (!$def) { - throw new Error('def must be defined.'); - } - switch ($def->kind) { - case NodeKind::OBJECT_TYPE_DEFINITION: - return $this->makeTypeDefConfig($def); - case NodeKind::INTERFACE_TYPE_DEFINITION: - return $this->makeInterfaceDefConfig($def); - case NodeKind::ENUM_TYPE_DEFINITION: - return $this->makeEnumDefConfig($def); - case NodeKind::UNION_TYPE_DEFINITION: - return $this->makeUnionDefConfig($def); - case NodeKind::SCALAR_TYPE_DEFINITION: - return $this->makeScalarDefConfig($def); - case NodeKind::INPUT_OBJECT_TYPE_DEFINITION: - return $this->makeInputObjectDefConfig($def); - default: - throw new Error("Type kind of {$def->kind} not supported."); - } - } - - private function makeSchemaDef($def, array $config = null) - { - if (!$def) { - throw new Error('def must be defined.'); - } - - $config = $config ?: $this->makeSchemaDefConfig($def); - - switch ($def->kind) { - case NodeKind::OBJECT_TYPE_DEFINITION: - return new ObjectType($config); - case NodeKind::INTERFACE_TYPE_DEFINITION: - return new InterfaceType($config); - case NodeKind::ENUM_TYPE_DEFINITION: - return new EnumType($config); - case NodeKind::UNION_TYPE_DEFINITION: - return new UnionType($config); - case NodeKind::SCALAR_TYPE_DEFINITION: - return new CustomScalarType($config); - case NodeKind::INPUT_OBJECT_TYPE_DEFINITION: - return new InputObjectType($config); - default: - throw new Error("Type kind of {$def->kind} not supported."); - } - } - - private function makeTypeDefConfig(ObjectTypeDefinitionNode $def) - { - $typeName = $def->name->value; - return [ - 'name' => $typeName, - 'description' => $this->getDescription($def), - 'fields' => function() use ($def) { - return $this->makeFieldDefMap($def); - }, - 'interfaces' => function() use ($def) { - return $this->makeImplementedInterfaces($def); - }, - 'astNode' => $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), - 'astNode' => $field - ]; - } - ); - } - - private function makeImplementedInterfaces(ObjectTypeDefinitionNode $def) - { - if (isset($def->interfaces)) { - return Utils::map($def->interfaces, function ($iface) { - return $this->produceInterfaceType($iface); - }); - } - return 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), - 'astNode' => $value - ]; - if (isset($value->defaultValue)) { - $config['defaultValue'] = AST::valueFromAST($value->defaultValue, $type); - } - return $config; - } - ); - } - - private function makeInterfaceDefConfig(InterfaceTypeDefinitionNode $def) - { - $typeName = $def->name->value; - return [ - 'name' => $typeName, - 'description' => $this->getDescription($def), - 'fields' => function() use ($def) { - return $this->makeFieldDefMap($def); - }, - 'astNode' => $def, - 'resolveType' => function() { - $this->cannotExecuteSchema(); - } - ]; - } - - private function makeEnumDefConfig(EnumTypeDefinitionNode $def) - { - return [ - 'name' => $def->name->value, - 'description' => $this->getDescription($def), - 'astNode' => $def, - 'values' => Utils::keyValMap( - $def->values, - function($enumValue) { - return $enumValue->name->value; - }, - function($enumValue) { - return [ - 'description' => $this->getDescription($enumValue), - 'deprecationReason' => $this->getDeprecationReason($enumValue), - 'astNode' => $enumValue - ]; - } - ) - ]; - } - - private function makeUnionDefConfig(UnionTypeDefinitionNode $def) - { - return [ - 'name' => $def->name->value, - 'description' => $this->getDescription($def), - 'types' => Utils::map($def->types, function($typeNode) { - return $this->produceObjectType($typeNode); - }), - 'astNode' => $def, - 'resolveType' => [$this, 'cannotExecuteSchema'] - ]; - } - - private function makeScalarDefConfig(ScalarTypeDefinitionNode $def) - { - return [ - 'name' => $def->name->value, - 'description' => $this->getDescription($def), - 'astNode' => $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 makeInputObjectDefConfig(InputObjectTypeDefinitionNode $def) - { - return [ - 'name' => $def->name->value, - 'description' => $this->getDescription($def), - 'fields' => function() use ($def) { return $this->makeInputValues($def->fields); }, - 'astNode' => $def, - ]; - } - /** - * Given a collection of directives, returns the string value for the - * deprecation reason. - * - * @param EnumValueDefinitionNode | FieldDefinitionNode $node - * @return string + * @param SchemaDefinitionNode $schemaDef + * @return array + * @throws Error */ - private function getDeprecationReason($node) + private function getOperationTypes($schemaDef) { - $deprecated = Values::getDirectiveValues(Directive::deprecatedDirective(), $node); - return isset($deprecated['reason']) ? $deprecated['reason'] : null; - } + $opTypes = []; - /** - * 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 || !$loc->startToken) { - 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; + foreach ($schemaDef->operationTypes as $operationType) { + $typeName = $operationType->type->name->value; + $operation = $operationType->operation; + + if (isset($opTypes[$operation])) { + throw new Error("Must provide only one $operation type in schema."); } - $comments[] = $value; - $token = $token->prev; + + if (!isset($this->nodeMap[$typeName])) { + throw new Error("Specified $operation type \"$typeName\" not found in document."); + } + + $opTypes[$operation] = $typeName; } - return implode("\n", array_map(function($comment) use ($minSpaces) { - return mb_substr(str_replace("\n", '', $comment), $minSpaces); - }, array_reverse($comments))); + + return $opTypes; } /** * A helper function to build a GraphQLSchema directly from a source * document. - * + * * @api * @param DocumentNode|Source|string $source * @param callable $typeConfigDecorator + * @param array $options * @return Schema */ - public static function build($source, callable $typeConfigDecorator = null) + public static function build($source, callable $typeConfigDecorator = null, array $options = []) { $doc = $source instanceof DocumentNode ? $source : Parser::parse($source); - return self::buildAST($doc, $typeConfigDecorator); + return self::buildAST($doc, $typeConfigDecorator, $options); } - - // 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/FindBreakingChanges.php b/src/Utils/FindBreakingChanges.php index c747a71..ecad9f8 100644 --- a/src/Utils/FindBreakingChanges.php +++ b/src/Utils/FindBreakingChanges.php @@ -5,10 +5,12 @@ namespace GraphQL\Utils; +use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\ListOfType; +use GraphQL\Type\Definition\NamedType; use GraphQL\Type\Definition\NonNull; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\ScalarType; @@ -19,35 +21,28 @@ use GraphQL\Type\Schema; class FindBreakingChanges { - const BREAKING_CHANGE_FIELD_CHANGED = 'FIELD_CHANGED_KIND'; + const BREAKING_CHANGE_FIELD_CHANGED_KIND = 'FIELD_CHANGED_KIND'; const BREAKING_CHANGE_FIELD_REMOVED = 'FIELD_REMOVED'; - const BREAKING_CHANGE_TYPE_CHANGED = 'TYPE_CHANGED_KIND'; + const BREAKING_CHANGE_TYPE_CHANGED_KIND = 'TYPE_CHANGED_KIND'; const BREAKING_CHANGE_TYPE_REMOVED = 'TYPE_REMOVED'; const BREAKING_CHANGE_TYPE_REMOVED_FROM_UNION = 'TYPE_REMOVED_FROM_UNION'; const BREAKING_CHANGE_VALUE_REMOVED_FROM_ENUM = 'VALUE_REMOVED_FROM_ENUM'; const BREAKING_CHANGE_ARG_REMOVED = 'ARG_REMOVED'; - const BREAKING_CHANGE_ARG_CHANGED = 'ARG_CHANGED_KIND'; + const BREAKING_CHANGE_ARG_CHANGED_KIND = 'ARG_CHANGED_KIND'; const BREAKING_CHANGE_NON_NULL_ARG_ADDED = 'NON_NULL_ARG_ADDED'; const BREAKING_CHANGE_NON_NULL_INPUT_FIELD_ADDED = 'NON_NULL_INPUT_FIELD_ADDED'; const BREAKING_CHANGE_INTERFACE_REMOVED_FROM_OBJECT = 'INTERFACE_REMOVED_FROM_OBJECT'; + const BREAKING_CHANGE_DIRECTIVE_REMOVED = 'DIRECTIVE_REMOVED'; + const BREAKING_CHANGE_DIRECTIVE_ARG_REMOVED = 'DIRECTIVE_ARG_REMOVED'; + const BREAKING_CHANGE_DIRECTIVE_LOCATION_REMOVED = 'DIRECTIVE_LOCATION_REMOVED'; + const BREAKING_CHANGE_NON_NULL_DIRECTIVE_ARG_ADDED = 'NON_NULL_DIRECTIVE_ARG_ADDED'; - const DANGEROUS_CHANGE_ARG_DEFAULT_VALUE = 'ARG_DEFAULT_VALUE_CHANGE'; + const DANGEROUS_CHANGE_ARG_DEFAULT_VALUE_CHANGED = 'ARG_DEFAULT_VALUE_CHANGE'; const DANGEROUS_CHANGE_VALUE_ADDED_TO_ENUM = 'VALUE_ADDED_TO_ENUM'; + const DANGEROUS_CHANGE_INTERFACE_ADDED_TO_OBJECT = 'INTERFACE_ADDED_TO_OBJECT'; const DANGEROUS_CHANGE_TYPE_ADDED_TO_UNION = 'TYPE_ADDED_TO_UNION'; - - /** - * Given two schemas, returns an Array containing descriptions of all the types - * of potentially dangerous changes covered by the other functions down below. - * - * @return array - */ - public static function findDangerousChanges(Schema $oldSchema, Schema $newSchema) - { - return array_merge(self::findArgChanges($oldSchema, $newSchema)['dangerousChanges'], - self::findValuesAddedToEnums($oldSchema, $newSchema), - self::findTypesAddedToUnions($oldSchema, $newSchema) - ); - } + const DANGEROUS_CHANGE_NULLABLE_INPUT_FIELD_ADDED = 'NULLABLE_INPUT_FIELD_ADDED'; + const DANGEROUS_CHANGE_NULLABLE_ARG_ADDED = 'NULLABLE_ARG_ADDED'; /** * Given two schemas, returns an Array containing descriptions of all the types @@ -60,11 +55,33 @@ class FindBreakingChanges return array_merge( self::findRemovedTypes($oldSchema, $newSchema), self::findTypesThatChangedKind($oldSchema, $newSchema), - self::findFieldsThatChangedType($oldSchema, $newSchema), + self::findFieldsThatChangedTypeOnObjectOrInterfaceTypes($oldSchema, $newSchema), + self::findFieldsThatChangedTypeOnInputObjectTypes($oldSchema, $newSchema)['breakingChanges'], self::findTypesRemovedFromUnions($oldSchema, $newSchema), self::findValuesRemovedFromEnums($oldSchema, $newSchema), self::findArgChanges($oldSchema, $newSchema)['breakingChanges'], - self::findInterfacesRemovedFromObjectTypes($oldSchema, $newSchema) + self::findInterfacesRemovedFromObjectTypes($oldSchema, $newSchema), + self::findRemovedDirectives($oldSchema, $newSchema), + self::findRemovedDirectiveArgs($oldSchema, $newSchema), + self::findAddedNonNullDirectiveArgs($oldSchema, $newSchema), + self::findRemovedDirectiveLocations($oldSchema, $newSchema) + ); + } + + /** + * Given two schemas, returns an Array containing descriptions of all the types + * of potentially dangerous changes covered by the other functions down below. + * + * @return array + */ + public static function findDangerousChanges(Schema $oldSchema, Schema $newSchema) + { + return array_merge( + self::findArgChanges($oldSchema, $newSchema)['dangerousChanges'], + self::findValuesAddedToEnums($oldSchema, $newSchema), + self::findInterfacesAddedToObjectTypes($oldSchema, $newSchema), + self::findTypesAddedToUnions($oldSchema, $newSchema), + self::findFieldsThatChangedTypeOnInputObjectTypes($oldSchema, $newSchema)['dangerousChanges'] ); } @@ -75,20 +92,21 @@ class FindBreakingChanges * @return array */ public static function findRemovedTypes( - Schema $oldSchema, Schema $newSchema - ) - { + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); $breakingChanges = []; - foreach ($oldTypeMap as $typeName => $typeDefinition) { + foreach (array_keys($oldTypeMap) as $typeName) { if (!isset($newTypeMap[$typeName])) { - $breakingChanges[] = - ['type' => self::BREAKING_CHANGE_TYPE_REMOVED, 'description' => "${typeName} was removed."]; + $breakingChanges[] = [ + 'type' => self::BREAKING_CHANGE_TYPE_REMOVED, + 'description' => "${typeName} was removed." + ]; } } - return $breakingChanges; } @@ -99,28 +117,27 @@ class FindBreakingChanges * @return array */ public static function findTypesThatChangedKind( - Schema $oldSchema, Schema $newSchema - ) - { + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); $breakingChanges = []; - foreach ($oldTypeMap as $typeName => $typeDefinition) { + foreach ($oldTypeMap as $typeName => $oldType) { if (!isset($newTypeMap[$typeName])) { continue; } - $newTypeDefinition = $newTypeMap[$typeName]; - if (!($typeDefinition instanceof $newTypeDefinition)) { - $oldTypeKindName = self::typeKindName($typeDefinition); - $newTypeKindName = self::typeKindName($newTypeDefinition); + $newType = $newTypeMap[$typeName]; + if (!($oldType instanceof $newType)) { + $oldTypeKindName = self::typeKindName($oldType); + $newTypeKindName = self::typeKindName($newType); $breakingChanges[] = [ - 'type' => self::BREAKING_CHANGE_TYPE_CHANGED, + 'type' => self::BREAKING_CHANGE_TYPE_CHANGED_KIND, 'description' => "${typeName} changed from ${oldTypeKindName} to ${newTypeKindName}." ]; } } - return $breakingChanges; } @@ -133,56 +150,63 @@ class FindBreakingChanges * @return array */ public static function findArgChanges( - Schema $oldSchema, Schema $newSchema - ) - { + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); $breakingChanges = []; $dangerousChanges = []; - foreach ($oldTypeMap as $oldTypeName => $oldTypeDefinition) { - $newTypeDefinition = isset($newTypeMap[$oldTypeName]) ? $newTypeMap[$oldTypeName] : null; - if (!($oldTypeDefinition instanceof ObjectType || $oldTypeDefinition instanceof InterfaceType) || - !($newTypeDefinition instanceof $oldTypeDefinition)) { + + foreach ($oldTypeMap as $typeName => $oldType) { + $newType = isset($newTypeMap[$typeName]) ? $newTypeMap[$typeName] : null; + if ( + !($oldType instanceof ObjectType || $oldType instanceof InterfaceType) || + !($newType instanceof ObjectType || $newType instanceof InterfaceType) || + !($newType instanceof $oldType) + ) { continue; } - $oldTypeFields = $oldTypeDefinition->getFields(); - $newTypeFields = $newTypeDefinition->getFields(); + $oldTypeFields = $oldType->getFields(); + $newTypeFields = $newType->getFields(); - foreach ($oldTypeFields as $fieldName => $fieldDefinition) { + foreach ($oldTypeFields as $fieldName => $oldField) { if (!isset($newTypeFields[$fieldName])) { continue; } - foreach ($fieldDefinition->args as $oldArgDef) { + foreach ($oldField->args as $oldArgDef) { $newArgs = $newTypeFields[$fieldName]->args; $newArgDef = Utils::find( - $newArgs, function ($arg) use ($oldArgDef) { - return $arg->name === $oldArgDef->name; - } + $newArgs, + function ($arg) use ($oldArgDef) { + return $arg->name === $oldArgDef->name; + } ); if (!$newArgDef) { - $argName = $oldArgDef->name; $breakingChanges[] = [ 'type' => self::BREAKING_CHANGE_ARG_REMOVED, - 'description' => "${oldTypeName}->${fieldName} arg ${argName} was removed" + 'description' => "${typeName}.${fieldName} arg {$oldArgDef->name} was removed" ]; } else { - $isSafe = self::isChangeSafeForInputObjectFieldOrFieldArg($oldArgDef->getType(), $newArgDef->getType()); + $isSafe = self::isChangeSafeForInputObjectFieldOrFieldArg( + $oldArgDef->getType(), + $newArgDef->getType() + ); $oldArgType = $oldArgDef->getType(); $oldArgName = $oldArgDef->name; if (!$isSafe) { $newArgType = $newArgDef->getType(); $breakingChanges[] = [ - 'type' => self::BREAKING_CHANGE_ARG_CHANGED, - 'description' => "${oldTypeName}->${fieldName} arg ${oldArgName} has changed type from ${oldArgType} to ${newArgType}." + 'type' => self::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => "${typeName}.${fieldName} arg ${oldArgName} has changed type from ${oldArgType} to ${newArgType}" ]; } elseif ($oldArgDef->defaultValueExists() && $oldArgDef->defaultValue !== $newArgDef->defaultValue) { $dangerousChanges[] = [ - 'type' => FindBreakingChanges::DANGEROUS_CHANGE_ARG_DEFAULT_VALUE, - 'description' => "${oldTypeName}->${fieldName} arg ${oldArgName} has changed defaultValue" + 'type' => FindBreakingChanges::DANGEROUS_CHANGE_ARG_DEFAULT_VALUE_CHANGED, + 'description' => "${typeName}.${fieldName} arg ${oldArgName} has changed defaultValue" ]; } } @@ -190,25 +214,36 @@ class FindBreakingChanges foreach ($newTypeFields[$fieldName]->args as $newArgDef) { $oldArgs = $oldTypeFields[$fieldName]->args; $oldArgDef = Utils::find( - $oldArgs, function ($arg) use ($newArgDef) { - return $arg->name === $newArgDef->name; - } + $oldArgs, + function ($arg) use ($newArgDef) { + return $arg->name === $newArgDef->name; + } ); - if (!$oldArgDef && $newArgDef->getType() instanceof NonNull) { - $newTypeName = $newTypeDefinition->name; + if (!$oldArgDef) { + $newTypeName = $newType->name; $newArgName = $newArgDef->name; - $breakingChanges[] = [ - 'type' => self::BREAKING_CHANGE_NON_NULL_ARG_ADDED, - 'description' => "A non-null arg ${newArgName} on ${newTypeName}->${fieldName} was added." - ]; + if ($newArgDef->getType() instanceof NonNull) { + $breakingChanges[] = [ + 'type' => self::BREAKING_CHANGE_NON_NULL_ARG_ADDED, + 'description' => "A non-null arg ${newArgName} on ${newTypeName}.${fieldName} was added" + ]; + } else { + $dangerousChanges[] = [ + 'type' => self::DANGEROUS_CHANGE_NULLABLE_ARG_ADDED, + 'description' => "A nullable arg ${newArgName} on ${newTypeName}.${fieldName} was added" + ]; + } } } } } } - return ['breakingChanges' => $breakingChanges, 'dangerousChanges' => $dangerousChanges]; + return [ + 'breakingChanges' => $breakingChanges, + 'dangerousChanges' => $dangerousChanges, + ]; } /** @@ -236,156 +271,189 @@ class FindBreakingChanges throw new \TypeError('unknown type ' . $type->name); } - /** - * Given two schemas, returns an Array containing descriptions of any breaking - * changes in the newSchema related to the fields on a type. This includes if - * a field has been removed from a type, if a field has changed type, or if - * a non-null field is added to an input type. - * - * @return array - */ - public static function findFieldsThatChangedType( - Schema $oldSchema, Schema $newSchema - ) - { - return array_merge( - self::findFieldsThatChangedTypeOnObjectOrInterfaceTypes($oldSchema, $newSchema), - self::findFieldsThatChangedTypeOnInputObjectTypes($oldSchema, $newSchema) - ); - } - - /** - * @param Schema $oldSchema - * @param Schema $newSchema - * - * @return array - */ - private static function findFieldsThatChangedTypeOnObjectOrInterfaceTypes(Schema $oldSchema, Schema $newSchema) - { + public static function findFieldsThatChangedTypeOnObjectOrInterfaceTypes( + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); - $breakingFieldChanges = []; + $breakingChanges = []; foreach ($oldTypeMap as $typeName => $oldType) { $newType = isset($newTypeMap[$typeName]) ? $newTypeMap[$typeName] : null; - if (!($oldType instanceof ObjectType || $oldType instanceof InterfaceType) || !($newType instanceof $oldType)) { + if ( + !($oldType instanceof ObjectType || $oldType instanceof InterfaceType) || + !($newType instanceof ObjectType || $newType instanceof InterfaceType) || + !($newType instanceof $oldType) + ) { continue; } + $oldTypeFieldsDef = $oldType->getFields(); $newTypeFieldsDef = $newType->getFields(); foreach ($oldTypeFieldsDef as $fieldName => $fieldDefinition) { + // Check if the field is missing on the type in the new schema. if (!isset($newTypeFieldsDef[$fieldName])) { - $breakingFieldChanges[] = ['type' => self::BREAKING_CHANGE_FIELD_REMOVED, 'description' => "${typeName}->${fieldName} was removed."]; + $breakingChanges[] = [ + 'type' => self::BREAKING_CHANGE_FIELD_REMOVED, + 'description' => "${typeName}.${fieldName} was removed." + ]; } else { $oldFieldType = $oldTypeFieldsDef[$fieldName]->getType(); - $newfieldType = $newTypeFieldsDef[$fieldName]->getType(); - $isSafe = self::isChangeSafeForObjectOrInterfaceField($oldFieldType, $newfieldType); + $newFieldType = $newTypeFieldsDef[$fieldName]->getType(); + $isSafe = self::isChangeSafeForObjectOrInterfaceField( + $oldFieldType, + $newFieldType + ); if (!$isSafe) { - - $oldFieldTypeString = self::isNamedType($oldFieldType) ? $oldFieldType->name : $oldFieldType; - $newFieldTypeString = self::isNamedType($newfieldType) ? $newfieldType->name : $newfieldType; - $breakingFieldChanges[] = ['type' => self::BREAKING_CHANGE_FIELD_CHANGED, 'description' => "${typeName}->${fieldName} changed type from ${oldFieldTypeString} to ${newFieldTypeString}."]; + $oldFieldTypeString = $oldFieldType instanceof NamedType + ? $oldFieldType->name + : $oldFieldType; + $newFieldTypeString = $newFieldType instanceof NamedType + ? $newFieldType->name + : $newFieldType; + $breakingChanges[] = [ + 'type' => self::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => "${typeName}.${fieldName} changed type from ${oldFieldTypeString} to ${newFieldTypeString}." + ]; } } } } - return $breakingFieldChanges; + return $breakingChanges; } - /** - * @param Schema $oldSchema - * @param Schema $newSchema - * - * @return array - */ public static function findFieldsThatChangedTypeOnInputObjectTypes( - Schema $oldSchema, Schema $newSchema - ) - { + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); - $breakingFieldChanges = []; + $breakingChanges = []; + $dangerousChanges = []; foreach ($oldTypeMap as $typeName => $oldType) { $newType = isset($newTypeMap[$typeName]) ? $newTypeMap[$typeName] : null; if (!($oldType instanceof InputObjectType) || !($newType instanceof InputObjectType)) { continue; } + $oldTypeFieldsDef = $oldType->getFields(); $newTypeFieldsDef = $newType->getFields(); - foreach ($oldTypeFieldsDef as $fieldName => $fieldDefinition) { + foreach (array_keys($oldTypeFieldsDef) as $fieldName) { if (!isset($newTypeFieldsDef[$fieldName])) { - $breakingFieldChanges[] = ['type' => self::BREAKING_CHANGE_FIELD_REMOVED, 'description' => "${typeName}->${fieldName} was removed."]; + $breakingChanges[] = [ + 'type' => self::BREAKING_CHANGE_FIELD_REMOVED, + 'description' => "${typeName}.${fieldName} was removed." + ]; } else { $oldFieldType = $oldTypeFieldsDef[$fieldName]->getType(); - $newfieldType = $newTypeFieldsDef[$fieldName]->getType(); - $isSafe = self::isChangeSafeForInputObjectFieldOrFieldArg($oldFieldType, $newfieldType); + $newFieldType = $newTypeFieldsDef[$fieldName]->getType(); + + $isSafe = self::isChangeSafeForInputObjectFieldOrFieldArg( + $oldFieldType, + $newFieldType + ); if (!$isSafe) { - $oldFieldTypeString = self::isNamedType($oldFieldType) ? $oldFieldType->name : $oldFieldType; - $newFieldTypeString = self::isNamedType($newfieldType) ? $newfieldType->name : $newfieldType; - $breakingFieldChanges[] = ['type' => self::BREAKING_CHANGE_FIELD_CHANGED, 'description' => "${typeName}->${fieldName} changed type from ${oldFieldTypeString} to ${newFieldTypeString}."]; + $oldFieldTypeString = $oldFieldType instanceof NamedType + ? $oldFieldType->name + : $oldFieldType; + $newFieldTypeString = $newFieldType instanceof NamedType + ? $newFieldType->name + : $newFieldType; + $breakingChanges[] = [ + 'type' => self::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => "${typeName}.${fieldName} changed type from ${oldFieldTypeString} to ${newFieldTypeString}."]; } } } + // Check if a field was added to the input object type foreach ($newTypeFieldsDef as $fieldName => $fieldDef) { - if (!isset($oldTypeFieldsDef[$fieldName]) && $fieldDef->getType() instanceof NonNull) { + if (!isset($oldTypeFieldsDef[$fieldName])) { $newTypeName = $newType->name; - $breakingFieldChanges[] = ['type' => self::BREAKING_CHANGE_NON_NULL_INPUT_FIELD_ADDED, 'description' => "A non-null field ${fieldName} on input type ${newTypeName} was added."]; + if ($fieldDef->getType() instanceof NonNull) { + $breakingChanges[] = [ + 'type' => self::BREAKING_CHANGE_NON_NULL_INPUT_FIELD_ADDED, + 'description' => "A non-null field ${fieldName} on input type ${newTypeName} was added." + ]; + } else { + $dangerousChanges[] = [ + 'type' => self::DANGEROUS_CHANGE_NULLABLE_INPUT_FIELD_ADDED, + 'description' => "A nullable field ${fieldName} on input type ${newTypeName} was added." + ]; + } } } } - return $breakingFieldChanges; + + return [ + 'breakingChanges' => $breakingChanges, + 'dangerousChanges' => $dangerousChanges, + ]; } private static function isChangeSafeForObjectOrInterfaceField( - Type $oldType, Type $newType - ) - { - if (self::isNamedType($oldType)) { - // if they're both named types, see if their names are equivalent - return (self::isNamedType($newType) && $oldType->name === $newType->name) + Type $oldType, + Type $newType + ) { + if ($oldType instanceof NamedType) { + return ( + // if they're both named types, see if their names are equivalent + ($newType instanceof NamedType && $oldType->name === $newType->name) || // moving from nullable to non-null of the same underlying type is safe - || ($newType instanceof NonNull - && self::isChangeSafeForObjectOrInterfaceField( - $oldType, $newType->getWrappedType() - )); + ($newType instanceof NonNull && + self::isChangeSafeForObjectOrInterfaceField($oldType, $newType->getWrappedType()) + ) + ); } elseif ($oldType instanceof ListOfType) { - // if they're both lists, make sure the underlying types are compatible - return ($newType instanceof ListOfType && + return ( + // if they're both lists, make sure the underlying types are compatible + ($newType instanceof ListOfType && self::isChangeSafeForObjectOrInterfaceField($oldType->getWrappedType(), $newType->getWrappedType())) || // moving from nullable to non-null of the same underlying type is safe ($newType instanceof NonNull && - self::isChangeSafeForObjectOrInterfaceField($oldType, $newType->getWrappedType())); + self::isChangeSafeForObjectOrInterfaceField($oldType, $newType->getWrappedType())) + ); } elseif ($oldType instanceof NonNull) { // if they're both non-null, make sure the underlying types are compatible - return $newType instanceof NonNull && - self::isChangeSafeForObjectOrInterfaceField($oldType->getWrappedType(), $newType->getWrappedType()); + return ( + $newType instanceof NonNull && + self::isChangeSafeForObjectOrInterfaceField($oldType->getWrappedType(), $newType->getWrappedType()) + ); } - return false; } /** * @param Type $oldType - * @param Schema $newSchema + * @param Type $newType * * @return bool */ private static function isChangeSafeForInputObjectFieldOrFieldArg( - Type $oldType, Type $newType - ) - { - if (self::isNamedType($oldType)) { - return self::isNamedType($newType) && $oldType->name === $newType->name; + Type $oldType, + Type $newType + ) { + if ($oldType instanceof NamedType) { + // if they're both named types, see if their names are equivalent + return $newType instanceof NamedType && $oldType->name === $newType->name; } elseif ($oldType instanceof ListOfType) { + // if they're both lists, make sure the underlying types are compatible return $newType instanceof ListOfType && self::isChangeSafeForInputObjectFieldOrFieldArg($oldType->getWrappedType(), $newType->getWrappedType()); } elseif ($oldType instanceof NonNull) { return ( - $newType instanceof NonNull && self::isChangeSafeForInputObjectFieldOrFieldArg($oldType->getWrappedType(), $newType->getWrappedType()) - ) || ( - !($newType instanceof NonNull) && self::isChangeSafeForInputObjectFieldOrFieldArg($oldType->getWrappedType(), $newType) - ); + // if they're both non-null, make sure the underlying types are + // compatible + ($newType instanceof NonNull && + self::isChangeSafeForInputObjectFieldOrFieldArg( + $oldType->getWrappedType(), + $newType->getWrappedType() + )) || + // moving from non-null to nullable of the same underlying type is safe + (!($newType instanceof NonNull) && + self::isChangeSafeForInputObjectFieldOrFieldArg($oldType->getWrappedType(), $newType)) + ); } return false; } @@ -397,9 +465,9 @@ class FindBreakingChanges * @return array */ public static function findTypesRemovedFromUnions( - Schema $oldSchema, Schema $newSchema - ) - { + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); @@ -415,8 +483,10 @@ class FindBreakingChanges } foreach ($oldType->getTypes() as $type) { if (!isset($typeNamesInNewUnion[$type->name])) { - $missingTypeName = $type->name; - $typesRemovedFromUnion[] = ['type' => self::BREAKING_CHANGE_TYPE_REMOVED_FROM_UNION, 'description' => "${missingTypeName} was removed from union type ${typeName}."]; + $typesRemovedFromUnion[] = [ + 'type' => self::BREAKING_CHANGE_TYPE_REMOVED_FROM_UNION, + 'description' => "{$type->name} was removed from union type ${typeName}.", + ]; } } } @@ -430,14 +500,13 @@ class FindBreakingChanges * @return array */ public static function findTypesAddedToUnions( - Schema $oldSchema, Schema $newSchema - ) - { + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); $typesAddedToUnion = []; - foreach ($newTypeMap as $typeName => $newType) { $oldType = isset($oldTypeMap[$typeName]) ? $oldTypeMap[$typeName] : null; if (!($oldType instanceof UnionType) || !($newType instanceof UnionType)) { @@ -450,12 +519,13 @@ class FindBreakingChanges } foreach ($newType->getTypes() as $type) { if (!isset($typeNamesInOldUnion[$type->name])) { - $addedTypeName = $type->name; - $typesAddedToUnion[] = ['type' => self::DANGEROUS_CHANGE_TYPE_ADDED_TO_UNION, 'description' => "${addedTypeName} was added to union type ${typeName}"]; + $typesAddedToUnion[] = [ + 'type' => self::DANGEROUS_CHANGE_TYPE_ADDED_TO_UNION, + 'description' => "{$type->name} was added to union type ${typeName}.", + ]; } } } - return $typesAddedToUnion; } @@ -466,14 +536,13 @@ class FindBreakingChanges * @return array */ public static function findValuesRemovedFromEnums( - Schema $oldSchema, Schema $newSchema - ) - { + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); $valuesRemovedFromEnums = []; - foreach ($oldTypeMap as $typeName => $oldType) { $newType = isset($newTypeMap[$typeName]) ? $newTypeMap[$typeName] : null; if (!($oldType instanceof EnumType) || !($newType instanceof EnumType)) { @@ -485,12 +554,13 @@ class FindBreakingChanges } foreach ($oldType->getValues() as $value) { if (!isset($valuesInNewEnum[$value->name])) { - $valueName = $value->name; - $valuesRemovedFromEnums[] = ['type' => self::BREAKING_CHANGE_VALUE_REMOVED_FROM_ENUM, 'description' => "${valueName} was removed from enum type ${typeName}."]; + $valuesRemovedFromEnums[] = [ + 'type' => self::BREAKING_CHANGE_VALUE_REMOVED_FROM_ENUM, + 'description' => "{$value->name} was removed from enum type ${typeName}.", + ]; } } } - return $valuesRemovedFromEnums; } @@ -501,9 +571,9 @@ class FindBreakingChanges * @return array */ public static function findValuesAddedToEnums( - Schema $oldSchema, Schema $newSchema - ) - { + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); @@ -519,12 +589,13 @@ class FindBreakingChanges } foreach ($newType->getValues() as $value) { if (!isset($valuesInOldEnum[$value->name])) { - $valueName = $value->name; - $valuesAddedToEnums[] = ['type' => self::DANGEROUS_CHANGE_VALUE_ADDED_TO_ENUM, 'description' => "${valueName} was added to enum type ${typeName}"]; + $valuesAddedToEnums[] = [ + 'type' => self::DANGEROUS_CHANGE_VALUE_ADDED_TO_ENUM, + 'description' => "{$value->name} was added to enum type ${typeName}.", + ]; } } } - return $valuesAddedToEnums; } @@ -535,13 +606,13 @@ class FindBreakingChanges * @return array */ public static function findInterfacesRemovedFromObjectTypes( - Schema $oldSchema, Schema $newSchema - ) - { + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); - $breakingChanges = []; + foreach ($oldTypeMap as $typeName => $oldType) { $newType = isset($newTypeMap[$typeName]) ? $newTypeMap[$typeName] : null; if (!($oldType instanceof ObjectType) || !($newType instanceof ObjectType)) { @@ -554,9 +625,9 @@ class FindBreakingChanges if (!Utils::find($newInterfaces, function (InterfaceType $interface) use ($oldInterface) { return $interface->name === $oldInterface->name; })) { - $oldInterfaceName = $oldInterface->name; - $breakingChanges[] = ['type' => self::BREAKING_CHANGE_INTERFACE_REMOVED_FROM_OBJECT, - 'description' => "${typeName} no longer implements interface ${oldInterfaceName}." + $breakingChanges[] = [ + 'type' => self::BREAKING_CHANGE_INTERFACE_REMOVED_FROM_OBJECT, + 'description' => "${typeName} no longer implements interface {$oldInterface->name}." ]; } } @@ -565,19 +636,170 @@ class FindBreakingChanges } /** - * @param Type $type + * @param Schema $oldSchema + * @param Schema $newSchema * - * @return bool + * @return array */ - private static function isNamedType(Type $type) - { - return ( - $type instanceof ScalarType || - $type instanceof ObjectType || - $type instanceof InterfaceType || - $type instanceof UnionType || - $type instanceof EnumType || - $type instanceof InputObjectType - ); + public static function findInterfacesAddedToObjectTypes( + Schema $oldSchema, + Schema $newSchema + ) { + $oldTypeMap = $oldSchema->getTypeMap(); + $newTypeMap = $newSchema->getTypeMap(); + $interfacesAddedToObjectTypes = []; + + foreach ($newTypeMap as $typeName => $newType) { + $oldType = isset($oldTypeMap[$typeName]) ? $oldTypeMap[$typeName] : null; + if (!($oldType instanceof ObjectType) || !($newType instanceof ObjectType)) { + continue; + } + + $oldInterfaces = $oldType->getInterfaces(); + $newInterfaces = $newType->getInterfaces(); + foreach ($newInterfaces as $newInterface) { + if (!Utils::find($oldInterfaces, function (InterfaceType $interface) use ($newInterface) { + return $interface->name === $newInterface->name; + })) { + $interfacesAddedToObjectTypes[] = [ + 'type' => self::DANGEROUS_CHANGE_INTERFACE_ADDED_TO_OBJECT, + 'description' => "{$newInterface->name} added to interfaces implemented by {$typeName}.", + ]; + } + } + } + return $interfacesAddedToObjectTypes; } -} \ No newline at end of file + + public static function findRemovedDirectives(Schema $oldSchema, Schema $newSchema) + { + $removedDirectives = []; + + $newSchemaDirectiveMap = self::getDirectiveMapForSchema($newSchema); + foreach($oldSchema->getDirectives() as $directive) { + if (!isset($newSchemaDirectiveMap[$directive->name])) { + $removedDirectives[] = [ + 'type' => self::BREAKING_CHANGE_DIRECTIVE_REMOVED, + 'description' => "{$directive->name} was removed", + ]; + } + } + + return $removedDirectives; + } + + public static function findRemovedArgsForDirectives(Directive $oldDirective, Directive $newDirective) + { + $removedArgs = []; + $newArgMap = self::getArgumentMapForDirective($newDirective); + foreach((array) $oldDirective->args as $arg) { + if (!isset($newArgMap[$arg->name])) { + $removedArgs[] = $arg; + } + } + + return $removedArgs; + } + + public static function findRemovedDirectiveArgs(Schema $oldSchema, Schema $newSchema) + { + $removedDirectiveArgs = []; + $oldSchemaDirectiveMap = self::getDirectiveMapForSchema($oldSchema); + + foreach($newSchema->getDirectives() as $newDirective) { + if (!isset($oldSchemaDirectiveMap[$newDirective->name])) { + continue; + } + + foreach(self::findRemovedArgsForDirectives($oldSchemaDirectiveMap[$newDirective->name], $newDirective) as $arg) { + $removedDirectiveArgs[] = [ + 'type' => self::BREAKING_CHANGE_DIRECTIVE_ARG_REMOVED, + 'description' => "{$arg->name} was removed from {$newDirective->name}", + ]; + } + } + + return $removedDirectiveArgs; + } + + public static function findAddedArgsForDirective(Directive $oldDirective, Directive $newDirective) + { + $addedArgs = []; + $oldArgMap = self::getArgumentMapForDirective($oldDirective); + foreach((array) $newDirective->args as $arg) { + if (!isset($oldArgMap[$arg->name])) { + $addedArgs[] = $arg; + } + } + + return $addedArgs; + } + + public static function findAddedNonNullDirectiveArgs(Schema $oldSchema, Schema $newSchema) + { + $addedNonNullableArgs = []; + $oldSchemaDirectiveMap = self::getDirectiveMapForSchema($oldSchema); + + foreach($newSchema->getDirectives() as $newDirective) { + if (!isset($oldSchemaDirectiveMap[$newDirective->name])) { + continue; + } + + foreach(self::findAddedArgsForDirective($oldSchemaDirectiveMap[$newDirective->name], $newDirective) as $arg) { + if (!$arg->getType() instanceof NonNull) { + continue; + } + $addedNonNullableArgs[] = [ + 'type' => self::BREAKING_CHANGE_NON_NULL_DIRECTIVE_ARG_ADDED, + 'description' => "A non-null arg {$arg->name} on directive {$newDirective->name} was added", + ]; + } + } + + return $addedNonNullableArgs; + } + + public static function findRemovedLocationsForDirective(Directive $oldDirective, Directive $newDirective) + { + $removedLocations = []; + $newLocationSet = array_flip($newDirective->locations); + foreach($oldDirective->locations as $oldLocation) { + if (!array_key_exists($oldLocation, $newLocationSet)) { + $removedLocations[] = $oldLocation; + } + } + + return $removedLocations; + } + + public static function findRemovedDirectiveLocations(Schema $oldSchema, Schema $newSchema) + { + $removedLocations = []; + $oldSchemaDirectiveMap = self::getDirectiveMapForSchema($oldSchema); + + foreach($newSchema->getDirectives() as $newDirective) { + if (!isset($oldSchemaDirectiveMap[$newDirective->name])) { + continue; + } + + foreach(self::findRemovedLocationsForDirective($oldSchemaDirectiveMap[$newDirective->name], $newDirective) as $location) { + $removedLocations[] = [ + 'type' => self::BREAKING_CHANGE_DIRECTIVE_LOCATION_REMOVED, + 'description' => "{$location} was removed from {$newDirective->name}", + ]; + } + } + + return $removedLocations; + } + + private static function getDirectiveMapForSchema(Schema $schema) + { + return Utils::keyMap($schema->getDirectives(), function ($dir) { return $dir->name; }); + } + + private static function getArgumentMapForDirective(Directive $directive) + { + return Utils::keyMap($directive->args ?: [], function ($arg) { return $arg->name; }); + } +} diff --git a/src/Utils/PairSet.php b/src/Utils/PairSet.php index 367b7b0..d00161d 100644 --- a/src/Utils/PairSet.php +++ b/src/Utils/PairSet.php @@ -2,86 +2,65 @@ namespace GraphQL\Utils; /** - * Class PairSet - * @package GraphQL\Utils + * A way to keep track of pairs of things when the ordering of the pair does + * not matter. We do this by maintaining a sort of double adjacency sets. */ class PairSet { - /** - * @var \SplObjectStorage> - */ - private $data; - /** * @var array */ - private $wrappers = []; + private $data; /** * PairSet constructor. */ public function __construct() { - $this->data = new \SplObjectStorage(); // SplObject hash instead? + $this->data = []; } /** - * @param $a - * @param $b - * @return null|object + * @param string $a + * @param string $b + * @param bool $areMutuallyExclusive + * @return bool */ - public function has($a, $b) + public function has($a, $b, $areMutuallyExclusive) { - $a = $this->toObj($a); - $b = $this->toObj($b); - - /** @var \SplObjectStorage $first */ $first = isset($this->data[$a]) ? $this->data[$a] : null; - return isset($first, $first[$b]) ? $first[$b] : null; + $result = ($first && isset($first[$b])) ? $first[$b] : null; + if ($result === null) { + return false; + } + // areMutuallyExclusive being false is a superset of being true, + // hence if we want to know if this PairSet "has" these two with no + // exclusivity, we have to ensure it was added as such. + if ($areMutuallyExclusive === false) { + return $result === false; + } + return true; } /** - * @param $a - * @param $b + * @param string $a + * @param string $b + * @param bool $areMutuallyExclusive */ - public function add($a, $b) + public function add($a, $b, $areMutuallyExclusive) { - $this->pairSetAdd($a, $b); - $this->pairSetAdd($b, $a); + $this->pairSetAdd($a, $b, $areMutuallyExclusive); + $this->pairSetAdd($b, $a, $areMutuallyExclusive); } /** - * @param $var - * @return mixed + * @param string $a + * @param string $b + * @param bool $areMutuallyExclusive */ - private function toObj($var) + private function pairSetAdd($a, $b, $areMutuallyExclusive) { - // SplObjectStorage expects objects, so wrapping non-objects to objects - if (is_object($var)) { - return $var; - } - if (!isset($this->wrappers[$var])) { - $tmp = new \stdClass(); - $tmp->_internal = $var; - $this->wrappers[$var] = $tmp; - } - return $this->wrappers[$var]; - } - - /** - * @param $a - * @param $b - */ - private function pairSetAdd($a, $b) - { - $a = $this->toObj($a); - $b = $this->toObj($b); - $set = isset($this->data[$a]) ? $this->data[$a] : null; - - if (!isset($set)) { - $set = new \SplObjectStorage(); - $this->data[$a] = $set; - } - $set[$b] = true; + $this->data[$a] = isset($this->data[$a]) ? $this->data[$a] : []; + $this->data[$a][$b] = $areMutuallyExclusive; } } diff --git a/src/Utils/SchemaPrinter.php b/src/Utils/SchemaPrinter.php index 1e1d9cb..9685554 100644 --- a/src/Utils/SchemaPrinter.php +++ b/src/Utils/SchemaPrinter.php @@ -1,9 +1,10 @@ getDirectives(), function($directive) use ($directiveFilter) { - return $directiveFilter($directive->name); + return $directiveFilter($directive); }); - $typeMap = $schema->getTypeMap(); - $types = array_filter(array_keys($typeMap), $typeFilter); - sort($types); - $types = array_map(function($typeName) use ($typeMap) { return $typeMap[$typeName]; }, $types); + $types = $schema->getTypeMap(); + ksort($types); + $types = array_filter($types, $typeFilter); return implode("\n\n", array_filter(array_merge( [self::printSchemaDefinition($schema)], - array_map('self::printDirective', $directives), - array_map('self::printType', $types) + array_map(function($directive) use ($options) { return self::printDirective($directive, $options); }, $directives), + array_map(function($type) use ($options) { return self::printType($type, $options); }, $types) ))) . "\n"; } @@ -112,7 +98,7 @@ class SchemaPrinter 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 @@ -145,95 +131,97 @@ class SchemaPrinter return true; } - public static function printType(Type $type) + public static function printType(Type $type, array $options = []) { if ($type instanceof ScalarType) { - return self::printScalar($type); + return self::printScalar($type, $options); } else if ($type instanceof ObjectType) { - return self::printObject($type); + return self::printObject($type, $options); } else if ($type instanceof InterfaceType) { - return self::printInterface($type); + return self::printInterface($type, $options); } else if ($type instanceof UnionType) { - return self::printUnion($type); + return self::printUnion($type, $options); } else if ($type instanceof EnumType) { - return self::printEnum($type); + return self::printEnum($type, $options); + } else if ($type instanceof InputObjectType) { + return self::printInputObject($type, $options); } - Utils::invariant($type instanceof InputObjectType); - return self::printInputObject($type); + + throw new Error('Unknown type: ' . Utils::printSafe($type) . '.'); } - private static function printScalar(ScalarType $type) + private static function printScalar(ScalarType $type, array $options) { - return self::printDescription($type) . "scalar {$type->name}"; + return self::printDescription($options, $type) . "scalar {$type->name}"; } - private static function printObject(ObjectType $type) + private static function printObject(ObjectType $type, array $options) { $interfaces = $type->getInterfaces(); $implementedInterfaces = !empty($interfaces) ? ' implements ' . implode(', ', array_map(function($i) { return $i->name; }, $interfaces)) : ''; - return self::printDescription($type) . + return self::printDescription($options, $type) . "type {$type->name}$implementedInterfaces {\n" . - self::printFields($type) . "\n" . + self::printFields($options, $type) . "\n" . "}"; } - private static function printInterface(InterfaceType $type) + private static function printInterface(InterfaceType $type, array $options) { - return self::printDescription($type) . + return self::printDescription($options, $type) . "interface {$type->name} {\n" . - self::printFields($type) . "\n" . + self::printFields($options, $type) . "\n" . "}"; } - private static function printUnion(UnionType $type) + private static function printUnion(UnionType $type, array $options) { - return self::printDescription($type) . + return self::printDescription($options, $type) . "union {$type->name} = " . implode(" | ", $type->getTypes()); } - private static function printEnum(EnumType $type) + private static function printEnum(EnumType $type, array $options) { - return self::printDescription($type) . + return self::printDescription($options, $type) . "enum {$type->name} {\n" . - self::printEnumValues($type->getValues()) . "\n" . + self::printEnumValues($type->getValues(), $options) . "\n" . "}"; } - private static function printEnumValues($values) + private static function printEnumValues($values, $options) { - return implode("\n", array_map(function($value, $i) { - return self::printDescription($value, ' ', !$i) . ' ' . + return implode("\n", array_map(function($value, $i) use ($options) { + return self::printDescription($options, $value, ' ', !$i) . ' ' . $value->name . self::printDeprecated($value); }, $values, array_keys($values))); } - private static function printInputObject(InputObjectType $type) + private static function printInputObject(InputObjectType $type, array $options) { $fields = array_values($type->getFields()); - return self::printDescription($type) . + return self::printDescription($options, $type) . "input {$type->name} {\n" . - implode("\n", array_map(function($f, $i) { - return self::printDescription($f, ' ', !$i) . ' ' . self::printInputValue($f); + implode("\n", array_map(function($f, $i) use ($options) { + return self::printDescription($options, $f, ' ', !$i) . ' ' . self::printInputValue($f); }, $fields, array_keys($fields))) . "\n" . "}"; } - private static function printFields($type) + private static function printFields($options, $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, ' ') . ': ' . + return implode("\n", array_map(function($f, $i) use ($options) { + return self::printDescription($options, $f, ' ', !$i) . ' ' . + $f->name . self::printArgs($options, $f->args, ' ') . ': ' . (string) $f->getType() . self::printDeprecated($f); }, $fields, array_keys($fields))); } - private static function printArgs($args, $indentation = '') + private static function printArgs($options, $args, $indentation = '') { - if (count($args) === 0) { + if (!$args) { return ''; } @@ -242,8 +230,8 @@ class SchemaPrinter 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 . + return "(\n" . implode("\n", array_map(function($arg, $i) use ($indentation, $options) { + return self::printDescription($options, $arg, ' ' . $indentation, !$i) . ' ' . $indentation . self::printInputValue($arg); }, $args, array_keys($args))) . "\n" . $indentation . ')'; } @@ -257,10 +245,10 @@ class SchemaPrinter return $argDecl; } - private static function printDirective($directive) + private static function printDirective($directive, $options) { - return self::printDescription($directive) . - 'directive @' . $directive->name . self::printArgs($directive->args) . + return self::printDescription($options, $directive) . + 'directive @' . $directive->name . self::printArgs($options, $directive->args) . ' on ' . implode(' | ', $directive->locations); } @@ -277,37 +265,77 @@ class SchemaPrinter Printer::doPrint(AST::astFromValue($reason, Type::string())) . ')'; } - private static function printDescription($def, $indentation = '', $firstInBlock = true) + private static function printDescription($options, $def, $indentation = '', $firstInBlock = true) { if (!$def->description) { return ''; } - $lines = explode("\n", $def->description); + $lines = self::descriptionLines($def->description, 120 - strlen($indentation)); + if (isset($options['commentDescriptions'])) { + return self::printDescriptionWithComments($lines, $indentation, $firstInBlock); + } + + $description = ($indentation && !$firstInBlock) ? "\n" : ''; + if (count($lines) === 1 && mb_strlen($lines[0]) < 70) { + $description .= $indentation . '"""' . self::escapeQuote($lines[0]) . "\"\"\"\n"; + return $description; + } + + $description .= $indentation . "\"\"\"\n"; + foreach ($lines as $line) { + $description .= $indentation . self::escapeQuote($line) . "\n"; + } + $description .= $indentation . "\"\"\"\n"; + + return $description; + } + + private static function escapeQuote($line) + { + return str_replace('"""', '\\"""', $line); + } + + private static function printDescriptionWithComments($lines, $indentation, $firstInBlock) + { $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"; - } + $description .= $indentation . '# ' . $line . "\n"; } } + return $description; } - private static function breakLine($line, $len) + private static function descriptionLines($description, $maxLen) { + $lines = []; + $rawLines = explode("\n", $description); + foreach($rawLines as $line) { + if ($line === '') { + $lines[] = $line; + } else { + // For > 120 character long lines, cut at space boundaries into sublines + // of ~80 chars. + $sublines = self::breakLine($line, $maxLen); + foreach ($sublines as $subline) { + $lines[] = $subline; + } + } + } + return $lines; + } + + private static function breakLine($line, $maxLen) { - if (strlen($line) < $len + 5) { + if (strlen($line) < $maxLen + 5) { return [$line]; } - preg_match_all("/((?: |^).{15," . ($len - 40) . "}(?= |$))/", $line, $parts); + preg_match_all("/((?: |^).{15," . ($maxLen - 40) . "}(?= |$))/", $line, $parts); $parts = $parts[0]; return array_map(function($part) { return trim($part); }, $parts); } -} \ No newline at end of file +} diff --git a/src/Utils/TypeComparators.php b/src/Utils/TypeComparators.php index 64639b4..0ddcbaf 100644 --- a/src/Utils/TypeComparators.php +++ b/src/Utils/TypeComparators.php @@ -48,7 +48,7 @@ class TypeComparators * @param Type $superType * @return bool */ - static function isTypeSubTypeOf(Schema $schema, Type $maybeSubType, Type $superType) + static function isTypeSubTypeOf(Schema $schema, $maybeSubType, $superType) { // Equivalent type is a valid subtype if ($maybeSubType === $superType) { diff --git a/src/Utils/TypeInfo.php b/src/Utils/TypeInfo.php index fa97d1b..a211c5a 100644 --- a/src/Utils/TypeInfo.php +++ b/src/Utils/TypeInfo.php @@ -19,7 +19,6 @@ use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InputType; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\ListOfType; -use GraphQL\Type\Definition\NonNull; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; use GraphQL\Type\Definition\UnionType; @@ -121,7 +120,7 @@ class TypeInfo if ($type instanceof ObjectType) { $nestedTypes = array_merge($nestedTypes, $type->getInterfaces()); } - if ($type instanceof ObjectType || $type instanceof InterfaceType || $type instanceof InputObjectType) { + if ($type instanceof ObjectType || $type instanceof InterfaceType) { foreach ((array) $type->getFields() as $fieldName => $field) { if (!empty($field->args)) { $fieldArgTypes = array_map(function(FieldArgument $arg) { return $arg->getType(); }, $field->args); @@ -130,6 +129,11 @@ class TypeInfo $nestedTypes[] = $field->getType(); } } + if ($type instanceof InputObjectType) { + foreach ((array) $type->getFields() as $fieldName => $field) { + $nestedTypes[] = $field->getType(); + } + } foreach ($nestedTypes as $type) { $typeMap = self::extractTypes($type, $typeMap); } @@ -211,14 +215,26 @@ class TypeInfo /** * TypeInfo constructor. * @param Schema $schema + * @param Type|null $initialType */ - public function __construct(Schema $schema) + public function __construct(Schema $schema, $initialType = null) { $this->schema = $schema; $this->typeStack = []; $this->parentTypeStack = []; $this->inputTypeStack = []; $this->fieldDefStack = []; + if ($initialType) { + if (Type::isInputType($initialType)) { + $this->inputTypeStack[] = $initialType; + } + if (Type::isCompositeType($initialType)) { + $this->parentTypeStack[] = $initialType; + } + if (Type::isOutputType($initialType)) { + $this->typeStack[] = $initialType; + } + } } /** @@ -233,7 +249,7 @@ class TypeInfo } /** - * @return Type + * @return CompositeType */ function getParentType() { @@ -254,6 +270,17 @@ class TypeInfo return null; } + /** + * @return InputType|null + */ + public function getParentInputType() + { + $inputTypeStackLength = count($this->inputTypeStack); + if ($inputTypeStackLength > 1) { + return $this->inputTypeStack[$inputTypeStackLength - 2]; + } + } + /** * @return FieldDefinition */ @@ -296,6 +323,10 @@ class TypeInfo { $schema = $this->schema; + // Note: many of the types below are explicitly typed as "mixed" to drop + // any assumptions of a valid schema to ensure runtime types are properly + // checked before continuing since TypeInfo is used as part of validation + // which occurs before guarantees of schema and document validity. switch ($node->kind) { case NodeKind::SELECTION_SET: $namedType = Type::getNamedType($this->getType()); @@ -308,8 +339,12 @@ class TypeInfo if ($parentType) { $fieldDef = self::getFieldDefinition($schema, $parentType, $node); } - $this->fieldDefStack[] = $fieldDef; // push - $this->typeStack[] = $fieldDef ? $fieldDef->getType() : null; // push + $fieldType = null; + if ($fieldDef) { + $fieldType = $fieldDef->getType(); + } + $this->fieldDefStack[] = $fieldDef; + $this->typeStack[] = Type::isOutputType($fieldType) ? $fieldType : null; break; case NodeKind::DIRECTIVE: @@ -325,14 +360,14 @@ class TypeInfo } else if ($node->operation === 'subscription') { $type = $schema->getSubscriptionType(); } - $this->typeStack[] = $type; // push + $this->typeStack[] = Type::isOutputType($type) ? $type : null; break; case NodeKind::INLINE_FRAGMENT: case NodeKind::FRAGMENT_DEFINITION: $typeConditionNode = $node->typeCondition; - $outputType = $typeConditionNode ? self::typeFromAST($schema, $typeConditionNode) : $this->getType(); - $this->typeStack[] = Type::isOutputType($outputType) ? $outputType : null; // push + $outputType = $typeConditionNode ? self::typeFromAST($schema, $typeConditionNode) : Type::getNamedType($this->getType()); + $this->typeStack[] = Type::isOutputType($outputType) ? $outputType : null; break; case NodeKind::VARIABLE_DEFINITION: @@ -350,23 +385,27 @@ class TypeInfo } } $this->argument = $argDef; - $this->inputTypeStack[] = $argType; // push + $this->inputTypeStack[] = Type::isInputType($argType) ? $argType : null; break; case NodeKind::LST: $listType = Type::getNullableType($this->getInputType()); - $this->inputTypeStack[] = ($listType instanceof ListOfType ? $listType->getWrappedType() : null); // push + $itemType = $listType instanceof ListOfType + ? $listType->getWrappedType() + : $listType; + $this->inputTypeStack[] = Type::isInputType($itemType) ? $itemType : null; break; case NodeKind::OBJECT_FIELD: $objectType = Type::getNamedType($this->getInputType()); $fieldType = null; + $inputFieldType = null; if ($objectType instanceof InputObjectType) { $tmp = $objectType->getFields(); $inputField = isset($tmp[$node->name->value]) ? $tmp[$node->name->value] : null; - $fieldType = $inputField ? $inputField->getType() : null; + $inputFieldType = $inputField ? $inputField->getType() : null; } - $this->inputTypeStack[] = $fieldType; + $this->inputTypeStack[] = Type::isInputType($inputFieldType) ? $inputFieldType : null; break; case NodeKind::ENUM: diff --git a/src/Utils/Utils.php b/src/Utils/Utils.php index fa1183a..ee202ae 100644 --- a/src/Utils/Utils.php +++ b/src/Utils/Utils.php @@ -1,8 +1,10 @@ 0) { - $keys = []; - $keyCount = 0; - foreach ($var as $key => $value) { - $keys[] = '"' . $key . '"'; - if ($keyCount++ > 4) { - break; - } - } - $keysLabel = $keyCount === 1 ? 'key' : 'keys'; - $msg = "object with first $keysLabel: " . implode(', ', $keys); - } else { - $msg = "array($count)"; - } - return $msg; + return json_encode($var); } if ('' === $var) { return '(empty string)'; @@ -285,7 +284,7 @@ class Utils return 'false'; } if (true === $var) { - return 'false'; + return 'true'; } if (is_string($var)) { return "\"$var\""; @@ -306,25 +305,14 @@ class Utils return $var->toString(); } if (is_object($var)) { - return 'instance of ' . get_class($var); + if (method_exists($var, '__toString')) { + return (string) $var; + } else { + return 'instance of ' . get_class($var); + } } if (is_array($var)) { - $count = count($var); - if (!isset($var[0]) && $count > 0) { - $keys = []; - $keyCount = 0; - foreach ($var as $key => $value) { - $keys[] = '"' . $key . '"'; - if ($keyCount++ > 4) { - break; - } - } - $keysLabel = $keyCount === 1 ? 'key' : 'keys'; - $msg = "associative array($count) with first $keysLabel: " . implode(', ', $keys); - } else { - $msg = "array($count)"; - } - return $msg; + return json_encode($var); } if ('' === $var) { return '(empty string)'; @@ -339,7 +327,7 @@ class Utils return 'true'; } if (is_string($var)) { - return "\"$var\""; + return $var; } if (is_scalar($var)) { return (string) $var; @@ -418,34 +406,46 @@ class Utils } /** + * Upholds the spec rules about naming. + * * @param $name - * @param bool $isIntrospection - * @throws InvariantViolation + * @throws Error */ - public static function assertValidName($name, $isIntrospection = false) + public static function assertValidName($name) { - $regex = '/^[_a-zA-Z][_a-zA-Z0-9]*$/'; + $error = self::isValidNameError($name); + if ($error) { + throw $error; + } + } - if (!$name || !is_string($name)) { - throw new InvariantViolation( - "Must be named. Unexpected name: " . self::printSafe($name) + /** + * Returns an Error if a name is invalid. + * + * @param string $name + * @param Node|null $node + * @return Error|null + */ + public static function isValidNameError($name, $node = null) + { + Utils::invariant(is_string($name), 'Expected string'); + + if (isset($name[1]) && $name[0] === '_' && $name[1] === '_') { + return new Error( + "Name \"{$name}\" must not begin with \"__\", which is reserved by " . + "GraphQL introspection.", + $node ); } - if (!$isIntrospection && isset($name[1]) && $name[0] === '_' && $name[1] === '_') { - Warning::warnOnce( - 'Name "'.$name.'" must not begin with "__", which is reserved by ' . - 'GraphQL introspection. In a future release of graphql this will ' . - 'become an exception', - Warning::WARNING_NAME + if (!preg_match('/^[_a-zA-Z][_a-zA-Z0-9]*$/', $name)) { + return new Error( + "Names must match /^[_a-zA-Z][_a-zA-Z0-9]*\$/ but \"{$name}\" does not.", + $node ); } - if (!preg_match($regex, $name)) { - throw new InvariantViolation( - 'Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "'.$name.'" does not.' - ); - } + return null; } /** @@ -471,4 +471,72 @@ class Utils } }; } + + + /** + * @param string[] $items + * @return string + */ + public static function quotedOrList(array $items) + { + $items = array_map(function($item) { return "\"$item\""; }, $items); + return self::orList($items); + } + + public static function orList(array $items) + { + if (!$items) { + throw new \LogicException('items must not need to be empty.'); + } + $selected = array_slice($items, 0, 5); + $selectedLength = count($selected); + $firstSelected = $selected[0]; + + if ($selectedLength === 1) { + return $firstSelected; + } + + return array_reduce( + range(1, $selectedLength - 1), + function ($list, $index) use ($selected, $selectedLength) { + return $list. + ($selectedLength > 2 ? ', ' : ' ') . + ($index === $selectedLength - 1 ? 'or ' : '') . + $selected[$index]; + }, + $firstSelected + ); + } + + /** + * Given an invalid input string and a list of valid options, returns a filtered + * list of valid options sorted based on their similarity with the input. + * + * Includes a custom alteration from Damerau-Levenshtein to treat case changes + * as a single edit which helps identify mis-cased values with an edit distance + * of 1 + * @param string $input + * @param array $options + * @return string[] + */ + public static function suggestionList($input, array $options) + { + $optionsByDistance = []; + $inputThreshold = mb_strlen($input) / 2; + foreach ($options as $option) { + $distance = $input === $option + ? 0 + : (strtolower($input) === strtolower($option) + ? 1 + : levenshtein($input, $option)); + $threshold = max($inputThreshold, mb_strlen($option) / 2, 1); + if ($distance <= $threshold) { + $optionsByDistance[$option] = $distance; + } + } + + asort($optionsByDistance); + + return array_keys($optionsByDistance); + } } diff --git a/src/Utils/Value.php b/src/Utils/Value.php new file mode 100644 index 0000000..5606091 --- /dev/null +++ b/src/Utils/Value.php @@ -0,0 +1,261 @@ +getWrappedType(), $blameNode, $path); + } + + if (null === $value) { + // Explicitly return the value null. + return self::ofValue(null); + } + + if ($type instanceof ScalarType) { + // Scalars determine if a value is valid via parseValue(), which can + // throw to indicate failure. If it throws, maintain a reference to + // the original error. + try { + $parseResult = $type->parseValue($value); + if (Utils::isInvalid($parseResult)) { + return self::ofErrors([ + self::coercionError("Expected type {$type->name}", $blameNode, $path), + ]); + } + + return self::ofValue($parseResult); + } catch (\Exception $error) { + return self::ofErrors([ + self::coercionError( + "Expected type {$type->name}", + $blameNode, + $path, + $error->getMessage(), + $error + ), + ]); + } catch (\Throwable $error) { + return self::ofErrors([ + self::coercionError( + "Expected type {$type->name}", + $blameNode, + $path, + $error->getMessage(), + $error + ), + ]); + } + } + + if ($type instanceof EnumType) { + if (is_string($value)) { + $enumValue = $type->getValue($value); + if ($enumValue) { + return self::ofValue($enumValue->value); + } + } + + $suggestions = Utils::suggestionList( + Utils::printSafe($value), + array_map(function($enumValue) { return $enumValue->name; }, $type->getValues()) + ); + $didYouMean = $suggestions + ? "did you mean " . Utils::orList($suggestions) . "?" + : null; + + return self::ofErrors([ + self::coercionError( + "Expected type {$type->name}", + $blameNode, + $path, + $didYouMean + ), + ]); + } + + if ($type instanceof ListOfType) { + $itemType = $type->getWrappedType(); + if (is_array($value) || $value instanceof \Traversable) { + $errors = []; + $coercedValue = []; + foreach ($value as $index => $itemValue) { + $coercedItem = self::coerceValue( + $itemValue, + $itemType, + $blameNode, + self::atPath($path, $index) + ); + if ($coercedItem['errors']) { + $errors = self::add($errors, $coercedItem['errors']); + } else { + $coercedValue[] = $coercedItem['value']; + } + } + return $errors ? self::ofErrors($errors) : self::ofValue($coercedValue); + } + // Lists accept a non-list value as a list of one. + $coercedItem = self::coerceValue($value, $itemType, $blameNode); + return $coercedItem['errors'] ? $coercedItem : self::ofValue([$coercedItem['value']]); + } + + if ($type instanceof InputObjectType) { + if (!is_object($value) && !is_array($value) && !$value instanceof \Traversable) { + return self::ofErrors([ + self::coercionError( + "Expected type {$type->name} to be an object", + $blameNode, + $path + ), + ]); + } + + $errors = []; + $coercedValue = []; + $fields = $type->getFields(); + foreach ($fields as $fieldName => $field) { + if (!array_key_exists($fieldName, $value)) { + if ($field->defaultValueExists()) { + $coercedValue[$fieldName] = $field->defaultValue; + } else if ($field->getType() instanceof NonNull) { + $fieldPath = self::printPath(self::atPath($path, $fieldName)); + $errors = self::add( + $errors, + self::coercionError( + "Field {$fieldPath} of required " . + "type {$field->type} was not provided", + $blameNode + ) + ); + } + } else { + $fieldValue = $value[$fieldName]; + $coercedField = self::coerceValue( + $fieldValue, + $field->getType(), + $blameNode, + self::atPath($path, $fieldName) + ); + if ($coercedField['errors']) { + $errors = self::add($errors, $coercedField['errors']); + } else { + $coercedValue[$fieldName] = $coercedField['value']; + } + } + } + + // Ensure every provided field is defined. + foreach ($value as $fieldName => $field) { + if (!array_key_exists($fieldName, $fields)) { + $suggestions = Utils::suggestionList( + $fieldName, + array_keys($fields) + ); + $didYouMean = $suggestions + ? "did you mean " . Utils::orList($suggestions) . "?" + : null; + $errors = self::add( + $errors, + self::coercionError( + "Field \"{$fieldName}\" is not defined by type {$type->name}", + $blameNode, + $path, + $didYouMean + ) + ); + } + } + + return $errors ? self::ofErrors($errors) : self::ofValue($coercedValue); + } + + throw new Error("Unexpected type {$type}"); + } + + private static function ofValue($value) { + return ['errors' => null, 'value' => $value]; + } + + private static function ofErrors($errors) { + return ['errors' => $errors, 'value' => Utils::undefined()]; + } + + private static function add($errors, $moreErrors) { + return array_merge($errors, is_array($moreErrors) ? $moreErrors : [$moreErrors]); + } + + private static function atPath($prev, $key) { + return ['prev' => $prev, 'key' => $key]; + } + + /** + * @param string $message + * @param Node $blameNode + * @param array|null $path + * @param string $subMessage + * @param \Exception|\Throwable|null $originalError + * @return Error + */ + private static function coercionError($message, $blameNode, array $path = null, $subMessage = null, $originalError = null) { + $pathStr = self::printPath($path); + // Return a GraphQLError instance + return new Error( + $message . + ($pathStr ? ' at ' . $pathStr : '') . + ($subMessage ? '; ' . $subMessage : '.'), + $blameNode, + null, + null, + null, + $originalError + ); + } + + /** + * Build a string describing the path into the value where the error was found + * + * @param $path + * @return string + */ + private static function printPath(array $path = null) { + $pathStr = ''; + $currentPath = $path; + while($currentPath) { + $pathStr = + (is_string($currentPath['key']) + ? '.' . $currentPath['key'] + : '[' . $currentPath['key'] . ']') . $pathStr; + $currentPath = $currentPath['prev']; + } + return $pathStr ? 'value' . $pathStr : ''; + } +} diff --git a/src/Validator/DocumentValidator.php b/src/Validator/DocumentValidator.php index 31b7649..3efd992 100644 --- a/src/Validator/DocumentValidator.php +++ b/src/Validator/DocumentValidator.php @@ -2,26 +2,15 @@ namespace GraphQL\Validator; use GraphQL\Error\Error; -use GraphQL\Error\InvariantViolation; -use GraphQL\Language\AST\ListValueNode; use GraphQL\Language\AST\DocumentNode; -use GraphQL\Language\AST\NodeKind; -use GraphQL\Language\AST\NullValueNode; -use GraphQL\Language\AST\VariableNode; -use GraphQL\Language\Printer; use GraphQL\Language\Visitor; use GraphQL\Type\Schema; -use GraphQL\Type\Definition\InputObjectType; -use GraphQL\Type\Definition\LeafType; -use GraphQL\Type\Definition\ListOfType; -use GraphQL\Type\Definition\NonNull; use GraphQL\Type\Definition\Type; -use GraphQL\Utils\Utils; use GraphQL\Utils\TypeInfo; use GraphQL\Validator\Rules\AbstractValidationRule; -use GraphQL\Validator\Rules\ArgumentsOfCorrectType; -use GraphQL\Validator\Rules\DefaultValuesOfCorrectType; +use GraphQL\Validator\Rules\ValuesOfCorrectType; use GraphQL\Validator\Rules\DisableIntrospection; +use GraphQL\Validator\Rules\ExecutableDefinitions; use GraphQL\Validator\Rules\FieldsOnCorrectType; use GraphQL\Validator\Rules\FragmentsOnCompositeTypes; use GraphQL\Validator\Rules\KnownArgumentNames; @@ -46,6 +35,7 @@ use GraphQL\Validator\Rules\UniqueInputFieldNames; use GraphQL\Validator\Rules\UniqueOperationNames; use GraphQL\Validator\Rules\UniqueVariableNames; use GraphQL\Validator\Rules\VariablesAreInputTypes; +use GraphQL\Validator\Rules\VariablesDefaultValueAllowed; use GraphQL\Validator\Rules\VariablesInAllowedPosition; /** @@ -122,6 +112,7 @@ class DocumentValidator { if (null === self::$defaultRules) { self::$defaultRules = [ + ExecutableDefinitions::class => new ExecutableDefinitions(), UniqueOperationNames::class => new UniqueOperationNames(), LoneAnonymousOperation::class => new LoneAnonymousOperation(), KnownTypeNames::class => new KnownTypeNames(), @@ -141,9 +132,9 @@ class DocumentValidator UniqueDirectivesPerLocation::class => new UniqueDirectivesPerLocation(), KnownArgumentNames::class => new KnownArgumentNames(), UniqueArgumentNames::class => new UniqueArgumentNames(), - ArgumentsOfCorrectType::class => new ArgumentsOfCorrectType(), + ValuesOfCorrectType::class => new ValuesOfCorrectType(), ProvidedNonNullArguments::class => new ProvidedNonNullArguments(), - DefaultValuesOfCorrectType::class => new DefaultValuesOfCorrectType(), + VariablesDefaultValueAllowed::class => new VariablesDefaultValueAllowed(), VariablesInAllowedPosition::class => new VariablesInAllowedPosition(), OverlappingFieldsCanBeMerged::class => new OverlappingFieldsCanBeMerged(), UniqueInputFieldNames::class => new UniqueInputFieldNames(), @@ -223,100 +214,23 @@ class DocumentValidator } /** - * Utility for validators which determines if a value literal AST is valid given - * an input type. + * Utility which determines if a value literal node is valid for an input type. * - * Note that this only validates literal values, variables are assumed to - * provide values of the correct type. + * Deprecated. Rely on validation for documents containing literal values. * - * @return array + * @deprecated + * @return Error[] */ public static function isValidLiteralValue(Type $type, $valueNode) { - // A value must be provided if the type is non-null. - if ($type instanceof NonNull) { - if (!$valueNode || $valueNode instanceof NullValueNode) { - return [ 'Expected "' . Utils::printSafe($type) . '", found null.' ]; - } - return static::isValidLiteralValue($type->getWrappedType(), $valueNode); - } - - if (!$valueNode || $valueNode instanceof NullValueNode) { - return []; - } - - // This function only tests literals, and assumes variables will provide - // values of the correct type. - if ($valueNode instanceof VariableNode) { - return []; - } - - // Lists accept a non-list value as a list of one. - if ($type instanceof ListOfType) { - $itemType = $type->getWrappedType(); - if ($valueNode instanceof ListValueNode) { - $errors = []; - foreach($valueNode->values as $index => $itemNode) { - $tmp = static::isValidLiteralValue($itemType, $itemNode); - - if ($tmp) { - $errors = array_merge($errors, Utils::map($tmp, function($error) use ($index) { - return "In element #$index: $error"; - })); - } - } - return $errors; - } else { - return static::isValidLiteralValue($itemType, $valueNode); - } - } - - // Input objects check each defined field and look for undefined fields. - if ($type instanceof InputObjectType) { - if ($valueNode->kind !== NodeKind::OBJECT) { - return [ "Expected \"{$type->name}\", found not an object." ]; - } - - $fields = $type->getFields(); - $errors = []; - - // Ensure every provided field is defined. - $fieldNodes = $valueNode->fields; - - foreach ($fieldNodes as $providedFieldNode) { - if (empty($fields[$providedFieldNode->name->value])) { - $errors[] = "In field \"{$providedFieldNode->name->value}\": Unknown field."; - } - } - - // Ensure every defined field is valid. - $fieldNodeMap = Utils::keyMap($fieldNodes, function($fieldNode) {return $fieldNode->name->value;}); - foreach ($fields as $fieldName => $field) { - $result = static::isValidLiteralValue( - $field->getType(), - isset($fieldNodeMap[$fieldName]) ? $fieldNodeMap[$fieldName]->value : null - ); - if ($result) { - $errors = array_merge($errors, Utils::map($result, function($error) use ($fieldName) { - return "In field \"$fieldName\": $error"; - })); - } - } - - return $errors; - } - - if ($type instanceof LeafType) { - // Scalars must parse to a non-null value - if (!$type->isValidLiteral($valueNode)) { - $printed = Printer::doPrint($valueNode); - return [ "Expected type \"{$type->name}\", found $printed." ]; - } - - return []; - } - - throw new InvariantViolation('Must be input type'); + $emptySchema = new Schema([]); + $emptyDoc = new DocumentNode(['definitions' => []]); + $typeInfo = new TypeInfo($emptySchema, $type); + $context = new ValidationContext($emptySchema, $emptyDoc, $typeInfo); + $validator = new ValuesOfCorrectType(); + $visitor = $validator->getVisitor($context); + Visitor::visit($valueNode, Visitor::visitWithTypeInfo($typeInfo, $visitor)); + return $context->getErrors(); } /** diff --git a/src/Validator/Rules/ArgumentsOfCorrectType.php b/src/Validator/Rules/ArgumentsOfCorrectType.php deleted file mode 100644 index 3e37322..0000000 --- a/src/Validator/Rules/ArgumentsOfCorrectType.php +++ /dev/null @@ -1,39 +0,0 @@ - function(ArgumentNode $argNode) use ($context) { - $argDef = $context->getArgument(); - if ($argDef) { - $errors = DocumentValidator::isValidLiteralValue($argDef->getType(), $argNode->value); - - if (!empty($errors)) { - $context->reportError(new Error( - self::badValueMessage($argNode->name->value, $argDef->getType(), Printer::doPrint($argNode->value), $errors), - [$argNode->value] - )); - } - } - return Visitor::skipNode(); - } - ]; - } -} diff --git a/src/Validator/Rules/DefaultValuesOfCorrectType.php b/src/Validator/Rules/DefaultValuesOfCorrectType.php deleted file mode 100644 index 792acd7..0000000 --- a/src/Validator/Rules/DefaultValuesOfCorrectType.php +++ /dev/null @@ -1,59 +0,0 @@ - function(VariableDefinitionNode $varDefNode) use ($context) { - $name = $varDefNode->variable->name->value; - $defaultValue = $varDefNode->defaultValue; - $type = $context->getInputType(); - - if ($type instanceof NonNull && $defaultValue) { - $context->reportError(new Error( - static::defaultForNonNullArgMessage($name, $type, $type->getWrappedType()), - [$defaultValue] - )); - } - if ($type && $defaultValue) { - $errors = DocumentValidator::isValidLiteralValue($type, $defaultValue); - if (!empty($errors)) { - $context->reportError(new Error( - static::badValueForDefaultArgMessage($name, $type, Printer::doPrint($defaultValue), $errors), - [$defaultValue] - )); - } - } - return Visitor::skipNode(); - }, - NodeKind::SELECTION_SET => function() {return Visitor::skipNode();}, - NodeKind::FRAGMENT_DEFINITION => function() {return Visitor::skipNode();} - ]; - } -} diff --git a/src/Validator/Rules/ExecutableDefinitions.php b/src/Validator/Rules/ExecutableDefinitions.php new file mode 100644 index 0000000..f512d6d --- /dev/null +++ b/src/Validator/Rules/ExecutableDefinitions.php @@ -0,0 +1,47 @@ + function (DocumentNode $node) use ($context) { + /** @var Node $definition */ + foreach ($node->definitions as $definition) { + if ( + !$definition instanceof OperationDefinitionNode && + !$definition instanceof FragmentDefinitionNode + ) { + $context->reportError(new Error( + self::nonExecutableDefinitionMessage($definition->name->value), + [$definition->name] + )); + } + } + + return Visitor::skipNode(); + } + ]; + } +} diff --git a/src/Validator/Rules/FieldsOnCorrectType.php b/src/Validator/Rules/FieldsOnCorrectType.php index 26ee748..7d052ae 100644 --- a/src/Validator/Rules/FieldsOnCorrectType.php +++ b/src/Validator/Rules/FieldsOnCorrectType.php @@ -4,27 +4,27 @@ namespace GraphQL\Validator\Rules; use GraphQL\Error\Error; use GraphQL\Language\AST\FieldNode; use GraphQL\Language\AST\NodeKind; +use GraphQL\Type\Definition\InputObjectType; +use GraphQL\Type\Definition\InterfaceType; +use GraphQL\Type\Definition\ObjectType; +use GraphQL\Type\Definition\Type; +use GraphQL\Type\Definition\UnionType; +use GraphQL\Type\Schema; use GraphQL\Utils\Utils; use GraphQL\Validator\ValidationContext; class FieldsOnCorrectType extends AbstractValidationRule { - static function undefinedFieldMessage($field, $type, array $suggestedTypes = []) + static function undefinedFieldMessage($fieldName, $type, array $suggestedTypeNames, array $suggestedFieldNames) { - $message = 'Cannot query field "' . $field . '" on type "' . $type.'".'; + $message = 'Cannot query field "' . $fieldName . '" on type "' . $type.'".'; - $maxLength = 5; - $count = count($suggestedTypes); - if ($count > 0) { - $suggestions = array_slice($suggestedTypes, 0, $maxLength); - $suggestions = Utils::map($suggestions, function($t) { return "\"$t\""; }); - $suggestions = implode(', ', $suggestions); - - if ($count > $maxLength) { - $suggestions .= ', and ' . ($count - $maxLength) . ' other types'; - } - $message .= " However, this field exists on $suggestions."; - $message .= ' Perhaps you meant to use an inline fragment?'; + if ($suggestedTypeNames) { + $suggestions = Utils::quotedOrList($suggestedTypeNames); + $message .= " Did you mean to use an inline fragment on $suggestions?"; + } else if ($suggestedFieldNames) { + $suggestions = Utils::quotedOrList($suggestedFieldNames); + $message .= " Did you mean {$suggestions}?"; } return $message; } @@ -37,8 +37,32 @@ class FieldsOnCorrectType extends AbstractValidationRule if ($type) { $fieldDef = $context->getFieldDef(); if (!$fieldDef) { + // This isn't valid. Let's find suggestions, if any. + $schema = $context->getSchema(); + $fieldName = $node->name->value; + // First determine if there are any suggested types to condition on. + $suggestedTypeNames = $this->getSuggestedTypeNames( + $schema, + $type, + $fieldName + ); + // If there are no suggested types, then perhaps this was a typo? + $suggestedFieldNames = $suggestedTypeNames + ? [] + : $this->getSuggestedFieldNames( + $schema, + $type, + $fieldName + ); + + // Report an error, including helpful suggestions. $context->reportError(new Error( - static::undefinedFieldMessage($node->name->value, $type->name), + static::undefinedFieldMessage( + $node->name->value, + $type->name, + $suggestedTypeNames, + $suggestedFieldNames + ), [$node] )); } @@ -46,4 +70,72 @@ class FieldsOnCorrectType extends AbstractValidationRule } ]; } + + /** + * Go through all of the implementations of type, as well as the interfaces + * that they implement. If any of those types include the provided field, + * suggest them, sorted by how often the type is referenced, starting + * with Interfaces. + * + * @param Schema $schema + * @param $type + * @param string $fieldName + * @return array + */ + private function getSuggestedTypeNames(Schema $schema, $type, $fieldName) + { + if (Type::isAbstractType($type)) { + $suggestedObjectTypes = []; + $interfaceUsageCount = []; + + foreach($schema->getPossibleTypes($type) as $possibleType) { + $fields = $possibleType->getFields(); + if (!isset($fields[$fieldName])) { + continue; + } + // This object type defines this field. + $suggestedObjectTypes[] = $possibleType->name; + foreach($possibleType->getInterfaces() as $possibleInterface) { + $fields = $possibleInterface->getFields(); + if (!isset($fields[$fieldName])) { + continue; + } + // This interface type defines this field. + $interfaceUsageCount[$possibleInterface->name] = + !isset($interfaceUsageCount[$possibleInterface->name]) + ? 0 + : $interfaceUsageCount[$possibleInterface->name] + 1; + } + } + + // Suggest interface types based on how common they are. + arsort($interfaceUsageCount); + $suggestedInterfaceTypes = array_keys($interfaceUsageCount); + + // Suggest both interface and object types. + return array_merge($suggestedInterfaceTypes, $suggestedObjectTypes); + } + + // Otherwise, must be an Object type, which does not have possible fields. + return []; + } + + /** + * For the field name provided, determine if there are any similar field names + * that may be the result of a typo. + * + * @param Schema $schema + * @param $type + * @param string $fieldName + * @return array|string[] + */ + private function getSuggestedFieldNames(Schema $schema, $type, $fieldName) + { + if ($type instanceof ObjectType || $type instanceof InterfaceType) { + $possibleFieldNames = array_keys($type->getFields()); + return Utils::suggestionList($fieldName, $possibleFieldNames); + } + // Otherwise, must be a Union type, which does not define fields. + return []; + } } diff --git a/src/Validator/Rules/KnownArgumentNames.php b/src/Validator/Rules/KnownArgumentNames.php index 78ee3f9..15a77ab 100644 --- a/src/Validator/Rules/KnownArgumentNames.php +++ b/src/Validator/Rules/KnownArgumentNames.php @@ -7,56 +7,68 @@ use GraphQL\Language\AST\NodeKind; use GraphQL\Utils\Utils; use GraphQL\Validator\ValidationContext; +/** + * Known argument names + * + * A GraphQL field is only valid if all supplied arguments are defined by + * that field. + */ class KnownArgumentNames extends AbstractValidationRule { - public static function unknownArgMessage($argName, $fieldName, $type) + public static function unknownArgMessage($argName, $fieldName, $typeName, array $suggestedArgs) { - return "Unknown argument \"$argName\" on field \"$fieldName\" of type \"$type\"."; + $message = "Unknown argument \"$argName\" on field \"$fieldName\" of type \"$typeName\"."; + if ($suggestedArgs) { + $message .= ' Did you mean ' . Utils::quotedOrList($suggestedArgs) . '?'; + } + return $message; } - public static function unknownDirectiveArgMessage($argName, $directiveName) + public static function unknownDirectiveArgMessage($argName, $directiveName, array $suggestedArgs) { - return "Unknown argument \"$argName\" on directive \"@$directiveName\"."; + $message = "Unknown argument \"$argName\" on directive \"@$directiveName\"."; + if ($suggestedArgs) { + $message .= ' Did you mean ' . Utils::quotedOrList($suggestedArgs) . '?'; + } + return $message; } public function getVisitor(ValidationContext $context) { return [ NodeKind::ARGUMENT => function(ArgumentNode $node, $key, $parent, $path, $ancestors) use ($context) { - $argumentOf = $ancestors[count($ancestors) - 1]; - if ($argumentOf->kind === NodeKind::FIELD) { - $fieldDef = $context->getFieldDef(); - - if ($fieldDef) { - $fieldArgDef = null; - foreach ($fieldDef->args as $arg) { - if ($arg->name === $node->name->value) { - $fieldArgDef = $arg; - break; - } - } - if (!$fieldArgDef) { - $parentType = $context->getParentType(); - Utils::invariant($parentType); + $argDef = $context->getArgument(); + if (!$argDef) { + $argumentOf = $ancestors[count($ancestors) - 1]; + if ($argumentOf->kind === NodeKind::FIELD) { + $fieldDef = $context->getFieldDef(); + $parentType = $context->getParentType(); + if ($fieldDef && $parentType) { $context->reportError(new Error( - self::unknownArgMessage($node->name->value, $fieldDef->name, $parentType->name), + self::unknownArgMessage( + $node->name->value, + $fieldDef->name, + $parentType->name, + Utils::suggestionList( + $node->name->value, + array_map(function ($arg) { return $arg->name; }, $fieldDef->args) + ) + ), [$node] )); } - } - } else if ($argumentOf->kind === NodeKind::DIRECTIVE) { - $directive = $context->getDirective(); - if ($directive) { - $directiveArgDef = null; - foreach ($directive->args as $arg) { - if ($arg->name === $node->name->value) { - $directiveArgDef = $arg; - break; - } - } - if (!$directiveArgDef) { + } else if ($argumentOf->kind === NodeKind::DIRECTIVE) { + $directive = $context->getDirective(); + if ($directive) { $context->reportError(new Error( - self::unknownDirectiveArgMessage($node->name->value, $directive->name), + self::unknownDirectiveArgMessage( + $node->name->value, + $directive->name, + Utils::suggestionList( + $node->name->value, + array_map(function ($arg) { return $arg->name; }, $directive->args) + ) + ), [$node] )); } diff --git a/src/Validator/Rules/KnownDirectives.php b/src/Validator/Rules/KnownDirectives.php index 3593f62..4ec3a01 100644 --- a/src/Validator/Rules/KnownDirectives.php +++ b/src/Validator/Rules/KnownDirectives.php @@ -1,13 +1,12 @@ name->value), [$node] )); - return ; + return; } - $appliedTo = $ancestors[count($ancestors) - 1]; - $candidateLocation = $this->getLocationForAppliedNode($appliedTo); + $candidateLocation = $this->getDirectiveLocationForASTPath($ancestors); if (!$candidateLocation) { $context->reportError(new Error( @@ -58,8 +56,9 @@ class KnownDirectives extends AbstractValidationRule ]; } - private function getLocationForAppliedNode(Node $appliedTo) + private function getDirectiveLocationForASTPath(array $ancestors) { + $appliedTo = $ancestors[count($ancestors) - 1]; switch ($appliedTo->kind) { case NodeKind::OPERATION_DEFINITION: switch ($appliedTo->operation) { @@ -68,10 +67,43 @@ class KnownDirectives extends AbstractValidationRule case 'subscription': return DirectiveLocation::SUBSCRIPTION; } break; - case NodeKind::FIELD: return DirectiveLocation::FIELD; - case NodeKind::FRAGMENT_SPREAD: return DirectiveLocation::FRAGMENT_SPREAD; - case NodeKind::INLINE_FRAGMENT: return DirectiveLocation::INLINE_FRAGMENT; - case NodeKind::FRAGMENT_DEFINITION: return DirectiveLocation::FRAGMENT_DEFINITION; + case NodeKind::FIELD: + return DirectiveLocation::FIELD; + case NodeKind::FRAGMENT_SPREAD: + return DirectiveLocation::FRAGMENT_SPREAD; + case NodeKind::INLINE_FRAGMENT: + return DirectiveLocation::INLINE_FRAGMENT; + case NodeKind::FRAGMENT_DEFINITION: + return DirectiveLocation::FRAGMENT_DEFINITION; + case NodeKind::SCHEMA_DEFINITION: + return DirectiveLocation::SCHEMA; + case NodeKind::SCALAR_TYPE_DEFINITION: + case NodeKind::SCALAR_TYPE_EXTENSION: + return DirectiveLocation::SCALAR; + case NodeKind::OBJECT_TYPE_DEFINITION: + case NodeKind::OBJECT_TYPE_EXTENSION: + return DirectiveLocation::OBJECT; + case NodeKind::FIELD_DEFINITION: + return DirectiveLocation::FIELD_DEFINITION; + case NodeKind::INTERFACE_TYPE_DEFINITION: + case NodeKind::INTERFACE_TYPE_EXTENSION: + return DirectiveLocation::IFACE; + case NodeKind::UNION_TYPE_DEFINITION: + case NodeKind::UNION_TYPE_EXTENSION: + return DirectiveLocation::UNION; + case NodeKind::ENUM_TYPE_DEFINITION: + case NodeKind::ENUM_TYPE_EXTENSION: + return DirectiveLocation::ENUM; + case NodeKind::ENUM_VALUE_DEFINITION: + return DirectiveLocation::ENUM_VALUE; + case NodeKind::INPUT_OBJECT_TYPE_DEFINITION: + case NodeKind::INPUT_OBJECT_TYPE_EXTENSION: + return DirectiveLocation::INPUT_OBJECT; + case NodeKind::INPUT_VALUE_DEFINITION: + $parentNode = $ancestors[count($ancestors) - 3]; + return $parentNode instanceof InputObjectTypeDefinitionNode + ? DirectiveLocation::INPUT_FIELD_DEFINITION + : DirectiveLocation::ARGUMENT_DEFINITION; } } } diff --git a/src/Validator/Rules/KnownTypeNames.php b/src/Validator/Rules/KnownTypeNames.php index 71fa60a..47065c1 100644 --- a/src/Validator/Rules/KnownTypeNames.php +++ b/src/Validator/Rules/KnownTypeNames.php @@ -1,35 +1,55 @@ $skip, NodeKind::INTERFACE_TYPE_DEFINITION => $skip, NodeKind::UNION_TYPE_DEFINITION => $skip, NodeKind::INPUT_OBJECT_TYPE_DEFINITION => $skip, - - NodeKind::NAMED_TYPE => function(NamedTypeNode $node, $key) use ($context) { + NodeKind::NAMED_TYPE => function(NamedTypeNode $node) use ($context) { + $schema = $context->getSchema(); $typeName = $node->name->value; - $type = $context->getSchema()->getType($typeName); + $type = $schema->getType($typeName); if (!$type) { - $context->reportError(new Error(self::unknownTypeMessage($typeName), [$node])); + $context->reportError(new Error( + self::unknownTypeMessage( + $typeName, + Utils::suggestionList($typeName, array_keys($schema->getTypeMap())) + ), [$node]) + ); } } ]; diff --git a/src/Validator/Rules/OverlappingFieldsCanBeMerged.php b/src/Validator/Rules/OverlappingFieldsCanBeMerged.php index d0a0fb0..0867be6 100644 --- a/src/Validator/Rules/OverlappingFieldsCanBeMerged.php +++ b/src/Validator/Rules/OverlappingFieldsCanBeMerged.php @@ -1,24 +1,23 @@ comparedSet = new PairSet(); + $this->comparedFragmentPairs = new PairSet(); + $this->cachedFieldsAndFragmentNames = new \SplObjectStorage(); return [ - NodeKind::SELECTION_SET => [ - // Note: we validate on the reverse traversal so deeper conflicts will be - // caught first, for clearer error messages. - 'leave' => function(SelectionSetNode $selectionSet) use ($context) { - $fieldMap = $this->collectFieldNodesAndDefs( - $context, - $context->getParentType(), - $selectionSet - ); + NodeKind::SELECTION_SET => function(SelectionSetNode $selectionSet) use ($context) { + $conflicts = $this->findConflictsWithinSelectionSet( + $context, + $context->getParentType(), + $selectionSet + ); - $conflicts = $this->findConflicts(false, $fieldMap, $context); + foreach ($conflicts as $conflict) { + $responseName = $conflict[0][0]; + $reason = $conflict[0][1]; + $fields1 = $conflict[1]; + $fields2 = $conflict[2]; - foreach ($conflicts as $conflict) { - $responseName = $conflict[0][0]; - $reason = $conflict[0][1]; - $fields1 = $conflict[1]; - $fields2 = $conflict[2]; - - $context->reportError(new Error( - self::fieldsConflictMessage($responseName, $reason), - array_merge($fields1, $fields2) - )); - } + $context->reportError(new Error( + self::fieldsConflictMessage($responseName, $reason), + array_merge($fields1, $fields2) + )); } - ] + } ]; } - private function findConflicts($parentFieldsAreMutuallyExclusive, $fieldMap, ValidationContext $context) + /** + * Algorithm: + * + * Conflicts occur when two fields exist in a query which will produce the same + * response name, but represent differing values, thus creating a conflict. + * The algorithm below finds all conflicts via making a series of comparisons + * between fields. In order to compare as few fields as possible, this makes + * a series of comparisons "within" sets of fields and "between" sets of fields. + * + * Given any selection set, a collection produces both a set of fields by + * also including all inline fragments, as well as a list of fragments + * referenced by fragment spreads. + * + * A) Each selection set represented in the document first compares "within" its + * collected set of fields, finding any conflicts between every pair of + * overlapping fields. + * Note: This is the *only time* that a the fields "within" a set are compared + * to each other. After this only fields "between" sets are compared. + * + * B) Also, if any fragment is referenced in a selection set, then a + * comparison is made "between" the original set of fields and the + * referenced fragment. + * + * C) Also, if multiple fragments are referenced, then comparisons + * are made "between" each referenced fragment. + * + * D) When comparing "between" a set of fields and a referenced fragment, first + * a comparison is made between each field in the original set of fields and + * each field in the the referenced set of fields. + * + * E) Also, if any fragment is referenced in the referenced selection set, + * then a comparison is made "between" the original set of fields and the + * referenced fragment (recursively referring to step D). + * + * F) When comparing "between" two fragments, first a comparison is made between + * each field in the first referenced set of fields and each field in the the + * second referenced set of fields. + * + * G) Also, any fragments referenced by the first must be compared to the + * second, and any fragments referenced by the second must be compared to the + * first (recursively referring to step F). + * + * H) When comparing two fields, if both have selection sets, then a comparison + * is made "between" both selection sets, first comparing the set of fields in + * the first selection set with the set of fields in the second. + * + * I) Also, if any fragment is referenced in either selection set, then a + * comparison is made "between" the other set of fields and the + * referenced fragment. + * + * J) Also, if two fragments are referenced in both selection sets, then a + * comparison is made "between" the two fragments. + * + */ + + /** + * Find all conflicts found "within" a selection set, including those found + * via spreading in fragments. Called when visiting each SelectionSet in the + * GraphQL Document. + * + * @param ValidationContext $context + * @param CompositeType $parentType + * @param SelectionSetNode $selectionSet + * @return array + */ + private function findConflictsWithinSelectionSet( + ValidationContext $context, + $parentType, + SelectionSetNode $selectionSet) { + list($fieldMap, $fragmentNames) = $this->getFieldsAndFragmentNames( + $context, + $parentType, + $selectionSet + ); + $conflicts = []; + + // (A) Find find all conflicts "within" the fields of this selection set. + // Note: this is the *only place* `collectConflictsWithin` is called. + $this->collectConflictsWithin( + $context, + $conflicts, + $fieldMap + ); + + + $fragmentNamesLength = count($fragmentNames); + if ($fragmentNamesLength !== 0) { + // (B) Then collect conflicts between these fields and those represented by + // each spread fragment name found. + $comparedFragments = []; + for ($i = 0; $i < $fragmentNamesLength; $i++) { + $this->collectConflictsBetweenFieldsAndFragment( + $context, + $conflicts, + $comparedFragments, + false, + $fieldMap, + $fragmentNames[$i] + ); + // (C) Then compare this fragment with all other fragments found in this + // selection set to collect conflicts between fragments spread together. + // This compares each item in the list of fragment names to every other item + // in that same list (except for itself). + for ($j = $i + 1; $j < $fragmentNamesLength; $j++) { + $this->collectConflictsBetweenFragments( + $context, + $conflicts, + false, + $fragmentNames[$i], + $fragmentNames[$j] + ); + } + } + } + + return $conflicts; + } + + /** + * Collect all conflicts found between a set of fields and a fragment reference + * including via spreading in any nested fragments. + * + * @param ValidationContext $context + * @param array $conflicts + * @param array $comparedFragments + * @param bool $areMutuallyExclusive + * @param array $fieldMap + * @param string $fragmentName + */ + private function collectConflictsBetweenFieldsAndFragment( + ValidationContext $context, + array &$conflicts, + array &$comparedFragments, + $areMutuallyExclusive, + array $fieldMap, + $fragmentName + ) { + if (isset($comparedFragments[$fragmentName])) { + return; + } + $comparedFragments[$fragmentName] = true; + + $fragment = $context->getFragment($fragmentName); + if (!$fragment) { + return; + } + + list($fieldMap2, $fragmentNames2) = $this->getReferencedFieldsAndFragmentNames( + $context, + $fragment + ); + + if ($fieldMap === $fieldMap2) { + return; + } + + // (D) First collect any conflicts between the provided collection of fields + // and the collection of fields represented by the given fragment. + $this->collectConflictsBetween( + $context, + $conflicts, + $areMutuallyExclusive, + $fieldMap, + $fieldMap2 + ); + + // (E) Then collect any conflicts between the provided collection of fields + // and any fragment names found in the given fragment. + $fragmentNames2Length = count($fragmentNames2); + for ($i = 0; $i < $fragmentNames2Length; $i++) { + $this->collectConflictsBetweenFieldsAndFragment( + $context, + $conflicts, + $comparedFragments, + $areMutuallyExclusive, + $fieldMap, + $fragmentNames2[$i] + ); + } + } + + /** + * Collect all conflicts found between two fragments, including via spreading in + * any nested fragments. + * + * @param ValidationContext $context + * @param array $conflicts + * @param bool $areMutuallyExclusive + * @param string $fragmentName1 + * @param string $fragmentName2 + */ + private function collectConflictsBetweenFragments( + ValidationContext $context, + array &$conflicts, + $areMutuallyExclusive, + $fragmentName1, + $fragmentName2 + ) { + // No need to compare a fragment to itself. + if ($fragmentName1 === $fragmentName2) { + return; + } + + // Memoize so two fragments are not compared for conflicts more than once. + if ( + $this->comparedFragmentPairs->has( + $fragmentName1, + $fragmentName2, + $areMutuallyExclusive + ) + ) { + return; + } + $this->comparedFragmentPairs->add( + $fragmentName1, + $fragmentName2, + $areMutuallyExclusive + ); + + $fragment1 = $context->getFragment($fragmentName1); + $fragment2 = $context->getFragment($fragmentName2); + if (!$fragment1 || !$fragment2) { + return; + } + + list($fieldMap1, $fragmentNames1) = $this->getReferencedFieldsAndFragmentNames( + $context, + $fragment1 + ); + list($fieldMap2, $fragmentNames2) = $this->getReferencedFieldsAndFragmentNames( + $context, + $fragment2 + ); + + // (F) First, collect all conflicts between these two collections of fields + // (not including any nested fragments). + $this->collectConflictsBetween( + $context, + $conflicts, + $areMutuallyExclusive, + $fieldMap1, + $fieldMap2 + ); + + // (G) Then collect conflicts between the first fragment and any nested + // fragments spread in the second fragment. + $fragmentNames2Length = count($fragmentNames2); + for ($j = 0; $j < $fragmentNames2Length; $j++) { + $this->collectConflictsBetweenFragments( + $context, + $conflicts, + $areMutuallyExclusive, + $fragmentName1, + $fragmentNames2[$j] + ); + } + + // (G) Then collect conflicts between the second fragment and any nested + // fragments spread in the first fragment. + $fragmentNames1Length = count($fragmentNames1); + for ($i = 0; $i < $fragmentNames1Length; $i++) { + $this->collectConflictsBetweenFragments( + $context, + $conflicts, + $areMutuallyExclusive, + $fragmentNames1[$i], + $fragmentName2 + ); + } + } + + /** + * Find all conflicts found between two selection sets, including those found + * via spreading in fragments. Called when determining if conflicts exist + * between the sub-fields of two overlapping fields. + * + * @param ValidationContext $context + * @param bool $areMutuallyExclusive + * @param CompositeType $parentType1 + * @param $selectionSet1 + * @param CompositeType $parentType2 + * @param $selectionSet2 + * @return array + */ + private function findConflictsBetweenSubSelectionSets( + ValidationContext $context, + $areMutuallyExclusive, + $parentType1, + SelectionSetNode $selectionSet1, + $parentType2, + SelectionSetNode $selectionSet2 + ) { + $conflicts = []; + + list($fieldMap1, $fragmentNames1) = $this->getFieldsAndFragmentNames( + $context, + $parentType1, + $selectionSet1 + ); + list($fieldMap2, $fragmentNames2) = $this->getFieldsAndFragmentNames( + $context, + $parentType2, + $selectionSet2 + ); + + // (H) First, collect all conflicts between these two collections of field. + $this->collectConflictsBetween( + $context, + $conflicts, + $areMutuallyExclusive, + $fieldMap1, + $fieldMap2 + ); + + // (I) Then collect conflicts between the first collection of fields and + // those referenced by each fragment name associated with the second. + $fragmentNames2Length = count($fragmentNames2); + if ($fragmentNames2Length !== 0) { + $comparedFragments = []; + for ($j = 0; $j < $fragmentNames2Length; $j++) { + $this->collectConflictsBetweenFieldsAndFragment( + $context, + $conflicts, + $comparedFragments, + $areMutuallyExclusive, + $fieldMap1, + $fragmentNames2[$j] + ); + } + } + + // (I) Then collect conflicts between the second collection of fields and + // those referenced by each fragment name associated with the first. + $fragmentNames1Length = count($fragmentNames1); + if ($fragmentNames1Length !== 0) { + $comparedFragments = []; + for ($i = 0; $i < $fragmentNames2Length; $i++) { + $this->collectConflictsBetweenFieldsAndFragment( + $context, + $conflicts, + $comparedFragments, + $areMutuallyExclusive, + $fieldMap2, + $fragmentNames1[$i] + ); + } + } + + // (J) Also collect conflicts between any fragment names by the first and + // fragment names by the second. This compares each item in the first set of + // names to each item in the second set of names. + for ($i = 0; $i < $fragmentNames1Length; $i++) { + for ($j = 0; $j < $fragmentNames2Length; $j++) { + $this->collectConflictsBetweenFragments( + $context, + $conflicts, + $areMutuallyExclusive, + $fragmentNames1[$i], + $fragmentNames2[$j] + ); + } + } + return $conflicts; + } + + /** + * Collect all Conflicts "within" one collection of fields. + * + * @param ValidationContext $context + * @param array $conflicts + * @param array $fieldMap + */ + private function collectConflictsWithin( + ValidationContext $context, + array &$conflicts, + array $fieldMap + ) + { + // A field map is a keyed collection, where each key represents a response + // name and the value at that key is a list of all fields which provide that + // response name. For every response name, if there are multiple fields, they + // must be compared to find a potential conflict. foreach ($fieldMap as $responseName => $fields) { - $count = count($fields); - if ($count > 1) { - for ($i = 0; $i < $count; $i++) { - for ($j = $i; $j < $count; $j++) { + // This compares every field in the list to every other field in this list + // (except to itself). If the list only has one item, nothing needs to + // be compared. + $fieldsLength = count($fields); + if ($fieldsLength > 1) { + for ($i = 0; $i < $fieldsLength; $i++) { + for ($j = $i + 1; $j < $fieldsLength; $j++) { $conflict = $this->findConflict( - $parentFieldsAreMutuallyExclusive, + $context, + false, // within one collection is never mutually exclusive $responseName, $fields[$i], - $fields[$j], - $context + $fields[$j] ); - if ($conflict) { $conflicts[] = $conflict; } @@ -105,50 +494,77 @@ class OverlappingFieldsCanBeMerged extends AbstractValidationRule } } } - return $conflicts; } /** - * @param $parentFieldsAreMutuallyExclusive - * @param $responseName - * @param [FieldNode, GraphQLFieldDefinition] $pair1 - * @param [FieldNode, GraphQLFieldDefinition] $pair2 + * Collect all Conflicts between two collections of fields. This is similar to, + * but different from the `collectConflictsWithin` function above. This check + * assumes that `collectConflictsWithin` has already been called on each + * provided collection of fields. This is true because this validator traverses + * each individual selection set. + * * @param ValidationContext $context + * @param array $conflicts + * @param bool $parentFieldsAreMutuallyExclusive + * @param array $fieldMap1 + * @param array $fieldMap2 + */ + private function collectConflictsBetween( + ValidationContext $context, + array &$conflicts, + $parentFieldsAreMutuallyExclusive, + array $fieldMap1, + array $fieldMap2 + ) { + // A field map is a keyed collection, where each key represents a response + // name and the value at that key is a list of all fields which provide that + // response name. For any response name which appears in both provided field + // maps, each field from the first field map must be compared to every field + // in the second field map to find potential conflicts. + foreach ($fieldMap1 as $responseName => $fields1) { + if (isset($fieldMap2[$responseName])) { + $fields2 = $fieldMap2[$responseName]; + $fields1Length = count($fields1); + $fields2Length = count($fields2); + for ($i = 0; $i < $fields1Length; $i++) { + for ($j = 0; $j < $fields2Length; $j++) { + $conflict = $this->findConflict( + $context, + $parentFieldsAreMutuallyExclusive, + $responseName, + $fields1[$i], + $fields2[$j] + ); + if ($conflict) { + $conflicts[] = $conflict; + } + } + } + } + } + } + + /** + * Determines if there is a conflict between two particular fields, including + * comparing their sub-fields. + * + * @param ValidationContext $context + * @param bool $parentFieldsAreMutuallyExclusive + * @param string $responseName + * @param array $field1 + * @param array $field2 * @return array|null */ private function findConflict( + ValidationContext $context, $parentFieldsAreMutuallyExclusive, $responseName, - array $pair1, - array $pair2, - ValidationContext $context + array $field1, + array $field2 ) { - list($parentType1, $ast1, $def1) = $pair1; - list($parentType2, $ast2, $def2) = $pair2; - - // Not a pair. - if ($ast1 === $ast2) { - return null; - } - - // Memoize, do not report the same issue twice. - // Note: Two overlapping ASTs could be encountered both when - // `parentFieldsAreMutuallyExclusive` is true and is false, which could - // produce different results (when `true` being a subset of `false`). - // However we do not need to include this piece of information when - // memoizing since this rule visits leaf fields before their parent fields, - // ensuring that `parentFieldsAreMutuallyExclusive` is `false` the first - // time two overlapping fields are encountered, ensuring that the full - // set of validation rules are always checked when necessary. - if ($this->comparedSet->has($ast1, $ast2)) { - return null; - } - $this->comparedSet->add($ast1, $ast2); - - // The return type for each field. - $type1 = isset($def1) ? $def1->getType() : null; - $type2 = isset($def2) ? $def2->getType() : null; + list($parentType1, $ast1, $def1) = $field1; + list($parentType2, $ast2, $def2) = $field2; // If it is known that two fields could not possibly apply at the same // time, due to the parent types, then it is safe to permit them to diverge @@ -158,16 +574,20 @@ class OverlappingFieldsCanBeMerged extends AbstractValidationRule // different Object types. Interface or Union types might overlap - if not // in the current state of the schema, then perhaps in some future version, // thus may not safely diverge. - $fieldsAreMutuallyExclusive = + $areMutuallyExclusive = $parentFieldsAreMutuallyExclusive || $parentType1 !== $parentType2 && $parentType1 instanceof ObjectType && $parentType2 instanceof ObjectType; - if (!$fieldsAreMutuallyExclusive) { + // The return type for each field. + $type1 = $def1 ? $def1->getType() : null; + $type2 = $def2 ? $def2->getType() : null; + + if (!$areMutuallyExclusive) { + // Two aliases must refer to the same field. $name1 = $ast1->name->value; $name2 = $ast2->name->value; - if ($name1 !== $name2) { return [ [$responseName, "$name1 and $name2 are different fields"], @@ -176,10 +596,7 @@ class OverlappingFieldsCanBeMerged extends AbstractValidationRule ]; } - $args1 = isset($ast1->arguments) ? $ast1->arguments : []; - $args2 = isset($ast2->arguments) ? $ast2->arguments : []; - - if (!$this->sameArguments($args1, $args2)) { + if (!$this->sameArguments($ast1->arguments ?: [], $ast2->arguments ?: [])) { return [ [$responseName, 'they have differing arguments'], [$ast1], @@ -188,7 +605,6 @@ class OverlappingFieldsCanBeMerged extends AbstractValidationRule } } - if ($type1 && $type2 && $this->doTypesConflict($type1, $type2)) { return [ [$responseName, "they return conflicting types $type1 and $type2"], @@ -197,71 +613,77 @@ class OverlappingFieldsCanBeMerged extends AbstractValidationRule ]; } - $subfieldMap = $this->getSubfieldMap($ast1, $type1, $ast2, $type2, $context); - - if ($subfieldMap) { - $conflicts = $this->findConflicts($fieldsAreMutuallyExclusive, $subfieldMap, $context); - return $this->subfieldConflicts($conflicts, $responseName, $ast1, $ast2); - } - return null; - } - - private function getSubfieldMap( - FieldNode $ast1, - $type1, - FieldNode $ast2, - $type2, - ValidationContext $context - ) { + // Collect and compare sub-fields. Use the same "visited fragment names" list + // for both collections so fields in a fragment reference are never + // compared to themselves. $selectionSet1 = $ast1->selectionSet; $selectionSet2 = $ast2->selectionSet; if ($selectionSet1 && $selectionSet2) { - $visitedFragmentNames = new \ArrayObject(); - $subfieldMap = $this->collectFieldNodesAndDefs( + $conflicts = $this->findConflictsBetweenSubSelectionSets( $context, + $areMutuallyExclusive, Type::getNamedType($type1), $selectionSet1, - $visitedFragmentNames + Type::getNamedType($type2), + $selectionSet2 ); - $subfieldMap = $this->collectFieldNodesAndDefs( - $context, - Type::getNamedType($type2), - $selectionSet2, - $visitedFragmentNames, - $subfieldMap + return $this->subfieldConflicts( + $conflicts, + $responseName, + $ast1, + $ast2 ); - return $subfieldMap; } - } - private function subfieldConflicts( - array $conflicts, - $responseName, - FieldNode $ast1, - FieldNode $ast2 - ) - { - if (!empty($conflicts)) { - return [ - [ - $responseName, - Utils::map($conflicts, function($conflict) {return $conflict[0];}) - ], - array_reduce( - $conflicts, - function($allFields, $conflict) { return array_merge($allFields, $conflict[1]);}, - [ $ast1 ] - ), - array_reduce( - $conflicts, - function($allFields, $conflict) {return array_merge($allFields, $conflict[2]);}, - [ $ast2 ] - ) - ]; - } + return null; } /** + * @param ArgumentNode[] $arguments1 + * @param ArgumentNode[] $arguments2 + * + * @return bool + */ + private function sameArguments($arguments1, $arguments2) + { + if (count($arguments1) !== count($arguments2)) { + return false; + } + foreach ($arguments1 as $argument1) { + $argument2 = null; + foreach ($arguments2 as $argument) { + if ($argument->name->value === $argument1->name->value) { + $argument2 = $argument; + break; + } + } + if (!$argument2) { + return false; + } + + if (!$this->sameValue($argument1->value, $argument2->value)) { + return false; + } + } + + return true; + } + + /** + * @param Node $value1 + * @param Node $value2 + * @return bool + */ + private function sameValue(Node $value1, Node $value2) + { + return (!$value1 && !$value2) || (Printer::doPrint($value1) === Printer::doPrint($value2)); + } + + /** + * Two types conflict if both types could not apply to a value simultaneously. + * Composite types are ignored as their individual field types will be compared + * later recursively. However List and Non-Null types must match. + * * @param OutputType $type1 * @param OutputType $type2 * @return bool @@ -295,33 +717,93 @@ class OverlappingFieldsCanBeMerged extends AbstractValidationRule } /** - * Given a selectionSet, adds all of the fields in that selection to - * the passed in map of fields, and returns it at the end. - * - * Note: This is not the same as execution's collectFields because at static - * time we do not know what object type will be used, so we unconditionally - * spread in all fragments. + * Given a selection set, return the collection of fields (a mapping of response + * name to field ASTs and definitions) as well as a list of fragment names + * referenced via fragment spreads. * * @param ValidationContext $context - * @param mixed $parentType + * @param CompositeType $parentType * @param SelectionSetNode $selectionSet - * @param \ArrayObject $visitedFragmentNames - * @param \ArrayObject $astAndDefs - * @return mixed + * @return array */ - private function collectFieldNodesAndDefs(ValidationContext $context, $parentType, SelectionSetNode $selectionSet, \ArrayObject $visitedFragmentNames = null, \ArrayObject $astAndDefs = null) - { - $_visitedFragmentNames = $visitedFragmentNames ?: new \ArrayObject(); - $_astAndDefs = $astAndDefs ?: new \ArrayObject(); + private function getFieldsAndFragmentNames( + ValidationContext $context, + $parentType, + SelectionSetNode $selectionSet + ) { + if (!isset($this->cachedFieldsAndFragmentNames[$selectionSet])) { + $astAndDefs = []; + $fragmentNames = []; - for ($i = 0; $i < count($selectionSet->selections); $i++) { + $this->internalCollectFieldsAndFragmentNames( + $context, + $parentType, + $selectionSet, + $astAndDefs, + $fragmentNames + ); + $cached = [$astAndDefs, array_keys($fragmentNames)]; + $this->cachedFieldsAndFragmentNames[$selectionSet] = $cached; + } else { + $cached = $this->cachedFieldsAndFragmentNames[$selectionSet]; + } + return $cached; + } + + /** + * Given a reference to a fragment, return the represented collection of fields + * as well as a list of nested fragment names referenced via fragment spreads. + * + * @param ValidationContext $context + * @param FragmentDefinitionNode $fragment + * @return array|object + */ + private function getReferencedFieldsAndFragmentNames( + ValidationContext $context, + FragmentDefinitionNode $fragment + ) + { + // Short-circuit building a type from the AST if possible. + if (isset($this->cachedFieldsAndFragmentNames[$fragment->selectionSet])) { + return $this->cachedFieldsAndFragmentNames[$fragment->selectionSet]; + } + + $fragmentType = TypeInfo::typeFromAST($context->getSchema(), $fragment->typeCondition); + return $this->getFieldsAndFragmentNames( + $context, + $fragmentType, + $fragment->selectionSet + ); + } + + /** + * Given a reference to a fragment, return the represented collection of fields + * as well as a list of nested fragment names referenced via fragment spreads. + * + * @param ValidationContext $context + * @param CompositeType $parentType + * @param SelectionSetNode $selectionSet + * @param array $astAndDefs + * @param array $fragmentNames + */ + private function internalCollectFieldsAndFragmentNames( + ValidationContext $context, + $parentType, + SelectionSetNode $selectionSet, + array &$astAndDefs, + array &$fragmentNames + ) + { + $selectionSetLength = count($selectionSet->selections); + for ($i = 0; $i < $selectionSetLength; $i++) { $selection = $selectionSet->selections[$i]; - switch ($selection->kind) { - case NodeKind::FIELD: + switch (true) { + case $selection instanceof FieldNode: $fieldName = $selection->name->value; $fieldDef = null; - if ($parentType && method_exists($parentType, 'getFields')) { + if ($parentType instanceof ObjectType || + $parentType instanceof InterfaceType) { $tmp = $parentType->getFields(); if (isset($tmp[$fieldName])) { $fieldDef = $tmp[$fieldName]; @@ -329,86 +811,72 @@ class OverlappingFieldsCanBeMerged extends AbstractValidationRule } $responseName = $selection->alias ? $selection->alias->value : $fieldName; - if (!isset($_astAndDefs[$responseName])) { - $_astAndDefs[$responseName] = new \ArrayObject(); + if (!isset($astAndDefs[$responseName])) { + $astAndDefs[$responseName] = []; } - $_astAndDefs[$responseName][] = [$parentType, $selection, $fieldDef]; + $astAndDefs[$responseName][] = [$parentType, $selection, $fieldDef]; break; - case NodeKind::INLINE_FRAGMENT: + case $selection instanceof FragmentSpreadNode: + $fragmentNames[$selection->name->value] = true; + break; + case $selection instanceof InlineFragmentNode: $typeCondition = $selection->typeCondition; $inlineFragmentType = $typeCondition ? TypeInfo::typeFromAST($context->getSchema(), $typeCondition) : $parentType; - $_astAndDefs = $this->collectFieldNodesAndDefs( + $this->internalcollectFieldsAndFragmentNames( $context, $inlineFragmentType, $selection->selectionSet, - $_visitedFragmentNames, - $_astAndDefs - ); - break; - case NodeKind::FRAGMENT_SPREAD: - /** @var FragmentSpreadNode $selection */ - $fragName = $selection->name->value; - if (!empty($_visitedFragmentNames[$fragName])) { - continue; - } - $_visitedFragmentNames[$fragName] = true; - $fragment = $context->getFragment($fragName); - if (!$fragment) { - continue; - } - $fragmentType = TypeInfo::typeFromAST($context->getSchema(), $fragment->typeCondition); - $_astAndDefs = $this->collectFieldNodesAndDefs( - $context, - $fragmentType, - $fragment->selectionSet, - $_visitedFragmentNames, - $_astAndDefs + $astAndDefs, + $fragmentNames ); break; } } - return $_astAndDefs; } /** - * @param ArgumentNode[]|DirectiveNode[] $arguments1 - * @param ArgumentNode[]|DirectiveNode[] $arguments2 + * Given a series of Conflicts which occurred between two sub-fields, generate + * a single Conflict. * - * @return bool|string + * @param array $conflicts + * @param string $responseName + * @param FieldNode $ast1 + * @param FieldNode $ast2 + * @return array|null */ - private function sameArguments($arguments1, $arguments2) + private function subfieldConflicts( + array $conflicts, + $responseName, + FieldNode $ast1, + FieldNode $ast2 + ) { - if (count($arguments1) !== count($arguments2)) { - return false; + if (count($conflicts) > 0) { + return [ + [ + $responseName, + array_map(function ($conflict) { + return $conflict[0]; + }, $conflicts), + ], + array_reduce( + $conflicts, + function ($allFields, $conflict) { + return array_merge($allFields, $conflict[1]); + }, + [$ast1] + ), + array_reduce( + $conflicts, + function ($allFields, $conflict) { + return array_merge($allFields, $conflict[2]); + }, + [$ast2] + ), + ]; } - foreach ($arguments1 as $arg1) { - $arg2 = null; - foreach ($arguments2 as $arg) { - if ($arg->name->value === $arg1->name->value) { - $arg2 = $arg; - break; - } - } - if (!$arg2) { - return false; - } - if (!$this->sameValue($arg1->value, $arg2->value)) { - return false; - } - } - return true; - } - - private function sameValue($value1, $value2) - { - return (!$value1 && !$value2) || (Printer::doPrint($value1) === Printer::doPrint($value2)); - } - - function sameType($type1, $type2) - { - return (string) $type1 === (string) $type2; } } diff --git a/src/Validator/Rules/PossibleFragmentSpreads.php b/src/Validator/Rules/PossibleFragmentSpreads.php index 3e7332e..37a24dc 100644 --- a/src/Validator/Rules/PossibleFragmentSpreads.php +++ b/src/Validator/Rules/PossibleFragmentSpreads.php @@ -61,7 +61,13 @@ class PossibleFragmentSpreads extends AbstractValidationRule private function getFragmentType(ValidationContext $context, $name) { $frag = $context->getFragment($name); - return $frag ? TypeInfo::typeFromAST($context->getSchema(), $frag->typeCondition) : null; + if ($frag) { + $type = TypeInfo::typeFromAST($context->getSchema(), $frag->typeCondition); + if ($type instanceof CompositeType) { + return $type; + } + } + return null; } private function doTypesOverlap(Schema $schema, CompositeType $fragType, CompositeType $parentType) diff --git a/src/Validator/Rules/QueryComplexity.php b/src/Validator/Rules/QueryComplexity.php index f20334e..0b1d611 100644 --- a/src/Validator/Rules/QueryComplexity.php +++ b/src/Validator/Rules/QueryComplexity.php @@ -203,11 +203,21 @@ class QueryComplexity extends AbstractQuerySecurity $args = []; if ($fieldDef instanceof FieldDefinition) { - $variableValues = Values::getVariableValues( + $variableValuesResult = Values::getVariableValues( $this->context->getSchema(), $this->variableDefs, $rawVariableValues ); + + if ($variableValuesResult['errors']) { + throw new Error(implode("\n\n", array_map( + function ($error) { + return $error->getMessage(); + } + , $variableValuesResult['errors']))); + } + $variableValues = $variableValuesResult['coerced']; + $args = Values::getArgumentValues($fieldDef, $node, $variableValues); } @@ -220,12 +230,21 @@ class QueryComplexity extends AbstractQuerySecurity return false; } - $variableValues = Values::getVariableValues( + $variableValuesResult = Values::getVariableValues( $this->context->getSchema(), $this->variableDefs, $this->getRawVariableValues() ); + if ($variableValuesResult['errors']) { + throw new Error(implode("\n\n", array_map( + function ($error) { + return $error->getMessage(); + } + , $variableValuesResult['errors']))); + } + $variableValues = $variableValuesResult['coerced']; + if ($directiveNode->name->value === 'include') { $directive = Directive::includeDirective(); $directiveArgs = Values::getArgumentValues($directive, $directiveNode, $variableValues); diff --git a/src/Validator/Rules/ValuesOfCorrectType.php b/src/Validator/Rules/ValuesOfCorrectType.php new file mode 100644 index 0000000..c77c93b --- /dev/null +++ b/src/Validator/Rules/ValuesOfCorrectType.php @@ -0,0 +1,238 @@ + function(NullValueNode $node) use ($context) { + $type = $context->getInputType(); + if ($type instanceof NonNull) { + $context->reportError( + new Error( + self::badValueMessage((string) $type, Printer::doPrint($node)), + $node + ) + ); + } + }, + NodeKind::LST => function(ListValueNode $node) use ($context) { + // Note: TypeInfo will traverse into a list's item type, so look to the + // parent input type to check if it is a list. + $type = Type::getNullableType($context->getParentInputType()); + if (!$type instanceof ListOfType) { + $this->isValidScalar($context, $node); + return Visitor::skipNode(); + } + }, + NodeKind::OBJECT => function(ObjectValueNode $node) use ($context) { + // Note: TypeInfo will traverse into a list's item type, so look to the + // parent input type to check if it is a list. + $type = Type::getNamedType($context->getInputType()); + if (!$type instanceof InputObjectType) { + $this->isValidScalar($context, $node); + return Visitor::skipNode(); + } + // Ensure every required field exists. + $inputFields = $type->getFields(); + $nodeFields = iterator_to_array($node->fields); + $fieldNodeMap = array_combine( + array_map(function ($field) { return $field->name->value; }, $nodeFields), + array_values($nodeFields) + ); + foreach ($inputFields as $fieldName => $fieldDef) { + $fieldType = $fieldDef->getType(); + if (!isset($fieldNodeMap[$fieldName]) && $fieldType instanceof NonNull) { + $context->reportError( + new Error( + self::requiredFieldMessage($type->name, $fieldName, (string) $fieldType), + $node + ) + ); + } + } + }, + NodeKind::OBJECT_FIELD => function(ObjectFieldNode $node) use ($context) { + $parentType = Type::getNamedType($context->getParentInputType()); + $fieldType = $context->getInputType(); + if (!$fieldType && $parentType instanceof InputObjectType) { + $suggestions = Utils::suggestionList( + $node->name->value, + array_keys($parentType->getFields()) + ); + $didYouMean = $suggestions + ? "Did you mean " . Utils::orList($suggestions) . "?" + : null; + + $context->reportError( + new Error( + self::unknownFieldMessage($parentType->name, $node->name->value, $didYouMean), + $node + ) + ); + } + }, + NodeKind::ENUM => function(EnumValueNode $node) use ($context) { + $type = Type::getNamedType($context->getInputType()); + if (!$type instanceof EnumType) { + $this->isValidScalar($context, $node); + } else if (!$type->getValue($node->value)) { + $context->reportError( + new Error( + self::badValueMessage( + $type->name, + Printer::doPrint($node), + $this->enumTypeSuggestion($type, $node) + ), + $node + ) + ); + } + }, + NodeKind::INT => function (IntValueNode $node) use ($context) { $this->isValidScalar($context, $node); }, + NodeKind::FLOAT => function (FloatValueNode $node) use ($context) { $this->isValidScalar($context, $node); }, + NodeKind::STRING => function (StringValueNode $node) use ($context) { $this->isValidScalar($context, $node); }, + NodeKind::BOOLEAN => function (BooleanValueNode $node) use ($context) { $this->isValidScalar($context, $node); }, + ]; + } + + private function isValidScalar(ValidationContext $context, ValueNode $node) + { + // Report any error at the full type expected by the location. + $locationType = $context->getInputType(); + + if (!$locationType) { + return; + } + + $type = Type::getNamedType($locationType); + + if (!$type instanceof ScalarType) { + $context->reportError( + new Error( + self::badValueMessage( + (string) $locationType, + Printer::doPrint($node), + $this->enumTypeSuggestion($type, $node) + ), + $node + ) + ); + return; + } + + // Scalars determine if a literal value is valid via parseLiteral() which + // may throw or return an invalid value to indicate failure. + try { + $parseResult = $type->parseLiteral($node); + if (Utils::isInvalid($parseResult)) { + $context->reportError( + new Error( + self::badValueMessage( + (string) $locationType, + Printer::doPrint($node) + ), + $node + ) + ); + } + } catch (\Exception $error) { + // Ensure a reference to the original error is maintained. + $context->reportError( + new Error( + self::badValueMessage( + (string) $locationType, + Printer::doPrint($node), + $error->getMessage() + ), + $node, + null, + null, + null, + $error + ) + ); + } catch (\Throwable $error) { + // Ensure a reference to the original error is maintained. + $context->reportError( + new Error( + self::badValueMessage( + (string) $locationType, + Printer::doPrint($node), + $error->getMessage() + ), + $node, + null, + null, + null, + $error + ) + ); + } + } + + private function enumTypeSuggestion($type, ValueNode $node) + { + if ($type instanceof EnumType) { + $suggestions = Utils::suggestionList( + Printer::doPrint($node), + array_map(function (EnumValueDefinition $value) { + return $value->name; + }, $type->getValues()) + ); + + return $suggestions ? 'Did you mean the enum value ' . Utils::orList($suggestions) . '?' : null; + } + } +} diff --git a/src/Validator/Rules/VariablesDefaultValueAllowed.php b/src/Validator/Rules/VariablesDefaultValueAllowed.php new file mode 100644 index 0000000..fcbbef4 --- /dev/null +++ b/src/Validator/Rules/VariablesDefaultValueAllowed.php @@ -0,0 +1,60 @@ + function(VariableDefinitionNode $node) use ($context) { + $name = $node->variable->name->value; + $defaultValue = $node->defaultValue; + $type = $context->getInputType(); + if ($type instanceof NonNull && $defaultValue) { + $context->reportError( + new Error( + self::defaultForRequiredVarMessage( + $name, + $type, + $type->getWrappedType() + ), + [$defaultValue] + ) + ); + } + + return Visitor::skipNode(); + }, + NodeKind::SELECTION_SET => function(SelectionSetNode $node) use ($context) { + return Visitor::skipNode(); + }, + NodeKind::FRAGMENT_DEFINITION => function(FragmentDefinitionNode $node) use ($context) { + return Visitor::skipNode(); + }, + ]; + } +} diff --git a/src/Validator/ValidationContext.php b/src/Validator/ValidationContext.php index 07d3268..4d82ce4 100644 --- a/src/Validator/ValidationContext.php +++ b/src/Validator/ValidationContext.php @@ -12,11 +12,9 @@ use GraphQL\Error\Error; use GraphQL\Type\Schema; use GraphQL\Language\AST\DocumentNode; use GraphQL\Language\AST\FragmentDefinitionNode; -use GraphQL\Language\AST\Node; use GraphQL\Type\Definition\CompositeType; use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\InputType; -use GraphQL\Type\Definition\OutputType; use GraphQL\Type\Definition\Type; use GraphQL\Utils\TypeInfo; @@ -124,7 +122,7 @@ class ValidationContext } /** - * @param $name + * @param string $name * @return FragmentDefinitionNode|null */ function getFragment($name) @@ -275,6 +273,14 @@ class ValidationContext return $this->typeInfo->getInputType(); } + /** + * @return InputType + */ + function getParentInputType() + { + return $this->typeInfo->getParentInputType(); + } + /** * @return FieldDefinition */ diff --git a/tests/ErrorTest.php b/tests/Error/ErrorTest.php similarity index 74% rename from tests/ErrorTest.php rename to tests/Error/ErrorTest.php index 5104a42..3843835 100644 --- a/tests/ErrorTest.php +++ b/tests/Error/ErrorTest.php @@ -1,5 +1,5 @@ assertEquals([new SourceLocation(2, 7)], $e->getLocations()); } + /** + * @it converts single node to positions and locations + */ + public function testConvertSingleNodeToPositionsAndLocations() + { + $source = new Source('{ + field + }'); + $ast = Parser::parse($source); + $fieldNode = $ast->definitions[0]->selectionSet->selections[0]; + $e = new Error('msg', $fieldNode); // Non-array value. + + $this->assertEquals([$fieldNode], $e->nodes); + $this->assertEquals($source, $e->getSource()); + $this->assertEquals([8], $e->getPositions()); + $this->assertEquals([new SourceLocation(2, 7)], $e->getLocations()); + } + /** * @it converts node with loc.start === 0 to positions and locations */ @@ -110,4 +128,23 @@ class ErrorTest extends \PHPUnit_Framework_TestCase $this->assertEquals([ 'path', 3, 'to', 'field' ], $e->path); $this->assertEquals(['message' => 'msg', 'path' => [ 'path', 3, 'to', 'field' ]], $e->toSerializableArray()); } + + /** + * @it default error formatter includes extension fields + */ + public function testDefaultErrorFormatterIncludesExtensionFields() + { + $e = new Error( + 'msg', + null, + null, + null, + null, + null, + ['foo' => 'bar'] + ); + + $this->assertEquals(['foo' => 'bar'], $e->getExtensions()); + $this->assertEquals(['message' => 'msg', 'foo' => 'bar'], $e->toSerializableArray()); + } } diff --git a/tests/Error/PrintErrorTest.php b/tests/Error/PrintErrorTest.php new file mode 100644 index 0000000..aa4affc --- /dev/null +++ b/tests/Error/PrintErrorTest.php @@ -0,0 +1,61 @@ +definitions[0]->fields[0]->type; + + $sourceB = Parser::parse(new Source('type Foo { + field: Int +}', + 'SourceB' + )); + + $fieldTypeB = $sourceB->definitions[0]->fields[0]->type; + + + $error = new Error( + 'Example error with two nodes', + [ + $fieldTypeA, + $fieldTypeB, + ] + ); + + $this->assertEquals( + 'Example error with two nodes + +SourceA (2:10) +1: type Foo { +2: field: String + ^ +3: } + +SourceB (2:10) +1: type Foo { +2: field: Int + ^ +3: } +', + FormattedError::printError($error) + ); + } +} diff --git a/tests/Executor/AbstractPromiseTest.php b/tests/Executor/AbstractPromiseTest.php index 5b0f576..7652d7e 100644 --- a/tests/Executor/AbstractPromiseTest.php +++ b/tests/Executor/AbstractPromiseTest.php @@ -87,9 +87,7 @@ class AbstractPromiseTest extends \PHPUnit_Framework_TestCase } }'; - Warning::suppress(Warning::WARNING_FULL_SCHEMA_SCAN); $result = GraphQL::execute($schema, $query); - Warning::enable(Warning::WARNING_FULL_SCHEMA_SCAN); $expected = [ 'data' => [ @@ -174,9 +172,7 @@ class AbstractPromiseTest extends \PHPUnit_Framework_TestCase } }'; - Warning::suppress(Warning::WARNING_FULL_SCHEMA_SCAN); $result = GraphQL::execute($schema, $query); - Warning::enable(Warning::WARNING_FULL_SCHEMA_SCAN); $expected = [ 'data' => [ diff --git a/tests/Executor/ExecutorTest.php b/tests/Executor/ExecutorTest.php index c38045e..bd6a16d 100644 --- a/tests/Executor/ExecutorTest.php +++ b/tests/Executor/ExecutorTest.php @@ -965,14 +965,14 @@ class ExecutorTest extends \PHPUnit_Framework_TestCase } /** - * @it fails to execute a query containing a type definition + * @it executes ignoring invalid non-executable definitions */ - public function testFailsToExecuteQueryContainingTypeDefinition() + public function testExecutesIgnoringInvalidNonExecutableDefinitions() { $query = Parser::parse(' { foo } - type Query { foo: String } + type Query { bar: String } '); $schema = new Schema([ @@ -988,12 +988,9 @@ class ExecutorTest extends \PHPUnit_Framework_TestCase $result = Executor::execute($schema, $query); $expected = [ - 'errors' => [ - [ - 'message' => 'GraphQL cannot execute a request containing a ObjectTypeDefinition.', - 'locations' => [['line' => 4, 'column' => 7]], - ] - ] + 'data' => [ + 'foo' => null, + ], ]; $this->assertArraySubset($expected, $result->toArray()); diff --git a/tests/Executor/TestClasses.php b/tests/Executor/TestClasses.php index 6e53b50..ef86938 100644 --- a/tests/Executor/TestClasses.php +++ b/tests/Executor/TestClasses.php @@ -2,6 +2,7 @@ namespace GraphQL\Tests\Executor; use GraphQL\Type\Definition\ScalarType; +use GraphQL\Utils\Utils; class Dog { @@ -65,15 +66,15 @@ class ComplexScalar extends ScalarType if ($value === 'SerializedValue') { return 'DeserializedValue'; } - return null; + return Utils::undefined(); } - public function parseLiteral($valueNode) + public function parseLiteral($valueNode, array $variables = null) { if ($valueNode->value === 'SerializedValue') { return 'DeserializedValue'; } - return null; + return Utils::undefined(); } } diff --git a/tests/Executor/UnionInterfaceTest.php b/tests/Executor/UnionInterfaceTest.php index 8fc3d8a..d2b0f14 100644 --- a/tests/Executor/UnionInterfaceTest.php +++ b/tests/Executor/UnionInterfaceTest.php @@ -256,9 +256,7 @@ class UnionInterfaceTest extends \PHPUnit_Framework_TestCase ] ]; - Warning::suppress(Warning::WARNING_FULL_SCHEMA_SCAN); $this->assertEquals($expected, Executor::execute($this->schema, $ast, $this->john)->toArray()); - Warning::enable(Warning::WARNING_FULL_SCHEMA_SCAN); } /** @@ -294,9 +292,7 @@ class UnionInterfaceTest extends \PHPUnit_Framework_TestCase ] ]; - Warning::suppress(Warning::WARNING_FULL_SCHEMA_SCAN); - $this->assertEquals($expected, Executor::execute($this->schema, $ast, $this->john)->toArray()); - Warning::enable(Warning::WARNING_FULL_SCHEMA_SCAN); + $this->assertEquals($expected, Executor::execute($this->schema, $ast, $this->john)->toArray(true)); } /** @@ -351,9 +347,7 @@ class UnionInterfaceTest extends \PHPUnit_Framework_TestCase ] ]; - Warning::suppress(Warning::WARNING_FULL_SCHEMA_SCAN); $this->assertEquals($expected, Executor::execute($this->schema, $ast, $this->john)->toArray()); - Warning::enable(Warning::WARNING_FULL_SCHEMA_SCAN); } /** diff --git a/tests/Executor/ValuesTest.php b/tests/Executor/ValuesTest.php index d50fb86..1fe81cf 100644 --- a/tests/Executor/ValuesTest.php +++ b/tests/Executor/ValuesTest.php @@ -12,197 +12,162 @@ use GraphQL\Type\Schema; class ValuesTest extends \PHPUnit_Framework_TestCase { - public function testGetIDVariableValues() - { - $this->expectInputVariablesMatchOutputVariables(['idInput' => '123456789']); - $this->assertEquals( - ['idInput' => '123456789'], - self::runTestCase(['idInput' => 123456789]), - 'Integer ID was not converted to string' - ); - } + public function testGetIDVariableValues() + { + $this->expectInputVariablesMatchOutputVariables(['idInput' => '123456789']); + $this->assertEquals( + ['errors'=> [], 'coerced' => ['idInput' => '123456789']], + self::runTestCase(['idInput' => 123456789]), + 'Integer ID was not converted to string' + ); + } - public function testGetBooleanVariableValues() - { - $this->expectInputVariablesMatchOutputVariables(['boolInput' => true]); - $this->expectInputVariablesMatchOutputVariables(['boolInput' => false]); - } + public function testGetBooleanVariableValues() + { + $this->expectInputVariablesMatchOutputVariables(['boolInput' => true]); + $this->expectInputVariablesMatchOutputVariables(['boolInput' => false]); + } - public function testGetIntVariableValues() - { - $this->expectInputVariablesMatchOutputVariables(['intInput' => -1]); - $this->expectInputVariablesMatchOutputVariables(['intInput' => 0]); - $this->expectInputVariablesMatchOutputVariables(['intInput' => 1]); + public function testGetIntVariableValues() + { + $this->expectInputVariablesMatchOutputVariables(['intInput' => -1]); + $this->expectInputVariablesMatchOutputVariables(['intInput' => 0]); + $this->expectInputVariablesMatchOutputVariables(['intInput' => 1]); - // Test the int size limit - $this->expectInputVariablesMatchOutputVariables(['intInput' => 2147483647]); - $this->expectInputVariablesMatchOutputVariables(['intInput' => -2147483648]); - } + // Test the int size limit + $this->expectInputVariablesMatchOutputVariables(['intInput' => 2147483647]); + $this->expectInputVariablesMatchOutputVariables(['intInput' => -2147483648]); + } - public function testGetStringVariableValues() - { - $this->expectInputVariablesMatchOutputVariables(['stringInput' => 'meow']); - $this->expectInputVariablesMatchOutputVariables(['stringInput' => '']); - $this->expectInputVariablesMatchOutputVariables(['stringInput' => '1']); - $this->expectInputVariablesMatchOutputVariables(['stringInput' => '0']); - $this->expectInputVariablesMatchOutputVariables(['stringInput' => 'false']); - $this->expectInputVariablesMatchOutputVariables(['stringInput' => '1.2']); - } + public function testGetStringVariableValues() + { + $this->expectInputVariablesMatchOutputVariables(['stringInput' => 'meow']); + $this->expectInputVariablesMatchOutputVariables(['stringInput' => '']); + $this->expectInputVariablesMatchOutputVariables(['stringInput' => '1']); + $this->expectInputVariablesMatchOutputVariables(['stringInput' => '0']); + $this->expectInputVariablesMatchOutputVariables(['stringInput' => 'false']); + $this->expectInputVariablesMatchOutputVariables(['stringInput' => '1.2']); + } - public function testGetFloatVariableValues() - { - $this->expectInputVariablesMatchOutputVariables(['floatInput' => 1.2]); - $this->expectInputVariablesMatchOutputVariables(['floatInput' => 1.0]); - $this->expectInputVariablesMatchOutputVariables(['floatInput' => 1]); - $this->expectInputVariablesMatchOutputVariables(['floatInput' => 0]); - $this->expectInputVariablesMatchOutputVariables(['floatInput' => 1e3]); - } + public function testGetFloatVariableValues() + { + $this->expectInputVariablesMatchOutputVariables(['floatInput' => 1.2]); + $this->expectInputVariablesMatchOutputVariables(['floatInput' => 1.0]); + $this->expectInputVariablesMatchOutputVariables(['floatInput' => 1]); + $this->expectInputVariablesMatchOutputVariables(['floatInput' => 0]); + $this->expectInputVariablesMatchOutputVariables(['floatInput' => 1e3]); + } - public function testBooleanForIDVariableThrowsError() - { - $this->expectGraphQLError(['idInput' => true]); - } + public function testBooleanForIDVariableThrowsError() + { + $this->expectGraphQLError(['idInput' => true]); + } - public function testFloatForIDVariableThrowsError() - { - $this->expectGraphQLError(['idInput' => 1.0]); - } + public function testFloatForIDVariableThrowsError() + { + $this->expectGraphQLError(['idInput' => 1.0]); + } - public function testStringForBooleanVariableThrowsError() - { - $this->expectGraphQLError(['boolInput' => 'true']); - } + public function testStringForBooleanVariableThrowsError() + { + $this->expectGraphQLError(['boolInput' => 'true']); + } - public function testIntForBooleanVariableThrowsError() - { - $this->expectGraphQLError(['boolInput' => 1]); - } + public function testIntForBooleanVariableThrowsError() + { + $this->expectGraphQLError(['boolInput' => 1]); + } - public function testFloatForBooleanVariableThrowsError() - { - $this->expectGraphQLError(['boolInput' => 1.0]); - } + public function testFloatForBooleanVariableThrowsError() + { + $this->expectGraphQLError(['boolInput' => 1.0]); + } - public function testBooleanForIntVariableThrowsError() - { - $this->expectGraphQLError(['intInput' => true]); - } + public function testStringForIntVariableThrowsError() + { + $this->expectGraphQLError(['intInput' => 'true']); + } - public function testStringForIntVariableThrowsError() - { - $this->expectGraphQLError(['intInput' => 'true']); - } + public function testPositiveBigIntForIntVariableThrowsError() + { + $this->expectGraphQLError(['intInput' => 2147483648]); + } - public function testFloatForIntVariableThrowsError() - { - $this->expectGraphQLError(['intInput' => 1.0]); - } + public function testNegativeBigIntForIntVariableThrowsError() + { + $this->expectGraphQLError(['intInput' => -2147483649]); + } - public function testPositiveBigIntForIntVariableThrowsError() - { - $this->expectGraphQLError(['intInput' => 2147483648]); - } + // Helpers for running test cases and making assertions - public function testNegativeBigIntForIntVariableThrowsError() - { - $this->expectGraphQLError(['intInput' => -2147483649]); - } + private function expectInputVariablesMatchOutputVariables($variables) + { + $this->assertEquals( + $variables, + self::runTestCase($variables)['coerced'], + 'Output variables did not match input variables' . PHP_EOL . var_export($variables, true) . PHP_EOL + ); + } - public function testBooleanForStringVariableThrowsError() - { - $this->expectGraphQLError(['stringInput' => true]); - } + private function expectGraphQLError($variables) + { + $result = self::runTestCase($variables); + $this->assertGreaterThan(0, count($result['errors'])); + } - public function testIntForStringVariableThrowsError() - { - $this->expectGraphQLError(['stringInput' => 1]); - } + private static $schema; - public function testFloatForStringVariableThrowsError() - { - $this->expectGraphQLError(['stringInput' => 1.0]); - } + private static function getSchema() + { + if (!self::$schema) { + self::$schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'test' => [ + 'type' => Type::boolean(), + 'args' => [ + 'idInput' => Type::id(), + 'boolInput' => Type::boolean(), + 'intInput' => Type::int(), + 'stringInput' => Type::string(), + 'floatInput' => Type::float() + ] + ], + ] + ]) + ]); + } + return self::$schema; + } - public function testBooleanForFloatVariableThrowsError() - { - $this->expectGraphQLError(['floatInput' => true]); - } + private static function getVariableDefinitionNodes() + { + $idInputDefinition = new VariableDefinitionNode([ + 'variable' => new VariableNode(['name' => new NameNode(['value' => 'idInput'])]), + 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'ID'])]) + ]); + $boolInputDefinition = new VariableDefinitionNode([ + 'variable' => new VariableNode(['name' => new NameNode(['value' => 'boolInput'])]), + 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'Boolean'])]) + ]); + $intInputDefinition = new VariableDefinitionNode([ + 'variable' => new VariableNode(['name' => new NameNode(['value' => 'intInput'])]), + 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'Int'])]) + ]); + $stringInputDefintion = new VariableDefinitionNode([ + 'variable' => new VariableNode(['name' => new NameNode(['value' => 'stringInput'])]), + 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'String'])]) + ]); + $floatInputDefinition = new VariableDefinitionNode([ + 'variable' => new VariableNode(['name' => new NameNode(['value' => 'floatInput'])]), + 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'Float'])]) + ]); + return [$idInputDefinition, $boolInputDefinition, $intInputDefinition, $stringInputDefintion, $floatInputDefinition]; + } - public function testStringForFloatVariableThrowsError() - { - $this->expectGraphQLError(['floatInput' => '1.0']); - } - - // Helpers for running test cases and making assertions - - private function expectInputVariablesMatchOutputVariables($variables) - { - $this->assertEquals( - $variables, - self::runTestCase($variables), - 'Output variables did not match input variables' . PHP_EOL . var_export($variables, true) . PHP_EOL - ); - } - - private function expectGraphQLError($variables) - { - $this->setExpectedException(\GraphQL\Error\Error::class); - self::runTestCase($variables); - } - - private static $schema; - - private static function getSchema() - { - if (!self::$schema) { - self::$schema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'test' => [ - 'type' => Type::boolean(), - 'args' => [ - 'idInput' => Type::id(), - 'boolInput' => Type::boolean(), - 'intInput' => Type::int(), - 'stringInput' => Type::string(), - 'floatInput' => Type::float() - ] - ], - ] - ]) - ]); - } - return self::$schema; - } - - private static function getVariableDefinitionNodes() - { - $idInputDefinition = new VariableDefinitionNode([ - 'variable' => new VariableNode(['name' => new NameNode(['value' => 'idInput'])]), - 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'ID'])]) - ]); - $boolInputDefinition = new VariableDefinitionNode([ - 'variable' => new VariableNode(['name' => new NameNode(['value' => 'boolInput'])]), - 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'Boolean'])]) - ]); - $intInputDefinition = new VariableDefinitionNode([ - 'variable' => new VariableNode(['name' => new NameNode(['value' => 'intInput'])]), - 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'Int'])]) - ]); - $stringInputDefintion = new VariableDefinitionNode([ - 'variable' => new VariableNode(['name' => new NameNode(['value' => 'stringInput'])]), - 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'String'])]) - ]); - $floatInputDefinition = new VariableDefinitionNode([ - 'variable' => new VariableNode(['name' => new NameNode(['value' => 'floatInput'])]), - 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'Float'])]) - ]); - return [$idInputDefinition, $boolInputDefinition, $intInputDefinition, $stringInputDefintion, $floatInputDefinition]; - } - - private function runTestCase($variables) - { - return Values::getVariableValues(self::getSchema(), self::getVariableDefinitionNodes(), $variables); - } -} \ No newline at end of file + private function runTestCase($variables) + { + return Values::getVariableValues(self::getSchema(), self::getVariableDefinitionNodes(), $variables); + } +} diff --git a/tests/Executor/VariablesTest.php b/tests/Executor/VariablesTest.php index 418ed12..89c931f 100644 --- a/tests/Executor/VariablesTest.php +++ b/tests/Executor/VariablesTest.php @@ -3,7 +3,7 @@ namespace GraphQL\Tests\Executor; require_once __DIR__ . '/TestClasses.php'; -use GraphQL\Error\InvariantViolation; +use GraphQL\Error\Error; use GraphQL\Executor\Executor; use GraphQL\Language\Parser; use GraphQL\Type\Schema; @@ -81,9 +81,9 @@ class VariablesTest extends \PHPUnit_Framework_TestCase $expected = [ 'data' => ['fieldWithObjectInput' => null], 'errors' => [[ - 'message' => 'Argument "input" got invalid value ["foo", "bar", "baz"].' . "\n" . - 'Expected "TestInputObject", found not an object.', - 'path' => ['fieldWithObjectInput'] + 'message' => 'Argument "input" has invalid value ["foo", "bar", "baz"].', + 'path' => ['fieldWithObjectInput'], + 'locations' => [['line' => 3, 'column' => 39]] ]] ]; $this->assertArraySubset($expected, $result); @@ -134,7 +134,7 @@ class VariablesTest extends \PHPUnit_Framework_TestCase ]; $this->assertEquals($expected, $result); - // properly parses single value to array: + // properly parses single value to list: $params = ['input' => ['a' => 'foo', 'b' => 'bar', 'c' => 'baz']]; $this->assertEquals( ['data' => ['fieldWithObjectInput' => '{"a":"foo","b":["bar"],"c":"baz"}']], @@ -158,13 +158,15 @@ class VariablesTest extends \PHPUnit_Framework_TestCase 'errors' => [ [ 'message' => - 'Variable "$input" got invalid value {"a":"foo","b":"bar","c":null}.'. "\n". - 'In field "c": Expected "String!", found null.', + 'Variable "$input" got invalid value ' . + '{"a":"foo","b":"bar","c":null}; ' . + 'Expected non-nullable type String! not to be null at value.c.', 'locations' => [['line' => 2, 'column' => 17]], 'category' => 'graphql' ] ] ]; + $this->assertEquals($expected, $result->toArray()); // errors on incorrect type: @@ -174,8 +176,8 @@ class VariablesTest extends \PHPUnit_Framework_TestCase 'errors' => [ [ 'message' => - 'Variable "$input" got invalid value "foo bar".' . "\n" . - 'Expected "TestInputObject", found not an object.', + 'Variable "$input" got invalid value "foo bar"; ' . + 'Expected type TestInputObject to be an object.', 'locations' => [ [ 'line' => 2, 'column' => 17 ] ], 'category' => 'graphql', ] @@ -191,8 +193,8 @@ class VariablesTest extends \PHPUnit_Framework_TestCase 'errors' => [ [ 'message' => - 'Variable "$input" got invalid value {"a":"foo","b":"bar"}.'. "\n". - 'In field "c": Expected "String!", found null.', + 'Variable "$input" got invalid value {"a":"foo","b":"bar"}; '. + 'Field value.c of required type String! was not provided.', 'locations' => [['line' => 2, 'column' => 17]], 'category' => 'graphql', ] @@ -214,9 +216,15 @@ class VariablesTest extends \PHPUnit_Framework_TestCase 'errors' => [ [ 'message' => - 'Variable "$input" got invalid value {"na":{"a":"foo"}}.' . "\n" . - 'In field "na": In field "c": Expected "String!", found null.' . "\n" . - 'In field "nb": Expected "String!", found null.', + 'Variable "$input" got invalid value {"na":{"a":"foo"}}; ' . + 'Field value.na.c of required type String! was not provided.', + 'locations' => [['line' => 2, 'column' => 19]], + 'category' => 'graphql', + ], + [ + 'message' => + 'Variable "$input" got invalid value {"na":{"a":"foo"}}; ' . + 'Field value.nb of required type String! was not provided.', 'locations' => [['line' => 2, 'column' => 19]], 'category' => 'graphql', ] @@ -226,14 +234,15 @@ class VariablesTest extends \PHPUnit_Framework_TestCase // errors on addition of unknown input field - $params = ['input' => [ 'a' => 'foo', 'b' => 'bar', 'c' => 'baz', 'd' => 'dog' ]]; + $params = ['input' => [ 'a' => 'foo', 'b' => 'bar', 'c' => 'baz', 'extra' => 'dog' ]]; $result = Executor::execute($schema, $ast, null, null, $params); $expected = [ 'errors' => [ [ 'message' => - 'Variable "$input" got invalid value {"a":"foo","b":"bar","c":"baz","d":"dog"}.'."\n". - 'In field "d": Expected type "ComplexScalar", found "dog".', + 'Variable "$input" got invalid value ' . + '{"a":"foo","b":"bar","c":"baz","extra":"dog"}; ' . + 'Field "extra" is not defined by type TestInputObject.', 'locations' => [['line' => 2, 'column' => 17]], 'category' => 'graphql', ] @@ -401,8 +410,8 @@ class VariablesTest extends \PHPUnit_Framework_TestCase 'errors' => [ [ 'message' => - 'Variable "$value" got invalid value null.' . "\n". - 'Expected "String!", found null.', + 'Variable "$value" got invalid value null; ' . + 'Expected non-nullable type String! not to be null.', 'locations' => [['line' => 2, 'column' => 31]], 'category' => 'graphql', ] @@ -482,8 +491,8 @@ class VariablesTest extends \PHPUnit_Framework_TestCase $expected = [ 'errors' => [[ 'message' => - 'Variable "$value" got invalid value [1,2,3].' . "\n" . - 'Expected type "String", found array(3).', + 'Variable "$value" got invalid value [1,2,3]; Expected type ' . + 'String; String cannot represent an array value: [1,2,3]', 'category' => 'graphql', 'locations' => [ ['line' => 2, 'column' => 31] @@ -491,7 +500,9 @@ class VariablesTest extends \PHPUnit_Framework_TestCase ]] ]; - $this->assertEquals($expected, Executor::execute($this->schema(), $ast, null, null, $variables)->toArray()); + $result = Executor::execute($this->schema(), $ast, null, null, $variables)->toArray(true); + + $this->assertEquals($expected, $result); } /** @@ -500,8 +511,8 @@ class VariablesTest extends \PHPUnit_Framework_TestCase public function testSerializingAnArrayViaGraphQLStringThrowsTypeError() { $this->setExpectedException( - InvariantViolation::class, - 'String cannot represent non scalar value: array(3)' + Error::class, + 'String cannot represent non scalar value: [1,2,3]' ); Type::string()->serialize([1, 2, 3]); } @@ -601,8 +612,8 @@ class VariablesTest extends \PHPUnit_Framework_TestCase 'errors' => [ [ 'message' => - 'Variable "$input" got invalid value null.' . "\n" . - 'Expected "[String]!", found null.', + 'Variable "$input" got invalid value null; ' . + 'Expected non-nullable type [String]! not to be null.', 'locations' => [['line' => 2, 'column' => 17]], 'category' => 'graphql', ] @@ -623,7 +634,7 @@ class VariablesTest extends \PHPUnit_Framework_TestCase '; $ast = Parser::parse($doc); $expected = ['data' => ['nnList' => '["A"]']]; - $this->assertEquals($expected, Executor::execute($this->schema(), $ast, null, null, ['input' => 'A'])->toArray()); + $this->assertEquals($expected, Executor::execute($this->schema(), $ast, null, null, ['input' => ['A']])->toArray()); } /** @@ -670,7 +681,7 @@ class VariablesTest extends \PHPUnit_Framework_TestCase $ast = Parser::parse($doc); $expected = ['data' => ['listNN' => '["A"]']]; - $this->assertEquals($expected, Executor::execute($this->schema(), $ast, null, null, ['input' => 'A'])->toArray()); + $this->assertEquals($expected, Executor::execute($this->schema(), $ast, null, null, ['input' => ['A']])->toArray()); } /** @@ -689,8 +700,8 @@ class VariablesTest extends \PHPUnit_Framework_TestCase 'errors' => [ [ 'message' => - 'Variable "$input" got invalid value ["A",null,"B"].' . "\n" . - 'In element #1: Expected "String!", found null.', + 'Variable "$input" got invalid value ["A",null,"B"]; ' . + 'Expected non-nullable type String! not to be null at value[1].', 'locations' => [ ['line' => 2, 'column' => 17] ], 'category' => 'graphql', ] @@ -715,8 +726,8 @@ class VariablesTest extends \PHPUnit_Framework_TestCase 'errors' => [ [ 'message' => - 'Variable "$input" got invalid value null.' . "\n" . - 'Expected "[String!]!", found null.', + 'Variable "$input" got invalid value null; ' . + 'Expected non-nullable type [String!]! not to be null.', 'locations' => [ ['line' => 2, 'column' => 17] ], 'category' => 'graphql', ] @@ -756,8 +767,8 @@ class VariablesTest extends \PHPUnit_Framework_TestCase 'errors' => [ [ 'message' => - 'Variable "$input" got invalid value ["A",null,"B"].'."\n". - 'In element #1: Expected "String!", found null.', + 'Variable "$input" got invalid value ["A",null,"B"]; ' . + 'Expected non-nullable type String! not to be null at value[1].', 'locations' => [ ['line' => 2, 'column' => 17] ], 'category' => 'graphql', ] @@ -865,8 +876,7 @@ class VariablesTest extends \PHPUnit_Framework_TestCase 'data' => ['fieldWithDefaultArgumentValue' => null], 'errors' => [[ 'message' => - 'Argument "input" got invalid value WRONG_TYPE.' . "\n" . - 'Expected type "String", found WRONG_TYPE.', + 'Argument "input" has invalid value WRONG_TYPE.', 'locations' => [ [ 'line' => 2, 'column' => 50 ] ], 'path' => [ 'fieldWithDefaultArgumentValue' ], 'category' => 'graphql', diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index e818fa2..4d62b5e 100644 --- a/tests/Language/LexerTest.php +++ b/tests/Language/LexerTest.php @@ -15,10 +15,11 @@ class LexerTest extends \PHPUnit_Framework_TestCase */ public function testDissallowsUncommonControlCharacters() { - $char = Utils::chr(0x0007); - - $this->setExpectedExceptionRegExp(SyntaxError::class, '/' . preg_quote('Syntax Error GraphQL (1:1) Cannot contain the invalid character "\u0007"', '/') . '/'); - $this->lexOne($char); + $this->expectSyntaxError( + Utils::chr(0x0007), + 'Cannot contain the invalid character "\u0007"', + $this->loc(1, 1) + ); } /** @@ -107,14 +108,21 @@ class LexerTest extends \PHPUnit_Framework_TestCase " ?\n" . "\n"; - $this->setExpectedException(SyntaxError::class, - 'Syntax Error GraphQL (3:5) Cannot parse the unexpected character "?".' . "\n" . - "\n" . - "2: \n" . - "3: ?\n" . - " ^\n" . - "4: \n"); - $this->lexOne($str); + try { + $this->lexOne($str); + $this->fail('Expected exception not thrown'); + } catch (SyntaxError $error) { + $this->assertEquals( + 'Syntax Error: Cannot parse the unexpected character "?".' . "\n" . + "\n" . + "GraphQL request (3:5)\n" . + "2: \n" . + "3: ?\n" . + " ^\n" . + "4: \n", + (string) $error + ); + } } /** @@ -129,34 +137,42 @@ class LexerTest extends \PHPUnit_Framework_TestCase "\n"; $source = new Source($str, 'foo.js', new SourceLocation(11, 12)); - $this->setExpectedException( - SyntaxError::class, - 'Syntax Error foo.js (13:6) ' . - 'Cannot parse the unexpected character "?".' . "\n" . - "\n" . - '12: ' . "\n" . - '13: ?' . "\n" . - ' ^' . "\n" . - '14: ' . "\n" - ); - $lexer = new Lexer($source); - $lexer->advance(); + try { + $lexer = new Lexer($source); + $lexer->advance(); + $this->fail('Expected exception not thrown'); + } catch (SyntaxError $error) { + $this->assertEquals( + 'Syntax Error: Cannot parse the unexpected character "?".' . "\n" . + "\n" . + "foo.js (13:6)\n" . + "12: \n" . + "13: ?\n" . + " ^\n" . + "14: \n", + (string) $error + ); + } } public function testUpdatesColumnNumbersInErrorForFileContext() { $source = new Source('?', 'foo.js', new SourceLocation(1, 5)); - $this->setExpectedException( - SyntaxError::class, - 'Syntax Error foo.js (1:5) ' . - 'Cannot parse the unexpected character "?".' . "\n" . - "\n" . - '1: ?' . "\n" . - ' ^' . "\n" - ); - $lexer = new Lexer($source); - $lexer->advance(); + try { + $lexer = new Lexer($source); + $lexer->advance(); + $this->fail('Expected exception not thrown'); + } catch (SyntaxError $error) { + $this->assertEquals( + 'Syntax Error: Cannot parse the unexpected character "?".' . "\n" . + "\n" . + "foo.js (1:5)\n" . + '1: ?' . "\n" . + ' ^' . "\n", + (string) $error + ); + } } /** @@ -223,33 +239,125 @@ class LexerTest extends \PHPUnit_Framework_TestCase ], (array) $this->lexOne('"\u1234\u5678\u90AB\uCDEF"')); } - public function reportsUsefulErrors() { + /** + * @it lexes block strings + */ + public function testLexesBlockString() + { + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 12, + 'value' => 'simple' + ], (array) $this->lexOne('"""simple"""')); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 19, + 'value' => ' white space ' + ], (array) $this->lexOne('""" white space """')); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 22, + 'value' => 'contains " quote' + ], (array) $this->lexOne('"""contains " quote"""')); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 31, + 'value' => 'contains """ triplequote' + ], (array) $this->lexOne('"""contains \\""" triplequote"""')); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 16, + 'value' => "multi\nline" + ], (array) $this->lexOne("\"\"\"multi\nline\"\"\"")); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 28, + 'value' => "multi\nline\nnormalized" + ], (array) $this->lexOne("\"\"\"multi\rline\r\nnormalized\"\"\"")); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 32, + 'value' => 'unescaped \\n\\r\\b\\t\\f\\u1234' + ], (array) $this->lexOne('"""unescaped \\n\\r\\b\\t\\f\\u1234"""')); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 19, + 'value' => 'slashes \\\\ \\/' + ], (array) $this->lexOne('"""slashes \\\\ \\/"""')); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 68, + 'value' => "spans\n multiple\n lines" + ], (array) $this->lexOne("\"\"\" + + spans + multiple + lines + + \"\"\"")); + } + + public function reportsUsefulStringErrors() { return [ - ['"', "Syntax Error GraphQL (1:2) Unterminated string.\n\n1: \"\n ^\n"], - ['"no end quote', "Syntax Error GraphQL (1:14) Unterminated string.\n\n1: \"no end quote\n ^\n"], - ["'single quotes'", "Syntax Error GraphQL (1:1) Unexpected single quote character ('), did you mean to use a double quote (\")?\n\n1: 'single quotes'\n ^\n"], - ['"contains unescaped \u0007 control char"', "Syntax Error GraphQL (1:21) Invalid character within String: \"\\u0007\"\n\n1: \"contains unescaped \\u0007 control char\"\n ^\n"], - ['"null-byte is not \u0000 end of file"', 'Syntax Error GraphQL (1:19) Invalid character within String: "\\u0000"' . "\n\n1: \"null-byte is not \\u0000 end of file\"\n ^\n"], - ['"multi' . "\n" . 'line"', "Syntax Error GraphQL (1:7) Unterminated string.\n\n1: \"multi\n ^\n2: line\"\n"], - ['"multi' . "\r" . 'line"', "Syntax Error GraphQL (1:7) Unterminated string.\n\n1: \"multi\n ^\n2: line\"\n"], - ['"bad \\z esc"', "Syntax Error GraphQL (1:7) Invalid character escape sequence: \\z\n\n1: \"bad \\z esc\"\n ^\n"], - ['"bad \\x esc"', "Syntax Error GraphQL (1:7) Invalid character escape sequence: \\x\n\n1: \"bad \\x esc\"\n ^\n"], - ['"bad \\u1 esc"', "Syntax Error GraphQL (1:7) Invalid character escape sequence: \\u1 es\n\n1: \"bad \\u1 esc\"\n ^\n"], - ['"bad \\u0XX1 esc"', "Syntax Error GraphQL (1:7) Invalid character escape sequence: \\u0XX1\n\n1: \"bad \\u0XX1 esc\"\n ^\n"], - ['"bad \\uXXXX esc"', "Syntax Error GraphQL (1:7) Invalid character escape sequence: \\uXXXX\n\n1: \"bad \\uXXXX esc\"\n ^\n"], - ['"bad \\uFXXX esc"', "Syntax Error GraphQL (1:7) Invalid character escape sequence: \\uFXXX\n\n1: \"bad \\uFXXX esc\"\n ^\n"], - ['"bad \\uXXXF esc"', "Syntax Error GraphQL (1:7) Invalid character escape sequence: \\uXXXF\n\n1: \"bad \\uXXXF esc\"\n ^\n"], + ['"', "Unterminated string.", $this->loc(1, 2)], + ['"no end quote', "Unterminated string.", $this->loc(1, 14)], + ["'single quotes'", "Unexpected single quote character ('), did you mean to use a double quote (\")?", $this->loc(1, 1)], + ['"contains unescaped \u0007 control char"', "Invalid character within String: \"\\u0007\"", $this->loc(1, 21)], + ['"null-byte is not \u0000 end of file"', 'Invalid character within String: "\\u0000"', $this->loc(1, 19)], + ['"multi' . "\n" . 'line"', "Unterminated string.", $this->loc(1, 7)], + ['"multi' . "\r" . 'line"', "Unterminated string.", $this->loc(1, 7)], + ['"bad \\z esc"', "Invalid character escape sequence: \\z", $this->loc(1, 7)], + ['"bad \\x esc"', "Invalid character escape sequence: \\x", $this->loc(1, 7)], + ['"bad \\u1 esc"', "Invalid character escape sequence: \\u1 es", $this->loc(1, 7)], + ['"bad \\u0XX1 esc"', "Invalid character escape sequence: \\u0XX1", $this->loc(1, 7)], + ['"bad \\uXXXX esc"', "Invalid character escape sequence: \\uXXXX", $this->loc(1, 7)], + ['"bad \\uFXXX esc"', "Invalid character escape sequence: \\uFXXX", $this->loc(1, 7)], + ['"bad \\uXXXF esc"', "Invalid character escape sequence: \\uXXXF", $this->loc(1, 7)], ]; } /** - * @dataProvider reportsUsefulErrors + * @dataProvider reportsUsefulStringErrors * @it lex reports useful string errors */ - public function testReportsUsefulErrors($str, $expectedMessage) + public function testLexReportsUsefulStringErrors($str, $expectedMessage, $location) { - $this->setExpectedException(SyntaxError::class, $expectedMessage); - $this->lexOne($str); + $this->expectSyntaxError($str, $expectedMessage, $location); + } + + public function reportsUsefulBlockStringErrors() { + return [ + ['"""', "Unterminated string.", $this->loc(1, 4)], + ['"""no end quote', "Unterminated string.", $this->loc(1, 16)], + ['"""contains unescaped ' . json_decode('"\u0007"') . ' control char"""', "Invalid character within String: \"\\u0007\"", $this->loc(1, 23)], + ['"""null-byte is not ' . json_decode('"\u0000"') . ' end of file"""', "Invalid character within String: \"\\u0000\"", $this->loc(1, 21)], + ]; + } + + /** + * @dataProvider reportsUsefulBlockStringErrors + * @it lex reports useful block string errors + */ + public function testReportsUsefulBlockStringErrors($str, $expectedMessage, $location) + { + $this->expectSyntaxError($str, $expectedMessage, $location); } /** @@ -326,14 +434,15 @@ class LexerTest extends \PHPUnit_Framework_TestCase public function reportsUsefulNumberErrors() { return [ - [ '00', "Syntax Error GraphQL (1:2) Invalid number, unexpected digit after 0: \"0\"\n\n1: 00\n ^\n"], - [ '+1', "Syntax Error GraphQL (1:1) Cannot parse the unexpected character \"+\".\n\n1: +1\n ^\n"], - [ '1.', "Syntax Error GraphQL (1:3) Invalid number, expected digit but got: \n\n1: 1.\n ^\n"], - [ '.123', "Syntax Error GraphQL (1:1) Cannot parse the unexpected character \".\".\n\n1: .123\n ^\n"], - [ '1.A', "Syntax Error GraphQL (1:3) Invalid number, expected digit but got: \"A\"\n\n1: 1.A\n ^\n"], - [ '-A', "Syntax Error GraphQL (1:2) Invalid number, expected digit but got: \"A\"\n\n1: -A\n ^\n"], - [ '1.0e', "Syntax Error GraphQL (1:5) Invalid number, expected digit but got: \n\n1: 1.0e\n ^\n"], - [ '1.0eA', "Syntax Error GraphQL (1:5) Invalid number, expected digit but got: \"A\"\n\n1: 1.0eA\n ^\n"], + [ '00', "Invalid number, unexpected digit after 0: \"0\"", $this->loc(1, 2)], + [ '+1', "Cannot parse the unexpected character \"+\".", $this->loc(1, 1)], + [ '1.', "Invalid number, expected digit but got: ", $this->loc(1, 3)], + [ '1.e1', "Invalid number, expected digit but got: \"e\"", $this->loc(1, 3)], + [ '.123', "Cannot parse the unexpected character \".\".", $this->loc(1, 1)], + [ '1.A', "Invalid number, expected digit but got: \"A\"", $this->loc(1, 3)], + [ '-A', "Invalid number, expected digit but got: \"A\"", $this->loc(1, 2)], + [ '1.0e', "Invalid number, expected digit but got: ", $this->loc(1, 5)], + [ '1.0eA', "Invalid number, expected digit but got: \"A\"", $this->loc(1, 5)], ]; } @@ -341,10 +450,9 @@ class LexerTest extends \PHPUnit_Framework_TestCase * @dataProvider reportsUsefulNumberErrors * @it lex reports useful number errors */ - public function testReportsUsefulNumberErrors($str, $expectedMessage) + public function testReportsUsefulNumberErrors($str, $expectedMessage, $location) { - $this->setExpectedException(SyntaxError::class, $expectedMessage); - $this->lexOne($str); + $this->expectSyntaxError($str, $expectedMessage, $location); } /** @@ -412,10 +520,10 @@ class LexerTest extends \PHPUnit_Framework_TestCase $unicode2 = json_decode('"\u200b"'); return [ - ['..', "Syntax Error GraphQL (1:1) Cannot parse the unexpected character \".\".\n\n1: ..\n ^\n"], - ['?', "Syntax Error GraphQL (1:1) Cannot parse the unexpected character \"?\".\n\n1: ?\n ^\n"], - [$unicode1, "Syntax Error GraphQL (1:1) Cannot parse the unexpected character \"\\u203b\".\n\n1: $unicode1\n ^\n"], - [$unicode2, "Syntax Error GraphQL (1:1) Cannot parse the unexpected character \"\\u200b\".\n\n1: $unicode2\n ^\n"], + ['..', "Cannot parse the unexpected character \".\".", $this->loc(1, 1)], + ['?', "Cannot parse the unexpected character \"?\".", $this->loc(1, 1)], + [$unicode1, "Cannot parse the unexpected character \"\\u203b\".", $this->loc(1, 1)], + [$unicode2, "Cannot parse the unexpected character \"\\u200b\".", $this->loc(1, 1)], ]; } @@ -423,10 +531,9 @@ class LexerTest extends \PHPUnit_Framework_TestCase * @dataProvider reportsUsefulUnknownCharErrors * @it lex reports useful unknown character error */ - public function testReportsUsefulUnknownCharErrors($str, $expectedMessage) + public function testReportsUsefulUnknownCharErrors($str, $expectedMessage, $location) { - $this->setExpectedException(SyntaxError::class, $expectedMessage); - $this->lexOne($str); + $this->expectSyntaxError($str, $expectedMessage, $location); } /** @@ -438,8 +545,14 @@ class LexerTest extends \PHPUnit_Framework_TestCase $lexer = new Lexer(new Source($q)); $this->assertArraySubset(['kind' => Token::NAME, 'start' => 0, 'end' => 1, 'value' => 'a'], (array) $lexer->advance()); - $this->setExpectedException(SyntaxError::class, 'Syntax Error GraphQL (1:3) Invalid number, expected digit but got: "b"' . "\n\n1: a-b\n ^\n"); - $lexer->advance(); + $this->setExpectedException(SyntaxError::class, 'Syntax Error: Invalid number, expected digit but got: "b"'); + try { + $lexer->advance(); + $this->fail('Expected exception not thrown'); + } catch(SyntaxError $error) { + $this->assertEquals([$this->loc(1,3)], $error->getLocations()); + throw $error; + } } /** @@ -493,4 +606,20 @@ class LexerTest extends \PHPUnit_Framework_TestCase $lexer = new Lexer(new Source($body)); return $lexer->advance(); } + + private function loc($line, $column) + { + return new SourceLocation($line, $column); + } + + private function expectSyntaxError($text, $message, $location) + { + $this->setExpectedException(SyntaxError::class, $message); + try { + $this->lexOne($text); + } catch (SyntaxError $error) { + $this->assertEquals([$location], $error->getLocations()); + throw $error; + } + } } diff --git a/tests/Language/ParserTest.php b/tests/Language/ParserTest.php index d901bf6..b34cd9a 100644 --- a/tests/Language/ParserTest.php +++ b/tests/Language/ParserTest.php @@ -39,13 +39,13 @@ class ParserTest extends \PHPUnit_Framework_TestCase public function parseProvidesUsefulErrors() { return [ - ['{', "Syntax Error GraphQL (1:2) Expected Name, found \n\n1: {\n ^\n", [1], [new SourceLocation(1, 2)]], + ['{', "Syntax Error: Expected Name, found ", "Syntax Error: Expected Name, found \n\nGraphQL request (1:2)\n1: {\n ^\n", [1], [new SourceLocation(1, 2)]], ['{ ...MissingOn } fragment MissingOn Type -', "Syntax Error GraphQL (2:20) Expected \"on\", found Name \"Type\"\n\n1: { ...MissingOn }\n2: fragment MissingOn Type\n ^\n3: \n",], - ['{ field: {} }', "Syntax Error GraphQL (1:10) Expected Name, found {\n\n1: { field: {} }\n ^\n"], - ['notanoperation Foo { field }', "Syntax Error GraphQL (1:1) Unexpected Name \"notanoperation\"\n\n1: notanoperation Foo { field }\n ^\n"], - ['...', "Syntax Error GraphQL (1:1) Unexpected ...\n\n1: ...\n ^\n"], +', "Syntax Error: Expected \"on\", found Name \"Type\"", "Syntax Error: Expected \"on\", found Name \"Type\"\n\nGraphQL request (2:20)\n1: { ...MissingOn }\n2: fragment MissingOn Type\n ^\n3: \n",], + ['{ field: {} }', "Syntax Error: Expected Name, found {", "Syntax Error: Expected Name, found {\n\nGraphQL request (1:10)\n1: { field: {} }\n ^\n"], + ['notanoperation Foo { field }', "Syntax Error: Unexpected Name \"notanoperation\"", "Syntax Error: Unexpected Name \"notanoperation\"\n\nGraphQL request (1:1)\n1: notanoperation Foo { field }\n ^\n"], + ['...', "Syntax Error: Unexpected ...", "Syntax Error: Unexpected ...\n\nGraphQL request (1:1)\n1: ...\n ^\n"], ]; } @@ -53,13 +53,14 @@ fragment MissingOn Type * @dataProvider parseProvidesUsefulErrors * @it parse provides useful errors */ - public function testParseProvidesUsefulErrors($str, $expectedMessage, $expectedPositions = null, $expectedLocations = null) + public function testParseProvidesUsefulErrors($str, $expectedMessage, $stringRepresentation, $expectedPositions = null, $expectedLocations = null) { try { Parser::parse($str); $this->fail('Expected exception not thrown'); } catch (SyntaxError $e) { $this->assertEquals($expectedMessage, $e->getMessage()); + $this->assertEquals($stringRepresentation, (string) $e); if ($expectedPositions) { $this->assertEquals($expectedPositions, $e->getPositions()); @@ -76,8 +77,15 @@ fragment MissingOn Type */ public function testParseProvidesUsefulErrorWhenUsingSource() { - $this->setExpectedException(SyntaxError::class, "Syntax Error MyQuery.graphql (1:6) Expected {, found \n\n1: query\n ^\n"); - Parser::parse(new Source('query', 'MyQuery.graphql')); + try { + Parser::parse(new Source('query', 'MyQuery.graphql')); + $this->fail('Expected exception not thrown'); + } catch (SyntaxError $error) { + $this->assertEquals( + "Syntax Error: Expected {, found \n\nMyQuery.graphql (1:6)\n1: query\n ^\n", + (string) $error + ); + } } /** @@ -94,8 +102,11 @@ fragment MissingOn Type */ public function testParsesConstantDefaultValues() { - $this->setExpectedException(SyntaxError::class, "Syntax Error GraphQL (1:37) Unexpected $\n\n" . '1: query Foo($x: Complex = { a: { b: [ $var ] } }) { field }' . "\n ^\n"); - Parser::parse('query Foo($x: Complex = { a: { b: [ $var ] } }) { field }'); + $this->expectSyntaxError( + 'query Foo($x: Complex = { a: { b: [ $var ] } }) { field }', + 'Unexpected $', + $this->loc(1,37) + ); } /** @@ -103,8 +114,11 @@ fragment MissingOn Type */ public function testDoesNotAcceptFragmentsNamedOn() { - $this->setExpectedException('GraphQL\Error\SyntaxError', 'Syntax Error GraphQL (1:10) Unexpected Name "on"'); - Parser::parse('fragment on on on { on }'); + $this->expectSyntaxError( + 'fragment on on on { on }', + 'Unexpected Name "on"', + $this->loc(1,10) + ); } /** @@ -112,8 +126,11 @@ fragment MissingOn Type */ public function testDoesNotAcceptFragmentSpreadOfOn() { - $this->setExpectedException('GraphQL\Error\SyntaxError', 'Syntax Error GraphQL (1:9) Expected Name, found }'); - Parser::parse('{ ...on }'); + $this->expectSyntaxError( + '{ ...on }', + 'Expected Name, found }', + $this->loc(1,9) + ); } /** @@ -276,7 +293,7 @@ fragment $fragmentName on Type { 'loc' => $loc(0, 40), 'operation' => 'query', 'name' => null, - 'variableDefinitions' => null, + 'variableDefinitions' => [], 'directives' => [], 'selectionSet' => [ 'kind' => NodeKind::SELECTION_SET, @@ -350,6 +367,81 @@ fragment $fragmentName on Type { $this->assertEquals($expected, $this->nodeToArray($result)); } + /** + * @it creates ast from nameless query without variables + */ + public function testParseCreatesAstFromNamelessQueryWithoutVariables() + { + $source = new Source('query { + node { + id + } +} +'); + $result = Parser::parse($source); + + $loc = function($start, $end) use ($source) { + return [ + 'start' => $start, + 'end' => $end + ]; + }; + + $expected = [ + 'kind' => NodeKind::DOCUMENT, + 'loc' => $loc(0, 30), + 'definitions' => [ + [ + 'kind' => NodeKind::OPERATION_DEFINITION, + 'loc' => $loc(0, 29), + 'operation' => 'query', + 'name' => null, + 'variableDefinitions' => [], + 'directives' => [], + 'selectionSet' => [ + 'kind' => NodeKind::SELECTION_SET, + 'loc' => $loc(6, 29), + 'selections' => [ + [ + 'kind' => NodeKind::FIELD, + 'loc' => $loc(10, 27), + 'alias' => null, + 'name' => [ + 'kind' => NodeKind::NAME, + 'loc' => $loc(10, 14), + 'value' => 'node' + ], + 'arguments' => [], + 'directives' => [], + 'selectionSet' => [ + 'kind' => NodeKind::SELECTION_SET, + 'loc' => $loc(15, 27), + 'selections' => [ + [ + 'kind' => NodeKind::FIELD, + 'loc' => $loc(21, 23), + 'alias' => null, + 'name' => [ + 'kind' => NodeKind::NAME, + 'loc' => $loc(21, 23), + 'value' => 'id' + ], + 'arguments' => [], + 'directives' => [], + 'selectionSet' => null + ] + ] + ] + ] + ] + ] + ] + ] + ]; + + $this->assertEquals($expected, $this->nodeToArray($result)); + } + /** * @it allows parsing without source location information */ @@ -361,10 +453,23 @@ fragment $fragmentName on Type { $this->assertEquals(null, $result->loc); } + /** + * @it Experimental: allows parsing fragment defined variables + */ + public function testExperimentalAllowsParsingFragmentDefinedVariables() + { + $source = new Source('fragment a($v: Boolean = false) on t { f(v: $v) }'); + // not throw + Parser::parse($source, ['experimentalFragmentVariables' => true]); + + $this->setExpectedException(SyntaxError::class); + Parser::parse($source); + } + /** * @it contains location information that only stringifys start/end */ - public function testConvertToArray() + public function testContainsLocationInformationThatOnlyStringifysStartEnd() { $source = new Source('{ id }'); $result = Parser::parse($source); @@ -422,7 +527,8 @@ fragment $fragmentName on Type { [ 'kind' => NodeKind::STRING, 'loc' => ['start' => 5, 'end' => 10], - 'value' => 'abc' + 'value' => 'abc', + 'block' => false ] ] ], $this->nodeToArray(Parser::parseValue('[123 "abc"]'))); @@ -534,4 +640,20 @@ fragment $fragmentName on Type { { return TestUtils::nodeToArray($node); } + + private function loc($line, $column) + { + return new SourceLocation($line, $column); + } + + private function expectSyntaxError($text, $message, $location) + { + $this->setExpectedException(SyntaxError::class, $message); + try { + Parser::parse($text); + } catch (SyntaxError $error) { + $this->assertEquals([$location], $error->getLocations()); + throw $error; + } + } } diff --git a/tests/Language/PrinterTest.php b/tests/Language/PrinterTest.php index 8b59910..9d4cce2 100644 --- a/tests/Language/PrinterTest.php +++ b/tests/Language/PrinterTest.php @@ -92,6 +92,87 @@ class PrinterTest extends \PHPUnit_Framework_TestCase $this->assertEquals($expected, Printer::doPrint($mutationAstWithArtifacts)); } + /** + * @it correctly prints single-line block strings with leading space + */ + public function testCorrectlyPrintsSingleLineBlockStringsWithLeadingSpace() + { + $mutationAstWithArtifacts = Parser::parse( + '{ field(arg: """ space-led value""") }' + ); + $expected = '{ + field(arg: """ space-led value""") +} +'; + $this->assertEquals($expected, Printer::doPrint($mutationAstWithArtifacts)); + } + + /** + * @it correctly prints block strings with a first line indentation + */ + public function testCorrectlyPrintsBlockStringsWithAFirstLineIndentation() + { + $mutationAstWithArtifacts = Parser::parse( + '{ + field(arg: """ + first + line + indentation + """) +}' + ); + $expected = '{ + field(arg: """ + first + line + indentation + """) +} +'; + $this->assertEquals($expected, Printer::doPrint($mutationAstWithArtifacts)); + } + + /** + * @it Experimental: correctly prints fragment defined variables + */ + public function testExperimentalCorrectlyPrintsFragmentDefinedVariables() + { + $fragmentWithVariable = Parser::parse(' + fragment Foo($a: ComplexType, $b: Boolean = false) on TestType { + id + } + ', + ['experimentalFragmentVariables' => true] + ); + + $this->assertEquals( + Printer::doPrint($fragmentWithVariable), + 'fragment Foo($a: ComplexType, $b: Boolean = false) on TestType { + id +} +' + ); + } + + /** + * @it correctly prints single-line with leading space and quotation + */ + public function testCorrectlyPrintsSingleLineStringsWithLeadingSpaceAndQuotation() + { + $mutationAstWithArtifacts = Parser::parse( + '{ + field(arg: """ space-led value "quoted string" + """) +}' + ); + $expected = '{ + field(arg: """ space-led value "quoted string" + """) +} +'; + $this->assertEquals($expected, Printer::doPrint($mutationAstWithArtifacts)); + } + /** * @it prints kitchen sink */ @@ -146,7 +227,9 @@ subscription StoryLikeSubscription($input: StoryLikeSubscribeInput) { } fragment frag on Friend { - foo(size: $size, bar: $b, obj: {key: "value"}) + foo(size: $size, bar: $b, obj: {key: "value", block: """ + block string uses \""" + """}) } { diff --git a/tests/Language/SchemaParserTest.php b/tests/Language/SchemaParserTest.php index 7b0324e..b7d018a 100644 --- a/tests/Language/SchemaParserTest.php +++ b/tests/Language/SchemaParserTest.php @@ -4,6 +4,7 @@ namespace GraphQL\Tests\Language; use GraphQL\Error\SyntaxError; use GraphQL\Language\AST\NodeKind; use GraphQL\Language\Parser; +use GraphQL\Language\SourceLocation; class SchemaParserTest extends \PHPUnit_Framework_TestCase { @@ -45,6 +46,93 @@ type Hello { $this->assertEquals($expected, TestUtils::nodeToArray($doc)); } + /** + * @it parses type with description string + */ + public function testParsesTypeWithDescriptionString() + { + $body = ' +"Description" +type Hello { + world: String +}'; + $doc = Parser::parse($body); + $loc = function($start, $end) {return TestUtils::locArray($start, $end);}; + + $expected = [ + 'kind' => NodeKind::DOCUMENT, + 'definitions' => [ + [ + 'kind' => NodeKind::OBJECT_TYPE_DEFINITION, + 'name' => $this->nameNode('Hello', $loc(20, 25)), + 'interfaces' => [], + 'directives' => [], + 'fields' => [ + $this->fieldNode( + $this->nameNode('world', $loc(30, 35)), + $this->typeNode('String', $loc(37, 43)), + $loc(30, 43) + ) + ], + 'loc' => $loc(1, 45), + 'description' => [ + 'kind' => NodeKind::STRING, + 'value' => 'Description', + 'loc' => $loc(1, 14), + 'block' => false + ] + ] + ], + 'loc' => $loc(0, 45) + ]; + $this->assertEquals($expected, TestUtils::nodeToArray($doc)); + } + + /** + * @it parses type with description multi-linestring + */ + public function testParsesTypeWithDescriptionMultiLineString() + { + $body = ' +""" +Description +""" +# Even with comments between them +type Hello { + world: String +}'; + $doc = Parser::parse($body); + $loc = function($start, $end) {return TestUtils::locArray($start, $end);}; + + $expected = [ + 'kind' => NodeKind::DOCUMENT, + 'definitions' => [ + [ + 'kind' => NodeKind::OBJECT_TYPE_DEFINITION, + 'name' => $this->nameNode('Hello', $loc(60, 65)), + 'interfaces' => [], + 'directives' => [], + 'fields' => [ + $this->fieldNode( + $this->nameNode('world', $loc(70, 75)), + $this->typeNode('String', $loc(77, 83)), + $loc(70, 83) + ) + ], + 'loc' => $loc(1, 85), + 'description' => [ + 'kind' => NodeKind::STRING, + 'value' => 'Description', + 'loc' => $loc(1, 20), + 'block' => true + ] + ] + ], + 'loc' => $loc(0, 85) + ]; + $this->assertEquals($expected, TestUtils::nodeToArray($doc)); + } + /** * @it Simple extension */ @@ -63,21 +151,16 @@ extend type Hello { 'kind' => NodeKind::DOCUMENT, 'definitions' => [ [ - 'kind' => NodeKind::TYPE_EXTENSION_DEFINITION, - 'definition' => [ - 'kind' => NodeKind::OBJECT_TYPE_DEFINITION, - 'name' => $this->nameNode('Hello', $loc(13, 18)), - 'interfaces' => [], - 'directives' => [], - 'fields' => [ - $this->fieldNode( - $this->nameNode('world', $loc(23, 28)), - $this->typeNode('String', $loc(30, 36)), - $loc(23, 36) - ) - ], - 'loc' => $loc(8, 38), - 'description' => null + 'kind' => NodeKind::OBJECT_TYPE_EXTENSION, + 'name' => $this->nameNode('Hello', $loc(13, 18)), + 'interfaces' => [], + 'directives' => [], + 'fields' => [ + $this->fieldNode( + $this->nameNode('world', $loc(23, 28)), + $this->typeNode('String', $loc(30, 36)), + $loc(23, 36) + ) ], 'loc' => $loc(1, 38) ] @@ -87,6 +170,81 @@ extend type Hello { $this->assertEquals($expected, TestUtils::nodeToArray($doc)); } + /** + * @it Extension without fields + */ + public function testExtensionWithoutFields() + { + $body = 'extend type Hello implements Greeting'; + $doc = Parser::parse($body); + $loc = function($start, $end) { + return TestUtils::locArray($start, $end); + }; + $expected = [ + 'kind' => NodeKind::DOCUMENT, + 'definitions' => [ + [ + 'kind' => NodeKind::OBJECT_TYPE_EXTENSION, + 'name' => $this->nameNode('Hello', $loc(12, 17)), + 'interfaces' => [ + $this->typeNode('Greeting', $loc(29, 37)), + ], + 'directives' => [], + 'fields' => [], + 'loc' => $loc(0, 37) + ] + ], + 'loc' => $loc(0, 37) + ]; + $this->assertEquals($expected, TestUtils::nodeToArray($doc)); + } + + /** + * @it Extension without anything throws + */ + public function testExtensionWithoutAnythingThrows() + { + $this->expectSyntaxError( + 'extend type Hello', + 'Unexpected ', + $this->loc(1, 18) + ); + } + + /** + * @it Extension do not include descriptions + */ + public function testExtensionDoNotIncludeDescriptions() + { + $body = ' + "Description" + extend type Hello { + world: String + }'; + $this->expectSyntaxError( + $body, + 'Unexpected Name "extend"', + $this->loc(3, 7) + ); + } + + /** + * @it Extension do not include descriptions + */ + public function testExtensionDoNotIncludeDescriptions2() + { + $body = ' + extend "Description" type Hello { + world: String + } +}'; + $this->expectSyntaxError( + $body, + 'Unexpected String "Description"', + $this->loc(2, 14) + ); + } + /** * @it Simple non-null type */ @@ -135,7 +293,7 @@ type Hello { */ public function testSimpleTypeInheritingInterface() { - $body = 'type Hello implements World { }'; + $body = 'type Hello implements World { field: String }'; $loc = function($start, $end) { return TestUtils::locArray($start, $end); }; $doc = Parser::parse($body); @@ -149,12 +307,18 @@ type Hello { $this->typeNode('World', $loc(22, 27)) ], 'directives' => [], - 'fields' => [], - 'loc' => $loc(0,31), + 'fields' => [ + $this->fieldNode( + $this->nameNode('field', $loc(30, 35)), + $this->typeNode('String', $loc(37, 43)), + $loc(30, 43) + ) + ], + 'loc' => $loc(0, 45), 'description' => null ] ], - 'loc' => $loc(0,31) + 'loc' => $loc(0, 45) ]; $this->assertEquals($expected, TestUtils::nodeToArray($doc)); @@ -165,7 +329,7 @@ type Hello { */ public function testSimpleTypeInheritingMultipleInterfaces() { - $body = 'type Hello implements Wo, rld { }'; + $body = 'type Hello implements Wo, rld { field: String }'; $loc = function($start, $end) {return TestUtils::locArray($start, $end);}; $doc = Parser::parse($body); @@ -180,12 +344,18 @@ type Hello { $this->typeNode('rld', $loc(26,29)) ], 'directives' => [], - 'fields' => [], - 'loc' => $loc(0, 33), + 'fields' => [ + $this->fieldNode( + $this->nameNode('field', $loc(32, 37)), + $this->typeNode('String', $loc(39, 45)), + $loc(32, 45) + ) + ], + 'loc' => $loc(0, 47), 'description' => null ] ], - 'loc' => $loc(0, 33) + 'loc' => $loc(0, 47) ]; $this->assertEquals($expected, TestUtils::nodeToArray($doc)); @@ -556,9 +726,11 @@ type Hello { */ public function testUnionFailsWithNoTypes() { - $body = 'union Hello = |'; - $this->setExpectedExceptionRegExp(SyntaxError::class, '/' . preg_quote('Syntax Error GraphQL (1:16) Expected Name, found ', '/') . '/'); - Parser::parse($body); + $this->expectSyntaxError( + 'union Hello = |', + 'Expected Name, found ', + $this->loc(1, 16) + ); } /** @@ -566,9 +738,11 @@ type Hello { */ public function testUnionFailsWithLeadingDoublePipe() { - $body = 'union Hello = || Wo | Rld'; - $this->setExpectedExceptionRegExp(SyntaxError::class, '/' . preg_quote('Syntax Error GraphQL (1:16) Expected Name, found |', '/') . '/'); - Parser::parse($body); + $this->expectSyntaxError( + 'union Hello = || Wo | Rld', + 'Expected Name, found |', + $this->loc(1, 16) + ); } /** @@ -576,9 +750,11 @@ type Hello { */ public function testUnionFailsWithDoublePipe() { - $body = 'union Hello = Wo || Rld'; - $this->setExpectedExceptionRegExp(SyntaxError::class, '/' . preg_quote('Syntax Error GraphQL (1:19) Expected Name, found |', '/') . '/'); - Parser::parse($body); + $this->expectSyntaxError( + 'union Hello = Wo || Rld', + 'Expected Name, found |', + $this->loc(1, 19) + ); } /** @@ -586,9 +762,11 @@ type Hello { */ public function testUnionFailsWithTrailingPipe() { - $body = 'union Hello = | Wo | Rld |'; - $this->setExpectedExceptionRegExp(SyntaxError::class, '/' . preg_quote('Syntax Error GraphQL (1:27) Expected Name, found ', '/') . '/'); - Parser::parse($body); + $this->expectSyntaxError( + 'union Hello = | Wo | Rld |', + 'Expected Name, found ', + $this->loc(1, 27) + ); } /** @@ -657,52 +835,29 @@ input Hello { public function testSimpleInputObjectWithArgsShouldFail() { $body = ' -input Hello { - world(foo: Int): String -}'; - $this->setExpectedException('GraphQL\Error\SyntaxError'); - Parser::parse($body); + input Hello { + world(foo: Int): String + }'; + $this->expectSyntaxError( + $body, + 'Expected :, found (', + $this->loc(3, 14) + ); } /** - * @it Simple type + * @it Directive with incorrect locations */ - public function testSimpleTypeDescriptionInComments() + public function testDirectiveWithIncorrectLocationShouldFail() { $body = ' -# This is a simple type description. -# It is multiline *and includes formatting*. -type Hello { - # And this is a field description - world: String -}'; - $doc = Parser::parse($body); - $loc = function($start, $end) {return TestUtils::locArray($start, $end);}; - - $fieldNode = $this->fieldNode( - $this->nameNode('world', $loc(134, 139)), - $this->typeNode('String', $loc(141, 147)), - $loc(134, 147) + directive @foo on FIELD | INCORRECT_LOCATION +'; + $this->expectSyntaxError( + $body, + 'Unexpected Name "INCORRECT_LOCATION"', + $this->loc(2, 33) ); - $fieldNode['description'] = " And this is a field description\n"; - $expected = [ - 'kind' => NodeKind::DOCUMENT, - 'definitions' => [ - [ - 'kind' => NodeKind::OBJECT_TYPE_DEFINITION, - 'name' => $this->nameNode('Hello', $loc(88, 93)), - 'interfaces' => [], - 'directives' => [], - 'fields' => [ - $fieldNode - ], - 'loc' => $loc(83, 149), - 'description' => " This is a simple type description.\n It is multiline *and includes formatting*.\n" - ] - ], - 'loc' => $loc(0, 149) - ]; - $this->assertEquals($expected, TestUtils::nodeToArray($doc)); } private function typeNode($name, $loc) @@ -764,4 +919,20 @@ type Hello { 'description' => null ]; } + + private function loc($line, $column) + { + return new SourceLocation($line, $column); + } + + private function expectSyntaxError($text, $message, $location) + { + $this->setExpectedException(SyntaxError::class, $message); + try { + Parser::parse($text); + } catch (SyntaxError $error) { + $this->assertEquals([$location], $error->getLocations()); + throw $error; + } + } } diff --git a/tests/Language/SchemaPrinterTest.php b/tests/Language/SchemaPrinterTest.php index a649ced..1e1b897 100644 --- a/tests/Language/SchemaPrinterTest.php +++ b/tests/Language/SchemaPrinterTest.php @@ -56,6 +56,10 @@ class SchemaPrinterTest extends \PHPUnit_Framework_TestCase mutation: MutationType } +""" +This is a description +of the `Foo` type. +""" type Foo implements Bar { one: Type two(argument: InputType!): Type @@ -70,6 +74,14 @@ type AnnotatedObject @onObject(arg: "value") { annotatedField(arg: Type = "default" @onArg): Type @onField } +type UndefinedType + +extend type Foo { + seven(argument: [String]): Type +} + +extend type Foo @onType + interface Bar { one: Type four(argument: String = "string"): String @@ -79,16 +91,32 @@ interface AnnotatedInterface @onInterface { annotatedField(arg: Type @onArg): Type @onField } +interface UndefinedInterface + +extend interface Bar { + two(argument: InputType!): Type +} + +extend interface Bar @onInterface + union Feed = Story | Article | Advert union AnnotatedUnion @onUnion = A | B union AnnotatedUnionTwo @onUnion = A | B +union UndefinedUnion + +extend union Feed = Photo | Video + +extend union Feed @onUnion + scalar CustomScalar scalar AnnotatedScalar @onScalar +extend scalar CustomScalar @onScalar + enum Site { DESKTOP MOBILE @@ -99,22 +127,30 @@ enum AnnotatedEnum @onEnum { OTHER_VALUE } +enum UndefinedEnum + +extend enum Site { + VR +} + +extend enum Site @onEnum + input InputType { key: String! answer: Int = 42 } -input AnnotatedInput @onInputObjectType { +input AnnotatedInput @onInputObject { annotatedField: Type @onField } -extend type Foo { - seven(argument: [String]): Type +input UndefinedInput + +extend input InputType { + other: Float = 1.23e4 } -extend type Foo @onType {} - -type NoFields {} +extend input InputType @onInputObject directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT diff --git a/tests/Language/VisitorTest.php b/tests/Language/VisitorTest.php index 2a85663..df78b55 100644 --- a/tests/Language/VisitorTest.php +++ b/tests/Language/VisitorTest.php @@ -18,6 +18,92 @@ use GraphQL\Utils\TypeInfo; class VisitorTest extends \PHPUnit_Framework_TestCase { + private function getNodeByPath(DocumentNode $ast, $path) + { + $result = $ast; + foreach ($path as $key) { + $resultArray = $result instanceof NodeList ? iterator_to_array($result) : $result->toArray(); + $this->assertArrayHasKey($key, $resultArray); + $result = $resultArray[$key]; + } + return $result; + } + + private function checkVisitorFnArgs($ast, $args, $isEdited = false) + { + /** @var Node $node */ + list($node, $key, $parent, $path, $ancestors) = $args; + + $parentArray = $parent && !is_array($parent) ? ($parent instanceof NodeList ? iterator_to_array($parent) : $parent->toArray()) : $parent; + + $this->assertInstanceOf(Node::class, $node); + $this->assertContains($node->kind, array_keys(NodeKind::$classMap)); + + $isRoot = $key === null; + if ($isRoot) { + if (!$isEdited) { + $this->assertEquals($ast, $node); + } + $this->assertEquals(null, $parent); + $this->assertEquals([], $path); + $this->assertEquals([], $ancestors); + return; + } + + $this->assertContains(gettype($key), ['integer', 'string']); + + $this->assertArrayHasKey($key, $parentArray); + + $this->assertInternalType('array', $path); + $this->assertEquals($key, $path[count($path) - 1]); + + $this->assertInternalType('array', $ancestors); + $this->assertCount(count($path) - 1, $ancestors); + + if (!$isEdited) { + $this->assertEquals($node, $parentArray[$key]); + $this->assertEquals($node, $this->getNodeByPath($ast, $path)); + $ancestorsLength = count($ancestors); + for ($i = 0; $i < $ancestorsLength; ++$i) { + $ancestorPath = array_slice($path, 0, $i); + $this->assertEquals($ancestors[$i], $this->getNodeByPath($ast, $ancestorPath)); + } + } + } + + public function testValidatesPathArgument() + { + $visited = []; + + $ast = Parser::parse('{ a }', ['noLocation' => true]); + + Visitor::visit($ast, [ + 'enter' => function ($node, $key, $parent, $path) use ($ast, &$visited) { + $this->checkVisitorFnArgs($ast, func_get_args()); + $visited[] = ['enter', $path]; + }, + 'leave' => function ($node, $key, $parent, $path) use ($ast, &$visited) { + $this->checkVisitorFnArgs($ast, func_get_args()); + $visited[] = ['leave', $path]; + }, + ]); + + $expected = [ + ['enter', []], + ['enter', ['definitions', 0]], + ['enter', ['definitions', 0, 'selectionSet']], + ['enter', ['definitions', 0, 'selectionSet', 'selections', 0]], + ['enter', ['definitions', 0, 'selectionSet', 'selections', 0, 'name']], + ['leave', ['definitions', 0, 'selectionSet', 'selections', 0, 'name']], + ['leave', ['definitions', 0, 'selectionSet', 'selections', 0]], + ['leave', ['definitions', 0, 'selectionSet']], + ['leave', ['definitions', 0]], + ['leave', []], + ]; + + $this->assertEquals($expected, $visited); + } + /** * @it allows editing a node both on enter and on leave */ @@ -28,7 +114,8 @@ class VisitorTest extends \PHPUnit_Framework_TestCase $selectionSet = null; $editedAst = Visitor::visit($ast, [ NodeKind::OPERATION_DEFINITION => [ - 'enter' => function(OperationDefinitionNode $node) use (&$selectionSet) { + 'enter' => function(OperationDefinitionNode $node) use (&$selectionSet, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $selectionSet = $node->selectionSet; $newNode = clone $node; @@ -38,7 +125,8 @@ class VisitorTest extends \PHPUnit_Framework_TestCase $newNode->didEnter = true; return $newNode; }, - 'leave' => function(OperationDefinitionNode $node) use (&$selectionSet) { + 'leave' => function(OperationDefinitionNode $node) use (&$selectionSet, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args(), true); $newNode = clone $node; $newNode->selectionSet = $selectionSet; $newNode->didLeave = true; @@ -66,13 +154,15 @@ class VisitorTest extends \PHPUnit_Framework_TestCase $editedAst = Visitor::visit($ast, [ NodeKind::DOCUMENT => [ - 'enter' => function (DocumentNode $node) { + 'enter' => function (DocumentNode $node) use ($ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $tmp = clone $node; $tmp->definitions = []; $tmp->didEnter = true; return $tmp; }, - 'leave' => function(DocumentNode $node) use ($definitions) { + 'leave' => function(DocumentNode $node) use ($definitions, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args(), true); $tmp = clone $node; $node->definitions = $definitions; $node->didLeave = true; @@ -96,7 +186,8 @@ class VisitorTest extends \PHPUnit_Framework_TestCase { $ast = Parser::parse('{ a, b, c { a, b, c } }', ['noLocation' => true]); $editedAst = Visitor::visit($ast, [ - 'enter' => function($node) { + 'enter' => function($node) use ($ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); if ($node instanceof FieldNode && $node->name->value === 'b') { return Visitor::removeNode(); } @@ -120,7 +211,8 @@ class VisitorTest extends \PHPUnit_Framework_TestCase { $ast = Parser::parse('{ a, b, c { a, b, c } }', ['noLocation' => true]); $editedAst = Visitor::visit($ast, [ - 'leave' => function($node) { + 'leave' => function($node) use ($ast) { + $this->checkVisitorFnArgs($ast, func_get_args(), true); if ($node instanceof FieldNode && $node->name->value === 'b') { return Visitor::removeNode(); } @@ -151,10 +243,11 @@ class VisitorTest extends \PHPUnit_Framework_TestCase $didVisitAddedField = false; - $ast = Parser::parse('{ a { x } }'); + $ast = Parser::parse('{ a { x } }', ['noLocation' => true]); Visitor::visit($ast, [ - 'enter' => function($node) use ($addedField, &$didVisitAddedField) { + 'enter' => function($node) use ($addedField, &$didVisitAddedField, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args(), true); if ($node instanceof FieldNode && $node->name->value === 'a') { return new FieldNode([ 'selectionSet' => new SelectionSetNode(array( @@ -177,16 +270,18 @@ class VisitorTest extends \PHPUnit_Framework_TestCase public function testAllowsSkippingASubTree() { $visited = []; - $ast = Parser::parse('{ a, b { x }, c }'); + $ast = Parser::parse('{ a, b { x }, c }', ['noLocation' => true]); Visitor::visit($ast, [ - 'enter' => function(Node $node) use (&$visited) { + 'enter' => function(Node $node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['enter', $node->kind, isset($node->value) ? $node->value : null]; if ($node instanceof FieldNode && $node->name->value === 'b') { return Visitor::skipNode(); } }, - 'leave' => function (Node $node) use (&$visited) { + 'leave' => function (Node $node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; } ]); @@ -218,16 +313,18 @@ class VisitorTest extends \PHPUnit_Framework_TestCase public function testAllowsEarlyExitWhileVisiting() { $visited = []; - $ast = Parser::parse('{ a, b { x }, c }'); + $ast = Parser::parse('{ a, b { x }, c }', ['noLocation' => true]); Visitor::visit($ast, [ - 'enter' => function(Node $node) use (&$visited) { + 'enter' => function(Node $node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['enter', $node->kind, isset($node->value) ? $node->value : null]; if ($node instanceof NameNode && $node->value === 'x') { return Visitor::stop(); } }, - 'leave' => function(Node $node) use (&$visited) { + 'leave' => function(Node $node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; } ]); @@ -258,12 +355,14 @@ class VisitorTest extends \PHPUnit_Framework_TestCase { $visited = []; - $ast = Parser::parse('{ a, b { x }, c }'); + $ast = Parser::parse('{ a, b { x }, c }', ['noLocation' => true]); Visitor::visit($ast, [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['enter', $node->kind, isset($node->value) ? $node->value : null]; }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; if ($node->kind === NodeKind::NAME && $node->value === 'x') { @@ -296,17 +395,20 @@ class VisitorTest extends \PHPUnit_Framework_TestCase public function testAllowsANamedFunctionsVisitorAPI() { $visited = []; - $ast = Parser::parse('{ a, b { x }, c }'); + $ast = Parser::parse('{ a, b { x }, c }', ['noLocation' => true]); Visitor::visit($ast, [ - NodeKind::NAME => function(NameNode $node) use (&$visited) { + NodeKind::NAME => function(NameNode $node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['enter', $node->kind, $node->value]; }, NodeKind::SELECTION_SET => [ - 'enter' => function(SelectionSetNode $node) use (&$visited) { + 'enter' => function(SelectionSetNode $node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['enter', $node->kind, null]; }, - 'leave' => function(SelectionSetNode $node) use (&$visited) { + 'leave' => function(SelectionSetNode $node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['leave', $node->kind, null]; } ] @@ -326,6 +428,65 @@ class VisitorTest extends \PHPUnit_Framework_TestCase $this->assertEquals($expected, $visited); } + /** + * @it Experimental: visits variables defined in fragments + */ + public function testExperimentalVisitsVariablesDefinedInFragments() + { + $ast = Parser::parse( + 'fragment a($v: Boolean = false) on t { f }', + [ + 'noLocation' => true, + 'experimentalFragmentVariables' => true, + ] + ); + $visited = []; + + Visitor::visit($ast, [ + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); + $visited[] = ['enter', $node->kind, isset($node->value) ? $node->value : null]; + }, + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); + $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; + }, + ]); + + $expected = [ + ['enter', 'Document', null], + ['enter', 'FragmentDefinition', null], + ['enter', 'Name', 'a'], + ['leave', 'Name', 'a'], + ['enter', 'VariableDefinition', null], + ['enter', 'Variable', null], + ['enter', 'Name', 'v'], + ['leave', 'Name', 'v'], + ['leave', 'Variable', null], + ['enter', 'NamedType', null], + ['enter', 'Name', 'Boolean'], + ['leave', 'Name', 'Boolean'], + ['leave', 'NamedType', null], + ['enter', 'BooleanValue', false], + ['leave', 'BooleanValue', false], + ['leave', 'VariableDefinition', null], + ['enter', 'NamedType', null], + ['enter', 'Name', 't'], + ['leave', 'Name', 't'], + ['leave', 'NamedType', null], + ['enter', 'SelectionSet', null], + ['enter', 'Field', null], + ['enter', 'Name', 'f'], + ['leave', 'Name', 'f'], + ['leave', 'Field', null], + ['leave', 'SelectionSet', null], + ['leave', 'FragmentDefinition', null], + ['leave', 'Document', null], + ]; + + $this->assertEquals($expected, $visited); + } + /** * @it visits kitchen sink */ @@ -336,11 +497,13 @@ class VisitorTest extends \PHPUnit_Framework_TestCase $visited = []; Visitor::visit($ast, [ - 'enter' => function(Node $node, $key, $parent) use (&$visited) { + 'enter' => function(Node $node, $key, $parent) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $r = ['enter', $node->kind, $key, $parent instanceof Node ? $parent->kind : null]; $visited[] = $r; }, - 'leave' => function(Node $node, $key, $parent) use (&$visited) { + 'leave' => function(Node $node, $key, $parent) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $r = ['leave', $node->kind, $key, $parent instanceof Node ? $parent->kind : null]; $visited[] = $r; } @@ -615,6 +778,12 @@ class VisitorTest extends \PHPUnit_Framework_TestCase [ 'enter', 'StringValue', 'value', 'ObjectField' ], [ 'leave', 'StringValue', 'value', 'ObjectField' ], [ 'leave', 'ObjectField', 0, null ], + [ 'enter', 'ObjectField', 1, null ], + [ 'enter', 'Name', 'name', 'ObjectField' ], + [ 'leave', 'Name', 'name', 'ObjectField' ], + [ 'enter', 'StringValue', 'value', 'ObjectField' ], + [ 'leave', 'StringValue', 'value', 'ObjectField' ], + [ 'leave', 'ObjectField', 1, null ], [ 'leave', 'ObjectValue', 'value', 'Argument' ], [ 'leave', 'Argument', 2, null ], [ 'leave', 'Field', 0, null ], @@ -669,7 +838,8 @@ class VisitorTest extends \PHPUnit_Framework_TestCase $ast = Parser::parse('{ a, b { x }, c }'); Visitor::visit($ast, Visitor::visitInParallel([ [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = [ 'enter', $node->kind, isset($node->value) ? $node->value : null]; if ($node->kind === 'Field' && isset($node->name->value) && $node->name->value === 'b') { @@ -677,7 +847,8 @@ class VisitorTest extends \PHPUnit_Framework_TestCase } }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; } ] @@ -712,24 +883,28 @@ class VisitorTest extends \PHPUnit_Framework_TestCase $ast = Parser::parse('{ a { x }, b { y} }'); Visitor::visit($ast, Visitor::visitInParallel([ [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['no-a', 'enter', $node->kind, isset($node->value) ? $node->value : null]; if ($node->kind === 'Field' && isset($node->name->value) && $node->name->value === 'a') { return Visitor::skipNode(); } }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = [ 'no-a', 'leave', $node->kind, isset($node->value) ? $node->value : null ]; } ], [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['no-b', 'enter', $node->kind, isset($node->value) ? $node->value : null]; if ($node->kind === 'Field' && isset($node->name->value) && $node->name->value === 'b') { return Visitor::skipNode(); } }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['no-b', 'leave', $node->kind, isset($node->value) ? $node->value : null]; } ] @@ -782,14 +957,16 @@ class VisitorTest extends \PHPUnit_Framework_TestCase $ast = Parser::parse('{ a, b { x }, c }'); Visitor::visit($ast, Visitor::visitInParallel([ [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $value = isset($node->value) ? $node->value : null; $visited[] = ['enter', $node->kind, $value]; if ($node->kind === 'Name' && $value === 'x') { return Visitor::stop(); } }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; } ] ])); @@ -821,26 +998,30 @@ class VisitorTest extends \PHPUnit_Framework_TestCase $ast = Parser::parse('{ a { y }, b { x } }'); Visitor::visit($ast, Visitor::visitInParallel([ [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $value = isset($node->value) ? $node->value : null; $visited[] = ['break-a', 'enter', $node->kind, $value]; if ($node->kind === 'Name' && $value === 'a') { return Visitor::stop(); } }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = [ 'break-a', 'leave', $node->kind, isset($node->value) ? $node->value : null ]; } ], [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $value = isset($node->value) ? $node->value : null; $visited[] = ['break-b', 'enter', $node->kind, $value]; if ($node->kind === 'Name' && $value === 'b') { return Visitor::stop(); } }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['break-b', 'leave', $node->kind, isset($node->value) ? $node->value : null]; } ], @@ -879,10 +1060,12 @@ class VisitorTest extends \PHPUnit_Framework_TestCase $ast = Parser::parse('{ a, b { x }, c }'); Visitor::visit($ast, Visitor::visitInParallel([ [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['enter', $node->kind, isset($node->value) ? $node->value : null]; }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $value = isset($node->value) ? $node->value : null; $visited[] = ['leave', $node->kind, $value]; if ($node->kind === 'Name' && $value === 'x') { @@ -919,10 +1102,12 @@ class VisitorTest extends \PHPUnit_Framework_TestCase $ast = Parser::parse('{ a { y }, b { x } }'); Visitor::visit($ast, Visitor::visitInParallel([ [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['break-a', 'enter', $node->kind, isset($node->value) ? $node->value : null]; }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['break-a', 'leave', $node->kind, isset($node->value) ? $node->value : null]; if ($node->kind === 'Field' && isset($node->name->value) && $node->name->value === 'a') { return Visitor::stop(); @@ -930,10 +1115,12 @@ class VisitorTest extends \PHPUnit_Framework_TestCase } ], [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['break-b', 'enter', $node->kind, isset($node->value) ? $node->value : null]; }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['break-b', 'leave', $node->kind, isset($node->value) ? $node->value : null]; if ($node->kind === 'Field' && isset($node->name->value) && $node->name->value === 'b') { return Visitor::stop(); @@ -992,17 +1179,20 @@ class VisitorTest extends \PHPUnit_Framework_TestCase $ast = Parser::parse('{ a, b, c { a, b, c } }', ['noLocation' => true]); $editedAst = Visitor::visit($ast, Visitor::visitInParallel([ [ - 'enter' => function ($node) use (&$visited) { + 'enter' => function ($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); if ($node->kind === 'Field' && isset($node->name->value) && $node->name->value === 'b') { return Visitor::removeNode(); } } ], [ - 'enter' => function ($node) use (&$visited) { + 'enter' => function ($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['enter', $node->kind, isset($node->value) ? $node->value : null]; }, - 'leave' => function ($node) use (&$visited) { + 'leave' => function ($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args(), true); $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; } ], @@ -1056,17 +1246,20 @@ class VisitorTest extends \PHPUnit_Framework_TestCase $ast = Parser::parse('{ a, b, c { a, b, c } }', ['noLocation' => true]); $editedAst = Visitor::visit($ast, Visitor::visitInParallel([ [ - 'leave' => function ($node) use (&$visited) { + 'leave' => function ($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args(), true); if ($node->kind === 'Field' && isset($node->name->value) && $node->name->value === 'b') { return Visitor::removeNode(); } } ], [ - 'enter' => function ($node) use (&$visited) { + 'enter' => function ($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['enter', $node->kind, isset($node->value) ? $node->value : null]; }, - 'leave' => function ($node) use (&$visited) { + 'leave' => function ($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args(), true); $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; } ], @@ -1125,11 +1318,12 @@ class VisitorTest extends \PHPUnit_Framework_TestCase { $visited = []; - $typeInfo = new TypeInfo(TestCase::getDefaultSchema()); + $typeInfo = new TypeInfo(TestCase::getTestSchema()); - $ast = Parser::parse('{ human(id: 4) { name, pets { name }, unknown } }'); + $ast = Parser::parse('{ human(id: 4) { name, pets { ... { name } }, unknown } }'); Visitor::visit($ast, Visitor::visitWithTypeInfo($typeInfo, [ - 'enter' => function ($node) use ($typeInfo, &$visited) { + 'enter' => function ($node) use ($typeInfo, &$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $parentType = $typeInfo->getParentType(); $type = $typeInfo->getType(); $inputType = $typeInfo->getInputType(); @@ -1142,7 +1336,8 @@ class VisitorTest extends \PHPUnit_Framework_TestCase $inputType ? (string)$inputType : null ]; }, - 'leave' => function ($node) use ($typeInfo, &$visited) { + 'leave' => function ($node) use ($typeInfo, &$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $parentType = $typeInfo->getParentType(); $type = $typeInfo->getType(); $inputType = $typeInfo->getInputType(); @@ -1179,10 +1374,14 @@ class VisitorTest extends \PHPUnit_Framework_TestCase ['enter', 'Name', 'pets', 'Human', '[Pet]', null], ['leave', 'Name', 'pets', 'Human', '[Pet]', null], ['enter', 'SelectionSet', null, 'Pet', '[Pet]', null], + ['enter', 'InlineFragment', null, 'Pet', 'Pet', null], + ['enter', 'SelectionSet', null, 'Pet', 'Pet', null], ['enter', 'Field', null, 'Pet', 'String', null], ['enter', 'Name', 'name', 'Pet', 'String', null], ['leave', 'Name', 'name', 'Pet', 'String', null], ['leave', 'Field', null, 'Pet', 'String', null], + ['leave', 'SelectionSet', null, 'Pet', 'Pet', null], + ['leave', 'InlineFragment', null, 'Pet', 'Pet', null], ['leave', 'SelectionSet', null, 'Pet', '[Pet]', null], ['leave', 'Field', null, 'Human', '[Pet]', null], ['enter', 'Field', null, 'Human', null, null], @@ -1203,13 +1402,14 @@ class VisitorTest extends \PHPUnit_Framework_TestCase public function testMaintainsTypeInfoDuringEdit() { $visited = []; - $typeInfo = new TypeInfo(TestCase::getDefaultSchema()); + $typeInfo = new TypeInfo(TestCase::getTestSchema()); $ast = Parser::parse( '{ human(id: 4) { name, pets }, alien }' ); $editedAst = Visitor::visit($ast, Visitor::visitWithTypeInfo($typeInfo, [ - 'enter' => function ($node) use ($typeInfo, &$visited) { + 'enter' => function ($node) use ($typeInfo, &$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args(), true); $parentType = $typeInfo->getParentType(); $type = $typeInfo->getType(); $inputType = $typeInfo->getInputType(); @@ -1244,7 +1444,8 @@ class VisitorTest extends \PHPUnit_Framework_TestCase ]); } }, - 'leave' => function ($node) use ($typeInfo, &$visited) { + 'leave' => function ($node) use ($typeInfo, &$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args(), true); $parentType = $typeInfo->getParentType(); $type = $typeInfo->getType(); $inputType = $typeInfo->getInputType(); diff --git a/tests/Language/kitchen-sink-noloc.ast b/tests/Language/kitchen-sink-noloc.ast index 0f37560..d1427c1 100644 --- a/tests/Language/kitchen-sink-noloc.ast +++ b/tests/Language/kitchen-sink-noloc.ast @@ -556,7 +556,20 @@ }, "value": { "kind": "StringValue", - "value": "value" + "value": "value", + "block": false + } + }, + { + "kind": "ObjectField", + "name": { + "kind": "Name", + "value": "block" + }, + "value": { + "kind": "StringValue", + "value": "block string uses \"\"\"", + "block": true } } ] @@ -575,6 +588,7 @@ { "kind": "OperationDefinition", "operation": "query", + "variableDefinitions": [], "directives": [], "selectionSet": { "kind": "SelectionSet", diff --git a/tests/Language/kitchen-sink.ast b/tests/Language/kitchen-sink.ast index 9c89af7..606b03c 100644 --- a/tests/Language/kitchen-sink.ast +++ b/tests/Language/kitchen-sink.ast @@ -2,7 +2,7 @@ "kind": "Document", "loc": { "start": 0, - "end": 1087 + "end": 1136 }, "definitions": [ { @@ -959,7 +959,7 @@ "kind": "FragmentDefinition", "loc": { "start": 942, - "end": 1018 + "end": 1067 }, "name": { "kind": "Name", @@ -989,14 +989,14 @@ "kind": "SelectionSet", "loc": { "start": 966, - "end": 1018 + "end": 1067 }, "selections": [ { "kind": "Field", "loc": { "start": 970, - "end": 1016 + "end": 1065 }, "name": { "kind": "Name", @@ -1071,13 +1071,13 @@ "kind": "Argument", "loc": { "start": 996, - "end": 1015 + "end": 1064 }, "value": { "kind": "ObjectValue", "loc": { "start": 1001, - "end": 1015 + "end": 1064 }, "fields": [ { @@ -1100,7 +1100,32 @@ "start": 1007, "end": 1014 }, - "value": "value" + "value": "value", + "block": false + } + }, + { + "kind": "ObjectField", + "loc": { + "start": 1016, + "end": 1063 + }, + "name": { + "kind": "Name", + "loc": { + "start": 1016, + "end": 1021 + }, + "value": "block" + }, + "value": { + "kind": "StringValue", + "loc": { + "start": 1023, + "end": 1063 + }, + "value": "block string uses \"\"\"", + "block": true } } ] @@ -1123,29 +1148,30 @@ { "kind": "OperationDefinition", "loc": { - "start": 1020, - "end": 1086 + "start": 1069, + "end": 1135 }, "operation": "query", + "variableDefinitions": [], "directives": [], "selectionSet": { "kind": "SelectionSet", "loc": { - "start": 1020, - "end": 1086 + "start": 1069, + "end": 1135 }, "selections": [ { "kind": "Field", "loc": { - "start": 1024, - "end": 1075 + "start": 1073, + "end": 1124 }, "name": { "kind": "Name", "loc": { - "start": 1024, - "end": 1031 + "start": 1073, + "end": 1080 }, "value": "unnamed" }, @@ -1153,22 +1179,22 @@ { "kind": "Argument", "loc": { - "start": 1032, - "end": 1044 + "start": 1081, + "end": 1093 }, "value": { "kind": "BooleanValue", "loc": { - "start": 1040, - "end": 1044 + "start": 1089, + "end": 1093 }, "value": true }, "name": { "kind": "Name", "loc": { - "start": 1032, - "end": 1038 + "start": 1081, + "end": 1087 }, "value": "truthy" } @@ -1176,22 +1202,22 @@ { "kind": "Argument", "loc": { - "start": 1046, - "end": 1059 + "start": 1095, + "end": 1108 }, "value": { "kind": "BooleanValue", "loc": { - "start": 1054, - "end": 1059 + "start": 1103, + "end": 1108 }, "value": false }, "name": { "kind": "Name", "loc": { - "start": 1046, - "end": 1052 + "start": 1095, + "end": 1101 }, "value": "falsey" } @@ -1199,21 +1225,21 @@ { "kind": "Argument", "loc": { - "start": 1061, - "end": 1074 + "start": 1110, + "end": 1123 }, "value": { "kind": "NullValue", "loc": { - "start": 1070, - "end": 1074 + "start": 1119, + "end": 1123 } }, "name": { "kind": "Name", "loc": { - "start": 1061, - "end": 1068 + "start": 1110, + "end": 1117 }, "value": "nullish" } @@ -1224,14 +1250,14 @@ { "kind": "Field", "loc": { - "start": 1079, - "end": 1084 + "start": 1128, + "end": 1133 }, "name": { "kind": "Name", "loc": { - "start": 1079, - "end": 1084 + "start": 1128, + "end": 1133 }, "value": "query" }, diff --git a/tests/Language/kitchen-sink.graphql b/tests/Language/kitchen-sink.graphql index 993de9a..53bb320 100644 --- a/tests/Language/kitchen-sink.graphql +++ b/tests/Language/kitchen-sink.graphql @@ -48,7 +48,11 @@ subscription StoryLikeSubscription($input: StoryLikeSubscribeInput) { } fragment frag on Friend { - foo(size: $size, bar: $b, obj: {key: "value"}) + foo(size: $size, bar: $b, obj: {key: "value", block: """ + + block string uses \""" + + """}) } { diff --git a/tests/Language/schema-kitchen-sink.graphql b/tests/Language/schema-kitchen-sink.graphql index 0544266..ae1a3e5 100644 --- a/tests/Language/schema-kitchen-sink.graphql +++ b/tests/Language/schema-kitchen-sink.graphql @@ -1,30 +1,40 @@ -# Copyright (c) 2015, Facebook, Inc. -# All rights reserved. +# Copyright (c) 2015-present, Facebook, Inc. # -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. An additional grant -# of patent rights can be found in the PATENTS file in the same directory. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. schema { query: QueryType mutation: MutationType } +""" +This is a description +of the `Foo` type. +""" type Foo implements Bar { - one: Type - two(argument: InputType!): Type - three(argument: InputType, other: String): Int - four(argument: String = "string"): String - five(argument: [String] = ["string", "string"]): String - six(argument: InputType = {key: "value"}): Type - seven(argument: Int = null): Type +one: Type +two(argument: InputType!): Type +three(argument: InputType, other: String): Int +four(argument: String = "string"): String +five(argument: [String] = ["string", "string"]): String +six(argument: InputType = {key: "value"}): Type +seven(argument: Int = null): Type } type AnnotatedObject @onObject(arg: "value") { annotatedField(arg: Type = "default" @onArg): Type @onField } -interface Bar { +type UndefinedType + + extend type Foo { + seven(argument: [String]): Type +} + +extend type Foo @onType + + interface Bar { one: Type four(argument: String = "string"): String } @@ -33,51 +43,75 @@ interface AnnotatedInterface @onInterface { annotatedField(arg: Type @onArg): Type @onField } +interface UndefinedInterface + + extend interface Bar { + two(argument: InputType!): Type +} + +extend interface Bar @onInterface + union Feed = Story | Article | Advert union AnnotatedUnion @onUnion = A | B union AnnotatedUnionTwo @onUnion = | A | B +union UndefinedUnion + +extend union Feed = Photo | Video + +extend union Feed @onUnion + scalar CustomScalar scalar AnnotatedScalar @onScalar +extend scalar CustomScalar @onScalar + enum Site { - DESKTOP - MOBILE +DESKTOP +MOBILE } enum AnnotatedEnum @onEnum { - ANNOTATED_VALUE @onEnumValue - OTHER_VALUE +ANNOTATED_VALUE @onEnumValue +OTHER_VALUE } +enum UndefinedEnum + +extend enum Site { +VR +} + +extend enum Site @onEnum + input InputType { - key: String! - answer: Int = 42 +key: String! +answer: Int = 42 } -input AnnotatedInput @onInputObjectType { - annotatedField: Type @onField +input AnnotatedInput @onInputObject { +annotatedField: Type @onField } -extend type Foo { - seven(argument: [String]): Type +input UndefinedInput + +extend input InputType { +other: Float = 1.23e4 } -extend type Foo @onType {} - -type NoFields {} +extend input InputType @onInputObject directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT directive @include(if: Boolean!) - on FIELD - | FRAGMENT_SPREAD - | INLINE_FRAGMENT +on FIELD +| FRAGMENT_SPREAD +| INLINE_FRAGMENT directive @include2(if: Boolean!) on - | FIELD - | FRAGMENT_SPREAD - | INLINE_FRAGMENT +| FIELD +| FRAGMENT_SPREAD +| INLINE_FRAGMENT diff --git a/tests/Server/QueryExecutionTest.php b/tests/Server/QueryExecutionTest.php index 75982cb..487d089 100644 --- a/tests/Server/QueryExecutionTest.php +++ b/tests/Server/QueryExecutionTest.php @@ -52,7 +52,7 @@ class QueryExecutionTest extends TestCase $this->assertSame(null, $result->data); $this->assertCount(1, $result->errors); $this->assertContains( - 'Syntax Error GraphQL (1:4) Expected Name, found ', + 'Syntax Error: Expected Name, found ', $result->errors[0]->getMessage() ); } @@ -322,7 +322,7 @@ class QueryExecutionTest extends TestCase $this->setExpectedException( InvariantViolation::class, 'Persistent query loader must return query string or instance of GraphQL\Language\AST\DocumentNode '. - 'but got: associative array(1) with first key: "err"' + 'but got: {"err":"err"}' ); $this->config->setPersistentQueryLoader(function($queryId, OperationParams $params) use (&$called) { return ['err' => 'err']; diff --git a/tests/Server/RequestValidationTest.php b/tests/Server/RequestValidationTest.php index 2a335f1..0d15a77 100644 --- a/tests/Server/RequestValidationTest.php +++ b/tests/Server/RequestValidationTest.php @@ -70,7 +70,7 @@ class RequestValidationTest extends \PHPUnit_Framework_TestCase $this->assertInputError( $parsedBody, - 'GraphQL Request parameter "query" must be string, but got object with first key: "t"' + 'GraphQL Request parameter "query" must be string, but got {"t":"{my query}"}' ); } @@ -82,7 +82,7 @@ class RequestValidationTest extends \PHPUnit_Framework_TestCase $this->assertInputError( $parsedBody, - 'GraphQL Request parameter "queryId" must be string, but got object with first key: "t"' + 'GraphQL Request parameter "queryId" must be string, but got {"t":"{my query}"}' ); } @@ -95,7 +95,7 @@ class RequestValidationTest extends \PHPUnit_Framework_TestCase $this->assertInputError( $parsedBody, - 'GraphQL Request parameter "operation" must be string, but got array(0)' + 'GraphQL Request parameter "operation" must be string, but got []' ); } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index a466842..db7df22 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -39,8 +39,9 @@ class ServerTest extends \PHPUnit_Framework_TestCase $this->assertEquals(500, $server->getUnexpectedErrorStatus()); $this->assertEquals(DocumentValidator::allRules(), $server->getValidationRules()); - $this->setExpectedException(InvariantViolation::class, 'Schema query must be Object Type but got: NULL'); - $server->getSchema(); + $schema = $server->getSchema(); + $this->setExpectedException(InvariantViolation::class, 'Query root type must be provided.'); + $schema->assertValid(); } public function testCannotUseSetQueryTypeAndSetSchema() @@ -303,10 +304,18 @@ class ServerTest extends \PHPUnit_Framework_TestCase $server = Server::create(); $ast = $server->parse('{q}'); $this->assertInstanceOf('GraphQL\Language\AST\DocumentNode', $ast); + } - $this->setExpectedExceptionRegExp(SyntaxError::class, '/' . preg_quote('{q', '/') . '/'); - $server->parse('{q'); - $this->fail('Expected exception not thrown'); + public function testParseFailure() + { + $server = Server::create(); + try { + $server->parse('{q'); + $this->fail('Expected exception not thrown'); + } catch (SyntaxError $error) { + $this->assertContains('{q', (string) $error); + $this->assertEquals('Syntax Error: Expected Name, found ', $error->getMessage()); + } } public function testValidate() @@ -320,8 +329,8 @@ class ServerTest extends \PHPUnit_Framework_TestCase $this->assertInternalType('array', $errors); $this->assertNotEmpty($errors); - $this->setExpectedException(InvariantViolation::class, 'Cannot validate, schema contains errors: Schema query must be Object Type but got: NULL'); $server = Server::create(); + $this->setExpectedException(InvariantViolation::class, 'Cannot validate, schema contains errors: Query root type must be provided.'); $server->validate($ast); } @@ -530,15 +539,14 @@ class ServerTest extends \PHPUnit_Framework_TestCase { $mock = $this->getMockBuilder('GraphQL\Server') ->setMethods(['readInput', 'produceOutput']) - ->getMock() - ; + ->getMock(); $mock->method('readInput') ->will($this->returnValue(json_encode(['query' => '{err}']))); $output = null; $mock->method('produceOutput') - ->will($this->returnCallback(function($a1, $a2) use (&$output) { + ->will($this->returnCallback(function ($a1, $a2) use (&$output) { $output = func_get_args(); })); @@ -546,17 +554,35 @@ class ServerTest extends \PHPUnit_Framework_TestCase $mock->handleRequest(); $this->assertInternalType('array', $output); - $this->assertArraySubset(['errors' => [['message' => 'Unexpected Error']]], $output[0]); - $this->assertEquals(500, $output[1]); + $this->assertArraySubset(['errors' => [['message' => 'Schema does not define the required query root type.']]], $output[0]); + $this->assertEquals(200, $output[1]); $output = null; $mock->setUnexpectedErrorMessage($newErr = 'Hey! Something went wrong!'); $mock->setUnexpectedErrorStatus(501); + $mock->method('readInput') + ->will($this->throwException(new \Exception('test'))); $mock->handleRequest(); $this->assertInternalType('array', $output); $this->assertEquals(['errors' => [['message' => $newErr]]], $output[0]); $this->assertEquals(501, $output[1]); + } + + public function testHandleRequest2() + { + $mock = $this->getMockBuilder('GraphQL\Server') + ->setMethods(['readInput', 'produceOutput']) + ->getMock(); + + $mock->method('readInput') + ->will($this->returnValue(json_encode(['query' => '{err}']))); + + $output = null; + $mock->method('produceOutput') + ->will($this->returnCallback(function ($a1, $a2) use (&$output) { + $output = func_get_args(); + })); $mock->setQueryType(new ObjectType([ 'name' => 'Query', diff --git a/tests/Type/DefinitionTest.php b/tests/Type/DefinitionTest.php index f0f3fe3..9457d17 100644 --- a/tests/Type/DefinitionTest.php +++ b/tests/Type/DefinitionTest.php @@ -74,10 +74,7 @@ class DefinitionTest extends \PHPUnit_Framework_TestCase public function setUp() { - $this->objectType = new ObjectType([ - 'name' => 'Object', - 'isTypeOf' => function() {return true;} - ]); + $this->objectType = new ObjectType(['name' => 'Object']); $this->interfaceType = new InterfaceType(['name' => 'Interface']); $this->unionType = new UnionType(['name' => 'Union', 'types' => [$this->objectType]]); $this->enumType = new EnumType(['name' => 'Enum']); @@ -363,7 +360,6 @@ class DefinitionTest extends \PHPUnit_Framework_TestCase 'f' => ['type' => Type::int()] ], 'interfaces' => [$someInterface], - 'isTypeOf' => function() {return true;} ]); $schema = new Schema([ @@ -391,7 +387,6 @@ class DefinitionTest extends \PHPUnit_Framework_TestCase 'f' => ['type' => Type::int()] ], 'interfaces' => function() use (&$someInterface) { return [$someInterface]; }, - 'isTypeOf' => function() {return true;} ]); $someInterface = new InterfaceType([ @@ -473,46 +468,6 @@ class DefinitionTest extends \PHPUnit_Framework_TestCase } } - /** - * @it prohibits nesting NonNull inside NonNull - */ - public function testProhibitsNonNullNesting() - { - $this->setExpectedException('\Exception'); - new NonNull(new NonNull(Type::int())); - } - - /** - * @it prohibits putting non-Object types in unions - */ - public function testProhibitsPuttingNonObjectTypesInUnions() - { - $int = Type::int(); - - $badUnionTypes = [ - $int, - new NonNull($int), - new ListOfType($int), - $this->interfaceType, - $this->unionType, - $this->enumType, - $this->inputObjectType - ]; - - foreach ($badUnionTypes as $type) { - try { - $union = new UnionType(['name' => 'BadUnion', 'types' => [$type]]); - $union->assertValid(); - $this->fail('Expected exception not thrown'); - } catch (\Exception $e) { - $this->assertSame( - 'BadUnion may only contain Object types, it cannot contain: ' . Utils::printSafe($type) . '.', - $e->getMessage() - ); - } - } - } - /** * @it allows a thunk for Union\'s types */ diff --git a/tests/Type/EnumTypeTest.php b/tests/Type/EnumTypeTest.php index ecce357..79741d3 100644 --- a/tests/Type/EnumTypeTest.php +++ b/tests/Type/EnumTypeTest.php @@ -1,7 +1,6 @@ "Argument \"fromEnum\" has invalid value \"GREEN\".\nExpected type \"Color\", found \"GREEN\".", + 'message' => "Expected type Color, found \"GREEN\"; Did you mean the enum value GREEN?", + 'locations' => [new SourceLocation(1, 23)] + ] + ); + } + + /** + * @it does not accept valuesNotInTheEnum + */ + public function testDoesNotAcceptValuesNotInTheEnum() + { + $this->expectFailure( + '{ colorEnum(fromEnum: GREENISH) }', + null, + [ + 'message' => "Expected type Color, found GREENISH; Did you mean the enum value GREEN?", + 'locations' => [new SourceLocation(1, 23)] + ] + ); + } + + /** + * @it does not accept values with incorrect casing + */ + public function testDoesNotAcceptValuesWithIncorrectCasing() + { + $this->expectFailure( + '{ colorEnum(fromEnum: green) }', + null, + [ + 'message' => "Expected type Color, found green; Did you mean the enum value GREEN?", 'locations' => [new SourceLocation(1, 23)] ] ); @@ -236,8 +265,9 @@ class EnumTypeTest extends \PHPUnit_Framework_TestCase '{ colorEnum(fromString: "GREEN") }', null, [ - 'message' => 'Expected a value of type "Color" but received: "GREEN"', - 'locations' => [new SourceLocation(1, 3)] + 'message' => 'Expected a value of type "Color" but received: GREEN', + 'locations' => [new SourceLocation(1, 3)], + 'path' => ['colorEnum'], ] ); } @@ -250,7 +280,7 @@ class EnumTypeTest extends \PHPUnit_Framework_TestCase $this->expectFailure( '{ colorEnum(fromEnum: 1) }', null, - "Argument \"fromEnum\" has invalid value 1.\nExpected type \"Color\", found 1." + "Expected type Color, found 1." ); } @@ -262,7 +292,7 @@ class EnumTypeTest extends \PHPUnit_Framework_TestCase $this->expectFailure( '{ colorEnum(fromInt: GREEN) }', null, - "Argument \"fromInt\" has invalid value GREEN.\nExpected type \"Int\", found GREEN." + "Expected type Int, found GREEN." ); } @@ -326,7 +356,7 @@ class EnumTypeTest extends \PHPUnit_Framework_TestCase $this->expectFailure( 'query test($color: Color!) { colorEnum(fromEnum: $color) }', ['color' => 2], - "Variable \"\$color\" got invalid value 2.\nExpected type \"Color\", found 2." + 'Variable "$color" got invalid value 2; Expected type Color.' ); } @@ -460,7 +490,7 @@ class EnumTypeTest extends \PHPUnit_Framework_TestCase [ 'data' => ['first' => 'ONE', 'second' => 'TWO', 'third' => null], 'errors' => [[ - 'debugMessage' => 'Expected a value of type "SimpleEnum" but received: "WRONG"', + 'debugMessage' => 'Expected a value of type "SimpleEnum" but received: WRONG', 'locations' => [['line' => 4, 'column' => 13]] ]] ], diff --git a/tests/Type/IntrospectionTest.php b/tests/Type/IntrospectionTest.php index 532028b..e1d92de 100644 --- a/tests/Type/IntrospectionTest.php +++ b/tests/Type/IntrospectionTest.php @@ -28,7 +28,7 @@ class IntrospectionTest extends \PHPUnit_Framework_TestCase ]) ]); - $request = Introspection::getIntrospectionQuery(false); + $request = Introspection::getIntrospectionQuery(['descriptions' => false]); $expected = array ( 'data' => array ( diff --git a/tests/Type/ScalarSerializationTest.php b/tests/Type/ScalarSerializationTest.php index 3ded023..8fdd0d6 100644 --- a/tests/Type/ScalarSerializationTest.php +++ b/tests/Type/ScalarSerializationTest.php @@ -1,8 +1,7 @@ setExpectedException(InvariantViolation::class, 'Int cannot represent non-integer value: 0.1'); + $this->setExpectedException(Error::class, 'Int cannot represent non-integer value: 0.1'); $intType->serialize(0.1); } public function testSerializesOutputIntCannotRepresentFloat2() { $intType = Type::int(); - $this->setExpectedException(InvariantViolation::class, 'Int cannot represent non-integer value: 1.1'); + $this->setExpectedException(Error::class, 'Int cannot represent non-integer value: 1.1'); $intType->serialize(1.1); } @@ -46,7 +45,7 @@ class ScalarSerializationTest extends \PHPUnit_Framework_TestCase public function testSerializesOutputIntCannotRepresentNegativeFloat() { $intType = Type::int(); - $this->setExpectedException(InvariantViolation::class, 'Int cannot represent non-integer value: -1.1'); + $this->setExpectedException(Error::class, 'Int cannot represent non-integer value: -1.1'); $intType->serialize(-1.1); } @@ -54,7 +53,7 @@ class ScalarSerializationTest extends \PHPUnit_Framework_TestCase public function testSerializesOutputIntCannotRepresentNumericString() { $intType = Type::int(); - $this->setExpectedException(InvariantViolation::class, ''); + $this->setExpectedException(Error::class, ''); $intType->serialize('Int cannot represent non-integer value: "-1.1"'); } @@ -64,7 +63,7 @@ class ScalarSerializationTest extends \PHPUnit_Framework_TestCase // Maybe a safe PHP int, but bigger than 2^32, so not // representable as a GraphQL Int $intType = Type::int(); - $this->setExpectedException(InvariantViolation::class, 'Int cannot represent non 32-bit signed integer value: 9876504321'); + $this->setExpectedException(Error::class, 'Int cannot represent non 32-bit signed integer value: 9876504321'); $intType->serialize(9876504321); } @@ -72,28 +71,28 @@ class ScalarSerializationTest extends \PHPUnit_Framework_TestCase public function testSerializesOutputIntCannotRepresentLowerThan32Bits() { $intType = Type::int(); - $this->setExpectedException(InvariantViolation::class, 'Int cannot represent non 32-bit signed integer value: -9876504321'); + $this->setExpectedException(Error::class, 'Int cannot represent non 32-bit signed integer value: -9876504321'); $intType->serialize(-9876504321); } public function testSerializesOutputIntCannotRepresentBiggerThanSigned32Bits() { $intType = Type::int(); - $this->setExpectedException(InvariantViolation::class, 'Int cannot represent non 32-bit signed integer value: 1.0E+100'); + $this->setExpectedException(Error::class, 'Int cannot represent non 32-bit signed integer value: 1.0E+100'); $intType->serialize(1e100); } public function testSerializesOutputIntCannotRepresentLowerThanSigned32Bits() { $intType = Type::int(); - $this->setExpectedException(InvariantViolation::class, 'Int cannot represent non 32-bit signed integer value: -1.0E+100'); + $this->setExpectedException(Error::class, 'Int cannot represent non 32-bit signed integer value: -1.0E+100'); $intType->serialize(-1e100); } public function testSerializesOutputIntCannotRepresentString() { $intType = Type::int(); - $this->setExpectedException(InvariantViolation::class, 'Int cannot represent non 32-bit signed integer value: "one"'); + $this->setExpectedException(Error::class, 'Int cannot represent non 32-bit signed integer value: one'); $intType->serialize('one'); } @@ -101,7 +100,7 @@ class ScalarSerializationTest extends \PHPUnit_Framework_TestCase public function testSerializesOutputIntCannotRepresentEmptyString() { $intType = Type::int(); - $this->setExpectedException(InvariantViolation::class, 'Int cannot represent non 32-bit signed integer value: (empty string)'); + $this->setExpectedException(Error::class, 'Int cannot represent non 32-bit signed integer value: (empty string)'); $intType->serialize(''); } @@ -127,14 +126,14 @@ class ScalarSerializationTest extends \PHPUnit_Framework_TestCase public function testSerializesOutputFloatCannotRepresentString() { $floatType = Type::float(); - $this->setExpectedException(InvariantViolation::class, 'Float cannot represent non numeric value: "one"'); + $this->setExpectedException(Error::class, 'Float cannot represent non numeric value: one'); $floatType->serialize('one'); } public function testSerializesOutputFloatCannotRepresentEmptyString() { $floatType = Type::float(); - $this->setExpectedException(InvariantViolation::class, 'Float cannot represent non numeric value: (empty string)'); + $this->setExpectedException(Error::class, 'Float cannot represent non numeric value: (empty string)'); $floatType->serialize(''); } @@ -156,14 +155,14 @@ class ScalarSerializationTest extends \PHPUnit_Framework_TestCase public function testSerializesOutputStringsCannotRepresentArray() { $stringType = Type::string(); - $this->setExpectedException(InvariantViolation::class, 'String cannot represent non scalar value: array(0)'); + $this->setExpectedException(Error::class, 'String cannot represent non scalar value: []'); $stringType->serialize([]); } public function testSerializesOutputStringsCannotRepresentObject() { $stringType = Type::string(); - $this->setExpectedException(InvariantViolation::class, 'String cannot represent non scalar value: instance of stdClass'); + $this->setExpectedException(Error::class, 'String cannot represent non scalar value: instance of stdClass'); $stringType->serialize(new \stdClass()); } @@ -202,7 +201,7 @@ class ScalarSerializationTest extends \PHPUnit_Framework_TestCase public function testSerializesOutputIDCannotRepresentObject() { $idType = Type::id(); - $this->setExpectedException(InvariantViolation::class, 'ID type cannot represent non scalar value: instance of stdClass'); + $this->setExpectedException(Error::class, 'ID type cannot represent non scalar value: instance of stdClass'); $idType->serialize(new \stdClass()); } } diff --git a/tests/Type/TypeLoaderTest.php b/tests/Type/TypeLoaderTest.php index 1848d7e..db9cb06 100644 --- a/tests/Type/TypeLoaderTest.php +++ b/tests/Type/TypeLoaderTest.php @@ -165,7 +165,7 @@ class TypeLoaderTest extends \PHPUnit_Framework_TestCase { $this->setExpectedException( InvariantViolation::class, - 'Schema type loader must be callable if provided but got: array(0)' + 'Schema type loader must be callable if provided but got: []' ); new Schema([ diff --git a/tests/Type/ValidationTest.php b/tests/Type/ValidationTest.php index 2552470..361607a 100644 --- a/tests/Type/ValidationTest.php +++ b/tests/Type/ValidationTest.php @@ -1,6 +1,7 @@ String = 'TestString'; + $this->Number = 1; $this->SomeScalarType = new CustomScalarType([ 'name' => 'SomeScalar', @@ -55,27 +55,13 @@ class ValidationTest extends \PHPUnit_Framework_TestCase 'fields' => [ 'f' => [ 'type' => Type::string() ] ], 'interfaces' => function() {return [$this->SomeInterfaceType];} ]); - - $this->ObjectWithIsTypeOf = new ObjectType([ - 'name' => 'ObjectWithIsTypeOf', - 'isTypeOf' => function() { - return true; - }, - 'fields' => [ 'f' => [ 'type' => Type::string() ]] - ]); $this->SomeUnionType = new UnionType([ 'name' => 'SomeUnion', - 'resolveType' => function() { - return null; - }, 'types' => [ $this->SomeObjectType ] ]); $this->SomeInterfaceType = new InterfaceType([ 'name' => 'SomeInterface', - 'resolveType' => function() { - return null; - }, 'fields' => [ 'f' => ['type' => Type::string() ]] ]); @@ -105,7 +91,7 @@ class ValidationTest extends \PHPUnit_Framework_TestCase $this->notOutputTypes = $this->withModifiers([ $this->SomeInputObjectType, ]); - $this->notOutputTypes[] = $this->String; + $this->notOutputTypes[] = $this->Number; $this->inputTypes = $this->withModifiers([ Type::string(), @@ -120,7 +106,7 @@ class ValidationTest extends \PHPUnit_Framework_TestCase $this->SomeInterfaceType, ]); - $this->notInputTypes[] = $this->String; + $this->notInputTypes[] = $this->Number; Warning::suppress(Warning::WARNING_NOT_A_TYPE); } @@ -131,6 +117,33 @@ class ValidationTest extends \PHPUnit_Framework_TestCase Warning::enable(Warning::WARNING_NOT_A_TYPE); } + /** + * @param InvariantViolation[]|Error[] $array + * @param array $messages + */ + private function assertContainsValidationMessage($array, $messages) { + $this->assertCount( + count($messages), + $array, + 'For messages: ' . $messages[0]['message'] . "\n" . + "Received: \n" . join("\n", array_map(function($error) { return $error->getMessage(); }, $array)) + ); + foreach ($array as $index => $error) { + if(!isset($messages[$index]) || !$error instanceof Error) { + $this->fail('Received unexpected error: ' . $error->getMessage()); + } + $this->assertEquals($messages[$index]['message'], $error->getMessage()); + $errorLocations = []; + foreach ($error->getLocations() as $location) { + $errorLocations[] = $location->toArray(); + } + $this->assertEquals( + isset($messages[$index]['locations']) ? $messages[$index]['locations'] : [], + $errorLocations + ); + } + } + public function testRejectsTypesWithoutNames() { $this->assertEachCallableThrows([ @@ -149,70 +162,7 @@ class ValidationTest extends \PHPUnit_Framework_TestCase function() { return new InterfaceType([]); } - ], 'Must be named. Unexpected name: null'); - } - - public function testRejectsAnObjectTypeWithReservedName() - { - $this->assertWarnsOnce([ - function() { - return new ObjectType([ - 'name' => '__ReservedName', - ]); - }, - function() { - return new EnumType([ - 'name' => '__ReservedName', - ]); - }, - function() { - return new InputObjectType([ - 'name' => '__ReservedName', - ]); - }, - function() { - return new UnionType([ - 'name' => '__ReservedName', - 'types' => [new ObjectType(['name' => 'Test'])] - ]); - }, - function() { - return new InterfaceType([ - 'name' => '__ReservedName', - ]); - } - ], 'Name "__ReservedName" must not begin with "__", which is reserved by GraphQL introspection. In a future release of graphql this will become an exception'); - } - - public function testRejectsAnObjectTypeWithInvalidName() - { - $this->assertEachCallableThrows([ - function() { - return new ObjectType([ - 'name' => 'a-b-c', - ]); - }, - function() { - return new EnumType([ - 'name' => 'a-b-c', - ]); - }, - function() { - return new InputObjectType([ - 'name' => 'a-b-c', - ]); - }, - function() { - return new UnionType([ - 'name' => 'a-b-c', - ]); - }, - function() { - return new InterfaceType([ - 'name' => 'a-b-c', - ]); - } - ], 'Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "a-b-c" does not.'); + ], 'Must provide name.'); } // DESCRIBE: Type System: A Schema must have Object root types @@ -222,11 +172,22 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testAcceptsASchemaWhoseQueryTypeIsAnObjectType() { - // Must not throw: - $schema = new Schema([ - 'query' => $this->SomeObjectType - ]); - $schema->assertValid(); + $schema = BuildSchema::build(' + type Query { + test: String + } + '); + $this->assertEquals([], $schema->validate()); + + $schemaWithDef = BuildSchema::build(' + schema { + query: QueryRoot + } + type QueryRoot { + test: String + } + '); + $this->assertEquals([], $schemaWithDef->validate()); } /** @@ -234,17 +195,32 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testAcceptsASchemaWhoseQueryAndMutationTypesAreObjectTypes() { - $mutationType = new ObjectType([ - 'name' => 'Mutation', - 'fields' => [ - 'edit' => ['type' => Type::string()] - ] - ]); - $schema = new Schema([ - 'query' => $this->SomeObjectType, - 'mutation' => $mutationType - ]); - $schema->assertValid(); + $schema = BuildSchema::build(' + type Query { + test: String + } + + type Mutation { + test: String + } + '); + $this->assertEquals([], $schema->validate()); + + $schema = BuildSchema::build(' + schema { + query: QueryRoot + mutation: MutationRoot + } + + type QueryRoot { + test: String + } + + type MutationRoot { + test: String + } + '); + $this->assertEquals([], $schema->validate()); } /** @@ -252,17 +228,32 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testAcceptsASchemaWhoseQueryAndSubscriptionTypesAreObjectTypes() { - $subscriptionType = new ObjectType([ - 'name' => 'Subscription', - 'fields' => [ - 'subscribe' => ['type' => Type::string()] - ] - ]); - $schema = new Schema([ - 'query' => $this->SomeObjectType, - 'subscription' => $subscriptionType - ]); - $schema->assertValid(); + $schema = BuildSchema::build(' + type Query { + test: String + } + + type Subscription { + test: String + } + '); + $this->assertEquals([], $schema->validate()); + + $schema = BuildSchema::build(' + schema { + query: QueryRoot + subscription: SubscriptionRoot + } + + type QueryRoot { + test: String + } + + type SubscriptionRoot { + test: String + } + '); + $this->assertEquals([], $schema->validate()); } /** @@ -270,22 +261,74 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testRejectsASchemaWithoutAQueryType() { - $this->setExpectedException(InvariantViolation::class, 'Schema query must be Object Type but got: NULL'); - new Schema([]); + $schema = BuildSchema::build(' + type Mutation { + test: String + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + [['message' => 'Query root type must be provided.']] + ); + + + $schemaWithDef = BuildSchema::build(' + schema { + mutation: MutationRoot + } + + type MutationRoot { + test: String + } + '); + + $this->assertContainsValidationMessage( + $schemaWithDef->validate(), + [[ + 'message' => 'Query root type must be provided.', + 'locations' => [['line' => 2, 'column' => 7]], + ]] + ); } /** - * @it rejects a Schema whose query type is an input type + * @it rejects a Schema whose query root type is not an Object type */ - public function testRejectsASchemaWhoseQueryTypeIsAnInputType() + public function testRejectsASchemaWhoseQueryTypeIsNotAnObjectType() { - $this->setExpectedException( - InvariantViolation::class, - 'Schema query must be Object Type if provided but got: SomeInputObject' + $schema = BuildSchema::build(' + input Query { + test: String + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Query root type must be Object type, it cannot be Query.', + 'locations' => [['line' => 2, 'column' => 7]], + ]] + ); + + + $schemaWithDef = BuildSchema::build(' + schema { + query: SomeInputObject + } + + input SomeInputObject { + test: String + } + '); + + $this->assertContainsValidationMessage( + $schemaWithDef->validate(), + [[ + 'message' => 'Query root type must be Object type, it cannot be SomeInputObject.', + 'locations' => [['line' => 3, 'column' => 16]], + ]] ); - new Schema([ - 'query' => $this->SomeInputObjectType - ]); } /** @@ -293,14 +336,46 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testRejectsASchemaWhoseMutationTypeIsAnInputType() { - $this->setExpectedException( - InvariantViolation::class, - 'Schema mutation must be Object Type if provided but got: SomeInputObject' + $schema = BuildSchema::build(' + type Query { + field: String + } + + input Mutation { + test: String + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Mutation root type must be Object type if provided, it cannot be Mutation.', + 'locations' => [['line' => 6, 'column' => 7]], + ]] + ); + + $schemaWithDef = BuildSchema::build(' + schema { + query: Query + mutation: SomeInputObject + } + + type Query { + field: String + } + + input SomeInputObject { + test: String + } + '); + + $this->assertContainsValidationMessage( + $schemaWithDef->validate(), + [[ + 'message' => 'Mutation root type must be Object type if provided, it cannot be SomeInputObject.', + 'locations' => [['line' => 4, 'column' => 19]], + ]] ); - new Schema([ - 'query' => $this->SomeObjectType, - 'mutation' => $this->SomeInputObjectType - ]); } /** @@ -308,14 +383,48 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testRejectsASchemaWhoseSubscriptionTypeIsAnInputType() { - $this->setExpectedException( - InvariantViolation::class, - 'Schema subscription must be Object Type if provided but got: SomeInputObject' + $schema = BuildSchema::build(' + type Query { + field: String + } + + input Subscription { + test: String + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Subscription root type must be Object type if provided, it cannot be Subscription.', + 'locations' => [['line' => 6, 'column' => 7]], + ]] ); - new Schema([ - 'query' => $this->SomeObjectType, - 'subscription' => $this->SomeInputObjectType - ]); + + $schemaWithDef = BuildSchema::build(' + schema { + query: Query + subscription: SomeInputObject + } + + type Query { + field: String + } + + input SomeInputObject { + test: String + } + '); + + $this->assertContainsValidationMessage( + $schemaWithDef->validate(), + [[ + 'message' => 'Subscription root type must be Object type if provided, it cannot be SomeInputObject.', + 'locations' => [['line' => 4, 'column' => 23]], + ]] + ); + + } /** @@ -328,118 +437,12 @@ class ValidationTest extends \PHPUnit_Framework_TestCase 'directives' => ['somedirective'] ]); - $this->setExpectedException( - InvariantViolation::class, - 'Each entry of "directives" option of Schema config must be an instance of GraphQL\Type\Definition\Directive but entry at position 0 is "somedirective".' + $this->assertContainsValidationMessage( + $schema->validate(), + [['message' => 'Expected directive but got: somedirective.']] ); - - $schema->assertValid(); } - // DESCRIBE: Type System: A Schema must contain uniquely named types - /** - * @it rejects a Schema which redefines a built-in type - */ - public function testRejectsASchemaWhichRedefinesABuiltInType() - { - $FakeString = new CustomScalarType([ - 'name' => 'String', - 'serialize' => function() { - return null; - }, - ]); - - $QueryType = new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'normal' => [ 'type' => Type::string() ], - 'fake' => [ 'type' => $FakeString ], - ] - ]); - - $this->setExpectedException( - InvariantViolation::class, - 'Schema must contain unique named types but contains multiple types named "String" '. - '(see http://webonyx.github.io/graphql-php/type-system/#type-registry).' - ); - new Schema(['query' => $QueryType]); - } - - /** - * @it rejects a Schema which defines an object type twice - */ - public function testRejectsASchemaWhichDfinesAnObjectTypeTwice() - { - $A = new ObjectType([ - 'name' => 'SameName', - 'fields' => [ 'f' => [ 'type' => Type::string() ]], - ]); - - $B = new ObjectType([ - 'name' => 'SameName', - 'fields' => [ 'f' => [ 'type' => Type::string() ] ], - ]); - - $QueryType = new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'a' => [ 'type' => $A ], - 'b' => [ 'type' => $B ] - ] - ]); - - $this->setExpectedException( - InvariantViolation::class, - 'Schema must contain unique named types but contains multiple types named "SameName" '. - '(see http://webonyx.github.io/graphql-php/type-system/#type-registry).' - ); - - new Schema([ 'query' => $QueryType ]); - } - - /** - * @it rejects a Schema which have same named objects implementing an interface - */ - public function testRejectsASchemaWhichHaveSameNamedObjectsImplementingAnInterface() - { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function() {}, - 'fields' => [ 'f' => [ 'type' => Type::string() ]], - ]); - - $FirstBadObject = new ObjectType([ - 'name' => 'BadObject', - 'interfaces' => [ $AnotherInterface ], - 'fields' => [ 'f' => [ 'type' => Type::string() ]], - ]); - - $SecondBadObject = new ObjectType([ - 'name' => 'BadObject', - 'interfaces' => [ $AnotherInterface ], - 'fields' => [ 'f' => [ 'type' => Type::string() ]], - ]); - - $QueryType = new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'iface' => [ 'type' => $AnotherInterface ], - ] - ]); - - $this->setExpectedException( - InvariantViolation::class, - 'Schema must contain unique named types but contains multiple types named "BadObject" '. - '(see http://webonyx.github.io/graphql-php/type-system/#type-registry).' - ); - - new Schema([ - 'query' => $QueryType, - 'types' => [ $FirstBadObject, $SecondBadObject ] - ]); - } - - // DESCRIBE: Type System: Objects must have fields /** @@ -447,31 +450,17 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testAcceptsAnObjectTypeWithFieldsObject() { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => [ - 'f' => [ 'type' => Type::string() ] - ] - ])); + $schema = BuildSchema::build(' + type Query { + field: SomeObject + } - // Should not throw: - $schema->assertValid(); - } + type SomeObject { + field: String + } + '); - /** - * @it accepts an Object type with a field function - */ - public function testAcceptsAnObjectTypeWithAfieldFunction() - { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => function() { - return [ - 'f' => ['type' => Type::string()] - ]; - } - ])); - $schema->assertValid(); + $this->assertEquals([], $schema->validate()); } /** @@ -479,32 +468,45 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testRejectsAnObjectTypeWithMissingFields() { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject' - ])); + $schema = BuildSchema::build(' + type Query { + test: IncompleteObject + } - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject fields must not be empty' - ); - $schema->assertValid(); - } + type IncompleteObject + '); - /** - * @it rejects an Object type field with undefined config - */ - public function testRejectsAnObjectTypeFieldWithUndefinedConfig() - { - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject.f field config must be an array, but got' + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Type IncompleteObject must define one or more fields.', + 'locations' => [['line' => 6, 'column' => 7]], + ]] + ); + + $manualSchema = $this->schemaWithFieldType( + new ObjectType([ + 'name' => 'IncompleteObject', + 'fields' => [], + ]) + ); + + $this->assertContainsValidationMessage( + $manualSchema->validate(), + [['message' => 'Type IncompleteObject must define one or more fields.']] + ); + + $manualSchema2 = $this->schemaWithFieldType( + new ObjectType([ + 'name' => 'IncompleteObject', + 'fields' => function () { return []; }, + ]) + ); + + $this->assertContainsValidationMessage( + $manualSchema2->validate(), + [['message' => 'Type IncompleteObject must define one or more fields.']] ); - $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => [ - 'f' => null - ] - ])); } /** @@ -512,124 +514,33 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testRejectsAnObjectTypeWithIncorrectlyNamedFields() { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => [ - 'bad-name-with-dashes' => ['type' => Type::string()] - ] - ])); - - $this->setExpectedException( - InvariantViolation::class + $schema = $this->schemaWithFieldType( + new ObjectType([ + 'name' => 'SomeObject', + 'fields' => [ + 'bad-name-with-dashes' => ['type' => Type::string()] + ], + ]) ); - $schema->assertValid(); - } - - /** - * @it warns about an Object type with reserved named fields - */ - public function testWarnsAboutAnObjectTypeWithReservedNamedFields() - { - $lastMessage = null; - Warning::setWarningHandler(function($message) use (&$lastMessage) { - $lastMessage = $message; - }); - - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => [ - '__notPartOfIntrospection' => ['type' => Type::string()] - ] - ])); - - $schema->assertValid(); - - $this->assertEquals( - 'Name "__notPartOfIntrospection" must not begin with "__", which is reserved by GraphQL introspection. '. - 'In a future release of graphql this will become an exception', - $lastMessage + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but ' . + '"bad-name-with-dashes" does not.', + ]] ); - Warning::setWarningHandler(null); } public function testAcceptsShorthandNotationForFields() { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => [ - 'field' => Type::string() - ] - ])); - $schema->assertValid(); - } - - /** - * @it rejects an Object type with incorrectly typed fields - */ - public function testRejectsAnObjectTypeWithIncorrectlyTypedFields() - { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => [ - 'field' => new \stdClass(['type' => Type::string()]) - ] - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject.field field type must be Output Type but got: instance of stdClass' - ); - $schema->assertValid(); - } - - /** - * @it rejects an Object type with empty fields - */ - public function testRejectsAnObjectTypeWithEmptyFields() - { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => [] - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject fields must not be empty' - ); - $schema->assertValid(); - } - - /** - * @it rejects an Object type with a field function that returns nothing - */ - public function testRejectsAnObjectTypeWithAFieldFunctionThatReturnsNothing() - { - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject fields must be an array or a callable which returns such an array.' - ); - $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => function() {} - ])); - } - - /** - * @it rejects an Object type with a field function that returns empty - */ - public function testRejectsAnObjectTypeWithAFieldFunctionThatReturnsEmpty() - { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => function() { - return []; - } - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject fields must not be empty' + $schema = $this->schemaWithFieldType( + new ObjectType([ + 'name' => 'SomeObject', + 'fields' => [ + 'field' => Type::string() + ] + ]) ); $schema->assertValid(); } @@ -652,7 +563,7 @@ class ValidationTest extends \PHPUnit_Framework_TestCase ] ] ])); - $schema->assertValid(); + $this->assertEquals([], $schema->validate()); } /** @@ -673,258 +584,59 @@ class ValidationTest extends \PHPUnit_Framework_TestCase ]); $schema = new Schema(['query' => $QueryType]); - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject.badField(bad-name-with-dashes:) Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "bad-name-with-dashes" does not.' + $this->assertContainsValidationMessage( + $schema->validate(), + [['message' => 'Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "bad-name-with-dashes" does not.']] ); - - $schema->assertValid(); } - // DESCRIBE: Type System: Fields args must be objects + // DESCRIBE: Type System: Union types must be valid /** - * @it accepts an Object type with field args - */ - public function testAcceptsAnObjectTypeWithFieldArgs() - { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => [ - 'goodField' => [ - 'type' => Type::string(), - 'args' => [ - 'goodArg' => ['type' => Type::string()] - ] - ] - ] - ])); - $schema->assertValid(); - } - - /** - * @it rejects an Object type with incorrectly typed field args - */ - public function testRejectsAnObjectTypeWithIncorrectlyTypedFieldArgs() - { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => [ - 'badField' => [ - 'type' => Type::string(), - 'args' => [ - ['badArg' => Type::string()] - ] - ] - ] - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject.badField(0:) Must be named. Unexpected name: 0' - ); - - $schema->assertValid(); - } - - // DESCRIBE: Type System: Object interfaces must be array - - /** - * @it accepts an Object type with array interfaces - */ - public function testAcceptsAnObjectTypeWithArrayInterfaces() - { - $AnotherInterfaceType = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => ['f' => ['type' => Type::string()]] - ]); - - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'interfaces' => [$AnotherInterfaceType], - 'fields' => ['f' => ['type' => Type::string()]] - ])); - $schema->assertValid(); - } - - /** - * @it accepts an Object type with interfaces as a function returning an array - */ - public function testAcceptsAnObjectTypeWithInterfacesAsAFunctionReturningAnArray() - { - $AnotherInterfaceType = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => ['f' => ['type' => Type::string()]] - ]); - - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'interfaces' => function () use ($AnotherInterfaceType) { - return [$AnotherInterfaceType]; - }, - 'fields' => ['f' => ['type' => Type::string()]] - ])); - $schema->assertValid(); - } - - /** - * @it rejects an Object type with incorrectly typed interfaces - */ - public function testRejectsAnObjectTypeWithIncorrectlyTypedInterfaces() - { - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject interfaces must be an Array or a callable which returns an Array.' - ); - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'interfaces' => new \stdClass(), - 'fields' => ['f' => ['type' => Type::string()]] - ])); - $schema->assertValid(); - } - - /** - * @it rejects an Object that declare it implements same interface more than once - */ - public function testRejectsAnObjectThatDeclareItImplementsSameInterfaceMoreThanOnce() - { - $NonUniqInterface = new InterfaceType([ - 'name' => 'NonUniqInterface', - 'resolveType' => function () { - }, - 'fields' => ['f' => ['type' => Type::string()]], - ]); - - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function(){}, - 'fields' => ['f' => ['type' => Type::string()]], - ]); - - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'interfaces' => function () use ($NonUniqInterface, $AnotherInterface) { - return [$NonUniqInterface, $AnotherInterface, $NonUniqInterface]; - }, - 'fields' => ['f' => ['type' => Type::string()]] - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject may declare it implements NonUniqInterface only once.' - ); - - $schema->assertValid(); - } - - // TODO: rejects an Object type with interfaces as a function returning an incorrect type - - /** - * @it rejects an Object type with interfaces as a function returning an incorrect type - */ - public function testRejectsAnObjectTypeWithInterfacesAsAFunctionReturningAnIncorrectType() - { - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject interfaces must be an Array or a callable which returns an Array.' - ); - $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'interfaces' => function () { - return new \stdClass(); - }, - 'fields' => ['f' => ['type' => Type::string()]] - ])); - } - - // DESCRIBE: Type System: Union types must be array - - /** - * @it accepts a Union type with array types + * @it accepts a Union type with member types */ public function testAcceptsAUnionTypeWithArrayTypes() { - $schema = $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'resolveType' => function () { - return null; - }, - 'types' => [$this->SomeObjectType], - ])); - $schema->assertValid(); - } + $schema = BuildSchema::build(' + type Query { + test: GoodUnion + } - /** - * @it accepts a Union type with function returning an array of types - */ - public function testAcceptsAUnionTypeWithFunctionReturningAnArrayOfTypes() - { - $schema = $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'resolveType' => function () { - return null; - }, - 'types' => function () { - return [$this->SomeObjectType]; - }, - ])); - $schema->assertValid(); - } + type TypeA { + field: String + } - /** - * @it rejects a Union type without types - */ - public function testRejectsAUnionTypeWithoutTypes() - { - $this->setExpectedException( - InvariantViolation::class, - 'SomeUnion types must be an Array or a callable which returns an Array.' - ); - $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'resolveType' => function() {return null;} - ])); + type TypeB { + field: String + } + + union GoodUnion = + | TypeA + | TypeB + '); + + $this->assertEquals([], $schema->validate()); } /** * @it rejects a Union type with empty types */ - public function testRejectsAUnionTypeWithemptyTypes() + public function testRejectsAUnionTypeWithEmptyTypes() { - $schema = $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'resolveType' => function () { - }, - 'types' => [] - ])); + $schema = BuildSchema::build(' + type Query { + test: BadUnion + } - $this->setExpectedException( - InvariantViolation::class, - 'SomeUnion types must not be empty' + union BadUnion + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Union type BadUnion must define one or more member types.', + 'locations' => [['line' => 6, 'column' => 7]], + ]] ); - $schema->assertValid(); - } - - /** - * @it rejects a Union type with incorrectly typed types - */ - public function testRejectsAUnionTypeWithIncorrectlyTypedTypes() - { - $this->setExpectedException( - InvariantViolation::class, - 'SomeUnion types must be an Array or a callable which returns an Array.' - ); - $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'resolveType' => function () { - }, - 'types' => $this->SomeObjectType - ])); } /** @@ -932,20 +644,88 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testRejectsAUnionTypeWithDuplicatedMemberType() { - $schema = $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'resolveType' => function(){}, - 'types' => [ - $this->SomeObjectType, - $this->SomeObjectType, - ], - ])); + $schema = BuildSchema::build(' + type Query { + test: BadUnion + } - $this->setExpectedException( - InvariantViolation::class, - 'SomeUnion can include SomeObject type only once.' + type TypeA { + field: String + } + + type TypeB { + field: String + } + + union BadUnion = + | TypeA + | TypeB + | TypeA + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Union type BadUnion can only include type TypeA once.', + 'locations' => [['line' => 15, 'column' => 11], ['line' => 17, 'column' => 11]], + ]] ); - $schema->assertValid(); + } + + /** + * @it rejects a Union type with non-Object members types + */ + public function testRejectsAUnionTypeWithNonObjectMembersType() + { + $schema = BuildSchema::build(' + type Query { + test: BadUnion + } + + type TypeA { + field: String + } + + type TypeB { + field: String + } + + union BadUnion = + | TypeA + | String + | TypeB + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Union type BadUnion can only include Object types, ' . + 'it cannot include String.', + 'locations' => [['line' => 16, 'column' => 11]], + ]] + + ); + + $badUnionMemberTypes = [ + Type::string(), + Type::nonNull($this->SomeObjectType), + Type::listOf($this->SomeObjectType), + $this->SomeInterfaceType, + $this->SomeUnionType, + $this->SomeEnumType, + $this->SomeInputObjectType, + ]; + + foreach($badUnionMemberTypes as $memberType) { + $badSchema = $this->schemaWithFieldType( + new UnionType(['name' => 'BadUnion', 'types' => [$memberType]]) + ); + $this->assertContainsValidationMessage( + $badSchema->validate(), + [[ + 'message' => 'Union type BadUnion can only include Object types, ' . + "it cannot include ". Utils::printSafe($memberType) . ".", + ]] + ); + } } // DESCRIBE: Type System: Input Objects must have fields @@ -955,31 +735,16 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testAcceptsAnInputObjectTypeWithFields() { - $schema = $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - 'fields' => [ - 'f' => ['type' => Type::string()] - ] - ])); + $schema = BuildSchema::build(' + type Query { + field(arg: SomeInputObject): String + } - $schema->assertValid(); - } - - /** - * @it accepts an Input Object type with a field function - */ - public function testAcceptsAnInputObjectTypeWithAFieldFunction() - { - $schema = $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - 'fields' => function () { - return [ - 'f' => ['type' => Type::string()] - ]; - } - ])); - - $schema->assertValid(); + input SomeInputObject { + field: String + } + '); + $this->assertEquals([], $schema->validate()); } /** @@ -987,15 +752,20 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testRejectsAnInputObjectTypeWithMissingFields() { - $schema = $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - ])); + $schema = BuildSchema::build(' + type Query { + field(arg: SomeInputObject): String + } - $this->setExpectedException( - InvariantViolation::class, - 'SomeInputObject fields must not be empty' + input SomeInputObject + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Input Object type SomeInputObject must define one or more fields.', + 'locations' => [['line' => 6, 'column' => 7]], + ]] ); - $schema->assertValid(); } /** @@ -1003,611 +773,80 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testRejectsAnInputObjectTypeWithIncorrectlyTypedFields() { - $schema = $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - 'fields' => [ - ['field' => Type::string()] - ] - ])); + $schema = BuildSchema::build(' + type Query { + field(arg: SomeInputObject): String + } + + type SomeObject { + field: String + } - $this->setExpectedException( - InvariantViolation::class, - 'SomeInputObject.0: Must be named. Unexpected name: 0' + union SomeUnion = SomeObject + + input SomeInputObject { + badObject: SomeObject + badUnion: SomeUnion + goodInputObject: SomeInputObject + } + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of SomeInputObject.badObject must be Input Type but got: SomeObject.', + 'locations' => [['line' => 13, 'column' => 20]], + ],[ + 'message' => 'The type of SomeInputObject.badUnion must be Input Type but got: SomeUnion.', + 'locations' => [['line' => 14, 'column' => 19]], + ]] ); - $schema->assertValid(); } - /** - * @it rejects an Input Object type with empty fields - */ - public function testRejectsAnInputObjectTypeWithEmptyFields() - { - $this->setExpectedException( - InvariantViolation::class, - 'SomeInputObject fields must be an array or a callable which returns such an array.' - ); - $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - 'fields' => new \stdClass() - ])); - } - - /** - * @it rejects an Input Object type with a field function that returns nothing - */ - public function testRejectsAnInputObjectTypeWithAFieldFunctionThatReturnsNothing() - { - $this->setExpectedException( - InvariantViolation::class, - 'SomeInputObject fields must be an array or a callable which returns such an array.' - ); - $this->schemaWithInputObject(new ObjectType([ - 'name' => 'SomeInputObject', - 'fields' => function () { - } - ])); - } - - /** - * @it rejects an Input Object type with a field function that returns empty - */ - public function testRejectsAnInputObjectTypeWithAFieldFunctionThatReturnsEmpty() - { - $this->setExpectedException( - InvariantViolation::class, - 'SomeInputObject fields must be an array or a callable which returns such an array.' - ); - $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - 'fields' => function () { - return new \stdClass(); - } - ])); - } - - // DESCRIBE: Type System: Input Object fields must not have resolvers - - /** - * @it accepts an Input Object type with no resolver - */ - public function testAcceptsAnInputObjectTypeWithNoResolver() - { - $schema = $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - 'fields' => [ - 'f' => [ - 'type' => Type::string(), - ] - ] - ])); - - $schema->assertValid(); - } - - /** - * @it accepts an Input Object type with null resolver - */ - public function testAcceptsAnInputObjectTypeWithNullResolver() - { - $schema = $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - 'fields' => [ - 'f' => [ - 'type' => Type::string(), - 'resolve' => null, - ] - ] - ])); - $schema->assertValid(); - } - - /** - * @it rejects an Input Object type with resolver function - */ - public function testRejectsAnInputObjectTypeWithResolverFunction() - { - $schema = $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - 'fields' => [ - 'f' => [ - 'type' => Type::string(), - 'resolve' => function () { - return 0; - }, - ] - ] - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeInputObject.f field type has a resolve property, but Input Types cannot define resolvers.' - ); - $schema->assertValid(); - } - - /** - * @it rejects an Input Object type with resolver constant - */ - public function testRejectsAnInputObjectTypeWithResolverConstant() - { - $schema = $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - 'fields' => [ - 'f' => [ - 'type' => Type::string(), - 'resolve' => new \stdClass(), - ] - ] - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeInputObject.f field type has a resolve property, but Input Types cannot define resolvers.' - ); - $schema->assertValid(); - } - - - // DESCRIBE: Type System: Object types must be assertable - - /** - * @it accepts an Object type with an isTypeOf function - */ - public function testAcceptsAnObjectTypeWithAnIsTypeOfFunction() - { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'AnotherObject', - 'isTypeOf' => function () { - return true; - }, - 'fields' => ['f' => ['type' => Type::string()]] - ])); - $schema->assertValid(); - } - - /** - * @it rejects an Object type with an incorrect type for isTypeOf - */ - public function testRejectsAnObjectTypeWithAnIncorrectTypeForIsTypeOf() - { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'AnotherObject', - 'isTypeOf' => new \stdClass(), - 'fields' => ['f' => ['type' => Type::string()]] - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'AnotherObject must provide \'isTypeOf\' as a function' - ); - - $schema->assertValid(); - } - - // DESCRIBE: Type System: Interface types must be resolvable - - /** - * @it accepts an Interface type defining resolveType - */ - public function testAcceptsAnInterfaceTypeDefiningResolveType() - { - $AnotherInterfaceType = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => ['f' => ['type' => Type::string()]] - ]); - - $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'interfaces' => [$AnotherInterfaceType], - 'fields' => ['f' => ['type' => Type::string()]] - ])); - } - - /** - * @it accepts an Interface with implementing type defining isTypeOf - */ - public function testAcceptsAnInterfaceWithImplementingTypeDefiningIsTypeOf() - { - $InterfaceTypeWithoutResolveType = new InterfaceType([ - 'name' => 'InterfaceTypeWithoutResolveType', - 'fields' => ['f' => ['type' => Type::string()]] - ]); - - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'isTypeOf' => function () { - return true; - }, - 'interfaces' => [$InterfaceTypeWithoutResolveType], - 'fields' => ['f' => ['type' => Type::string()]] - ])); - - $schema->assertValid(); - } - - /** - * @it accepts an Interface type defining resolveType with implementing type defining isTypeOf - */ - public function testAcceptsAnInterfaceTypeDefiningResolveTypeWithImplementingTypeDefiningIsTypeOf() - { - $AnotherInterfaceType = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => ['f' => ['type' => Type::string()]] - ]); - - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'isTypeOf' => function () { - return true; - }, - 'interfaces' => [$AnotherInterfaceType], - 'fields' => ['f' => ['type' => Type::string()]] - ])); - - $schema->assertValid(); - } - - /** - * @it rejects an Interface type with an incorrect type for resolveType - */ - public function testRejectsAnInterfaceTypeWithAnIncorrectTypeForResolveType() - { - $type = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => new \stdClass(), - 'fields' => ['f' => ['type' => Type::string()]] - ]); - - $this->setExpectedException( - InvariantViolation::class, - 'AnotherInterface must provide "resolveType" as a function.' - ); - - $type->assertValid(); - } - - /** - * @it rejects an Interface type not defining resolveType with implementing type not defining isTypeOf - */ - public function testRejectsAnInterfaceTypeNotDefiningResolveTypeWithImplementingTypeNotDefiningIsTypeOf() - { - $InterfaceTypeWithoutResolveType = new InterfaceType([ - 'name' => 'InterfaceTypeWithoutResolveType', - 'fields' => ['f' => ['type' => Type::string()]] - ]); - - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'interfaces' => [$InterfaceTypeWithoutResolveType], - 'fields' => ['f' => ['type' => Type::string()]] - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'Interface Type InterfaceTypeWithoutResolveType does not provide a "resolveType" function and implementing '. - 'Type SomeObject does not provide a "isTypeOf" function. There is no way to resolve this implementing type '. - 'during execution.' - ); - - $schema->assertValid(); - } - - // DESCRIBE: Type System: Union types must be resolvable - - /** - * @it accepts a Union type defining resolveType - */ - public function testAcceptsAUnionTypeDefiningResolveType() - { - $schema = $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'resolveType' => function () { - }, - 'types' => [$this->SomeObjectType], - ])); - $schema->assertValid(); - } - - /** - * @it accepts a Union of Object types defining isTypeOf - */ - public function testAcceptsAUnionOfObjectTypesDefiningIsTypeOf() - { - $schema = $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'types' => [$this->ObjectWithIsTypeOf], - ])); - - $schema->assertValid(); - } - - /** - * @it accepts a Union type defining resolveType of Object types defining isTypeOf - */ - public function testAcceptsAUnionTypeDefiningResolveTypeOfObjectTypesDefiningIsTypeOf() - { - $schema = $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'resolveType' => function () { - }, - 'types' => [$this->ObjectWithIsTypeOf], - ])); - $schema->assertValid(); - } - - /** - * @it rejects a Union type with an incorrect type for resolveType - */ - public function testRejectsAUnionTypeWithAnIncorrectTypeForResolveType() - { - $schema = $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'resolveType' => new \stdClass(), - 'types' => [$this->ObjectWithIsTypeOf], - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeUnion must provide "resolveType" as a function.' - ); - - $schema->assertValid(); - } - - /** - * @it rejects a Union type not defining resolveType of Object types not defining isTypeOf - */ - public function testRejectsAUnionTypeNotDefiningResolveTypeOfObjectTypesNotDefiningIsTypeOf() - { - $schema = $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'types' => [$this->SomeObjectType], - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'Union type "SomeUnion" does not provide a "resolveType" function and possible type "SomeObject" '. - 'does not provide an "isTypeOf" function. There is no way to resolve this possible type during execution.' - ); - - $schema->assertValid(); - } - - // DESCRIBE: Type System: Scalar types must be serializable - - /** - * @it accepts a Scalar type defining serialize - */ - public function testAcceptsAScalarTypeDefiningSerialize() - { - $schema = $this->schemaWithFieldType(new CustomScalarType([ - 'name' => 'SomeScalar', - 'serialize' => function () { - }, - ])); - $schema->assertValid(); - } - - /** - * @it rejects a Scalar type not defining serialize - */ - public function testRejectsAScalarTypeNotDefiningSerialize() - { - $schema = $this->schemaWithFieldType(new CustomScalarType([ - 'name' => 'SomeScalar', - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeScalar must provide "serialize" function. If this custom Scalar is also used as an input type, '. - 'ensure "parseValue" and "parseLiteral" functions are also provided.' - ); - - $schema->assertValid(); - } - - /** - * @it rejects a Scalar type defining serialize with an incorrect type - */ - public function testRejectsAScalarTypeDefiningSerializeWithAnIncorrectType() - { - $schema = $this->schemaWithFieldType(new CustomScalarType([ - 'name' => 'SomeScalar', - 'serialize' => new \stdClass() - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeScalar must provide "serialize" function. If this custom Scalar ' . - 'is also used as an input type, ensure "parseValue" and "parseLiteral" ' . - 'functions are also provided.' - ); - - $schema->assertValid(); - } - - /** - * @it accepts a Scalar type defining parseValue and parseLiteral - */ - public function testAcceptsAScalarTypeDefiningParseValueAndParseLiteral() - { - $schema = $this->schemaWithFieldType(new CustomScalarType([ - 'name' => 'SomeScalar', - 'serialize' => function () { - }, - 'parseValue' => function () { - }, - 'parseLiteral' => function () { - }, - ])); - - $schema->assertValid(); - } - - /** - * @it rejects a Scalar type defining parseValue but not parseLiteral - */ - public function testRejectsAScalarTypeDefiningParseValueButNotParseLiteral() - { - $schema = $this->schemaWithFieldType(new CustomScalarType([ - 'name' => 'SomeScalar', - 'serialize' => function () { - }, - 'parseValue' => function () { - }, - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeScalar must provide both "parseValue" and "parseLiteral" functions.' - ); - $schema->assertValid(); - } - - /** - * @it rejects a Scalar type defining parseLiteral but not parseValue - */ - public function testRejectsAScalarTypeDefiningParseLiteralButNotParseValue() - { - $schema = $this->schemaWithFieldType(new CustomScalarType([ - 'name' => 'SomeScalar', - 'serialize' => function () { - }, - 'parseLiteral' => function () { - }, - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeScalar must provide both "parseValue" and "parseLiteral" functions.' - ); - - $schema->assertValid(); - } - - /** - * @it rejects a Scalar type defining parseValue and parseLiteral with an incorrect type - */ - public function testRejectsAScalarTypeDefiningParseValueAndParseLiteralWithAnIncorrectType() - { - $schema = $this->schemaWithFieldType(new CustomScalarType([ - 'name' => 'SomeScalar', - 'serialize' => function () { - }, - 'parseValue' => new \stdClass(), - 'parseLiteral' => new \stdClass(), - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeScalar must provide both "parseValue" and "parseLiteral" functions.' - ); - - $schema->assertValid(); - } - - // DESCRIBE: Type System: Enum types must be well defined - /** - * @it accepts a well defined Enum type with empty value definition - */ - public function testAcceptsAWellDefinedEnumTypeWithEmptyValueDefinition() - { - $type = new EnumType([ - 'name' => 'SomeEnum', - 'values' => [ - 'FOO' => [], - 'BAR' => [], - ] - ]); - - $type->assertValid(); - } - - // TODO: accepts a well defined Enum type with internal value definition - - /** - * @it accepts a well defined Enum type with internal value definition - */ - public function testAcceptsAWellDefinedEnumTypeWithInternalValueDefinition() - { - $type = new EnumType([ - 'name' => 'SomeEnum', - 'values' => [ - 'FOO' => ['value' => 10], - 'BAR' => ['value' => 20], - ] - ]); - $type->assertValid(); - } - /** * @it rejects an Enum type without values */ public function testRejectsAnEnumTypeWithoutValues() { - $type = new EnumType([ - 'name' => 'SomeEnum', - ]); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeEnum values must be an array.' + $schema = BuildSchema::build(' + type Query { + field: SomeEnum + } + + enum SomeEnum + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Enum type SomeEnum must define one or more values.', + 'locations' => [['line' => 6, 'column' => 7]], + ]] ); - - $type->assertValid(); } /** - * @it rejects an Enum type with empty values + * @it rejects an Enum type with duplicate values */ - public function testRejectsAnEnumTypeWithEmptyValues() + public function testRejectsAnEnumTypeWithDuplicateValues() { - $type = new EnumType([ - 'name' => 'SomeEnum', - 'values' => [] - ]); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeEnum values must be not empty.' + $schema = BuildSchema::build(' + type Query { + field: SomeEnum + } + + enum SomeEnum { + SOME_VALUE + SOME_VALUE + } + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Enum type SomeEnum can include value SOME_VALUE only once.', + 'locations' => [['line' => 7, 'column' => 9], ['line' => 8, 'column' => 9]], + ]] ); - - $type->assertValid(); - } - - /** - * @it rejects an Enum type with incorrectly typed values - */ - public function testRejectsAnEnumTypeWithIncorrectlyTypedValues() - { - $type = new EnumType([ - 'name' => 'SomeEnum', - 'values' => [ - ['FOO' => 10] - ] - ]); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeEnum values must be an array with value names as keys.' - ); - $type->assertValid(); - } - - public function invalidEnumValueName() - { - return [ - ['#value', 'SomeEnum has value with invalid name: "#value" (Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "#value" does not.)'], - ['true', 'SomeEnum: "true" can not be used as an Enum value.'], - ['false', 'SomeEnum: "false" can not be used as an Enum value.'], - ['null', 'SomeEnum: "null" can not be used as an Enum value.'], - ]; } public function testDoesNotAllowIsDeprecatedWithoutDeprecationReasonOnEnum() @@ -1625,14 +864,28 @@ class ValidationTest extends \PHPUnit_Framework_TestCase $enum->assertValid(); } - private function enumValue($name) + private function schemaWithEnum($name) { - return new EnumType([ - 'name' => 'SomeEnum', - 'values' => [ - $name => [] - ] - ]); + return $this->schemaWithFieldType( + new EnumType([ + 'name' => 'SomeEnum', + 'values' => [ + $name => [] + ] + ]) + ); + } + + public function invalidEnumValueName() + { + return [ + ['#value', 'Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "#value" does not.'], + ['1value', 'Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "1value" does not.'], + ['KEBAB-CASE', 'Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "KEBAB-CASE" does not.'], + ['false', 'Enum type SomeEnum cannot include value: false.'], + ['true', 'Enum type SomeEnum cannot include value: true.'], + ['null', 'Enum type SomeEnum cannot include value: null.'], + ]; } /** @@ -1641,10 +894,14 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testRejectsAnEnumTypeWithIncorrectlyNamedValues($name, $expectedMessage) { - $enum = $this->enumValue($name); + $schema = $this->schemaWithEnum($name); - $this->setExpectedException(InvariantViolation::class, $expectedMessage); - $enum->assertValid(); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => $expectedMessage, + ]] + ); } // DESCRIBE: Type System: Object fields must have output types @@ -1656,12 +913,10 @@ class ValidationTest extends \PHPUnit_Framework_TestCase { foreach ($this->outputTypes as $type) { $schema = $this->schemaWithObjectFieldOfType($type); - $schema->assertValid(); + $this->assertEquals([], $schema->validate()); } } - // TODO: rejects an empty Object field type - /** * @it rejects an empty Object field type */ @@ -1669,12 +924,12 @@ class ValidationTest extends \PHPUnit_Framework_TestCase { $schema = $this->schemaWithObjectFieldOfType(null); - $this->setExpectedException( - InvariantViolation::class, - 'BadObject.badField field type must be Output Type but got: null' + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of BadObject.badField must be Output Type but got: null.', + ]] ); - - $schema->assertValid(); } /** @@ -1685,144 +940,123 @@ class ValidationTest extends \PHPUnit_Framework_TestCase foreach ($this->notOutputTypes as $type) { $schema = $this->schemaWithObjectFieldOfType($type); - try { - $schema->assertValid(); - $this->fail('Expected exception not thrown for ' . Utils::printSafe($type)); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'BadObject.badField field type must be Output Type but got: ' . Utils::printSafe($type), - $e->getMessage() - ); - } + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of BadObject.badField must be Output Type but got: ' . Utils::printSafe($type) . '.', + ]] + ); } } - // DESCRIBE: Type System: Object fields must have valid resolve values - /** - * @it accepts a lambda as an Object field resolver + * @it rejects with relevant locations for a non-output type as an Object field type */ - public function testAcceptsALambdaAsAnObjectFieldResolver() + public function testRejectsWithReleventLocationsForANonOutputTypeAsAnObjectFieldType() { - $schema = $this->schemaWithObjectWithFieldResolver(function() {return [];}); - $schema->assertValid(); - } - - /** - * @it rejects an empty Object field resolver - */ - public function testRejectsAnEmptyObjectFieldResolver() - { - $schema = $this->schemaWithObjectWithFieldResolver([]); - - $this->setExpectedException( - InvariantViolation::class, - 'BadResolver.badField field resolver must be a function if provided, but got: array(0)' + $schema = BuildSchema::build(' + type Query { + field: [SomeInputObject] + } + + input SomeInputObject { + field: String + } + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of Query.field must be Output Type but got: [SomeInputObject].', + 'locations' => [['line' => 3, 'column' => 16]], + ]] ); - - $schema->assertValid(); } - /** - * @it rejects a constant scalar value resolver - */ - public function testRejectsAConstantScalarValueResolver() - { - $schema = $this->schemaWithObjectWithFieldResolver(0); - $this->setExpectedException( - InvariantViolation::class, - 'BadResolver.badField field resolver must be a function if provided, but got: 0' - ); - $schema->assertValid(); - } - - - - // DESCRIBE: Type System: Objects can only implement interfaces - - /** - * @it accepts an Object implementing an Interface - */ - public function testAcceptsAnObjectImplementingAnInterface() - { - $AnotherInterfaceType = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => ['f' => ['type' => Type::string()]] - ]); - - $schema = $this->schemaWithObjectImplementingType($AnotherInterfaceType); - $schema->assertValid(); - } + // DESCRIBE: Type System: Objects can only implement unique interfaces /** * @it rejects an Object implementing a non-Interface type */ public function testRejectsAnObjectImplementingANonInterfaceType() { - $notInterfaceTypes = $this->withModifiers([ - $this->SomeScalarType, - $this->SomeEnumType, - $this->SomeObjectType, - $this->SomeUnionType, - $this->SomeInputObjectType, - ]); - foreach ($notInterfaceTypes as $type) { - $schema = $this->schemaWithObjectImplementingType($type); - - try { - $schema->assertValid(); - $this->fail('Exepected exception not thrown for type ' . $type); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'BadObject may only implement Interface types, it cannot implement ' . $type . '.', - $e->getMessage() - ); - } - } - } - - - // DESCRIBE: Type System: Unions must represent Object types - - /** - * @it accepts a Union of an Object Type - */ - public function testAcceptsAUnionOfAnObjectType() - { - $schema = $this->schemaWithUnionOfType($this->SomeObjectType); - $schema->assertValid(); + $schema = BuildSchema::build(' + type Query { + field: BadObject + } + + input SomeInputObject { + field: String + } + + type BadObject implements SomeInputObject { + field: String + } + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Type BadObject must only implement Interface types, it cannot implement SomeInputObject.', + 'locations' => [['line' => 10, 'column' => 33]], + ]] + ); } /** - * @it rejects a Union of a non-Object type + * @it rejects an Object implementing the same interface twice */ - public function testRejectsAUnionOfANonObjectType() + public function testRejectsAnObjectImplementingTheSameInterfaceTwice() { - $notObjectTypes = $this->withModifiers([ - $this->SomeScalarType, - $this->SomeEnumType, - $this->SomeInterfaceType, - $this->SomeUnionType, - $this->SomeInputObjectType, - ]); - foreach ($notObjectTypes as $type) { - $schema = $this->schemaWithUnionOfType($type); - try { - $schema->assertValid(); - $this->fail('Expected exception not thrown for type: ' . $type); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'BadUnion may only contain Object types, it cannot contain: ' . $type . '.', - $e->getMessage() - ); - } - } - - // "BadUnion may only contain Object types, it cannot contain: $type." + $schema = BuildSchema::build(' + type Query { + field: AnotherObject + } + + interface AnotherInterface { + field: String + } + + type AnotherObject implements AnotherInterface, AnotherInterface { + field: String + } + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Type AnotherObject can only implement AnotherInterface once.', + 'locations' => [['line' => 10, 'column' => 37], ['line' => 10, 'column' => 55]], + ]] + ); } + /** + * @it rejects an Object implementing the same interface twice due to extension + */ + public function testRejectsAnObjectImplementingTheSameInterfaceTwiceDueToExtension() + { + $this->markTestIncomplete('extend does not work this way (yet).'); + $schema = BuildSchema::build(' + type Query { + field: AnotherObject + } + + interface AnotherInterface { + field: String + } + + type AnotherObject implements AnotherInterface { + field: String + } + + extend type AnotherObject implements AnotherInterface + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Type AnotherObject can only implement AnotherInterface once.', + 'locations' => [['line' => 10, 'column' => 37], ['line' => 14, 'column' => 38]], + ]] + ); + } // DESCRIBE: Type System: Interface fields must have output types @@ -1833,7 +1067,7 @@ class ValidationTest extends \PHPUnit_Framework_TestCase { foreach ($this->outputTypes as $type) { $schema = $this->schemaWithInterfaceFieldOfType($type); - $schema->assertValid(); + $this->assertEquals([], $schema->validate()); } } @@ -1843,13 +1077,12 @@ class ValidationTest extends \PHPUnit_Framework_TestCase public function testRejectsAnEmptyInterfaceFieldType() { $schema = $this->schemaWithInterfaceFieldOfType(null); - - $this->setExpectedException( - InvariantViolation::class, - 'BadInterface.badField field type must be Output Type but got: null' + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of BadInterface.badField must be Output Type but got: null.', + ]] ); - - $schema->assertValid(); } /** @@ -1860,18 +1093,41 @@ class ValidationTest extends \PHPUnit_Framework_TestCase foreach ($this->notOutputTypes as $type) { $schema = $this->schemaWithInterfaceFieldOfType($type); - try { - $schema->assertValid(); - $this->fail('Expected exception not thrown for type ' . $type); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'BadInterface.badField field type must be Output Type but got: ' . Utils::printSafe($type), - $e->getMessage() - ); - } + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of BadInterface.badField must be Output Type but got: ' . Utils::printSafe($type) . '.', + ]] + ); } } + /** + * @it rejects a non-output type as an Interface field type with locations + */ + public function testRejectsANonOutputTypeAsAnInterfaceFieldTypeWithLocations() + { + $schema = BuildSchema::build(' + type Query { + field: SomeInterface + } + + interface SomeInterface { + field: SomeInputObject + } + + input SomeInputObject { + foo: String + } + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of SomeInterface.field must be Output Type but got: SomeInputObject.', + 'locations' => [['line' => 7, 'column' => 16]], + ]] + ); + } // DESCRIBE: Type System: Field arguments must have input types @@ -1882,7 +1138,7 @@ class ValidationTest extends \PHPUnit_Framework_TestCase { foreach ($this->inputTypes as $type) { $schema = $this->schemaWithArgOfType($type); - $schema->assertValid(); + $this->assertEquals([], $schema->validate()); } } @@ -1892,9 +1148,12 @@ class ValidationTest extends \PHPUnit_Framework_TestCase public function testRejectsAnEmptyFieldArgType() { $schema = $this->schemaWithArgOfType(null); - - $this->setExpectedException(InvariantViolation::class, 'BadObject.badField(badArg): argument type must be Input Type but got: null'); - $schema->assertValid(); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of BadObject.badField(badArg:) must be Input Type but got: null.', + ]] + ); } /** @@ -1904,18 +1163,37 @@ class ValidationTest extends \PHPUnit_Framework_TestCase { foreach ($this->notInputTypes as $type) { $schema = $this->schemaWithArgOfType($type); - try { - $schema->assertValid(); - $this->fail('Expected exception not thrown for type ' . $type); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'BadObject.badField(badArg): argument type must be Input Type but got: ' . Utils::printSafe($type), - $e->getMessage() - ); - } + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of BadObject.badField(badArg:) must be Input Type but got: ' . Utils::printSafe($type) . '.', + ]] + ); } } + /** + * @it rejects a non-input type as a field arg with locations + */ + public function testANonInputTypeAsAFieldArgWithLocations() + { + $schema = BuildSchema::build(' + type Query { + test(arg: SomeObject): String + } + + type SomeObject { + foo: String + } + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of Query.test(arg:) must be Input Type but got: SomeObject.', + 'locations' => [['line' => 3, 'column' => 19]], + ]] + ); + } // DESCRIBE: Type System: Input Object fields must have input types @@ -1926,7 +1204,7 @@ class ValidationTest extends \PHPUnit_Framework_TestCase { foreach ($this->inputTypes as $type) { $schema = $this->schemaWithInputFieldOfType($type); - $schema->assertValid(); + $this->assertEquals([], $schema->validate()); } } @@ -1936,12 +1214,12 @@ class ValidationTest extends \PHPUnit_Framework_TestCase public function testRejectsAnEmptyInputFieldType() { $schema = $this->schemaWithInputFieldOfType(null); - - $this->setExpectedException( - InvariantViolation::class, - 'BadInputObject.badField field type must be Input Type but got: null.' + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of BadInputObject.badField must be Input Type but got: null.', + ]] ); - $schema->assertValid(); } /** @@ -1951,131 +1229,42 @@ class ValidationTest extends \PHPUnit_Framework_TestCase { foreach ($this->notInputTypes as $type) { $schema = $this->schemaWithInputFieldOfType($type); - try { - $schema->assertValid(); - $this->fail('Expected exception not thrown for type ' . $type); - } catch (InvariantViolation $e) { - $this->assertEquals( - "BadInputObject.badField field type must be Input Type but got: " . Utils::printSafe($type) . ".", - $e->getMessage() - ); - } - } - } - - - // DESCRIBE: Type System: List must accept GraphQL types - - /** - * @it accepts an type as item type of list - */ - public function testAcceptsAnTypeAsItemTypeOfList() - { - $types = $this->withModifiers([ - Type::string(), - $this->SomeScalarType, - $this->SomeObjectType, - $this->SomeUnionType, - $this->SomeInterfaceType, - $this->SomeEnumType, - $this->SomeInputObjectType, - ]); - - foreach ($types as $type) { - try { - Type::listOf($type); - } catch (\Exception $e) { - throw new \Exception("Expection thrown for type $type: {$e->getMessage()}", null, $e); - } + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of BadInputObject.badField must be Input Type but got: ' . Utils::printSafe($type) . '.', + ]] + ); } } /** - * @it rejects a non-type as item type of list + * @it rejects a non-input type as an input object field with locations */ - public function testRejectsANonTypeAsItemTypeOfList() + public function testRejectsANonInputTypeAsAnInputObjectFieldWithLocations() { - $notTypes = [ - [], - new \stdClass(), - 'String', - 10, - null, - true, - false, - // TODO: function() {} - ]; - foreach ($notTypes as $type) { - try { - Type::listOf($type); - $this->fail("Expected exception not thrown for: " . Utils::printSafe($type)); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Can only create List of a GraphQLType but got: ' . Utils::printSafe($type), - $e->getMessage() - ); - } - } + $schema = BuildSchema::build(' + type Query { + test(arg: SomeInputObject): String + } + + input SomeInputObject { + foo: SomeObject + } + + type SomeObject { + bar: String + } + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of SomeInputObject.foo must be Input Type but got: SomeObject.', + 'locations' => [['line' => 7, 'column' => 14]], + ]] + ); } - - // DESCRIBE: Type System: NonNull must accept GraphQL types - - /** - * @it accepts an type as nullable type of non-null - */ - public function testAcceptsAnTypeAsNullableTypeOfNonNull() - { - $nullableTypes = [ - Type::string(), - $this->SomeScalarType, - $this->SomeObjectType, - $this->SomeUnionType, - $this->SomeInterfaceType, - $this->SomeEnumType, - $this->SomeInputObjectType, - Type::listOf(Type::string()), - Type::listOf(Type::nonNull(Type::string())), - ]; - foreach ($nullableTypes as $type) { - try { - Type::nonNull($type); - } catch (\Exception $e) { - throw new \Exception("Exception thrown for type $type: " . $e->getMessage(), null, $e); - } - } - } - - // TODO: rejects a non-type as nullable type of non-null: ${type} - - /** - * @it rejects a non-type as nullable type of non-null - */ - public function testRejectsANonTypeAsNullableTypeOfNonNull() - { - $notNullableTypes = [ - Type::nonNull(Type::string()), - [], - new \stdClass(), - 'String', - null, - true, - false - ]; - foreach ($notNullableTypes as $type) { - try { - Type::nonNull($type); - $this->fail("Expected exception not thrown for: " . Utils::printSafe($type)); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Can only create NonNull of a Nullable GraphQLType but got: ' . Utils::printSafe($type), - $e->getMessage() - ); - } - } - } - - // DESCRIBE: Objects must adhere to Interface they implement /** @@ -2083,35 +1272,24 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testAcceptsAnObjectWhichImplementsAnInterface() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()] - ] - ] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field(input: String): String + } + + type AnotherObject implements AnotherInterface { + field(input: String): String + } + '); - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()] - ] - ] - ] - ]); - - $schema = $this->schemaWithFieldType($AnotherObject); - $schema->assertValid(); + $this->assertEquals( + [], + $schema->validate() + ); } /** @@ -2119,36 +1297,25 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testAcceptsAnObjectWhichImplementsAnInterfaceAlongWithMoreFields() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()], - ] - ] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()], - ] - ], - 'anotherfield' => ['type' => Type::string()] - ] - ]); + interface AnotherInterface { + field(input: String): String + } - $schema = $this->schemaWithFieldType($AnotherObject); - $schema->assertValid(); + type AnotherObject implements AnotherInterface { + field(input: String): String + anotherField: String + } + '); + + $this->assertEquals( + [], + $schema->validate() + ); } /** @@ -2156,79 +1323,24 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testAcceptsAnObjectWhichImplementsAnInterfaceFieldAlongWithAdditionalOptionalArguments() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()], - ] - ] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()], - 'anotherInput' => ['type' => Type::string()], - ] - ] - ] - ]); + interface AnotherInterface { + field(input: String): String + } - $schema = $this->schemaWithFieldType($AnotherObject); - $schema->assertValid(); - } + type AnotherObject implements AnotherInterface { + field(input: String, anotherInput: String): String + } + '); - /** - * @it rejects an Object which implements an Interface field along with additional required arguments - */ - public function testRejectsAnObjectWhichImplementsAnInterfaceFieldAlongWithAdditionalRequiredArguments() - { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()], - ] - ] - ] - ]); - - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()], - 'anotherInput' => ['type' => Type::nonNull(Type::string())], - ] - ] - ] - ]); - - $schema = $this->schemaWithFieldType($AnotherObject); - - $this->setExpectedException( - InvariantViolation::class, - 'AnotherObject.field(anotherInput:) is of required type "String!" but is not also provided by the interface AnotherInterface.field.' + $this->assertEquals( + [], + $schema->validate() ); - - $schema->assertValid(); } /** @@ -2236,35 +1348,28 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testRejectsAnObjectMissingAnInterfaceField() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()], - ] - ] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'anotherfield' => ['type' => Type::string()] - ] - ]); + interface AnotherInterface { + field(input: String): String + } - $schema = $this->schemaWithFieldType($AnotherObject); + type AnotherObject implements AnotherInterface { + anotherField: String + } + '); - $this->setExpectedException( - InvariantViolation::class, - 'AnotherInterface expects field "field" but AnotherObject does not provide it' + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Interface field AnotherInterface.field expected but ' . + 'AnotherObject does not provide it.', + 'locations' => [['line' => 7, 'column' => 9], ['line' => 10, 'column' => 7]], + ]] ); - $schema->assertValid(); } /** @@ -2272,29 +1377,28 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testRejectsAnObjectWithAnIncorrectlyTypedInterfaceField() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => [ - 'field' => ['type' => Type::string()] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => ['type' => $this->SomeScalarType] - ] - ]); + interface AnotherInterface { + field(input: String): String + } - $schema = $this->schemaWithFieldType($AnotherObject); - $this->setExpectedException( - InvariantViolation::class, - 'AnotherInterface.field expects type "String" but AnotherObject.field provides type "SomeScalar"' + type AnotherObject implements AnotherInterface { + field(input: String): Int + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Interface field AnotherInterface.field expects type String but ' . + 'AnotherObject.field is type Int.', + 'locations' => [['line' => 7, 'column' => 31], ['line' => 11, 'column' => 31]], + ]] ); - $schema->assertValid(); } /** @@ -2302,45 +1406,31 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testRejectsAnObjectWithADifferentlyTypedInterfaceField() { - $TypeA = new ObjectType([ - 'name' => 'A', - 'fields' => [ - 'foo' => ['type' => Type::string()] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $TypeB = new ObjectType([ - 'name' => 'B', - 'fields' => [ - 'foo' => ['type' => Type::string()] - ] - ]); + type A { foo: String } + type B { foo: String } - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => [ - 'field' => ['type' => $TypeA] - ] - ]); + interface AnotherInterface { + field: A + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => ['type' => $TypeB] - ] - ]); + type AnotherObject implements AnotherInterface { + field: B + } + '); - $schema = $this->schemaWithFieldType($AnotherObject); - - $this->setExpectedException( - InvariantViolation::class, - 'AnotherInterface.field expects type "A" but AnotherObject.field provides type "B"' + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Interface field AnotherInterface.field expects type A but ' . + 'AnotherObject.field is type B.', + 'locations' => [['line' => 10, 'column' => 16], ['line' => 14, 'column' => 16]], + ]] ); - - $schema->assertValid(); } /** @@ -2348,29 +1438,21 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testAcceptsAnObjectWithASubtypedInterfaceFieldForInterface() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => function () use (&$AnotherInterface) { - return [ - 'field' => ['type' => $AnotherInterface] - ]; - } - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => function () use (&$AnotherObject) { - return [ - 'field' => ['type' => $AnotherObject] - ]; - } - ]); + interface AnotherInterface { + field: AnotherInterface + } - $schema = $this->schemaWithFieldType($AnotherObject); - $schema->assertValid(); + type AnotherObject implements AnotherInterface { + field: AnotherObject + } + '); + + $this->assertEquals([], $schema->validate()); } /** @@ -2378,25 +1460,27 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testAcceptsAnObjectWithASubtypedInterfaceFieldForUnion() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => [ - 'field' => ['type' => $this->SomeUnionType] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => ['type' => $this->SomeObjectType] - ] - ]); + type SomeObject { + field: String + } - $schema = $this->schemaWithFieldType($AnotherObject); - $schema->assertValid(); + union SomeUnionType = SomeObject + + interface AnotherInterface { + field: SomeUnionType + } + + type AnotherObject implements AnotherInterface { + field: SomeObject + } + '); + + $this->assertEquals([],$schema->validate()); } /** @@ -2404,38 +1488,28 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testRejectsAnObjectMissingAnInterfaceArgument() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()], - ] - ] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - ] - ] - ]); + interface AnotherInterface { + field(input: String): String + } - $schema = $this->schemaWithFieldType($AnotherObject); + type AnotherObject implements AnotherInterface { + field: String + } + '); - $this->setExpectedException( - InvariantViolation::class, - 'AnotherInterface.field expects argument "input" but AnotherObject.field does not provide it.' + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Interface field argument AnotherInterface.field(input:) expected ' . + 'but AnotherObject.field does not provide it.', + 'locations' => [['line' => 7, 'column' => 15], ['line' => 11, 'column' => 9]], + ]] ); - - $schema->assertValid(); } /** @@ -2443,67 +1517,113 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testRejectsAnObjectWithAnIncorrectlyTypedInterfaceArgument() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()], - ] - ] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => $this->SomeScalarType], - ] - ] - ] - ]); + interface AnotherInterface { + field(input: String): String + } - $schema = $this->schemaWithFieldType($AnotherObject); + type AnotherObject implements AnotherInterface { + field(input: Int): String + } + '); - $this->setExpectedException( - InvariantViolation::class, - 'AnotherInterface.field(input:) expects type "String" but AnotherObject.field(input:) provides type "SomeScalar".' + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Interface field argument AnotherInterface.field(input:) expects ' . + 'type String but AnotherObject.field(input:) is type Int.', + 'locations' => [['line' => 7, 'column' => 22], ['line' => 11, 'column' => 22]], + ]] ); - - $schema->assertValid(); } /** - * @it accepts an Object with an equivalently modified Interface field type + * @it rejects an Object with both an incorrectly typed field and argument */ - public function testAcceptsAnObjectWithAnEquivalentlyModifiedInterfaceFieldType() + public function testRejectsAnObjectWithBothAnIncorrectlyTypedFieldAndArgument() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => [ - 'field' => ['type' => Type::nonNull(Type::listOf(Type::string()))] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => ['type' => Type::nonNull(Type::listOf(Type::string()))] - ] - ]); + interface AnotherInterface { + field(input: String): String + } - $schema = $this->schemaWithFieldType($AnotherObject); - $schema->assertValid(); + type AnotherObject implements AnotherInterface { + field(input: Int): Int + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Interface field AnotherInterface.field expects type String but ' . + 'AnotherObject.field is type Int.', + 'locations' => [['line' => 7, 'column' => 31], ['line' => 11, 'column' => 28]], + ], [ + 'message' => 'Interface field argument AnotherInterface.field(input:) expects ' . + 'type String but AnotherObject.field(input:) is type Int.', + 'locations' => [['line' => 7, 'column' => 22], ['line' => 11, 'column' => 22]], + ]] + ); + } + + /** + * @it rejects an Object which implements an Interface field along with additional required arguments + */ + public function testRejectsAnObjectWhichImplementsAnInterfaceFieldAlongWithAdditionalRequiredArguments() + { + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field(input: String): String + } + + type AnotherObject implements AnotherInterface { + field(input: String, anotherInput: String!): String + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Object field argument AnotherObject.field(anotherInput:) is of ' . + 'required type String! but is not also provided by the Interface ' . + 'field AnotherInterface.field.', + 'locations' => [['line' => 11, 'column' => 44], ['line' => 7, 'column' => 9]], + ]] + ); + } + + /** + * @it accepts an Object with an equivalently wrapped Interface field type + */ + public function testAcceptsAnObjectWithAnEquivalentlyWrappedInterfaceFieldType() + { + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: [String]! + } + + type AnotherObject implements AnotherInterface { + field: [String]! + } + '); + + $this->assertEquals([], $schema->validate()); } /** @@ -2511,31 +1631,28 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testRejectsAnObjectWithANonListInterfaceFieldListType() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => [ - 'field' => ['type' => Type::listOf(Type::string())] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => ['type' => Type::string()] - ] - ]); + interface AnotherInterface { + field: [String] + } - $schema = $this->schemaWithFieldType($AnotherObject); + type AnotherObject implements AnotherInterface { + field: String + } + '); - $this->setExpectedException( - InvariantViolation::class, - 'AnotherInterface.field expects type "[String]" but AnotherObject.field provides type "String"' + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Interface field AnotherInterface.field expects type [String] ' . + 'but AnotherObject.field is type String.', + 'locations' => [['line' => 7, 'column' => 16], ['line' => 11, 'column' => 16]], + ]] ); - - $schema->assertValid(); } /** @@ -2543,29 +1660,28 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testRejectsAnObjectWithAListInterfaceFieldNonListType() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => [ - 'field' => ['type' => Type::string()] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => ['type' => Type::listOf(Type::string())] - ] - ]); + interface AnotherInterface { + field: String + } - $schema = $this->schemaWithFieldType($AnotherObject); - $this->setExpectedException( - InvariantViolation::class, - 'AnotherInterface.field expects type "String" but AnotherObject.field provides type "[String]"' + type AnotherObject implements AnotherInterface { + field: [String] + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Interface field AnotherInterface.field expects type String but ' . + 'AnotherObject.field is type [String].', + 'locations' => [['line' => 7, 'column' => 16], ['line' => 11, 'column' => 16]], + ]] ); - $schema->assertValid(); } /** @@ -2573,25 +1689,21 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testAcceptsAnObjectWithASubsetNonNullInterfaceFieldType() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => [ - 'field' => ['type' => Type::string()] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => ['type' => Type::nonNull(Type::string())] - ] - ]); + interface AnotherInterface { + field: String + } - $schema = $this->schemaWithFieldType($AnotherObject); - $schema->assertValid(); + type AnotherObject implements AnotherInterface { + field: String! + } + '); + + $this->assertEquals([], $schema->validate()); } /** @@ -2599,54 +1711,28 @@ class ValidationTest extends \PHPUnit_Framework_TestCase */ public function testRejectsAnObjectWithASupersetNullableInterfaceFieldType() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, - 'fields' => [ - 'field' => ['type' => Type::nonNull(Type::string())] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => ['type' => Type::string()] - ] - ]); + interface AnotherInterface { + field: String! + } - $schema = $this->schemaWithFieldType($AnotherObject); + type AnotherObject implements AnotherInterface { + field: String + } + '); - $this->setExpectedException( - InvariantViolation::class, - 'AnotherInterface.field expects type "String!" but AnotherObject.field provides type "String"' + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Interface field AnotherInterface.field expects type String! ' . + 'but AnotherObject.field is type String.', + 'locations' => [['line' => 7, 'column' => 16], ['line' => 11, 'column' => 16]], + ]] ); - - $schema->assertValid(); - } - - /** - * @it does not allow isDeprecated without deprecationReason on field - */ - public function testDoesNotAllowIsDeprecatedWithoutDeprecationReasonOnField() - { - $OldObject = new ObjectType([ - 'name' => 'OldObject', - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'isDeprecated' => true - ] - ] - ]); - - $schema = $this->schemaWithFieldType($OldObject); - $this->setExpectedException( - InvariantViolation::class, - 'OldObject.field should provide "deprecationReason" instead of "isDeprecated".' - ); - $schema->assertValid(); } public function testRejectsDifferentInstancesOfTheSameType() @@ -2691,26 +1777,6 @@ class ValidationTest extends \PHPUnit_Framework_TestCase } } - private function assertWarnsOnce($closures, $expectedError) - { - $warned = false; - - foreach ($closures as $index => $factory) { - if (!$warned) { - try { - $factory(); - $this->fail('Expected exception not thrown for entry ' . $index); - } catch (\PHPUnit_Framework_Error_Warning $e) { - $warned = true; - $this->assertEquals($expectedError, $e->getMessage(), 'Error in callable #' . $index); - } - } else { - // Should not throw - $factory(); - } - } - } - private function schemaWithFieldType($type) { return new Schema([ @@ -2722,23 +1788,6 @@ class ValidationTest extends \PHPUnit_Framework_TestCase ]); } - private function schemaWithInputObject($inputObjectType) - { - return new Schema([ - 'query' => new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'f' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => $inputObjectType] - ] - ] - ] - ]) - ]); - } - private function schemaWithObjectFieldOfType($fieldType) { $BadObjectType = new ObjectType([ @@ -2759,47 +1808,6 @@ class ValidationTest extends \PHPUnit_Framework_TestCase ]); } - private function schemaWithObjectWithFieldResolver($resolveValue) - { - $BadResolverType = new ObjectType([ - 'name' => 'BadResolver', - 'fields' => [ - 'badField' => [ - 'type' => Type::string(), - 'resolve' => $resolveValue - ] - ] - ]); - - return new Schema([ - 'query' => new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'f' => ['type' => $BadResolverType] - ] - ]) - ]); - } - - private function schemaWithObjectImplementingType($implementedType) - { - $BadObjectType = new ObjectType([ - 'name' => 'BadObject', - 'interfaces' => [$implementedType], - 'fields' => ['f' => ['type' => Type::string()]] - ]); - - return new Schema([ - 'query' => new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'f' => ['type' => $BadObjectType] - ] - ]), - 'types' => [$BadObjectType] - ]); - } - private function withModifiers($types) { return array_merge( @@ -2816,24 +1824,6 @@ class ValidationTest extends \PHPUnit_Framework_TestCase ); } - private function schemaWithUnionOfType($type) - { - $BadUnionType = new UnionType([ - 'name' => 'BadUnion', - 'resolveType' => function () { - }, - 'types' => [$type], - ]); - return new Schema([ - 'query' => new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'f' => ['type' => $BadUnionType] - ] - ]) - ]); - } - private function schemaWithInterfaceFieldOfType($fieldType) { $BadInterfaceType = new InterfaceType([ @@ -2850,18 +1840,6 @@ class ValidationTest extends \PHPUnit_Framework_TestCase 'f' => ['type' => $BadInterfaceType] ] ]), - // Have to add types implementing interfaces to bypass the "could not find implementers" exception - 'types' => [ - new ObjectType([ - 'name' => 'BadInterfaceImplementer', - 'fields' => [ - 'badField' => ['type' => $fieldType] - ], - 'interfaces' => [$BadInterfaceType], - 'isTypeOf' => function() {} - ]), - $this->SomeObjectType - ] ]); } diff --git a/tests/Utils/AssertValidNameTest.php b/tests/Utils/AssertValidNameTest.php new file mode 100644 index 0000000..50899f7 --- /dev/null +++ b/tests/Utils/AssertValidNameTest.php @@ -0,0 +1,47 @@ +setExpectedException( + Error::class, + '"__bad" must not begin with "__", which is reserved by GraphQL introspection.' + ); + Utils::assertValidName('__bad'); + } + + /** + * @it throws for non-strings + */ + public function testThrowsForNonStrings() + { + $this->setExpectedException( + InvariantViolation::class, + 'Expected string' + ); + Utils::assertValidName([]); + } + + /** + * @it throws for names with invalid characters + */ + public function testThrowsForNamesWithInvalidCharacters() + { + $this->setExpectedException( + Error::class, + 'Names must match' + ); + Utils::assertValidName('>--()-->'); + } +} diff --git a/tests/Utils/AstFromValueUntypedTest.php b/tests/Utils/AstFromValueUntypedTest.php new file mode 100644 index 0000000..53c81da --- /dev/null +++ b/tests/Utils/AstFromValueUntypedTest.php @@ -0,0 +1,110 @@ +assertEquals( + $expected, + AST::valueFromASTUntyped(Parser::parseValue($valueText), $variables) + ); + } + + /** + * @it parses simple values + */ + public function testParsesSimpleValues() + { + $this->assertTestCase('null', null); + $this->assertTestCase('true', true); + $this->assertTestCase('false', false); + $this->assertTestCase('123', 123); + $this->assertTestCase('123.456', 123.456); + $this->assertTestCase('abc123', 'abc123'); + } + + /** + * @it parses lists of values + */ + public function testParsesListsOfValues() + { + $this->assertTestCase('[true, false]', [true, false]); + $this->assertTestCase('[true, 123.45]', [true, 123.45]); + $this->assertTestCase('[true, null]', [true, null]); + $this->assertTestCase('[true, ["foo", 1.2]]', [true, ['foo', 1.2]]); + } + + /** + * @it parses input objects + */ + public function testParsesInputObjects() + { + $this->assertTestCase( + '{ int: 123, bool: false }', + ['int' => 123, 'bool' => false] + ); + + $this->assertTestCase( + '{ foo: [ { bar: "baz"} ] }', + ['foo' => [['bar' => 'baz']]] + ); + } + + /** + * @it parses enum values as plain strings + */ + public function testParsesEnumValuesAsPlainStrings() + { + $this->assertTestCase( + 'TEST_ENUM_VALUE', + 'TEST_ENUM_VALUE' + ); + + $this->assertTestCase( + '[TEST_ENUM_VALUE]', + ['TEST_ENUM_VALUE'] + ); + } + + /** + * @it parses enum values as plain strings + */ + public function testParsesVariables() + { + $this->assertTestCase( + '$testVariable', + 'foo', + ['testVariable' => 'foo'] + ); + $this->assertTestCase( + '[$testVariable]', + ['foo'], + ['testVariable' => 'foo'] + ); + $this->assertTestCase( + '{a:[$testVariable]}', + ['a' => ['foo']], + ['testVariable' => 'foo'] + ); + $this->assertTestCase( + '$testVariable', + null, + ['testVariable' => null] + ); + $this->assertTestCase( + '$testVariable', + null, + [] + ); + $this->assertTestCase( + '$testVariable', + null, + null + ); + } +} diff --git a/tests/Utils/BuildSchemaTest.php b/tests/Utils/BuildSchemaTest.php index 951d358..916f377 100644 --- a/tests/Utils/BuildSchemaTest.php +++ b/tests/Utils/BuildSchemaTest.php @@ -17,11 +17,11 @@ class BuildSchemaTest extends \PHPUnit_Framework_TestCase { // Describe: Schema Builder - private function cycleOutput($body) + private function cycleOutput($body, $options = []) { $ast = Parser::parse($body); - $schema = BuildSchema::buildAST($ast); - return "\n" . SchemaPrinter::doPrint($schema); + $schema = BuildSchema::buildAST($ast, null, $options); + return "\n" . SchemaPrinter::doPrint($schema, $options); } /** @@ -35,9 +35,9 @@ class BuildSchemaTest extends \PHPUnit_Framework_TestCase str: String } ')); - - $result = GraphQL::execute($schema, '{ str }', ['str' => 123]); - $this->assertEquals($result['data'], ['str' => 123]); + + $result = GraphQL::executeQuery($schema, '{ str }', ['str' => 123]); + $this->assertEquals(['str' => 123], $result->toArray(true)['data']); } /** @@ -52,7 +52,7 @@ class BuildSchemaTest extends \PHPUnit_Framework_TestCase } "); - $result = GraphQL::execute( + $result = GraphQL::executeQuery( $schema, '{ add(x: 34, y: 55) }', [ @@ -61,7 +61,7 @@ class BuildSchemaTest extends \PHPUnit_Framework_TestCase } ] ); - $this->assertEquals($result, ['data' => ['add' => 89]]); + $this->assertEquals(['data' => ['add' => 89]], $result->toArray(true)); } /** @@ -110,6 +110,42 @@ type Hello { * @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 { @@ -137,7 +173,7 @@ type Hello { str: String } '; - $output = $this->cycleOutput($body); + $output = $this->cycleOutput($body, [ 'commentDescriptions' => true ]); $this->assertEquals($body, $output); } @@ -411,6 +447,135 @@ type WorldTwo { $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 */ @@ -471,6 +636,26 @@ type Hello { $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 */ @@ -678,21 +863,6 @@ type Query { // 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 */ @@ -708,25 +878,6 @@ 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 } @@ -840,7 +991,9 @@ schema { query: Hello } -type Hello implements Bar { } +type Hello implements Bar { + field: String +} '; $doc = Parser::parse($body); $schema = BuildSchema::buildAST($doc); @@ -1057,9 +1210,8 @@ interface Hello { $this->assertInstanceOf(InterfaceTypeDefinitionNode::class, $node); $this->assertEquals('Hello', $defaultConfig['name']); $this->assertInstanceOf(\Closure::class, $defaultConfig['fields']); - $this->assertInstanceOf(\Closure::class, $defaultConfig['resolveType']); $this->assertArrayHasKey('description', $defaultConfig); - $this->assertCount(5, $defaultConfig); + $this->assertCount(4, $defaultConfig); $this->assertEquals(array_keys($allNodesMap), ['Query', 'Color', 'Hello']); $this->assertEquals('My description of Hello', $schema->getType('Hello')->description); } @@ -1116,43 +1268,4 @@ type World implements Hello { $this->assertArrayHasKey('World', $types); } - public function testScalarDescription() - { - $schemaDef = ' -# An ISO-8601 encoded UTC date string. -scalar Date - -type Query { - now: Date - test: String -} -'; - $q = ' -{ - __type(name: "Date") { - name - description - } - strType: __type(name: "String") { - name - description - } -} -'; - $schema = BuildSchema::build($schemaDef); - $result = GraphQL::executeQuery($schema, $q)->toArray(); - $expected = ['data' => [ - '__type' => [ - 'name' => 'Date', - 'description' => 'An ISO-8601 encoded UTC date string.' - ], - 'strType' => [ - 'name' => 'String', - 'description' => 'The `String` scalar type represents textual data, represented as UTF-8' . "\n" . - 'character sequences. The String type is most often used by GraphQL to'. "\n" . - 'represent free-form human-readable text.' - ] - ]]; - $this->assertEquals($expected, $result); - } } diff --git a/tests/Utils/CoerceValueTest.php b/tests/Utils/CoerceValueTest.php new file mode 100644 index 0000000..bb5823a --- /dev/null +++ b/tests/Utils/CoerceValueTest.php @@ -0,0 +1,336 @@ +testEnum = new EnumType([ + 'name' => 'TestEnum', + 'values' => [ + 'FOO' => 'InternalFoo', + 'BAR' => 123456789, + ], + ]); + + $this->testInputObject = new InputObjectType([ + 'name' => 'TestInputObject', + 'fields' => [ + 'foo' => Type::nonNull(Type::int()), + 'bar' => Type::int(), + ], + ]); + } + + // Describe: coerceValue + + /** + * @it coercing an array to GraphQLString produces an error + */ + public function testCoercingAnArrayToGraphQLStringProducesAnError() + { + $result = Value::coerceValue([1, 2, 3], Type::string()); + $this->expectError( + $result, + 'Expected type String; String cannot represent an array value: [1,2,3]' + ); + + $this->assertEquals( + 'String cannot represent an array value: [1,2,3]', + $result['errors'][0]->getPrevious()->getMessage() + ); + } + + // Describe: for GraphQLInt + + /** + * @it returns no error for int input + */ + public function testIntReturnsNoErrorForIntInput() + { + $result = Value::coerceValue('1', Type::int()); + $this->expectNoErrors($result); + } + + /** + * @it returns no error for negative int input + */ + public function testIntReturnsNoErrorForNegativeIntInput() + { + $result = Value::coerceValue('-1', Type::int()); + $this->expectNoErrors($result); + } + + /** + * @it returns no error for exponent input + */ + public function testIntReturnsNoErrorForExponentInput() + { + $result = Value::coerceValue('1e3', Type::int()); + $this->expectNoErrors($result); + } + + /** + * @it returns no error for null + */ + public function testIntReturnsASingleErrorNull() + { + $result = Value::coerceValue(null, Type::int()); + $this->expectNoErrors($result); + } + + /** + * @it returns a single error for empty value + */ + public function testIntReturnsASingleErrorForEmptyValue() + { + $result = Value::coerceValue('', Type::int()); + $this->expectError( + $result, + 'Expected type Int; Int cannot represent non 32-bit signed integer value: (empty string)' + ); + } + + /** + * @it returns error for float input as int + */ + public function testIntReturnsErrorForFloatInputAsInt() + { + $result = Value::coerceValue('1.5', Type::int()); + $this->expectError( + $result, + 'Expected type Int; Int cannot represent non-integer value: 1.5' + ); + } + + /** + * @it returns a single error for char input + */ + public function testIntReturnsASingleErrorForCharInput() + { + $result = Value::coerceValue('a', Type::int()); + $this->expectError( + $result, + 'Expected type Int; Int cannot represent non 32-bit signed integer value: a' + ); + } + + /** + * @it returns a single error for multi char input + */ + public function testIntReturnsASingleErrorForMultiCharInput() + { + $result = Value::coerceValue('meow', Type::int()); + $this->expectError( + $result, + 'Expected type Int; Int cannot represent non 32-bit signed integer value: meow' + ); + } + + // Describe: for GraphQLFloat + + /** + * @it returns no error for int input + */ + public function testFloatReturnsNoErrorForIntInput() + { + $result = Value::coerceValue('1', Type::float()); + $this->expectNoErrors($result); + } + + /** + * @it returns no error for exponent input + */ + public function testFloatReturnsNoErrorForExponentInput() + { + $result = Value::coerceValue('1e3', Type::float()); + $this->expectNoErrors($result); + } + + /** + * @it returns no error for float input + */ + public function testFloatReturnsNoErrorForFloatInput() + { + $result = Value::coerceValue('1.5', Type::float()); + $this->expectNoErrors($result); + } + + /** + * @it returns no error for null + */ + public function testFloatReturnsASingleErrorNull() + { + $result = Value::coerceValue(null, Type::float()); + $this->expectNoErrors($result); + } + + /** + * @it returns a single error for empty value + */ + public function testFloatReturnsASingleErrorForEmptyValue() + { + $result = Value::coerceValue('', Type::float()); + $this->expectError( + $result, + 'Expected type Float; Float cannot represent non numeric value: (empty string)' + ); + } + + /** + * @it returns a single error for char input + */ + public function testFloatReturnsASingleErrorForCharInput() + { + $result = Value::coerceValue('a', Type::float()); + $this->expectError( + $result, + 'Expected type Float; Float cannot represent non numeric value: a' + ); + } + + /** + * @it returns a single error for multi char input + */ + public function testFloatReturnsASingleErrorForMultiCharInput() + { + $result = Value::coerceValue('meow', Type::float()); + $this->expectError( + $result, + 'Expected type Float; Float cannot represent non numeric value: meow' + ); + } + + // DESCRIBE: for GraphQLEnum + + /** + * @it returns no error for a known enum name + */ + public function testReturnsNoErrorForAKnownEnumName() + { + $fooResult = Value::coerceValue('FOO', $this->testEnum); + $this->expectNoErrors($fooResult); + $this->assertEquals('InternalFoo', $fooResult['value']); + + $barResult = Value::coerceValue('BAR', $this->testEnum); + $this->expectNoErrors($barResult); + $this->assertEquals(123456789, $barResult['value']); + } + + /** + * @it results error for misspelled enum value + */ + public function testReturnsErrorForMisspelledEnumValue() + { + $result = Value::coerceValue('foo', $this->testEnum); + $this->expectError($result, 'Expected type TestEnum; did you mean FOO?'); + } + + /** + * @it results error for incorrect value type + */ + public function testReturnsErrorForIncorrectValueType() + { + $result1 = Value::coerceValue(123, $this->testEnum); + $this->expectError($result1, 'Expected type TestEnum.'); + + $result2 = Value::coerceValue(['field' => 'value'], $this->testEnum); + $this->expectError($result2, 'Expected type TestEnum.'); + } + + // DESCRIBE: for GraphQLInputObject + + /** + * @it returns no error for a valid input + */ + public function testReturnsNoErrorForValidInput() + { + $result = Value::coerceValue(['foo' => 123], $this->testInputObject); + $this->expectNoErrors($result); + $this->assertEquals(['foo' => 123], $result['value']); + } + + /** + * @it returns no error for a non-object type + */ + public function testReturnsErrorForNonObjectType() + { + $result = Value::coerceValue(123, $this->testInputObject); + $this->expectError($result, 'Expected type TestInputObject to be an object.'); + } + + /** + * @it returns no error for an invalid field + */ + public function testReturnErrorForAnInvalidField() + { + $result = Value::coerceValue(['foo' => 'abc'], $this->testInputObject); + $this->expectError($result, 'Expected type Int at value.foo; Int cannot represent non 32-bit signed integer value: abc'); + } + + /** + * @it returns multiple errors for multiple invalid fields + */ + public function testReturnsMultipleErrorsForMultipleInvalidFields() + { + $result = Value::coerceValue(['foo' => 'abc', 'bar' => 'def'], $this->testInputObject); + $this->assertEquals([ + 'Expected type Int at value.foo; Int cannot represent non 32-bit signed integer value: abc', + 'Expected type Int at value.bar; Int cannot represent non 32-bit signed integer value: def', + ], $result['errors']); + } + + /** + * @it returns error for a missing required field + */ + public function testReturnsErrorForAMissingRequiredField() + { + $result = Value::coerceValue(['bar' => 123], $this->testInputObject); + $this->expectError($result, 'Field value.foo of required type Int! was not provided.'); + } + + /** + * @it returns error for an unknown field + */ + public function testReturnsErrorForAnUnknownField() + { + $result = Value::coerceValue(['foo' => 123, 'unknownField' => 123], $this->testInputObject); + $this->expectError($result, 'Field "unknownField" is not defined by type TestInputObject.'); + } + + /** + * @it returns error for a misspelled field + */ + public function testReturnsErrorForAMisspelledField() + { + $result = Value::coerceValue(['foo' => 123, 'bart' => 123], $this->testInputObject); + $this->expectError($result, 'Field "bart" is not defined by type TestInputObject; did you mean bar?'); + } + + private function expectNoErrors($result) + { + $this->assertInternalType('array', $result); + $this->assertNull($result['errors']); + $this->assertNotEquals(Utils::undefined(), $result['value']); + } + + + private function expectError($result, $expected) { + $this->assertInternalType('array', $result); + $this->assertInternalType('array', $result['errors']); + $this->assertCount(1, $result['errors']); + $this->assertEquals($expected, $result['errors'][0]->getMessage()); + $this->assertEquals(Utils::undefined(), $result['value']); + } +} diff --git a/tests/Utils/FindBreakingChangesTest.php b/tests/Utils/FindBreakingChangesTest.php index 2fc0f6f..60134a0 100644 --- a/tests/Utils/FindBreakingChangesTest.php +++ b/tests/Utils/FindBreakingChangesTest.php @@ -1,11 +1,10 @@ 'Type1', @@ -46,31 +49,33 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase ] ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $type1, - 'type2' => $type2 - ] - ]) + 'query' => $this->queryType, + 'types' => [$type1, $type2] ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type2' => $type2 - ] - ]) + 'query' => $this->queryType, + 'types' => [$type2] ]); - $this->assertEquals(['type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_REMOVED, 'description' => 'Type1 was removed.'], - FindBreakingChanges::findRemovedTypes($oldSchema, $newSchema)[0] + $expected = [ + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_REMOVED, + 'description' => 'Type1 was removed.' + ] + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findRemovedTypes($oldSchema, $newSchema) ); $this->assertEquals([], FindBreakingChanges::findRemovedTypes($oldSchema, $oldSchema)); } - public function testShouldDetectTypeChanges() + /** + * @it should detect if a type changed its type + */ + public function testShouldDetectIfATypeChangedItsType() { $objectType = new ObjectType([ 'name' => 'ObjectType', @@ -88,41 +93,45 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase $unionType = new UnionType([ 'name' => 'Type1', - 'types' => [new ObjectType(['name' => 'blah'])], + 'types' => [$objectType], ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $interfaceType - ] - ]) + 'query' => $this->queryType, + 'types' => [$interfaceType] ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $unionType - ] - ]) + 'query' => $this->queryType, + 'types' => [$unionType] ]); + $expected = [ + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_CHANGED_KIND, + 'description' => 'Type1 changed from an Interface type to a Union type.' + ] + ]; + $this->assertEquals( - ['type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_CHANGED, 'description' => 'Type1 changed from an Interface type to a Union type.'], - FindBreakingChanges::findTypesThatChangedKind($oldSchema, $newSchema)[0] + $expected, + FindBreakingChanges::findTypesThatChangedKind($oldSchema, $newSchema) ); } - public function testShouldDetectFieldChangesAndDeletions() + /** + * @it should detect if a field on a type was deleted or changed type + */ + public function testShouldDetectIfAFieldOnATypeWasDeletedOrChangedType() { - $typeA1 = new ObjectType([ + $typeA = new ObjectType([ 'name' => 'TypeA', 'fields' => [ 'field1' => ['type' => Type::string()], ] ]); + // logically equivalent to TypeA; findBreakingFieldChanges shouldn't + // treat this as different than TypeA $typeA2 = new ObjectType([ 'name' => 'TypeA', 'fields' => [ @@ -138,10 +147,10 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase $oldType1 = new InterfaceType([ 'name' => 'Type1', 'fields' => [ - 'field1' => ['type' => $typeA1], + 'field1' => ['type' => $typeA], 'field2' => ['type' => Type::string()], 'field3' => ['type' => Type::string()], - 'field4' => ['type' => $typeA1], + 'field4' => ['type' => $typeA], 'field6' => ['type' => Type::string()], 'field7' => ['type' => Type::listOf(Type::string())], 'field8' => ['type' => Type::int()], @@ -186,84 +195,78 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase ] ]); - $expectedFieldChanges = [ - [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_REMOVED, - 'description' => 'Type1->field2 was removed.', - ], - [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field3 changed type from String to Boolean.', - ], - [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field4 changed type from TypeA to TypeB.', - ], - [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field6 changed type from String to [String].', - ], - [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field7 changed type from [String] to String.', - ], - [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field9 changed type from Int! to Int.', - ], - [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field10 changed type from [Int]! to [Int].', - ], - [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field11 changed type from Int to [Int]!.', - ], - [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field13 changed type from [Int!] to [Int].', - ], - [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field14 changed type from [Int] to [[Int]].', - ], - [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field15 changed type from [[Int]] to [Int].', - ], - [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field16 changed type from Int! to [Int]!.', - ], - [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field18 changed type from [[Int!]!] to [[Int!]].', - ], - ]; - $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'Type1' => $oldType1 - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldType1], ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'Type1' => $newType1 - ] - ]) + 'query' => $this->queryType, + 'types' => [$newType1], ]); - $this->assertEquals($expectedFieldChanges, FindBreakingChanges::findFieldsThatChangedType($oldSchema, $newSchema)); + $expectedFieldChanges = [ + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_REMOVED, + 'description' => 'Type1.field2 was removed.', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field3 changed type from String to Boolean.', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field4 changed type from TypeA to TypeB.', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field6 changed type from String to [String].', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field7 changed type from [String] to String.', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field9 changed type from Int! to Int.', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field10 changed type from [Int]! to [Int].', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field11 changed type from Int to [Int]!.', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field13 changed type from [Int!] to [Int].', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field14 changed type from [Int] to [[Int]].', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field15 changed type from [[Int]] to [Int].', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field16 changed type from Int! to [Int]!.', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field18 changed type from [[Int!]!] to [[Int!]].', + ], + ]; + + $this->assertEquals($expectedFieldChanges, FindBreakingChanges::findFieldsThatChangedTypeOnObjectOrInterfaceTypes($oldSchema, $newSchema)); } - - public function testShouldDetectInputFieldChanges() + /** + * @it should detect if fields on input types changed kind or were removed + */ + public function testShouldDetectIfFieldsOnInputTypesChangedKindOrWereRemoved() { $oldInputType = new InputObjectType([ 'name' => 'InputType1', @@ -365,74 +368,69 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldInputType - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldInputType] ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newInputType - ] - ]) + 'query' => $this->queryType, + 'types' => [$newInputType] ]); $expectedFieldChanges = [ [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field1 changed type from String to Int.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field1 changed type from String to Int.', ], [ 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_REMOVED, - 'description' => 'InputType1->field2 was removed.', + 'description' => 'InputType1.field2 was removed.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field3 changed type from [String] to String.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field3 changed type from [String] to String.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field5 changed type from String to String!.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field5 changed type from String to String!.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field6 changed type from [Int] to [Int]!.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field6 changed type from [Int] to [Int]!.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field8 changed type from Int to [Int]!.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field8 changed type from Int to [Int]!.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field9 changed type from [Int] to [Int!].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field9 changed type from [Int] to [Int!].', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field11 changed type from [Int] to [[Int]].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field11 changed type from [Int] to [[Int]].', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field12 changed type from [[Int]] to [Int].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field12 changed type from [[Int]] to [Int].', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field13 changed type from Int! to [Int]!.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field13 changed type from Int! to [Int]!.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field15 changed type from [[Int]!] to [[Int!]!].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field15 changed type from [[Int]!] to [[Int!]!].', ], ]; - $this->assertEquals($expectedFieldChanges, FindBreakingChanges::findFieldsThatChangedType($oldSchema, $newSchema)); + $this->assertEquals($expectedFieldChanges, FindBreakingChanges::findFieldsThatChangedTypeOnInputObjectTypes($oldSchema, $newSchema)['breakingChanges']); } - public function testDetectsNonNullFieldAddedToInputType() + /** + * @it should detect if a non-null field is added to an input type + */ + public function testShouldDetectIfANonNullFieldIsAddedToAnInputType() { $oldInputType = new InputObjectType([ 'name' => 'InputType1', @@ -451,33 +449,32 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldInputType - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldInputType], ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newInputType - ] - ]) + 'query' => $this->queryType, + 'types' => [$newInputType], ]); - $this->assertEquals( + $expected = [ [ 'type' => FindBreakingChanges::BREAKING_CHANGE_NON_NULL_INPUT_FIELD_ADDED, 'description' => 'A non-null field requiredField on input type InputType1 was added.' ], - FindBreakingChanges::findFieldsThatChangedType($oldSchema, $newSchema)[0] + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findFieldsThatChangedTypeOnInputObjectTypes($oldSchema, $newSchema)['breakingChanges'] ); } - public function testDetectsIfTypeWasRemovedFromUnion() + /** + * @it should detect if a type was removed from a union type + */ + public function testShouldRetectIfATypeWasRemovedFromAUnionType() { $type1 = new ObjectType([ 'name' => 'Type1', @@ -485,21 +482,20 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase 'field1' => Type::string() ] ]); - + // logially equivalent to type1; findTypesRemovedFromUnions should not + // treat this as different than type1 $type1a = new ObjectType([ 'name' => 'Type1', 'fields' => [ 'field1' => Type::string() ] ]); - $type2 = new ObjectType([ 'name' => 'Type2', 'fields' => [ 'field1' => Type::string() ] ]); - $type3 = new ObjectType([ 'name' => 'Type3', 'fields' => [ @@ -510,46 +506,38 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase $oldUnionType = new UnionType([ 'name' => 'UnionType1', 'types' => [$type1, $type2], - 'resolveType' => function () { - } ]); - - $newUnionType = new UnionType([ 'name' => 'UnionType1', 'types' => [$type1a, $type3], - 'resolveType' => function () { - } ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldUnionType - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldUnionType], ]); - $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newUnionType - ] - ]) + 'query' => $this->queryType, + 'types' => [$newUnionType], ]); - $this->assertEquals( + $expected = [ [ 'type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_REMOVED_FROM_UNION, 'description' => 'Type2 was removed from union type UnionType1.' - ], - FindBreakingChanges::findTypesRemovedFromUnions($oldSchema, $newSchema)[0] + ] + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findTypesRemovedFromUnions($oldSchema, $newSchema) ); } - public function testDetectsValuesRemovedFromEnum() + /** + * @it should detect if a value was removed from an enum type + */ + public function testShouldDetectIfAValueWasRemovedFromAnEnumType() { $oldEnumType = new EnumType([ 'name' => 'EnumType1', @@ -569,35 +557,33 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldEnumType - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldEnumType] ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newEnumType - ] - ]) + 'query' => $this->queryType, + 'types' => [$newEnumType] ]); - $this->assertEquals( + $expected = [ [ 'type' => FindBreakingChanges::BREAKING_CHANGE_VALUE_REMOVED_FROM_ENUM, 'description' => 'VALUE1 was removed from enum type EnumType1.' - ], - FindBreakingChanges::findValuesRemovedFromEnums($oldSchema, $newSchema)[0] + ] + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findValuesRemovedFromEnums($oldSchema, $newSchema) ); } - public function testDetectsRemovalOfFieldArgument() + /** + * @it should detect if a field argument was removed + */ + public function testShouldDetectIfAFieldArgumentWasRemoved() { - $oldType = new ObjectType([ 'name' => 'Type1', 'fields' => [ @@ -610,7 +596,6 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase ] ]); - $inputType = new InputObjectType([ 'name' => 'InputType1', 'fields' => [ @@ -649,48 +634,38 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldType, - 'type2' => $oldInterfaceType - ], - 'types' => [$oldType, $oldInterfaceType] - ]) + 'query' => $this->queryType, + 'types' => [$oldType, $oldInterfaceType], ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newType, - 'type2' => $newInterfaceType - ], - 'types' => [$newType, $newInterfaceType] - ]) + 'query' => $this->queryType, + 'types' => [$newType, $newInterfaceType], ]); $expectedChanges = [ [ 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_REMOVED, - 'description' => 'Type1->field1 arg name was removed', + 'description' => 'Type1.field1 arg name was removed', ], [ 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_REMOVED, - 'description' => 'Interface1->field1 arg arg1 was removed', + 'description' => 'Interface1.field1 arg arg1 was removed', ], [ 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_REMOVED, - 'description' => 'Interface1->field1 arg objectArg was removed', + 'description' => 'Interface1.field1 arg objectArg was removed', ] ]; $this->assertEquals($expectedChanges, FindBreakingChanges::findArgChanges($oldSchema, $newSchema)['breakingChanges']); } - public function testDetectsFieldArgumentTypeChange() + /** + * @it should detect if a field argument has changed type + */ + public function testShouldDetectIfAFieldArgumentHasChangedType() { - $oldType = new ObjectType([ 'name' => 'Type1', 'fields' => [ @@ -744,78 +719,73 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldType - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldType] ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newType - ] - ]) + 'query' => $this->queryType, + 'types' => [$newType] ]); $expectedChanges = [ [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg1 has changed type from String to Int.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg1 has changed type from String to Int', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg2 has changed type from String to [String].' + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg2 has changed type from String to [String]' ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg3 has changed type from [String] to String.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg3 has changed type from [String] to String', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg4 has changed type from String to String!.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg4 has changed type from String to String!', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg5 has changed type from String! to Int.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg5 has changed type from String! to Int', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg6 has changed type from String! to Int!.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg6 has changed type from String! to Int!', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg8 has changed type from Int to [Int]!.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg8 has changed type from Int to [Int]!', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg9 has changed type from [Int] to [Int!].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg9 has changed type from [Int] to [Int!]', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg11 has changed type from [Int] to [[Int]].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg11 has changed type from [Int] to [[Int]]', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg12 has changed type from [[Int]] to [Int].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg12 has changed type from [[Int]] to [Int]', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg13 has changed type from Int! to [Int]!.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg13 has changed type from Int! to [Int]!', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg15 has changed type from [[Int]!] to [[Int!]!].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg15 has changed type from [[Int]!] to [[Int!]!]', ], ]; $this->assertEquals($expectedChanges, FindBreakingChanges::findArgChanges($oldSchema, $newSchema)['breakingChanges']); } - public function testDetectsAdditionOfFieldArg() + /** + * @it should detect if a non-null field argument was added + */ + public function testShouldDetectIfANonNullFieldArgumentWasAdded() { $oldType = new ObjectType([ 'name' => 'Type1', @@ -840,31 +810,30 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase ] ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldType, - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldType] ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newType - ] - ]) + 'query' => $this->queryType, + 'types' => [$newType] ]); - $this->assertEquals( + $expected = [ [ 'type' => FindBreakingChanges::BREAKING_CHANGE_NON_NULL_ARG_ADDED, - 'description' => 'A non-null arg newRequiredArg on Type1->field1 was added.' - ], - FindBreakingChanges::findArgChanges($oldSchema, $newSchema)['breakingChanges'][0]); + 'description' => 'A non-null arg newRequiredArg on Type1.field1 was added' + ] + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findArgChanges($oldSchema, $newSchema)['breakingChanges']); } - public function testDoesNotFlagArgsWithSameTypeSignature() + /** + * @it should not flag args with the same type signature as breaking + */ + public function testShouldNotFlagArgsWithTheSameTypeSignatureAsBreaking() { $inputType1a = new InputObjectType([ 'name' => 'InputType1', @@ -907,26 +876,21 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldType, - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldType], ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newType - ] - ]) + 'query' => $this->queryType, + 'types' => [$newType], ]); $this->assertEquals([], FindBreakingChanges::findArgChanges($oldSchema, $newSchema)['breakingChanges']); } - public function testArgsThatMoveAwayFromNonNull() + /** + * @it should consider args that move away from NonNull as non-breaking + */ + public function testShouldConsiderArgsThatMoveAwayFromNonNullAsNonBreaking() { $oldType = new ObjectType([ 'name' => 'Type1', @@ -952,34 +916,27 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldType, - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldType], ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newType - ] - ]) + 'query' => $this->queryType, + 'types' => [$newType], ]); $this->assertEquals([], FindBreakingChanges::findArgChanges($oldSchema, $newSchema)['breakingChanges']); } - public function testDetectsRemovalOfInterfaces() + /** + * @it should detect interfaces removed from types + */ + public function testShouldDetectInterfacesRemovedFromTypes() { $interface1 = new InterfaceType([ 'name' => 'Interface1', 'fields' => [ 'field1' => Type::string() ], - 'resolveType' => function () { - } ]); $oldType = new ObjectType([ 'name' => 'Type1', @@ -996,31 +953,30 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldType, - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldType], ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newType - ] - ]) + 'query' => $this->queryType, + 'types' => [$newType], ]); - $this->assertEquals( + $expected = [ [ 'type' => FindBreakingChanges::BREAKING_CHANGE_INTERFACE_REMOVED_FROM_OBJECT, 'description' => 'Type1 no longer implements interface Interface1.' ], - FindBreakingChanges::findInterfacesRemovedFromObjectTypes($oldSchema, $newSchema)[0]); + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findInterfacesRemovedFromObjectTypes($oldSchema, $newSchema)); } - public function testDetectsAllBreakingChanges() + /** + * @it should detect all breaking changes + */ + public function testShouldDetectAllBreakingChanges() { $typeThatGetsRemoved = new ObjectType([ 'name' => 'TypeThatGetsRemoved', @@ -1099,15 +1055,11 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase $unionTypeThatLosesATypeOld = new UnionType([ 'name' => 'UnionTypeThatLosesAType', 'types' => [$typeInUnion1, $typeInUnion2], - 'resolveType' => function () { - } ]); $unionTypeThatLosesATypeNew = new UnionType([ 'name' => 'UnionTypeThatLosesAType', 'types' => [$typeInUnion1], - 'resolveType' => function () { - } ]); $enumTypeThatLosesAValueOld = new EnumType([ @@ -1132,8 +1084,6 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase 'fields' => [ 'field1' => Type::string() ], - 'resolveType' => function () { - } ]); $typeThatLosesInterfaceOld = new ObjectType([ @@ -1151,32 +1101,78 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase ] ]); + $directiveThatIsRemoved = Directive::skipDirective(); + $directiveThatRemovesArgOld = new Directive([ + 'name' => 'DirectiveThatRemovesArg', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + 'args' => FieldArgument::createMap([ + 'arg1' => [ + 'name' => 'arg1', + ], + ]), + ]); + $directiveThatRemovesArgNew = new Directive([ + 'name' => 'DirectiveThatRemovesArg', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + ]); + $nonNullDirectiveAddedOld = new Directive([ + 'name' => 'NonNullDirectiveAdded', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + ]); + $nonNullDirectiveAddedNew = new Directive([ + 'name' => 'NonNullDirectiveAdded', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + 'args' => FieldArgument::createMap([ + 'arg1' => [ + 'name' => 'arg1', + 'type' => Type::nonNull(Type::boolean()), + ], + ]), + ]); + $directiveRemovedLocationOld = new Directive([ + 'name' => 'Directive Name', + 'locations' => [DirectiveLocation::FIELD_DEFINITION, DirectiveLocation::QUERY], + ]); + $directiveRemovedLocationNew = new Directive([ + 'name' => 'Directive Name', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + ]); + $oldSchema = new Schema([ 'query' => $this->queryType, - 'types' => - [ - 'TypeThatGetsRemoved' => $typeThatGetsRemoved, - 'TypeThatChangesType' => $typeThatChangesTypeOld, - 'TypeThatHasBreakingFieldChanges' => $typeThatHasBreakingFieldChangesOld, - 'UnionTypeThatLosesAType' => $unionTypeThatLosesATypeOld, - 'EnumTypeThatLosesAValue' => $enumTypeThatLosesAValueOld, - 'ArgThatChanges' => $argThatChanges, - 'TypeThatLosesInterface' => $typeThatLosesInterfaceOld - ] + 'types' => [ + $typeThatGetsRemoved, + $typeThatChangesTypeOld, + $typeThatHasBreakingFieldChangesOld, + $unionTypeThatLosesATypeOld, + $enumTypeThatLosesAValueOld, + $argThatChanges, + $typeThatLosesInterfaceOld + ], + 'directives' => [ + $directiveThatIsRemoved, + $directiveThatRemovesArgOld, + $nonNullDirectiveAddedOld, + $directiveRemovedLocationOld, + ] ]); $newSchema = new Schema([ 'query' => $this->queryType, - 'types' => - [ - 'TypeThatChangesType' => $typeThatChangesTypeNew, - 'TypeThatHasBreakingFieldChanges' => $typeThatHasBreakingFieldChangesNew, - 'UnionTypeThatLosesAType' => $unionTypeThatLosesATypeNew, - 'EnumTypeThatLosesAValue' => $enumTypeThatLosesAValueNew, - 'ArgThatChanges' => $argChanged, - 'TypeThatLosesInterface' => $typeThatLosesInterfaceNew, - 'Interface1' => $interface1 - ] + 'types' => [ + $typeThatChangesTypeNew, + $typeThatHasBreakingFieldChangesNew, + $unionTypeThatLosesATypeNew, + $enumTypeThatLosesAValueNew, + $argChanged, + $typeThatLosesInterfaceNew, + $interface1 + ], + 'directives' => [ + $directiveThatRemovesArgNew, + $nonNullDirectiveAddedNew, + $directiveRemovedLocationNew, + ] ]); $expectedBreakingChanges = [ @@ -1188,25 +1184,23 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase 'type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_REMOVED, 'description' => 'TypeInUnion2 was removed.', ], - /* - // NB the below assertion is included in the graphql-js tests, but it makes no sense. - // Seriously, look for what `int` type was supposed to be removed between the two schemas. There is none. - // I honestly think it's a bug in the js implementation and was put into the test just to make it pass. + /* This is reported in the js version because builtin sclar types are added on demand + and not like here always [ 'type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_REMOVED, 'description' => 'Int was removed.' ],*/ [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_CHANGED, + 'type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_CHANGED_KIND, 'description' => 'TypeThatChangesType changed from an Object type to an Interface type.', ], [ 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_REMOVED, - 'description' => 'TypeThatHasBreakingFieldChanges->field1 was removed.', + 'description' => 'TypeThatHasBreakingFieldChanges.field1 was removed.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'TypeThatHasBreakingFieldChanges->field2 changed type from String to Boolean.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'TypeThatHasBreakingFieldChanges.field2 changed type from String to Boolean.', ], [ 'type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_REMOVED_FROM_UNION, @@ -1217,21 +1211,220 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase 'description' => 'VALUE0 was removed from enum type EnumTypeThatLosesAValue.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'ArgThatChanges->field1 arg id has changed type from Int to String.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'ArgThatChanges.field1 arg id has changed type from Int to String', ], [ 'type' => FindBreakingChanges::BREAKING_CHANGE_INTERFACE_REMOVED_FROM_OBJECT, 'description' => 'TypeThatLosesInterface1 no longer implements interface Interface1.', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_DIRECTIVE_REMOVED, + 'description' => 'skip was removed', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_DIRECTIVE_ARG_REMOVED, + 'description' => 'arg1 was removed from DirectiveThatRemovesArg', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_NON_NULL_DIRECTIVE_ARG_ADDED, + 'description' => 'A non-null arg arg1 on directive NonNullDirectiveAdded was added', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_DIRECTIVE_LOCATION_REMOVED, + 'description' => 'QUERY was removed from Directive Name', ] ]; $this->assertEquals($expectedBreakingChanges, FindBreakingChanges::findBreakingChanges($oldSchema, $newSchema)); } - // findDangerousChanges tests below here + /** + * @it should detect if a directive was explicitly removed + */ + public function testShouldDetectIfADirectiveWasExplicitlyRemoved() + { + $oldSchema = new Schema([ + 'directives' => [Directive::skipDirective(), Directive::includeDirective()], + ]); - public function testFindDangerousArgChanges() + $newSchema = new Schema([ + 'directives' => [Directive::skipDirective()], + ]); + + $includeDirective = Directive::includeDirective(); + + $expectedBreakingChanges = [ + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_DIRECTIVE_REMOVED, + 'description' => "{$includeDirective->name} was removed", + ] + ]; + + $this->assertEquals($expectedBreakingChanges, FindBreakingChanges::findRemovedDirectives($oldSchema, $newSchema)); + } + + /** + * @it should detect if a directive was implicitly removed + */ + public function testShouldDetectIfADirectiveWasImplicitlyRemoved() + { + $oldSchema = new Schema([]); + + $newSchema = new Schema([ + 'directives' => [Directive::skipDirective(), Directive::includeDirective()], + ]); + + $deprecatedDirective = Directive::deprecatedDirective(); + + $expectedBreakingChanges = [ + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_DIRECTIVE_REMOVED, + 'description' => "{$deprecatedDirective->name} was removed", + ] + ]; + + $this->assertEquals($expectedBreakingChanges, FindBreakingChanges::findRemovedDirectives($oldSchema, $newSchema)); + } + + /** + * @it should detect if a directive argument was removed + */ + public function testShouldDetectIfADirectiveArgumentWasRemoved() + { + $oldSchema = new Schema([ + 'directives' => [ + new Directive([ + 'name' => 'DirectiveWithArg', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + 'args' => FieldArgument::createMap([ + 'arg1' => [ + 'name' => 'arg1', + ], + ]), + ]) + ], + ]); + + $newSchema = new Schema([ + 'directives' => [ + new Directive([ + 'name' => 'DirectiveWithArg', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + ]) + ], + ]); + + $expectedBreakingChanges = [ + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_DIRECTIVE_ARG_REMOVED, + 'description' => "arg1 was removed from DirectiveWithArg", + ] + ]; + + $this->assertEquals($expectedBreakingChanges, FindBreakingChanges::findRemovedDirectiveArgs($oldSchema, $newSchema)); + } + + /** + * @it should detect if a non-nullable directive argument was added + */ + public function testShouldDetectIfANonNullableDirectiveArgumentWasAdded() + { + $oldSchema = new Schema([ + 'directives' => [ + new Directive([ + 'name' => 'DirectiveName', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + ]) + ], + ]); + + $newSchema = new Schema([ + 'directives' => [ + new Directive([ + 'name' => 'DirectiveName', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + 'args' => FieldArgument::createMap([ + 'arg1' => [ + 'name' => 'arg1', + 'type' => Type::nonNull(Type::boolean()), + ], + ]), + ]) + ], + ]); + + $expectedBreakingChanges = [ + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_NON_NULL_DIRECTIVE_ARG_ADDED, + 'description' => "A non-null arg arg1 on directive DirectiveName was added", + ] + ]; + + $this->assertEquals($expectedBreakingChanges, FindBreakingChanges::findAddedNonNullDirectiveArgs($oldSchema, $newSchema)); + } + + /** + * @it should detect locations removed from a directive + */ + public function testShouldDetectLocationsRemovedFromADirective() + { + $d1 = new Directive([ + 'name' => 'Directive Name', + 'locations' => [DirectiveLocation::FIELD_DEFINITION, DirectiveLocation::QUERY], + ]); + + $d2 = new Directive([ + 'name' => 'Directive Name', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + ]); + + $this->assertEquals([DirectiveLocation::QUERY], FindBreakingChanges::findRemovedLocationsForDirective($d1, $d2)); + } + + /** + * @it should detect locations removed directives within a schema + */ + public function testShouldDetectLocationsRemovedDirectiveWithinASchema() + { + $oldSchema = new Schema([ + 'directives' => [ + new Directive([ + 'name' => 'Directive Name', + 'locations' => [ + DirectiveLocation::FIELD_DEFINITION, + DirectiveLocation::QUERY + ], + ]) + ], + ]); + + $newSchema = new Schema([ + 'directives' => [ + new Directive([ + 'name' => 'Directive Name', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + ]) + ], + ]); + + $expectedBreakingChanges = [ + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_DIRECTIVE_LOCATION_REMOVED, + 'description' => "QUERY was removed from Directive Name", + ] + ]; + + $this->assertEquals($expectedBreakingChanges, FindBreakingChanges::findRemovedDirectiveLocations($oldSchema, $newSchema)); + } + + // DESCRIBE: findDangerousChanges + // DESCRIBE: findArgChanges + + /** + * @it should detect if an argument's defaultValue has changed + */ + public function testShouldDetectIfAnArgumentsDefaultValueHasChanged() { $oldType = new ObjectType([ 'name' => 'Type1', @@ -1256,7 +1449,7 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase 'args' => [ 'name' => [ 'type' => Type::string(), - 'defaultValue' => 'Testertest' + 'defaultValue' => 'Test' ] ] ] @@ -1265,28 +1458,31 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase $oldSchema = new Schema([ 'query' => $this->queryType, - 'types' => [ - $oldType - ] + 'types' => [$oldType], ]); $newSchema = new Schema([ 'query' => $this->queryType, - 'types' => [ - $newType - ] + 'types' => [$newType], ]); - $this->assertEquals( + $expected = [ [ - 'type' => FindBreakingChanges::DANGEROUS_CHANGE_ARG_DEFAULT_VALUE, - 'description' => 'Type1->field1 arg name has changed defaultValue' - ], - FindBreakingChanges::findArgChanges($oldSchema, $newSchema)['dangerousChanges'][0] + 'type' => FindBreakingChanges::DANGEROUS_CHANGE_ARG_DEFAULT_VALUE_CHANGED, + 'description' => 'Type1.field1 arg name has changed defaultValue' + ] + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findArgChanges($oldSchema, $newSchema)['dangerousChanges'] ); } - public function testDetectsEnumValueAdditions() + /** + * @it should detect if a value was added to an enum type + */ + public function testShouldDetectIfAValueWasAddedToAnEnumType() { $oldEnumType = new EnumType([ 'name' => 'EnumType1', @@ -1306,28 +1502,80 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase $oldSchema = new Schema([ 'query' => $this->queryType, - 'types' => [ - $oldEnumType - ] + 'types' => [$oldEnumType], ]); $newSchema = new Schema([ 'query' => $this->queryType, - 'types' => [ - $newEnumType - ] + 'types' => [$newEnumType], ]); - $this->assertEquals( + $expected = [ [ 'type' => FindBreakingChanges::DANGEROUS_CHANGE_VALUE_ADDED_TO_ENUM, - 'description' => 'VALUE2 was added to enum type EnumType1' - ], - FindBreakingChanges::findValuesAddedToEnums($oldSchema, $newSchema)[0] + 'description' => 'VALUE2 was added to enum type EnumType1.' + ] + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findValuesAddedToEnums($oldSchema, $newSchema) ); } - public function testDetectsAdditionsToUnionType() + /** + * @it should detect interfaces added to types + */ + public function testShouldDetectInterfacesAddedToTypes() + { + $interface1 = new InterfaceType([ + 'name' => 'Interface1', + 'fields' => [ + 'field1' => Type::string(), + ], + ]); + $oldType = new ObjectType([ + 'name' => 'Type1', + 'fields' => [ + 'field1' => Type::string(), + ], + ]); + + $newType = new ObjectType([ + 'name' => 'Type1', + 'interfaces' => [$interface1], + 'fields' => [ + 'field1' => Type::string(), + ], + ]); + + $oldSchema = new Schema([ + 'query' => $this->queryType, + 'types' => [$oldType], + ]); + + $newSchema = new Schema([ + 'query' => $this->queryType, + 'types' => [$newType], + ]); + + $expected = [ + [ + 'type' => FindBreakingChanges::DANGEROUS_CHANGE_INTERFACE_ADDED_TO_OBJECT, + 'description' => 'Interface1 added to interfaces implemented by Type1.' + ] + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findInterfacesAddedToObjectTypes($oldSchema, $newSchema) + ); + } + + /** + * @it should detect if a type was added to a union type + */ + public function testShouldDetectIfATypeWasAddedToAUnionType() { $type1 = new ObjectType([ 'name' => 'Type1', @@ -1335,14 +1583,14 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase 'field1' => Type::string() ] ]); - + // logially equivalent to type1; findTypesRemovedFromUnions should not + //treat this as different than type1 $type1a = new ObjectType([ 'name' => 'Type1', 'fields' => [ 'field1' => Type::string() ] ]); - $type2 = new ObjectType([ 'name' => 'Type2', 'fields' => [ @@ -1353,41 +1601,88 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase $oldUnionType = new UnionType([ 'name' => 'UnionType1', 'types' => [$type1], - 'resolveType' => function () { - } ]); - $newUnionType = new UnionType([ 'name' => 'UnionType1', 'types' => [$type1a, $type2], - 'resolveType' => function () { - } + ]); + + $oldSchema = new Schema([ + 'query' => $this->queryType, + 'types' => [$oldUnionType], + ]); + + $newSchema = new Schema([ + 'query' => $this->queryType, + 'types' => [$newUnionType], + ]); + + $expected = [ + [ + 'type' => FindBreakingChanges::DANGEROUS_CHANGE_TYPE_ADDED_TO_UNION, + 'description' => 'Type2 was added to union type UnionType1.' + ] + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findTypesAddedToUnions($oldSchema, $newSchema) + ); + } + + /** + * @it should detect if a nullable field was added to an input + */ + public function testShouldDetectIfANullableFieldWasAddedToAnInput() + { + $oldInputType = new InputObjectType([ + 'name' => 'InputType1', + 'fields' => [ + 'field1' => [ + 'type' => Type::string(), + ], + ], + ]); + $newInputType = new InputObjectType([ + 'name' => 'InputType1', + 'fields' => [ + 'field1' => [ + 'type' => Type::string(), + ], + 'field2' => [ + 'type' => Type::int(), + ], + ], ]); $oldSchema = new Schema([ 'query' => $this->queryType, 'types' => [ - $oldUnionType + $oldInputType, ] ]); $newSchema = new Schema([ 'query' => $this->queryType, 'types' => [ - $newUnionType + $newInputType, ] ]); - $this->assertEquals( + $expectedFieldChanges = [ [ - 'type' => FindBreakingChanges::DANGEROUS_CHANGE_TYPE_ADDED_TO_UNION, - 'description' => 'Type2 was added to union type UnionType1' + 'description' => 'A nullable field field2 on input type InputType1 was added.', + 'type' => FindBreakingChanges::DANGEROUS_CHANGE_NULLABLE_INPUT_FIELD_ADDED ], - FindBreakingChanges::findTypesAddedToUnions($oldSchema, $newSchema)[0] - ); + ]; + + $this->assertEquals($expectedFieldChanges, FindBreakingChanges::findFieldsThatChangedTypeOnInputObjectTypes($oldSchema, $newSchema)['dangerousChanges']); } - public function testFindsAllDangerousChanges() + /** + * @it should find all dangerous changes + */ + public function testShouldFindAllDangerousChanges() { $enumThatGainsAValueOld = new EnumType([ 'name' => 'EnumType1', @@ -1420,6 +1715,27 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase ] ]); + $typeInUnion1 = new ObjectType([ + 'name' => 'TypeInUnion1', + 'fields' => [ + 'field1' => Type::string() + ] + ]); + $typeInUnion2 = new ObjectType([ + 'name' => 'TypeInUnion2', + 'fields' => [ + 'field1' => Type::string() + ] + ]); + $unionTypeThatGainsATypeOld = new UnionType([ + 'name' => 'UnionTypeThatGainsAType', + 'types' => [$typeInUnion1], + ]); + $unionTypeThatGainsATypeNew = new UnionType([ + 'name' => 'UnionTypeThatGainsAType', + 'types' => [$typeInUnion1, $typeInUnion2], + ]); + $newType = new ObjectType([ 'name' => 'Type1', 'fields' => [ @@ -1428,39 +1744,33 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase 'args' => [ 'name' => [ 'type' => Type::string(), - 'defaultValue' => 'Testertest' + 'defaultValue' => 'Test' ] ] ] ] ]); - $typeInUnion1 = new ObjectType([ - 'name' => 'TypeInUnion1', + $interface1 = new InterfaceType([ + 'name' => 'Interface1', 'fields' => [ - 'field1' => Type::string() - ] + 'field1' => Type::string(), + ], ]); - $typeInUnion2 = new ObjectType([ - 'name' => 'TypeInUnion2', + $typeThatGainsInterfaceOld = new ObjectType([ + 'name' => 'TypeThatGainsInterface1', 'fields' => [ - 'field1' => Type::string() - ] + 'field1' => Type::string(), + ], ]); - $unionTypeThatGainsATypeOld = new UnionType([ - 'name' => 'UnionType1', - 'types' => [$typeInUnion1], - 'resolveType' => function () { - } - ]); - - $unionTypeThatGainsATypeNew = new UnionType([ - 'name' => 'UnionType1', - 'types' => [$typeInUnion1, $typeInUnion2], - 'resolveType' => function () { - } + $typeThatGainsInterfaceNew = new ObjectType([ + 'name' => 'TypeThatGainsInterface1', + 'interfaces' => [$interface1], + 'fields' => [ + 'field1' => Type::string(), + ], ]); $oldSchema = new Schema([ @@ -1468,6 +1778,7 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase 'types' => [ $oldType, $enumThatGainsAValueOld, + $typeThatGainsInterfaceOld, $unionTypeThatGainsATypeOld ] ]); @@ -1477,25 +1788,86 @@ class FindBreakingChangesTest extends \PHPUnit_Framework_TestCase 'types' => [ $newType, $enumThatGainsAValueNew, + $typeThatGainsInterfaceNew, $unionTypeThatGainsATypeNew ] ]); $expectedDangerousChanges = [ [ - 'description' => 'Type1->field1 arg name has changed defaultValue', - 'type' => FindBreakingChanges::DANGEROUS_CHANGE_ARG_DEFAULT_VALUE + 'description' => 'Type1.field1 arg name has changed defaultValue', + 'type' => FindBreakingChanges::DANGEROUS_CHANGE_ARG_DEFAULT_VALUE_CHANGED ], [ - 'description' => 'VALUE2 was added to enum type EnumType1', + 'description' => 'VALUE2 was added to enum type EnumType1.', 'type' => FindBreakingChanges::DANGEROUS_CHANGE_VALUE_ADDED_TO_ENUM ], + [ + 'type' => FindBreakingChanges::DANGEROUS_CHANGE_INTERFACE_ADDED_TO_OBJECT, + 'description' => 'Interface1 added to interfaces implemented by TypeThatGainsInterface1.', + ], [ 'type' => FindBreakingChanges::DANGEROUS_CHANGE_TYPE_ADDED_TO_UNION, - 'description' => 'TypeInUnion2 was added to union type UnionType1', + 'description' => 'TypeInUnion2 was added to union type UnionTypeThatGainsAType.', ] ]; $this->assertEquals($expectedDangerousChanges, FindBreakingChanges::findDangerousChanges($oldSchema, $newSchema)); } -} \ No newline at end of file + + /** + * @it should detect if a nullable field argument was added + */ + public function testShouldDetectIfANullableFieldArgumentWasAdded() + { + $oldType = new ObjectType([ + 'name' => 'Type1', + 'fields' => [ + 'field1' => [ + 'type' => Type::string(), + 'args' => [ + 'arg1' => [ + 'type' => Type::string(), + ], + ], + ], + ], + ]); + + $newType = new ObjectType([ + 'name' => 'Type1', + 'fields' => [ + 'field1' => [ + 'type' => Type::string(), + 'args' => [ + 'arg1' => [ + 'type' => Type::string(), + ], + 'arg2' => [ + 'type' => Type::string(), + ], + ], + ], + ], + ]); + + $oldSchema = new Schema([ + 'query' => $this->queryType, + 'types' => [$oldType], + ]); + + $newSchema = new Schema([ + 'query' => $this->queryType, + 'types' => [$newType], + ]); + + $expectedFieldChanges = [ + [ + 'description' => 'A nullable arg arg2 on Type1.field1 was added', + 'type' => FindBreakingChanges::DANGEROUS_CHANGE_NULLABLE_ARG_ADDED + ], + ]; + + $this->assertEquals($expectedFieldChanges, FindBreakingChanges::findArgChanges($oldSchema, $newSchema)['dangerousChanges']); + } +} diff --git a/tests/Utils/IsValidLiteralValueTest.php b/tests/Utils/IsValidLiteralValueTest.php new file mode 100644 index 0000000..33b0592 --- /dev/null +++ b/tests/Utils/IsValidLiteralValueTest.php @@ -0,0 +1,37 @@ +assertEquals( + [], + DocumentValidator::isValidLiteralValue(Type::int(), Parser::parseValue('123')) + ); + } + + /** + * @it Returns errors for an invalid value + */ + public function testReturnsErrorsForForInvalidValue() + { + $errors = DocumentValidator::isValidLiteralValue(Type::int(), Parser::parseValue('"abc"')); + + $this->assertCount(1, $errors); + $this->assertEquals('Expected type Int, found "abc".', $errors[0]->getMessage()); + $this->assertEquals([new SourceLocation(1, 1)], $errors[0]->getLocations()); + $this->assertEquals(null, $errors[0]->getPath()); + } +} diff --git a/tests/Utils/IsValidPHPValueTest.php b/tests/Utils/IsValidPHPValueTest.php deleted file mode 100644 index 937fa4f..0000000 --- a/tests/Utils/IsValidPHPValueTest.php +++ /dev/null @@ -1,132 +0,0 @@ -expectNoErrors($result); - - // returns no error for negative int value - $result = Values::isValidPHPValue(-1, Type::int()); - $this->expectNoErrors($result); - - // returns no error for null value - $result = Values::isValidPHPValue(null, Type::int()); - $this->expectNoErrors($result); - - // returns a single error for positive int string value - $result = Values::isValidPHPValue('1', Type::int()); - $this->expectErrorResult($result, 1); - - // returns a single error for negative int string value - $result = Values::isValidPHPValue('-1', Type::int()); - $this->expectErrorResult($result, 1); - - // returns errors for exponential int string value - $result = Values::isValidPHPValue('1e3', Type::int()); - $this->expectErrorResult($result, 1); - $result = Values::isValidPHPValue('0e3', Type::int()); - $this->expectErrorResult($result, 1); - - // returns a single error for empty value - $result = Values::isValidPHPValue('', Type::int()); - $this->expectErrorResult($result, 1); - - // returns error for float value - $result = Values::isValidPHPValue(1.5, Type::int()); - $this->expectErrorResult($result, 1); - $result = Values::isValidPHPValue(1e3, Type::int()); - $this->expectErrorResult($result, 1); - - // returns error for float string value - $result = Values::isValidPHPValue('1.5', Type::int()); - $this->expectErrorResult($result, 1); - - // returns a single error for char input - $result = Values::isValidPHPValue('a', Type::int()); - $this->expectErrorResult($result, 1); - - // returns a single error for char input - $result = Values::isValidPHPValue('meow', Type::int()); - $this->expectErrorResult($result, 1); - } - - public function testValidFloatValue() - { - // returns no error for positive float value - $result = Values::isValidPHPValue(1.2, Type::float()); - $this->expectNoErrors($result); - - // returns no error for exponential float value - $result = Values::isValidPHPValue(1e3, Type::float()); - $this->expectNoErrors($result); - - // returns no error for negative float value - $result = Values::isValidPHPValue(-1.2, Type::float()); - $this->expectNoErrors($result); - - // returns no error for a positive int value - $result = Values::isValidPHPValue(1, Type::float()); - $this->expectNoErrors($result); - - // returns no errors for a negative int value - $result = Values::isValidPHPValue(-1, Type::float()); - $this->expectNoErrors($result); - - // returns no error for null value: - $result = Values::isValidPHPValue(null, Type::float()); - $this->expectNoErrors($result); - - // returns error for positive float string value - $result = Values::isValidPHPValue('1.2', Type::float()); - $this->expectErrorResult($result, 1); - - // returns error for negative float string value - $result = Values::isValidPHPValue('-1.2', Type::float()); - $this->expectErrorResult($result, 1); - - // returns error for a positive int string value - $result = Values::isValidPHPValue('1', Type::float()); - $this->expectErrorResult($result, 1); - - // returns errors for a negative int string value - $result = Values::isValidPHPValue('-1', Type::float()); - $this->expectErrorResult($result, 1); - - // returns error for exponent input - $result = Values::isValidPHPValue('1e3', Type::float()); - $this->expectErrorResult($result, 1); - $result = Values::isValidPHPValue('0e3', Type::float()); - $this->expectErrorResult($result, 1); - - // returns a single error for empty value - $result = Values::isValidPHPValue('', Type::float()); - $this->expectErrorResult($result, 1); - - // returns a single error for char input - $result = Values::isValidPHPValue('a', Type::float()); - $this->expectErrorResult($result, 1); - - // returns a single error for char input - $result = Values::isValidPHPValue('meow', Type::float()); - $this->expectErrorResult($result, 1); - } - - private function expectNoErrors($result) - { - $this->assertInternalType('array', $result); - $this->assertEquals([], $result); - } - - private function expectErrorResult($result, $size) { - $this->assertInternalType('array', $result); - $this->assertEquals($size, count($result)); - } -} diff --git a/tests/Utils/QuotedOrListTest.php b/tests/Utils/QuotedOrListTest.php new file mode 100644 index 0000000..d739c4c --- /dev/null +++ b/tests/Utils/QuotedOrListTest.php @@ -0,0 +1,65 @@ +setExpectedException(\LogicException::class); + Utils::quotedOrList([]); + } + + /** + * @it Returns single quoted item + */ + public function testReturnsSingleQuotedItem() + { + $this->assertEquals( + '"A"', + Utils::quotedOrList(['A']) + ); + } + + /** + * @it Returns two item list + */ + public function testReturnsTwoItemList() + { + $this->assertEquals( + '"A" or "B"', + Utils::quotedOrList(['A', 'B']) + ); + } + + /** + * @it Returns comma separated many item list + */ + public function testReturnsCommaSeparatedManyItemList() + { + $this->assertEquals( + '"A", "B", or "C"', + Utils::quotedOrList(['A', 'B', 'C']) + ); + } + + /** + * @it Limits to five items + */ + public function testLimitsToFiveItems() + { + $this->assertEquals( + '"A", "B", "C", "D", or "E"', + Utils::quotedOrList(['A', 'B', 'C', 'D', 'E', 'F']) + ); + } +} diff --git a/tests/Utils/SchemaPrinterTest.php b/tests/Utils/SchemaPrinterTest.php index 49314f0..3c39a50 100644 --- a/tests/Utils/SchemaPrinterTest.php +++ b/tests/Utils/SchemaPrinterTest.php @@ -1,9 +1,10 @@ printSingleFieldSchema([ 'type' => Type::string() ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -48,7 +49,7 @@ schema { type Root { singleField: String } -'); +', $output); } /** @@ -59,7 +60,7 @@ type Root { $output = $this->printSingleFieldSchema([ 'type' => Type::listOf(Type::string()) ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -67,7 +68,7 @@ schema { type Root { singleField: [String] } -'); +', $output); } /** @@ -78,7 +79,7 @@ type Root { $output = $this->printSingleFieldSchema([ 'type' => Type::nonNull(Type::string()) ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -86,7 +87,7 @@ schema { type Root { singleField: String! } -'); +', $output); } /** @@ -97,7 +98,7 @@ type Root { $output = $this->printSingleFieldSchema([ 'type' => Type::nonNull(Type::listOf(Type::string())) ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -105,7 +106,7 @@ schema { type Root { singleField: [String]! } -'); +', $output); } /** @@ -116,7 +117,7 @@ type Root { $output = $this->printSingleFieldSchema([ 'type' => Type::listOf(Type::nonNull(Type::string())) ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -124,7 +125,7 @@ schema { type Root { singleField: [String!] } -'); +', $output); } /** @@ -135,7 +136,7 @@ type Root { $output = $this->printSingleFieldSchema([ 'type' => Type::nonNull(Type::listOf(Type::nonNull(Type::string()))) ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -143,7 +144,7 @@ schema { type Root { singleField: [String!]! } -'); +', $output); } /** @@ -163,7 +164,7 @@ type Root { $schema = new Schema(['query' => $root]); $output = $this->printForTest($schema); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -175,7 +176,7 @@ type Foo { type Root { foo: Foo } -'); +', $output); } /** @@ -187,7 +188,7 @@ type Root { 'type' => Type::string(), 'args' => ['argOne' => ['type' => Type::int()]] ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -195,7 +196,7 @@ schema { type Root { singleField(argOne: Int): String } -'); +', $output); } /** @@ -207,7 +208,7 @@ type Root { 'type' => Type::string(), 'args' => ['argOne' => ['type' => Type::int(), 'defaultValue' => 2]] ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -215,7 +216,7 @@ schema { type Root { singleField(argOne: Int = 2): String } -'); +', $output); } /** @@ -227,7 +228,7 @@ type Root { 'type' => Type::string(), 'args' => ['argOne' => ['type' => Type::int(), 'defaultValue' => null]] ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -235,7 +236,7 @@ schema { type Root { singleField(argOne: Int = null): String } -'); +', $output); } /** @@ -247,7 +248,7 @@ type Root { 'type' => Type::string(), 'args' => ['argOne' => ['type' => Type::nonNull(Type::int())]] ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -255,7 +256,7 @@ schema { type Root { singleField(argOne: Int!): String } -'); +', $output); } /** @@ -270,7 +271,7 @@ type Root { 'argTwo' => ['type' => Type::string()] ] ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -278,7 +279,7 @@ schema { type Root { singleField(argOne: Int, argTwo: String): String } -'); +', $output); } /** @@ -294,7 +295,7 @@ type Root { 'argThree' => ['type' => Type::boolean()] ] ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -302,7 +303,7 @@ schema { type Root { singleField(argOne: Int = 1, argTwo: String, argThree: Boolean): String } -'); +', $output); } /** @@ -318,7 +319,7 @@ type Root { 'argThree' => ['type' => Type::boolean()] ] ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -326,7 +327,7 @@ schema { type Root { singleField(argOne: Int, argTwo: String = "foo", argThree: Boolean): String } -'); +', $output); } /** @@ -342,7 +343,7 @@ type Root { 'argThree' => ['type' => Type::boolean(), 'defaultValue' => false] ] ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -350,7 +351,7 @@ schema { type Root { singleField(argOne: Int, argTwo: String, argThree: Boolean = false): String } -'); +', $output); } /** @@ -360,7 +361,6 @@ type Root { { $fooType = new InterfaceType([ 'name' => 'Foo', - 'resolveType' => function() { return null; }, 'fields' => ['str' => ['type' => Type::string()]] ]); @@ -380,7 +380,7 @@ type Root { 'types' => [$barType] ]); $output = $this->printForTest($schema); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -396,7 +396,7 @@ interface Foo { type Root { bar: Bar } -'); +', $output); } /** @@ -406,13 +406,11 @@ type Root { { $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()]] ]); @@ -435,7 +433,7 @@ type Root { 'types' => [$barType] ]); $output = $this->printForTest($schema); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -456,7 +454,7 @@ interface Foo { type Root { bar: Bar } -'); +', $output); } /** @@ -476,13 +474,11 @@ type Root { $singleUnion = new UnionType([ 'name' => 'SingleUnion', - 'resolveType' => function() { return null; }, 'types' => [$fooType] ]); $multipleUnion = new UnionType([ 'name' => 'MultipleUnion', - 'resolveType' => function() { return null; }, 'types' => [$fooType, $barType] ]); @@ -496,7 +492,7 @@ type Root { $schema = new Schema(['query' => $root]); $output = $this->printForTest($schema); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -517,7 +513,7 @@ type Root { } union SingleUnion = Foo -'); +', $output); } /** @@ -542,7 +538,7 @@ union SingleUnion = Foo $schema = new Schema(['query' => $root]); $output = $this->printForTest($schema); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -554,7 +550,7 @@ input InputType { type Root { str(argOne: InputType): String } -'); +', $output); } /** @@ -578,7 +574,7 @@ type Root { $schema = new Schema(['query' => $root]); $output = $this->printForTest($schema); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -588,7 +584,7 @@ scalar Odd type Root { odd: Odd } -'); +', $output); } /** @@ -614,7 +610,7 @@ type Root { $schema = new Schema(['query' => $root]); $output = $this->printForTest($schema); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -628,7 +624,41 @@ enum RGB { type Root { rgb: RGB } -'); +', $output); + } + + /** + * @it Prints custom directives + */ + public function testPrintsCustomDirectives() + { + $query = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'field' => ['type' => Type::string()], + ] + ]); + + $customDirectives = new Directive([ + 'name' => 'customDirective', + 'locations' => [ + DirectiveLocation::FIELD + ] + ]); + + $schema = new Schema([ + 'query' => $query, + 'directives' => [$customDirectives], + ]); + + $output = $this->printForTest($schema); + $this->assertEquals(' +directive @customDirective on FIELD + +type Query { + field: String +} +', $output); } /** @@ -650,6 +680,257 @@ 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($introspectionSchema, $output); + } + + /** + * @it Print Introspection Schema with comment description + */ + public function testPrintIntrospectionSchemaWithCommentDescription() + { + $root = new ObjectType([ + 'name' => 'Root', + 'fields' => [ + 'onlyField' => ['type' => Type::string()] + ] + ]); + + $schema = new Schema(['query' => $root]); + $output = SchemaPrinter::printIntrosepctionSchema($schema, [ + 'commentDescriptions' => true + ]); + $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. @@ -845,6 +1126,6 @@ enum __TypeKind { } EOT; - $this->assertEquals($output, $introspectionSchema); + $this->assertEquals($introspectionSchema, $output); } -} \ No newline at end of file +} diff --git a/tests/Utils/SuggestionListTest.php b/tests/Utils/SuggestionListTest.php new file mode 100644 index 0000000..73797f7 --- /dev/null +++ b/tests/Utils/SuggestionListTest.php @@ -0,0 +1,45 @@ +assertEquals( + Utils::suggestionList('', ['a']), + ['a'] + ); + } + + /** + * @it Returns empty array when there are no options + */ + public function testReturnsEmptyArrayWhenThereAreNoOptions() + { + $this->assertEquals( + Utils::suggestionList('input', []), + [] + ); + } + + /** + * @it Returns options sorted based on similarity + */ + public function testReturnsOptionsSortedBasedOnSimilarity() + { + $this->assertEquals( + Utils::suggestionList('abc', ['a', 'ab', 'abc']), + ['abc', 'ab'] + ); + } +} diff --git a/tests/Validator/AbstractQuerySecurityTest.php b/tests/Validator/AbstractQuerySecurityTest.php index 885fc99..7e5e36b 100644 --- a/tests/Validator/AbstractQuerySecurityTest.php +++ b/tests/Validator/AbstractQuerySecurityTest.php @@ -53,7 +53,7 @@ abstract class AbstractQuerySecurityTest extends \PHPUnit_Framework_TestCase protected function assertIntrospectionQuery($maxExpected) { - $query = Introspection::getIntrospectionQuery(true); + $query = Introspection::getIntrospectionQuery(); $this->assertMaxValue($query, $maxExpected); } diff --git a/tests/Validator/DefaultValuesOfCorrectTypeTest.php b/tests/Validator/DefaultValuesOfCorrectTypeTest.php deleted file mode 100644 index a0ac412..0000000 --- a/tests/Validator/DefaultValuesOfCorrectTypeTest.php +++ /dev/null @@ -1,188 +0,0 @@ -expectPassesRule(new DefaultValuesOfCorrectType, ' - query NullableValues($a: Int, $b: String, $c: ComplexInput) { - dog { name } - } - '); - } - - /** - * @it required variables without default values - */ - public function testRequiredVariablesWithoutDefaultValues() - { - $this->expectPassesRule(new DefaultValuesOfCorrectType, ' - query RequiredValues($a: Int!, $b: String!) { - dog { name } - } - '); - } - - /** - * @it variables with valid default values - */ - public function testVariablesWithValidDefaultValues() - { - $this->expectPassesRule(new DefaultValuesOfCorrectType, ' - query WithDefaultValues( - $a: Int = 1, - $b: String = "ok", - $c: ComplexInput = { requiredField: true, intField: 3 } - ) { - dog { name } - } - '); - } - - /** - * @it variables with valid default null values - */ - public function testVariablesWithValidDefaultNullValues() - { - $this->expectPassesRule(new DefaultValuesOfCorrectType(), ' - query WithDefaultValues( - $a: Int = null, - $b: String = null, - $c: ComplexInput = { requiredField: true, intField: null } - ) { - dog { name } - } - '); - } - - /** - * @it variables with invalid default null values - */ - public function testVariablesWithInvalidDefaultNullValues() - { - $this->expectFailsRule(new DefaultValuesOfCorrectType(), ' - query WithDefaultValues( - $a: Int! = null, - $b: String! = null, - $c: ComplexInput = { requiredField: null, intField: null } - ) { - dog { name } - } - ', [ - $this->defaultForNonNullArg('a', 'Int!', 'Int', 3, 20), - $this->badValue('a', 'Int!', 'null', 3, 20, [ - 'Expected "Int!", found null.' - ]), - $this->defaultForNonNullArg('b', 'String!', 'String', 4, 23), - $this->badValue('b', 'String!', 'null', 4, 23, [ - 'Expected "String!", found null.' - ]), - $this->badValue('c', 'ComplexInput', '{requiredField: null, intField: null}', - 5, 28, [ - 'In field "requiredField": Expected "Boolean!", found null.' - ] - ), - ]); - } - - /** - * @it no required variables with default values - */ - public function testNoRequiredVariablesWithDefaultValues() - { - $this->expectFailsRule(new DefaultValuesOfCorrectType, ' - query UnreachableDefaultValues($a: Int! = 3, $b: String! = "default") { - dog { name } - } - ', [ - $this->defaultForNonNullArg('a', 'Int!', 'Int', 2, 49), - $this->defaultForNonNullArg('b', 'String!', 'String', 2, 66) - ]); - } - - /** - * @it variables with invalid default values - */ - public function testVariablesWithInvalidDefaultValues() - { - $this->expectFailsRule(new DefaultValuesOfCorrectType, ' - query InvalidDefaultValues( - $a: Int = "one", - $b: String = 4, - $c: ComplexInput = "notverycomplex" - ) { - dog { name } - } - ', [ - $this->badValue('a', 'Int', '"one"', 3, 19, [ - 'Expected type "Int", found "one".' - ]), - $this->badValue('b', 'String', '4', 4, 22, [ - 'Expected type "String", found 4.' - ]), - $this->badValue('c', 'ComplexInput', '"notverycomplex"', 5, 28, [ - 'Expected "ComplexInput", found not an object.' - ]) - ]); - } - - /** - * @it complex variables missing required field - */ - public function testComplexVariablesMissingRequiredField() - { - $this->expectFailsRule(new DefaultValuesOfCorrectType, ' - query MissingRequiredField($a: ComplexInput = {intField: 3}) { - dog { name } - } - ', [ - $this->badValue('a', 'ComplexInput', '{intField: 3}', 2, 53, [ - 'In field "requiredField": Expected "Boolean!", found null.' - ]) - ]); - } - - /** - * @it list variables with invalid item - */ - public function testListVariablesWithInvalidItem() - { - $this->expectFailsRule(new DefaultValuesOfCorrectType, ' - query InvalidItem($a: [String] = ["one", 2]) { - dog { name } - } - ', [ - $this->badValue('a', '[String]', '["one", 2]', 2, 40, [ - 'In element #1: Expected type "String", found 2.' - ]) - ]); - } - - private function defaultForNonNullArg($varName, $typeName, $guessTypeName, $line, $column) - { - return FormattedError::create( - DefaultValuesOfCorrectType::defaultForNonNullArgMessage($varName, $typeName, $guessTypeName), - [ new SourceLocation($line, $column) ] - ); - } - - private function badValue($varName, $typeName, $val, $line, $column, $errors = null) - { - $realErrors = !$errors ? ["Expected type \"$typeName\", found $val."] : $errors; - - return FormattedError::create( - DefaultValuesOfCorrectType::badValueForDefaultArgMessage($varName, $typeName, $val, $realErrors), - [ new SourceLocation($line, $column) ] - ); - } -} diff --git a/tests/Validator/ExecutableDefinitionsTest.php b/tests/Validator/ExecutableDefinitionsTest.php new file mode 100644 index 0000000..2b9eda3 --- /dev/null +++ b/tests/Validator/ExecutableDefinitionsTest.php @@ -0,0 +1,79 @@ +expectPassesRule(new ExecutableDefinitions, ' + query Foo { + dog { + name + } + } + '); + } + + /** + * @it with operation and fragment + */ + public function testWithOperationAndFragment() + { + $this->expectPassesRule(new ExecutableDefinitions, ' + query Foo { + dog { + name + ...Frag + } + } + + fragment Frag on Dog { + name + } + '); + } + + /** + * @it with typeDefinition + */ + public function testWithTypeDefinition() + { + $this->expectFailsRule(new ExecutableDefinitions, ' + query Foo { + dog { + name + } + } + + type Cow { + name: String + } + + extend type Dog { + color: String + } + ', + [ + $this->nonExecutableDefinition('Cow', 8, 12), + $this->nonExecutableDefinition('Dog', 12, 19), + ]); + } + + private function nonExecutableDefinition($defName, $line, $column) + { + return FormattedError::create( + ExecutableDefinitions::nonExecutableDefinitionMessage($defName), + [ new SourceLocation($line, $column) ] + ); + } +} diff --git a/tests/Validator/FieldsOnCorrectTypeTest.php b/tests/Validator/FieldsOnCorrectTypeTest.php index 7d4b78b..75a891e 100644 --- a/tests/Validator/FieldsOnCorrectTypeTest.php +++ b/tests/Validator/FieldsOnCorrectTypeTest.php @@ -97,8 +97,10 @@ class FieldsOnCorrectTypeTest extends TestCase } } }', - [ $this->undefinedField('unknown_pet_field', 'Pet', [], 3, 9), - $this->undefinedField('unknown_cat_field', 'Cat', [], 5, 13) ] + [ + $this->undefinedField('unknown_pet_field', 'Pet', [], [], 3, 9), + $this->undefinedField('unknown_cat_field', 'Cat', [], [], 5, 13) + ] ); } @@ -111,7 +113,7 @@ class FieldsOnCorrectTypeTest extends TestCase fragment fieldNotDefined on Dog { meowVolume }', - [$this->undefinedField('meowVolume', 'Dog', [], 3, 9)] + [$this->undefinedField('meowVolume', 'Dog', [], ['barkVolume'], 3, 9)] ); } @@ -126,7 +128,7 @@ class FieldsOnCorrectTypeTest extends TestCase deeper_unknown_field } }', - [$this->undefinedField('unknown_field', 'Dog', [], 3, 9)] + [$this->undefinedField('unknown_field', 'Dog', [], [], 3, 9)] ); } @@ -141,7 +143,7 @@ class FieldsOnCorrectTypeTest extends TestCase unknown_field } }', - [$this->undefinedField('unknown_field', 'Pet', [], 4, 11)] + [$this->undefinedField('unknown_field', 'Pet', [], [], 4, 11)] ); } @@ -156,7 +158,7 @@ class FieldsOnCorrectTypeTest extends TestCase meowVolume } }', - [$this->undefinedField('meowVolume', 'Dog', [], 4, 11)] + [$this->undefinedField('meowVolume', 'Dog', [], ['barkVolume'], 4, 11)] ); } @@ -169,7 +171,7 @@ class FieldsOnCorrectTypeTest extends TestCase fragment aliasedFieldTargetNotDefined on Dog { volume : mooVolume }', - [$this->undefinedField('mooVolume', 'Dog', [], 3, 9)] + [$this->undefinedField('mooVolume', 'Dog', [], ['barkVolume'], 3, 9)] ); } @@ -182,7 +184,7 @@ class FieldsOnCorrectTypeTest extends TestCase fragment aliasedLyingFieldTargetNotDefined on Dog { barkVolume : kawVolume }', - [$this->undefinedField('kawVolume', 'Dog', [], 3, 9)] + [$this->undefinedField('kawVolume', 'Dog', [], ['barkVolume'], 3, 9)] ); } @@ -195,7 +197,7 @@ class FieldsOnCorrectTypeTest extends TestCase fragment notDefinedOnInterface on Pet { tailLength }', - [$this->undefinedField('tailLength', 'Pet', [], 3, 9)] + [$this->undefinedField('tailLength', 'Pet', [], [], 3, 9)] ); } @@ -208,8 +210,7 @@ class FieldsOnCorrectTypeTest extends TestCase fragment definedOnImplementorsButNotInterface on Pet { nickname }', - //[$this->undefinedField('nickname', 'Pet', [ 'Cat', 'Dog' ], 3, 9)] - [$this->undefinedField('nickname', 'Pet', [ ], 3, 9)] + [$this->undefinedField('nickname', 'Pet', ['Dog', 'Cat'], ['name'], 3, 9)] ); } @@ -234,7 +235,7 @@ class FieldsOnCorrectTypeTest extends TestCase fragment directFieldSelectionOnUnion on CatOrDog { directField }', - [$this->undefinedField('directField', 'CatOrDog', [], 3, 9)] + [$this->undefinedField('directField', 'CatOrDog', [], [], 3, 9)] ); } @@ -247,8 +248,14 @@ class FieldsOnCorrectTypeTest extends TestCase fragment definedOnImplementorsQueriedOnUnion on CatOrDog { name }', - //[$this->undefinedField('name', 'CatOrDog', [ 'Being', 'Pet', 'Canine', 'Cat', 'Dog' ], 3, 9)] - [$this->undefinedField('name', 'CatOrDog', [ ], 3, 9)] + [$this->undefinedField( + 'name', + 'CatOrDog', + ['Being', 'Pet', 'Canine', 'Dog', 'Cat'], + [], + 3, + 9 + )] ); } @@ -273,38 +280,78 @@ class FieldsOnCorrectTypeTest extends TestCase */ public function testWorksWithNoSuggestions() { - $this->assertEquals('Cannot query field "T" on type "f".', FieldsOnCorrectType::undefinedFieldMessage('T', 'f', [])); + $this->assertEquals('Cannot query field "f" on type "T".', FieldsOnCorrectType::undefinedFieldMessage('f', 'T', [], [])); } /** - * @it Works with no small numbers of suggestions + * @it Works with no small numbers of type suggestions */ - public function testWorksWithNoSmallNumbersOfSuggestions() + public function testWorksWithNoSmallNumbersOfTypeSuggestions() { - $expected = 'Cannot query field "T" on type "f". ' . - 'However, this field exists on "A", "B". ' . - 'Perhaps you meant to use an inline fragment?'; + $expected = 'Cannot query field "f" on type "T". ' . + 'Did you mean to use an inline fragment on "A" or "B"?'; - $this->assertEquals($expected, FieldsOnCorrectType::undefinedFieldMessage('T', 'f', [ 'A', 'B' ])); + $this->assertEquals($expected, FieldsOnCorrectType::undefinedFieldMessage('f', 'T', ['A', 'B'], [])); } /** - * @it Works with lots of suggestions + * @it Works with no small numbers of field suggestions */ - public function testWorksWithLotsOfSuggestions() + public function testWorksWithNoSmallNumbersOfFieldSuggestions() { - $expected = 'Cannot query field "T" on type "f". ' . - 'However, this field exists on "A", "B", "C", "D", "E", ' . - 'and 1 other types. ' . - 'Perhaps you meant to use an inline fragment?'; + $expected = 'Cannot query field "f" on type "T". ' . + 'Did you mean "z" or "y"?'; - $this->assertEquals($expected, FieldsOnCorrectType::undefinedFieldMessage('T', 'f', [ 'A', 'B', 'C', 'D', 'E', 'F' ])); + $this->assertEquals($expected, FieldsOnCorrectType::undefinedFieldMessage('f', 'T', [], ['z', 'y'])); } - private function undefinedField($field, $type, $suggestions, $line, $column) + /** + * @it Only shows one set of suggestions at a time, preferring types + */ + public function testOnlyShowsOneSetOfSuggestionsAtATimePreferringTypes() + { + $expected = 'Cannot query field "f" on type "T". ' . + 'Did you mean to use an inline fragment on "A" or "B"?'; + + $this->assertEquals($expected, FieldsOnCorrectType::undefinedFieldMessage('f', 'T', ['A', 'B'], ['z', 'y'])); + } + + /** + * @it Limits lots of type suggestions + */ + public function testLimitsLotsOfTypeSuggestions() + { + $expected = 'Cannot query field "f" on type "T". ' . + 'Did you mean to use an inline fragment on "A", "B", "C", "D", or "E"?'; + + $this->assertEquals($expected, FieldsOnCorrectType::undefinedFieldMessage( + 'f', + 'T', + ['A', 'B', 'C', 'D', 'E', 'F'], + [] + )); + } + + /** + * @it Limits lots of field suggestions + */ + public function testLimitsLotsOfFieldSuggestions() + { + $expected = 'Cannot query field "f" on type "T". ' . + 'Did you mean "z", "y", "x", "w", or "v"?'; + + $this->assertEquals($expected, FieldsOnCorrectType::undefinedFieldMessage( + 'f', + 'T', + [], + ['z', 'y', 'x', 'w', 'v', 'u'] + )); + } + + private function undefinedField($field, $type, $suggestedTypes, $suggestedFields, $line, $column) { return FormattedError::create( - FieldsOnCorrectType::undefinedFieldMessage($field, $type, $suggestions), + FieldsOnCorrectType::undefinedFieldMessage($field, $type, $suggestedTypes, $suggestedFields), [new SourceLocation($line, $column)] ); } diff --git a/tests/Validator/KnownArgumentNamesTest.php b/tests/Validator/KnownArgumentNamesTest.php index 80ced66..84b7e38 100644 --- a/tests/Validator/KnownArgumentNamesTest.php +++ b/tests/Validator/KnownArgumentNamesTest.php @@ -112,7 +112,21 @@ class KnownArgumentNamesTest extends TestCase dog @skip(unless: true) } ', [ - $this->unknownDirectiveArg('unless', 'skip', 3, 19), + $this->unknownDirectiveArg('unless', 'skip', [], 3, 19), + ]); + } + + /** + * @it misspelled directive args are reported + */ + public function testMisspelledDirectiveArgsAreReported() + { + $this->expectFailsRule(new KnownArgumentNames, ' + { + dog @skip(iff: true) + } + ', [ + $this->unknownDirectiveArg('iff', 'skip', ['if'], 3, 19), ]); } @@ -126,7 +140,21 @@ class KnownArgumentNamesTest extends TestCase doesKnowCommand(unknown: true) } ', [ - $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', 3, 25), + $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', [],3, 25), + ]); + } + + /** + * @it misspelled arg name is reported + */ + public function testMisspelledArgNameIsReported() + { + $this->expectFailsRule(new KnownArgumentNames, ' + fragment invalidArgName on Dog { + doesKnowCommand(dogcommand: true) + } + ', [ + $this->unknownArg('dogcommand', 'doesKnowCommand', 'Dog', ['dogCommand'],3, 25), ]); } @@ -140,8 +168,8 @@ class KnownArgumentNamesTest extends TestCase doesKnowCommand(whoknows: 1, dogCommand: SIT, unknown: true) } ', [ - $this->unknownArg('whoknows', 'doesKnowCommand', 'Dog', 3, 25), - $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', 3, 55), + $this->unknownArg('whoknows', 'doesKnowCommand', 'Dog', [], 3, 25), + $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', [], 3, 55), ]); } @@ -164,23 +192,23 @@ class KnownArgumentNamesTest extends TestCase } } ', [ - $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', 4, 27), - $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', 9, 31), + $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', [], 4, 27), + $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', [], 9, 31), ]); } - private function unknownArg($argName, $fieldName, $typeName, $line, $column) + private function unknownArg($argName, $fieldName, $typeName, $suggestedArgs, $line, $column) { return FormattedError::create( - KnownArgumentNames::unknownArgMessage($argName, $fieldName, $typeName), + KnownArgumentNames::unknownArgMessage($argName, $fieldName, $typeName, $suggestedArgs), [new SourceLocation($line, $column)] ); } - private function unknownDirectiveArg($argName, $directiveName, $line, $column) + private function unknownDirectiveArg($argName, $directiveName, $suggestedArgs, $line, $column) { return FormattedError::create( - KnownArgumentNames::unknownDirectiveArgMessage($argName, $directiveName), + KnownArgumentNames::unknownDirectiveArgMessage($argName, $directiveName, $suggestedArgs), [new SourceLocation($line, $column)] ); } diff --git a/tests/Validator/KnownDirectivesTest.php b/tests/Validator/KnownDirectivesTest.php index 50fe215..9374b73 100644 --- a/tests/Validator/KnownDirectivesTest.php +++ b/tests/Validator/KnownDirectivesTest.php @@ -89,12 +89,16 @@ class KnownDirectivesTest extends TestCase public function testWithWellPlacedDirectives() { $this->expectPassesRule(new KnownDirectives, ' - query Foo { + query Foo @onQuery { name @include(if: true) ...Frag @include(if: true) skippedField @skip(if: true) ...SkippedFrag @skip(if: true) } + + mutation Bar @onMutation { + someField + } '); } @@ -105,16 +109,115 @@ class KnownDirectivesTest extends TestCase { $this->expectFailsRule(new KnownDirectives, ' query Foo @include(if: true) { - name @operationOnly - ...Frag @operationOnly + name @onQuery + ...Frag @onQuery + } + + mutation Bar @onQuery { + someField } ', [ $this->misplacedDirective('include', 'QUERY', 2, 17), - $this->misplacedDirective('operationOnly', 'FIELD', 3, 14), - $this->misplacedDirective('operationOnly', 'FRAGMENT_SPREAD', 4, 17), + $this->misplacedDirective('onQuery', 'FIELD', 3, 14), + $this->misplacedDirective('onQuery', 'FRAGMENT_SPREAD', 4, 17), + $this->misplacedDirective('onQuery', 'MUTATION', 7, 20), ]); } + // within schema language + + /** + * @it with well placed directives + */ + public function testWSLWithWellPlacedDirectives() + { + $this->expectPassesRule(new KnownDirectives, ' + type MyObj implements MyInterface @onObject { + myField(myArg: Int @onArgumentDefinition): String @onFieldDefinition + } + + extend type MyObj @onObject + + scalar MyScalar @onScalar + + extend scalar MyScalar @onScalar + + interface MyInterface @onInterface { + myField(myArg: Int @onArgumentDefinition): String @onFieldDefinition + } + + extend interface MyInterface @onInterface + + union MyUnion @onUnion = MyObj | Other + + extend union MyUnion @onUnion + + enum MyEnum @onEnum { + MY_VALUE @onEnumValue + } + + extend enum MyEnum @onEnum + + input MyInput @onInputObject { + myField: Int @onInputFieldDefinition + } + + extend input MyInput @onInputObject + + schema @onSchema { + query: MyQuery + } + '); + } + + /** + * @it with misplaced directives + */ + public function testWSLWithMisplacedDirectives() + { + $this->expectFailsRule(new KnownDirectives, ' + type MyObj implements MyInterface @onInterface { + myField(myArg: Int @onInputFieldDefinition): String @onInputFieldDefinition + } + + scalar MyScalar @onEnum + + interface MyInterface @onObject { + myField(myArg: Int @onInputFieldDefinition): String @onInputFieldDefinition + } + + union MyUnion @onEnumValue = MyObj | Other + + enum MyEnum @onScalar { + MY_VALUE @onUnion + } + + input MyInput @onEnum { + myField: Int @onArgumentDefinition + } + + schema @onObject { + query: MyQuery + } + ', + [ + $this->misplacedDirective('onInterface', 'OBJECT', 2, 43), + $this->misplacedDirective('onInputFieldDefinition', 'ARGUMENT_DEFINITION', 3, 30), + $this->misplacedDirective('onInputFieldDefinition', 'FIELD_DEFINITION', 3, 63), + $this->misplacedDirective('onEnum', 'SCALAR', 6, 25), + $this->misplacedDirective('onObject', 'INTERFACE', 8, 31), + $this->misplacedDirective('onInputFieldDefinition', 'ARGUMENT_DEFINITION', 9, 30), + $this->misplacedDirective('onInputFieldDefinition', 'FIELD_DEFINITION', 9, 63), + $this->misplacedDirective('onEnumValue', 'UNION', 12, 23), + $this->misplacedDirective('onScalar', 'ENUM', 14, 21), + $this->misplacedDirective('onUnion', 'ENUM_VALUE', 15, 20), + $this->misplacedDirective('onEnum', 'INPUT_OBJECT', 18, 23), + $this->misplacedDirective('onArgumentDefinition', 'INPUT_FIELD_DEFINITION', 19, 24), + $this->misplacedDirective('onObject', 'SCHEMA', 22, 16), + ] + ); + } + private function unknownDirective($directiveName, $line, $column) { return FormattedError::create( diff --git a/tests/Validator/KnownTypeNamesTest.php b/tests/Validator/KnownTypeNamesTest.php index 8bdab66..f8aba61 100644 --- a/tests/Validator/KnownTypeNamesTest.php +++ b/tests/Validator/KnownTypeNamesTest.php @@ -42,9 +42,9 @@ class KnownTypeNamesTest extends TestCase name } ', [ - $this->unknownType('JumbledUpLetters', 2, 23), - $this->unknownType('Badger', 5, 25), - $this->unknownType('Peettt', 8, 29) + $this->unknownType('JumbledUpLetters', [], 2, 23), + $this->unknownType('Badger', [], 5, 25), + $this->unknownType('Peettt', ['Pet'], 8, 29) ]); } @@ -70,14 +70,14 @@ class KnownTypeNamesTest extends TestCase } } ', [ - $this->unknownType('NotInTheSchema', 12, 23), + $this->unknownType('NotInTheSchema', [], 12, 23), ]); } - private function unknownType($typeName, $line, $column) + private function unknownType($typeName, $suggestedTypes, $line, $column) { return FormattedError::create( - KnownTypeNames::unknownTypeMessage($typeName), + KnownTypeNames::unknownTypeMessage($typeName, $suggestedTypes), [new SourceLocation($line, $column)] ); } diff --git a/tests/Validator/OverlappingFieldsCanBeMergedTest.php b/tests/Validator/OverlappingFieldsCanBeMergedTest.php index 48d3053..399af6a 100644 --- a/tests/Validator/OverlappingFieldsCanBeMergedTest.php +++ b/tests/Validator/OverlappingFieldsCanBeMergedTest.php @@ -2,13 +2,11 @@ namespace GraphQL\Tests\Validator; use GraphQL\Error\FormattedError; -use GraphQL\Language\Source; use GraphQL\Language\SourceLocation; -use GraphQL\Schema; +use GraphQL\Type\Schema; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; -use GraphQL\Type\Definition\UnionType; use GraphQL\Validator\Rules\OverlappingFieldsCanBeMerged; class OverlappingFieldsCanBeMergedTest extends TestCase @@ -296,12 +294,12 @@ class OverlappingFieldsCanBeMergedTest extends TestCase [new SourceLocation(18, 9), new SourceLocation(21, 9)] ), FormattedError::create( - OverlappingFieldsCanBeMerged::fieldsConflictMessage('x', 'a and c are different fields'), - [new SourceLocation(18, 9), new SourceLocation(14, 11)] + OverlappingFieldsCanBeMerged::fieldsConflictMessage('x', 'c and a are different fields'), + [new SourceLocation(14, 11), new SourceLocation(18, 9)] ), FormattedError::create( - OverlappingFieldsCanBeMerged::fieldsConflictMessage('x', 'b and c are different fields'), - [new SourceLocation(21, 9), new SourceLocation(14, 11)] + OverlappingFieldsCanBeMerged::fieldsConflictMessage('x', 'c and b are different fields'), + [new SourceLocation(14, 11), new SourceLocation(21, 9)] ) ]); } @@ -434,6 +432,113 @@ class OverlappingFieldsCanBeMergedTest extends TestCase ]); } + /** + * @it reports deep conflict to nearest common ancestor in fragments + */ + public function testReportsDeepConflictToNearestCommonAncestorInFragments() + { + $this->expectFailsRule(new OverlappingFieldsCanBeMerged, ' + { + field { + ...F + } + field { + ...F + } + } + fragment F on T { + deepField { + deeperField { + x: a + } + deeperField { + x: b + } + } + deepField { + deeperField { + y + } + } + } + ', [ + FormattedError::create( + OverlappingFieldsCanBeMerged::fieldsConflictMessage('deeperField', [['x', 'a and b are different fields']]), + [ + new SourceLocation(12,11), + new SourceLocation(13,13), + new SourceLocation(15,11), + new SourceLocation(16,13), + ] + ) + ]); + } + + /** + * @it reports deep conflict in nested fragments + */ + public function testReportsDeepConflictInNestedFragments() + { + $this->expectFailsRule(new OverlappingFieldsCanBeMerged, ' + { + field { + ...F + } + field { + ...I + } + } + fragment F on T { + x: a + ...G + } + fragment G on T { + y: c + } + fragment I on T { + y: d + ...J + } + fragment J on T { + x: b + } + ', [ + FormattedError::create( + OverlappingFieldsCanBeMerged::fieldsConflictMessage('field', [ + ['x', 'a and b are different fields'], + ['y', 'c and d are different fields'], + ]), + [ + new SourceLocation(3,9), + new SourceLocation(11,9), + new SourceLocation(15,9), + new SourceLocation(6,9), + new SourceLocation(22,9), + new SourceLocation(18,9), + ] + ) + ]); + } + + /** + * @it ignores unknown fragments + */ + public function testIgnoresUnknownFragments() + { + $this->expectPassesRule(new OverlappingFieldsCanBeMerged, ' + { + field { + ...Unknown + ...Known + } + } + fragment Known on T { + field + ...OtherUnknown + } + '); + } + // Describe: return types must be unambiguous /** @@ -445,7 +550,7 @@ class OverlappingFieldsCanBeMergedTest extends TestCase // type IntBox and the interface type NonNullStringBox1. While that // condition does not exist in the current schema, the schema could // expand in the future to allow this. Thus it is invalid. - $this->expectFailsRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectFailsRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ...on IntBox { @@ -476,7 +581,7 @@ class OverlappingFieldsCanBeMergedTest extends TestCase // In this case `deepBox` returns `SomeBox` in the first usage, and // `StringBox` in the second usage. These return types are not the same! // however this is valid because the return *shapes* are compatible. - $this->expectPassesRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectPassesRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ... on SomeBox { @@ -499,7 +604,7 @@ class OverlappingFieldsCanBeMergedTest extends TestCase */ public function testDisallowsDifferingReturnTypesDespiteNoOverlap() { - $this->expectFailsRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectFailsRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ... on IntBox { @@ -522,12 +627,76 @@ class OverlappingFieldsCanBeMergedTest extends TestCase ]); } + /** + * @it reports correctly when a non-exclusive follows an exclusive + */ + public function testReportsCorrectlyWhenANonExclusiveFollowsAnExclusive() + { + $this->expectFailsRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' + { + someBox { + ... on IntBox { + deepBox { + ...X + } + } + } + someBox { + ... on StringBox { + deepBox { + ...Y + } + } + } + memoed: someBox { + ... on IntBox { + deepBox { + ...X + } + } + } + memoed: someBox { + ... on StringBox { + deepBox { + ...Y + } + } + } + other: someBox { + ...X + } + other: someBox { + ...Y + } + } + fragment X on SomeBox { + scalar + } + fragment Y on SomeBox { + scalar: unrelatedField + } + ', [ + FormattedError::create( + OverlappingFieldsCanBeMerged::fieldsConflictMessage( + 'other', + [['scalar', 'scalar and unrelatedField are different fields']] + ), + [ + new SourceLocation(31, 11), + new SourceLocation(39, 11), + new SourceLocation(34, 11), + new SourceLocation(42, 11), + ] + ) + ]); + } + /** * @it disallows differing return type nullability despite no overlap */ public function testDisallowsDifferingReturnTypeNullabilityDespiteNoOverlap() { - $this->expectFailsRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectFailsRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ... on NonNullStringBox1 { @@ -555,7 +724,7 @@ class OverlappingFieldsCanBeMergedTest extends TestCase */ public function testDisallowsDifferingReturnTypeListDespiteNoOverlap() { - $this->expectFailsRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectFailsRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ... on IntBox { @@ -582,7 +751,7 @@ class OverlappingFieldsCanBeMergedTest extends TestCase ]); - $this->expectFailsRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectFailsRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ... on IntBox { @@ -611,7 +780,7 @@ class OverlappingFieldsCanBeMergedTest extends TestCase public function testDisallowsDifferingSubfields() { - $this->expectFailsRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectFailsRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ... on IntBox { @@ -645,7 +814,7 @@ class OverlappingFieldsCanBeMergedTest extends TestCase */ public function testDisallowsDifferingDeepReturnTypesDespiteNoOverlap() { - $this->expectFailsRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectFailsRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ... on IntBox { @@ -681,7 +850,7 @@ class OverlappingFieldsCanBeMergedTest extends TestCase */ public function testAllowsNonConflictingOverlapingTypes() { - $this->expectPassesRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectPassesRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ... on IntBox { @@ -700,7 +869,7 @@ class OverlappingFieldsCanBeMergedTest extends TestCase */ public function testSameWrappedScalarReturnTypes() { - $this->expectPassesRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectPassesRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ...on NonNullStringBox1 { @@ -719,7 +888,7 @@ class OverlappingFieldsCanBeMergedTest extends TestCase */ public function testAllowsInlineTypelessFragments() { - $this->expectPassesRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectPassesRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { a ... { @@ -734,7 +903,7 @@ class OverlappingFieldsCanBeMergedTest extends TestCase */ public function testComparesDeepTypesIncludingList() { - $this->expectFailsRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectFailsRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { connection { ...edgeID @@ -755,14 +924,14 @@ class OverlappingFieldsCanBeMergedTest extends TestCase } ', [ FormattedError::create( - OverlappingFieldsCanBeMerged::fieldsConflictMessage('edges', [['node', [['id', 'id and name are different fields']]]]), + OverlappingFieldsCanBeMerged::fieldsConflictMessage('edges', [['node', [['id', 'name and id are different fields']]]]), [ - new SourceLocation(14, 11), - new SourceLocation(15, 13), - new SourceLocation(16, 15), new SourceLocation(5, 13), new SourceLocation(6, 15), new SourceLocation(7, 17), + new SourceLocation(14, 11), + new SourceLocation(15, 13), + new SourceLocation(16, 15), ] ) ]); @@ -773,7 +942,7 @@ class OverlappingFieldsCanBeMergedTest extends TestCase */ public function testIgnoresUnknownTypes() { - $this->expectPassesRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectPassesRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ...on UnknownType { @@ -787,7 +956,78 @@ class OverlappingFieldsCanBeMergedTest extends TestCase '); } - private function getTestSchema() + /** + * @it error message contains hint for alias conflict + */ + public function testErrorMessageContainsHintForAliasConflict() + { + // The error template should end with a hint for the user to try using + // different aliases. + $error = OverlappingFieldsCanBeMerged::fieldsConflictMessage('x', 'a and b are different fields'); + $hint = 'Use different aliases on the fields to fetch both if this was intentional.'; + + $this->assertStringEndsWith($hint, $error); + } + + /** + * @it does not infinite loop on recursive fragment + */ + public function testDoesNotInfiniteLoopOnRecursiveFragment() + { + $this->expectPassesRule(new OverlappingFieldsCanBeMerged, ' + fragment fragA on Human { name, relatives { name, ...fragA } } + '); + } + + /** + * @it does not infinite loop on immediately recursive fragment + */ + public function testDoesNotInfiniteLoopOnImmeditelyRecursiveFragment() + { + $this->expectPassesRule(new OverlappingFieldsCanBeMerged, ' + fragment fragA on Human { name, ...fragA } + '); + } + + /** + * @it does not infinite loop on transitively recursive fragment + */ + public function testDoesNotInfiniteLoopOnTransitivelyRecursiveFragment() + { + $this->expectPassesRule(new OverlappingFieldsCanBeMerged, ' + fragment fragA on Human { name, ...fragB } + fragment fragB on Human { name, ...fragC } + fragment fragC on Human { name, ...fragA } + '); + } + + /** + * @it find invalid case even with immediately recursive fragment + */ + public function testFindInvalidCaseEvenWithImmediatelyRecursiveFragment() + { + $this->expectFailsRule(new OverlappingFieldsCanBeMerged, ' + fragment sameAliasesWithDifferentFieldTargets on Dob { + ...sameAliasesWithDifferentFieldTargets + fido: name + fido: nickname + } + ', + [ + FormattedError::create( + OverlappingFieldsCanBeMerged::fieldsConflictMessage( + 'fido', + 'name and nickname are different fields' + ), + [ + new SourceLocation(4, 9), + new SourceLocation(5, 9), + ] + ) + ]); + } + + private function getSchema() { $StringBox = null; $IntBox = null; @@ -795,7 +1035,6 @@ class OverlappingFieldsCanBeMergedTest extends TestCase $SomeBox = new InterfaceType([ 'name' => 'SomeBox', - 'resolveType' => function() use (&$StringBox) {return $StringBox;}, 'fields' => function() use (&$SomeBox) { return [ 'deepBox' => ['type' => $SomeBox], @@ -837,7 +1076,6 @@ class OverlappingFieldsCanBeMergedTest extends TestCase $NonNullStringBox1 = new InterfaceType([ 'name' => 'NonNullStringBox1', - 'resolveType' => function() use (&$StringBox) {return $StringBox;}, 'fields' => [ 'scalar' => [ 'type' => Type::nonNull(Type::string()) ] ] @@ -855,7 +1093,6 @@ class OverlappingFieldsCanBeMergedTest extends TestCase $NonNullStringBox2 = new InterfaceType([ 'name' => 'NonNullStringBox2', - 'resolveType' => function() use (&$StringBox) {return $StringBox;}, 'fields' => [ 'scalar' => ['type' => Type::nonNull(Type::string())] ] diff --git a/tests/Validator/PossibleFragmentSpreadsTest.php b/tests/Validator/PossibleFragmentSpreadsTest.php index d186157..a3139de 100644 --- a/tests/Validator/PossibleFragmentSpreadsTest.php +++ b/tests/Validator/PossibleFragmentSpreadsTest.php @@ -128,6 +128,17 @@ class PossibleFragmentSpreadsTest extends TestCase '); } + /** + * @it ignores incorrect type (caught by FragmentsOnCompositeTypes) + */ + public function testIgnoresIncorrectTypeCaughtByFragmentsOnCompositeTypes() + { + $this->expectPassesRule(new PossibleFragmentSpreads, ' + fragment petFragment on Pet { ...badInADifferentWay } + fragment badInADifferentWay on String { name } + '); + } + /** * @it different object into object */ diff --git a/tests/Validator/QuerySecuritySchema.php b/tests/Validator/QuerySecuritySchema.php index 771af77..024990a 100644 --- a/tests/Validator/QuerySecuritySchema.php +++ b/tests/Validator/QuerySecuritySchema.php @@ -1,7 +1,7 @@ 'Dog', - 'isTypeOf' => function() {return true;}, 'fields' => [ 'name' => [ 'type' => Type::string(), @@ -94,7 +91,6 @@ abstract class TestCase extends \PHPUnit_Framework_TestCase $Cat = new ObjectType([ 'name' => 'Cat', - 'isTypeOf' => function() {return true;}, 'fields' => function() use (&$FurColor) { return [ 'name' => [ @@ -113,10 +109,6 @@ abstract class TestCase extends \PHPUnit_Framework_TestCase $CatOrDog = new UnionType([ 'name' => 'CatOrDog', 'types' => [$Dog, $Cat], - 'resolveType' => function($value) { - // not used for validation - return null; - } ]); $Intelligent = new InterfaceType([ @@ -129,7 +121,6 @@ abstract class TestCase extends \PHPUnit_Framework_TestCase $Human = null; $Human = new ObjectType([ 'name' => 'Human', - 'isTypeOf' => function() {return true;}, 'interfaces' => [$Being, $Intelligent], 'fields' => function() use (&$Human, $Pet) { return [ @@ -146,7 +137,6 @@ abstract class TestCase extends \PHPUnit_Framework_TestCase $Alien = new ObjectType([ 'name' => 'Alien', - 'isTypeOf' => function() {return true;}, 'interfaces' => [$Being, $Intelligent], 'fields' => [ 'iq' => ['type' => Type::int()], @@ -161,19 +151,11 @@ abstract class TestCase extends \PHPUnit_Framework_TestCase $DogOrHuman = new UnionType([ 'name' => 'DogOrHuman', 'types' => [$Dog, $Human], - 'resolveType' => function() { - // not used for validation - return null; - } ]); $HumanOrAlien = new UnionType([ 'name' => 'HumanOrAlien', 'types' => [$Human, $Alien], - 'resolveType' => function() { - // not used for validation - return null; - } ]); $FurColor = new EnumType([ @@ -278,6 +260,26 @@ abstract class TestCase extends \PHPUnit_Framework_TestCase ] ]); + $invalidScalar = new CustomScalarType([ + 'name' => 'Invalid', + 'serialize' => function ($value) { + return $value; + }, + 'parseLiteral' => function ($node) { + throw new \Exception('Invalid scalar is always invalid: ' . $node->value); + }, + 'parseValue' => function ($node) { + throw new \Exception('Invalid scalar is always invalid: ' . $node); + }, + ]); + + $anyScalar = new CustomScalarType([ + 'name' => 'Any', + 'serialize' => function ($value) { return $value; }, + 'parseLiteral' => function ($node) { return $node; }, // Allows any value + 'parseValue' => function ($value) { return $value; }, // Allows any value + ]); + $queryRoot = new ObjectType([ 'name' => 'QueryRoot', 'fields' => [ @@ -292,20 +294,100 @@ abstract class TestCase extends \PHPUnit_Framework_TestCase 'catOrDog' => ['type' => $CatOrDog], 'dogOrHuman' => ['type' => $DogOrHuman], 'humanOrAlien' => ['type' => $HumanOrAlien], - 'complicatedArgs' => ['type' => $ComplicatedArgs] + 'complicatedArgs' => ['type' => $ComplicatedArgs], + 'invalidArg' => [ + 'args' => [ + 'arg' => ['type' => $invalidScalar] + ], + 'type' => Type::string(), + ], + 'anyArg' => [ + 'args' => ['arg' => ['type' => $anyScalar]], + 'type' => Type::string(), + ], ] ]); - $defaultSchema = new Schema([ + $testSchema = new Schema([ 'query' => $queryRoot, - 'directives' => array_merge(GraphQL::getInternalDirectives(), [ + 'directives' => [ + Directive::includeDirective(), + Directive::skipDirective(), new Directive([ - 'name' => 'operationOnly', - 'locations' => [ 'QUERY' ], - ]) - ]) + 'name' => 'onQuery', + 'locations' => ['QUERY'], + ]), + new Directive([ + 'name' => 'onMutation', + 'locations' => ['MUTATION'], + ]), + new Directive([ + 'name' => 'onSubscription', + 'locations' => ['SUBSCRIPTION'], + ]), + new Directive([ + 'name' => 'onField', + 'locations' => ['FIELD'], + ]), + new Directive([ + 'name' => 'onFragmentDefinition', + 'locations' => ['FRAGMENT_DEFINITION'], + ]), + new Directive([ + 'name' => 'onFragmentSpread', + 'locations' => ['FRAGMENT_SPREAD'], + ]), + new Directive([ + 'name' => 'onInlineFragment', + 'locations' => ['INLINE_FRAGMENT'], + ]), + new Directive([ + 'name' => 'onSchema', + 'locations' => ['SCHEMA'], + ]), + new Directive([ + 'name' => 'onScalar', + 'locations' => ['SCALAR'], + ]), + new Directive([ + 'name' => 'onObject', + 'locations' => ['OBJECT'], + ]), + new Directive([ + 'name' => 'onFieldDefinition', + 'locations' => ['FIELD_DEFINITION'], + ]), + new Directive([ + 'name' => 'onArgumentDefinition', + 'locations' => ['ARGUMENT_DEFINITION'], + ]), + new Directive([ + 'name' => 'onInterface', + 'locations' => ['INTERFACE'], + ]), + new Directive([ + 'name' => 'onUnion', + 'locations' => ['UNION'], + ]), + new Directive([ + 'name' => 'onEnum', + 'locations' => ['ENUM'], + ]), + new Directive([ + 'name' => 'onEnumValue', + 'locations' => ['ENUM_VALUE'], + ]), + new Directive([ + 'name' => 'onInputObject', + 'locations' => ['INPUT_OBJECT'], + ]), + new Directive([ + 'name' => 'onInputFieldDefinition', + 'locations' => ['INPUT_FIELD_DEFINITION'], + ]), + ], ]); - return $defaultSchema; + return $testSchema; } function expectValid($schema, $rules, $queryString) @@ -329,12 +411,12 @@ abstract class TestCase extends \PHPUnit_Framework_TestCase function expectPassesRule($rule, $queryString) { - $this->expectValid($this->getDefaultSchema(), [$rule], $queryString); + $this->expectValid($this->getTestSchema(), [$rule], $queryString); } function expectFailsRule($rule, $queryString, $errors) { - return $this->expectInvalid($this->getDefaultSchema(), [$rule], $queryString, $errors); + return $this->expectInvalid($this->getTestSchema(), [$rule], $queryString, $errors); } function expectPassesRuleWithSchema($schema, $rule, $queryString) @@ -349,11 +431,11 @@ abstract class TestCase extends \PHPUnit_Framework_TestCase function expectPassesCompleteValidation($queryString) { - $this->expectValid($this->getDefaultSchema(), DocumentValidator::allRules(), $queryString); + $this->expectValid($this->getTestSchema(), DocumentValidator::allRules(), $queryString); } function expectFailsCompleteValidation($queryString, $errors) { - $this->expectInvalid($this->getDefaultSchema(), DocumentValidator::allRules(), $queryString, $errors); + $this->expectInvalid($this->getTestSchema(), DocumentValidator::allRules(), $queryString, $errors); } } diff --git a/tests/Validator/ValidationTest.php b/tests/Validator/ValidationTest.php index d5fd855..51c7dbf 100644 --- a/tests/Validator/ValidationTest.php +++ b/tests/Validator/ValidationTest.php @@ -1,9 +1,6 @@ assertSame($rule, $instance); + /** + * @it detects bad scalar parse + */ + public function testDetectsBadScalarParse() + { + $doc = ' + query { + invalidArg(arg: "bad value") + } + '; + + $expectedError = [ + 'message' => "Expected type Invalid, found \"bad value\"; Invalid scalar is always invalid: bad value", + 'locations' => [ ['line' => 3, 'column' => 25] ] + ]; + + $this->expectInvalid( + $this->getTestSchema(), + null, + $doc, + [$expectedError] + ); } -*/ + public function testPassesValidationWithEmptyRules() { $query = '{invalid}'; $expectedError = [ - 'message' => 'Cannot query field "invalid" on type "QueryRoot".', + 'message' => 'Cannot query field "invalid" on type "QueryRoot". Did you mean "invalidArg"?', 'locations' => [ ['line' => 1, 'column' => 2] ] ]; $this->expectFailsCompleteValidation($query, [$expectedError]); - $this->expectValid($this->getDefaultSchema(), [], $query); + $this->expectValid($this->getTestSchema(), [], $query); } } diff --git a/tests/Validator/ArgumentsOfCorrectTypeTest.php b/tests/Validator/ValuesOfCorrectTypeTest.php similarity index 58% rename from tests/Validator/ArgumentsOfCorrectTypeTest.php rename to tests/Validator/ValuesOfCorrectTypeTest.php index 5aefb8c..7240f99 100644 --- a/tests/Validator/ArgumentsOfCorrectTypeTest.php +++ b/tests/Validator/ValuesOfCorrectTypeTest.php @@ -3,21 +3,45 @@ namespace GraphQL\Tests\Validator; use GraphQL\Error\FormattedError; use GraphQL\Language\SourceLocation; -use GraphQL\Validator\Rules\ArgumentsOfCorrectType; +use GraphQL\Validator\Rules\ValuesOfCorrectType; -class ArgumentsOfCorrectTypeTest extends TestCase +class ValuesOfCorrectTypeTest extends TestCase { - function badValue($argName, $typeName, $value, $line, $column, $errors = null) + private function badValue($typeName, $value, $line, $column, $message = null) { - $realErrors = !$errors ? ["Expected type \"$typeName\", found $value."] : $errors; - return FormattedError::create( - ArgumentsOfCorrectType::badValueMessage($argName, $typeName, $value, $realErrors), + ValuesOfCorrectType::badValueMessage( + $typeName, + $value, + $message + ), [new SourceLocation($line, $column)] ); } - // Validate: Argument values of correct type + private function requiredField($typeName, $fieldName, $fieldTypeName, $line, $column) { + return FormattedError::create( + ValuesOfCorrectType::requiredFieldMessage( + $typeName, + $fieldName, + $fieldTypeName + ), + [new SourceLocation($line, $column)] + ); + } + + private function unknownField($typeName, $fieldName, $line, $column, $message = null) { + return FormattedError::create( + ValuesOfCorrectType::unknownFieldMessage( + $typeName, + $fieldName, + $message + ), + [new SourceLocation($line, $column)] + ); + } + + // Validate: Values of correct type // Valid values /** @@ -25,7 +49,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testGoodIntValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { intArgField(intArg: 2) @@ -39,7 +63,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testGoodNegativeIntValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { intArgField(intArg: -2) @@ -53,7 +77,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testGoodBooleanValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { booleanArgField(booleanArg: true) @@ -67,7 +91,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testGoodStringValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { stringArgField(stringArg: "foo") @@ -81,7 +105,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testGoodFloatValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { floatArgField(floatArg: 1.1) @@ -92,7 +116,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase public function testGoodNegativeFloatValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { floatArgField(floatArg: -1.1) @@ -106,7 +130,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testIntIntoFloat() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { floatArgField(floatArg: 1) @@ -120,7 +144,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testIntIntoID() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { idArgField(idArg: 1) @@ -134,7 +158,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testStringIntoID() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { idArgField(idArg: "someIdString") @@ -148,7 +172,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testGoodEnumValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { dog { doesKnowCommand(dogCommand: SIT) @@ -162,7 +186,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testEnumWithNullValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { enumArgField(enumArg: NO_FUR) @@ -176,7 +200,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testNullIntoNullableType() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { intArgField(intArg: null) @@ -184,7 +208,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase } '); - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { dog(a: null, b: null, c:{ requiredField: true, intField: null }) { name @@ -200,14 +224,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testIntIntoString() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { stringArgField(stringArg: 1) } } ', [ - $this->badValue('stringArg', 'String', '1', 4, 39) + $this->badValue('String', '1', 4, 39) ]); } @@ -216,14 +240,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testFloatIntoString() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { stringArgField(stringArg: 1.0) } } ', [ - $this->badValue('stringArg', 'String', '1.0', 4, 39) + $this->badValue('String', '1.0', 4, 39) ]); } @@ -232,14 +256,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testBooleanIntoString() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { stringArgField(stringArg: true) } } ', [ - $this->badValue('stringArg', 'String', 'true', 4, 39) + $this->badValue('String', 'true', 4, 39) ]); } @@ -248,14 +272,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testUnquotedStringIntoString() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { stringArgField(stringArg: BAR) } } ', [ - $this->badValue('stringArg', 'String', 'BAR', 4, 39) + $this->badValue('String', 'BAR', 4, 39) ]); } @@ -266,14 +290,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testStringIntoInt() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { intArgField(intArg: "3") } } ', [ - $this->badValue('intArg', 'Int', '"3"', 4, 33) + $this->badValue('Int', '"3"', 4, 33) ]); } @@ -282,14 +306,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testBigIntIntoInt() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { intArgField(intArg: 829384293849283498239482938) } } ', [ - $this->badValue('intArg', 'Int', '829384293849283498239482938', 4, 33) + $this->badValue('Int', '829384293849283498239482938', 4, 33) ]); } @@ -298,14 +322,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testUnquotedStringIntoInt() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { intArgField(intArg: FOO) } } ', [ - $this->badValue('intArg', 'Int', 'FOO', 4, 33) + $this->badValue('Int', 'FOO', 4, 33) ]); } @@ -314,14 +338,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testSimpleFloatIntoInt() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { intArgField(intArg: 3.0) } } ', [ - $this->badValue('intArg', 'Int', '3.0', 4, 33) + $this->badValue('Int', '3.0', 4, 33) ]); } @@ -330,14 +354,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testFloatIntoInt() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { intArgField(intArg: 3.333) } } ', [ - $this->badValue('intArg', 'Int', '3.333', 4, 33) + $this->badValue('Int', '3.333', 4, 33) ]); } @@ -348,14 +372,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testStringIntoFloat() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { floatArgField(floatArg: "3.333") } } ', [ - $this->badValue('floatArg', 'Float', '"3.333"', 4, 37) + $this->badValue('Float', '"3.333"', 4, 37) ]); } @@ -364,14 +388,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testBooleanIntoFloat() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { floatArgField(floatArg: true) } } ', [ - $this->badValue('floatArg', 'Float', 'true', 4, 37) + $this->badValue('Float', 'true', 4, 37) ]); } @@ -380,14 +404,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testUnquotedIntoFloat() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { floatArgField(floatArg: FOO) } } ', [ - $this->badValue('floatArg', 'Float', 'FOO', 4, 37) + $this->badValue('Float', 'FOO', 4, 37) ]); } @@ -398,14 +422,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testIntIntoBoolean() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { booleanArgField(booleanArg: 2) } } ', [ - $this->badValue('booleanArg', 'Boolean', '2', 4, 41) + $this->badValue('Boolean', '2', 4, 41) ]); } @@ -414,14 +438,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testFloatIntoBoolean() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { booleanArgField(booleanArg: 1.0) } } ', [ - $this->badValue('booleanArg', 'Boolean', '1.0', 4, 41) + $this->badValue('Boolean', '1.0', 4, 41) ]); } @@ -430,14 +454,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testStringIntoBoolean() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { booleanArgField(booleanArg: "true") } } ', [ - $this->badValue('booleanArg', 'Boolean', '"true"', 4, 41) + $this->badValue('Boolean', '"true"', 4, 41) ]); } @@ -446,14 +470,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testUnquotedIntoBoolean() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { booleanArgField(booleanArg: TRUE) } } ', [ - $this->badValue('booleanArg', 'Boolean', 'TRUE', 4, 41) + $this->badValue('Boolean', 'TRUE', 4, 41) ]); } @@ -464,14 +488,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testFloatIntoID() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { idArgField(idArg: 1.0) } } ', [ - $this->badValue('idArg', 'ID', '1.0', 4, 31) + $this->badValue('ID', '1.0', 4, 31) ]); } @@ -480,14 +504,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testBooleanIntoID() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { idArgField(idArg: true) } } ', [ - $this->badValue('idArg', 'ID', 'true', 4, 31) + $this->badValue('ID', 'true', 4, 31) ]); } @@ -496,14 +520,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testUnquotedIntoID() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { idArgField(idArg: SOMETHING) } } ', [ - $this->badValue('idArg', 'ID', 'SOMETHING', 4, 31) + $this->badValue('ID', 'SOMETHING', 4, 31) ]); } @@ -514,14 +538,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testIntIntoEnum() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { dog { doesKnowCommand(dogCommand: 2) } } ', [ - $this->badValue('dogCommand', 'DogCommand', '2', 4, 41) + $this->badValue('DogCommand', '2', 4, 41) ]); } @@ -530,14 +554,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testFloatIntoEnum() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { dog { doesKnowCommand(dogCommand: 1.0) } } ', [ - $this->badValue('dogCommand', 'DogCommand', '1.0', 4, 41) + $this->badValue('DogCommand', '1.0', 4, 41) ]); } @@ -546,14 +570,20 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testStringIntoEnum() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { dog { doesKnowCommand(dogCommand: "SIT") } } ', [ - $this->badValue('dogCommand', 'DogCommand', '"SIT"', 4, 41) + $this->badValue( + 'DogCommand', + '"SIT"', + 4, + 41, + 'Did you mean the enum value SIT?' + ) ]); } @@ -562,14 +592,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testBooleanIntoEnum() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { dog { doesKnowCommand(dogCommand: true) } } ', [ - $this->badValue('dogCommand', 'DogCommand', 'true', 4, 41) + $this->badValue('DogCommand', 'true', 4, 41) ]); } @@ -578,14 +608,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testUnknownEnumValueIntoEnum() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { dog { doesKnowCommand(dogCommand: JUGGLE) } } ', [ - $this->badValue('dogCommand', 'DogCommand', 'JUGGLE', 4, 41) + $this->badValue('DogCommand', 'JUGGLE', 4, 41) ]); } @@ -594,14 +624,20 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testDifferentCaseEnumValueIntoEnum() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { dog { doesKnowCommand(dogCommand: sit) } } ', [ - $this->badValue('dogCommand', 'DogCommand', 'sit', 4, 41) + $this->badValue( + 'DogCommand', + 'sit', + 4, + 41, + 'Did you mean the enum value SIT?' + ) ]); } @@ -612,10 +648,10 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testGoodListValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { - stringListArgField(stringListArg: ["one", "two"]) + stringListArgField(stringListArg: ["one", null, "two"]) } } '); @@ -626,7 +662,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testEmptyListValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { stringListArgField(stringListArg: []) @@ -640,7 +676,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testNullValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { stringListArgField(stringListArg: null) @@ -654,7 +690,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testSingleValueIntoList() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { stringListArgField(stringListArg: "one") @@ -670,16 +706,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testIncorrectItemtype() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { stringListArgField(stringListArg: ["one", 2]) } } ', [ - $this->badValue('stringListArg', '[String]', '["one", 2]', 4, 47, [ - 'In element #1: Expected type "String", found 2.' - ]), + $this->badValue('String', '2', 4, 55), ]); } @@ -688,14 +722,14 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testSingleValueOfIncorrectType() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { stringListArgField(stringListArg: 1) } } ', [ - $this->badValue('stringListArg', 'String', '1', 4, 47), + $this->badValue('[String]', '1', 4, 47), ]); } @@ -706,7 +740,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testArgOnOptionalArg() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { dog { isHousetrained(atOtherHomes: true) @@ -720,7 +754,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testNoArgOnOptionalArg() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { dog { isHousetrained @@ -734,7 +768,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testMultipleArgs() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleReqs(req1: 1, req2: 2) @@ -748,7 +782,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testMultipleArgsReverseOrder() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleReqs(req2: 2, req1: 1) @@ -762,7 +796,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testNoArgsOnMultipleOptional() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleOpts @@ -776,7 +810,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testOneArgOnMultipleOptional() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleOpts(opt1: 1) @@ -790,7 +824,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testSecondArgOnMultipleOptional() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleOpts(opt2: 1) @@ -804,7 +838,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testMultipleReqsOnMixedList() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleOptAndReq(req1: 3, req2: 4) @@ -818,7 +852,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testMultipleReqsAndOneOptOnMixedList() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleOptAndReq(req1: 3, req2: 4, opt1: 5) @@ -832,7 +866,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testAllReqsAndOptsOnMixedList() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleOptAndReq(req1: 3, req2: 4, opt1: 5, opt2: 6) @@ -848,31 +882,31 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testIncorrectValueType() { - $this->expectFailsRule(new ArgumentsOfCorrectType, ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleReqs(req2: "two", req1: "one") } } ', [ - $this->badValue('req2', 'Int', '"two"', 4, 32), - $this->badValue('req1', 'Int', '"one"', 4, 45), + $this->badValue('Int!', '"two"', 4, 32), + $this->badValue('Int!', '"one"', 4, 45), ]); } /** - * @it Incorrect value and missing argument + * @it Incorrect value and missing argument (ProvidedNonNullArguments) */ - public function testIncorrectValueAndMissingArgument() + public function testIncorrectValueAndMissingArgumentProvidedNonNullArguments() { - $this->expectFailsRule(new ArgumentsOfCorrectType, ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleReqs(req1: "one") } } ', [ - $this->badValue('req1', 'Int', '"one"', 4, 32), + $this->badValue('Int!', '"one"', 4, 32), ]); } @@ -881,28 +915,26 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testNullValue2() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleReqs(req1: null) } } ', [ - $this->badValue('req1', 'Int!', 'null', 4, 32, [ - 'Expected "Int!", found null.' - ]), + $this->badValue('Int!', 'null', 4, 32), ]); } - // Valid input object value + // DESCRIBE: Valid input object value /** * @it Optional arg, despite required field in type */ public function testOptionalArgDespiteRequiredFieldInType() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { complexArgField @@ -916,7 +948,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testPartialObjectOnlyRequired() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { complexArgField(complexArg: { requiredField: true }) @@ -930,7 +962,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testPartialObjectRequiredFieldCanBeFalsey() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { complexArgField(complexArg: { requiredField: false }) @@ -944,7 +976,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testPartialObjectIncludingRequired() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { complexArgField(complexArg: { requiredField: true, intField: 4 }) @@ -958,7 +990,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testFullObject() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { complexArgField(complexArg: { @@ -978,7 +1010,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testFullObjectWithFieldsInDifferentOrder() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { complexArgField(complexArg: { @@ -993,23 +1025,21 @@ class ArgumentsOfCorrectTypeTest extends TestCase '); } - // Invalid input object value + // DESCRIBE: Invalid input object value /** * @it Partial object, missing required */ public function testPartialObjectMissingRequired() { - $this->expectFailsRule(new ArgumentsOfCorrectType, ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { complexArgField(complexArg: { intField: 4 }) } } ', [ - $this->badValue('complexArg', 'ComplexInput', '{intField: 4}', 4, 41, [ - 'In field "requiredField": Expected "Boolean!", found null.' - ]), + $this->requiredField('ComplexInput', 'requiredField', 'Boolean!', 4, 41), ]); } @@ -1018,7 +1048,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testPartialObjectInvalidFieldType() { - $this->expectFailsRule(new ArgumentsOfCorrectType, ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { complexArgField(complexArg: { @@ -1028,23 +1058,19 @@ class ArgumentsOfCorrectTypeTest extends TestCase } } ', [ - $this->badValue( - 'complexArg', - 'ComplexInput', - '{stringListField: ["one", 2], requiredField: true}', - 4, - 41, - [ 'In field "stringListField": In element #1: Expected type "String", found 2.' ] - ), + $this->badValue('String', '2', 5, 40), ]); } /** * @it Partial object, unknown field arg + * + * The sorting of equal elements has changed so that the test fails on php < 7 + * @requires PHP 7.0 */ public function testPartialObjectUnknownFieldArg() { - $this->expectFailsRule(new ArgumentsOfCorrectType, ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { complexArgField(complexArg: { @@ -1054,25 +1080,66 @@ class ArgumentsOfCorrectTypeTest extends TestCase } } ', [ - $this->badValue( - 'complexArg', + $this->unknownField( 'ComplexInput', - '{requiredField: true, unknownField: "value"}', - 4, - 41, - [ 'In field "unknownField": Unknown field.' ] + 'unknownField', + 6, + 15, + 'Did you mean intField or booleanField?' ), ]); } - // Directive arguments + + + /** + * @it reports original error for custom scalar which throws + */ + public function testReportsOriginalErrorForCustomScalarWhichThrows() + { + $errors = $this->expectFailsRule(new ValuesOfCorrectType, ' + { + invalidArg(arg: 123) + } + ', [ + $this->badValue( + 'Invalid', + '123', + 3, + 27, + 'Invalid scalar is always invalid: 123' + ), + ]); + + $this->assertEquals( + 'Invalid scalar is always invalid: 123', + $errors[0]->getPrevious()->getMessage() + ); + } + + /** + * @it allows custom scalar to accept complex literals + */ + public function testAllowsCustomScalarToAcceptComplexLiterals() + { + $this->expectPassesRule(new ValuesOfCorrectType, ' + { + test1: anyArg(arg: 123) + test2: anyArg(arg: "abc") + test3: anyArg(arg: [123, "abc"]) + test4: anyArg(arg: {deep: [123, "abc"]}) + } + '); + } + + // DESCRIBE: Directive arguments /** * @it with directives of valid types */ public function testWithDirectivesOfValidTypes() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { dog @include(if: true) { name @@ -1081,7 +1148,7 @@ class ArgumentsOfCorrectTypeTest extends TestCase name } } - '); + '); } /** @@ -1089,15 +1156,134 @@ class ArgumentsOfCorrectTypeTest extends TestCase */ public function testWithDirectiveWithIncorrectTypes() { - $this->expectFailsRule(new ArgumentsOfCorrectType, ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { dog @include(if: "yes") { name @skip(if: ENUM) } } - ', [ - $this->badValue('if', 'Boolean', '"yes"', 3, 28), - $this->badValue('if', 'Boolean', 'ENUM', 4, 28), + ', [ + $this->badValue('Boolean!', '"yes"', 3, 28), + $this->badValue('Boolean!', 'ENUM', 4, 28), + ]); + } + + // DESCRIBE: Variable default values + + /** + * @it variables with valid default values + */ + public function testVariablesWithValidDefaultValues() + { + $this->expectPassesRule(new ValuesOfCorrectType, ' + query WithDefaultValues( + $a: Int = 1, + $b: String = "ok", + $c: ComplexInput = { requiredField: true, intField: 3 } + ) { + dog { name } + } + '); + } + + /** + * @it variables with valid default null values + */ + public function testVariablesWithValidDefaultNullValues() + { + $this->expectPassesRule(new ValuesOfCorrectType, ' + query WithDefaultValues( + $a: Int = null, + $b: String = null, + $c: ComplexInput = { requiredField: true, intField: null } + ) { + dog { name } + } + '); + } + + /** + * @it variables with invalid default null values + */ + public function testVariablesWithInvalidDefaultNullValues() + { + $this->expectFailsRule(new ValuesOfCorrectType, ' + query WithDefaultValues( + $a: Int! = null, + $b: String! = null, + $c: ComplexInput = { requiredField: null, intField: null } + ) { + dog { name } + } + ', [ + $this->badValue('Int!', 'null', 3, 22), + $this->badValue('String!', 'null', 4, 25), + $this->badValue('Boolean!', 'null', 5, 47), + ]); + } + + /** + * @it variables with invalid default values + */ + public function testVariablesWithInvalidDefaultValues() + { + $this->expectFailsRule(new ValuesOfCorrectType, ' + query InvalidDefaultValues( + $a: Int = "one", + $b: String = 4, + $c: ComplexInput = "notverycomplex" + ) { + dog { name } + } + ', [ + $this->badValue('Int', '"one"', 3, 21), + $this->badValue('String', '4', 4, 24), + $this->badValue('ComplexInput', '"notverycomplex"', 5, 30), + ]); + } + + /** + * @it variables with complex invalid default values + */ + public function testVariablesWithComplexInvalidDefaultValues() + { + $this->expectFailsRule(new ValuesOfCorrectType, ' + query WithDefaultValues( + $a: ComplexInput = { requiredField: 123, intField: "abc" } + ) { + dog { name } + } + ', [ + $this->badValue('Boolean!', '123', 3, 47), + $this->badValue('Int', '"abc"', 3, 62), + ]); + } + + /** + * @it complex variables missing required field + */ + public function testComplexVariablesMissingRequiredField() + { + $this->expectFailsRule(new ValuesOfCorrectType, ' + query MissingRequiredField($a: ComplexInput = {intField: 3}) { + dog { name } + } + ', [ + $this->requiredField('ComplexInput', 'requiredField', 'Boolean!', 2, 55), + ]); + } + + /** + * @it list variables with invalid item + */ + public function testListVariablesWithInvalidItem() + { + $this->expectFailsRule(new ValuesOfCorrectType, ' + query InvalidItem($a: [String] = ["one", 2]) { + dog { name } + } + ', [ + $this->badValue('String', '2', 2, 50), ]); } } diff --git a/tests/Validator/VariablesDefaultValueAllowedTest.php b/tests/Validator/VariablesDefaultValueAllowedTest.php new file mode 100644 index 0000000..da4ca9b --- /dev/null +++ b/tests/Validator/VariablesDefaultValueAllowedTest.php @@ -0,0 +1,109 @@ +expectPassesRule(new VariablesDefaultValueAllowed(), ' + query NullableValues($a: Int, $b: String, $c: ComplexInput) { + dog { name } + } + '); + } + + /** + * @it required variables without default values + */ + public function testRequiredVariablesWithoutDefaultValues() + { + $this->expectPassesRule(new VariablesDefaultValueAllowed(), ' + query RequiredValues($a: Int!, $b: String!) { + dog { name } + } + '); + } + + /** + * @it variables with valid default values + */ + public function testVariablesWithValidDefaultValues() + { + $this->expectPassesRule(new VariablesDefaultValueAllowed(), ' + query WithDefaultValues( + $a: Int = 1, + $b: String = "ok", + $c: ComplexInput = { requiredField: true, intField: 3 } + ) { + dog { name } + } + '); + } + + /** + * @it variables with valid default null values + */ + public function testVariablesWithValidDefaultNullValues() + { + $this->expectPassesRule(new VariablesDefaultValueAllowed(), ' + query WithDefaultValues( + $a: Int = null, + $b: String = null, + $c: ComplexInput = { requiredField: true, intField: null } + ) { + dog { name } + } + '); + } + + /** + * @it no required variables with default values + */ + public function testNoRequiredVariablesWithDefaultValues() + { + $this->expectFailsRule(new VariablesDefaultValueAllowed(), ' + query UnreachableDefaultValues($a: Int! = 3, $b: String! = "default") { + dog { name } + } + ', [ + $this->defaultForRequiredVar('a', 'Int!', 'Int', 2, 49), + $this->defaultForRequiredVar('b', 'String!', 'String', 2, 66), + ]); + } + + /** + * @it variables with invalid default null values + */ + public function testNullIntoNullableType() + { + $this->expectFailsRule(new VariablesDefaultValueAllowed(), ' + query WithDefaultValues($a: Int! = null, $b: String! = null) { + dog { name } + } + ', [ + $this->defaultForRequiredVar('a', 'Int!', 'Int', 2, 42), + $this->defaultForRequiredVar('b', 'String!', 'String', 2, 62), + ]); + } +} diff --git a/tools/gendocs.php b/tools/gendocs.php index 7a2d16c..d28b0c9 100644 --- a/tools/gendocs.php +++ b/tools/gendocs.php @@ -9,7 +9,7 @@ $entries = [ \GraphQL\GraphQL::class, \GraphQL\Type\Definition\Type::class, \GraphQL\Type\Definition\ResolveInfo::class, - \GraphQL\Type\Definition\DirectiveLocation::class => ['constants' => true], + \GraphQL\Language\DirectiveLocation::class => ['constants' => true], \GraphQL\Type\SchemaConfig::class, \GraphQL\Type\Schema::class, \GraphQL\Language\Parser::class, @@ -41,7 +41,7 @@ function renderClassMethod(ReflectionMethod $method) { if ($p->isDefaultValueAvailable()) { $val = $p->isDefaultValueConstant() ? $p->getDefaultValueConstantName() : $p->getDefaultValue(); - $def .= " = " . (is_array($val) ? '[]' : Utils::printSafe($val)); + $def .= " = " . Utils::printSafeJson($val); } return $def; @@ -63,7 +63,7 @@ TEMPLATE; } function renderConstant($value, $name) { - return "const $name = " . Utils::printSafe($value) . ";"; + return "const $name = " . Utils::printSafeJson($value) . ";"; } function renderProp(ReflectionProperty $prop) {