From 20c482ce2f1af2db4dd04dc0c31a47cdb503262f Mon Sep 17 00:00:00 2001 From: vladar Date: Wed, 15 Jul 2015 23:05:46 +0600 Subject: [PATCH] Version 0.1 --- .gitignore | 2 + composer.json | 32 + src/Error.php | 86 + src/Executor/ExecutionContext.php | 60 + src/Executor/Executor.php | 540 ++++++ src/Executor/Values.php | 275 +++ src/FormattedError.php | 18 + src/GraphQL.php | 35 + src/Language/AST/Argument.php | 17 + src/Language/AST/ArrayValue.php | 12 + src/Language/AST/BooleanValue.php | 13 + src/Language/AST/Definition.php | 10 + src/Language/AST/Directive.php | 17 + src/Language/AST/Document.php | 12 + src/Language/AST/EnumValue.php | 12 + src/Language/AST/Field.php | 32 + src/Language/AST/FloatValue.php | 13 + src/Language/AST/FragmentDefinition.php | 28 + src/Language/AST/FragmentSpread.php | 17 + src/Language/AST/InlineFragment.php | 22 + src/Language/AST/IntValue.php | 13 + src/Language/AST/ListType.php | 12 + src/Language/AST/Location.php | 29 + src/Language/AST/Name.php | 12 + src/Language/AST/Node.php | 121 ++ src/Language/AST/NonNullType.php | 12 + src/Language/AST/ObjectField.php | 18 + src/Language/AST/ObjectValue.php | 12 + src/Language/AST/OperationDefinition.php | 35 + src/Language/AST/Selection.php | 9 + src/Language/AST/SelectionSet.php | 12 + src/Language/AST/StringValue.php | 13 + src/Language/AST/Type.php | 13 + src/Language/AST/Value.php | 16 + src/Language/AST/Variable.php | 12 + src/Language/AST/VariableDefinition.php | 22 + src/Language/Exception.php | 59 + src/Language/Lexer.php | 303 ++++ src/Language/Parser.php | 675 +++++++ src/Language/Printer.php | 149 ++ src/Language/Source.php | 49 + src/Language/SourceLocation.php | 14 + src/Language/Token.php | 89 + src/Language/Visitor.php | 353 ++++ src/Schema.php | 121 ++ src/Type/Definition/AbstractType.php | 16 + src/Type/Definition/BooleanType.php | 22 + src/Type/Definition/CompositeType.php | 13 + src/Type/Definition/Config.php | 203 +++ src/Type/Definition/Directive.php | 87 + src/Type/Definition/EnumType.php | 107 ++ src/Type/Definition/EnumValueDefinition.php | 25 + src/Type/Definition/FieldArgument.php | 54 + src/Type/Definition/FieldDefinition.php | 111 ++ src/Type/Definition/FloatType.php | 23 + src/Type/Definition/IDType.php | 23 + src/Type/Definition/InputObjectField.php | 39 + src/Type/Definition/InputObjectType.php | 41 + src/Type/Definition/InputType.php | 14 + src/Type/Definition/IntType.php | 31 + src/Type/Definition/InterfaceType.php | 108 ++ src/Type/Definition/LeafType.php | 12 + src/Type/Definition/ListOfType.php | 43 + src/Type/Definition/NonNull.php | 53 + src/Type/Definition/ObjectType.php | 125 ++ src/Type/Definition/OutputType.php | 16 + src/Type/Definition/ScalarType.php | 33 + src/Type/Definition/ScalarTypeConfig.php | 26 + src/Type/Definition/StringType.php | 28 + src/Type/Definition/Type.php | 247 +++ src/Type/Definition/UnionType.php | 81 + src/Type/Definition/UnmodifiedType.php | 16 + src/Type/Definition/WrappingType.php | 12 + src/Type/Introspection.php | 406 +++++ src/Type/SchemaValidator.php | 179 ++ src/Types.php | 9 + src/Utils.php | 121 ++ src/Utils/PairSet.php | 61 + src/Utils/TypeInfo.php | 269 +++ src/Validator/DocumentValidator.php | 318 ++++ src/Validator/Messages.php | 160 ++ .../Rules/ArgumentsOfCorrectType.php | 68 + .../Rules/DefaultValuesOfCorrectType.php | 40 + src/Validator/Rules/FieldsOnCorrectType.php | 30 + .../Rules/FragmentsOnCompositeTypes.php | 44 + src/Validator/Rules/KnownArgumentNames.php | 40 + src/Validator/Rules/KnownDirectives.php | 66 + src/Validator/Rules/KnownFragmentNames.php | 27 + src/Validator/Rules/KnownTypeNames.php | 28 + src/Validator/Rules/NoFragmentCycles.php | 101 ++ src/Validator/Rules/NoUndefinedVariables.php | 75 + src/Validator/Rules/NoUnusedFragments.php | 70 + src/Validator/Rules/NoUnusedVariables.php | 57 + .../Rules/OverlappingFieldsCanBeMerged.php | 276 +++ .../Rules/PossibleFragmentSpreads.php | 79 + src/Validator/Rules/ScalarLeafs.php | 37 + .../Rules/VariablesAreInputTypes.php | 43 + .../Rules/VariablesInAllowedPosition.php | 86 + src/Validator/ValidationContext.php | 116 ++ tests/Executor/DirectivesTest.php | 225 +++ tests/Executor/ExecutorSchemaTest.php | 206 +++ tests/Executor/ExecutorTest.php | 470 +++++ tests/Executor/InputObjectTest.php | 525 ++++++ tests/Executor/ListsTest.php | 385 ++++ tests/Executor/MutationsTest.php | 219 +++ tests/Executor/NonNullTest.php | 262 +++ tests/Executor/UnionInterfaceTest.php | 364 ++++ tests/Language/LexerTest.php | 260 +++ tests/Language/ParserTest.php | 167 ++ tests/Language/PrinterTest.php | 151 ++ tests/Language/VisitorTest.php | 415 +++++ tests/Language/kitchen-sink.graphql | 38 + tests/StarWarsData.php | 128 ++ tests/StarWarsIntrospectionTest.php | 296 +++ tests/StarWarsQueryTest.php | 338 ++++ tests/StarWarsSchema.php | 266 +++ tests/StarWarsValidationTest.php | 126 ++ tests/Type/DefinitionTest.php | 280 +++ tests/Type/IntrospectionTest.php | 1579 +++++++++++++++++ tests/Type/ScalarCoercionTest.php | 63 + tests/Type/SchemaValidatorTest.php | 364 ++++ .../Validator/ArgumentsOfCorrectTypeTest.php | 813 +++++++++ .../DefaultValuesOfCorrectTypeTest.php | 109 ++ tests/Validator/FieldsOnCorrectTypeTest.php | 192 ++ .../FragmentsOnCompositeTypesTest.php | 103 ++ tests/Validator/KnownArgumentNamesTest.php | 134 ++ tests/Validator/KnownDirectivesTest.php | 100 ++ tests/Validator/KnownFragmentNamesTest.php | 65 + tests/Validator/KnownTypeNamesTest.php | 52 + tests/Validator/NoFragmentCyclesTest.php | 189 ++ tests/Validator/NoUndefinedVariablesTest.php | 318 ++++ tests/Validator/NoUnusedFragmentsTest.php | 140 ++ tests/Validator/NoUnusedVariablesTest.php | 221 +++ .../OverlappingFieldsCanBeMergedTest.php | 426 +++++ .../Validator/PossibleFragmentSpreadsTest.php | 226 +++ tests/Validator/ScalarLeafsTest.php | 117 ++ tests/Validator/TestCase.php | 307 ++++ .../Validator/VariablesAreInputTypesTest.php | 42 + .../VariablesInAllowedPositionTest.php | 330 ++++ 139 files changed, 18852 insertions(+) create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 src/Error.php create mode 100644 src/Executor/ExecutionContext.php create mode 100644 src/Executor/Executor.php create mode 100644 src/Executor/Values.php create mode 100644 src/FormattedError.php create mode 100644 src/GraphQL.php create mode 100644 src/Language/AST/Argument.php create mode 100644 src/Language/AST/ArrayValue.php create mode 100644 src/Language/AST/BooleanValue.php create mode 100644 src/Language/AST/Definition.php create mode 100644 src/Language/AST/Directive.php create mode 100644 src/Language/AST/Document.php create mode 100644 src/Language/AST/EnumValue.php create mode 100644 src/Language/AST/Field.php create mode 100644 src/Language/AST/FloatValue.php create mode 100644 src/Language/AST/FragmentDefinition.php create mode 100644 src/Language/AST/FragmentSpread.php create mode 100644 src/Language/AST/InlineFragment.php create mode 100644 src/Language/AST/IntValue.php create mode 100644 src/Language/AST/ListType.php create mode 100644 src/Language/AST/Location.php create mode 100644 src/Language/AST/Name.php create mode 100644 src/Language/AST/Node.php create mode 100644 src/Language/AST/NonNullType.php create mode 100644 src/Language/AST/ObjectField.php create mode 100644 src/Language/AST/ObjectValue.php create mode 100644 src/Language/AST/OperationDefinition.php create mode 100644 src/Language/AST/Selection.php create mode 100644 src/Language/AST/SelectionSet.php create mode 100644 src/Language/AST/StringValue.php create mode 100644 src/Language/AST/Type.php create mode 100644 src/Language/AST/Value.php create mode 100644 src/Language/AST/Variable.php create mode 100644 src/Language/AST/VariableDefinition.php create mode 100644 src/Language/Exception.php create mode 100644 src/Language/Lexer.php create mode 100644 src/Language/Parser.php create mode 100644 src/Language/Printer.php create mode 100644 src/Language/Source.php create mode 100644 src/Language/SourceLocation.php create mode 100644 src/Language/Token.php create mode 100644 src/Language/Visitor.php create mode 100644 src/Schema.php create mode 100644 src/Type/Definition/AbstractType.php create mode 100644 src/Type/Definition/BooleanType.php create mode 100644 src/Type/Definition/CompositeType.php create mode 100644 src/Type/Definition/Config.php create mode 100644 src/Type/Definition/Directive.php create mode 100644 src/Type/Definition/EnumType.php create mode 100644 src/Type/Definition/EnumValueDefinition.php create mode 100644 src/Type/Definition/FieldArgument.php create mode 100644 src/Type/Definition/FieldDefinition.php create mode 100644 src/Type/Definition/FloatType.php create mode 100644 src/Type/Definition/IDType.php create mode 100644 src/Type/Definition/InputObjectField.php create mode 100644 src/Type/Definition/InputObjectType.php create mode 100644 src/Type/Definition/InputType.php create mode 100644 src/Type/Definition/IntType.php create mode 100644 src/Type/Definition/InterfaceType.php create mode 100644 src/Type/Definition/LeafType.php create mode 100644 src/Type/Definition/ListOfType.php create mode 100644 src/Type/Definition/NonNull.php create mode 100644 src/Type/Definition/ObjectType.php create mode 100644 src/Type/Definition/OutputType.php create mode 100644 src/Type/Definition/ScalarType.php create mode 100644 src/Type/Definition/ScalarTypeConfig.php create mode 100644 src/Type/Definition/StringType.php create mode 100644 src/Type/Definition/Type.php create mode 100644 src/Type/Definition/UnionType.php create mode 100644 src/Type/Definition/UnmodifiedType.php create mode 100644 src/Type/Definition/WrappingType.php create mode 100644 src/Type/Introspection.php create mode 100644 src/Type/SchemaValidator.php create mode 100644 src/Types.php create mode 100644 src/Utils.php create mode 100644 src/Utils/PairSet.php create mode 100644 src/Utils/TypeInfo.php create mode 100644 src/Validator/DocumentValidator.php create mode 100644 src/Validator/Messages.php create mode 100644 src/Validator/Rules/ArgumentsOfCorrectType.php create mode 100644 src/Validator/Rules/DefaultValuesOfCorrectType.php create mode 100644 src/Validator/Rules/FieldsOnCorrectType.php create mode 100644 src/Validator/Rules/FragmentsOnCompositeTypes.php create mode 100644 src/Validator/Rules/KnownArgumentNames.php create mode 100644 src/Validator/Rules/KnownDirectives.php create mode 100644 src/Validator/Rules/KnownFragmentNames.php create mode 100644 src/Validator/Rules/KnownTypeNames.php create mode 100644 src/Validator/Rules/NoFragmentCycles.php create mode 100644 src/Validator/Rules/NoUndefinedVariables.php create mode 100644 src/Validator/Rules/NoUnusedFragments.php create mode 100644 src/Validator/Rules/NoUnusedVariables.php create mode 100644 src/Validator/Rules/OverlappingFieldsCanBeMerged.php create mode 100644 src/Validator/Rules/PossibleFragmentSpreads.php create mode 100644 src/Validator/Rules/ScalarLeafs.php create mode 100644 src/Validator/Rules/VariablesAreInputTypes.php create mode 100644 src/Validator/Rules/VariablesInAllowedPosition.php create mode 100644 src/Validator/ValidationContext.php create mode 100644 tests/Executor/DirectivesTest.php create mode 100644 tests/Executor/ExecutorSchemaTest.php create mode 100644 tests/Executor/ExecutorTest.php create mode 100644 tests/Executor/InputObjectTest.php create mode 100644 tests/Executor/ListsTest.php create mode 100644 tests/Executor/MutationsTest.php create mode 100644 tests/Executor/NonNullTest.php create mode 100644 tests/Executor/UnionInterfaceTest.php create mode 100644 tests/Language/LexerTest.php create mode 100644 tests/Language/ParserTest.php create mode 100644 tests/Language/PrinterTest.php create mode 100644 tests/Language/VisitorTest.php create mode 100644 tests/Language/kitchen-sink.graphql create mode 100644 tests/StarWarsData.php create mode 100644 tests/StarWarsIntrospectionTest.php create mode 100644 tests/StarWarsQueryTest.php create mode 100644 tests/StarWarsSchema.php create mode 100644 tests/StarWarsValidationTest.php create mode 100644 tests/Type/DefinitionTest.php create mode 100644 tests/Type/IntrospectionTest.php create mode 100644 tests/Type/ScalarCoercionTest.php create mode 100644 tests/Type/SchemaValidatorTest.php create mode 100644 tests/Validator/ArgumentsOfCorrectTypeTest.php create mode 100644 tests/Validator/DefaultValuesOfCorrectTypeTest.php create mode 100644 tests/Validator/FieldsOnCorrectTypeTest.php create mode 100644 tests/Validator/FragmentsOnCompositeTypesTest.php create mode 100644 tests/Validator/KnownArgumentNamesTest.php create mode 100644 tests/Validator/KnownDirectivesTest.php create mode 100644 tests/Validator/KnownFragmentNamesTest.php create mode 100644 tests/Validator/KnownTypeNamesTest.php create mode 100644 tests/Validator/NoFragmentCyclesTest.php create mode 100644 tests/Validator/NoUndefinedVariablesTest.php create mode 100644 tests/Validator/NoUnusedFragmentsTest.php create mode 100644 tests/Validator/NoUnusedVariablesTest.php create mode 100644 tests/Validator/OverlappingFieldsCanBeMergedTest.php create mode 100644 tests/Validator/PossibleFragmentSpreadsTest.php create mode 100644 tests/Validator/ScalarLeafsTest.php create mode 100644 tests/Validator/TestCase.php create mode 100644 tests/Validator/VariablesAreInputTypesTest.php create mode 100644 tests/Validator/VariablesInAllowedPositionTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fd0457 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +### Intellij ### +.idea/ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..fd46071 --- /dev/null +++ b/composer.json @@ -0,0 +1,32 @@ +{ + "name": "webonyx/graphql-php", + "description": "A PHP port of GraphQL reference implementation (https://github.com/graphql/graphql-js, ref: 099eeb7b5a49a16fa3d55353b9774291881e959c)", + "type": "library", + "license": "BSD", + "homepage": "https://github.com/webonyx/graphql-php", + "keywords": [ + "graphql", + "API" + ], + "require": { + "php": ">=5.4,<8.0-DEV" + }, + "require-dev": { + "phpunit/phpunit": "4.7.6" + }, + "config": { + "bin-dir": "bin" + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "autoload-dev": { + "classmap": [ + "tests/" + ], + "files": [ + ] + } +} diff --git a/src/Error.php b/src/Error.php new file mode 100644 index 0000000..98fae3b --- /dev/null +++ b/src/Error.php @@ -0,0 +1,86 @@ + + */ + public $locations; + + /** + * @var Source|null + */ + public $source; + + /** + * @param $error + * @return FormattedError + */ + public static function formatError($error) + { + if (is_array($error)) { + $message = isset($error['message']) ? $error['message'] : null; + $locations = isset($error['locations']) ? $error['locations'] : null; + } else if ($error instanceof Error) { + $message = $error->message; + $locations = $error->locations; + } else { + $message = (string) $error; + $locations = null; + } + + return new FormattedError($message, $locations); + } + + /** + * @param string $message + * @param array|null $nodes + * @param null $stack + */ + public function __construct($message, array $nodes = null, $stack = null) + { + $this->message = $message; + $this->stack = $stack ?: $message; + + if ($nodes) { + $this->nodes = $nodes; + $positions = array_map(function($node) { return isset($node->loc) ? $node->loc->start : null; }, $nodes); + $positions = array_filter($positions); + + if (!empty($positions)) { + $this->positions = $positions; + $loc = $nodes[0]->loc; + $source = $loc ? $loc->source : null; + if ($source) { + $this->locations = array_map(function($pos) use($source) {return $source->getLocation($pos);}, $positions); + $this->source = $source; + } + } + } + } +} diff --git a/src/Executor/ExecutionContext.php b/src/Executor/ExecutionContext.php new file mode 100644 index 0000000..59b6454 --- /dev/null +++ b/src/Executor/ExecutionContext.php @@ -0,0 +1,60 @@ + + */ + public $fragments; + + /** + * @var + */ + public $root; + + /** + * @var OperationDefinition + */ + public $operation; + + /** + * @var array + */ + public $variables; + + /** + * @var array + */ + public $errors; + + public function __construct($schema, $fragments, $root, $operation, $variables, $errors) + { + $this->schema = $schema; + $this->fragments = $fragments; + $this->root = $root; + $this->operation = $operation; + $this->variables = $variables; + $this->errors = $errors ?: []; + } + + public function addError($error) + { + $this->errors[] = $error; + return $this; + } +} diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php new file mode 100644 index 0000000..377435a --- /dev/null +++ b/src/Executor/Executor.php @@ -0,0 +1,540 @@ +operation); + } catch (\Exception $e) { + $errors[] = $e; + } + + $result = [ + 'data' => isset($data) ? $data : null + ]; + if (count($errors) > 0) { + $result['errors'] = array_map(['GraphQL\Error', 'formatError'], $errors->getArrayCopy()); + } + + return $result; + } + + /** + * Constructs a ExecutionContext object from the arguments passed to + * execute, which we will pass throughout the other execution methods. + */ + private static function buildExecutionContext(Schema $schema, $root, Document $ast, $operationName = null, array $args = null, &$errors) + { + $operations = []; + $fragments = []; + + foreach ($ast->definitions as $statement) { + switch ($statement->kind) { + case Node::OPERATION_DEFINITION: + $operations[$statement->name ? $statement->name->value : ''] = $statement; + break; + case Node::FRAGMENT_DEFINITION: + $fragments[$statement->name->value] = $statement; + break; + } + } + + if (!$operationName && count($operations) !== 1) { + throw new Error( + 'Must provide operation name if query contains multiple operations' + ); + } + + $opName = $operationName ?: key($operations); + if (!isset($operations[$opName])) { + throw new Error('Unknown operation name: ' . $opName); + } + $operation = $operations[$opName]; + + $variables = Values::getVariableValues($schema, $operation->variableDefinitions ?: array(), $args ?: []); + $exeContext = new ExecutionContext($schema, $fragments, $root, $operation, $variables, $errors); + return $exeContext; + } + + /** + * Implements the "Evaluating operations" section of the spec. + */ + private static function executeOperation(ExecutionContext $exeContext, $root, OperationDefinition $operation) + { + $type = self::getOperationRootType($exeContext->schema, $operation); + $fields = self::collectFields($exeContext, $type, $operation->selectionSet, new \ArrayObject(), new \ArrayObject()); + if ($operation->operation === 'mutation') { + return self::executeFieldsSerially($exeContext, $type, $root, $fields->getArrayCopy()); + } + return self::executeFields($exeContext, $type, $root, $fields); + } + + + /** + * Extracts the root type of the operation from the schema. + * + * @param Schema $schema + * @param OperationDefinition $operation + * @return ObjectType + * @throws Error + */ + private static function getOperationRootType(Schema $schema, OperationDefinition $operation) + { + switch ($operation->operation) { + case 'query': + return $schema->getQueryType(); + case 'mutation': + $mutationType = $schema->getMutationType(); + if (!$mutationType) { + throw new Error( + 'Schema is not configured for mutations', + [$operation] + ); + } + return $mutationType; + default: + throw new Error( + 'Can only execute queries and mutations', + [$operation] + ); + } + } + + /** + * Implements the "Evaluating selection sets" section of the spec + * for "write" mode. + */ + private static function executeFieldsSerially(ExecutionContext $exeContext, ObjectType $parentType, $source, $fields) + { + $results = []; + foreach ($fields as $responseName => $fieldASTs) { + $result = self::resolveField($exeContext, $parentType, $source, $fieldASTs); + + if ($result !== self::$UNDEFINED) { + // Undefined means that field is not defined in schema + $results[$responseName] = $result; + } + } + return $results; + } + + /** + * Implements the "Evaluating selection sets" section of the spec + * for "read" mode. + */ + private static function executeFields(ExecutionContext $exeContext, ObjectType $parentType, $source, $fields) + { + // Native PHP doesn't support promises. + // Custom executor should be built for platforms like ReactPHP + return self::executeFieldsSerially($exeContext, $parentType, $source, $fields); + } + + + /** + * Given a selectionSet, adds all of the fields in that selection to + * the passed in map of fields, and returns it at the end. + * + * @return \ArrayObject + */ + private static function collectFields( + ExecutionContext $exeContext, + ObjectType $type, + SelectionSet $selectionSet, + $fields, + $visitedFragmentNames + ) + { + for ($i = 0; $i < count($selectionSet->selections); $i++) { + $selection = $selectionSet->selections[$i]; + switch ($selection->kind) { + case Node::FIELD: + if (!self::shouldIncludeNode($exeContext, $selection->directives)) { + continue; + } + $name = self::getFieldEntryKey($selection); + if (!isset($fields[$name])) { + $fields[$name] = new \ArrayObject(); + } + $fields[$name][] = $selection; + break; + case Node::INLINE_FRAGMENT: + if (!self::shouldIncludeNode($exeContext, $selection->directives) || + !self::doesFragmentConditionMatch($exeContext, $selection, $type) + ) { + continue; + } + self::collectFields( + $exeContext, + $type, + $selection->selectionSet, + $fields, + $visitedFragmentNames + ); + break; + case Node::FRAGMENT_SPREAD: + $fragName = $selection->name->value; + if (!empty($visitedFragmentNames[$fragName]) || !self::shouldIncludeNode($exeContext, $selection->directives)) { + continue; + } + $visitedFragmentNames[$fragName] = true; + + /** @var FragmentDefinition|null $fragment */ + $fragment = isset($exeContext->fragments[$fragName]) ? $exeContext->fragments[$fragName] : null; + if (!$fragment || + !self::shouldIncludeNode($exeContext, $fragment->directives) || + !self::doesFragmentConditionMatch($exeContext, $fragment, $type) + ) { + continue; + } + self::collectFields( + $exeContext, + $type, + $fragment->selectionSet, + $fields, + $visitedFragmentNames + ); + break; + } + } + return $fields; + } + + /** + * Determines if a field should be included based on @if and @unless directives. + */ + private static function shouldIncludeNode(ExecutionContext $exeContext, $directives) + { + $ifDirective = Values::getDirectiveValue(Directive::ifDirective(), $directives, $exeContext->variables); + if ($ifDirective !== null) { + return $ifDirective; + } + + $unlessDirective = Values::getDirectiveValue(Directive::unlessDirective(), $directives, $exeContext->variables); + if ($unlessDirective !== null) { + return !$unlessDirective; + } + + return true; + } + + /** + * Determines if a fragment is applicable to the given type. + */ + private static function doesFragmentConditionMatch(ExecutionContext $exeContext,/* FragmentDefinition | InlineFragment*/ $fragment, ObjectType $type) + { + $conditionalType = Utils\TypeInfo::typeFromAST($exeContext->schema, $fragment->typeCondition); + if ($conditionalType === $type) { + return true; + } + if ($conditionalType instanceof InterfaceType || + $conditionalType instanceof UnionType + ) { + return $conditionalType->isPossibleType($type); + } + return false; + } + + /** + * Implements the logic to compute the key of a given fields entry + */ + private static function getFieldEntryKey(Field $node) + { + return $node->alias ? $node->alias->value : $node->name->value; + } + + /** + * A wrapper function for resolving the field, that catches the error + * and adds it to the context's global if the error is not rethrowable. + */ + private static function resolveField(ExecutionContext $exeContext, ObjectType $parentType, $source, $fieldASTs) + { + $fieldDef = self::getFieldDef($exeContext->schema, $parentType, $fieldASTs[0]); + if (!$fieldDef) { + return self::$UNDEFINED; + } + + // If the field type is non-nullable, then it is resolved without any + // protection from errors. + if ($fieldDef->getType() instanceof NonNull) { + return self::resolveFieldOrError( + $exeContext, + $parentType, + $source, + $fieldASTs, + $fieldDef + ); + } + + // Otherwise, error protection is applied, logging the error and resolving + // a null value for this field if one is encountered. + try { + $result = self::resolveFieldOrError( + $exeContext, + $parentType, + $source, + $fieldASTs, + $fieldDef + ); + + return $result; + } catch (\Exception $error) { + $exeContext->addError($error); + return null; + } + } + + /** + * Resolves the field on the given source object. In particular, this + * figures out the object that the field returns using the resolve function, + * then calls completeField to coerce scalars or execute the sub + * selection set for objects. + */ + private static function resolveFieldOrError( + ExecutionContext $exeContext, + ObjectType $parentType, + $source, + /*array*/ $fieldASTs, + FieldDefinition $fieldDef + ) + { + $fieldAST = $fieldASTs[0]; + $fieldType = $fieldDef->getType(); + $resolveFn = $fieldDef->resolve ?: [__CLASS__, 'defaultResolveFn']; + + // Build a JS object of arguments from the field.arguments AST, using the + // variables scope to fulfill any variable references. + // TODO: find a way to memoize, in case this field is within a Array type. + $args = Values::getArgumentValues( + $fieldDef->args, + $fieldAST->arguments, + $exeContext->variables + ); + + try { + $result = call_user_func($resolveFn, + $source, + $args, + $exeContext->root, + // TODO: provide all fieldASTs, not just the first field + $fieldAST, + $fieldType, + $parentType, + $exeContext->schema + ); + } catch (\Exception $error) { + throw new Error($error->getMessage(), [$fieldAST], $error->getTrace()); + } + + return self::completeField( + $exeContext, + $fieldType, + $fieldASTs, + $result + ); + } + + + /** + * Implements the instructions for completeValue as defined in the + * "Field entries" section of the spec. + * + * If the field type is Non-Null, then this recursively completes the value + * for the inner type. It throws a field error if that completion returns null, + * as per the "Nullability" section of the spec. + * + * If the field type is a List, then this recursively completes the value + * for the inner type on each item in the list. + * + * If the field type is a Scalar or Enum, ensures the completed value is a legal + * value of the type by calling the `coerce` method of GraphQL type definition. + * + * Otherwise, the field type expects a sub-selection set, and will complete the + * value by evaluating all sub-selections. + */ + private static function completeField(ExecutionContext $exeContext, Type $fieldType,/* Array */ $fieldASTs, &$result) + { + // If field type is NonNull, complete for inner type, and throw field error + // if result is null. + if ($fieldType instanceof NonNull) { + $completed = self::completeField( + $exeContext, + $fieldType->getWrappedType(), + $fieldASTs, + $result + ); + if ($completed === null) { + throw new Error( + 'Cannot return null for non-nullable type.', + $fieldASTs instanceof \ArrayObject ? $fieldASTs->getArrayCopy() : $fieldASTs + ); + } + return $completed; + } + + // If result is null-like, return null. + if (null === $result) { + return null; + } + + // If field type is List, complete each item in the list with the inner type + if ($fieldType instanceof ListOfType) { + $itemType = $fieldType->getWrappedType(); + Utils::invariant( + is_array($result) || $result instanceof \ArrayObject, + 'User Error: expected iterable, but did not find one.' + ); + + $tmp = []; + foreach ($result as $item) { + $tmp[] = self::completeField($exeContext, $itemType, $fieldASTs, $item); + } + return $tmp; + } + + // If field type is Scalar or Enum, coerce to a valid value, returning null + // if coercion is not possible. + if ($fieldType instanceof ScalarType || + $fieldType instanceof EnumType + ) { + Utils::invariant(method_exists($fieldType, 'coerce'), 'Missing coerce method on type'); + return $fieldType->coerce($result); + } + + // Field type must be Object, Interface or Union and expect sub-selections. + + $objectType = + $fieldType instanceof ObjectType ? $fieldType : + ($fieldType instanceof InterfaceType || + $fieldType instanceof UnionType ? $fieldType->resolveType($result) : + null); + + if (!$objectType) { + return null; + } + + // Collect sub-fields to execute to complete this value. + $subFieldASTs = new \ArrayObject(); + $visitedFragmentNames = new \ArrayObject(); + for ($i = 0; $i < count($fieldASTs); $i++) { + $selectionSet = $fieldASTs[$i]->selectionSet; + if ($selectionSet) { + $subFieldASTs = self::collectFields( + $exeContext, + $objectType, + $selectionSet, + $subFieldASTs, + $visitedFragmentNames + ); + } + } + + return self::executeFields($exeContext, $objectType, $result, $subFieldASTs); + } + + + /** + * If a resolve function is not given, then a default resolve behavior is used + * which takes the property of the source object of the same name as the field + * and returns it as the result, or if it's a function, returns the result + * of calling that function. + */ + public static function defaultResolveFn($source, $args, $root, $fieldAST) + { + $property = null; + if (is_array($source) || $source instanceof \ArrayAccess) { + if (isset($source[$fieldAST->name->value])) { + $property = $source[$fieldAST->name->value]; + } + } else if (is_object($source)) { + if (property_exists($source, $fieldAST->name->value)) { + $e = func_get_args(); + $property = $source->{$fieldAST->name->value}; + } + } + + return is_callable($property) ? call_user_func($property, $source) : $property; + } + + /** + * This method looks up the field on the given type defintion. + * It has special casing for the two introspection fields, __schema + * and __typename. __typename is special because it can always be + * queried as a field, even in situations where no other fields + * are allowed, like on a Union. __schema could get automatically + * added to the query type, but that would require mutating type + * definitions, which would cause issues. + * + * @return FieldDefinition + */ + private static function getFieldDef(Schema $schema, ObjectType $parentType, Field $fieldAST) + { + $name = $fieldAST->name->value; + $schemaMetaFieldDef = Introspection::schemaMetaFieldDef(); + $typeMetaFieldDef = Introspection::typeMetaFieldDef(); + $typeNameMetaFieldDef = Introspection::typeNameMetaFieldDef(); + + if ($name === $schemaMetaFieldDef->name && + $schema->getQueryType() === $parentType + ) { + return $schemaMetaFieldDef; + } else if ($name === $typeMetaFieldDef->name && + $schema->getQueryType() === $parentType + ) { + return $typeMetaFieldDef; + } else if ($name === $typeNameMetaFieldDef->name) { + return $typeNameMetaFieldDef; + } + $tmp = $parentType->getFields(); + return isset($tmp[$name]) ? $tmp[$name] : null; + } +} diff --git a/src/Executor/Values.php b/src/Executor/Values.php new file mode 100644 index 0000000..3636eb7 --- /dev/null +++ b/src/Executor/Values.php @@ -0,0 +1,275 @@ + */ $definitionASTs, array $inputs) + { + $values = []; + foreach ($definitionASTs as $defAST) { + $varName = $defAST->variable->name->value; + $values[$varName] = self::getvariableValue($schema, $defAST, isset($inputs[$varName]) ? $inputs[$varName] : null); + } + return $values; + } + + /** + * Prepares an object map of argument values given a list of argument + * definitions and list of argument AST nodes. + */ + public static function getArgumentValues(/* Array*/ $argDefs, /*Array*/ $argASTs, $variables) + { + if (!$argDefs || count($argDefs) === 0) { + return null; + } + $argASTMap = $argASTs ? Utils::keyMap($argASTs, function ($arg) { + return $arg->name->value; + }) : []; + $result = []; + foreach ($argDefs as $argDef) { + $name = $argDef->name; + $valueAST = isset($argASTMap[$name]) ? $argASTMap[$name]->value : null; + $result[$name] = self::coerceValueAST($argDef->getType(), $valueAST, $variables); + } + return $result; + } + + public static function getDirectiveValue(Directive $directiveDef, /* Array */ $directives, $variables) + { + $directiveAST = null; + if ($directives) { + foreach ($directives as $directive) { + if ($directive->name->value === $directiveDef->name) { + $directiveAST = $directive; + break; + } + } + } + if ($directiveAST) { + if (!$directiveDef->type) { + return null; + } + return self::coerceValueAST($directiveDef->type, $directiveAST->value, $variables); + } + } + + /** + * Given a variable definition, and any value of input, return a value which + * adheres to the variable definition, or throw an error. + */ + private static function getVariableValue(Schema $schema, VariableDefinition $definitionAST, $input) + { + $type = Utils\TypeInfo::typeFromAST($schema, $definitionAST->type); + if (!$type) { + return null; + } + if (self::isValidValue($type, $input)) { + if (null === $input) { + $defaultValue = $definitionAST->defaultValue; + if ($defaultValue) { + return self::coerceValueAST($type, $defaultValue); + } + } + return self::coerceValue($type, $input); + } + + throw new Error( + "Variable \${$definitionAST->variable->name->value} expected value of type " . + Printer::doPrint($definitionAST->type) . " but got: " . json_encode($input) . '.', + [$definitionAST] + ); + } + + + /** + * Given a type and any value, return true if that value is valid. + */ + private static function isValidValue(Type $type, $value) + { + if ($type instanceof NonNull) { + if (null === $value) { + return false; + } + return self::isValidValue($type->getWrappedType(), $value); + } + + if ($value === null) { + return true; + } + + if ($type instanceof ListOfType) { + $itemType = $type->getWrappedType(); + if (is_array($value)) { + foreach ($value as $item) { + if (!self::isValidValue($itemType, $item)) { + return false; + } + } + return true; + } else { + return self::isValidValue($itemType, $value); + } + } + + if ($type instanceof InputObjectType) { + $fields = $type->getFields(); + foreach ($fields as $fieldName => $field) { + /** @var FieldDefinition $field */ + if (!self::isValidValue($field->getType(), isset($value[$fieldName]) ? $value[$fieldName] : null)) { + return false; + } + } + return true; + } + + if ($type instanceof ScalarType || + $type instanceof EnumType + ) { + return null !== $type->coerce($value); + } + + return false; + } + + /** + * Given a type and any value, return a runtime value coerced to match the type. + */ + private static function coerceValue(Type $type, $value) + { + if ($type instanceof NonNull) { + // Note: we're not checking that the result of coerceValue is non-null. + // We only call this function after calling isValidValue. + return self::coerceValue($type->getWrappedType(), $value); + } + + if (null === $value) { + return null; + } + + if ($type instanceof ListOfType) { + $itemType = $type->getWrappedType(); + // TODO: support iterable input + if (is_array($value)) { + return array_map(function ($item) use ($itemType) { + return Values::coerceValue($itemType, $item); + }, $value); + } else { + return [self::coerceValue($itemType, $value)]; + } + } + + if ($type instanceof InputObjectType) { + $fields = $type->getFields(); + $obj = []; + foreach ($fields as $fieldName => $field) { + $fieldValue = self::coerceValue($field->getType(), $value[$fieldName]); + $obj[$fieldName] = $fieldValue === null ? $field->defaultValue : $fieldValue; + } + return $obj; + + } + + if ($type instanceof ScalarType || + $type instanceof EnumType + ) { + $coerced = $type->coerce($value); + if (null !== $coerced) { + return $coerced; + } + } + + return null; + } + + /** + * Given a type and a value AST node known to match this type, build a + * runtime value. + */ + private static function coerceValueAST(Type $type, $valueAST, $variables) + { + if ($type instanceof NonNull) { + // Note: we're not checking that the result of coerceValueAST is non-null. + // We're assuming that this query has been validated and the value used + // here is of the correct type. + return self::coerceValueAST($type->getWrappedType(), $valueAST, $variables); + } + + if (!$valueAST) { + return null; + } + + if ($valueAST->kind === Node::VARIABLE) { + $variableName = $valueAST->name->value; + + if (!isset($variables, $variables[$variableName])) { + return null; + } + + // Note: we're not doing any checking that this variable is correct. We're + // assuming that this query has been validated and the variable usage here + // is of the correct type. + return $variables[$variableName]; + } + + if ($type instanceof ListOfType) { + $itemType = $type->getWrappedType(); + if ($valueAST->kind === Node::ARR) { + $tmp = []; + foreach ($valueAST->values as $itemAST) { + $tmp[] = self::coerceValueAST($itemType, $itemAST, $variables); + } + return $tmp; + } else { + return [self::coerceValueAST($itemType, $valueAST, $variables)]; + } + } + + if ($type instanceof InputObjectType) { + $fields = $type->getFields(); + if ($valueAST->kind !== Node::OBJECT) { + return null; + } + $fieldASTs = Utils::keyMap($valueAST->fields, function ($field) { + return $field->name->value; + }); + + $obj = []; + foreach ($fields as $fieldName => $field) { + $fieldAST = $fieldASTs[$fieldName]; + $fieldValue = self::coerceValueAST($field->getType(), $fieldAST ? $fieldAST->value : null, $variables); + $obj[$fieldName] = $fieldValue === null ? $field->defaultValue : $fieldValue; + } + return $obj; + } + + if ($type instanceof ScalarType || $type instanceof EnumType) { + $coerced = $type->coerceLiteral($valueAST); + if (null !== $coerced) { + return $coerced; + } + } + + return null; + } +} diff --git a/src/FormattedError.php b/src/FormattedError.php new file mode 100644 index 0000000..1f0f09e --- /dev/null +++ b/src/FormattedError.php @@ -0,0 +1,18 @@ + + */ + public $locations; + + public function __construct($message, $locations = null) + { + $this->message = $message; + $this->locations = $locations; + } +} diff --git a/src/GraphQL.php b/src/GraphQL.php new file mode 100644 index 0000000..f2c7cab --- /dev/null +++ b/src/GraphQL.php @@ -0,0 +1,35 @@ +|null $variableValues + * @param string|null $operationName + * @return array + */ + public static function execute(Schema $schema, $requestString, $rootObject = null, $variableValues = null, $operationName = null) + { + try { + $source = new Source($requestString ?: '', 'GraphQL request'); + $ast = Parser::parse($source); + $validationResult = DocumentValidator::validate($schema, $ast); + + if (empty($validationResult['isValid'])) { + return ['errors' => $validationResult['errors']]; + } else { + return Executor::execute($schema, $rootObject, $ast, $operationName, $variableValues); + } + } catch (\Exception $e) { + return ['errors' => Error::formatError($e)]; + } + } +} diff --git a/src/Language/AST/Argument.php b/src/Language/AST/Argument.php new file mode 100644 index 0000000..d2f5a53 --- /dev/null +++ b/src/Language/AST/Argument.php @@ -0,0 +1,17 @@ + + */ + public $values; +} diff --git a/src/Language/AST/BooleanValue.php b/src/Language/AST/BooleanValue.php new file mode 100644 index 0000000..cc6a1b3 --- /dev/null +++ b/src/Language/AST/BooleanValue.php @@ -0,0 +1,13 @@ + + */ + public $definitions; +} diff --git a/src/Language/AST/EnumValue.php b/src/Language/AST/EnumValue.php new file mode 100644 index 0000000..7524735 --- /dev/null +++ b/src/Language/AST/EnumValue.php @@ -0,0 +1,12 @@ +|null + */ + public $arguments; + + /** + * @var array|null + */ + public $directives; + + /** + * @var SelectionSet|null + */ + public $selectionSet; +} diff --git a/src/Language/AST/FloatValue.php b/src/Language/AST/FloatValue.php new file mode 100644 index 0000000..b29ffd3 --- /dev/null +++ b/src/Language/AST/FloatValue.php @@ -0,0 +1,13 @@ + + */ + public $directives; + + /** + * @var SelectionSet + */ + public $selectionSet; +} diff --git a/src/Language/AST/FragmentSpread.php b/src/Language/AST/FragmentSpread.php new file mode 100644 index 0000000..a989bc2 --- /dev/null +++ b/src/Language/AST/FragmentSpread.php @@ -0,0 +1,17 @@ + + */ + public $directives; +} diff --git a/src/Language/AST/InlineFragment.php b/src/Language/AST/InlineFragment.php new file mode 100644 index 0000000..827f3f8 --- /dev/null +++ b/src/Language/AST/InlineFragment.php @@ -0,0 +1,22 @@ +|null + */ + public $directives; + + /** + * @var SelectionSet + */ + public $selectionSet; +} \ No newline at end of file diff --git a/src/Language/AST/IntValue.php b/src/Language/AST/IntValue.php new file mode 100644 index 0000000..45cde14 --- /dev/null +++ b/src/Language/AST/IntValue.php @@ -0,0 +1,13 @@ +start = $start; + $this->end = $end; + $this->source = $source; + } +} diff --git a/src/Language/AST/Name.php b/src/Language/AST/Name.php new file mode 100644 index 0000000..e9fdb67 --- /dev/null +++ b/src/Language/AST/Name.php @@ -0,0 +1,12 @@ +_cloneValue($this); + } + + private function _cloneValue($value) + { + if (is_array($value)) { + $cloned = []; + foreach ($value as $key => $arrValue) { + $cloned[$key] = $this->_cloneValue($arrValue); + } + } else if ($value instanceof Node) { + $cloned = clone $value; + foreach (get_object_vars($cloned) as $prop => $propValue) { + $cloned->{$prop} = $this->_cloneValue($propValue); + } + } else { + $cloned = $value; + } + + return $cloned; + } + + /** + * @return string + */ + public function __toString() + { + return json_encode($this); + } +} diff --git a/src/Language/AST/NonNullType.php b/src/Language/AST/NonNullType.php new file mode 100644 index 0000000..857b87c --- /dev/null +++ b/src/Language/AST/NonNullType.php @@ -0,0 +1,12 @@ + + */ + public $fields; +} diff --git a/src/Language/AST/OperationDefinition.php b/src/Language/AST/OperationDefinition.php new file mode 100644 index 0000000..2c66b5f --- /dev/null +++ b/src/Language/AST/OperationDefinition.php @@ -0,0 +1,35 @@ + + */ + public $variableDefinitions; + + /** + * @var array + */ + public $directives; + + /** + * @var SelectionSet + */ + public $selectionSet; +} diff --git a/src/Language/AST/Selection.php b/src/Language/AST/Selection.php new file mode 100644 index 0000000..2955967 --- /dev/null +++ b/src/Language/AST/Selection.php @@ -0,0 +1,9 @@ + + */ + public $selections; +} diff --git a/src/Language/AST/StringValue.php b/src/Language/AST/StringValue.php new file mode 100644 index 0000000..f1e1c1c --- /dev/null +++ b/src/Language/AST/StringValue.php @@ -0,0 +1,13 @@ +getLocation($position); + $syntaxError = new self( + "Syntax Error {$source->name} ({$location->line}:{$location->column}) $description\n\n" . + self::highlightSourceAtLocation($source, $location) + ); + $syntaxError->source = $source; + $syntaxError->position = $position; + $syntaxError->location = $location; + + return $syntaxError; + } + + public static function highlightSourceAtLocation(Source $source, SourceLocation $location) + { + $line = $location->line; + $prevLineNum = (string)($line - 1); + $lineNum = (string)$line; + $nextLineNum = (string)($line + 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); + + $lpad = function($len, $str) { + return str_pad($str, $len - mb_strlen($str, 'UTF-8') + 1, ' ', STR_PAD_LEFT); + }; + + return + ($line >= 2 ? $lpad($padLen, $prevLineNum) . ': ' . $lines[$line - 2] . "\n" : '') . + ($lpad($padLen, $lineNum) . ': ' . $lines[$line - 1] . "\n") . + (str_repeat(' ', 1 + $padLen + $location->column) . "^\n") . + ($line < count($lines) ? $lpad($padLen, $nextLineNum) . ': ' . $lines[$line] . "\n" : ''); + } +} diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php new file mode 100644 index 0000000..2190e35 --- /dev/null +++ b/src/Language/Lexer.php @@ -0,0 +1,303 @@ +prevPosition = 0; + $this->source = $source; + } + + /** + * @param int|null $resetPosition + * @return Token + */ + public function nextToken($resetPosition = null) + { + $token = $this->readToken($resetPosition === null ? $this->prevPosition : $resetPosition); + $this->prevPosition = $token->end; + return $token; + } + + /** + * @param int $fromPosition + * @return Token + * @throws Exception + */ + private function readToken($fromPosition) + { + $body = $this->source->body; + $bodyLength = $this->source->length; + + $position = $this->positionAfterWhitespace($body, $fromPosition); + $code = Utils::charCodeAt($body, $position); + + if ($position >= $bodyLength) { + return new Token(Token::EOF, $position, $position); + } + + switch ($code) { + // ! + case 33: return new Token(Token::BANG, $position, $position + 1); + // $ + case 36: return new Token(Token::DOLLAR, $position, $position + 1); + // ( + case 40: return new Token(Token::PAREN_L, $position, $position + 1); + // ) + case 41: return new Token(Token::PAREN_R, $position, $position + 1); + // . + case 46: + if (Utils::charCodeAt($body, $position+1) === 46 && + Utils::charCodeAt($body, $position+2) === 46) { + return new Token(Token::SPREAD, $position, $position + 3); + } + break; + // : + case 58: return new Token(Token::COLON, $position, $position + 1); + // = + case 61: return new Token(Token::EQUALS, $position, $position + 1); + // @ + case 64: return new Token(Token::AT, $position, $position + 1); + // [ + case 91: return new Token(Token::BRACKET_L, $position, $position + 1); + // ] + case 93: return new Token(Token::BRACKET_R, $position, $position + 1); + // { + case 123: return new Token(Token::BRACE_L, $position, $position + 1); + // | + case 124: return new Token(Token::PIPE, $position, $position + 1); + // } + case 125: return new Token(Token::BRACE_R, $position, $position + 1); + // A-Z + case 65: case 66: case 67: case 68: case 69: case 70: case 71: case 72: + case 73: case 74: case 75: case 76: case 77: case 78: case 79: case 80: + case 81: case 82: case 83: case 84: case 85: case 86: case 87: case 88: + case 89: case 90: + // _ + case 95: + // a-z + case 97: case 98: case 99: case 100: case 101: case 102: case 103: case 104: + case 105: case 106: case 107: case 108: case 109: case 110: case 111: + case 112: case 113: case 114: case 115: case 116: case 117: case 118: + case 119: case 120: case 121: case 122: + return $this->readName($position); + // - + case 45: + // 0-9 + case 48: case 49: case 50: case 51: case 52: + case 53: case 54: case 55: case 56: case 57: + return $this->readNumber($position, $code); + // " + case 34: return $this->readString($position); + } + + throw Exception::create($this->source, $position, 'Unexpected character "' . Utils::chr($code). '"'); + } + + /** + * Reads an alphanumeric + underscore name from the source. + * + * [_A-Za-z][_0-9A-Za-z]* + * @param int $position + * @return Token + */ + private function readName($position) + { + $body = $this->source->body; + $bodyLength = $this->source->length; + $end = $position + 1; + + while ( + $end !== $bodyLength && + ($code = Utils::charCodeAt($body, $end)) && + ( + $code === 95 || // _ + $code >= 48 && $code <= 57 || // 0-9 + $code >= 65 && $code <= 90 || // A-Z + $code >= 97 && $code <= 122 // a-z + ) + ) { + ++$end; + } + return new Token(Token::NAME, $position, $end, mb_substr($body, $position, $end - $position, 'UTF-8')); + } + + /** + * Reads a number token from the source file, either a float + * or an int depending on whether a decimal point appears. + * + * Int: -?(0|[1-9][0-9]*) + * Float: -?(0|[1-9][0-9]*)\.[0-9]+(e-?[0-9]+)? + * + * @param $start + * @param $firstCode + * @return Token + * @throws Exception + */ + private function readNumber($start, $firstCode) + { + $code = $firstCode; + $body = $this->source->body; + $position = $start; + $isFloat = false; + + if ($code === 45) { // - + $code = Utils::charCodeAt($body, ++$position); + } + + if ($code === 48) { // 0 + $code = Utils::charCodeAt($body, ++$position); + } else if ($code >= 49 && $code <= 57) { // 1 - 9 + do { + $code = Utils::charCodeAt($body, ++$position); + } while ($code >= 48 && $code <= 57); // 0 - 9 + } else { + throw Exception::create($this->source, $position, 'Invalid number'); + } + + if ($code === 46) { // . + $isFloat = true; + + $code = Utils::charCodeAt($body, ++$position); + if ($code >= 48 && $code <= 57) { // 0 - 9 + do { + $code = Utils::charCodeAt($body, ++$position); + } while ($code >= 48 && $code <= 57); // 0 - 9 + } else { + throw Exception::create($this->source, $position, 'Invalid number'); + } + + if ($code === 101) { // e + $code = Utils::charCodeAt($body, ++$position); + if ($code === 45) { // - + $code = Utils::charCodeAt($body, ++$position); + } + if ($code >= 48 && $code <= 57) { // 0 - 9 + do { + $code = Utils::charCodeAt($body, ++$position); + } while ($code >= 48 && $code <= 57); // 0 - 9 + } else { + throw Exception::create($this->source, $position, 'Invalid number'); + } + } + } + return new Token( + $isFloat ? Token::FLOAT : Token::INT, + $start, + $position, + mb_substr($body, $start, $position - $start, 'UTF-8') + ); + } + + private function readString($start) + { + $body = $this->source->body; + $bodyLength = $this->source->length; + + $position = $start + 1; + $chunkStart = $position; + $code = null; + $value = ''; + + while ( + $position < $bodyLength && + ($code = Utils::charCodeAt($body, $position)) && + $code !== 34 && + $code !== 10 && $code !== 13 && $code !== 0x2028 && $code !== 0x2029 + ) { + ++$position; + if ($code === 92) { // \ + $value .= mb_substr($body, $chunkStart, $position - 1 - $chunkStart, 'UTF-8'); + $code = Utils::charCodeAt($body, $position); + switch ($code) { + case 34: $value .= '"'; break; + case 47: $value .= '\/'; break; + case 92: $value .= '\\'; break; + case 98: $value .= '\b'; break; + case 102: $value .= '\f'; break; + case 110: $value .= '\n'; break; + case 114: $value .= '\r'; break; + case 116: $value .= '\t'; break; + case 117: + $hex = mb_substr($body, $position + 1, 4); + if (!preg_match('/[0-9a-fA-F]{4}/', $hex)) { + throw Exception::create($this->source, $position, 'Bad character escape sequence'); + } + $value .= Utils::chr(hexdec($hex)); + $position += 4; + break; + default: + throw Exception::create($this->source, $position, 'Bad character escape sequence'); + } + ++$position; + $chunkStart = $position; + } + } + + if ($code !== 34) { + throw Exception::create($this->source, $position, 'Unterminated string'); + } + + $value .= mb_substr($body, $chunkStart, $position - $chunkStart, 'UTF-8'); + return new Token(Token::STRING, $start, $position + 1, $value); + } + + /** + * Reads from body starting at startPosition until it finds a non-whitespace + * or commented character, then returns the position of that character for + * lexing. + * + * @param $body + * @param $startPosition + * @return int + */ + private function positionAfterWhitespace($body, $startPosition) + { + $bodyLength = mb_strlen($body, 'UTF-8'); + $position = $startPosition; + + while ($position < $bodyLength) { + $code = Utils::charCodeAt($body, $position); + + // Skip whitespace + if ( + $code === 32 || // space + $code === 44 || // comma + $code === 160 || // '\xa0' + $code === 0x2028 || // line separator + $code === 0x2029 || // paragraph separator + $code > 8 && $code < 14 // whitespace + ) { + ++$position; + // Skip comments + } else if ($code === 35) { // # + ++$position; + while ( + $position < $bodyLength && + ($code = Utils::charCodeAt($body, $position)) && + $code !== 10 && $code !== 13 && $code !== 0x2028 && $code !== 0x2029 + ) { + ++$position; + } + } else { + break; + } + } + return $position; + } +} diff --git a/src/Language/Parser.php b/src/Language/Parser.php new file mode 100644 index 0000000..a497fa4 --- /dev/null +++ b/src/Language/Parser.php @@ -0,0 +1,675 @@ +parseDocument(); + } + + /** + * @var Source + */ + private $source; + + /** + * @var array + */ + private $options; + + /** + * @var int + */ + private $prevEnd; + + /** + * @var Lexer + */ + private $lexer; + + /** + * @var Token + */ + private $token; + + function __construct(Source $source, array $options = array()) + { + $this->lexer = new Lexer($source); + $this->source = $source; + $this->options = $options; + $this->prevEnd = 0; + $this->token = $this->lexer->nextToken(); + } + + /** + * Returns a location object, used to identify the place in + * the source that created a given parsed object. + * + * @param int $start + * @return Location|null + */ + function loc($start) + { + if (!empty($this->options['noLocation'])) { + return null; + } + if (!empty($this->options['noSource'])) { + return new Location($start, $this->prevEnd); + } + return new Location($start, $this->prevEnd, $this->source); + } + + /** + * Moves the internal parser object to the next lexed token. + */ + function advance() + { + $prevEnd = $this->token->end; + $this->prevEnd = $prevEnd; + $this->token = $this->lexer->nextToken($prevEnd); + } + + /** + * Determines if the next token is of a given kind + * + * @param $kind + * @return bool + */ + function peek($kind) + { + return $this->token->kind === $kind; + } + + /** + * If the next token is of the given kind, return true after advancing + * the parser. Otherwise, do not change the parser state and return false. + * + * @param $kind + * @return bool + */ + function skip($kind) + { + $match = $this->token->kind === $kind; + + if ($match) { + $this->advance(); + } + return $match; + } + + /** + * If the next token is of the given kind, return that token after advancing + * the parser. Otherwise, do not change the parser state and return false. + * @param string $kind + * @return Token + * @throws Exception + */ + function expect($kind) + { + $token = $this->token; + + if ($token->kind === $kind) { + $this->advance(); + return $token; + } + + throw Exception::create( + $this->source, + $token->start, + "Expected " . Token::getKindDescription($kind) . ", found " . $token->getDescription() + ); + } + + /** + * If the next token is a keyword with the given value, return that token after + * advancing the parser. Otherwise, do not change the parser state and return + * false. + * + * @param string $value + * @return Token + * @throws Exception + */ + function expectKeyword($value) + { + $token = $this->token; + + if ($token->kind === Token::NAME && $token->value === $value) { + $this->advance(); + return $token; + } + throw Exception::create( + $this->source, + $token->start, + 'Expected "' . $value . '", found ' . $token->getDescription() + ); + } + + /** + * @param Token|null $atToken + * @return Exception + */ + function unexpected(Token $atToken = null) + { + $token = $atToken ?: $this->token; + return Exception::create($this->source, $token->start, "Unexpected " . $token->getDescription()); + } + + /** + * Returns a possibly empty list of parse nodes, determined by + * the parseFn. This list begins with a lex token of openKind + * and ends with a lex token of closeKind. Advances the parser + * to the next lex token after the closing token. + * + * @param int $openKind + * @param callable $parseFn + * @param int $closeKind + * @return array + * @throws Exception + */ + function any($openKind, $parseFn, $closeKind) + { + $this->expect($openKind); + + $nodes = array(); + while (!$this->skip($closeKind)) { + $nodes[] = $parseFn($this); + } + return $nodes; + } + + /** + * Returns a non-empty list of parse nodes, determined by + * the parseFn. This list begins with a lex token of openKind + * and ends with a lex token of closeKind. Advances the parser + * to the next lex token after the closing token. + * + * @param $openKind + * @param $parseFn + * @param $closeKind + * @return array + * @throws Exception + */ + function many($openKind, $parseFn, $closeKind) + { + $this->expect($openKind); + + $nodes = array($parseFn($this)); + while (!$this->skip($closeKind)) { + $nodes[] = $parseFn($this); + } + return $nodes; + } + + /** + * Converts a name lex token into a name parse node. + * + * @return Name + * @throws Exception + */ + function parseName() + { + $token = $this->expect(Token::NAME); + + return new Name(array( + 'value' => $token->value, + 'loc' => $this->loc($token->start) + )); + } + + /** + * Implements the parsing rules in the Document section. + * + * @return Document + * @throws Exception + */ + function parseDocument() + { + $start = $this->token->start; + $definitions = array(); + + do { + if ($this->peek(Token::BRACE_L)) { + $definitions[] = $this->parseOperationDefinition(); + } else if ($this->peek(Token::NAME)) { + if ($this->token->value === 'query' || $this->token->value === 'mutation') { + $definitions[] = $this->parseOperationDefinition(); + } else if ($this->token->value === 'fragment') { + $definitions[] = $this->parseFragmentDefinition(); + } else { + throw $this->unexpected(); + } + } else { + throw $this->unexpected(); + } + } while (!$this->skip(Token::EOF)); + + return new Document(array( + 'definitions' => $definitions, + 'loc' => $this->loc($start) + )); + } + + // Implements the parsing rules in the Operations section. + + /** + * @return OperationDefinition + * @throws Exception + */ + function parseOperationDefinition() + { + $start = $this->token->start; + if ($this->peek(Token::BRACE_L)) { + return new OperationDefinition(array( + 'operation' => 'query', + 'name' => null, + 'variableDefinitions' => null, + 'directives' => array(), + 'selectionSet' => $this->parseSelectionSet(), + 'loc' => $this->loc($start) + )); + } + + $operationToken = $this->expect(Token::NAME); + $operation = $operationToken->value; + + return new OperationDefinition(array( + 'operation' => $operation, + 'name' => $this->parseName(), + 'variableDefinitions' => $this->parseVariableDefinitions(), + 'directives' => $this->parseDirectives(), + 'selectionSet' => $this->parseSelectionSet(), + 'loc' => $this->loc($start) + )); + } + + /** + * @return array + */ + function parseVariableDefinitions() + { + return $this->peek(Token::PAREN_L) ? + $this->many( + Token::PAREN_L, + array($this, 'parseVariableDefinition'), + Token::PAREN_R + ) : + array(); + } + + /** + * @return VariableDefinition + * @throws Exception + */ + function parseVariableDefinition() + { + $start = $this->token->start; + $var = $this->parseVariable(); + + $this->expect(Token::COLON); + $type = $this->parseType(); + + return new VariableDefinition(array( + 'variable' => $var, + 'type' => $type, + 'defaultValue' => + ($this->skip(Token::EQUALS) ? $this->parseValue(true) : null), + 'loc' => $this->loc($start) + )); + } + + /** + * @return Variable + * @throws Exception + */ + function parseVariable() { + $start = $this->token->start; + $this->expect(Token::DOLLAR); + + return new Variable(array( + 'name' => $this->parseName(), + 'loc' => $this->loc($start) + )); + } + + /** + * @return SelectionSet + */ + function parseSelectionSet() { + $start = $this->token->start; + return new SelectionSet(array( + 'selections' => $this->many(Token::BRACE_L, array($this, 'parseSelection'), Token::BRACE_R), + 'loc' => $this->loc($start) + )); + } + + /** + * @return mixed + */ + function parseSelection() { + return $this->peek(Token::SPREAD) ? + $this->parseFragment() : + $this->parseField(); + } + + /** + * @return Field + */ + function parseField() { + $start = $this->token->start; + $nameOrAlias = $this->parseName(); + + if ($this->skip(Token::COLON)) { + $alias = $nameOrAlias; + $name = $this->parseName(); + } else { + $alias = null; + $name = $nameOrAlias; + } + + return new Field(array( + 'alias' => $alias, + 'name' => $name, + 'arguments' => $this->parseArguments(), + 'directives' => $this->parseDirectives(), + 'selectionSet' => $this->peek(Token::BRACE_L) ? $this->parseSelectionSet() : null, + 'loc' => $this->loc($start) + )); + } + + /** + * @return array + */ + function parseArguments() { + return $this->peek(Token::PAREN_L) ? + $this->many(Token::PAREN_L, array($this, 'parseArgument'), Token::PAREN_R) : + array(); + } + + /** + * @return Argument + * @throws Exception + */ + function parseArgument() + { + $start = $this->token->start; + $name = $this->parseName(); + + $this->expect(Token::COLON); + $value = $this->parseValue(false); + + return new Argument(array( + 'name' => $name, + 'value' => $value, + 'loc' => $this->loc($start) + )); + } + + // Implements the parsing rules in the Fragments section. + + /** + * @return FragmentSpread|InlineFragment + * @throws Exception + */ + function parseFragment() { + $start = $this->token->start; + $this->expect(Token::SPREAD); + + if ($this->token->value === 'on') { + $this->advance(); + return new InlineFragment(array( + 'typeCondition' => $this->parseName(), + 'directives' => $this->parseDirectives(), + 'selectionSet' => $this->parseSelectionSet(), + 'loc' => $this->loc($start) + )); + } + return new FragmentSpread(array( + 'name' => $this->parseName(), + 'directives' => $this->parseDirectives(), + 'loc' => $this->loc($start) + )); + } + + /** + * @return FragmentDefinition + * @throws Exception + */ + function parseFragmentDefinition() { + $start = $this->token->start; + $this->expectKeyword('fragment'); + + $name = $this->parseName(); + $this->expectKeyword('on'); + $typeCondition = $this->parseName(); + + return new FragmentDefinition(array( + 'name' => $name, + 'typeCondition' => $typeCondition, + 'directives' => $this->parseDirectives(), + 'selectionSet' => $this->parseSelectionSet(), + 'loc' => $this->loc($start) + )); + } + + // Implements the parsing rules in the Values section. + function parseVariableValue() + { + return $this->parseValue(false); + } + + /** + * @return BooleanValue|EnumValue|FloatValue|IntValue|StringValue|Variable + * @throws Exception + */ + function parseConstValue() + { + return $this->parseValue(true); + } + + /** + * @param $isConst + * @return BooleanValue|EnumValue|FloatValue|IntValue|StringValue|Variable + * @throws Exception + */ + function parseValue($isConst) { + $token = $this->token; + switch ($token->kind) { + case Token::BRACKET_L: + return $this->parseArray($isConst); + case Token::BRACE_L: + return $this->parseObject($isConst); + case Token::INT: + $this->advance(); + return new IntValue(array( + 'value' => $token->value, + 'loc' => $this->loc($token->start) + )); + case Token::FLOAT: + $this->advance(); + return new FloatValue(array( + 'value' => $token->value, + 'loc' => $this->loc($token->start) + )); + case Token::STRING: + $this->advance(); + return new StringValue(array( + 'value' => $token->value, + 'loc' => $this->loc($token->start) + )); + case Token::NAME: + $this->advance(); + switch ($token->value) { + case 'true': + case 'false': + return new BooleanValue(array( + 'value' => $token->value === 'true', + 'loc' => $this->loc($token->start) + )); + } + return new EnumValue(array( + 'value' => $token->value, + 'loc' => $this->loc($token->start) + )); + case Token::DOLLAR: + if (!$isConst) { + return $this->parseVariable(); + } + break; + } + throw $this->unexpected(); + } + + /** + * @param bool $isConst + * @return ArrayValue + */ + function parseArray($isConst) + { + $start = $this->token->start; + $item = $isConst ? 'parseConstValue' : 'parseVariableValue'; + return new ArrayValue(array( + 'values' => $this->any(Token::BRACKET_L, array($this, $item), Token::BRACKET_R), + 'loc' => $this->loc($start) + )); + } + + function parseObject($isConst) + { + $start = $this->token->start; + $this->expect(Token::BRACE_L); + $fieldNames = array(); + $fields = array(); + while (!$this->skip(Token::BRACE_R)) { + $fields[] = $this->parseObjectField($isConst, $fieldNames); + } + return new ObjectValue(array( + 'fields' => $fields, + 'loc' => $this->loc($start) + )); + } + + function parseObjectField($isConst, &$fieldNames) + { + $start = $this->token->start; + $name = $this->parseName(); + + if (array_key_exists($name->value, $fieldNames)) { + throw Exception::create($this->source, $start, "Duplicate input object field " . $name->value . '.'); + } + $fieldNames[$name->value] = true; + $this->expect(Token::COLON); + + return new ObjectField(array( + 'name' => $name, + 'value' => $this->parseValue($isConst), + 'loc' => $this->loc($start) + )); + } + + // Implements the parsing rules in the Directives section. + + /** + * @return array + */ + function parseDirectives() + { + $directives = array(); + while ($this->peek(Token::AT)) { + $directives[] = $this->parseDirective(); + } + return $directives; + } + + /** + * @return Directive + * @throws Exception + */ + function parseDirective() + { + $start = $this->token->start; + $this->expect(Token::AT); + return new Directive(array( + 'name' => $this->parseName(), + 'value' => $this->skip(Token::COLON) ? $this->parseValue(false) : null, + 'loc' => $this->loc($start) + )); + } + + // Implements the parsing rules in the Types section. + + /** + * Handles the Type: TypeName, ListType, and NonNullType parsing rules. + * + * @return ListType|Name|NonNullType + * @throws Exception + */ + function parseType() + { + $start = $this->token->start; + + if ($this->skip(Token::BRACKET_L)) { + $type = $this->parseType(); + $this->expect(Token::BRACKET_R); + $type = new ListType(array( + 'type' => $type, + 'loc' => $this->loc($start) + )); + } else { + $type = $this->parseName(); + } + if ($this->skip(Token::BANG)) { + return new NonNullType(array( + 'type' => $type, + 'loc' => $this->loc($start) + )); + + } + return $type; + } +} diff --git a/src/Language/Printer.php b/src/Language/Printer.php new file mode 100644 index 0000000..c5c884b --- /dev/null +++ b/src/Language/Printer.php @@ -0,0 +1,149 @@ + array( + Node::NAME => function($node) {return $node->value . '';}, + Node::VARIABLE => function($node) {return '$' . $node->name;}, + Node::DOCUMENT => function(Document $node) {return self::join($node->definitions, "\n\n") . "\n";}, + Node::OPERATION_DEFINITION => function(OperationDefinition $node) { + $op = $node->operation; + $name = $node->name; + $defs = Printer::manyList('(', $node->variableDefinitions, ', ', ')'); + $directives = self::join($node->directives, ' '); + $selectionSet = $node->selectionSet; + return !$name ? $selectionSet : + self::join([$op, self::join([$name, $defs]), $directives, $selectionSet], ' '); + }, + Node::VARIABLE_DEFINITION => function(VariableDefinition $node) { + return self::join([$node->variable . ': ' . $node->type, $node->defaultValue], ' = '); + }, + Node::SELECTION_SET => function(SelectionSet $node) { + return self::blockList($node->selections, ",\n"); + }, + Node::FIELD => function(Field $node) { + $r11 = self::join([ + $node->alias, + $node->name + ], ': '); + + $r1 = self::join([ + $r11, + self::manyList('(', $node->arguments, ', ', ')') + ]); + + $r2 = self::join($node->directives, ' '); + + return self::join([ + $r1, + $r2, + $node->selectionSet + ], ' '); + }, + Node::ARGUMENT => function(Argument $node) { + return $node->name . ': ' . $node->value; + }, + + // Fragments + Node::FRAGMENT_SPREAD => function(FragmentSpread $node) { + return self::join(['...' . $node->name, self::join($node->directives, '')], ' '); + }, + Node::INLINE_FRAGMENT => function(InlineFragment $node) { + return self::join([ + '... on', + $node->typeCondition, + self::join($node->directives, ' '), + $node->selectionSet + ], ' '); + }, + Node::FRAGMENT_DEFINITION => function(FragmentDefinition $node) { + return self::join([ + 'fragment', + $node->name, + 'on', + $node->typeCondition, + self::join($node->directives, ' '), + $node->selectionSet + ], ' '); + }, + + // Value + Node::INT => function(IntValue $node) {return $node->value;}, + Node::FLOAT => function(FloatValue $node) {return $node->value;}, + Node::STRING => function(StringValue $node) {return json_encode($node->value);}, + Node::BOOLEAN => function(BooleanValue $node) {return $node->value ? 'true' : 'false';}, + Node::ENUM => function(EnumValue $node) {return $node->value;}, + Node::ARR => function(ArrayValue $node) {return '[' . self::join($node->values, ', ') . ']';}, + Node::OBJECT => function(ObjectValue $node) {return '{' . self::join($node->fields, ', ') . '}';}, + Node::OBJECT_FIELD => function(ObjectField $node) {return $node->name . ': ' . $node->value;}, + + // Directive + Node::DIRECTIVE => function(Directive $node) {return self::join(['@' . $node->name, $node->value], ': ');}, + + // Type + Node::LIST_TYPE => function(ListType $node) {return '[' . $node->type . ']';}, + Node::NON_NULL_TYPE => function(NonNullType $node) {return $node->type . '!';} + ) + )); + } + + public static function blockList($list, $separator) + { + return self::length($list) === 0 ? null : self::indent("{\n" . self::join($list, $separator)) . "\n}"; + } + + public static function indent($maybeString) + { + return $maybeString ? str_replace("\n", "\n ", $maybeString) : ''; + } + + public static function manyList($start, $list, $separator, $end) + { + return self::length($list) === 0 ? null : ($start . self::join($list, $separator) . $end); + } + + public static function length($maybeArray) + { + return $maybeArray ? count($maybeArray) : 0; + } + + public static function join($maybeArray, $separator = '') + { + return $maybeArray + ? implode( + $separator, + array_filter( + $maybeArray, + function($x) { return !!$x;} + ) + ) + : ''; + } +} diff --git a/src/Language/Source.php b/src/Language/Source.php new file mode 100644 index 0000000..1d3d335 --- /dev/null +++ b/src/Language/Source.php @@ -0,0 +1,49 @@ +body = $body; + $this->length = mb_strlen($body, 'UTF-8'); + $this->name = $name ?: 'GraphQL'; + } + + /** + * @param $position + * @return SourceLocation + */ + public function getLocation($position) + { + $line = 1; + $column = $position + 1; + + $utfChars = json_decode('"\u2028\u2029"'); + $lineRegexp = '/\r\n|[\n\r'.$utfChars.']/su'; + $matches = array(); + preg_match_all($lineRegexp, mb_substr($this->body, 0, $position, 'UTF-8'), $matches, PREG_OFFSET_CAPTURE); + + foreach ($matches[0] as $index => $match) { + $line += 1; + $column = $position + 1 - ($match[1] + mb_strlen($match[0], 'UTF-8')); + } + + return new SourceLocation($line, $column); + } +} diff --git a/src/Language/SourceLocation.php b/src/Language/SourceLocation.php new file mode 100644 index 0000000..3155b40 --- /dev/null +++ b/src/Language/SourceLocation.php @@ -0,0 +1,14 @@ +line = $line; + $this->column = $col; + } +} diff --git a/src/Language/Token.php b/src/Language/Token.php new file mode 100644 index 0000000..cdeac45 --- /dev/null +++ b/src/Language/Token.php @@ -0,0 +1,89 @@ +kind = $kind; + $this->start = (int) $start; + $this->end = (int) $end; + $this->value = $value; + } + + /** + * @return string + */ + public function getDescription() + { + return self::getKindDescription($this->kind) . ($this->value ? ' "' . $this->value . '"' : ''); + } +} diff --git a/src/Language/Visitor.php b/src/Language/Visitor.php new file mode 100644 index 0000000..8dd19e7 --- /dev/null +++ b/src/Language/Visitor.php @@ -0,0 +1,353 @@ +doBreak = true; + return $r; + } + + /** + * Skip current node + */ + public static function skipNode() + { + $r = new VisitorOperation(); + $r->doContinue = true; + return $r; + } + + /** + * Remove current node + */ + public static function removeNode() + { + $r = new VisitorOperation(); + $r->removeNode = true; + return $r; + } + + public static $visitorKeys = array( + Node::NAME => [], + Node::DOCUMENT => ['definitions'], + Node::OPERATION_DEFINITION => ['name', 'variableDefinitions', 'directives', 'selectionSet'], + Node::VARIABLE_DEFINITION => ['variable', 'type', 'defaultValue'], + Node::VARIABLE => ['name'], + Node::SELECTION_SET => ['selections'], + Node::FIELD => ['alias', 'name', 'arguments', 'directives', 'selectionSet'], + Node::ARGUMENT => ['name', 'value'], + Node::FRAGMENT_SPREAD => ['name', 'directives'], + Node::INLINE_FRAGMENT => ['typeCondition', 'directives', 'selectionSet'], + Node::FRAGMENT_DEFINITION => ['name', 'typeCondition', 'directives', 'selectionSet'], + + Node::INT => [], + Node::FLOAT => [], + Node::STRING => [], + Node::BOOLEAN => [], + Node::ENUM => [], + Node::ARR => ['values'], + Node::OBJECT => ['fields'], + Node::OBJECT_FIELD => ['name', 'value'], + Node::DIRECTIVE => ['name', 'value'], + Node::LIST_TYPE => ['type'], + Node::NON_NULL_TYPE => ['type'], + ); + + /** + * visit() will walk through an AST using a depth first traversal, calling + * the visitor's enter function at each node in the traversal, and calling the + * leave function after visiting that node and all of it's child nodes. + * + * By returning different values from the enter and leave functions, the + * behavior of the visitor can be altered, including skipping over a sub-tree of + * the AST (by returning false), editing the AST by returning a value or null + * to remove the value, or to stop the whole traversal by returning BREAK. + * + * When using visit() to edit an AST, the original AST will not be modified, and + * a new version of the AST with the changes applied will be returned from the + * visit function. + * + * var editedAST = visit(ast, { + * enter(node, key, parent, path, ancestors) { + * // @return + * // undefined: no action + * // false: skip visiting this node + * // visitor.BREAK: stop visiting altogether + * // null: delete this node + * // any value: replace this node with the returned value + * }, + * leave(node, key, parent, path, ancestors) { + * // @return + * // undefined: no action + * // visitor.BREAK: stop visiting altogether + * // null: delete this node + * // any value: replace this node with the returned value + * } + * }); + * + * Alternatively to providing enter() and leave() functions, a visitor can + * instead provide functions named the same as the kinds of AST nodes, or + * enter/leave visitors at a named key, leading to four permutations of + * visitor API: + * + * 1) Named visitors triggered when entering a node a specific kind. + * + * visit(ast, { + * Kind(node) { + * // enter the "Kind" node + * } + * }) + * + * 2) Named visitors that trigger upon entering and leaving a node of + * a specific kind. + * + * visit(ast, { + * Kind: { + * enter(node) { + * // enter the "Kind" node + * } + * leave(node) { + * // leave the "Kind" node + * } + * } + * }) + * + * 3) Generic visitors that trigger upon entering and leaving any node. + * + * visit(ast, { + * enter(node) { + * // enter any node + * }, + * leave(node) { + * // leave any node + * } + * }) + * + * 4) Parallel visitors for entering and leaving nodes of a specific kind. + * + * visit(ast, { + * enter: { + * Kind(node) { + * // enter the "Kind" node + * } + * }, + * leave: { + * Kind(node) { + * // leave the "Kind" node + * } + * } + * }) + */ + public static function visit($root, $visitor) + { + $visitorKeys = isset($visitor['keys']) ? $visitor['keys'] : self::$visitorKeys; + + $stack = null; + $inArray = is_array($root); + $keys = [$root]; + $index = -1; + $edits = []; + $parent = null; + $path = []; + $ancestors = []; + $newRoot = $root; + + $UNDEFINED = null; + + do { + $index++; + $isLeaving = $index === count($keys); + $key = null; + $node = null; + $isEdited = $isLeaving && count($edits) !== 0; + + if ($isLeaving) { + $key = count($ancestors) === 0 ? $UNDEFINED : array_pop($path); + $node = $parent; + $parent = array_pop($ancestors); + + if ($isEdited) { + if ($inArray) { + // $node = $node; // arrays are value types in PHP + } else { + $node = clone $node; + } + $editOffset = 0; + for ($ii = 0; $ii < count($edits); $ii++) { + $editKey = $edits[$ii][0]; + $editValue = $edits[$ii][1]; + + if ($inArray) { + $editKey -= $editOffset; + } + if ($inArray && $editValue === null) { + array_splice($node, $editKey, 1); + $editOffset++; + } else { + if (is_array($node)) { + $node[$editKey] = $editValue; + } else { + $node->{$editKey} = $editValue; + } + } + } + } + $index = $stack['index']; + $keys = $stack['keys']; + $edits = $stack['edits']; + $inArray = $stack['inArray']; + $stack = $stack['prev']; + } else { + $key = $parent ? ($inArray ? $index : $keys[$index]) : $UNDEFINED; + $node = $parent ? (is_array($parent) ? $parent[$key] : $parent->{$key}) : $newRoot; + if ($node === null || $node === $UNDEFINED) { + continue; + } + if ($parent) { + $path[] = $key; + } + } + + $result = null; + if (!is_array($node)) { + if (!($node instanceof Node)) { + throw new \Exception('Invalid AST Node: ' . json_encode($node)); + } + + $visitFn = self::getVisitFn($visitor, $isLeaving, $node->kind); + + if ($visitFn) { + $result = call_user_func($visitFn, $node, $key, $parent, $path, $ancestors); + + if ($result !== null) { + if ($result instanceof VisitorOperation) { + if ($result->doBreak) { + break; + } + if (!$isLeaving && $result->doContinue) { + array_pop($path); + continue; + } + $editValue = null; + } else { + $editValue = $result; + } + + $edits[] = [$key, $editValue]; + if (!$isLeaving) { + if ($editValue instanceof Node) { + $node = $editValue; + } else { + array_pop($path); + continue; + } + } + } + } + } + + if ($result === null && $isEdited) { + $edits[] = [$key, $node]; + } + + if (!$isLeaving) { + $stack = array( + 'inArray' => $inArray, + 'index' => $index, + 'keys' => $keys, + 'edits' => $edits, + 'prev' => $stack + ); + $inArray = is_array($node); + + $keys = ($inArray ? $node : $visitorKeys[$node->kind]) ?: array(); + $index = -1; + $edits = []; + if ($parent) { + $ancestors[] = $parent; + } + $parent = $node; + } + + } while ($stack); + + if (count($edits) !== 0) { + $newRoot = $edits[0][1]; + } + + return $newRoot; + } + + /** + * @param $visitor + * @param $isLeaving + * @param $kind + * @return null + */ + public static function getVisitFn($visitor, $isLeaving, $kind) + { + if (!$visitor) { + return null; + } + $kindVisitor = isset($visitor[$kind]) ? $visitor[$kind] : null; + + if (!$isLeaving && is_callable($kindVisitor)) { + // { Kind() {} } + return $kindVisitor; + } + + if (is_array($kindVisitor)) { + if ($isLeaving) { + $kindSpecificVisitor = isset($kindVisitor['leave']) ? $kindVisitor['leave'] : null; + } else { + $kindSpecificVisitor = isset($kindVisitor['enter']) ? $kindVisitor['enter'] : null; + } + + if ($kindSpecificVisitor && is_callable($kindSpecificVisitor)) { + // { Kind: { enter() {}, leave() {} } } + return $kindSpecificVisitor; + } + return null; + } + + $visitor += ['leave' => null, 'enter' => null]; + $specificVisitor = $isLeaving ? $visitor['leave'] : $visitor['enter']; + + if ($specificVisitor) { + if (is_callable($specificVisitor)) { + // { enter() {}, leave() {} } + return $specificVisitor; + } + $specificKindVisitor = isset($specificVisitor[$kind]) ? $specificVisitor[$kind] : null; + + if (is_callable($specificKindVisitor)) { + // { enter: { Kind() {} }, leave: { Kind() {} } } + return $specificKindVisitor; + } + } + return null; + } +} + + +class VisitorOperation +{ + public $doBreak; + + public $doContinue; + + public $removeNode; +} diff --git a/src/Schema.php b/src/Schema.php new file mode 100644 index 0000000..f2fb7a1 --- /dev/null +++ b/src/Schema.php @@ -0,0 +1,121 @@ +querySchema = $querySchema; + $this->mutationSchema = $mutationSchema; + } + + public function getQueryType() + { + return $this->querySchema; + } + + public function getMutationType() + { + return $this->mutationSchema; + } + + /** + * @param $name + * @return null + */ + public function getDirective($name) + { + foreach ($this->getDirectives() as $directive) { + if ($directive->name === $name) { + return $directive; + } + } + return null; + } + + /** + * @return array + */ + public function getDirectives() + { + if (!$this->_directives) { + $this->_directives = [ + Directive::ifDirective(), + Directive::unlessDirective() + ]; + } + return $this->_directives; + } + + public function getTypeMap() + { + if (null === $this->_typeMap) { + $map = []; + foreach ([$this->getQueryType(), $this->getMutationType(), Introspection::_schema()] as $type) { + $this->_extractTypes($type, $map); + } + $this->_typeMap = $map + Type::getInternalTypes(); + } + return $this->_typeMap; + } + + private function _extractTypes($type, &$map) + { + if ($type instanceof WrappingType) { + return $this->_extractTypes($type->getWrappedType(), $map); + } + + if (!$type instanceof Type || !empty($map[$type->name])) { + // TODO: warning? + return $map; + } + + $map[$type->name] = $type; + + $nestedTypes = []; + + if ($type instanceof InterfaceType || $type instanceof UnionType) { + $nestedTypes = $type->getPossibleTypes(); + } + if ($type instanceof ObjectType) { + $nestedTypes = array_merge($nestedTypes, $type->getInterfaces()); + } + if ($type instanceof ObjectType || $type instanceof InterfaceType) { + foreach ((array) $type->getFields() as $fieldName => $field) { + if (null === $field->args) { + trigger_error('WTF ' . $field->name . ' has no args?'); // gg + } + $fieldArgTypes = array_map(function($arg) { return $arg->getType(); }, $field->args); + $nestedTypes = array_merge($nestedTypes, $fieldArgTypes); + $nestedTypes[] = $field->getType(); + } + } + foreach ($nestedTypes as $type) { + $this->_extractTypes($type, $map); + } + return $map; + } + + public function getType($name) + { + $map = $this->getTypeMap(); + return isset($map[$name]) ? $map[$name] : null; + } +} diff --git a/src/Type/Definition/AbstractType.php b/src/Type/Definition/AbstractType.php new file mode 100644 index 0000000..af5a55b --- /dev/null +++ b/src/Type/Definition/AbstractType.php @@ -0,0 +1,16 @@ + + */ + public function getPossibleTypes(); +} diff --git a/src/Type/Definition/BooleanType.php b/src/Type/Definition/BooleanType.php new file mode 100644 index 0000000..d3b2676 --- /dev/null +++ b/src/Type/Definition/BooleanType.php @@ -0,0 +1,22 @@ +value; + } + return null; + } +} diff --git a/src/Type/Definition/CompositeType.php b/src/Type/Definition/CompositeType.php new file mode 100644 index 0000000..585b36d --- /dev/null +++ b/src/Type/Definition/CompositeType.php @@ -0,0 +1,13 @@ +isMap = true; + $tmp->definition = $definition; + $tmp->flags = $flags; + return $tmp; + } + + /** + * @param array|int $definition + * @param int $flags + * @return \stdClass + */ + public static function arrayOf($definition, $flags = 0) + { + $tmp = new \stdClass(); + $tmp->isArray = true; + $tmp->definition = $definition; + $tmp->flags = (int) $flags; + return $tmp; + } + + private static function _validateMap(array $map, array $definitions, $pathStr = null) + { + $suffix = $pathStr ? " at $pathStr" : ''; + + // Make sure there are no unexpected keys in map + $unexpectedKeys = array_keys(array_diff_key($map, $definitions)); + Utils::invariant(empty($unexpectedKeys), 'Unexpected keys "%s" ' . $suffix, implode(', ', $unexpectedKeys)); + + // Make sure that all required keys are present in map + $requiredKeys = array_filter($definitions, function($def) {return (self::_getFlags($def) & self::REQUIRED) > 0;}); + $missingKeys = array_keys(array_diff_key($requiredKeys, $map)); + Utils::invariant(empty($missingKeys), 'Required keys missing: "%s"' . $suffix, implode(', ', $missingKeys)); + + // Make sure that every map value is valid given the definition + foreach ($map as $key => $value) { + self::_validateEntry($key, $value, $definitions[$key], $pathStr ? "$pathStr:$key" : $key); + } + } + + private static function _validateEntry($key, $value, $def, $pathStr) + { + $type = Utils::getVariableType($value); + $err = 'Expecting %s at "' . $pathStr . '", but got "' . $type . '"'; + + if ($def instanceof \stdClass) { + if ($def->flags & self::REQUIRED === 0 && $value === null) { + return ; + } + Utils::invariant(is_array($value), $err, 'array'); + + if (!empty($def->isMap)) { + if ($def->flags & self::KEY_AS_NAME) { + $value += ['name' => $key]; + } + self::_validateMap($value, $def->definition, $pathStr); + } else if (!empty($def->isArray)) { + + if ($def->flags & self::REQUIRED) { + Utils::invariant(!empty($value), "Value at '$pathStr' cannot be empty array"); + } + + $err = "Each entry at '$pathStr' must be an array, but '%s' is '%s'"; + + foreach ($value as $arrKey => $arrValue) { + if (is_array($def->definition)) { + Utils::invariant(is_array($arrValue), $err, $arrKey, Utils::getVariableType($arrValue)); + + if ($def->flags & self::KEY_AS_NAME) { + $arrValue += ['name' => $arrKey]; + } + self::_validateMap($arrValue, $def->definition, "$pathStr:$arrKey"); + } else { + self::_validateEntry($arrKey, $arrValue, $def->definition, "$pathStr:$arrKey"); + } + } + } else { + throw new \Exception("Unexpected definition: " . print_r($def, true)); + } + } else { + Utils::invariant(is_int($def), "Definition for '$pathStr' is expected to be single integer value"); + + if ($def & self::REQUIRED) { + Utils::invariant($value !== null, 'Value at "%s" can not be null', $pathStr); + } + + switch (true) { + case $def & self::ANY: + break; + case $def & self::BOOLEAN: + Utils::invariant(is_bool($value), $err, 'boolean'); + break; + case $def & self::STRING: + Utils::invariant(is_string($value), $err, 'string'); + break; + case $def & self::NUMERIC: + Utils::invariant(is_numeric($value), $err, 'numeric'); + break; + case $def & self::FLOAT: + Utils::invariant(is_float($value), $err, 'float'); + break; + case $def & self::INT: + Utils::invariant(is_int($value), $err, 'int'); + break; + case $def & self::CALLBACK: + Utils::invariant(is_callable($value), $err, 'callable'); + break; + case $def & self::SCALAR: + Utils::invariant(is_scalar($value), $err, 'scalar'); + break; + case $def & self::INPUT_TYPE: + Utils::invariant( + is_callable($value) || $value instanceof InputType, + $err, + 'callable or instance of GraphQL\Type\Definition\InputType' + ); + break; + case $def & self::OUTPUT_TYPE: + Utils::invariant( + is_callable($value) || $value instanceof OutputType, + $err, + 'callable or instance of GraphQL\Type\Definition\OutputType' + ); + break; + case $def & self::INTERFACE_TYPE: + Utils::invariant( + is_callable($value) || $value instanceof InterfaceType, + $err, + 'callable or instance of GraphQL\Type\Definition\InterfaceType' + ); + break; + case $def & self::OBJECT_TYPE: + Utils::invariant( + is_callable($value) || $value instanceof ObjectType, + $err, + 'callable or instance of GraphQL\Type\Definition\ObjectType' + ); + break; + default: + throw new \Exception("Unexpected validation rule: " . $def); + } + } + } + + private static function _getFlags($def) + { + return is_object($def) ? $def->flags : $def; + } +} \ No newline at end of file diff --git a/src/Type/Definition/Directive.php b/src/Type/Definition/Directive.php new file mode 100644 index 0000000..9545b9b --- /dev/null +++ b/src/Type/Definition/Directive.php @@ -0,0 +1,87 @@ + new self([ + 'name' => 'if', + 'description' => 'Directs the executor to omit this field if the argument provided is false.', + 'type' => Type::nonNull(Type::boolean()), + 'onOperation' => false, + 'onFragment' => false, + 'onField' => true + ]), + 'unless' => new self([ + 'name' => 'unless', + 'description' => 'Directs the executor to omit this field if the argument provided is true.', + 'type' => Type::nonNull(Type::boolean()), + 'onOperation' => false, + 'onFragment' => false, + 'onField' => true + ]) + ]; + } + return self::$internalDirectives; + } + + /** + * @var string + */ + public $name; + + /** + * @var string|null + */ + public $description; + + /** + * @var Type + */ + public $type; + + /** + * @var boolean + */ + public $onOperation; + + /** + * @var boolean + */ + public $onFragment; + + /** + * @var boolean + */ + public $onField; + + public function __construct(array $config) + { + foreach ($config as $key => $value) { + $this->{$key} = $value; + } + } +} diff --git a/src/Type/Definition/EnumType.php b/src/Type/Definition/EnumType.php new file mode 100644 index 0000000..0d7a8c8 --- /dev/null +++ b/src/Type/Definition/EnumType.php @@ -0,0 +1,107 @@ + + */ + private $_values; + + /** + * @var \ArrayObject + */ + private $_valueLookup; + + /** + * @var \ArrayObject + */ + private $_nameLookup; + + public function __construct($config) + { + Config::validate($config, [ + 'name' => Config::STRING | Config::REQUIRED, + 'values' => Config::arrayOf([ + 'name' => Config::STRING | Config::REQUIRED, + 'value' => Config::ANY, + 'deprecationReason' => Config::STRING, + 'description' => Config::STRING + ], Config::KEY_AS_NAME), + 'description' => Config::STRING + ]); + + $this->name = $config['name']; + $this->description = isset($config['description']) ? $config['description'] : null; + $this->_values = []; + + if (!empty($config['values'])) { + foreach ($config['values'] as $name => $value) { + $this->_values[] = Utils::assign(new EnumValueDefinition(), $value + ['name' => $name]); + } + } + } + + /** + * @return array + */ + public function getValues() + { + return $this->_values; + } + + public function coerce($value) + { + $enumValue = $this->_getValueLookup()->offsetGet($value); + return $enumValue ? $enumValue->name : null; + } + + public function coerceLiteral($value) + { + if ($value instanceof EnumValue) { + $lookup = $this->_getNameLookup(); + if (isset($lookup[$value->value])) { + $enumValue = $lookup[$value->value]; + if ($enumValue) { + return $enumValue->value; + } + } + } + return null; + } + + /** + * @todo Value lookup for any type, not just scalars + * @return \ArrayObject + */ + protected function _getValueLookup() + { + if (null === $this->_valueLookup) { + $this->_valueLookup = new \ArrayObject(); + + foreach ($this->getValues() as $valueName => $value) { + $this->_valueLookup[$value->value] = $value; + } + } + + return $this->_valueLookup; + } + + /** + * @return \ArrayObject + */ + protected function _getNameLookup() + { + if (!$this->_nameLookup) { + $lookup = new \ArrayObject(); + foreach ($this->getValues() as $value) { + $lookup[$value->name] = $value; + } + $this->_nameLookup = $lookup; + } + return $this->_nameLookup; + } +} diff --git a/src/Type/Definition/EnumValueDefinition.php b/src/Type/Definition/EnumValueDefinition.php new file mode 100644 index 0000000..7bf9ff9 --- /dev/null +++ b/src/Type/Definition/EnumValueDefinition.php @@ -0,0 +1,25 @@ + $argConfig) { + $map[] = new self($argConfig + ['name' => $name]); + } + return $map; + } + + public function __construct(array $def) + { + foreach ($def as $key => $value) { + $this->{$key} = $value; + } + } + + public function getType() + { + if (null === $this->resolvedType) { + $this->resolvedType = Type::resolve($this->type); + } + return $this->resolvedType; + } +} diff --git a/src/Type/Definition/FieldDefinition.php b/src/Type/Definition/FieldDefinition.php new file mode 100644 index 0000000..dd76eb6 --- /dev/null +++ b/src/Type/Definition/FieldDefinition.php @@ -0,0 +1,111 @@ + + */ + public $args; + + /** + * source?: any, + * args?: ?{[argName: string]: any}, + * context?: any, + * fieldAST?: any, + * fieldType?: any, + * parentType?: any, + * schema?: GraphQLSchema + * + * @var callable + */ + public $resolve; + + /** + * @var string|null + */ + public $description; + + /** + * @var string|null + */ + public $deprecationReason; + + private static $def; + + public static function getDefinition() + { + return self::$def ?: (self::$def = [ + 'name' => Config::STRING | Config::REQUIRED, + 'type' => Config::OUTPUT_TYPE | Config::REQUIRED, + 'args' => Config::arrayOf([ + 'name' => Config::STRING | Config::REQUIRED, + 'type' => Config::INPUT_TYPE | Config::REQUIRED, + 'defaultValue' => Config::ANY + ], Config::KEY_AS_NAME), + 'resolve' => Config::CALLBACK, + 'description' => Config::STRING, + 'deprecationReason' => Config::STRING, + ]); + } + + /** + * @param array|Config $fields + * @return array + */ + public static function createMap(array $fields) + { + $map = []; + foreach ($fields as $name => $field) { + if (!isset($field['name'])) { + $field['name'] = $name; + } + $map[$name] = self::create($field); + } + return $map; + } + + /** + * @param array|Config $field + * @return FieldDefinition + */ + public static function create($field) + { + Config::validate($field, self::getDefinition()); + return new self($field); + } + + protected function __construct(array $config) + { + $this->name = $config['name']; + $this->type = $config['type']; + $this->resolve = isset($config['resolve']) ? $config['resolve'] : null; + $this->args = isset($config['args']) ? FieldArgument::createMap($config['args']) : []; + + $this->description = isset($config['description']) ? $config['description'] : null; + $this->deprecationReason = isset($config['deprecationReason']) ? $config['deprecationReason'] : null; + } + + /** + * @return Type + */ + public function getType() + { + if (null === $this->resolvedType) { + $this->resolvedType = Type::resolve($this->type); + } + return $this->resolvedType; + } +} diff --git a/src/Type/Definition/FloatType.php b/src/Type/Definition/FloatType.php new file mode 100644 index 0000000..30b204a --- /dev/null +++ b/src/Type/Definition/FloatType.php @@ -0,0 +1,23 @@ +value; + } + return null; + } +} diff --git a/src/Type/Definition/IDType.php b/src/Type/Definition/IDType.php new file mode 100644 index 0000000..d8947fb --- /dev/null +++ b/src/Type/Definition/IDType.php @@ -0,0 +1,23 @@ +value; + } + return null; + } +} diff --git a/src/Type/Definition/InputObjectField.php b/src/Type/Definition/InputObjectField.php new file mode 100644 index 0000000..cdcf68c --- /dev/null +++ b/src/Type/Definition/InputObjectField.php @@ -0,0 +1,39 @@ + $v) { + $this->{$k} = $v; + } + } + + public function getType() + { + return Type::resolve($this->type); + } +} diff --git a/src/Type/Definition/InputObjectType.php b/src/Type/Definition/InputObjectType.php new file mode 100644 index 0000000..e85d766 --- /dev/null +++ b/src/Type/Definition/InputObjectType.php @@ -0,0 +1,41 @@ + + */ + private $_fields = []; + + public function __construct(array $config) + { + Config::validate($config, [ + 'name' => Config::STRING | Config::REQUIRED, + 'fields' => Config::arrayOf([ + 'name' => Config::STRING | Config::REQUIRED, + 'type' => Config::INPUT_TYPE | Config::REQUIRED, + 'defaultValue' => Config::ANY, + 'description' => Config::STRING + ], Config::KEY_AS_NAME), + 'description' => Config::STRING + ]); + + if (!empty($config['fields'])) { + foreach ($config['fields'] as $name => $field) { + $this->_fields[$name] = new InputObjectField($field + ['name' => $name]); + } + } + + $this->name = $config['name']; + $this->description = isset($config['description']) ? $config['description'] : null; + } + + /** + * @return array + */ + public function getFields() + { + return $this->_fields; + } +} diff --git a/src/Type/Definition/InputType.php b/src/Type/Definition/InputType.php new file mode 100644 index 0000000..3bd0031 --- /dev/null +++ b/src/Type/Definition/InputType.php @@ -0,0 +1,14 @@ += -1 * PHP_INT_MAX) { + return (int) $value; + } + return null; + } + + public function coerceLiteral($ast) + { + if ($ast instanceof IntValue) { + $val = (int) $ast->value; + if ($ast->value === (string) $val) { + return $val; + } + } + return null; + } +} diff --git a/src/Type/Definition/InterfaceType.php b/src/Type/Definition/InterfaceType.php new file mode 100644 index 0000000..5f15396 --- /dev/null +++ b/src/Type/Definition/InterfaceType.php @@ -0,0 +1,108 @@ + + */ + private $_fields; + + public $description; + + /** + * @var array + */ + private $_implementations = []; + + /** + * @var {[typeName: string]: boolean} + */ + private $_possibleTypeNames; + + /** + * @var callback + */ + private $_resolveType; + + /** + * Update the interfaces to know about this implementation. + * This is an rare and unfortunate use of mutation in the type definition + * implementations, but avoids an expensive "getPossibleTypes" + * implementation for Interface types. + * + * @param ObjectType $impl + * @param array $interfaces + */ + public static function addImplementationToInterfaces(ObjectType $impl, array $interfaces) + { + foreach ($interfaces as $interface) { + $interface->_implementations[] = $impl; + } + } + + public function __construct(array $config) + { + Config::validate($config, [ + 'name' => Config::STRING, + 'fields' => Config::arrayOf( + FieldDefinition::getDefinition(), + Config::KEY_AS_NAME + ), + 'resolveType' => Config::CALLBACK, + 'description' => Config::STRING + ]); + + $this->name = $config['name']; + $this->description = isset($config['description']) ? $config['description'] : null; + $this->_fields = !empty($config['fields']) ? FieldDefinition::createMap($config['fields']) : []; + $this->_resolveType = isset($config['resolveType']) ? $config['resolveType'] : null; + } + + /** + * @return array + */ + public function getFields() + { + return $this->_fields; + } + + public function getField($name) + { + Utils::invariant(isset($this->_fields[$name]), 'Field "%s" is not defined for type "%s"', $name, $this->name); + return $this->_fields[$name]; + } + + /** + * @return array + */ + public function getPossibleTypes() + { + return $this->_implementations; + } + + public function isPossibleType(ObjectType $type) + { + $possibleTypeNames = $this->_possibleTypeNames; + if (!$possibleTypeNames) { + $this->_possibleTypeNames = $possibleTypeNames = array_reduce($this->getPossibleTypes(), function(&$map, Type $possibleType) { + $map[$possibleType->name] = true; + return $map; + }, []); + } + return !empty($possibleTypeNames[$type->name]); + } + + /** + * @param $value + * @return ObjectType|null + */ + public function resolveType($value) + { + $resolver = $this->_resolveType; + return $resolver ? call_user_func($resolver, $value) : Type::getTypeOf($value, $this); + } +} diff --git a/src/Type/Definition/LeafType.php b/src/Type/Definition/LeafType.php new file mode 100644 index 0000000..d037347 --- /dev/null +++ b/src/Type/Definition/LeafType.php @@ -0,0 +1,12 @@ +ofType = $type; + } + + /** + * @return string + */ + public function toString() + { + $str = $this->ofType instanceof Type ? $this->ofType->toString() : (string) $this->ofType; + return '[' . $str . ']'; + } + + /** + * @return Type + */ + public function getWrappedType() + { + return Type::resolve($this->ofType); + } +} diff --git a/src/Type/Definition/NonNull.php b/src/Type/Definition/NonNull.php new file mode 100644 index 0000000..75bd561 --- /dev/null +++ b/src/Type/Definition/NonNull.php @@ -0,0 +1,53 @@ +ofType = $type; + } + + /** + * @return Type|callable + */ + public function getWrappedType() + { + $type = Type::resolve($this->ofType); + + Utils::invariant( + !($type instanceof NonNull), + 'Cannot nest NonNull inside NonNull' + ); + + return $type; + } + + /** + * @return string + */ + public function toString() + { + return $this->getWrappedType()->toString() . '!'; + } +} diff --git a/src/Type/Definition/ObjectType.php b/src/Type/Definition/ObjectType.php new file mode 100644 index 0000000..acf8607 --- /dev/null +++ b/src/Type/Definition/ObjectType.php @@ -0,0 +1,125 @@ + ({ + * name: { type: GraphQLString }, + * bestFriend: { type: PersonType }, + * }) + * }); + * + */ +class ObjectType extends Type implements OutputType, CompositeType +{ + /** + * @var array + */ + private $_fields = []; + + /** + * @var array + */ + private $_interfaces; + + /** + * @var callable + */ + private $_isTypeOf; + + public function __construct(array $config) + { + Config::validate($config, [ + 'name' => Config::STRING | Config::REQUIRED, + 'fields' => Config::arrayOf( + FieldDefinition::getDefinition(), + Config::KEY_AS_NAME + ), + 'description' => Config::STRING, + 'interfaces' => Config::arrayOf( + Config::INTERFACE_TYPE + ), + 'isTypeOf' => Config::CALLBACK, + ]); + + $this->name = $config['name']; + $this->description = isset($config['description']) ? $config['description'] : null; + + if (isset($config['fields'])) { + $this->_fields = FieldDefinition::createMap($config['fields']); + } + + $this->_interfaces = isset($config['interfaces']) ? $config['interfaces'] : []; + $this->_isTypeOf = isset($config['isTypeOf']) ? $config['isTypeOf'] : null; + + if (!empty($this->_interfaces)) { + InterfaceType::addImplementationToInterfaces($this, $this->_interfaces); + } + } + + /** + * @return array + */ + public function getFields() + { + return $this->_fields; + } + + /** + * @param string $name + * @return FieldDefinition + * @throws \Exception + */ + public function getField($name) + { + Utils::invariant(isset($this->_fields[$name]), "Field '%s' is not defined for type '%s'", $name, $this->name); + return $this->_fields[$name]; + } + + /** + * @return array + */ + public function getInterfaces() + { + return $this->_interfaces; + } + + /** + * @param $value + * @return bool|null + */ + public function isTypeOf($value) + { + return isset($this->_isTypeOf) ? call_user_func($this->_isTypeOf, $value) : null; + } +} diff --git a/src/Type/Definition/OutputType.php b/src/Type/Definition/OutputType.php new file mode 100644 index 0000000..c9ee6a0 --- /dev/null +++ b/src/Type/Definition/OutputType.php @@ -0,0 +1,16 @@ +name, 'Type must be named.'); + } + + abstract public function coerce($value); + + abstract public function coerceLiteral($ast); +} diff --git a/src/Type/Definition/ScalarTypeConfig.php b/src/Type/Definition/ScalarTypeConfig.php new file mode 100644 index 0000000..eb5255d --- /dev/null +++ b/src/Type/Definition/ScalarTypeConfig.php @@ -0,0 +1,26 @@ +value; + } + return null; + } +} diff --git a/src/Type/Definition/Type.php b/src/Type/Definition/Type.php new file mode 100644 index 0000000..2bf188d --- /dev/null +++ b/src/Type/Definition/Type.php @@ -0,0 +1,247 @@ + new IDType(), + self::STRING => new StringType(), + self::FLOAT => new FloatType(), + self::INT => new IntType(), + self::BOOLEAN => new BooleanType() + ]; + } + return $name ? self::$internalTypes[$name] : self::$internalTypes; + } + + /** + * @return Type + */ + public static function getInternalTypes() + { + return self::getInternalType(); + } + + /** + * @param $type + * @return bool + */ + public static function isInputType($type) + { + $nakedType = self::getUnmodifiedType($type); + return $nakedType instanceof InputType; + } + + /** + * @param $type + * @return bool + */ + public static function isOutputType($type) + { + $nakedType = self::getUnmodifiedType($type); + return $nakedType instanceof OutputType; + } + + public static function isLeafType($type) + { + $nakedType = self::getUnmodifiedType($type); + return ( + $nakedType instanceof ScalarType || + $nakedType instanceof EnumType + ); + } + + public static function isCompositeType($type) + { + return ( + $type instanceof ObjectType || + $type instanceof InterfaceType || + $type instanceof UnionType + ); + } + + public static function isAbstractType($type) + { + return ( + $type instanceof InterfaceType || + $type instanceof UnionType + ); + } + + /** + * @param $type + * @return Type + */ + public static function getNullableType($type) + { + return $type instanceof NonNull ? $type->getWrappedType() : $type; + } + + /** + * @param $type + * @return UnmodifiedType + */ + public static function getUnmodifiedType($type) + { + if (null === $type) { + return null; + } + while ($type instanceof WrappingType) { + $type = $type->getWrappedType(); + } + return self::resolve($type); + } + + public static function resolve($type) + { + if (is_callable($type)) { + $type = $type(); + } + + Utils::invariant( + $type instanceof Type, + 'Expecting instance of ' . __CLASS__ . ' (or callable returning instance of that type), got "%s"', + Utils::getVariableType($type) + ); + return $type; + } + + /** + * @param $value + * @param AbstractType $abstractType + * @return Type + * @throws \Exception + */ + public static function getTypeOf($value, AbstractType $abstractType) + { + $possibleTypes = $abstractType->getPossibleTypes(); + + for ($i = 0; $i < count($possibleTypes); $i++) { + /** @var ObjectType $type */ + $type = $possibleTypes[$i]; + $isTypeOf = $type->isTypeOf($value); + + if ($isTypeOf === null) { + // TODO: move this to a JS impl specific type system validation step + // so the error can be found before execution. + throw new \Exception( + 'Non-Object Type ' . $abstractType->name . ' does not implement ' . + 'resolveType and Object Type ' . $type->name . ' does not implement ' . + 'isTypeOf. There is no way to determine if a value is of this type.' + ); + } + + if ($isTypeOf) { + return $type; + } + } + return null; + } + + /** + * @var string + */ + public $name; + + /** + * @var string|null + */ + public $description; + + public function toString() + { + return $this->name; + } + + public function __toString() + { + try { + return $this->toString(); + } catch (\Exception $e) { + echo $e; + } + } +} diff --git a/src/Type/Definition/UnionType.php b/src/Type/Definition/UnionType.php new file mode 100644 index 0000000..529b991 --- /dev/null +++ b/src/Type/Definition/UnionType.php @@ -0,0 +1,81 @@ + + */ + private $_types; + + /** + * @var array + */ + private $_possibleTypeNames; + + /** + * @var callback + */ + private $_resolveType; + + public function __construct($config) + { + Config::validate($config, [ + 'name' => Config::STRING | Config::REQUIRED, + 'types' => Config::arrayOf(Config::OBJECT_TYPE | Config::REQUIRED), + 'resolveType' => Config::CALLBACK, + 'description' => Config::STRING + ]); + + Utils::invariant(!empty($config['types']), ""); + + /** + * Optionally provide a custom type resolver function. If one is not provided, + * the default implemenation will call `isTypeOf` on each implementing + * Object type. + */ + $this->name = $config['name']; + $this->description = isset($config['description']) ? $config['description'] : null; + $this->_types = $config['types']; + $this->_resolveType = isset($config['resolveType']) ? $config['resolveType'] : null; + } + + /** + * @return array + */ + public function getPossibleTypes() + { + return $this->_types; + } + + /** + * @param Type $type + * @return mixed + */ + public function isPossibleType(Type $type) + { + if (!$type instanceof ObjectType) { + return false; + } + + if (null === $this->_possibleTypeNames) { + $this->_possibleTypeNames = []; + foreach ($this->getPossibleTypes() as $possibleType) { + $this->_possibleTypeNames[$possibleType->name] = true; + } + } + return $this->_possibleTypeNames[$type->name] === true; + } + + /** + * @param ObjectType $value + * @return Type + */ + public function resolveType($value) + { + $resolver = $this->_resolveType; + return $resolver ? call_user_func($resolver, $value) : Type::getTypeOf($value, $this); + } +} diff --git a/src/Type/Definition/UnmodifiedType.php b/src/Type/Definition/UnmodifiedType.php new file mode 100644 index 0000000..b13d9b2 --- /dev/null +++ b/src/Type/Definition/UnmodifiedType.php @@ -0,0 +1,16 @@ + '__Schema', + 'description' => + 'A GraphQL Schema defines the capabilities of a GraphQL ' . + 'server. It exposes all available types and directives on ' . + 'the server, as well as the entry points for query and ' . + 'mutation operations.', + 'fields' => [ + 'types' => [ + 'description' => 'A list of all types supported by this server.', + 'type' => new NonNull(new ListOfType(new NonNull(self::_type()))), + 'resolve' => function (Schema $schema) { + return array_values($schema->getTypeMap()); + } + ], + 'queryType' => [ + 'description' => 'The type that query operations will be rooted at.', + 'type' => new NonNull(self::_type()), + 'resolve' => function (Schema $schema) { + return $schema->getQueryType(); + } + ], + 'mutationType' => [ + 'description' => + 'If this server supports mutation, the type that ' . + 'mutation operations will be rooted at.', + 'type' => self::_type(), + 'resolve' => function (Schema $schema) { + return $schema->getMutationType(); + } + ], + 'directives' => [ + 'description' => 'A list of all directives supported by this server.', + 'type' => Type::nonNull(Type::listOf(Type::nonNull(self::_directive()))), + 'resolve' => function(Schema $schema) { + return $schema->getDirectives(); + } + ] + ] + ]); + } + return self::$_map['__Schema']; + } + + public static function _directive() + { + if (!isset(self::$_map['__Directive'])) { + self::$_map['__Directive'] = new ObjectType([ + 'name' => '__Directive', + 'fields' => [ + 'name' => ['type' => Type::string()], + 'description' => ['type' => Type::string()], + 'type' => ['type' => [__CLASS__, '_type']], + 'onOperation' => ['type' => Type::boolean()], + 'onFragment' => ['type' => Type::boolean()], + 'onField' => ['type' => Type::boolean()] + ] + ]); + } + return self::$_map['__Directive']; + } + + public static function _type() + { + if (!isset(self::$_map['__Type'])) { + self::$_map['__Type'] = new ObjectType([ + 'name' => '__Type', + 'fields' => [ + 'kind' => [ + 'type' => Type::nonNull(self::_typeKind()), + 'resolve' => function (Type $type) { + switch (true) { + case $type instanceof ListOfType: + return TypeKind::LIST_KIND; + case $type instanceof NonNull: + return TypeKind::NON_NULL; + case $type instanceof ScalarType: + return TypeKind::SCALAR; + case $type instanceof ObjectType: + return TypeKind::OBJECT; + case $type instanceof EnumType: + return TypeKind::ENUM; + case $type instanceof InputObjectType: + return TypeKind::INPUT_OBJECT; + case $type instanceof InterfaceType: + return TypeKind::INTERFACE_KIND; + case $type instanceof UnionType: + return TypeKind::UNION; + default: + throw new \Exception("Unknown kind of type: " . print_r($type, true)); + } + } + ], + 'name' => ['type' => Type::string()], + 'description' => ['type' => Type::string()], + 'fields' => [ + 'type' => Type::listOf(Type::nonNull(self::_field())), + 'args' => [ + 'includeDeprecated' => ['type' => Type::boolean(), 'defaultValue' => false] + ], + 'resolve' => function (Type $type, $args) { + if ($type instanceof ObjectType || $type instanceof InterfaceType) { + $fields = $type->getFields(); + + if (empty($args['includeDeprecated'])) { + $fields = array_filter($fields, function (FieldDefinition $field) { + return !$field->deprecationReason; + }); + } + return array_values($fields); + } + return null; + } + ], + 'interfaces' => [ + 'type' => Type::listOf(Type::nonNull([__CLASS__, '_type'])), + 'resolve' => function ($type) { + if ($type instanceof ObjectType) { + return $type->getInterfaces(); + } + return null; + } + ], + 'possibleTypes' => [ + 'type' => Type::listOf(Type::nonNull([__CLASS__, '_type'])), + 'resolve' => function ($type) { + if ($type instanceof InterfaceType || $type instanceof UnionType) { + return $type->getPossibleTypes(); + } + return null; + } + ], + 'enumValues' => [ + 'type' => Type::listOf(Type::nonNull(self::_enumValue())), + 'args' => [ + 'includeDeprecated' => ['type' => Type::boolean(), 'defaultValue' => false] + ], + 'resolve' => function ($type, $args) { + if ($type instanceof EnumType) { + $values = array_values($type->getValues()); + + if (empty($args['includeDeprecated'])) { + $values = array_filter($values, function ($value) { + return !$value->deprecationReason; + }); + } + + return $values; + } + return null; + } + ], + 'inputFields' => [ + 'type' => Type::listOf(Type::nonNull(self::_inputValue())), + 'resolve' => function ($type) { + if ($type instanceof InputObjectType) { + return array_values($type->getFields()); + } + return null; + } + ], + 'ofType' => [ + 'type' => [__CLASS__, '_type'], + 'resolve' => function($type) { + if ($type instanceof WrappingType) { + return $type->getWrappedType(); + } + return null; + }] + ] + ]); + } + return self::$_map['__Type']; + } + + public static function _field() + { + if (!isset(self::$_map['__Field'])) { + + self::$_map['__Field'] = new ObjectType([ + 'name' => '__Field', + 'fields' => [ + 'name' => ['type' => Type::nonNull(Type::string())], + 'description' => ['type' => Type::string()], + 'args' => [ + 'type' => Type::nonNull(Type::listOf(Type::nonNull(self::_inputValue()))), + 'resolve' => function (FieldDefinition $field) { + return empty($field->args) ? [] : $field->args; + } + ], + 'type' => [ + 'type' => Type::nonNull([__CLASS__, '_type']), + 'resolve' => function ($field) { + return $field->getType(); + } + ], + 'isDeprecated' => [ + 'type' => Type::nonNull(Type::boolean()), + 'resolve' => function (FieldDefinition $field) { + return !!$field->deprecationReason; + } + ], + 'deprecationReason' => [ + 'type' => Type::string() + ] + ] + ]); + } + return self::$_map['__Field']; + } + + public static function _inputValue() + { + if (!isset(self::$_map['__InputValue'])) { + self::$_map['__InputValue'] = new ObjectType([ + 'name' => '__InputValue', + 'fields' => [ + 'name' => ['type' => Type::nonNull(Type::string())], + 'description' => ['type' => Type::string()], + 'type' => [ + 'type' => Type::nonNull([__CLASS__, '_type']), + 'resolve' => function($value) { + return method_exists($value, 'getType') ? $value->getType() : $value->type; + } + ], + 'defaultValue' => [ + 'type' => Type::string(), + 'resolve' => function ($inputValue) { + return $inputValue->defaultValue === null ? null : json_encode($inputValue->defaultValue); + } + ] + ] + ]); + } + return self::$_map['__InputValue']; + } + + public static function _enumValue() + { + if (!isset(self::$_map['__EnumValue'])) { + self::$_map['__EnumValue'] = new ObjectType([ + 'name' => '__EnumValue', + 'fields' => [ + 'name' => ['type' => Type::nonNull(Type::string())], + 'description' => ['type' => Type::string()], + 'isDeprecated' => [ + 'type' => Type::nonNull(Type::boolean()), + 'resolve' => function ($enumValue) { + return !!$enumValue->deprecationReason; + } + ], + 'deprecationReason' => [ + 'type' => Type::string() + ] + ] + ]); + } + return self::$_map['__EnumValue']; + } + + public static function _typeKind() + { + if (!isset(self::$_map['__TypeKind'])) { + self::$_map['__TypeKind'] = new EnumType([ + 'name' => '__TypeKind', + 'description' => 'An enum describing what kind of type a given __Type is', + 'values' => [ + 'SCALAR' => [ + 'value' => TypeKind::SCALAR, + 'description' => 'Indicates this type is a scalar.' + ], + 'OBJECT' => [ + 'value' => TypeKind::OBJECT, + 'description' => 'Indicates this type is an object. `fields` and `interfaces` are valid fields.' + ], + 'INTERFACE' => [ + 'value' => TypeKind::INTERFACE_KIND, + 'description' => 'Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.' + ], + 'UNION' => [ + 'value' => TypeKind::UNION, + 'description' => 'Indicates this type is a union. `possibleTypes` is a valid field.' + ], + 'ENUM' => [ + 'value' => TypeKind::ENUM, + 'description' => 'Indicates this type is an enum. `enumValues` is a valid field.' + ], + 'INPUT_OBJECT' => [ + 'value' => TypeKind::INPUT_OBJECT, + 'description' => 'Indicates this type is an input object. `inputFields` is a valid field.' + ], + 'LIST' => [ + 'value' => TypeKind::LIST_KIND, + 'description' => 'Indicates this type is a list. `ofType` is a valid field.' + ], + 'NON_NULL' => [ + 'value' => TypeKind::NON_NULL, + 'description' => 'Indicates this type is a non-null. `ofType` is a valid field.' + ] + ] + ]); + } + return self::$_map['__TypeKind']; + } + + public static function schemaMetaFieldDef() + { + if (!isset(self::$_map['__schema'])) { + self::$_map['__schema'] = FieldDefinition::create([ + 'name' => '__schema', + 'type' => Type::nonNull(self::_schema()), + 'description' => 'Access the current type schema of this server.', + 'args' => [], + 'resolve' => function ( + $source, + $args, + $root, + $fieldAST, + $fieldType, + $parentType, + $schema + ) { + // TODO: move 3+ args to separate object + return $schema; + } + ]); + } + return self::$_map['__schema']; + } + + public static function typeMetaFieldDef() + { + if (!isset(self::$_map['__type'])) { + self::$_map['__type'] = FieldDefinition::create([ + 'name' => '__type', + 'type' => self::_type(), + 'description' => 'Request the type information of a single type.', + 'args' => [ + ['name' => 'name', 'type' => Type::nonNull(Type::string())] + ], + 'resolve' => function ($source, $args, $root, $fieldAST, $fieldType, $parentType, $schema) { + return $schema->getType($args['name']); + } + ]); + } + return self::$_map['__type']; + } + + public static function typeNameMetaFieldDef() + { + if (!isset(self::$_map['__typename'])) { + self::$_map['__typename'] = FieldDefinition::create([ + 'name' => '__typename', + 'type' => Type::nonNull(Type::string()), + 'description' => 'The name of the current Object type at runtime.', + 'args' => [], + 'resolve' => function ( + $source, + $args, + $root, + $fieldAST, + $fieldType, + $parentType + ) { + return $parentType->name; + } + ]); + } + return self::$_map['__typename']; + } +} diff --git a/src/Type/SchemaValidator.php b/src/Type/SchemaValidator.php new file mode 100644 index 0000000..04131d0 --- /dev/null +++ b/src/Type/SchemaValidator.php @@ -0,0 +1,179 @@ +getTypeMap(); + $errors = []; + + $queryType = $schema->getQueryType(); + if ($queryType) { + $queryError = $operationMayNotBeInputType($queryType, 'query'); + if ($queryError !== null) { + $errors[] = $queryError; + } + } + + $mutationType = $schema->getMutationType(); + if ($mutationType) { + $mutationError = $operationMayNotBeInputType($mutationType, 'mutation'); + if ($mutationError !== null) { + $errors[] = $mutationError; + } + } + + foreach ($typeMap as $typeName => $type) { + if ($type instanceof ObjectType || $type instanceof InterfaceType) { + $fields = $type->getFields(); + foreach ($fields as $fieldName => $field) { + if ($field->getType() instanceof InputObjectType) { + $errors[] = new Error( + "Field $typeName.{$field->name} is of type " . + "{$field->getType()->name}, which is an input type, but field types " . + "must be output types!" + ); + } + } + } + } + + return !empty($errors) ? $errors : null; + }; + } + + public static function noOutputTypesAsInputArgsRule() + { + return function($context) { + /** @var Schema $schema */ + $schema = $context['schema']; + $typeMap = $schema->getTypeMap(); + $errors = []; + + foreach ($typeMap as $typeName => $type) { + if ($type instanceof InputObjectType) { + $fields = $type->getFields(); + + foreach ($fields as $fieldName => $field) { + if (!Type::isInputType($field->getType())) { + $errors[] = new Error( + "Input field {$type->name}.{$field->name} has type ". + "{$field->getType()}, which is not an input type!" + ); + } + } + } + } + return !empty($errors) ? $errors : null; + }; + } + + public static function interfacePossibleTypesMustImplementTheInterfaceRule() + { + return function($context) { + /** @var Schema $schema */ + $schema = $context['schema']; + $typeMap = $schema->getTypeMap(); + $errors = []; + + foreach ($typeMap as $typeName => $type) { + if ($type instanceof InterfaceType) { + $possibleTypes = $type->getPossibleTypes(); + foreach ($possibleTypes as $possibleType) { + if (!in_array($type, $possibleType->getInterfaces())) { + $errors[] = new Error( + "$possibleType is a possible type of interface $type but does " . + "not implement it!" + ); + } + } + } + } + + return !empty($errors) ? $errors : null; + }; + } + + public static function typesInterfacesMustShowThemAsPossibleRule() + { + return function($context) { + /** @var Schema $schema */ + $schema = $context['schema']; + $typeMap = $schema->getTypeMap(); + $errors = []; + + foreach ($typeMap as $typeName => $type) { + if ($type instanceof ObjectType) { + $interfaces = $type->getInterfaces(); + foreach ($interfaces as $interfaceType) { + if (!$interfaceType->isPossibleType($type)) { + $errors[] = new Error( + "$typeName implements interface {$interfaceType->name}, but " . + "{$interfaceType->name} does not list it as possible!" + ); + } + } + } + } + return !empty($errors) ? $errors : null; + }; + } + + /** + * @param Schema $schema + * @param array |null $argRules + * @return array + */ + public static function validate(Schema $schema, $argRules = null) + { + $context = ['schema' => $schema]; + $errors = []; + $rules = $argRules ?: self::getAllRules(); + + for ($i = 0; $i < count($rules); ++$i) { + $newErrors = call_user_func($rules[$i], $context); + if ($newErrors) { + $errors = array_merge($errors, $newErrors); + } + } + $isValid = empty($errors); + $result = [ + 'isValid' => $isValid, + 'errors' => $isValid ? null : array_map(['GraphQL\Error', 'formatError'], $errors) + ]; + return (object) $result; + } +} \ No newline at end of file diff --git a/src/Types.php b/src/Types.php new file mode 100644 index 0000000..da85983 --- /dev/null +++ b/src/Types.php @@ -0,0 +1,9 @@ + $value) { + if (!property_exists($obj, $key)) { + $cls = get_class($obj); + trigger_error("Trying to set non-existing property '$key' on class '$cls'"); + } + $obj->{$key} = $value; + } + return $obj; + } + + /** + * @param array $list + * @param $predicate + * @return null + */ + public static function find(array $list, $predicate) + { + for ($i = 0; $i < count($list); $i++) { + if ($predicate($list[$i])) { + return $list[$i]; + } + } + return null; + } + + /** + * @param $test + * @param string $message + * @param mixed $sprintfParam1 + * @param mixed $sprintfParam2 ... + * @throws \Exception + */ + public static function invariant($test, $message = '') + { + if (!$test) { + if (func_num_args() > 2) { + $args = func_get_args(); + array_shift($args); + $message = call_user_func_array('sprintf', $args); + } + throw new \Exception($message); + } + } + + /** + * @param $var + * @return string + */ + public static function getVariableType($var) + { + return is_object($var) ? get_class($var) : gettype($var); + } + + public static function chr($ord, $encoding = 'UTF-8') + { + if ($ord <= 255) { + return chr($ord); + } + if ($encoding === 'UCS-4BE') { + return pack("N", $ord); + } else { + return mb_convert_encoding(self::chr($ord, 'UCS-4BE'), $encoding, 'UCS-4BE'); + } + } + + /** + * UTF-8 compatible ord() + * + * @param $char + * @param string $encoding + * @return mixed + */ + public static function ord($char, $encoding = 'UTF-8') + { + if (!isset($char[1])) { + return ord($char); + } + if ($encoding === 'UCS-4BE') { + list(, $ord) = (strlen($char) === 4) ? @unpack('N', $char) : @unpack('n', $char); + return $ord; + } else { + return self::ord(mb_convert_encoding($char, 'UCS-4BE', $encoding), 'UCS-4BE'); + } + } + + public static function charCodeAt($string, $position) + { + $char = mb_substr($string, $position, 1, 'UTF-8'); + return self::ord($char); + } + + /** + * + */ + public static function keyMap(array $list, callable $keyFn) + { + $map = []; + foreach ($list as $value) { + $map[$keyFn($value)] = $value; + } + return $map; + } +} diff --git a/src/Utils/PairSet.php b/src/Utils/PairSet.php new file mode 100644 index 0000000..3d89561 --- /dev/null +++ b/src/Utils/PairSet.php @@ -0,0 +1,61 @@ +> + */ + private $_data; + + private $_wrappers = []; + + public function __construct() + { + $this->_data = new \SplObjectStorage(); // SplObject hash instead? + } + + public function has($a, $b) + { + $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; + } + + public function add($a, $b) + { + $this->_pairSetAdd($a, $b); + $this->_pairSetAdd($b, $a); + } + + private function _toObj($var) + { + // 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]; + } + + 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; + } +} diff --git a/src/Utils/TypeInfo.php b/src/Utils/TypeInfo.php new file mode 100644 index 0000000..7cce63f --- /dev/null +++ b/src/Utils/TypeInfo.php @@ -0,0 +1,269 @@ +type); + return $innerType ? new ListOfType($innerType) : null; + } + if ($inputTypeAst instanceof NonNullType) { + $innerType = self::typeFromAST($schema, $inputTypeAst->type); + return $innerType ? new NonNull($innerType) : null; + } + + Utils::invariant($inputTypeAst instanceof Name, 'Must be a type name'); + return $schema->getType($inputTypeAst->value); + } + + /** + * Not exactly the same as the executor's definition of getFieldDef, in this + * statically evaluated environment we do not always have an Object type, + * and need to handle Interface and Union types. + * + * @return FieldDefinition + */ + static private function _getFieldDef(Schema $schema, Type $parentType, Field $fieldAST) + { + $name = $fieldAST->name->value; + $schemaMeta = Introspection::schemaMetaFieldDef(); + if ($name === $schemaMeta->name && $schema->getQueryType() === $parentType) { + return $schemaMeta; + } + + $typeMeta = Introspection::typeMetaFieldDef(); + if ($name === $typeMeta->name && $schema->getQueryType() === $parentType) { + return $typeMeta; + } + $typeNameMeta = Introspection::typeNameMetaFieldDef(); + if ($name === $typeNameMeta->name && + ($parentType instanceof ObjectType || + $parentType instanceof InterfaceType || + $parentType instanceof UnionType) + ) { + return $typeNameMeta; + } + if ($parentType instanceof ObjectType || + $parentType instanceof InterfaceType) { + $fields = $parentType->getFields(); + return isset($fields[$name]) ? $fields[$name] : null; + } + return null; + } + + + /** + * @var Schema + */ + private $_schema; + + /** + * @var \SplStack + */ + private $_typeStack; + + /** + * @var \SplStack + */ + private $_parentTypeStack; + + /** + * @var \SplStack + */ + private $_inputTypeStack; + + /** + * @var \SplStack + */ + private $_fieldDefStack; + + + public function __construct(Schema $schema) + { + $this->_schema = $schema; + $this->_typeStack = []; + $this->_parentTypeStack = []; + $this->_inputTypeStack = []; + $this->_fieldDefStack = []; + } + + /** + * @return Type + */ + function getType() + { + if (!empty($this->_typeStack)) { + return $this->_typeStack[count($this->_typeStack) - 1]; + } + return null; + } + + /** + * @return Type + */ + function getParentType() + { + if (!empty($this->_parentTypeStack)) { + return $this->_parentTypeStack[count($this->_parentTypeStack) - 1]; + } + return null; + } + + /** + * @return InputType + */ + function getInputType() + { + if (!empty($this->_inputTypeStack)) { + return $this->_inputTypeStack[count($this->_inputTypeStack) - 1]; + } + return null; + } + + /** + * @return FieldDefinition + */ + function getFieldDef() + { + if (!empty($this->_fieldDefStack)) { + return $this->_fieldDefStack[count($this->_fieldDefStack) - 1]; + } + return null; + } + + + function enter(Node $node) + { + $schema = $this->_schema; + + switch ($node->kind) { + case Node::SELECTION_SET: + // var $compositeType: ?GraphQLCompositeType; + $rawType = Type::getUnmodifiedType($this->getType()); + $compositeType = null; + if (Type::isCompositeType($rawType)) { + // isCompositeType is a type refining predicate, so this is safe. + $compositeType = $rawType; + } + array_push($this->_parentTypeStack, $compositeType); + break; + + case Node::FIELD: + $parentType = $this->getParentType(); + $fieldDef = null; + if ($parentType) { + $fieldDef = self::_getFieldDef($schema, $parentType, $node); + } + array_push($this->_fieldDefStack, $fieldDef); + array_push($this->_typeStack, $fieldDef ? $fieldDef->getType() : null); + break; + + case Node::OPERATION_DEFINITION: + $type = null; + if ($node->operation === 'query') { + $type = $schema->getQueryType(); + } else if ($node->operation === 'mutation') { + $type = $schema->getMutationType(); + } + array_push($this->_typeStack, $type); + break; + + case Node::INLINE_FRAGMENT: + case Node::FRAGMENT_DEFINITION: + $type = $schema->getType($node->typeCondition->value); + array_push($this->_typeStack, $type); + break; + + case Node::VARIABLE_DEFINITION: + array_push($this->_inputTypeStack, self::typeFromAST($schema, $node->type)); + break; + + case Node::ARGUMENT: + $field = $this->getFieldDef(); + $argType = null; + if ($field) { + $argDef = Utils::find($field->args, function($arg) use ($node) {return $arg->name === $node->name->value;}); + if ($argDef) { + $argType = $argDef->getType(); + } + } + array_push($this->_inputTypeStack, $argType); + break; + + case Node::DIRECTIVE: + $directive = $schema->getDirective($node->name->value); + array_push($this->_inputTypeStack, $directive ? $directive->type : null); + break; + + case Node::ARR: + $arrayType = Type::getNullableType($this->getInputType()); + array_push( + $this->_inputTypeStack, + $arrayType instanceof ListOfType ? $arrayType->getWrappedType() : null + ); + break; + + case Node::OBJECT_FIELD: + $objectType = Type::getUnmodifiedType($this->getInputType()); + $fieldType = null; + if ($objectType instanceof InputObjectType) { + $tmp = $objectType->getFields(); + $inputField = isset($tmp[$node->name->value]) ? $tmp[$node->name->value] : null; + $fieldType = $inputField ? $inputField->getType() : null; + } + array_push($this->_inputTypeStack, $fieldType); + break; + } + } + + function leave(Node $node) + { + switch ($node->kind) { + case Node::SELECTION_SET: + array_pop($this->_parentTypeStack); + break; + case Node::FIELD: + array_pop($this->_fieldDefStack); + array_pop($this->_typeStack); + break; + case Node::OPERATION_DEFINITION: + case Node::INLINE_FRAGMENT: + case Node::FRAGMENT_DEFINITION: + array_pop($this->_typeStack); + break; + case Node::VARIABLE_DEFINITION: + case Node::ARGUMENT: + case Node::DIRECTIVE: + case Node::ARR: + case Node::OBJECT_FIELD: + array_pop($this->_inputTypeStack); + break; + } + } +} diff --git a/src/Validator/DocumentValidator.php b/src/Validator/DocumentValidator.php new file mode 100644 index 0000000..2aa9d43 --- /dev/null +++ b/src/Validator/DocumentValidator.php @@ -0,0 +1,318 @@ + $isValid, + 'errors' => $isValid ? null : array_map(['GraphQL\Error', 'formatError'], $errors) + ]; + return $result; + } + + static function isError($value) + { + return is_array($value) + ? count(array_filter($value, function($item) { return $item instanceof \Exception;})) === count($value) + : $value instanceof \Exception; + } + + static function append(&$arr, $items) + { + if (is_array($items)) { + $arr = array_merge($arr, $items); + } else { + $arr[] = $items; + } + return $arr; + } + + static function isValidLiteralValue($valueAST, Type $type) + { + // A value can only be not provided if the type is nullable. + if (!$valueAST) { + return !($type instanceof NonNull); + } + + // Unwrap non-null. + if ($type instanceof NonNull) { + return self::isValidLiteralValue($valueAST, $type->getWrappedType()); + } + + // This function only tests literals, and assumes variables will provide + // values of the correct type. + if ($valueAST instanceof Variable) { + return true; + } + + if (!$valueAST instanceof Value) { + return false; + } + + // Lists accept a non-list value as a list of one. + if ($type instanceof ListOfType) { + $itemType = $type->getWrappedType(); + if ($valueAST instanceof ArrayValue) { + foreach($valueAST->values as $itemAST) { + if (!self::isValidLiteralValue($itemAST, $itemType)) { + return false; + } + } + return true; + } else { + return self::isValidLiteralValue($valueAST, $itemType); + } + } + + // Scalar/Enum input checks to ensure the type can coerce the value to + // a non-null value. + if ($type instanceof ScalarType || $type instanceof EnumType) { + return $type->coerceLiteral($valueAST) !== null; + } + + // Input objects check each defined field, ensuring it is of the correct + // type and provided if non-nullable. + if ($type instanceof InputObjectType) { + $fields = $type->getFields(); + if ($valueAST->kind !== Node::OBJECT) { + return false; + } + $fieldASTs = $valueAST->fields; + $fieldASTMap = Utils::keyMap($fieldASTs, function($field) {return $field->name->value;}); + + foreach ($fields as $fieldKey => $field) { + $fieldName = $field->name ?: $fieldKey; + if (!isset($fieldASTMap[$fieldName]) && $field->getType() instanceof NonNull) { + // Required fields missing + return false; + } + } + foreach ($fieldASTs as $fieldAST) { + if (empty($fields[$fieldAST->name->value]) || !self::isValidLiteralValue($fieldAST->value, $fields[$fieldAST->name->value]->getType())) { + return false; + } + } + return true; + } + + // Any other kind of type is not an input type, and a literal cannot be used. + return false; + } + + /** + * This uses a specialized visitor which runs multiple visitors in parallel, + * while maintaining the visitor skip and break API. + * + * @param Schema $schema + * @param Document $documentAST + * @param array $rules + * @return array + */ + public static function visitUsingRules(Schema $schema, Document $documentAST, array $rules) + { + $typeInfo = new TypeInfo($schema); + $context = new ValidationContext($schema, $documentAST, $typeInfo); + $errors = []; + + // TODO: convert to class + $visitInstances = function($ast, $instances) use ($typeInfo, $context, &$errors, &$visitInstances) { + $skipUntil = new \SplFixedArray(count($instances)); + $skipCount = 0; + + Visitor::visit($ast, [ + 'enter' => function ($node, $key) use ($typeInfo, $instances, $skipUntil, &$skipCount, &$errors, $context, $visitInstances) { + $typeInfo->enter($node); + for ($i = 0; $i < count($instances); $i++) { + // Do not visit this instance if it returned false for a previous node + if ($skipUntil[$i]) { + continue; + } + + $result = null; + + // Do not visit top level fragment definitions if this instance will + // visit those fragments inline because it + // provided `visitSpreadFragments`. + if ($node->kind === Node::FRAGMENT_DEFINITION && $key !== null && !empty($instances[$i]['visitSpreadFragments'])) { + $result = Visitor::skipNode(); + } else { + $enter = Visitor::getVisitFn($instances[$i], false, $node->kind); + if ($enter instanceof \Closure) { + // $enter = $enter->bindTo($instances[$i]); + $result = call_user_func_array($enter, func_get_args()); + } else { + $result = null; + } + } + + if ($result instanceof VisitorOperation) { + if ($result->doContinue) { + $skipUntil[$i] = $node; + $skipCount++; + // If all instances are being skipped over, skip deeper traversal + if ($skipCount === count($instances)) { + for ($k = 0; $k < count($instances); $k++) { + if ($skipUntil[$k] === $node) { + $skipUntil[$k] = null; + $skipCount--; + } + } + return Visitor::skipNode(); + } + } else if ($result->doBreak) { + $instances[$i] = null; + } + } else if ($result && self::isError($result)) { + self::append($errors, $result); + for ($j = $i - 1; $j >= 0; $j--) { + $leaveFn = Visitor::getVisitFn($instances[$j], true, $node->kind); + if ($leaveFn) { + // $leaveFn = $leaveFn->bindTo($instances[$j]) + $result = call_user_func_array($leaveFn, func_get_args()); + + if ($result instanceof VisitorOperation) { + if ($result->doBreak) { + $instances[$j] = null; + } + } else if (self::isError($result)) { + self::append($errors, $result); + } else if ($result !== null) { + throw new \Exception("Config cannot edit document."); + } + } + } + $typeInfo->leave($node); + return Visitor::skipNode(); + } else if ($result !== null) { + throw new \Exception("Config cannot edit document."); + } + } + + // If any validation instances provide the flag `visitSpreadFragments` + // and this node is a fragment spread, validate the fragment from + // this point. + if ($node instanceof FragmentSpread) { + $fragment = $context->getFragment($node->name->value); + if ($fragment) { + $fragVisitingInstances = []; + foreach ($instances as $idx => $inst) { + if (!empty($inst['visitSpreadFragments']) && !$skipUntil[$idx]) { + $fragVisitingInstances[] = $inst; + } + } + if (!empty($fragVisitingInstances)) { + $visitInstances($fragment, $fragVisitingInstances); + } + } + } + }, + 'leave' => function ($node) use ($instances, $typeInfo, $skipUntil, &$skipCount, &$errors) { + for ($i = count($instances) - 1; $i >= 0; $i--) { + if ($skipUntil[$i]) { + if ($skipUntil[$i] === $node) { + $skipUntil[$i] = null; + $skipCount--; + } + continue; + } + $leaveFn = Visitor::getVisitFn($instances[$i], true, $node->kind); + + if ($leaveFn) { + // $leaveFn = $leaveFn.bindTo($instances[$i]); + $result = call_user_func_array($leaveFn, func_get_args()); + + if ($result instanceof VisitorOperation) { + if ($result->doBreak) { + $instances[$i] = null; + } + } if (self::isError($result)) { + self::append($errors, $result); + } else if ($result !== null) { + throw new \Exception("Config cannot edit document."); + } + } + } + $typeInfo->leave($node); + } + ]); + }; + + // Visit the whole document with instances of all provided rules. + $allRuleInstances = []; + foreach ($rules as $rule) { + $allRuleInstances[] = $rule($context); + } + $visitInstances($documentAST, $allRuleInstances); + + return $errors; + } +} diff --git a/src/Validator/Messages.php b/src/Validator/Messages.php new file mode 100644 index 0000000..a8bb5ed --- /dev/null +++ b/src/Validator/Messages.php @@ -0,0 +1,160 @@ + function(Field $fieldAST) use ($context) { + $fieldDef = $context->getFieldDef(); + if (!$fieldDef) { + return Visitor::skipNode(); + } + $errors = []; + $argASTs = $fieldAST->arguments ?: []; + $argASTMap = Utils::keyMap($argASTs, function (Argument $arg) { + return $arg->name->value; + }); + + foreach ($fieldDef->args as $argDef) { + $argAST = isset($argASTMap[$argDef->name]) ? $argASTMap[$argDef->name] : null; + if (!$argAST && $argDef->getType() instanceof NonNull) { + $errors[] = new Error( + Messages::missingArgMessage( + $fieldAST->name->value, + $argDef->name, + $argDef->getType() + ), + [$fieldAST] + ); + } + } + + $argDefMap = Utils::keyMap($fieldDef->args, function ($def) { + return $def->name; + }); + foreach ($argASTs as $argAST) { + $argDef = $argDefMap[$argAST->name->value]; + if ($argDef && !DocumentValidator::isValidLiteralValue($argAST->value, $argDef->getType())) { + $errors[] = new Error( + Messages::badValueMessage( + $argAST->name->value, + $argDef->getType(), + Printer::doPrint($argAST->value) + ), + [$argAST->value] + ); + } + } + + return !empty($errors) ? $errors : null; + } + ]; + } +} diff --git a/src/Validator/Rules/DefaultValuesOfCorrectType.php b/src/Validator/Rules/DefaultValuesOfCorrectType.php new file mode 100644 index 0000000..1f29991 --- /dev/null +++ b/src/Validator/Rules/DefaultValuesOfCorrectType.php @@ -0,0 +1,40 @@ + function(VariableDefinition $varDefAST) use ($context) { + $name = $varDefAST->variable->name->value; + $defaultValue = $varDefAST->defaultValue; + $type = $context->getInputType(); + + if ($type instanceof NonNull && $defaultValue) { + return new Error( + Messages::defaultForNonNullArgMessage($name, $type, $type->getWrappedType()), + [$defaultValue] + ); + } + if ($type && $defaultValue && !DocumentValidator::isValidLiteralValue($defaultValue, $type)) { + return new Error( + Messages::badValueForDefaultArgMessage($name, $type, Printer::doPrint($defaultValue)), + [$defaultValue] + ); + } + return null; + } + ]; + } +} diff --git a/src/Validator/Rules/FieldsOnCorrectType.php b/src/Validator/Rules/FieldsOnCorrectType.php new file mode 100644 index 0000000..ee91b47 --- /dev/null +++ b/src/Validator/Rules/FieldsOnCorrectType.php @@ -0,0 +1,30 @@ + function(Field $node) use ($context) { + $type = $context->getParentType(); + if ($type) { + $fieldDef = $context->getFieldDef(); + if (!$fieldDef) { + return new Error( + Messages::undefinedFieldMessage($node->name->value, $type->name), + [$node] + ); + } + } + } + ]; + } +} diff --git a/src/Validator/Rules/FragmentsOnCompositeTypes.php b/src/Validator/Rules/FragmentsOnCompositeTypes.php new file mode 100644 index 0000000..b6fe572 --- /dev/null +++ b/src/Validator/Rules/FragmentsOnCompositeTypes.php @@ -0,0 +1,44 @@ + function(InlineFragment $node) use ($context) { + $typeName = $node->typeCondition->value; + $type = $context->getSchema()->getType($typeName); + $isCompositeType = $type instanceof CompositeType; + + if (!$isCompositeType) { + return new Error( + "Fragment cannot condition on non composite type \"$typeName\".", + [$node->typeCondition] + ); + } + }, + Node::FRAGMENT_DEFINITION => function(FragmentDefinition $node) use ($context) { + $typeName = $node->typeCondition->value; + $type = $context->getSchema()->getType($typeName); + $isCompositeType = $type instanceof CompositeType; + + if (!$isCompositeType) { + return new Error( + Messages::fragmentOnNonCompositeErrorMessage($node->name->value, $typeName), + [$node->typeCondition] + ); + } + } + ]; + } +} diff --git a/src/Validator/Rules/KnownArgumentNames.php b/src/Validator/Rules/KnownArgumentNames.php new file mode 100644 index 0000000..84e7e5c --- /dev/null +++ b/src/Validator/Rules/KnownArgumentNames.php @@ -0,0 +1,40 @@ + function(Argument $node) use ($context) { + $fieldDef = $context->getFieldDef(); + if ($fieldDef) { + $argDef = null; + foreach ($fieldDef->args as $arg) { + if ($arg->name === $node->name->value) { + $argDef = $arg; + break; + } + } + + if (!$argDef) { + $parentType = $context->getParentType(); + Utils::invariant($parentType); + return new Error( + Messages::unknownArgMessage($node->name->value, $fieldDef->name, $parentType->name), + [$node] + ); + } + } + } + ]; + } +} diff --git a/src/Validator/Rules/KnownDirectives.php b/src/Validator/Rules/KnownDirectives.php new file mode 100644 index 0000000..eacc820 --- /dev/null +++ b/src/Validator/Rules/KnownDirectives.php @@ -0,0 +1,66 @@ + function (Directive $node, $key, $parent, $path, $ancestors) use ($context) { + $directiveDef = null; + foreach ($context->getSchema()->getDirectives() as $def) { + if ($def->name === $node->name->value) { + $directiveDef = $def; + break; + } + } + + if (!$directiveDef) { + return new Error( + Messages::unknownDirectiveMessage($node->name->value), + [$node] + ); + } + $appliedTo = $ancestors[count($ancestors) - 1]; + + if ($appliedTo instanceof OperationDefinition && !$directiveDef->onOperation) { + return new Error( + Messages::misplacedDirectiveMessage($node->name->value, 'operation'), + [$node] + ); + } + if ($appliedTo instanceof Field && !$directiveDef->onField) { + return new Error( + Messages::misplacedDirectiveMessage($node->name->value, 'field'), + [$node] + ); + } + + $fragmentKind = ( + $appliedTo instanceof FragmentSpread || + $appliedTo instanceof InlineFragment || + $appliedTo instanceof FragmentDefinition + ); + + if ($fragmentKind && !$directiveDef->onFragment) { + return new Error( + Messages::misplacedDirectiveMessage($node->name->value, 'fragment'), + [$node] + ); + } + } + ]; + } +} diff --git a/src/Validator/Rules/KnownFragmentNames.php b/src/Validator/Rules/KnownFragmentNames.php new file mode 100644 index 0000000..5cc0495 --- /dev/null +++ b/src/Validator/Rules/KnownFragmentNames.php @@ -0,0 +1,27 @@ + function(FragmentSpread $node) use ($context) { + $fragmentName = $node->name->value; + $fragment = $context->getFragment($fragmentName); + if (!$fragment) { + return new Error( + "Undefined fragment $fragmentName.", + [$node->name] + ); + } + } + ]; + } +} diff --git a/src/Validator/Rules/KnownTypeNames.php b/src/Validator/Rules/KnownTypeNames.php new file mode 100644 index 0000000..91b9c46 --- /dev/null +++ b/src/Validator/Rules/KnownTypeNames.php @@ -0,0 +1,28 @@ + function(Name $node, $key) use ($context) { + + if ($key === 'type' || $key === 'typeCondition') { + $typeName = $node->value; + $type = $context->getSchema()->getType($typeName); + if (!$type) { + return new Error(Messages::unknownTypeMessage($typeName), [$node]); + } + } + } + ]; + } +} diff --git a/src/Validator/Rules/NoFragmentCycles.php b/src/Validator/Rules/NoFragmentCycles.php new file mode 100644 index 0000000..bf9bb15 --- /dev/null +++ b/src/Validator/Rules/NoFragmentCycles.php @@ -0,0 +1,101 @@ +getDocument()->definitions; + $spreadsInFragment = []; + foreach ($definitions as $node) { + if ($node instanceof FragmentDefinition) { + $spreadsInFragment[$node->name->value] = $this->gatherSpreads($node); + } + } + + // Tracks spreads known to lead to cycles to ensure that cycles are not + // redundantly reported. + $knownToLeadToCycle = new \SplObjectStorage(); + + return [ + Node::FRAGMENT_DEFINITION => function(FragmentDefinition $node) use ($spreadsInFragment, $knownToLeadToCycle) { + $errors = []; + $initialName = $node->name->value; + + // Array of AST nodes used to produce meaningful errors + $spreadPath = []; + + $this->detectCycleRecursive($initialName, $spreadsInFragment, $knownToLeadToCycle, $initialName, $spreadPath, $errors); + + if (!empty($errors)) { + return $errors; + } + } + ]; + } + + private function detectCycleRecursive($fragmentName, array $spreadsInFragment, \SplObjectStorage $knownToLeadToCycle, $initialName, array &$spreadPath, &$errors) + { + $spreadNodes = $spreadsInFragment[$fragmentName]; + + for ($i = 0; $i < count($spreadNodes); ++$i) { + $spreadNode = $spreadNodes[$i]; + if (isset($knownToLeadToCycle[$spreadNode])) { + continue ; + } + if ($spreadNode->name->value === $initialName) { + $cyclePath = array_merge($spreadPath, [$spreadNode]); + foreach ($cyclePath as $spread) { + $knownToLeadToCycle[$spread] = true; + } + $errors[] = new Error( + Messages::cycleErrorMessage($initialName, array_map(function ($s) { + return $s->name->value; + }, $spreadPath)), + $cyclePath + ); + continue; + } + + foreach ($spreadPath as $spread) { + if ($spread === $spreadNode) { + continue 2; + } + } + + $spreadPath[] = $spreadNode; + $this->detectCycleRecursive($spreadNode->name->value, $spreadsInFragment, $knownToLeadToCycle, $initialName, $spreadPath, $errors); + array_pop($spreadPath); + } + } + + + private function gatherSpreads($node) + { + $spreadNodes = []; + Visitor::visit($node, [ + Node::FRAGMENT_SPREAD => function(FragmentSpread $spread) use (&$spreadNodes) { + $spreadNodes[] = $spread; + } + ]); + return $spreadNodes; + } +} diff --git a/src/Validator/Rules/NoUndefinedVariables.php b/src/Validator/Rules/NoUndefinedVariables.php new file mode 100644 index 0000000..56a3c70 --- /dev/null +++ b/src/Validator/Rules/NoUndefinedVariables.php @@ -0,0 +1,75 @@ + true, + + Node::OPERATION_DEFINITION => function(OperationDefinition $node, $key, $parent, $path, $ancestors) use (&$operation, &$visitedFragmentNames, &$definedVariableNames) { + $operation = $node; + $visitedFragmentNames = []; + $definedVariableNames = []; + }, + Node::VARIABLE_DEFINITION => function(VariableDefinition $def) use (&$definedVariableNames) { + $definedVariableNames[$def->variable->name->value] = true; + }, + Node::VARIABLE => function(Variable $variable, $key, $parent, $path, $ancestors) use (&$definedVariableNames, &$visitedFragmentNames, &$operation) { + $varName = $variable->name->value; + if (empty($definedVariableNames[$varName])) { + $withinFragment = false; + foreach ($ancestors as $ancestor) { + if ($ancestor instanceof FragmentDefinition) { + $withinFragment = true; + break; + } + } + if ($withinFragment && $operation && $operation->name) { + return new Error( + Messages::undefinedVarByOpMessage($varName, $operation->name->value), + [$variable, $operation] + ); + } + return new Error( + Messages::undefinedVarMessage($varName), + [$variable] + ); + } + }, + Node::FRAGMENT_SPREAD => function(FragmentSpread $spreadAST) use (&$visitedFragmentNames) { + // Only visit fragments of a particular name once per operation + if (!empty($visitedFragmentNames[$spreadAST->name->value])) { + return Visitor::skipNode(); + } + $visitedFragmentNames[$spreadAST->name->value] = true; + } + ]; + } +} diff --git a/src/Validator/Rules/NoUnusedFragments.php b/src/Validator/Rules/NoUnusedFragments.php new file mode 100644 index 0000000..99d10ea --- /dev/null +++ b/src/Validator/Rules/NoUnusedFragments.php @@ -0,0 +1,70 @@ + function() use (&$spreadNames, &$spreadsWithinOperation) { + $spreadNames = new \stdClass(); + $spreadsWithinOperation[] = $spreadNames; + }, + Node::FRAGMENT_DEFINITION => function(FragmentDefinition $def) use (&$fragmentDefs, &$spreadNames, &$fragAdjacencies) { + $fragmentDefs[] = $def; + $spreadNames = new \stdClass(); + $fragAdjacencies->{$def->name->value} = $spreadNames; + }, + Node::FRAGMENT_SPREAD => function(FragmentSpread $spread) use (&$spreadNames) { + $spreadNames->{$spread->name->value} = true; + }, + Node::DOCUMENT => [ + 'leave' => function() use (&$fragAdjacencies, &$spreadsWithinOperation, &$fragmentDefs) { + $fragmentNameUsed = []; + + foreach ($spreadsWithinOperation as $spreads) { + $this->reduceSpreadFragments($spreads, $fragmentNameUsed, $fragAdjacencies); + } + + $errors = []; + foreach ($fragmentDefs as $def) { + if (empty($fragmentNameUsed[$def->name->value])) { + $errors[] = new Error( + Messages::unusedFragMessage($def->name->value), + [$def] + ); + } + } + return !empty($errors) ? $errors : null; + } + ] + ]; + } + + private function reduceSpreadFragments($spreads, &$fragmentNameUsed, &$fragAdjacencies) + { + foreach ($spreads as $fragName => $fragment) { + if (empty($fragmentNameUsed[$fragName])) { + $fragmentNameUsed[$fragName] = true; + $this->reduceSpreadFragments( + $fragAdjacencies->{$fragName}, + $fragmentNameUsed, + $fragAdjacencies + ); + } + } + } +} diff --git a/src/Validator/Rules/NoUnusedVariables.php b/src/Validator/Rules/NoUnusedVariables.php new file mode 100644 index 0000000..7299153 --- /dev/null +++ b/src/Validator/Rules/NoUnusedVariables.php @@ -0,0 +1,57 @@ + true, + Node::OPERATION_DEFINITION => [ + 'enter' => function() use (&$visitedFragmentNames, &$variableDefs, &$variableNameUsed) { + $visitedFragmentNames = new \stdClass(); + $variableDefs = []; + $variableNameUsed = new \stdClass(); + }, + 'leave' => function() use (&$visitedFragmentNames, &$variableDefs, &$variableNameUsed) { + $errors = []; + foreach ($variableDefs as $def) { + if (empty($variableNameUsed->{$def->variable->name->value})) { + $errors[] = new Error( + Messages::unusedVariableMessage($def->variable->name->value), + [$def] + ); + } + } + return !empty($errors) ? $errors : null; + } + ], + Node::VARIABLE_DEFINITION => function($def) use (&$variableDefs) { + $variableDefs[] = $def; + return Visitor::skipNode(); + }, + Node::VARIABLE => function($variable) use (&$variableNameUsed) { + $variableNameUsed->{$variable->name->value} = true; + }, + Node::FRAGMENT_SPREAD => function($spreadAST) use (&$visitedFragmentNames) { + // Only visit fragments of a particular name once per operation + if (!empty($visitedFragmentNames->{$spreadAST->name->value})) { + return Visitor::skipNode(); + } + $visitedFragmentNames->{$spreadAST->name->value} = true; + } + ]; + } +} diff --git a/src/Validator/Rules/OverlappingFieldsCanBeMerged.php b/src/Validator/Rules/OverlappingFieldsCanBeMerged.php new file mode 100644 index 0000000..c185a3c --- /dev/null +++ b/src/Validator/Rules/OverlappingFieldsCanBeMerged.php @@ -0,0 +1,276 @@ + [ + // Note: we validate on the reverse traversal so deeper conflicts will be + // caught first, for clearer error messages. + 'leave' => function(SelectionSet $selectionSet) use ($context, $comparedSet) { + $fieldMap = $this->collectFieldASTsAndDefs( + $context, + $context->getType(), + $selectionSet + ); + + $conflicts = $this->findConflicts($fieldMap, $context, $comparedSet); + + if (!empty($conflicts)) { + return array_map(function ($conflict) { + $responseName = $conflict[0][0]; + $reason = $conflict[0][1]; + $blameNodes = $conflict[1]; + + return new Error( + Messages::fieldsConflictMessage($responseName, $reason), + $blameNodes + ); + }, $conflicts); + + } + } + ] + ]; + } + + private function findConflicts($fieldMap, ValidationContext $context, PairSet $comparedSet) + { + $conflicts = []; + foreach ($fieldMap as $responseName => $fields) { + $count = count($fields); + if ($count > 1) { + for ($i = 0; $i < $count; $i++) { + for ($j = $i; $j < $count; $j++) { + $conflict = $this->findConflict($responseName, $fields[$i], $fields[$j], $context, $comparedSet); + if ($conflict) { + $conflicts[] = $conflict; + } + } + } + } + } + return $conflicts; + } + + /** + * @param ValidationContext $context + * @param PairSet $comparedSet + * @param $responseName + * @param [Field, GraphQLFieldDefinition] $pair1 + * @param [Field, GraphQLFieldDefinition] $pair2 + * @return array|null + */ + private function findConflict($responseName, array $pair1, array $pair2, ValidationContext $context, PairSet $comparedSet) + { + list($ast1, $def1) = $pair1; + list($ast2, $def2) = $pair2; + + if ($ast1 === $ast2 || $comparedSet->has($ast1, $ast2)) { + return null; + } + $comparedSet->add($ast1, $ast2); + + $name1 = $ast1->name->value; + $name2 = $ast2->name->value; + + if ($name1 !== $name2) { + return [ + [$responseName, "$name1 and $name2 are different fields"], + [$ast1, $ast2] + ]; + } + + $type1 = isset($def1) ? $def1->getType() : null; + $type2 = isset($def2) ? $def2->getType() : null; + + if (!$this->sameType($type1, $type2)) { + return [ + [$responseName, "they return differing types $type1 and $type2"], + [$ast1, $ast2] + ]; + } + + $args1 = isset($ast1->arguments) ? $ast1->arguments : []; + $args2 = isset($ast2->arguments) ? $ast2->arguments : []; + + if (!$this->sameNameValuePairs($args1, $args2)) { + return [ + [$responseName, 'they have differing arguments'], + [$ast1, $ast2] + ]; + } + + $directives1 = isset($ast1->directives) ? $ast1->directives : []; + $directives2 = isset($ast2->directives) ? $ast2->directives : []; + + if (!$this->sameNameValuePairs($directives1, $directives2)) { + return [ + [$responseName, 'they have differing directives'], + [$ast1, $ast2] + ]; + } + + $selectionSet1 = isset($ast1->selectionSet) ? $ast1->selectionSet : null; + $selectionSet2 = isset($ast2->selectionSet) ? $ast2->selectionSet : null; + + if ($selectionSet1 && $selectionSet2) { + $visitedFragmentNames = new \ArrayObject(); + + $subfieldMap = $this->collectFieldASTsAndDefs( + $context, + $type1, + $selectionSet1, + $visitedFragmentNames + ); + $subfieldMap = $this->collectFieldASTsAndDefs( + $context, + $type2, + $selectionSet2, + $visitedFragmentNames, + $subfieldMap + ); + $conflicts = $this->findConflicts($subfieldMap, $context, $comparedSet); + + if (!empty($conflicts)) { + return [ + [$responseName, array_map(function ($conflict) { return $conflict[0]; }, $conflicts)], + array_reduce($conflicts, function ($list, $conflict) { return array_merge($list, $conflict[1]); }, [$ast1, $ast2]) + ]; + } + } + } + + /** + * 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. + * + * @param ValidationContext $context + * @param Type|null $parentType + * @param SelectionSet $selectionSet + * @param \ArrayObject $visitedFragmentNames + * @param \ArrayObject $astAndDefs + * @return mixed + */ + private function collectFieldASTsAndDefs(ValidationContext $context, $parentType, SelectionSet $selectionSet, \ArrayObject $visitedFragmentNames = null, \ArrayObject $astAndDefs = null) + { + $_visitedFragmentNames = $visitedFragmentNames ?: new \ArrayObject(); + $_astAndDefs = $astAndDefs ?: new \ArrayObject(); + + for ($i = 0; $i < count($selectionSet->selections); $i++) { + $selection = $selectionSet->selections[$i]; + + switch ($selection->kind) { + case Node::FIELD: + $fieldAST = $selection; + $fieldName = $fieldAST->name->value; + $fieldDef = null; + if ($parentType && method_exists($parentType, 'getFields')) { + $tmp = $parentType->getFields(); + if (isset($tmp[$fieldName])) { + $fieldDef = $tmp[$fieldName]; + } + } + $responseName = $fieldAST->alias ? $fieldAST->alias->value : $fieldName; + + if (!isset($_astAndDefs[$responseName])) { + $_astAndDefs[$responseName] = new \ArrayObject(); + } + $_astAndDefs[$responseName][] = [$fieldAST, $fieldDef]; + break; + case Node::INLINE_FRAGMENT: + /** @var InlineFragment $inlineFragment */ + $inlineFragment = $selection; + $_astAndDefs = $this->collectFieldASTsAndDefs( + $context, + TypeInfo::typeFromAST($context->getSchema(), $inlineFragment->typeCondition), + $inlineFragment->selectionSet, + $_visitedFragmentNames, + $_astAndDefs + ); + break; + case Node::FRAGMENT_SPREAD: + /** @var FragmentSpread $fragmentSpread */ + $fragmentSpread = $selection; + $fragName = $fragmentSpread->name->value; + if (!empty($_visitedFragmentNames[$fragName])) { + continue; + } + $_visitedFragmentNames[$fragName] = true; + $fragment = $context->getFragment($fragName); + if (!$fragment) { + continue; + } + $_astAndDefs = $this->collectFieldASTsAndDefs( + $context, + TypeInfo::typeFromAST($context->getSchema(), $fragment->typeCondition), + $fragment->selectionSet, + $_visitedFragmentNames, + $_astAndDefs + ); + break; + } + } + return $_astAndDefs; + } + + + /** + * @param Array $pairs1 + * @param Array $pairs2 + * @return bool|string + */ + private function sameNameValuePairs(array $pairs1, array $pairs2) + { + if (count($pairs1) !== count($pairs2)) { + return false; + } + foreach ($pairs1 as $pair1) { + $matchedPair2 = null; + foreach ($pairs2 as $pair2) { + if ($pair2->name->value === $pair1->name->value) { + $matchedPair2 = $pair2; + break; + } + } + if (!$matchedPair2) { + return false; + } + if (!$this->sameValue($pair1->value, $matchedPair2->value)) { + return false; + } + } + return true; + } + + private function sameValue($value1, $value2) + { + return (!$value1 && !$value2) || (Printer::doPrint($value1) === Printer::doPrint($value2)); + } + + function sameType($type1, $type2) + { + return (!$type1 && !$type2) || (string) $type1 === (string) $type2; + } +} diff --git a/src/Validator/Rules/PossibleFragmentSpreads.php b/src/Validator/Rules/PossibleFragmentSpreads.php new file mode 100644 index 0000000..cb6fa9f --- /dev/null +++ b/src/Validator/Rules/PossibleFragmentSpreads.php @@ -0,0 +1,79 @@ + function(InlineFragment $node) use ($context) { + $fragType = Type::getUnmodifiedType($context->getType()); + $parentType = $context->getParentType(); + if ($fragType && $parentType && !$this->doTypesOverlap($fragType, $parentType)) { + return new Error( + Messages::typeIncompatibleAnonSpreadMessage($parentType, $fragType), + [$node] + ); + } + }, + Node::FRAGMENT_SPREAD => function(FragmentSpread $node) use ($context) { + $fragName = $node->name->value; + $fragType = Type::getUnmodifiedType($this->getFragmentType($context, $fragName)); + $parentType = $context->getParentType(); + + if ($fragType && $parentType && !$this->doTypesOverlap($fragType, $parentType)) { + return new Error( + Messages::typeIncompatibleSpreadMessage($fragName, $parentType, $fragType), + [$node] + ); + } + } + ]; + } + + private function getFragmentType(ValidationContext $context, $name) + { + $frag = $context->getFragment($name); + return $frag ? $context->getSchema()->getType($frag->typeCondition->value) : null; + } + + private function doTypesOverlap($t1, $t2) + { + if ($t1 === $t2) { + return true; + } + if ($t1 instanceof ObjectType) { + if ($t2 instanceof ObjectType) { + return false; + } + return in_array($t1, $t2->getPossibleTypes()); + } + if ($t1 instanceof InterfaceType || $t1 instanceof UnionType) { + if ($t2 instanceof ObjectType) { + return in_array($t2, $t1->getPossibleTypes()); + } + $t1TypeNames = Utils::keyMap($t1->getPossibleTypes(), function ($type) { + return $type->name; + }); + foreach ($t2->getPossibleTypes() as $type) { + if (!empty($t1TypeNames[$type->name])) { + return true; + } + } + } + return false; + } +} diff --git a/src/Validator/Rules/ScalarLeafs.php b/src/Validator/Rules/ScalarLeafs.php new file mode 100644 index 0000000..898aed3 --- /dev/null +++ b/src/Validator/Rules/ScalarLeafs.php @@ -0,0 +1,37 @@ + function(Field $node) use ($context) { + $type = $context->getType(); + if ($type) { + if (Type::isLeafType($type)) { + if ($node->selectionSet) { + return new Error( + Messages::noSubselectionAllowedMessage($node->name->value, $type), + [$node->selectionSet] + ); + } + } else if (!$node->selectionSet) { + return new Error( + Messages::requiredSubselectionMessage($node->name->value, $type), + [$node] + ); + } + } + } + ]; + } +} diff --git a/src/Validator/Rules/VariablesAreInputTypes.php b/src/Validator/Rules/VariablesAreInputTypes.php new file mode 100644 index 0000000..7eca699 --- /dev/null +++ b/src/Validator/Rules/VariablesAreInputTypes.php @@ -0,0 +1,43 @@ + function(VariableDefinition $node) use ($context) { + $typeName = $this->getTypeASTName($node->type); + $type = $context->getSchema()->getType($typeName); + + if (!($type instanceof InputType)) { + $variableName = $node->variable->name->value; + return new Error( + Messages::nonInputTypeOnVarMessage($variableName, Printer::doPrint($node->type)), + [$node->type] + ); + } + } + ]; + } + + private function getTypeASTName(Type $typeAST) + { + if ($typeAST->kind === Node::NAME) { + return $typeAST->value; + } + Utils::invariant($typeAST->type, 'Must be wrapping type'); + return $this->getTypeASTName($typeAST->type); + } +} diff --git a/src/Validator/Rules/VariablesInAllowedPosition.php b/src/Validator/Rules/VariablesInAllowedPosition.php new file mode 100644 index 0000000..ad6ccd7 --- /dev/null +++ b/src/Validator/Rules/VariablesInAllowedPosition.php @@ -0,0 +1,86 @@ + true, + Node::OPERATION_DEFINITION => function () use (&$varDefMap, &$visitedFragmentNames) { + $varDefMap = new \ArrayObject(); + $visitedFragmentNames = new \ArrayObject(); + }, + Node::VARIABLE_DEFINITION => function (VariableDefinition $varDefAST) use ($varDefMap) { + $varDefMap[$varDefAST->variable->name->value] = $varDefAST; + }, + Node::FRAGMENT_SPREAD => function (FragmentSpread $spreadAST) use ($visitedFragmentNames) { + // Only visit fragments of a particular name once per operation + if (!empty($visitedFragmentNames[$spreadAST->name->value])) { + return Visitor::skipNode(); + } + $visitedFragmentNames[$spreadAST->name->value] = true; + }, + Node::VARIABLE => function (Variable $variableAST) use ($context, $varDefMap) { + $varName = $variableAST->name->value; + $varDef = isset($varDefMap[$varName]) ? $varDefMap[$varName] : null; + $varType = $varDef ? TypeInfo::typeFromAST($context->getSchema(), $varDef->type) : null; + $inputType = $context->getInputType(); + + if ($varType && $inputType && + !$this->varTypeAllowedForType($this->effectiveType($varType, $varDef), $inputType) + ) { + return new Error( + Messages::badVarPosMessage($varName, $varType, $inputType), + [$variableAST] + ); + } + } + ]; + } + + // A var type is allowed if it is the same or more strict than the expected + // type. It can be more strict if the variable type is non-null when the + // expected type is nullable. If both are list types, the variable item type can + // be more strict than the expected item type. + private function varTypeAllowedForType($varType, $expectedType) + { + if ($expectedType instanceof NonNull) { + if ($varType instanceof NonNull) { + return $this->varTypeAllowedForType($varType->getWrappedType(), $expectedType->getWrappedType()); + } + return false; + } + if ($varType instanceof NonNull) { + return $this->varTypeAllowedForType($varType->getWrappedType(), $expectedType); + } + if ($varType instanceof ListOfType && $expectedType instanceof ListOfType) { + return $this->varTypeAllowedForType($varType->getWrappedType(), $expectedType->getWrappedType()); + } + return $varType === $expectedType; + } + + // If a variable definition has a default value, it's effectively non-null. + private function effectiveType($varType, $varDef) + { + return (!$varDef->defaultValue || $varType instanceof NonNull) ? $varType : new NonNull($varType); + } + +} diff --git a/src/Validator/ValidationContext.php b/src/Validator/ValidationContext.php new file mode 100644 index 0000000..eaac972 --- /dev/null +++ b/src/Validator/ValidationContext.php @@ -0,0 +1,116 @@ + + */ + private $_fragments; + + function __construct(Schema $schema, Document $ast, TypeInfo $typeInfo) + { + $this->_schema = $schema; + $this->_ast = $ast; + $this->_typeInfo = $typeInfo; + } + + /** + * @return Schema + */ + function getSchema() + { + return $this->_schema; + } + + /** + * @return Document + */ + function getDocument() + { + return $this->_ast; + } + + /** + * @param $name + * @return FragmentDefinition|null + */ + function getFragment($name) + { + $fragments = $this->_fragments; + if (!$fragments) { + $this->_fragments = $fragments = + array_reduce($this->getDocument()->definitions, function($frags, $statement) { + if ($statement->kind === Node::FRAGMENT_DEFINITION) { + $frags[$statement->name->value] = $statement; + } + return $frags; + }, []); + } + return isset($fragments[$name]) ? $fragments[$name] : null; + } + + /** + * Returns OutputType + * + * @return Type + */ + function getType() + { + return $this->_typeInfo->getType(); + } + + /** + * @return CompositeType + */ + function getParentType() + { + return $this->_typeInfo->getParentType(); + } + + /** + * @return InputType + */ + function getInputType() + { + return $this->_typeInfo->getInputType(); + } + + /** + * @return FieldDefinition + */ + function getFieldDef() + { + return $this->_typeInfo->getFieldDef(); + } +} diff --git a/tests/Executor/DirectivesTest.php b/tests/Executor/DirectivesTest.php new file mode 100644 index 0000000..7c1df7b --- /dev/null +++ b/tests/Executor/DirectivesTest.php @@ -0,0 +1,225 @@ +assertEquals(['data' => ['a' => 'a', 'b' => 'b']], $this->executeTestQuery('{ a, b }')); + } + + public function testWorksOnScalars() + { + // if true includes scalar + $this->assertEquals(['data' => ['a' => 'a', 'b' => 'b']], $this->executeTestQuery('{ a, b @if:true }')); + + // if false omits on scalar + $this->assertEquals(['data' => ['a' => 'a']], $this->executeTestQuery('{ a, b @if:false }')); + + // unless false includes scalar + $this->assertEquals(['data' => ['a' => 'a', 'b' => 'b']], $this->executeTestQuery('{ a, b @unless:false }')); + + // unless true omits scalar + $this->assertEquals(['data' => ['a' => 'a']], $this->executeTestQuery('{ a, b @unless:true }')); + } + + public function testWorksOnFragmentSpreads() + { + // if false omits fragment spread + $q = ' + query Q { + a + ...Frag @if:false + } + fragment Frag on TestType { + b + } + '; + $this->assertEquals(['data' => ['a' => 'a']], $this->executeTestQuery($q)); + + // if true includes fragment spread + $q = ' + query Q { + a + ...Frag @if:true + } + fragment Frag on TestType { + b + } + '; + $this->assertEquals(['data' => ['a' => 'a', 'b' => 'b']], $this->executeTestQuery($q)); + + // unless false includes fragment spread + $q = ' + query Q { + a + ...Frag @unless:false + } + fragment Frag on TestType { + b + } + '; + $this->assertEquals(['data' => ['a' => 'a', 'b' => 'b']], $this->executeTestQuery($q)); + + // unless true omits fragment spread + $q = ' + query Q { + a + ...Frag @unless:true + } + fragment Frag on TestType { + b + } + '; + $this->assertEquals(['data' => ['a' => 'a']], $this->executeTestQuery($q)); + } + + public function testWorksOnInlineFragment() + { + // if false omits inline fragment + $q = ' + query Q { + a + ... on TestType @if:false { + b + } + } + fragment Frag on TestType { + b + } + '; + $this->assertEquals(['data' => ['a' => 'a']], $this->executeTestQuery($q)); + + // if true includes inline fragment + $q = ' + query Q { + a + ... on TestType @if:true { + b + } + } + fragment Frag on TestType { + b + } + '; + $this->assertEquals(['data' => ['a' => 'a', 'b' => 'b']], $this->executeTestQuery($q)); + + // unless false includes inline fragment + $q = ' + query Q { + a + ... on TestType @unless:false { + b + } + } + fragment Frag on TestType { + b + } + '; + $this->assertEquals(['data' => ['a' => 'a', 'b' => 'b']], $this->executeTestQuery($q)); + + // unless true includes inline fragment + $q = ' + query Q { + a + ... on TestType @unless:true { + b + } + } + fragment Frag on TestType { + b + } + '; + $this->assertEquals(['data' => ['a' => 'a']], $this->executeTestQuery($q)); + } + + public function testWorksOnFragment() + { + // if false omits fragment + $q = ' + query Q { + a + ...Frag + } + fragment Frag on TestType @if:false { + b + } + '; + $this->assertEquals(['data' => ['a' => 'a']], $this->executeTestQuery($q)); + + // if true includes fragment + $q = ' + query Q { + a + ...Frag + } + fragment Frag on TestType @if:true { + b + } + '; + $this->assertEquals(['data' => ['a' => 'a', 'b' => 'b']], $this->executeTestQuery($q)); + + // unless false includes fragment + $q = ' + query Q { + a + ...Frag + } + fragment Frag on TestType @unless:false { + b + } + '; + $this->assertEquals(['data' => ['a' => 'a', 'b' => 'b']], $this->executeTestQuery($q)); + + // unless true omits fragment + $q = ' + query Q { + a + ...Frag + } + fragment Frag on TestType @unless:true { + b + } + '; + $this->assertEquals(['data' => ['a' => 'a']], $this->executeTestQuery($q)); + } + + + + + private static $schema; + + private static $data; + + private static function getSchema() + { + return self::$schema ?: (self::$schema = new Schema(new ObjectType([ + 'name' => 'TestType', + 'fields' => [ + 'a' => ['type' => Type::string()], + 'b' => ['type' => Type::string()] + ] + ]))); + } + + private static function getData() + { + return self::$data ?: (self::$data = [ + 'a' => function() { return 'a'; }, + 'b' => function() { return 'b'; } + ]); + } + + private function executeTestQuery($doc) + { + return Executor::execute(self::getSchema(), self::getData(), Parser::parse($doc)); + } +} diff --git a/tests/Executor/ExecutorSchemaTest.php b/tests/Executor/ExecutorSchemaTest.php new file mode 100644 index 0000000..6240dfe --- /dev/null +++ b/tests/Executor/ExecutorSchemaTest.php @@ -0,0 +1,206 @@ + 'Image', + 'fields' => [ + 'url' => ['type' => Type::string()], + 'width' => ['type' => Type::int()], + 'height' => ['type' => Type::int()], + ] + ]); + + $BlogAuthor = new ObjectType([ + 'name' => 'Author', + 'fields' => [ + 'id' => ['type' => Type::string()], + 'name' => ['type' => Type::string()], + 'pic' => [ + 'args' => ['width' => ['type' => Type::int()], 'height' => ['type' => Type::int()]], + 'type' => $BlogImage, + 'resolve' => function ($obj, $args) { + return $obj['pic']($args['width'], $args['height']); + } + ], + 'recentArticle' => ['type' => function () use (&$BlogArticle) { + return $BlogArticle; + }] + ] + ]); + + $BlogArticle = new ObjectType([ + 'name' => 'Article', + 'fields' => [ + 'id' => ['type' => Type::nonNull(Type::string())], + 'isPublished' => ['type' => Type::boolean()], + 'author' => ['type' => $BlogAuthor], + 'title' => ['type' => Type::string()], + 'body' => ['type' => Type::string()], + 'keywords' => ['type' => Type::listOf(Type::string())] + ] + ]); + + $BlogQuery = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'article' => [ + 'type' => $BlogArticle, + 'args' => ['id' => ['type' => Type::id()]], + 'resolve' => function ($_, $args) { + return $this->article($args['id']); + } + ], + 'feed' => [ + 'type' => Type::listOf($BlogArticle), + 'resolve' => function () { + return [ + $this->article(1), + $this->article(2), + $this->article(3), + $this->article(4), + $this->article(5), + $this->article(6), + $this->article(7), + $this->article(8), + $this->article(9), + $this->article(10) + ]; + } + ] + ] + ]); + + $BlogSchema = new Schema($BlogQuery); + + + $request = ' + { + feed { + id, + title + }, + article(id: "1") { + ...articleFields, + author { + id, + name, + pic(width: 640, height: 480) { + url, + width, + height + }, + recentArticle { + ...articleFields, + keywords + } + } + } + } + + fragment articleFields on Article { + id, + isPublished, + title, + body, + hidden, + notdefined + } + '; + + $expected = [ + 'data' => [ + 'feed' => [ + ['id' => '1', + 'title' => 'My Article 1'], + ['id' => '2', + 'title' => 'My Article 2'], + ['id' => '3', + 'title' => 'My Article 3'], + ['id' => '4', + 'title' => 'My Article 4'], + ['id' => '5', + 'title' => 'My Article 5'], + ['id' => '6', + 'title' => 'My Article 6'], + ['id' => '7', + 'title' => 'My Article 7'], + ['id' => '8', + 'title' => 'My Article 8'], + ['id' => '9', + 'title' => 'My Article 9'], + ['id' => '10', + 'title' => 'My Article 10'] + ], + 'article' => [ + 'id' => '1', + 'isPublished' => true, + 'title' => 'My Article 1', + 'body' => 'This is a post', + 'author' => [ + 'id' => '123', + 'name' => 'John Smith', + 'pic' => [ + 'url' => 'cdn://123', + 'width' => 640, + 'height' => 480 + ], + 'recentArticle' => [ + 'id' => '1', + 'isPublished' => true, + 'title' => 'My Article 1', + 'body' => 'This is a post', + 'keywords' => ['foo', 'bar', '1', 'true', null] + ] + ] + ] + ] + ]; + + $this->assertEquals($expected, Executor::execute($BlogSchema, null, Parser::parse($request), '', [])); + } + + private function article($id) + { + $johnSmith = null; + $article = function($id) use (&$johnSmith) { + return [ + 'id' => $id, + 'isPublished' => 'true', + 'author' => $johnSmith, + 'title' => 'My Article ' . $id, + 'body' => 'This is a post', + 'hidden' => 'This data is not exposed in the schema', + 'keywords' => ['foo', 'bar', 1, true, null] + ]; + }; + + $getPic = function($uid, $width, $height) { + return [ + 'url' => "cdn://$uid", + 'width' => $width, + 'height' => $height + ]; + }; + + $johnSmith = [ + 'id' => 123, + 'name' => 'John Smith', + 'pic' => function($width, $height) use ($getPic) {return $getPic(123, $width, $height);}, + 'recentArticle' => $article(1), + ]; + + return $article($id); + } +} diff --git a/tests/Executor/ExecutorTest.php b/tests/Executor/ExecutorTest.php new file mode 100644 index 0000000..29ddf59 --- /dev/null +++ b/tests/Executor/ExecutorTest.php @@ -0,0 +1,470 @@ + function () { return 'Apple';}, + 'b' => function () {return 'Banana';}, + 'c' => function () {return 'Cookie';}, + 'd' => function () {return 'Donut';}, + 'e' => function () {return 'Egg';}, + 'f' => 'Fish', + 'pic' => function ($size = 50) { + return 'Pic of size: ' . $size; + }, + 'promise' => function() use (&$data) { + return $data; + }, + 'deep' => function () use (&$deepData) { + return $deepData; + } + ]; + + $deepData = [ + 'a' => function () { return 'Already Been Done'; }, + 'b' => function () { return 'Boring'; }, + 'c' => function () { + return ['Contrived', null, 'Confusing']; + }, + 'deeper' => function () use ($data) { + return [$data, null, $data]; + } + ]; + + + $doc = ' + query Example($size: Int) { + a, + b, + x: c + ...c + f + ...on DataType { + pic(size: $size) + promise { + a + } + } + deep { + a + b + c + deeper { + a + b + } + } + } + + fragment c on DataType { + d + e + } + '; + + $ast = Parser::parse($doc); + $expected = [ + 'data' => [ + 'a' => 'Apple', + 'b' => 'Banana', + 'x' => 'Cookie', + 'd' => 'Donut', + 'e' => 'Egg', + 'f' => 'Fish', + 'pic' => 'Pic of size: 100', + 'promise' => [ + 'a' => 'Apple' + ], + 'deep' => [ + 'a' => 'Already Been Done', + 'b' => 'Boring', + 'c' => [ 'Contrived', null, 'Confusing' ], + 'deeper' => [ + [ 'a' => 'Apple', 'b' => 'Banana' ], + null, + [ 'a' => 'Apple', 'b' => 'Banana' ] + ] + ] + ] + ]; + + $deepDataType = null; + $dataType = new ObjectType([ + 'name' => 'DataType', + 'fields' => [ + 'a' => [ 'type' => Type::string() ], + 'b' => [ 'type' => Type::string() ], + 'c' => [ 'type' => Type::string() ], + 'd' => [ 'type' => Type::string() ], + 'e' => [ 'type' => Type::string() ], + 'f' => [ 'type' => Type::string() ], + 'pic' => [ + 'args' => [ 'size' => ['type' => Type::int() ] ], + 'type' => Type::string(), + 'resolve' => function($obj, $args) { return $obj['pic']($args['size']); } + ], + 'promise' => ['type' => function() use (&$dataType) {return $dataType;}], + 'deep' => [ 'type' => function() use(&$deepDataType) {return $deepDataType; }], + ] + ]); + + $deepDataType = new ObjectType([ + 'name' => 'DeepDataType', + 'fields' => [ + 'a' => [ 'type' => Type::string() ], + 'b' => [ 'type' => Type::string() ], + 'c' => [ 'type' => Type::listOf(Type::string()) ], + 'deeper' => [ 'type' => Type::listOf($dataType) ] + ] + ]); + $schema = new Schema($dataType); + + $this->assertEquals($expected, Executor::execute($schema, $data, $ast, 'Example', ['size' => 100])); + } + + public function testMergesParallelFragments() + { + $ast = Parser::parse(' + { a, ...FragOne, ...FragTwo } + + fragment FragOne on Type { + b + deep { b, deeper: deep { b } } + } + + fragment FragTwo on Type { + c + deep { c, deeper: deep { c } } + } + '); + + $Type = new ObjectType([ + 'name' => 'Type', + 'fields' => [ + 'a' => ['type' => Type::string(), 'resolve' => function () { + return 'Apple'; + }], + 'b' => ['type' => Type::string(), 'resolve' => function () { + return 'Banana'; + }], + 'c' => ['type' => Type::string(), 'resolve' => function () { + return 'Cherry'; + }], + 'deep' => ['type' => function () use (&$Type) { + return $Type; + }, 'resolve' => function () { + return []; + }] + ] + ]); + $schema = new Schema($Type); + $expected = [ + 'data' => [ + 'a' => 'Apple', + 'b' => 'Banana', + 'c' => 'Cherry', + 'deep' => [ + 'b' => 'Banana', + 'c' => 'Cherry', + 'deeper' => [ + 'b' => 'Banana', + 'c' => 'Cherry' + ] + ] + ] + ]; + + $this->assertEquals($expected, Executor::execute($schema, null, $ast)); + } + + public function testThreadsContextCorrectly() + { + $doc = 'query Example { a }'; + + $gotHere = false; + + $data = [ + 'contextThing' => 'thing', + ]; + + $ast = Parser::parse($doc); + $schema = new Schema(new ObjectType([ + 'name' => 'Type', + 'fields' => [ + 'a' => [ + 'type' => Type::string(), + 'resolve' => function ($context) use ($doc, &$gotHere) { + $this->assertEquals('thing', $context['contextThing']); + $gotHere = true; + } + ] + ] + ])); + + Executor::execute($schema, $data, $ast, 'Example', []); + $this->assertEquals(true, $gotHere); + } + + public function testCorrectlyThreadsArguments() + { + $doc = ' + query Example { + b(numArg: 123, stringArg: "foo") + } + '; + + $gotHere = false; + + $docAst = Parser::parse($doc); + $schema = new Schema(new ObjectType([ + 'name' => 'Type', + 'fields' => [ + 'b' => [ + 'args' => [ + 'numArg' => ['type' => Type::int()], + 'stringArg' => ['type' => Type::string()] + ], + 'type' => Type::string(), + 'resolve' => function ($_, $args) use (&$gotHere) { + $this->assertEquals(123, $args['numArg']); + $this->assertEquals('foo', $args['stringArg']); + $gotHere = true; + } + ] + ] + ])); + Executor::execute($schema, null, $docAst, 'Example', []); + $this->assertSame($gotHere, true); + } + + public function testNullsOutErrorSubtrees() + { + $doc = '{ + sync, + syncError, + async, + asyncReject, + asyncError + }'; + + $data = [ + 'sync' => function () { + return 'sync'; + }, + 'syncError' => function () { + throw new \Exception('Error getting syncError'); + }, + + // Following are inherited from JS reference implementation, but make no sense in this PHP impl + // leaving them just to simplify migrations from newer js versions + 'async' => function() { + return 'async'; + }, + 'asyncReject' => function() { + throw new \Exception('Error getting asyncReject'); + }, + 'asyncError' => function() { + throw new \Exception('Error getting asyncError'); + } + ]; + + $docAst = Parser::parse($doc); + $schema = new Schema(new ObjectType([ + 'name' => 'Type', + 'fields' => [ + 'sync' => ['type' => Type::string()], + 'syncError' => ['type' => Type::string()], + 'async' => ['type' => Type::string()], + 'asyncReject' => ['type' => Type::string() ], + 'asyncError' => ['type' => Type::string()], + ] + ])); + + $expected = [ + 'data' => [ + 'sync' => 'sync', + 'syncError' => null, + 'async' => 'async', + 'asyncReject' => null, + 'asyncError' => null, + ], + 'errors' => [ + new FormattedError('Error getting syncError', [new SourceLocation(3, 7)]), + new FormattedError('Error getting asyncReject', [new SourceLocation(5, 7)]), + new FormattedError('Error getting asyncError', [new SourceLocation(6, 7)]) + ] + ]; + + $result = Executor::execute($schema, $data, $docAst); + + $this->assertEquals($expected, $result); + } + + public function testUsesTheInlineOperationIfNoOperationIsProvided() + { + // uses the inline operation if no operation is provided + $doc = '{ a }'; + $data = ['a' => 'b']; + $ast = Parser::parse($doc); + $schema = new Schema(new ObjectType([ + 'name' => 'Type', + 'fields' => [ + 'a' => ['type' => Type::string()], + ] + ])); + + $ex = Executor::execute($schema, $data, $ast); + + $this->assertEquals(['data' => ['a' => 'b']], $ex); + } + + public function testUsesTheOnlyOperationIfNoOperationIsProvided() + { + $doc = 'query Example { a }'; + $data = [ 'a' => 'b' ]; + $ast = Parser::parse($doc); + $schema = new Schema(new ObjectType([ + 'name' => 'Type', + 'fields' => [ + 'a' => [ 'type' => Type::string() ], + ] + ])); + + $ex = Executor::execute($schema, $data, $ast); + $this->assertEquals(['data' => ['a' => 'b']], $ex); + } + + public function testThrowsIfNoOperationIsProvidedWithMultipleOperations() + { + $doc = 'query Example { a } query OtherExample { a }'; + $data = [ 'a' => 'b' ]; + $ast = Parser::parse($doc); + $schema = new Schema(new ObjectType([ + 'name' => 'Type', + 'fields' => [ + 'a' => [ 'type' => Type::string() ], + ] + ])); + + $ex = Executor::execute($schema, $data, $ast); + + $this->assertEquals( + [ + 'data' => null, + 'errors' => [new FormattedError('Must provide operation name if query contains multiple operations')] + ], + $ex + ); + } + + public function testUsesTheQuerySchemaForQueries() + { + $doc = 'query Q { a } mutation M { c }'; + $data = ['a' => 'b', 'c' => 'd']; + $ast = Parser::parse($doc); + $schema = new Schema( + new ObjectType([ + 'name' => 'Q', + 'fields' => [ + 'a' => ['type' => Type::string()], + ] + ]), + new ObjectType([ + 'name' => 'M', + 'fields' => [ + 'c' => ['type' => Type::string()], + ] + ]) + ); + + $queryResult = Executor::execute($schema, $data, $ast, 'Q'); + $this->assertEquals(['data' => ['a' => 'b']], $queryResult); + } + + public function testUsesTheMutationSchemaForMutations() + { + $doc = 'query Q { a } mutation M { c }'; + $data = [ 'a' => 'b', 'c' => 'd' ]; + $ast = Parser::parse($doc); + $schema = new Schema( + new ObjectType([ + 'name' => 'Q', + 'fields' => [ + 'a' => ['type' => Type::string()], + ] + ]), + new ObjectType([ + 'name' => 'M', + 'fields' => [ + 'c' => [ 'type' => Type::string() ], + ] + ]) + ); + $mutationResult = Executor::execute($schema, $data, $ast, 'M'); + $this->assertEquals(['data' => ['c' => 'd']], $mutationResult); + } + + public function testAvoidsRecursion() + { + $doc = ' + query Q { + a + ...Frag + ...Frag + } + + fragment Frag on DataType { + a, + ...Frag + } + '; + $data = ['a' => 'b']; + $ast = Parser::parse($doc); + $schema = new Schema(new ObjectType([ + 'name' => 'Type', + 'fields' => [ + 'a' => ['type' => Type::string()], + ] + ])); + + $queryResult = Executor::execute($schema, $data, $ast, 'Q'); + $this->assertEquals(['data' => ['a' => 'b']], $queryResult); + } + + public function testDoesNotIncludeIllegalFieldsInOutput() + { + $doc = 'mutation M { + thisIsIllegalDontIncludeMe + }'; + $ast = Parser::parse($doc); + $schema = new Schema( + new ObjectType([ + 'name' => 'Q', + 'fields' => [ + 'a' => ['type' => Type::string()], + ] + ]), + new ObjectType([ + 'name' => 'M', + 'fields' => [ + 'c' => ['type' => Type::string()], + ] + ]) + ); + $mutationResult = Executor::execute($schema, null, $ast); + $this->assertEquals(['data' => []], $mutationResult); + } +} diff --git a/tests/Executor/InputObjectTest.php b/tests/Executor/InputObjectTest.php new file mode 100644 index 0000000..4f3ed19 --- /dev/null +++ b/tests/Executor/InputObjectTest.php @@ -0,0 +1,525 @@ + [ + 'fieldWithObjectInput' => '{"a":"foo","b":["bar"],"c":"baz"}' + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast)); + + // properly coerces single value to array: + $doc = ' + { + fieldWithObjectInput(input: {a: "foo", b: "bar", c: "baz"}) + } + '; + $ast = Parser::parse($doc); + $expected = ['data' => ['fieldWithObjectInput' => '{"a":"foo","b":["bar"],"c":"baz"}']]; + + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast)); + } + + public function testUsingVariables() + { + // executes with complex input: + $doc = ' + query q($input:TestInputObject) { + fieldWithObjectInput(input: $input) + } + '; + $ast = Parser::parse($doc); + $params = ['input' => ['a' => 'foo', 'b' => ['bar'], 'c' => 'baz']]; + $schema = $this->schema(); + + $this->assertEquals( + ['data' => ['fieldWithObjectInput' => '{"a":"foo","b":["bar"],"c":"baz"}']], + Executor::execute($schema, null, $ast, null, $params) + ); + + // properly coerces single value to array: + $params = ['input' => ['a' => 'foo', 'b' => 'bar', 'c' => 'baz']]; + $this->assertEquals( + ['data' => ['fieldWithObjectInput' => '{"a":"foo","b":["bar"],"c":"baz"}']], + Executor::execute($schema, null, $ast, null, $params) + ); + + // errors on null for nested non-null: + $params = ['input' => ['a' => 'foo', 'b' => 'bar', 'c' => null]]; + $expected = [ + 'data' => null, + 'errors' => [ + new FormattedError( + 'Variable $input expected value of type ' . + 'TestInputObject but got: ' . + '{"a":"foo","b":"bar","c":null}.', + [new SourceLocation(2, 17)] + ) + ] + ]; + + $this->assertEquals($expected, Executor::execute($schema, null, $ast, null, $params)); + + // errors on omission of nested non-null: + $params = ['input' => ['a' => 'foo', 'b' => 'bar']]; + $expected = [ + 'data' => null, + 'errors' => [ + new FormattedError( + 'Variable $input expected value of type ' . + 'TestInputObject but got: {"a":"foo","b":"bar"}.', + [new SourceLocation(2, 17)] + ) + ] + ]; + + $this->assertEquals($expected, Executor::execute($schema, null, $ast, null, $params)); + } + + + // Handles nullable scalars + public function testAllowsNullableInputsToBeOmitted() + { + $doc = ' + { + fieldWithNullableStringInput + } + '; + $ast = Parser::parse($doc); + $expected = [ + 'data' => ['fieldWithNullableStringInput' => 'null'] + ]; + + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast)); + } + + public function testAllowsNullableInputsToBeOmittedInAVariable() + { + $doc = ' + query SetsNullable($value: String) { + fieldWithNullableStringInput(input: $value) + } + '; + $ast = Parser::parse($doc); + $expected = ['data' => ['fieldWithNullableStringInput' => 'null']]; + + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast)); + } + + public function testAllowsNullableInputsToBeOmittedInAnUnlistedVariable() + { + $doc = ' + query SetsNullable { + fieldWithNullableStringInput(input: $value) + } + '; + $ast = Parser::parse($doc); + $expected = ['data' => ['fieldWithNullableStringInput' => 'null']]; + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast)); + } + + public function testAllowsNullableInputsToBeSetToNullInAVariable() + { + $doc = ' + query SetsNullable($value: String) { + fieldWithNullableStringInput(input: $value) + } + '; + $ast = Parser::parse($doc); + $expected = ['data' => ['fieldWithNullableStringInput' => 'null']]; + + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast, null, ['value' => null])); + } + + public function testAllowsNullableInputsToBeSetToNullDirectly() + { + $doc = ' + { + fieldWithNullableStringInput(input: null) + } + '; + $ast = Parser::parse($doc); + $expected = ['data' => ['fieldWithNullableStringInput' => 'null']]; + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast)); + } + + public function testAllowsNullableInputsToBeSetToAValueInAVariable() + { + $doc = ' + query SetsNullable($value: String) { + fieldWithNullableStringInput(input: $value) + } + '; + $ast = Parser::parse($doc); + $expected = ['data' => ['fieldWithNullableStringInput' => '"a"']]; + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast, null, ['value' => 'a'])); + } + + public function testAllowsNullableInputsToBeSetToAValueDirectly() + { + $doc = ' + { + fieldWithNullableStringInput(input: "a") + } + '; + $ast = Parser::parse($doc); + $expected = ['data' => ['fieldWithNullableStringInput' => '"a"']]; + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast)); + } + + + // Handles non-nullable scalars + public function testDoesntAllowNonNullableInputsToBeOmittedInAVariable() + { + // does not allow non-nullable inputs to be omitted in a variable + $doc = ' + query SetsNonNullable($value: String!) { + fieldWithNonNullableStringInput(input: $value) + } + '; + $ast = Parser::parse($doc); + $expected = [ + 'data' => null, + 'errors' => [ + new FormattedError( + 'Variable $value expected value of type String! but got: null.', + [new SourceLocation(2, 31)] + ) + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast)); + } + + public function testDoesNotAllowNonNullableInputsToBeSetToNullInAVariable() + { + // does not allow non-nullable inputs to be set to null in a variable + $doc = ' + query SetsNonNullable($value: String!) { + fieldWithNonNullableStringInput(input: $value) + } + '; + $ast = Parser::parse($doc); + $expected = [ + 'data' => null, + 'errors' => [ + new FormattedError( + 'Variable $value expected value of type String! but got: null.', + [new SourceLocation(2, 31)] + ) + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast, null, ['value' => null])); + } + + public function testAllowsNonNullableInputsToBeSetToAValueInAVariable() + { + $doc = ' + query SetsNonNullable($value: String!) { + fieldWithNonNullableStringInput(input: $value) + } + '; + $ast = Parser::parse($doc); + $expected = ['data' => ['fieldWithNonNullableStringInput' => '"a"']]; + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast, null, ['value' => 'a'])); + } + + public function testAllowsNonNullableInputsToBeSetToAValueDirectly() + { + $doc = ' + { + fieldWithNonNullableStringInput(input: "a") + } + '; + $ast = Parser::parse($doc); + $expected = ['data' => ['fieldWithNonNullableStringInput' => '"a"']]; + + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast)); + } + + public function testPassesAlongNullForNonNullableInputsIfExplcitlySetInTheQuery() + { + $doc = ' + { + fieldWithNonNullableStringInput + } + '; + $ast = Parser::parse($doc); + $expected = ['data' => ['fieldWithNonNullableStringInput' => 'null']]; + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast)); + } + + // Handles lists and nullability + public function testAllowsListsToBeNull() + { + $doc = ' + query q($input:[String]) { + list(input: $input) + } + '; + $ast = Parser::parse($doc); + $expected = ['data' => ['list' => 'null']]; + + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast, null, ['input' => null])); + } + + public function testAllowsListsToContainValues() + { + $doc = ' + query q($input:[String]) { + list(input: $input) + } + '; + $ast = Parser::parse($doc); + $expected = ['data' => ['list' => '["A"]']]; + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast, null, ['input' => ['A']])); + } + + public function testAllowsListsToContainNull() + { + $doc = ' + query q($input:[String]) { + list(input: $input) + } + '; + $ast = Parser::parse($doc); + $expected = ['data' => ['list' => '["A",null,"B"]']]; + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast, null, ['input' => ['A',null,'B']])); + } + + public function testDoesNotAllowNonNullListsToBeNull() + { + $doc = ' + query q($input:[String]!) { + nnList(input: $input) + } + '; + $ast = Parser::parse($doc); + $expected = [ + 'data' => null, + 'errors' => [ + new FormattedError( + 'Variable $input expected value of type [String]! but got: null.', + [new SourceLocation(2, 17)] + ) + ] + ]; + + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast, null, ['input' => null])); + } + + public function testAllowsNonNullListsToContainValues() + { + $doc = ' + query q($input:[String]!) { + nnList(input: $input) + } + '; + $ast = Parser::parse($doc); + $expected = ['data' => ['nnList' => '["A"]']]; + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast, null, ['input' => 'A'])); + } + + public function testAllowsNonNullListsToContainNull() + { + $doc = ' + query q($input:[String]!) { + nnList(input: $input) + } + '; + $ast = Parser::parse($doc); + $expected = ['data' => ['nnList' => '["A",null,"B"]']]; + + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast, null, ['input' => ['A',null,'B']])); + } + + public function testAllowsListsOfNonNullsToBeNull() + { + $doc = ' + query q($input:[String!]) { + listNN(input: $input) + } + '; + $ast = Parser::parse($doc); + $expected = ['data' => ['listNN' => 'null']]; + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast, null, ['input' => null])); + } + + public function testAllowsListsOfNonNullsToContainValues() + { + $doc = ' + query q($input:[String!]) { + listNN(input: $input) + } + '; + $ast = Parser::parse($doc); + $expected = ['data' => ['listNN' => '["A"]']]; + + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast, null, ['input' => 'A'])); + } + + public function testDoesNotAllowListsOfNonNullsToContainNull() + { + $doc = ' + query q($input:[String!]) { + listNN(input: $input) + } + '; + $ast = Parser::parse($doc); + $expected = [ + 'data' => null, + 'errors' => [ + new FormattedError( + 'Variable $input expected value of type [String!] but got: ["A",null,"B"].', + [new SourceLocation(2, 17)] + ) + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast, null, ['input' => ['A', null, 'B']])); + } + + public function testDoesNotAllowNonNullListsOfNonNullsToBeNull() + { + $doc = ' + query q($input:[String!]!) { + nnListNN(input: $input) + } + '; + $ast = Parser::parse($doc); + $expected = [ + 'data' => null, + 'errors' => [ + new FormattedError( + 'Variable $input expected value of type [String!]! but got: null.', + [new SourceLocation(2, 17)] + ) + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast, null, ['input' => null])); + } + + public function testAllowsNonNullListsOfNonNullsToContainValues() + { + $doc = ' + query q($input:[String!]!) { + nnListNN(input: $input) + } + '; + $ast = Parser::parse($doc); + $expected = ['data' => ['nnListNN' => '["A"]']]; + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast, null, ['input' => ['A']])); + } + + public function testDoesNotAllowNonNullListsOfNonNullsToContainNull() + { + $doc = ' + query q($input:[String!]!) { + nnListNN(input: $input) + } + '; + $ast = Parser::parse($doc); + $expected = [ + 'data' => null, + 'errors' => [ + new FormattedError( + 'Variable $input expected value of type [String!]! but got: ["A",null,"B"].', + [new SourceLocation(2, 17)] + ) + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema(), null, $ast, null, ['input' => ['A',null,'B']])); + } + + + public function schema() + { + $TestInputObject = new InputObjectType([ + 'name' => 'TestInputObject', + 'fields' => [ + 'a' => ['type' => Type::string()], + 'b' => ['type' => Type::listOf(Type::string())], + 'c' => ['type' => Type::nonNull(Type::string())] + ] + ]); + + $TestType = new ObjectType([ + 'name' => 'TestType', + 'fields' => [ + 'fieldWithObjectInput' => [ + 'type' => Type::string(), + 'args' => ['input' => ['type' => $TestInputObject]], + 'resolve' => function ($_, $args) { + return json_encode($args['input']); + } + ], + 'fieldWithNullableStringInput' => [ + 'type' => Type::string(), + 'args' => ['input' => ['type' => Type::string()]], + 'resolve' => function ($_, $args) { + return json_encode($args['input']); + } + ], + 'fieldWithNonNullableStringInput' => [ + 'type' => Type::string(), + 'args' => ['input' => ['type' => Type::nonNull(Type::string())]], + 'resolve' => function ($_, $args) { + return json_encode($args['input']); + } + ], + 'list' => [ + 'type' => Type::string(), + 'args' => ['input' => ['type' => Type::listOf(Type::string())]], + 'resolve' => function ($_, $args) { + return json_encode($args['input']); + } + ], + 'nnList' => [ + 'type' => Type::string(), + 'args' => ['input' => ['type' => Type::nonNull(Type::listOf(Type::string()))]], + 'resolve' => function ($_, $args) { + return json_encode($args['input']); + } + ], + 'listNN' => [ + 'type' => Type::string(), + 'args' => ['input' => ['type' => Type::listOf(Type::nonNull(Type::string()))]], + 'resolve' => function ($_, $args) { + return json_encode($args['input']); + } + ], + 'nnListNN' => [ + 'type' => Type::string(), + 'args' => ['input' => ['type' => Type::nonNull(Type::listOf(Type::nonNull(Type::string())))]], + 'resolve' => function ($_, $args) { + return json_encode($args['input']); + } + ], + ] + ]); + + $schema = new Schema($TestType); + return $schema; + } +} diff --git a/tests/Executor/ListsTest.php b/tests/Executor/ListsTest.php new file mode 100644 index 0000000..36b0294 --- /dev/null +++ b/tests/Executor/ListsTest.php @@ -0,0 +1,385 @@ + ['nest' => ['list' => [1,2]]]]; + $this->assertEquals($expected, Executor::execute($this->schema(), $this->data(), $ast, 'Q', [])); + } + + public function testHandlesListsOfNonNullsWhenTheyReturnNonNullValues() + { + $doc = ' + query Q { + nest { + listOfNonNull, + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'nest' => [ + 'listOfNonNull' => [1, 2], + ] + ] + ]; + + $this->assertEquals($expected, Executor::execute($this->schema(), $this->data(), $ast, 'Q', [])); + } + + public function testHandlesNonNullListsOfWhenTheyReturnNonNullValues() + { + $doc = ' + query Q { + nest { + nonNullList, + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'nest' => [ + 'nonNullList' => [1, 2], + ] + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema(), $this->data(), $ast, 'Q', [])); + } + + public function testHandlesNonNullListsOfNonNullsWhenTheyReturnNonNullValues() + { + $doc = ' + query Q { + nest { + nonNullListOfNonNull, + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'nest' => [ + 'nonNullListOfNonNull' => [1, 2], + ] + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema(), $this->data(), $ast, 'Q', [])); + } + + public function testHandlesListsWhenTheyReturnNullAsAValue() + { + $doc = ' + query Q { + nest { + listContainsNull, + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'nest' => [ + 'listContainsNull' => [1, null, 2], + ] + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema(), $this->data(), $ast, 'Q', [])); + } + + public function testHandlesListsOfNonNullsWhenTheyReturnNullAsAValue() + { + $doc = ' + query Q { + nest { + listOfNonNullContainsNull, + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'nest' => [ + 'listOfNonNullContainsNull' => null + ] + ], + 'errors' => [ + new FormattedError( + 'Cannot return null for non-nullable type.', + [new SourceLocation(4, 11)] + ) + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema(), $this->data(), $ast, 'Q', [])); + } + + public function testHandlesNonNullListsOfWhenTheyReturnNullAsAValue() + { + $doc = ' + query Q { + nest { + nonNullListContainsNull, + } + } + '; + + $ast = Parser::parse($doc); + $expected = [ + 'data' => [ + 'nest' => ['nonNullListContainsNull' => [1, null, 2]] + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema(), $this->data(), $ast, 'Q', [])); + } + + public function testHandlesNonNullListsOfNonNullsWhenTheyReturnNullAsAValue() + { + $doc = ' + query Q { + nest { + nonNullListOfNonNullContainsNull, + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'nest' => null + ], + 'errors' => [ + new FormattedError( + 'Cannot return null for non-nullable type.', + [new SourceLocation(4, 11)] + ) + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema(), $this->data(), $ast, 'Q', [])); + } + + public function testHandlesListsWhenTheyReturnNull() + { + $doc = ' + query Q { + nest { + listReturnsNull, + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'nest' => [ + 'listReturnsNull' => null + ] + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema(), $this->data(), $ast, 'Q', [])); + } + + public function testHandlesListsOfNonNullsWhenTheyReturnNull() + { + $doc = ' + query Q { + nest { + listOfNonNullReturnsNull, + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'nest' => [ + 'listOfNonNullReturnsNull' => null + ] + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema(), $this->data(), $ast, 'Q', [])); + } + + public function testHandlesNonNullListsOfWhenTheyReturnNull() + { + $doc = ' + query Q { + nest { + nonNullListReturnsNull, + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'nest' => null, + ], + 'errors' => [ + new FormattedError( + 'Cannot return null for non-nullable type.', + [new SourceLocation(4, 11)] + ) + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema(), $this->data(), $ast, 'Q', [])); + } + + public function testHandlesNonNullListsOfNonNullsWhenTheyReturnNull() + { + $doc = ' + query Q { + nest { + nonNullListOfNonNullReturnsNull, + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'nest' => null + ], + 'errors' => [ + new FormattedError( + 'Cannot return null for non-nullable type.', + [new SourceLocation(4, 11)] + ) + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema(), $this->data(), $ast, 'Q', [])); + } + + + + private function schema() + { + $dataType = new ObjectType([ + 'name' => 'DataType', + 'fields' => [ + 'list' => [ + 'type' => Type::listOf(Type::int()) + ], + 'listOfNonNull' => [ + 'type' => Type::listOf(Type::nonNull(Type::int())) + ], + 'nonNullList' => [ + 'type' => Type::nonNull(Type::listOf(Type::int())) + ], + 'nonNullListOfNonNull' => [ + 'type' => Type::nonNull(Type::listOf(Type::nonNull(Type::int()))) + ], + 'listContainsNull' => [ + 'type' => Type::listOf(Type::int()) + ], + 'listOfNonNullContainsNull' => [ + 'type' => Type::listOf(Type::nonNull(Type::int())), + ], + 'nonNullListContainsNull' => [ + 'type' => Type::nonNull(Type::listOf(Type::int())) + ], + 'nonNullListOfNonNullContainsNull' => [ + 'type' => Type::nonNull(Type::listOf(Type::nonNull(Type::int()))) + ], + 'listReturnsNull' => [ + 'type' => Type::listOf(Type::int()) + ], + 'listOfNonNullReturnsNull' => [ + 'type' => Type::listOf(Type::nonNull(Type::int())) + ], + 'nonNullListReturnsNull' => [ + 'type' => Type::nonNull(Type::listOf(Type::int())) + ], + 'nonNullListOfNonNullReturnsNull' => [ + 'type' => Type::nonNull(Type::listOf(Type::nonNull(Type::int()))) + ], + 'nest' => ['type' => function () use (&$dataType) { + return $dataType; + }] + ] + ]); + + $schema = new Schema($dataType); + return $schema; + } + + private function data() + { + return [ + 'list' => function () { + return [1, 2]; + }, + 'listOfNonNull' => function () { + return [1, 2]; + }, + 'nonNullList' => function () { + return [1, 2]; + }, + 'nonNullListOfNonNull' => function () { + return [1, 2]; + }, + 'listContainsNull' => function () { + return [1, null, 2]; + }, + 'listOfNonNullContainsNull' => function () { + return [1, null, 2]; + }, + 'nonNullListContainsNull' => function () { + return [1, null, 2]; + }, + 'nonNullListOfNonNullContainsNull' => function () { + return [1, null, 2]; + }, + 'listReturnsNull' => function () { + return null; + }, + 'listOfNonNullReturnsNull' => function () { + return null; + }, + 'nonNullListReturnsNull' => function () { + return null; + }, + 'nonNullListOfNonNullReturnsNull' => function () { + return null; + }, + 'nest' => function () { + return self::data(); + } + ]; + } +} \ No newline at end of file diff --git a/tests/Executor/MutationsTest.php b/tests/Executor/MutationsTest.php new file mode 100644 index 0000000..35e5653 --- /dev/null +++ b/tests/Executor/MutationsTest.php @@ -0,0 +1,219 @@ +schema(), new Root(6), $ast, 'M'); + $expected = [ + 'data' => [ + 'first' => [ + 'theNumber' => 1 + ], + 'second' => [ + 'theNumber' => 2 + ], + 'third' => [ + 'theNumber' => 3 + ], + 'fourth' => [ + 'theNumber' => 4 + ], + 'fifth' => [ + 'theNumber' => 5 + ] + ] + ]; + $this->assertEquals($mutationResult, $expected); + } + + public function testEvaluatesMutationsCorrectlyInThePresenseOfAFailedMutation() + { + $doc = 'mutation M { + first: immediatelyChangeTheNumber(newNumber: 1) { + theNumber + }, + second: promiseToChangeTheNumber(newNumber: 2) { + theNumber + }, + third: failToChangeTheNumber(newNumber: 3) { + theNumber + } + fourth: promiseToChangeTheNumber(newNumber: 4) { + theNumber + }, + fifth: immediatelyChangeTheNumber(newNumber: 5) { + theNumber + } + sixth: promiseAndFailToChangeTheNumber(newNumber: 6) { + theNumber + } + }'; + $ast = Parser::parse($doc); + $mutationResult = Executor::execute($this->schema(), new Root(6), $ast, 'M'); + $expected = [ + 'data' => [ + 'first' => [ + 'theNumber' => 1 + ], + 'second' => [ + 'theNumber' => 2 + ], + 'third' => null, + 'fourth' => [ + 'theNumber' => 4 + ], + 'fifth' => [ + 'theNumber' => 5 + ], + 'sixth' => null, + ], + 'errors' => [ + new FormattedError( + 'Cannot change the number', + [new SourceLocation(8, 7)] + ), + new FormattedError( + 'Cannot change the number', + [new SourceLocation(17, 7)] + ) + ] + ]; + $this->assertEquals($expected, $mutationResult); + } + + private function schema() + { + $numberHolderType = new ObjectType([ + 'fields' => [ + 'theNumber' => ['type' => Type::int()], + ], + 'name' => 'NumberHolder', + ]); + $schema = new Schema( + new ObjectType([ + 'fields' => [ + 'numberHolder' => ['type' => $numberHolderType], + ], + 'name' => 'Query', + ]), + new ObjectType([ + 'fields' => [ + 'immediatelyChangeTheNumber' => [ + 'type' => $numberHolderType, + 'args' => ['newNumber' => ['type' => Type::int()]], + 'resolve' => (function (Root $obj, $args) { + return $obj->immediatelyChangeTheNumber($args['newNumber']); + }) + ], + 'promiseToChangeTheNumber' => [ + 'type' => $numberHolderType, + 'args' => ['newNumber' => ['type' => Type::int()]], + 'resolve' => (function (Root $obj, $args) { + return $obj->promiseToChangeTheNumber($args['newNumber']); + }) + ], + 'failToChangeTheNumber' => [ + 'type' => $numberHolderType, + 'args' => ['newNumber' => ['type' => Type::int()]], + 'resolve' => (function (Root $obj, $args) { + return $obj->failToChangeTheNumber($args['newNumber']); + }) + ], + 'promiseAndFailToChangeTheNumber' => [ + 'type' => $numberHolderType, + 'args' => ['newNumber' => ['type' => Type::int()]], + 'resolve' => (function (Root $obj, $args) { + return $obj->promiseAndFailToChangeTheNumber($args['newNumber']); + }) + ] + ], + 'name' => 'Mutation', + ]) + ); + return $schema; + } +} + +class NumberHolder +{ + public $theNumber; + + public function __construct($originalNumber) + { + $this->theNumber = $originalNumber; + } +} + +class Root { + public $numberHolder; + + public function __construct($originalNumber) + { + $this->numberHolder = new NumberHolder($originalNumber); + } + + /** + * @param $newNumber + * @return NumberHolder + */ + public function immediatelyChangeTheNumber($newNumber) + { + $this->numberHolder->theNumber = $newNumber; + return $this->numberHolder; + } + + /** + * @param $newNumber + * @return NumberHolder + */ + public function promiseToChangeTheNumber($newNumber) + { + // No promises + return $this->immediatelyChangeTheNumber($newNumber); + } + + /** + * @throws \Exception + */ + public function failToChangeTheNumber() + { + throw new \Exception('Cannot change the number'); + } + + /** + * @throws \Exception + */ + public function promiseAndFailToChangeTheNumber() + { + // No promises + throw new \Exception("Cannot change the number"); + } +} diff --git a/tests/Executor/NonNullTest.php b/tests/Executor/NonNullTest.php new file mode 100644 index 0000000..76e00cc --- /dev/null +++ b/tests/Executor/NonNullTest.php @@ -0,0 +1,262 @@ +syncError = new Error('sync'); + $this->nonNullSyncError = new Error('nonNullSync'); + + $this->throwingData = [ + 'sync' => function () { + throw $this->syncError; + }, + 'nonNullSync' => function () { + throw $this->nonNullSyncError; + }, + 'nest' => function () { + return $this->throwingData; + }, + 'nonNullNest' => function () { + return $this->throwingData; + }, + ]; + + $this->nullingData = [ + 'sync' => function () { + return null; + }, + 'nonNullSync' => function () { + return null; + }, + 'nest' => function () { + return $this->nullingData; + }, + 'nonNullNest' => function () { + return $this->nullingData; + }, + ]; + + $dataType = new ObjectType([ + 'name' => 'DataType', + 'fields' => [ + 'sync' => ['type' => Type::string()], + 'nonNullSync' => ['type' => Type::nonNull(Type::string())], + 'nest' => ['type' => function () use (&$dataType) { + return $dataType; + }], + 'nonNullNest' => ['type' => function () use (&$dataType) { + return Type::nonNull($dataType); + }] + ] + ]); + + $this->schema = new Schema($dataType); + } + + // Execute: handles non-nullable types + public function testNullsANullableFieldThatThrowsSynchronously() + { + $doc = ' + query Q { + sync + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'sync' => null, + ], + 'errors' => [ + new FormattedError( + $this->syncError->message, + [new SourceLocation(3, 9)] + ) + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema, $this->throwingData, $ast, 'Q', [])); + } + + public function testNullsASynchronouslyReturnedObjectThatContainsANonNullableFieldThatThrowsSynchronously() + { + // nulls a synchronously returned object that contains a non-nullable field that throws synchronously + $doc = ' + query Q { + nest { + nonNullSync, + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'nest' => null + ], + 'errors' => [ + new FormattedError($this->nonNullSyncError->message, [new SourceLocation(4, 11)]) + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema, $this->throwingData, $ast, 'Q', [])); + } + + public function testNullsAComplexTreeOfNullableFieldsThatThrow() + { + $doc = ' + query Q { + nest { + sync + nest { + sync + } + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'nest' => [ + 'sync' => null, + 'nest' => [ + 'sync' => null, + ] + ] + ], + 'errors' => [ + new FormattedError($this->syncError->message, [new SourceLocation(4, 11)]), + new FormattedError($this->syncError->message, [new SourceLocation(6, 13)]), + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema, $this->throwingData, $ast, 'Q', [])); + } + + public function testNullsANullableFieldThatSynchronouslyReturnsNull() + { + $doc = ' + query Q { + sync + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'sync' => null, + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema, $this->nullingData, $ast, 'Q', [])); + } + + public function test4() + { + // nulls a synchronously returned object that contains a non-nullable field that returns null synchronously + $doc = ' + query Q { + nest { + nonNullSync, + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'nest' => null + ], + 'errors' => [ + new FormattedError('Cannot return null for non-nullable type.', [new SourceLocation(4, 11)]) + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema, $this->nullingData, $ast, 'Q', [])); + } + + public function test5() + { + // nulls a complex tree of nullable fields that return null + + $doc = ' + query Q { + nest { + sync + nest { + sync + nest { + sync + } + } + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'nest' => [ + 'sync' => null, + 'nest' => [ + 'sync' => null, + 'nest' => [ + 'sync' => null + ] + ], + ], + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema, $this->nullingData, $ast, 'Q', [])); + } + + public function testNullsTheTopLevelIfSyncNonNullableFieldThrows() + { + $doc = ' + query Q { nonNullSync } + '; + + $expected = [ + 'data' => null, + 'errors' => [ + new FormattedError($this->nonNullSyncError->message, [new SourceLocation(2, 17)]) + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema, $this->throwingData, Parser::parse($doc))); + } + + public function testNullsTheTopLevelIfSyncNonNullableFieldReturnsNull() + { + // nulls the top level if sync non-nullable field returns null + $doc = ' + query Q { nonNullSync } + '; + + $expected = [ + 'data' => null, + 'errors' => [ + new FormattedError('Cannot return null for non-nullable type.', [new SourceLocation(2, 17)]), + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema, $this->nullingData, Parser::parse($doc))); + } +} diff --git a/tests/Executor/UnionInterfaceTest.php b/tests/Executor/UnionInterfaceTest.php new file mode 100644 index 0000000..e04a9ae --- /dev/null +++ b/tests/Executor/UnionInterfaceTest.php @@ -0,0 +1,364 @@ + 'Named', + 'fields' => [ + 'name' => ['type' => Type::string()] + ] + ]); + + $DogType = new ObjectType([ + 'name' => 'Dog', + 'interfaces' => [$NamedType], + 'fields' => [ + 'name' => ['type' => Type::string()], + 'barks' => ['type' => Type::boolean()] + ], + 'isTypeOf' => function ($value) { + return $value instanceof Dog; + } + ]); + + $CatType = new ObjectType([ + 'name' => 'Cat', + 'interfaces' => [$NamedType], + 'fields' => [ + 'name' => ['type' => Type::string()], + 'meows' => ['type' => Type::boolean()] + ], + 'isTypeOf' => function ($value) { + return $value instanceof Cat; + } + ]); + + $PetType = new UnionType([ + 'name' => 'Pet', + 'types' => [$DogType, $CatType], + 'resolveType' => function ($value) use ($DogType, $CatType) { + if ($value instanceof Dog) { + return $DogType; + } + if ($value instanceof Cat) { + return $CatType; + } + } + ]); + + $PersonType = new ObjectType([ + 'name' => 'Person', + 'interfaces' => [$NamedType], + 'fields' => [ + 'name' => ['type' => Type::string()], + 'pets' => ['type' => Type::listOf($PetType)], + 'friends' => ['type' => Type::listOf($NamedType)] + ], + 'isTypeOf' => function ($value) { + return $value instanceof Person; + } + ]); + + $this->schema = new Schema($PersonType); + + $this->garfield = new Cat('Garfield', false); + $this->odie = new Dog('Odie', true); + $this->liz = new Person('Liz'); + $this->john = new Person('John', [$this->garfield, $this->odie], [$this->liz, $this->odie]); + + } + + // Execute: Union and intersection types + public function testCanIntrospectOnUnionAndIntersectionTypes() + { + + $ast = Parser::parse(' + { + Named: __type(name: "Named") { + kind + name + fields { name } + interfaces { name } + possibleTypes { name } + enumValues { name } + inputFields { name } + } + Pet: __type(name: "Pet") { + kind + name + fields { name } + interfaces { name } + possibleTypes { name } + enumValues { name } + inputFields { name } + } + } + '); + + $expected = [ + 'data' => [ + 'Named' => [ + 'kind' => 'INTERFACE', + 'name' => 'Named', + 'fields' => [ + ['name' => 'name'] + ], + 'interfaces' => null, + 'possibleTypes' => [ + ['name' => 'Dog'], + ['name' => 'Cat'], + ['name' => 'Person'] + ], + 'enumValues' => null, + 'inputFields' => null + ], + 'Pet' => [ + 'kind' => 'UNION', + 'name' => 'Pet', + 'fields' => null, + 'interfaces' => null, + 'possibleTypes' => [ + ['name' => 'Dog'], + ['name' => 'Cat'] + ], + 'enumValues' => null, + 'inputFields' => null + ] + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema, null, $ast)); + } + + public function testExecutesUsingUnionTypes() + { + // NOTE: This is an *invalid* query, but it should be an *executable* query. + $ast = Parser::parse(' + { + __typename + name + pets { + __typename + name + barks + meows + } + } + '); + $expected = [ + 'data' => [ + '__typename' => 'Person', + 'name' => 'John', + 'pets' => [ + ['__typename' => 'Cat', 'name' => 'Garfield', 'meows' => false], + ['__typename' => 'Dog', 'name' => 'Odie', 'barks' => true] + ] + ] + ]; + + $this->assertEquals($expected, Executor::execute($this->schema, $this->john, $ast)); + } + + public function testExecutesUnionTypesWithInlineFragments() + { + // This is the valid version of the query in the above test. + $ast = Parser::parse(' + { + __typename + name + pets { + __typename + ... on Dog { + name + barks + } + ... on Cat { + name + meows + } + } + } + '); + $expected = [ + 'data' => [ + '__typename' => 'Person', + 'name' => 'John', + 'pets' => [ + ['__typename' => 'Cat', 'name' => 'Garfield', 'meows' => false], + ['__typename' => 'Dog', 'name' => 'Odie', 'barks' => true] + ] + + ] + ]; + $this->assertEquals($expected, Executor::execute($this->schema, $this->john, $ast)); + } + + public function testExecutesUsingInterfaceTypes() + { + // NOTE: This is an *invalid* query, but it should be an *executable* query. + $ast = Parser::parse(' + { + __typename + name + friends { + __typename + name + barks + meows + } + } + '); + $expected = [ + 'data' => [ + '__typename' => 'Person', + 'name' => 'John', + 'friends' => [ + ['__typename' => 'Person', 'name' => 'Liz'], + ['__typename' => 'Dog', 'name' => 'Odie', 'barks' => true] + ] + ] + ]; + + $this->assertEquals($expected, Executor::execute($this->schema, $this->john, $ast)); + } + + public function testExecutesInterfaceTypesWithInlineFragments() + { + // This is the valid version of the query in the above test. + $ast = Parser::parse(' + { + __typename + name + friends { + __typename + name + ... on Dog { + barks + } + ... on Cat { + meows + } + } + } + '); + $expected = [ + 'data' => [ + '__typename' => 'Person', + 'name' => 'John', + 'friends' => [ + ['__typename' => 'Person', 'name' => 'Liz'], + ['__typename' => 'Dog', 'name' => 'Odie', 'barks' => true] + ] + ] + ]; + + $this->assertEquals($expected, Executor::execute($this->schema, $this->john, $ast)); + } + + public function testAllowsFragmentConditionsToBeAbstractTypes() + { + $ast = Parser::parse(' + { + __typename + name + pets { ...PetFields } + friends { ...FriendFields } + } + + fragment PetFields on Pet { + __typename + ... on Dog { + name + barks + } + ... on Cat { + name + meows + } + } + + fragment FriendFields on Named { + __typename + name + ... on Dog { + barks + } + ... on Cat { + meows + } + } + '); + + $expected = [ + 'data' => [ + '__typename' => 'Person', + 'name' => 'John', + 'pets' => [ + ['__typename' => 'Cat', 'name' => 'Garfield', 'meows' => false], + ['__typename' => 'Dog', 'name' => 'Odie', 'barks' => true] + ], + 'friends' => [ + ['__typename' => 'Person', 'name' => 'Liz'], + ['__typename' => 'Dog', 'name' => 'Odie', 'barks' => true] + ] + ] + ]; + + $this->assertEquals($expected, Executor::execute($this->schema, $this->john, $ast)); + } +} + + +class Dog +{ + public $name; + public $barks; + + function __construct($name, $barks) + { + $this->name = $name; + $this->barks = $barks; + } +} + +class Cat +{ + public $name; + public $meows; + + function __construct($name, $meows) + { + $this->name = $name; + $this->meows = $meows; + } +} + +class Person +{ + public $name; + public $pets; + public $friends; + + function __construct($name, $pets = null, $friends = null) + { + $this->name = $name; + $this->pets = $pets; + $this->friends = $friends; + } +} diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php new file mode 100644 index 0000000..a54e05a --- /dev/null +++ b/tests/Language/LexerTest.php @@ -0,0 +1,260 @@ +assertEquals(new Token(Token::NAME, 6, 9, 'foo'), $this->lexOne($example1)); + + $example2 = ' + #comment + foo#comment +'; + + $this->assertEquals(new Token(Token::NAME, 18, 21, 'foo'), $this->lexOne($example2)); + + $example3 = ',,,foo,,,'; + $this->assertEquals(new Token(Token::NAME, 3, 6, 'foo'), $this->lexOne($example3)); + } + + public function testErrorsRespectWhitespace() + { + $example = " + + ? + + +"; + try { + $this->lexOne($example); + $this->fail('Expected exception not thrown'); + } catch (Exception $e) { + $this->assertEquals( + 'Syntax Error GraphQL (3:5) Unexpected character "?"' . "\n" . + "\n" . + "2: \n" . + "3: ?\n" . + " ^\n" . + "4: \n", + $e->getMessage() + ); + } + } + + public function testLexesStrings() + { + $this->assertEquals(new Token(Token::STRING, 0, 8, 'simple'), $this->lexOne('"simple"')); + $this->assertEquals(new Token(Token::STRING, 0, 15, ' white space '), $this->lexOne('" white space "')); + $this->assertEquals(new Token(Token::STRING, 0, 10, 'quote "'), $this->lexOne('"quote \\""')); + $this->assertEquals(new Token(Token::STRING, 0, 20, 'escaped \n\r\b\t\f'), $this->lexOne('"escaped \\n\\r\\b\\t\\f"')); + $this->assertEquals(new Token(Token::STRING, 0, 15, 'slashes \\ \/'), $this->lexOne('"slashes \\\\ \\/"')); + + $this->assertEquals(new Token(Token::STRING, 0, 13, 'unicode яуц'), $this->lexOne('"unicode яуц"')); + + $unicode = json_decode('"\u1234\u5678\u90AB\uCDEF"'); + $this->assertEquals(new Token(Token::STRING, 0, 34, 'unicode ' . $unicode), $this->lexOne('"unicode \u1234\u5678\u90AB\uCDEF"')); + } + + public function testReportsUsefulErrors() + { + $run = function($num, $str, $expectedMessage) { + try { + $this->lexOne($str); + $this->fail('Expected exception not thrown in example: ' . $num); + } catch (Exception $e) { + $this->assertEquals($expectedMessage, $e->getMessage(), "Test case $num failed"); + } + }; + + $run(1, '"no end quote', "Syntax Error GraphQL (1:14) Unterminated string\n\n1: \"no end quote\n ^\n"); + $run(2, '"multi'."\n".'line"', "Syntax Error GraphQL (1:7) Unterminated string\n\n1: \"multi\n ^\n2: line\"\n"); + $run(3, '"multi'."\r".'line"', "Syntax Error GraphQL (1:7) Unterminated string\n\n1: \"multi\n ^\n2: line\"\n"); + $run(4, '"multi' . json_decode('"\u2028"') . 'line"', "Syntax Error GraphQL (1:7) Unterminated string\n\n1: \"multi\n ^\n2: line\"\n"); + $run(5, '"multi' . json_decode('"\u2029"') . 'line"', "Syntax Error GraphQL (1:7) Unterminated string\n\n1: \"multi\n ^\n2: line\"\n"); + $run(6, '"bad \\z esc"', "Syntax Error GraphQL (1:7) Bad character escape sequence\n\n1: \"bad \\z esc\"\n ^\n"); + $run(7, '"bad \\x esc"', "Syntax Error GraphQL (1:7) Bad character escape sequence\n\n1: \"bad \\x esc\"\n ^\n"); + $run(8, '"bad \\u1 esc"', "Syntax Error GraphQL (1:7) Bad character escape sequence\n\n1: \"bad \\u1 esc\"\n ^\n"); + $run(9, '"bad \\u0XX1 esc"', "Syntax Error GraphQL (1:7) Bad character escape sequence\n\n1: \"bad \\u0XX1 esc\"\n ^\n"); + $run(10, '"bad \\uXXXX esc"', "Syntax Error GraphQL (1:7) Bad character escape sequence\n\n1: \"bad \\uXXXX esc\"\n ^\n"); + $run(11, '"bad \\uFXXX esc"', "Syntax Error GraphQL (1:7) Bad character escape sequence\n\n1: \"bad \\uFXXX esc\"\n ^\n"); + $run(12, '"bad \\uXXXF esc"', "Syntax Error GraphQL (1:7) Bad character escape sequence\n\n1: \"bad \\uXXXF esc\"\n ^\n"); + } + + public function testLexesNumbers() + { + $this->assertEquals( + new Token(Token::STRING, 0, 8, 'simple'), + $this->lexOne('"simple"') + ); + $this->assertEquals( + new Token(Token::STRING, 0, 15, ' white space '), + $this->lexOne('" white space "') + ); + $this->assertEquals( + new Token(Token::STRING, 0, 20, 'escaped \n\r\b\t\f'), + $this->lexOne('"escaped \\n\\r\\b\\t\\f"') + ); + $this->assertEquals( + new Token(Token::STRING, 0, 15, 'slashes \\ \/'), + $this->lexOne('"slashes \\\\ \\/"') + ); + $this->assertEquals( + new Token(Token::STRING, 0, 34, 'unicode ' . json_decode('"\u1234\u5678\u90AB\uCDEF"')), + $this->lexOne('"unicode \\u1234\\u5678\\u90AB\\uCDEF"') + ); + $this->assertEquals( + new Token(Token::INT, 0, 1, '4'), + $this->lexOne('4') + ); + $this->assertEquals( + new Token(Token::FLOAT, 0, 5, '4.123'), + $this->lexOne('4.123') + ); + $this->assertEquals( + new Token(Token::INT, 0, 2, '-4'), + $this->lexOne('-4') + ); + $this->assertEquals( + new Token(Token::INT, 0, 1, '9'), + $this->lexOne('9') + ); + $this->assertEquals( + new Token(Token::INT, 0, 1, '0'), + $this->lexOne('0') + ); + $this->assertEquals( + new Token(Token::INT, 0, 1, '0'), + $this->lexOne('00') + ); + $this->assertEquals( + new Token(Token::FLOAT, 0, 6, '-4.123'), + $this->lexOne('-4.123') + ); + $this->assertEquals( + new Token(Token::FLOAT, 0, 5, '0.123'), + $this->lexOne('0.123') + ); + $this->assertEquals( + new Token(Token::FLOAT, 0, 8, '-1.123e4'), + $this->lexOne('-1.123e4') + ); + $this->assertEquals( + new Token(Token::FLOAT, 0, 9, '-1.123e-4'), + $this->lexOne('-1.123e-4') + ); + $this->assertEquals( + new Token(Token::FLOAT, 0, 11, '-1.123e4567'), + $this->lexOne('-1.123e4567') + ); + } + + public function testReportsUsefulNumberErrors() + { + $run = function($num, $str, $expectedMessage) { + try { + $this->lexOne($str); + $this->fail('Expected exception not thrown in example: ' . $num); + } catch (Exception $e) { + $this->assertEquals($expectedMessage, $e->getMessage(), "Test case $num failed"); + } + }; + + $run(1, '+1', "Syntax Error GraphQL (1:1) Unexpected character \"+\"\n\n1: +1\n ^\n"); + $run(2, '1.', "Syntax Error GraphQL (1:3) Invalid number\n\n1: 1.\n ^\n"); + $run(3, '1.A', "Syntax Error GraphQL (1:3) Invalid number\n\n1: 1.A\n ^\n"); + $run(4, '-A', "Syntax Error GraphQL (1:2) Invalid number\n\n1: -A\n ^\n"); + $run(5, '1.0e+4', "Syntax Error GraphQL (1:5) Invalid number\n\n1: 1.0e+4\n ^\n"); + $run(6, '1.0e', "Syntax Error GraphQL (1:5) Invalid number\n\n1: 1.0e\n ^\n"); + $run(7, '1.0eA', "Syntax Error GraphQL (1:5) Invalid number\n\n1: 1.0eA\n ^\n"); + } + + public function testLexesPunctuation() + { + $this->assertEquals( + new Token(Token::BANG, 0, 1, null), + $this->lexOne('!') + ); + $this->assertEquals( + new Token(Token::DOLLAR, 0, 1, null), + $this->lexOne('$') + ); + $this->assertEquals( + new Token(Token::PAREN_L, 0, 1, null), + $this->lexOne('(') + ); + $this->assertEquals( + new Token(Token::PAREN_R, 0, 1, null), + $this->lexOne(')') + ); + $this->assertEquals( + new Token(Token::SPREAD, 0, 3, null), + $this->lexOne('...') + ); + $this->assertEquals( + new Token(Token::COLON, 0, 1, null), + $this->lexOne(':') + ); + $this->assertEquals( + new Token(Token::EQUALS, 0, 1, null), + $this->lexOne('=') + ); + $this->assertEquals( + new Token(Token::AT, 0, 1, null), + $this->lexOne('@') + ); + $this->assertEquals( + new Token(Token::BRACKET_L, 0, 1, null), + $this->lexOne('[') + ); + $this->assertEquals( + new Token(Token::BRACKET_R, 0, 1, null), + $this->lexOne(']') + ); + $this->assertEquals( + new Token(Token::BRACE_L, 0, 1, null), + $this->lexOne('{') + ); + $this->assertEquals( + new Token(Token::BRACE_R, 0, 1, null), + $this->lexOne('}') + ); + $this->assertEquals( + new Token(Token::PIPE, 0, 1, null), + $this->lexOne('|') + ); + } + + public function testReportsUsefulUnknownCharErrors() + { + $run = function($num, $str, $expectedMessage) { + try { + $this->lexOne($str); + $this->fail('Expected exception not thrown in example: ' . $num); + } catch (Exception $e) { + $this->assertEquals($expectedMessage, $e->getMessage(), "Test case $num failed"); + } + }; + $run(1, '..', "Syntax Error GraphQL (1:1) Unexpected character \".\"\n\n1: ..\n ^\n"); + $run(2, '?', "Syntax Error GraphQL (1:1) Unexpected character \"?\"\n\n1: ?\n ^\n"); + + $unicode = json_decode('"\u203B"'); + $run(3, $unicode, "Syntax Error GraphQL (1:1) Unexpected character \"$unicode\"\n\n1: $unicode\n ^\n"); + } + + /** + * @param string $body + * @return Token + */ + private function lexOne($body) + { + $lexer = new Lexer(new Source($body)); + return $lexer->nextToken(); + } +} diff --git a/tests/Language/ParserTest.php b/tests/Language/ParserTest.php new file mode 100644 index 0000000..c3c5d80 --- /dev/null +++ b/tests/Language/ParserTest.php @@ -0,0 +1,167 @@ +fail('Expected exception not thrown in example: ' . $num); + } catch (Exception $e) { + $this->assertEquals($expectedMessage, $e->getMessage(), "Test case $num failed"); + } + }; + + $run(1, +'{ ...MissingOn } +fragment MissingOn Type +', +"Syntax Error GraphQL (2:20) Expected \"on\", found Name \"Type\"\n\n1: { ...MissingOn }\n2: fragment MissingOn Type\n ^\n3: \n" +); + + $run(2, '{ field: {} }', "Syntax Error GraphQL (1:10) Expected Name, found {\n\n1: { field: {} }\n ^\n"); + $run(3, 'notanoperation Foo { field }', "Syntax Error GraphQL (1:1) Unexpected Name \"notanoperation\"\n\n1: notanoperation Foo { field }\n ^\n"); + $run(4, '...', "Syntax Error GraphQL (1:1) Unexpected ...\n\n1: ...\n ^\n"); + } + + public function testParseProvidesUsefulErrorWhenUsingSource() + { + try { + $this->assertEquals(Parser::parse(new Source('query', 'MyQuery.graphql'))); + $this->fail('Expected exception not thrown'); + } catch (Exception $e) { + $this->assertEquals("Syntax Error MyQuery.graphql (1:6) Expected Name, found EOF\n\n1: query\n ^\n", $e->getMessage()); + } + } + + public function testParsesVariableInlineValues() + { + // Following line should not throw: + Parser::parse('{ field(complex: { a: { b: [ $var ] } }) }'); + } + + public function testParsesConstantDefaultValues() + { + try { + Parser::parse('query Foo($x: Complex = { a: { b: [ $var ] } }) { field }'); + $this->fail('Expected exception not thrown'); + } catch (Exception $e) { + $this->assertEquals( + "Syntax Error GraphQL (1:37) Unexpected $\n\n" . '1: query Foo($x: Complex = { a: { b: [ $var ] } }) { field }' . "\n ^\n", + $e->getMessage() + ); + } + } + + public function testDuplicateKeysInInputObjectIsSyntaxError() + { + try { + Parser::parse('{ field(arg: { a: 1, a: 2 }) }'); + $this->fail('Expected exception not thrown'); + } catch (Exception $e) { + $this->assertEquals( + "Syntax Error GraphQL (1:22) Duplicate input object field a.\n\n1: { field(arg: { a: 1, a: 2 }) }\n ^\n", + $e->getMessage() + ); + } + } + + public function testParsesKitchenSink() + { + // Following should not throw: + $kitchenSink = file_get_contents(__DIR__ . '/kitchen-sink.graphql'); + Parser::parse($kitchenSink); + } + + public function testParseCreatesAst() + { + $source = new Source('{ + node(id: 4) { + id, + name + } +} +'); + $result = Parser::parse($source); + + $expected = new Document(array( + 'loc' => new Location(0, 41, $source), + 'definitions' => array( + new OperationDefinition(array( + 'loc' => new Location(0, 40, $source), + 'operation' => 'query', + 'name' => null, + 'variableDefinitions' => null, + 'directives' => array(), + 'selectionSet' => new SelectionSet(array( + 'loc' => new Location(0, 40, $source), + 'selections' => array( + new Field(array( + 'loc' => new Location(4, 38, $source), + 'alias' => null, + 'name' => new Name(array( + 'loc' => new Location(4, 8, $source), + 'value' => 'node' + )), + 'arguments' => array( + new Argument(array( + 'name' => new Name(array( + 'loc' => new Location(9, 11, $source), + 'value' => 'id' + )), + 'value' => new IntValue(array( + 'loc' => new Location(13, 14, $source), + 'value' => '4' + )), + 'loc' => new Location(9, 14, $source) + )) + ), + 'directives' => [], + 'selectionSet' => new SelectionSet(array( + 'loc' => new Location(16, 38, $source), + 'selections' => array( + new Field(array( + 'loc' => new Location(22, 24, $source), + 'alias' => null, + 'name' => new Name(array( + 'loc' => new Location(22, 24, $source), + 'value' => 'id' + )), + 'arguments' => [], + 'directives' => [], + 'selectionSet' => null + )), + new Field(array( + 'loc' => new Location(30, 34, $source), + 'alias' => null, + 'name' => new Name(array( + 'loc' => new Location(30, 34, $source), + 'value' => 'name' + )), + 'arguments' => [], + 'directives' => [], + 'selectionSet' => null + )) + ) + )) + )) + ) + )) + )) + ) + )); + + $this->assertEquals($expected, $result); + } +} diff --git a/tests/Language/PrinterTest.php b/tests/Language/PrinterTest.php new file mode 100644 index 0000000..c523087 --- /dev/null +++ b/tests/Language/PrinterTest.php @@ -0,0 +1,151 @@ +cloneDeep(); + $this->assertEquals($astCopy, $ast); + + Printer::doPrint($ast); + $this->assertEquals($astCopy, $ast); + } + + public function testPrintsMinimalAst() + { + $ast = new Field(['name' => new Name(['value' => 'foo'])]); + $this->assertEquals('foo', Printer::doPrint($ast)); + } + + public function testProducesHelpfulErrorMessages() + { + $badAst1 = new \ArrayObject(array('random' => 'Data')); + try { + Printer::doPrint($badAst1); + $this->fail('Expected exception not thrown'); + } catch (\Exception $e) { + $this->assertEquals('Invalid AST Node: {"random":"Data"}', $e->getMessage()); + } + } + + public function testX() + { + $queryStr = <<<'EOT' +query queryName($foo: ComplexType, $site: Site = MOBILE) { + whoever123is { + id + } +} + +EOT; +; + $ast = Parser::parse($queryStr, ['noLocation' => true]); + + $expectedAst = new Document(array( + 'definitions' => [ + new OperationDefinition(array( + 'operation' => 'query', + 'name' => new Name([ + 'value' => 'queryName' + ]), + 'variableDefinitions' => [ + new VariableDefinition([ + 'variable' => new Variable([ + 'name' => new Name(['value' => 'foo']) + ]), + 'type' => new Name(['value' => 'ComplexType']) + ]), + new VariableDefinition([ + 'variable' => new Variable([ + 'name' => new Name(['value' => 'site']) + ]), + 'type' => new Name(['value' => 'Site']), + 'defaultValue' => new EnumValue(['value' => 'MOBILE']) + ]) + ], + 'directives' => [], + 'selectionSet' => new SelectionSet([ + 'selections' => [ + new Field([ + 'name' => new Name(['value' => 'whoever123is']), + 'arguments' => [], + 'directives' => [], + 'selectionSet' => new SelectionSet([ + 'selections' => [ + new Field([ + 'name' => new Name(['value' => 'id']), + 'arguments' => [], + 'directives' => [] + ]) + ] + ]) + ]) + ] + ]) + )) + ] + )); + + $this->assertEquals($expectedAst, $ast); + $this->assertEquals($queryStr, Printer::doPrint($ast)); + + } + + public function testPrintsKitchenSink() + { + $kitchenSink = file_get_contents(__DIR__ . '/kitchen-sink.graphql'); + $ast = Parser::parse($kitchenSink); + + $printed = Printer::doPrint($ast); + + $expected = <<<'EOT' +query queryName($foo: ComplexType, $site: Site = MOBILE) { + whoever123is: node(id: [123, 456]) { + id, + ... on User @defer { + field2 { + id, + alias: field1(first: 10, after: $foo) @if: $foo { + id, + ...frag + } + } + } + } +} + +mutation likeStory { + like(story: 123) @defer { + story { + id + } + } +} + +fragment frag on Friend { + foo(size: $size, bar: $b, obj: {key: "value"}) +} + +{ + unnamed(truthy: true, falsey: false), + query +} + +EOT; + $this->assertEquals($expected, $printed); + } +} diff --git a/tests/Language/VisitorTest.php b/tests/Language/VisitorTest.php new file mode 100644 index 0000000..868c00c --- /dev/null +++ b/tests/Language/VisitorTest.php @@ -0,0 +1,415 @@ + true]); + $editedAst = Visitor::visit($ast, [ + 'enter' => function($node) { + if ($node instanceof Field && $node->name->value === 'b') { + return Visitor::removeNode(); + } + } + ]); + + $this->assertEquals( + Parser::parse('{ a, b, c { a, b, c } }', ['noLocation' => true]), + $ast + ); + $this->assertEquals( + Parser::parse('{ a, c { a, c } }', ['noLocation' => true]), + $editedAst + ); + } + + public function testAllowsForEditingOnLeave() + { + $ast = Parser::parse('{ a, b, c { a, b, c } }', ['noLocation' => true]); + $editedAst = Visitor::visit($ast, [ + 'leave' => function($node) { + if ($node instanceof Field && $node->name->value === 'b') { + return Visitor::removeNode(); + } + } + ]); + + $this->assertEquals( + Parser::parse('{ a, b, c { a, b, c } }', ['noLocation' => true]), + $ast + ); + + $this->assertEquals( + Parser::parse('{ a, c { a, c } }', ['noLocation' => true]), + $editedAst + ); + } + + public function testVisitsEditedNode() + { + $addedField = new Field(array( + 'name' => new Name(array( + 'value' => '__typename' + )) + )); + + $didVisitAddedField = false; + + $ast = Parser::parse('{ a { x } }'); + + Visitor::visit($ast, [ + 'enter' => function($node) use ($addedField, &$didVisitAddedField) { + if ($node instanceof Field && $node->name->value === 'a') { + return new Field([ + 'selectionSet' => new SelectionSet(array( + 'selections' => array_merge([$addedField], $node->selectionSet->selections) + )) + ]); + } + if ($node === $addedField) { + $didVisitAddedField = true; + } + } + ]); + + $this->assertTrue($didVisitAddedField); + } + + public function testAllowsSkippingASubTree() + { + $visited = []; + $ast = Parser::parse('{ a, b { x }, c }'); + + Visitor::visit($ast, [ + 'enter' => function(Node $node) use (&$visited) { + $visited[] = ['enter', $node->kind, isset($node->value) ? $node->value : null]; + if ($node instanceof Field && $node->name->value === 'b') { + return Visitor::skipNode(); + } + }, + 'leave' => function (Node $node) use (&$visited) { + $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; + } + ]); + + $expected = [ + [ 'enter', 'Document', null ], + [ 'enter', 'OperationDefinition', null ], + [ 'enter', 'SelectionSet', null ], + [ 'enter', 'Field', null ], + [ 'enter', 'Name', 'a' ], + [ 'leave', 'Name', 'a' ], + [ 'leave', 'Field', null ], + [ 'enter', 'Field', null ], + [ 'enter', 'Field', null ], + [ 'enter', 'Name', 'c' ], + [ 'leave', 'Name', 'c' ], + [ 'leave', 'Field', null ], + [ 'leave', 'SelectionSet', null ], + [ 'leave', 'OperationDefinition', null ], + [ 'leave', 'Document', null ] + ]; + + $this->assertEquals($expected, $visited); + } + + public function testAllowsEarlyExitWhileVisiting() + { + $visited = []; + $ast = Parser::parse('{ a, b { x }, c }'); + + Visitor::visit($ast, [ + 'enter' => function(Node $node) use (&$visited) { + $visited[] = ['enter', $node->kind, isset($node->value) ? $node->value : null]; + if ($node instanceof Name && $node->value === 'x') { + return Visitor::stop(); + } + }, + 'leave' => function(Node $node) use (&$visited) { + $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; + } + ]); + + $expected = [ + [ 'enter', 'Document', null ], + [ 'enter', 'OperationDefinition', null ], + [ 'enter', 'SelectionSet', null ], + [ 'enter', 'Field', null ], + [ 'enter', 'Name', 'a' ], + [ 'leave', 'Name', 'a' ], + [ 'leave', 'Field', null ], + [ 'enter', 'Field', null ], + [ 'enter', 'Name', 'b' ], + [ 'leave', 'Name', 'b' ], + [ 'enter', 'SelectionSet', null ], + [ 'enter', 'Field', null ], + [ 'enter', 'Name', 'x' ] + ]; + + $this->assertEquals($expected, $visited); + } + + public function testAllowsANamedFunctionsVisitorAPI() + { + $visited = []; + $ast = Parser::parse('{ a, b { x }, c }'); + + Visitor::visit($ast, [ + Node::NAME => function(Name $node) use (&$visited) { + $visited[] = ['enter', $node->kind, $node->value]; + }, + Node::SELECTION_SET => [ + 'enter' => function(SelectionSet $node) use (&$visited) { + $visited[] = ['enter', $node->kind, null]; + }, + 'leave' => function(SelectionSet $node) use (&$visited) { + $visited[] = ['leave', $node->kind, null]; + } + ] + ]); + + $expected = [ + [ 'enter', 'SelectionSet', null ], + [ 'enter', 'Name', 'a' ], + [ 'enter', 'Name', 'b' ], + [ 'enter', 'SelectionSet', null ], + [ 'enter', 'Name', 'x' ], + [ 'leave', 'SelectionSet', null ], + [ 'enter', 'Name', 'c' ], + [ 'leave', 'SelectionSet', null ], + ]; + + $this->assertEquals($expected, $visited); + } + + public function testVisitsKitchenSink() + { + $kitchenSink = file_get_contents(__DIR__ . '/kitchen-sink.graphql'); + $ast = Parser::parse($kitchenSink); + + $visited = []; + Visitor::visit($ast, [ + 'enter' => function(Node $node, $key, $parent) use (&$visited) { + $r = ['enter', $node->kind, $key, $parent instanceof Node ? $parent->kind : null]; + $visited[] = $r; + }, + 'leave' => function(Node $node, $key, $parent) use (&$visited) { + $r = ['leave', $node->kind, $key, $parent instanceof Node ? $parent->kind : null]; + $visited[] = $r; + } + ]); + + $expected = [ + [ 'enter', 'Document', null, null ], + [ 'enter', 'OperationDefinition', 0, null ], + [ 'enter', 'Name', 'name', 'OperationDefinition' ], + [ 'leave', 'Name', 'name', 'OperationDefinition' ], + [ 'enter', 'VariableDefinition', 0, null ], + [ 'enter', 'Variable', 'variable', 'VariableDefinition' ], + [ 'enter', 'Name', 'name', 'Variable' ], + [ 'leave', 'Name', 'name', 'Variable' ], + [ 'leave', 'Variable', 'variable', 'VariableDefinition' ], + [ 'enter', 'Name', 'type', 'VariableDefinition' ], + [ 'leave', 'Name', 'type', 'VariableDefinition' ], + [ 'leave', 'VariableDefinition', 0, null ], + [ 'enter', 'VariableDefinition', 1, null ], + [ 'enter', 'Variable', 'variable', 'VariableDefinition' ], + [ 'enter', 'Name', 'name', 'Variable' ], + [ 'leave', 'Name', 'name', 'Variable' ], + [ 'leave', 'Variable', 'variable', 'VariableDefinition' ], + [ 'enter', 'Name', 'type', 'VariableDefinition' ], + [ 'leave', 'Name', 'type', 'VariableDefinition' ], + [ 'enter', 'EnumValue', 'defaultValue', 'VariableDefinition' ], + [ 'leave', 'EnumValue', 'defaultValue', 'VariableDefinition' ], + [ 'leave', 'VariableDefinition', 1, null ], + [ 'enter', 'SelectionSet', 'selectionSet', 'OperationDefinition' ], + [ 'enter', 'Field', 0, null ], + [ 'enter', 'Name', 'alias', 'Field' ], + [ 'leave', 'Name', 'alias', 'Field' ], + [ 'enter', 'Name', 'name', 'Field' ], + [ 'leave', 'Name', 'name', 'Field' ], + [ 'enter', 'Argument', 0, null ], + [ 'enter', 'Name', 'name', 'Argument' ], + [ 'leave', 'Name', 'name', 'Argument' ], + [ 'enter', 'ArrayValue', 'value', 'Argument' ], + [ 'enter', 'IntValue', 0, null ], + [ 'leave', 'IntValue', 0, null ], + [ 'enter', 'IntValue', 1, null ], + [ 'leave', 'IntValue', 1, null ], + [ 'leave', 'ArrayValue', 'value', 'Argument' ], + [ 'leave', 'Argument', 0, null ], + [ 'enter', 'SelectionSet', 'selectionSet', 'Field' ], + [ 'enter', 'Field', 0, null ], + [ 'enter', 'Name', 'name', 'Field' ], + [ 'leave', 'Name', 'name', 'Field' ], + [ 'leave', 'Field', 0, null ], + [ 'enter', 'InlineFragment', 1, null ], + [ 'enter', 'Name', 'typeCondition', 'InlineFragment' ], + [ 'leave', 'Name', 'typeCondition', 'InlineFragment' ], + [ 'enter', 'Directive', 0, null ], + [ 'enter', 'Name', 'name', 'Directive' ], + [ 'leave', 'Name', 'name', 'Directive' ], + [ 'leave', 'Directive', 0, null ], + [ 'enter', 'SelectionSet', 'selectionSet', 'InlineFragment' ], + [ 'enter', 'Field', 0, null ], + [ 'enter', 'Name', 'name', 'Field' ], + [ 'leave', 'Name', 'name', 'Field' ], + [ 'enter', 'SelectionSet', 'selectionSet', 'Field' ], + [ 'enter', 'Field', 0, null ], + [ 'enter', 'Name', 'name', 'Field' ], + [ 'leave', 'Name', 'name', 'Field' ], + [ 'leave', 'Field', 0, null ], + [ 'enter', 'Field', 1, null ], + [ 'enter', 'Name', 'alias', 'Field' ], + [ 'leave', 'Name', 'alias', 'Field' ], + [ 'enter', 'Name', 'name', 'Field' ], + [ 'leave', 'Name', 'name', 'Field' ], + [ 'enter', 'Argument', 0, null ], + [ 'enter', 'Name', 'name', 'Argument' ], + [ 'leave', 'Name', 'name', 'Argument' ], + [ 'enter', 'IntValue', 'value', 'Argument' ], + [ 'leave', 'IntValue', 'value', 'Argument' ], + [ 'leave', 'Argument', 0, null ], + [ 'enter', 'Argument', 1, null ], + [ 'enter', 'Name', 'name', 'Argument' ], + [ 'leave', 'Name', 'name', 'Argument' ], + [ 'enter', 'Variable', 'value', 'Argument' ], + [ 'enter', 'Name', 'name', 'Variable' ], + [ 'leave', 'Name', 'name', 'Variable' ], + [ 'leave', 'Variable', 'value', 'Argument' ], + [ 'leave', 'Argument', 1, null ], + [ 'enter', 'Directive', 0, null ], + [ 'enter', 'Name', 'name', 'Directive' ], + [ 'leave', 'Name', 'name', 'Directive' ], + [ 'enter', 'Variable', 'value', 'Directive' ], + [ 'enter', 'Name', 'name', 'Variable' ], + [ 'leave', 'Name', 'name', 'Variable' ], + [ 'leave', 'Variable', 'value', 'Directive' ], + [ 'leave', 'Directive', 0, null ], + [ 'enter', 'SelectionSet', 'selectionSet', 'Field' ], + [ 'enter', 'Field', 0, null ], + [ 'enter', 'Name', 'name', 'Field' ], + [ 'leave', 'Name', 'name', 'Field' ], + [ 'leave', 'Field', 0, null ], + [ 'enter', 'FragmentSpread', 1, null ], + [ 'enter', 'Name', 'name', 'FragmentSpread' ], + [ 'leave', 'Name', 'name', 'FragmentSpread' ], + [ 'leave', 'FragmentSpread', 1, null ], + [ 'leave', 'SelectionSet', 'selectionSet', 'Field' ], + [ 'leave', 'Field', 1, null ], + [ 'leave', 'SelectionSet', 'selectionSet', 'Field' ], + [ 'leave', 'Field', 0, null ], + [ 'leave', 'SelectionSet', 'selectionSet', 'InlineFragment' ], + [ 'leave', 'InlineFragment', 1, null ], + [ 'leave', 'SelectionSet', 'selectionSet', 'Field' ], + [ 'leave', 'Field', 0, null ], + [ 'leave', 'SelectionSet', 'selectionSet', 'OperationDefinition' ], + [ 'leave', 'OperationDefinition', 0, null ], + [ 'enter', 'OperationDefinition', 1, null ], + [ 'enter', 'Name', 'name', 'OperationDefinition' ], + [ 'leave', 'Name', 'name', 'OperationDefinition' ], + [ 'enter', 'SelectionSet', 'selectionSet', 'OperationDefinition' ], + [ 'enter', 'Field', 0, null ], + [ 'enter', 'Name', 'name', 'Field' ], + [ 'leave', 'Name', 'name', 'Field' ], + [ 'enter', 'Argument', 0, null ], + [ 'enter', 'Name', 'name', 'Argument' ], + [ 'leave', 'Name', 'name', 'Argument' ], + [ 'enter', 'IntValue', 'value', 'Argument' ], + [ 'leave', 'IntValue', 'value', 'Argument' ], + [ 'leave', 'Argument', 0, null ], + [ 'enter', 'Directive', 0, null ], + [ 'enter', 'Name', 'name', 'Directive' ], + [ 'leave', 'Name', 'name', 'Directive' ], + [ 'leave', 'Directive', 0, null ], + [ 'enter', 'SelectionSet', 'selectionSet', 'Field' ], + [ 'enter', 'Field', 0, null ], + [ 'enter', 'Name', 'name', 'Field' ], + [ 'leave', 'Name', 'name', 'Field' ], + [ 'enter', 'SelectionSet', 'selectionSet', 'Field' ], + [ 'enter', 'Field', 0, null ], + [ 'enter', 'Name', 'name', 'Field' ], + [ 'leave', 'Name', 'name', 'Field' ], + [ 'leave', 'Field', 0, null ], + [ 'leave', 'SelectionSet', 'selectionSet', 'Field' ], + [ 'leave', 'Field', 0, null ], + [ 'leave', 'SelectionSet', 'selectionSet', 'Field' ], + [ 'leave', 'Field', 0, null ], + [ 'leave', 'SelectionSet', 'selectionSet', 'OperationDefinition' ], + [ 'leave', 'OperationDefinition', 1, null ], + [ 'enter', 'FragmentDefinition', 2, null ], + [ 'enter', 'Name', 'name', 'FragmentDefinition' ], + [ 'leave', 'Name', 'name', 'FragmentDefinition' ], + [ 'enter', 'Name', 'typeCondition', 'FragmentDefinition' ], + [ 'leave', 'Name', 'typeCondition', 'FragmentDefinition' ], + [ 'enter', 'SelectionSet', 'selectionSet', 'FragmentDefinition' ], + [ 'enter', 'Field', 0, null ], + [ 'enter', 'Name', 'name', 'Field' ], + [ 'leave', 'Name', 'name', 'Field' ], + [ 'enter', 'Argument', 0, null ], + [ 'enter', 'Name', 'name', 'Argument' ], + [ 'leave', 'Name', 'name', 'Argument' ], + [ 'enter', 'Variable', 'value', 'Argument' ], + [ 'enter', 'Name', 'name', 'Variable' ], + [ 'leave', 'Name', 'name', 'Variable' ], + [ 'leave', 'Variable', 'value', 'Argument' ], + [ 'leave', 'Argument', 0, null ], + [ 'enter', 'Argument', 1, null ], + [ 'enter', 'Name', 'name', 'Argument' ], + [ 'leave', 'Name', 'name', 'Argument' ], + [ 'enter', 'Variable', 'value', 'Argument' ], + [ 'enter', 'Name', 'name', 'Variable' ], + [ 'leave', 'Name', 'name', 'Variable' ], + [ 'leave', 'Variable', 'value', 'Argument' ], + [ 'leave', 'Argument', 1, null ], + [ 'enter', 'Argument', 2, null ], + [ 'enter', 'Name', 'name', 'Argument' ], + [ 'leave', 'Name', 'name', 'Argument' ], + [ 'enter', 'ObjectValue', 'value', 'Argument' ], + [ 'enter', 'ObjectField', 0, null ], + [ 'enter', 'Name', 'name', 'ObjectField' ], + [ 'leave', 'Name', 'name', 'ObjectField' ], + [ 'enter', 'StringValue', 'value', 'ObjectField' ], + [ 'leave', 'StringValue', 'value', 'ObjectField' ], + [ 'leave', 'ObjectField', 0, null ], + [ 'leave', 'ObjectValue', 'value', 'Argument' ], + [ 'leave', 'Argument', 2, null ], + [ 'leave', 'Field', 0, null ], + [ 'leave', 'SelectionSet', 'selectionSet', 'FragmentDefinition' ], + [ 'leave', 'FragmentDefinition', 2, null ], + [ 'enter', 'OperationDefinition', 3, null ], + [ 'enter', 'SelectionSet', 'selectionSet', 'OperationDefinition' ], + [ 'enter', 'Field', 0, null ], + [ 'enter', 'Name', 'name', 'Field' ], + [ 'leave', 'Name', 'name', 'Field' ], + [ 'enter', 'Argument', 0, null ], + [ 'enter', 'Name', 'name', 'Argument' ], + [ 'leave', 'Name', 'name', 'Argument' ], + [ 'enter', 'BooleanValue', 'value', 'Argument' ], + [ 'leave', 'BooleanValue', 'value', 'Argument' ], + [ 'leave', 'Argument', 0, null ], + [ 'enter', 'Argument', 1, null ], + [ 'enter', 'Name', 'name', 'Argument' ], + [ 'leave', 'Name', 'name', 'Argument' ], + [ 'enter', 'BooleanValue', 'value', 'Argument' ], + [ 'leave', 'BooleanValue', 'value', 'Argument' ], + [ 'leave', 'Argument', 1, null ], + [ 'leave', 'Field', 0, null ], + [ 'enter', 'Field', 1, null ], + [ 'enter', 'Name', 'name', 'Field' ], + [ 'leave', 'Name', 'name', 'Field' ], + [ 'leave', 'Field', 1, null ], + [ 'leave', 'SelectionSet', 'selectionSet', 'OperationDefinition' ], + [ 'leave', 'OperationDefinition', 3, null ], + [ 'leave', 'Document', null, null ] + ]; + + $this->assertEquals($expected, $visited); + } +} diff --git a/tests/Language/kitchen-sink.graphql b/tests/Language/kitchen-sink.graphql new file mode 100644 index 0000000..18a5147 --- /dev/null +++ b/tests/Language/kitchen-sink.graphql @@ -0,0 +1,38 @@ +# Copyright (c) 2015, Facebook, Inc. +# All rights reserved. +# +# 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. + +query queryName($foo: ComplexType, $site: Site = MOBILE) { + whoever123is: node(id: [123, 456]) { + id , + ... on User @defer { + field2 { + id , + alias: field1(first:10, after:$foo,) @if: $foo { + id, + ...frag + } + } + } + } +} + +mutation likeStory { + like(story: 123) @defer { + story { + id + } + } +} + +fragment frag on Friend { + foo(size: $size, bar: $b, obj: {key: "value"}) +} + +{ + unnamed(truthy: true, falsey: false), + query +} diff --git a/tests/StarWarsData.php b/tests/StarWarsData.php new file mode 100644 index 0000000..653dc24 --- /dev/null +++ b/tests/StarWarsData.php @@ -0,0 +1,128 @@ + '1000', + 'name' => 'Luke Skywalker', + 'friends' => ['1002', '1003', '2000', '2001'], + 'appearsIn' => [4, 5, 6], + 'homePlanet' => 'Tatooine', + ]; + } + + private static function vader() + { + return [ + 'id' => '1001', + 'name' => 'Darth Vader', + 'friends' => ['1004'], + 'appearsIn' => [4, 5, 6], + 'homePlanet' => 'Tatooine', + ]; + } + + private static function han() + { + return [ + 'id' => '1002', + 'name' => 'Han Solo', + 'friends' => ['1000', '1003', '2001'], + 'appearsIn' => [4, 5, 6], + ]; + } + + private static function leia() + { + return [ + 'id' => '1003', + 'name' => 'Leia Organa', + 'friends' => ['1000', '1002', '2000', '2001'], + 'appearsIn' => [4, 5, 6], + 'homePlanet' => 'Alderaan', + ]; + } + + private static function tarkin() + { + return [ + 'id' => '1004', + 'name' => 'Wilhuff Tarkin', + 'friends' => ['1001'], + 'appearsIn' => [4], + ]; + } + + static function humans() + { + return [ + '1000' => self::luke(), + '1001' => self::vader(), + '1002' => self::han(), + '1003' => self::leia(), + '1004' => self::tarkin(), + ]; + } + + private static function threepio() + { + return [ + 'id' => '2000', + 'name' => 'C-3PO', + 'friends' => ['1000', '1002', '1003', '2001'], + 'appearsIn' => [4, 5, 6], + 'primaryFunction' => 'Protocol', + ]; + } + + /** + * We export artoo directly because the schema returns him + * from a root field, and hence needs to reference him. + */ + static function artoo() + { + return [ + + 'id' => '2001', + 'name' => 'R2-D2', + 'friends' => ['1000', '1002', '1003'], + 'appearsIn' => [4, 5, 6], + 'primaryFunction' => 'Astromech', + ]; + } + + static function droids() + { + return [ + '2000' => self::threepio(), + '2001' => self::artoo(), + ]; + } + + /** + * Helper function to get a character by ID. + */ + static function getCharacter($id) + { + $humans = self::humans(); + $droids = self::droids(); + if (isset($humans[$id])) { + return $humans[$id]; + } + if (isset($droids[$id])) { + return $droids[$id]; + } + return null; + } + + /** + * Allows us to query for a character's friends. + */ + static function getFriends($character) + { + return array_map([__CLASS__, 'getCharacter'], $character['friends']); + } +} diff --git a/tests/StarWarsIntrospectionTest.php b/tests/StarWarsIntrospectionTest.php new file mode 100644 index 0000000..e0cf799 --- /dev/null +++ b/tests/StarWarsIntrospectionTest.php @@ -0,0 +1,296 @@ + [ + 'types' => [ + ['name' => 'Query'], + ['name' => 'Character'], + ['name' => 'Human'], + ['name' => 'String'], + ['name' => 'Episode'], + ['name' => 'Droid'], + ['name' => '__Schema'], + ['name' => '__Type'], + ['name' => '__TypeKind'], + ['name' => 'Boolean'], + ['name' => '__Field'], + ['name' => '__InputValue'], + ['name' => '__EnumValue'], + ['name' => '__Directive'], + ['name' => 'ID'], + ['name' => 'Float'], + ['name' => 'Int'] + ] + ] + ]; + $this->assertValidQuery($query, $expected); + } + + // it('Allows querying the schema for query type') + public function testAllowsQueryingTheSchemaForQueryType() + { + $query = ' + query IntrospectionQueryTypeQuery { + __schema { + queryType { + name + } + } + } + '; + $expected = [ + '__schema' => [ + 'queryType' => [ + 'name' => 'Query' + ], + ] + ]; + $this->assertValidQuery($query, $expected); + } + + // it('Allows querying the schema for a specific type') + public function testAllowsQueryingTheSchemaForASpecificType() + { + $query = ' + query IntrospectionDroidTypeQuery { + __type(name: "Droid") { + name + } + } + '; + $expected = [ + '__type' => [ + 'name' => 'Droid' + ] + ]; + $this->assertValidQuery($query, $expected); + } + + // it('Allows querying the schema for an object kind') + public function testAllowsQueryingForAnObjectKind() + { + $query = ' + query IntrospectionDroidKindQuery { + __type(name: "Droid") { + name + kind + } + } + '; + $expected = [ + '__type' => [ + 'name' => 'Droid', + 'kind' => 'OBJECT' + ] + ]; + $this->assertValidQuery($query, $expected); + } + + // it('Allows querying the schema for an interface kind') + public function testAllowsQueryingForInterfaceKind() + { + $query = ' + query IntrospectionCharacterKindQuery { + __type(name: "Character") { + name + kind + } + } + '; + $expected = [ + '__type' => [ + 'name' => 'Character', + 'kind' => 'INTERFACE' + ] + ]; + $this->assertValidQuery($query, $expected); + } + + // it('Allows querying the schema for object fields') + public function testAllowsQueryingForObjectFields() + { + $query = ' + query IntrospectionDroidFieldsQuery { + __type(name: "Droid") { + name + fields { + name + type { + name + kind + } + } + } + } + '; + $expected = [ + '__type' => [ + 'name' => 'Droid', + 'fields' => [ + [ + 'name' => 'id', + 'type' => [ + 'name' => null, + 'kind' => 'NON_NULL' + ] + ], + [ + 'name' => 'name', + 'type' => [ + 'name' => 'String', + 'kind' => 'SCALAR' + ] + ], + [ + 'name' => 'friends', + 'type' => [ + 'name' => null, + 'kind' => 'LIST' + ] + ], + [ + 'name' => 'appearsIn', + 'type' => [ + 'name' => null, + 'kind' => 'LIST' + ] + ], + [ + 'name' => 'primaryFunction', + 'type' => [ + 'name' => 'String', + 'kind' => 'SCALAR' + ] + ] + ] + ] + ]; + $this->assertValidQuery($query, $expected); + } + + // it('Allows querying the schema for nested object fields') + public function testAllowsQueryingTheSchemaForNestedObjectFields() + { + $query = ' + query IntrospectionDroidNestedFieldsQuery { + __type(name: "Droid") { + name + fields { + name + type { + name + kind + ofType { + name + kind + } + } + } + } + } + '; + $expected = [ + '__type' => [ + 'name' => 'Droid', + 'fields' => [ + [ + 'name' => 'id', + 'type' => [ + 'name' => null, + 'kind' => 'NON_NULL', + 'ofType' => [ + 'name' => 'String', + 'kind' => 'SCALAR' + ] + ] + ], + [ + 'name' => 'name', + 'type' => [ + 'name' => 'String', + 'kind' => 'SCALAR', + 'ofType' => null + ] + ], + [ + 'name' => 'friends', + 'type' => [ + 'name' => null, + 'kind' => 'LIST', + 'ofType' => [ + 'name' => 'Character', + 'kind' => 'INTERFACE' + ] + ] + ], + [ + 'name' => 'appearsIn', + 'type' => [ + 'name' => null, + 'kind' => 'LIST', + 'ofType' => [ + 'name' => 'Episode', + 'kind' => 'ENUM' + ] + ] + ], + [ + 'name' => 'primaryFunction', + 'type' => [ + 'name' => 'String', + 'kind' => 'SCALAR', + 'ofType' => null + ] + ] + ] + ] + ]; + $this->assertValidQuery($query, $expected); + } + + // it('Allows querying the schema for documentation') + public function testAllowsQueryingTheSchemaForDocumentation() + { + $query = ' + query IntrospectionDroidDescriptionQuery { + __type(name: "Droid") { + name + description + } + } + '; + $expected = [ + '__type' => [ + 'name' => 'Droid', + 'description' => 'A mechanical creature in the Star Wars universe.' + ] + ]; + $this->assertValidQuery($query, $expected); + } + + /** + * Helper function to test a query and the expected response. + */ + private function assertValidQuery($query, $expected) + { + $this->assertEquals(['data' => $expected], GraphQL::execute(StarWarsSchema::build(), $query)); + } +} diff --git a/tests/StarWarsQueryTest.php b/tests/StarWarsQueryTest.php new file mode 100644 index 0000000..d1c31ec --- /dev/null +++ b/tests/StarWarsQueryTest.php @@ -0,0 +1,338 @@ + [ + 'name' => 'R2-D2' + ] + ]; + $this->assertValidQuery($query, $expected); + } + + public function testAllowsUsToQueryForTheIDAndFriendsOfR2D2() + { + $query = ' + query HeroNameAndFriendsQuery { + hero { + id + name + friends { + name + } + } + } + '; + $expected = [ + 'hero' => [ + 'id' => '2001', + 'name' => 'R2-D2', + 'friends' => [ + [ + 'name' => 'Luke Skywalker', + ], + [ + 'name' => 'Han Solo', + ], + [ + 'name' => 'Leia Organa', + ], + ] + ] + ]; + $this->assertValidQuery($query, $expected); + } + + // Nested Queries + public function testAllowsUsToQueryForTheFriendsOfFriendsOfR2D2() + { + $query = ' + query NestedQuery { + hero { + name + friends { + name + appearsIn + friends { + name + } + } + } + } + '; + $expected = [ + 'hero' => [ + 'name' => 'R2-D2', + 'friends' => [ + [ + 'name' => 'Luke Skywalker', + 'appearsIn' => ['NEWHOPE', 'EMPIRE', 'JEDI',], + 'friends' => [ + ['name' => 'Han Solo',], + ['name' => 'Leia Organa',], + ['name' => 'C-3PO',], + ['name' => 'R2-D2',], + ], + ], + [ + 'name' => 'Han Solo', + 'appearsIn' => ['NEWHOPE', 'EMPIRE', 'JEDI'], + 'friends' => [ + ['name' => 'Luke Skywalker',], + ['name' => 'Leia Organa'], + ['name' => 'R2-D2',], + ] + ], + [ + 'name' => 'Leia Organa', + 'appearsIn' => ['NEWHOPE', 'EMPIRE', 'JEDI'], + 'friends' => + [ + ['name' => 'Luke Skywalker',], + ['name' => 'Han Solo',], + ['name' => 'C-3PO',], + ['name' => 'R2-D2',], + ], + ], + ], + ] + ]; + $this->assertValidQuery($query, $expected); + } + + // Using IDs and query parameters to refetch objects + public function testAllowsUsToQueryForLukeSkywalkerDirectlyUsingHisID() + { + $query = ' + query FetchLukeQuery { + human(id: "1000") { + name + } + } + '; + $expected = [ + 'human' => [ + 'name' => 'Luke Skywalker' + ] + ]; + + $this->assertValidQuery($query, $expected); + } + + public function testGenericQueryToGetLukeSkywalkerById() + { + // Allows us to create a generic query, then use it to fetch Luke Skywalker using his ID + $query = ' + query FetchSomeIDQuery($someId: String!) { + human(id: $someId) { + name + } + } + '; + $params = [ + 'someId' => '1000' + ]; + $expected = [ + 'human' => [ + 'name' => 'Luke Skywalker' + ] + ]; + + $this->assertValidQueryWithParams($query, $params, $expected); + } + + public function testGenericQueryToGetHanSoloById() + { + // Allows us to create a generic query, then use it to fetch Han Solo using his ID + $query = ' + query FetchSomeIDQuery($someId: String!) { + human(id: $someId) { + name + } + } + '; + $params = [ + 'someId' => '1002' + ]; + $expected = [ + 'human' => [ + 'name' => 'Han Solo' + ] + ]; + $this->assertValidQueryWithParams($query, $params, $expected); + } + + public function testGenericQueryWithInvalidId() + { + // Allows us to create a generic query, then pass an invalid ID to get null back + $query = ' + query humanQuery($id: String!) { + human(id: $id) { + name + } + } + '; + $params = [ + 'id' => 'not a valid id' + ]; + $expected = [ + 'human' => null + ]; + $this->assertValidQueryWithParams($query, $params, $expected); + } + + // Using aliases to change the key in the response + function testLukeKeyAlias() + { + // Allows us to query for Luke, changing his key with an alias + $query = ' + query FetchLukeAliased { + luke: human(id: "1000") { + name + } + } + '; + $expected = [ + 'luke' => [ + 'name' => 'Luke Skywalker' + ], + ]; + $this->assertValidQuery($query, $expected); + } + + function testTwoRootKeysAsAnAlias() + { + // Allows us to query for both Luke and Leia, using two root fields and an alias + $query = ' + query FetchLukeAndLeiaAliased { + luke: human(id: "1000") { + name + } + leia: human(id: "1003") { + name + } + } + '; + $expected = [ + 'luke' => [ + 'name' => 'Luke Skywalker' + ], + 'leia' => [ + 'name' => 'Leia Organa' + ] + ]; + $this->assertValidQuery($query, $expected); + } + + // Uses fragments to express more complex queries + function testQueryUsingDuplicatedContent() + { + // Allows us to query using duplicated content + $query = ' + query DuplicateFields { + luke: human(id: "1000") { + name + homePlanet + } + leia: human(id: "1003") { + name + homePlanet + } + } + '; + $expected = [ + 'luke' => [ + 'name' => 'Luke Skywalker', + 'homePlanet' => 'Tatooine' + ], + 'leia' => [ + 'name' => 'Leia Organa', + 'homePlanet' => 'Alderaan' + ] + ]; + $this->assertValidQuery($query, $expected); + } + + function testUsingFragment() + { + // Allows us to use a fragment to avoid duplicating content + $query = ' + query UseFragment { + luke: human(id: "1000") { + ...HumanFragment + } + leia: human(id: "1003") { + ...HumanFragment + } + } + + fragment HumanFragment on Human { + name + homePlanet + } + '; + + $expected = [ + 'luke' => [ + 'name' => 'Luke Skywalker', + 'homePlanet' => 'Tatooine' + ], + 'leia' => [ + 'name' => 'Leia Organa', + 'homePlanet' => 'Alderaan' + ] + ]; + $this->assertValidQuery($query, $expected); + } + + // Using __typename to find the type of an object + public function testVerifyThatR2D2IsADroid() + { + $query = ' + query CheckTypeOfR2 { + hero { + __typename + name + } + } + '; + $expected = [ + 'hero' => [ + '__typename' => 'Droid', + 'name' => 'R2-D2' + ], + ]; + $this->assertValidQuery($query, $expected); + } + + /** + * Helper function to test a query and the expected response. + */ + private function assertValidQuery($query, $expected) + { + $this->assertEquals(['data' => $expected], GraphQL::execute(StarWarsSchema::build(), $query)); + } + + /** + * Helper function to test a query with params and the expected response. + */ + private function assertValidQueryWithParams($query, $params, $expected) + { + $this->assertEquals(['data' => $expected], GraphQL::execute(StarWarsSchema::build(), $query, null, $params)); + } +} diff --git a/tests/StarWarsSchema.php b/tests/StarWarsSchema.php new file mode 100644 index 0000000..c794342 --- /dev/null +++ b/tests/StarWarsSchema.php @@ -0,0 +1,266 @@ + 'Episode', + 'description' => 'One of the films in the Star Wars Trilogy', + 'values' => [ + 'NEWHOPE' => [ + 'value' => 4, + 'description' => 'Released in 1977.' + ], + 'EMPIRE' => [ + 'value' => 5, + 'description' => 'Released in 1980.' + ], + 'JEDI' => [ + 'value' => 6, + 'description' => 'Released in 1983.' + ], + ] + ]); + + $humanType = null; + $droidType = null; + + /** + * Characters in the Star Wars trilogy are either humans or droids. + * + * This implements the following type system shorthand: + * interface Character { + * id: String! + * name: String + * friends: [Character] + * appearsIn: [Episode] + * } + */ + $characterInterface = new InterfaceType([ + 'name' => 'Character', + 'description' => 'A character in the Star Wars Trilogy', + 'fields' => [ + 'id' => [ + 'type' => Type::nonNull(Type::string()), + 'description' => 'The id of the character.', + ], + 'name' => [ + 'type' => Type::string(), + 'description' => 'The name of the character.' + ], + 'friends' => [ + 'type' => function () use (&$characterInterface) { + return Type::listOf($characterInterface); + }, + 'description' => 'The friends of the character, or an empty list if they have none.', + ], + 'appearsIn' => [ + 'type' => Type::listOf($episodeEnum), + 'description' => 'Which movies they appear in.' + ] + ], + 'resolveType' => function ($obj) use (&$humanType, &$droidType) { + $humans = StarWarsData::humans(); + $droids = StarWarsData::droids(); + if (isset($humans[$obj['id']])) { + return $humanType; + } + if (isset($droids[$obj['id']])) { + return $droidType; + } + return null; + }, + ]); + + /** + * We define our human type, which implements the character interface. + * + * This implements the following type system shorthand: + * type Human : Character { + * id: String! + * name: String + * friends: [Character] + * appearsIn: [Episode] + * } + */ + $humanType = new ObjectType([ + 'name' => 'Human', + 'description' => 'A humanoid creature in the Star Wars universe.', + 'fields' => [ + 'id' => [ + 'type' => new NonNull(Type::string()), + 'description' => 'The id of the human.', + ], + 'name' => [ + 'type' => Type::string(), + 'description' => 'The name of the human.', + ], + 'friends' => [ + 'type' => Type::listOf($characterInterface), + 'description' => 'The friends of the human, or an empty list if they have none.', + 'resolve' => function ($human) { + return StarWarsData::getFriends($human); + }, + ], + 'appearsIn' => [ + 'type' => Type::listOf($episodeEnum), + 'description' => 'Which movies they appear in.' + ], + 'homePlanet' => [ + 'type' => Type::string(), + 'description' => 'The home planet of the human, or null if unknown.' + ], + ], + 'interfaces' => [$characterInterface] + ]); + + /** + * The other type of character in Star Wars is a droid. + * + * This implements the following type system shorthand: + * type Droid : Character { + * id: String! + * name: String + * friends: [Character] + * appearsIn: [Episode] + * primaryFunction: String + * } + */ + $droidType = new ObjectType([ + 'name' => 'Droid', + 'description' => 'A mechanical creature in the Star Wars universe.', + 'fields' => [ + 'id' => [ + 'type' => Type::nonNull(Type::string()), + 'description' => 'The id of the droid.', + ], + 'name' => [ + 'type' => Type::string(), + 'description' => 'The name of the droid.' + ], + 'friends' => [ + 'type' => Type::listOf($characterInterface), + 'description' => 'The friends of the droid, or an empty list if they have none.', + 'resolve' => function ($droid) { + return StarWarsData::getFriends($droid); + }, + ], + 'appearsIn' => [ + 'type' => Type::listOf($episodeEnum), + 'description' => 'Which movies they appear in.' + ], + 'primaryFunction' => [ + 'type' => Type::string(), + 'description' => 'The primary function of the droid.' + ] + ], + 'interfaces' => [$characterInterface] + ]); + + /** + * This is the type that will be the root of our query, and the + * entry point into our schema. It gives us the ability to fetch + * objects by their IDs, as well as to fetch the undisputed hero + * of the Star Wars trilogy, R2-D2, directly. + * + * This implements the following type system shorthand: + * type Query { + * hero: Character + * human(id: String!): Human + * droid(id: String!): Droid + * } + * + */ + $queryType = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'hero' => [ + 'type' => $characterInterface, + 'resolve' => function () { + return StarWarsData::artoo(); + }, + ], + 'human' => [ + 'type' => $humanType, + 'args' => ['id' => ['name' => 'id', 'type' => Type::nonNull(Type::string())]], + 'resolve' => function ($root, $args) { + $humans = StarWarsData::humans(); + return isset($humans[$args['id']]) ? $humans[$args['id']] : null; + } + ], + 'droid' => [ + 'type' => $droidType, + 'args' => ['id' => ['name' => 'id', 'type' => Type::nonNull(Type::string())]], + 'resolve' => function ($root, $args) { + $droids = StarWarsData::droids(); + return isset($droids[$args['id']]) ? $droids[$args['id']] : null; + } + ] + ] + ]); + + return new Schema($queryType); + } +} diff --git a/tests/StarWarsValidationTest.php b/tests/StarWarsValidationTest.php new file mode 100644 index 0000000..a2b76a0 --- /dev/null +++ b/tests/StarWarsValidationTest.php @@ -0,0 +1,126 @@ +validationResult($query); + $this->assertEquals(true, $result['isValid']); + } + + public function testThatNonExistentFieldsAreInvalid() + { + // Notes that non-existent fields are invalid + $query = ' + query HeroSpaceshipQuery { + hero { + favoriteSpaceship + } + } + '; + $this->assertEquals(false, $this->validationResult($query)['isValid']); + } + + public function testRequiresFieldsOnObjects() + { + $query = ' + query HeroNoFieldsQuery { + hero + } + '; + $this->assertEquals(false, $this->validationResult($query)['isValid']); + } + + public function testDisallowsFieldsOnScalars() + { + + $query = ' + query HeroFieldsOnScalarQuery { + hero { + name { + firstCharacterOfName + } + } + } + '; + $this->assertEquals(false, $this->validationResult($query)['isValid']); + } + + public function testDisallowsObjectFieldsOnInterfaces() + { + $query = ' + query DroidFieldOnCharacter { + hero { + name + primaryFunction + } + } + '; + $this->assertEquals(false, $this->validationResult($query)['isValid']); + } + + public function testAllowsObjectFieldsInFragments() + { + $query = ' + query DroidFieldInFragment { + hero { + name + ...DroidFields + } + } + + fragment DroidFields on Droid { + primaryFunction + } + '; + $this->assertEquals(true, $this->validationResult($query)['isValid']); + } + + public function testAllowsObjectFieldsInInlineFragments() + { + $query = ' + query DroidFieldInFragment { + hero { + name + ... on Droid { + primaryFunction + } + } + } + '; + $this->assertEquals(true, $this->validationResult($query)['isValid']); + } + + /** + * Helper function to test a query and the expected response. + */ + private function validationResult($query) + { + $ast = Parser::parse($query); + return DocumentValidator::validate(StarWarsSchema::build(), $ast); + } +} diff --git a/tests/Type/DefinitionTest.php b/tests/Type/DefinitionTest.php new file mode 100644 index 0000000..a9800da --- /dev/null +++ b/tests/Type/DefinitionTest.php @@ -0,0 +1,280 @@ +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']); + $this->inputObjectType = new InputObjectType(['name' => 'InputObject']); + + $this->blogImage = new ObjectType([ + 'name' => 'Image', + 'fields' => [ + 'url' => ['type' => Type::string()], + 'width' => ['type' => Type::int()], + 'height' => ['type' => Type::int()] + ] + ]); + + $this->blogAuthor = new ObjectType([ + 'name' => 'Author', + 'fields' => [ + 'id' => ['type' => Type::string()], + 'name' => ['type' => Type::string()], + 'pic' => [ 'type' => $this->blogImage, 'args' => [ + 'width' => ['type' => Type::int()], + 'height' => ['type' => Type::int()] + ]], + 'recentArticle' => ['type' => function() {return $this->blogArticle;}], + ], + ]); + + $this->blogArticle = new ObjectType([ + 'name' => 'Article', + 'fields' => [ + 'id' => ['type' => Type::string()], + 'isPublished' => ['type' => Type::boolean()], + 'author' => ['type' => $this->blogAuthor], + 'title' => ['type' => Type::string()], + 'body' => ['type' => Type::string()] + ] + ]); + + $this->blogQuery = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'article' => ['type' => $this->blogArticle, 'args' => [ + 'id' => ['type' => Type::string()] + ]], + 'feed' => ['type' => new ListOfType($this->blogArticle)] + ] + ]); + + $this->blogMutation = new ObjectType([ + 'name' => 'Mutation', + 'fields' => [ + 'writeArticle' => ['type' => $this->blogArticle] + ] + ]); + } + + public function testDefinesAQueryOnlySchema() + { + $blogSchema = new Schema($this->blogQuery); + + $this->assertSame($blogSchema->getQueryType(), $this->blogQuery); + + $articleField = $this->blogQuery->getField('article'); + $this->assertSame($articleField->getType(), $this->blogArticle); + $this->assertSame($articleField->getType()->name, 'Article'); + $this->assertSame($articleField->name, 'article'); + + /** @var ObjectType $articleFieldType */ + $articleFieldType = $articleField->getType(); + $titleField = $articleFieldType->getField('title'); + + $this->assertInstanceOf('GraphQL\Type\Definition\FieldDefinition', $titleField); + $this->assertSame('title', $titleField->name); + $this->assertSame(Type::string(), $titleField->getType()); + + $authorField = $articleFieldType->getField('author'); + $this->assertInstanceOf('GraphQL\Type\Definition\FieldDefinition', $authorField); + + /** @var ObjectType $authorFieldType */ + $authorFieldType = $authorField->getType(); + $this->assertSame($this->blogAuthor, $authorFieldType); + + $recentArticleField = $authorFieldType->getField('recentArticle'); + $this->assertInstanceOf('GraphQL\Type\Definition\FieldDefinition', $recentArticleField); + $this->assertSame($this->blogArticle, $recentArticleField->getType()); + + $feedField = $this->blogQuery->getField('feed'); + $this->assertInstanceOf('GraphQL\Type\Definition\FieldDefinition', $feedField); + + /** @var ListOfType $feedFieldType */ + $feedFieldType = $feedField->getType(); + $this->assertInstanceOf('GraphQL\Type\Definition\ListOfType', $feedFieldType); + $this->assertSame($this->blogArticle, $feedFieldType->getWrappedType()); + } + + public function testDefinesAMutationSchema() + { + $schema = new Schema($this->blogQuery, $this->blogMutation); + + $this->assertSame($this->blogMutation, $schema->getMutationType()); + $writeMutation = $this->blogMutation->getField('writeArticle'); + + $this->assertInstanceOf('GraphQL\Type\Definition\FieldDefinition', $writeMutation); + $this->assertSame($this->blogArticle, $writeMutation->getType()); + $this->assertSame('Article', $writeMutation->getType()->name); + $this->assertSame('writeArticle', $writeMutation->name); + } + + public function testIncludesInterfaceSubtypesInTheTypeMap() + { + $someInterface = new InterfaceType([ + 'name' => 'SomeInterface', + 'fields' => [] + ]); + + $someSubtype = new ObjectType([ + 'name' => 'SomeSubtype', + 'fields' => [], + 'interfaces' => [$someInterface] + ]); + + $schema = new Schema($someInterface); + $this->assertSame($someSubtype, $schema->getType('SomeSubtype')); + } + + public function testStringifiesSimpleTypes() + { + $this->assertSame('Int', (string) Type::int()); + $this->assertSame('Article', (string) $this->blogArticle); + + $this->assertSame('Interface', (string) $this->interfaceType); + $this->assertSame('Union', (string) $this->unionType); + $this->assertSame('Enum', (string) $this->enumType); + $this->assertSame('InputObject', (string) $this->inputObjectType); + $this->assertSame('Object', (string) $this->objectType); + + $this->assertSame('Int!', (string) new NonNull(Type::int())); + $this->assertSame('[Int]', (string) new ListOfType(Type::int())); + $this->assertSame('[Int]!', (string) new NonNull(new ListOfType(Type::int()))); + $this->assertSame('[Int!]', (string) new ListOfType(new NonNull(Type::int()))); + $this->assertSame('[[Int]]', (string) new ListOfType(new ListOfType(Type::int()))); + } + + public function testIdentifiesInputTypes() + { + $expected = [ + [Type::int(), true], + [$this->objectType, false], + [$this->interfaceType, false], + [$this->unionType, false], + [$this->enumType, true], + [$this->inputObjectType, true] + ]; + + foreach ($expected as $index => $entry) { + $this->assertSame($entry[1], Type::isInputType($entry[0]), "Type {$entry[0]} was detected incorrectly"); + } + } + + public function testIdentifiesOutputTypes() + { + $expected = [ + [Type::int(), true], + [$this->objectType, true], + [$this->interfaceType, true], + [$this->unionType, true], + [$this->enumType, true], + [$this->inputObjectType, false] + ]; + + foreach ($expected as $index => $entry) { + $this->assertSame($entry[1], Type::isOutputType($entry[0]), "Type {$entry[0]} was detected incorrectly"); + } + } + + public function testProhibitsNonNullNesting() + { + $this->setExpectedException('\Exception'); + new NonNull(new NonNull(Type::int())); + } + + public function testProhibitsPuttingNonObjectTypesInUnions() + { + $int = Type::int(); + + $badUnionTypes = [ + $int, + new NonNull($int), + new ListOfType($int), + $this->interfaceType, + $this->unionType, + $this->enumType, + $this->inputObjectType + ]; + + Config::enableValidation(); + foreach ($badUnionTypes as $type) { + try { + new UnionType(['name' => 'BadUnion', 'types' => [$type]]); + $this->fail('Expected exception not thrown'); + } catch (\Exception $e) { + $this->assertSame( + 'Expecting callable or instance of GraphQL\Type\Definition\ObjectType at "types:0", but got "' . get_class($type) . '"', + $e->getMessage() + ); + } + } + Config::disableValidation(); + } +} diff --git a/tests/Type/IntrospectionTest.php b/tests/Type/IntrospectionTest.php new file mode 100644 index 0000000..5af9b04 --- /dev/null +++ b/tests/Type/IntrospectionTest.php @@ -0,0 +1,1579 @@ + 'QueryRoot', + 'fields' => [] + ])); + + $request = <<<'EOD' + query IntrospectionTestQuery { + schemaType: __type(name: "__Schema") { + name + } + queryRootType: __type(name: "QueryRoot") { + name + } + __schema { + __typename + types { + __typename + kind + name + fields { + __typename + name + args { + __typename + name + type { ...TypeRef } + defaultValue + } + type { + ...TypeRef + } + isDeprecated + deprecationReason + } + interfaces { + ...TypeRef + } + enumValues { + __typename + name + isDeprecated + deprecationReason + } + } + directives { + __typename + name + type { ...TypeRef } + onOperation + onFragment + onField + } + } + } + + fragment TypeRef on __Type { + __typename + kind + name + ofType { + __typename + kind + name + ofType { + __typename + kind + name + ofType { + __typename + kind + name + } + } + } + } +EOD; + + $expected = array( + 'data' => + array( + 'schemaType' => + array( + 'name' => '__Schema', + ), + 'queryRootType' => + array( + 'name' => 'QueryRoot', + ), + '__schema' => + array( + '__typename' => '__Schema', + 'types' => + array( + 0 => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => 'QueryRoot', + 'fields' => + array( + ), + 'interfaces' => + array(), + 'enumValues' => NULL, + ), + 1 => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__Schema', + 'fields' => + array( + 0 => + array( + '__typename' => '__Field', + 'name' => 'types', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'LIST', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__Type', + ), + ), + ), + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 1 => + array( + '__typename' => '__Field', + 'name' => 'queryType', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__Type', + 'ofType' => NULL, + ), + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 2 => + array( + '__typename' => '__Field', + 'name' => 'mutationType', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__Type', + 'ofType' => NULL, + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 3 => + array( + '__typename' => '__Field', + 'name' => 'directives', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'LIST', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__Directive', + ), + ), + ), + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + ), + 'interfaces' => + array(), + 'enumValues' => NULL, + ), + 2 => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__Type', + 'fields' => + array( + 0 => + array( + '__typename' => '__Field', + 'name' => 'kind', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'ENUM', + 'name' => '__TypeKind', + 'ofType' => NULL, + ), + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 1 => + array( + '__typename' => '__Field', + 'name' => 'name', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'String', + 'ofType' => NULL, + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 2 => + array( + '__typename' => '__Field', + 'name' => 'description', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'String', + 'ofType' => NULL, + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 3 => + array( + '__typename' => '__Field', + 'name' => 'fields', + 'args' => + array( + 0 => + array( + '__typename' => '__InputValue', + 'name' => 'includeDeprecated', + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'Boolean', + 'ofType' => NULL, + ), + 'defaultValue' => 'false', + ), + ), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'LIST', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__Field', + 'ofType' => NULL, + ), + ), + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 4 => + array( + '__typename' => '__Field', + 'name' => 'interfaces', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'LIST', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__Type', + 'ofType' => NULL, + ), + ), + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 5 => + array( + '__typename' => '__Field', + 'name' => 'possibleTypes', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'LIST', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__Type', + 'ofType' => NULL, + ), + ), + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 6 => + array( + '__typename' => '__Field', + 'name' => 'enumValues', + 'args' => + array( + 0 => + array( + '__typename' => '__InputValue', + 'defaultValue' => 'false', + 'name' => 'includeDeprecated', + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'Boolean', + 'ofType' => NULL, + ), + ), + ), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'LIST', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__EnumValue', + 'ofType' => NULL, + ), + ), + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 7 => + array( + '__typename' => '__Field', + 'name' => 'inputFields', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'LIST', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__InputValue', + 'ofType' => NULL, + ), + ), + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 8 => + array( + '__typename' => '__Field', + 'name' => 'ofType', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__Type', + 'ofType' => NULL, + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + ), + 'interfaces' => + array(), + 'enumValues' => NULL, + ), + 3 => + array( + '__typename' => '__Type', + 'kind' => 'ENUM', + 'name' => '__TypeKind', + 'fields' => NULL, + 'interfaces' => NULL, + 'enumValues' => + array( + 0 => + array( + '__typename' => '__EnumValue', + 'name' => 'SCALAR', + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 1 => + array( + '__typename' => '__EnumValue', + 'name' => 'OBJECT', + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 2 => + array( + '__typename' => '__EnumValue', + 'name' => 'INTERFACE', + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 3 => + array( + '__typename' => '__EnumValue', + 'name' => 'UNION', + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 4 => + array( + '__typename' => '__EnumValue', + 'name' => 'ENUM', + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 5 => + array( + '__typename' => '__EnumValue', + 'name' => 'INPUT_OBJECT', + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 6 => + array( + '__typename' => '__EnumValue', + 'name' => 'LIST', + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 7 => + array( + '__typename' => '__EnumValue', + 'name' => 'NON_NULL', + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + ), + ), + 4 => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'String', + 'fields' => NULL, + 'interfaces' => NULL, + 'enumValues' => NULL, + ), + 5 => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'Boolean', + 'fields' => NULL, + 'interfaces' => NULL, + 'enumValues' => NULL, + ), + 6 => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__Field', + 'fields' => + array( + 0 => + array( + '__typename' => '__Field', + 'name' => 'name', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'String', + 'ofType' => NULL, + ), + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 1 => + array( + '__typename' => '__Field', + 'name' => 'description', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'String', + 'ofType' => NULL, + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 2 => + array( + '__typename' => '__Field', + 'name' => 'args', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'LIST', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__InputValue', + ), + ), + ), + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 3 => + array( + '__typename' => '__Field', + 'name' => 'type', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__Type', + 'ofType' => NULL, + ), + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 4 => + array( + '__typename' => '__Field', + 'name' => 'isDeprecated', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'Boolean', + 'ofType' => NULL, + ), + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 5 => + array( + '__typename' => '__Field', + 'name' => 'deprecationReason', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'String', + 'ofType' => NULL, + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + ), + 'interfaces' => + array(), + 'enumValues' => NULL, + ), + 7 => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__InputValue', + 'fields' => + array( + 0 => + array( + '__typename' => '__Field', + 'name' => 'name', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'String', + 'ofType' => NULL, + ), + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 1 => + array( + '__typename' => '__Field', + 'name' => 'description', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'String', + 'ofType' => NULL, + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 2 => + array( + '__typename' => '__Field', + 'name' => 'type', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__Type', + 'ofType' => NULL, + ), + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 3 => + array( + '__typename' => '__Field', + 'name' => 'defaultValue', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'String', + 'ofType' => NULL, + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + ), + 'interfaces' => + array(), + 'enumValues' => NULL, + ), + 8 => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__EnumValue', + 'fields' => + array( + 0 => + array( + '__typename' => '__Field', + 'name' => 'name', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'String', + 'ofType' => NULL, + ), + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 1 => + array( + '__typename' => '__Field', + 'name' => 'description', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'String', + 'ofType' => NULL, + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 2 => + array( + '__typename' => '__Field', + 'name' => 'isDeprecated', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'Boolean', + 'ofType' => NULL, + ), + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 3 => + array( + '__typename' => '__Field', + 'name' => 'deprecationReason', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'String', + 'ofType' => NULL, + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + ), + 'interfaces' => + array(), + 'enumValues' => NULL, + ), + 9 => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__Directive', + 'fields' => + array( + 0 => + array( + '__typename' => '__Field', + 'name' => 'name', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'String', + 'ofType' => NULL, + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 1 => + array( + '__typename' => '__Field', + 'name' => 'description', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'String', + 'ofType' => NULL, + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 2 => + array( + '__typename' => '__Field', + 'name' => 'type', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'OBJECT', + 'name' => '__Type', + 'ofType' => NULL, + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 3 => + array( + '__typename' => '__Field', + 'name' => 'onOperation', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'Boolean', + 'ofType' => NULL, + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 4 => + array( + '__typename' => '__Field', + 'name' => 'onFragment', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'Boolean', + 'ofType' => NULL, + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + 5 => + array( + '__typename' => '__Field', + 'name' => 'onField', + 'args' => + array(), + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'Boolean', + 'ofType' => NULL, + ), + 'isDeprecated' => false, + 'deprecationReason' => NULL, + ), + ), + 'interfaces' => + array(), + 'enumValues' => NULL, + ), + 10 => [ + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'ID', + 'fields' => null, + 'interfaces' => null, + 'enumValues' => null, + ], + 11 => [ + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'Float', + 'fields' => null, + 'interfaces' => null, + 'enumValues' => null, + ], + 12 => [ + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'Int', + 'fields' => null, + 'interfaces' => null, + 'enumValues' => null, + ] + ), + 'directives' => + array( + 0 => + array( + '__typename' => '__Directive', + 'name' => 'if', + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'Boolean', + 'ofType' => NULL, + ), + ), + 'onOperation' => false, + 'onFragment' => false, + 'onField' => true, + ), + 1 => + array( + '__typename' => '__Directive', + 'name' => 'unless', + 'type' => + array( + '__typename' => '__Type', + 'kind' => 'NON_NULL', + 'name' => NULL, + 'ofType' => + array( + '__typename' => '__Type', + 'kind' => 'SCALAR', + 'name' => 'Boolean', + 'ofType' => NULL, + ), + ), + 'onOperation' => false, + 'onFragment' => false, + 'onField' => true, + ), + ), + ), + ) + ); + + $this->assertEquals($expected, GraphQL::execute($emptySchema, $request)); + } + + function testIntrospectsOnInputObject() + { + $TestInputObject = new InputObjectType([ + 'name' => 'TestInputObject', + 'fields' => [ + 'a' => ['type' => Type::string(), 'defaultValue' => 'foo'], + 'b' => ['type' => Type::listOf(Type::string())] + ] + ]); + + $TestType = new ObjectType([ + 'name' => 'TestType', + 'fields' => [ + 'field' => [ + 'type' => Type::string(), + 'args' => ['complex' => ['type' => $TestInputObject]], + 'resolve' => function ($_, $args) { + return json_encode($args['complex']); + } + ] + ] + ]); + + $schema = new Schema($TestType); + $request = ' + { + __schema { + types { + kind + name + inputFields { + name + type { ...TypeRef } + defaultValue + } + } + } + } + + fragment TypeRef on __Type { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + } + } + } + } + '; + + + $expectedFragment = [ + 'kind' => 'INPUT_OBJECT', + 'name' => 'TestInputObject', + 'inputFields' => [ + ['name' => 'a', 'type' => [ + 'kind' => 'SCALAR', + 'name' => 'String', + 'ofType' => null], + 'defaultValue' => '"foo"' + ], + ['name' => 'b', 'type' => [ + 'kind' => 'LIST', + 'name' => null, + 'ofType' => ['kind' => 'SCALAR', 'name' => 'String', 'ofType' => null]], + 'defaultValue' => null + ] + ] + ]; + + $result = GraphQL::execute($schema, $request); + $result = $result['data']['__schema']['types']; + // $this->assertEquals($expectedFragment, $result[1]); + $this->assertContains($expectedFragment, $result); + } + + public function testSupportsThe__typeRootField() + { + + $TestType = new ObjectType([ + 'name' => 'TestType', + 'fields' => [ + 'testField' => [ + 'type' => Type::string(), + ] + ] + ]); + + $schema = new Schema($TestType); + $request = ' + { + __type(name: "TestType") { + name + } + } + '; + + $expected = ['data' => [ + '__type' => [ + 'name' => 'TestType' + ] + ]]; + + $this->assertEquals($expected, GraphQL::execute($schema, $request)); + } + + public function testIdentifiesDeprecatedFields() + { + + $TestType = new ObjectType([ + 'name' => 'TestType', + 'fields' => [ + 'nonDeprecated' => [ + 'type' => Type::string(), + ], + 'deprecated' => [ + 'type' => Type::string(), + 'deprecationReason' => 'Removed in 1.0' + ] + ] + ]); + + $schema = new Schema($TestType); + $request = ' + { + __type(name: "TestType") { + name + fields(includeDeprecated: true) { + name + isDeprecated, + deprecationReason + } + } + } + '; + + $expected = [ + 'data' => [ + '__type' => [ + 'name' => 'TestType', + 'fields' => [ + [ + 'name' => 'nonDeprecated', + 'isDeprecated' => false, + 'deprecationReason' => null + ], + [ + 'name' => 'deprecated', + 'isDeprecated' => true, + 'deprecationReason' => 'Removed in 1.0' + ] + ] + ] + ] + ]; + $this->assertEquals($expected, GraphQL::execute($schema, $request)); + } + + public function testRespectsTheIncludeDeprecatedParameterForFields() + { + $TestType = new ObjectType([ + 'name' => 'TestType', + 'fields' => [ + 'nonDeprecated' => [ + 'type' => Type::string(), + ], + 'deprecated' => [ + 'type' => Type::string(), + 'deprecationReason' => 'Removed in 1.0' + ] + ] + ]); + + $schema = new Schema($TestType); + $request = ' + { + __type(name: "TestType") { + name + trueFields: fields(includeDeprecated: true) { + name + } + falseFields: fields(includeDeprecated: false) { + name + } + omittedFields: fields { + name + } + } + } + '; + + $expected = [ + 'data' => [ + '__type' => [ + 'name' => 'TestType', + 'trueFields' => [ + [ + 'name' => 'nonDeprecated', + ], + [ + 'name' => 'deprecated', + ] + ], + 'falseFields' => [ + [ + 'name' => 'nonDeprecated', + ] + ], + 'omittedFields' => [ + [ + 'name' => 'nonDeprecated', + ] + ], + ] + ] + ]; + + $this->assertEquals($expected, GraphQL::execute($schema, $request)); + } + + public function testIdentifiesDeprecatedEnumValues() + { + $TestEnum = new EnumType([ + 'name' => 'TestEnum', + 'values' => [ + 'NONDEPRECATED' => ['value' => 0], + 'DEPRECATED' => ['value' => 1, 'deprecationReason' => 'Removed in 1.0'], + 'ALSONONDEPRECATED' => ['value' => 2] + ] + ]); + + $TestType = new ObjectType([ + 'name' => 'TestType', + 'fields' => [ + 'testEnum' => [ + 'type' => $TestEnum, + ], + ] + ]); + + $schema = new Schema($TestType); + $request = ' + { + __type(name: "TestEnum") { + name + enumValues(includeDeprecated: true) { + name + isDeprecated, + deprecationReason + } + } + } + '; + + $expected = [ + 'data' => [ + '__type' => [ + 'name' => 'TestEnum', + 'enumValues' => [ + [ + 'name' => 'NONDEPRECATED', + 'isDeprecated' => false, + 'deprecationReason' => null + ], + [ + 'name' => 'DEPRECATED', + 'isDeprecated' => true, + 'deprecationReason' => 'Removed in 1.0' + ], + [ + 'name' => 'ALSONONDEPRECATED', + 'isDeprecated' => false, + 'deprecationReason' => null + ] + ] + ] + ] + ]; + $this->assertEquals($expected, GraphQL::execute($schema, $request)); + } + + public function testRespectsTheIncludeDeprecatedParameterForEnumValues() + { + $TestEnum = new EnumType([ + 'name' => 'TestEnum', + 'values' => [ + 'NONDEPRECATED' => ['value' => 0], + 'DEPRECATED' => ['value' => 1, 'deprecationReason' => 'Removed in 1.0'], + 'ALSONONDEPRECATED' => ['value' => 2] + ] + ]); + + $TestType = new ObjectType([ + 'name' => 'TestType', + 'fields' => [ + 'testEnum' => [ + 'type' => $TestEnum, + ], + ] + ]); + + $schema = new Schema($TestType); + $request = ' + { + __type(name: "TestEnum") { + name + trueValues: enumValues(includeDeprecated: true) { + name + } + falseValues: enumValues(includeDeprecated: false) { + name + } + omittedValues: enumValues { + name + } + } + } + '; + $expected = [ + 'data' => [ + '__type' => [ + 'name' => 'TestEnum', + 'trueValues' => [ + ['name' => 'NONDEPRECATED'], + ['name' => 'DEPRECATED'], + ['name' => 'ALSONONDEPRECATED'] + ], + 'falseValues' => [ + ['name' => 'NONDEPRECATED'], + ['name' => 'ALSONONDEPRECATED'] + ], + 'omittedValues' => [ + ['name' => 'NONDEPRECATED'], + ['name' => 'ALSONONDEPRECATED'] + ], + ] + ] + ]; + $this->assertEquals($expected, GraphQL::execute($schema, $request)); + } + + public function testFailsAsExpectedOnThe__typeRootFieldWithoutAnArg() + { + + $TestType = new ObjectType([ + 'name' => 'TestType', + 'fields' => [ + 'testField' => [ + 'type' => Type::string(), + ] + ] + ]); + + $schema = new Schema($TestType); + $request = ' + { + __type { + name + } + } + '; + $expected = [ + 'errors' => [ + new FormattedError(Messages::missingArgMessage('__type', 'name', 'String!'), [new SourceLocation(3, 9)]) + ] + ]; + $this->assertEquals($expected, GraphQL::execute($schema, $request)); + } + + public function testExposesDescriptionsOnTypesAndFields() + { + $QueryRoot = new ObjectType([ + 'name' => 'QueryRoot', + 'fields' => [] + ]); + + $schema = new Schema($QueryRoot); + $request = ' + { + schemaType: __type(name: "__Schema") { + name, + description, + fields { + name, + description + } + } + } + '; + $expected = [ + 'data' => [ + 'schemaType' => [ + 'name' => '__Schema', + 'description' => 'A GraphQL Schema defines the capabilities of a ' . + 'GraphQL server. It exposes all available types and ' . + 'directives on the server, as well as the entry ' . + 'points for query and mutation operations.', + 'fields' => [ + [ + 'name' => 'types', + 'description' => 'A list of all types supported by this server.' + ], + [ + 'name' => 'queryType', + 'description' => 'The type that query operations will be rooted at.' + ], + [ + 'name' => 'mutationType', + 'description' => 'If this server supports mutation, the type that ' . + 'mutation operations will be rooted at.' + ], + [ + 'name' => 'directives', + 'description' => 'A list of all directives supported by this server.' + ] + ] + ] + ] + ]; + $this->assertEquals($expected, GraphQL::execute($schema, $request)); + } + + public function testExposesDescriptionsOnEnums() + { + $QueryRoot = new ObjectType([ + 'name' => 'QueryRoot', + 'fields' => [] + ]); + + $schema = new Schema($QueryRoot); + $request = ' + { + typeKindType: __type(name: "__TypeKind") { + name, + description, + enumValues { + name, + description + } + } + } + '; + $expected = [ + 'data' => [ + 'typeKindType' => [ + 'name' => '__TypeKind', + 'description' => 'An enum describing what kind of type a given __Type is', + 'enumValues' => [ + [ + 'description' => 'Indicates this type is a scalar.', + 'name' => 'SCALAR' + ], + [ + 'description' => 'Indicates this type is an object. ' . + '`fields` and `interfaces` are valid fields.', + 'name' => 'OBJECT' + ], + [ + 'description' => 'Indicates this type is an interface. ' . + '`fields` and `possibleTypes` are valid fields.', + 'name' => 'INTERFACE' + ], + [ + 'description' => 'Indicates this type is a union. ' . + '`possibleTypes` is a valid field.', + 'name' => 'UNION' + ], + [ + 'description' => 'Indicates this type is an enum. ' . + '`enumValues` is a valid field.', + 'name' => 'ENUM' + ], + [ + 'description' => 'Indicates this type is an input object. ' . + '`inputFields` is a valid field.', + 'name' => 'INPUT_OBJECT' + ], + [ + 'description' => 'Indicates this type is a list. ' . + '`ofType` is a valid field.', + 'name' => 'LIST' + ], + [ + 'description' => 'Indicates this type is a non-null. ' . + '`ofType` is a valid field.', + 'name' => 'NON_NULL' + ] + ] + ] + ] + ]; + + $this->assertEquals($expected, GraphQL::execute($schema, $request)); + } +} diff --git a/tests/Type/ScalarCoercionTest.php b/tests/Type/ScalarCoercionTest.php new file mode 100644 index 0000000..f3257d1 --- /dev/null +++ b/tests/Type/ScalarCoercionTest.php @@ -0,0 +1,63 @@ +assertSame(1, $intType->coerce(1)); + $this->assertSame(0, $intType->coerce(0)); + $this->assertSame(0, $intType->coerce(0.1)); + $this->assertSame(1, $intType->coerce(1.1)); + $this->assertSame(-1, $intType->coerce(-1.1)); + $this->assertSame(100000, $intType->coerce(1e5)); + $this->assertSame(null, $intType->coerce(1e100)); + $this->assertSame(null, $intType->coerce(-1e100)); + $this->assertSame(-1, $intType->coerce('-1.1')); + $this->assertSame(null, $intType->coerce('one')); + $this->assertSame(0, $intType->coerce(false)); + } + + public function testCoercesOutputFloat() + { + $floatType = Type::float(); + + $this->assertSame(1.0, $floatType->coerce(1)); + $this->assertSame(-1.0, $floatType->coerce(-1)); + $this->assertSame(0.1, $floatType->coerce(0.1)); + $this->assertSame(1.1, $floatType->coerce(1.1)); + $this->assertSame(-1.1, $floatType->coerce(-1.1)); + $this->assertSame(null, $floatType->coerce('one')); + $this->assertSame(0.0, $floatType->coerce(false)); + $this->assertSame(1.0, $floatType->coerce(true)); + } + + public function testCoercesOutputStrings() + { + $stringType = Type::string(); + + $this->assertSame('string', $stringType->coerce('string')); + $this->assertSame('1', $stringType->coerce(1)); + $this->assertSame('-1.1', $stringType->coerce(-1.1)); + $this->assertSame('true', $stringType->coerce(true)); + $this->assertSame('false', $stringType->coerce(false)); + } + + public function testCoercesOutputBoolean() + { + $boolType = Type::boolean(); + + $this->assertSame(true, $boolType->coerce('string')); + $this->assertSame(false, $boolType->coerce('')); + $this->assertSame(true, $boolType->coerce(1)); + $this->assertSame(false, $boolType->coerce(0)); + $this->assertSame(true, $boolType->coerce(true)); + $this->assertSame(false, $boolType->coerce(false)); + + // TODO: how should it behaive on '0'? + } +} \ No newline at end of file diff --git a/tests/Type/SchemaValidatorTest.php b/tests/Type/SchemaValidatorTest.php new file mode 100644 index 0000000..cb08cc9 --- /dev/null +++ b/tests/Type/SchemaValidatorTest.php @@ -0,0 +1,364 @@ +someInputType = new InputObjectType([ + 'name' => 'SomeInputType', + 'fields' => [ + 'val' => [ 'type' => Type::float(), 'defaultValue' => 42 ] + ] + ]); + } + + + // Type System Config + public function testPassesOnTheIntrospectionSchema() + { + $schema = new Schema(Introspection::_schema()); + $validationResult = SchemaValidator::validate($schema); + + $this->assertSame(true, $validationResult->isValid); + $this->assertSame(null, $validationResult->errors); + } + + + // Rule: NoInputTypesAsOutputFields + public function testRejectsSchemaWhoseQueryOrMutationTypeIsAnInputType() + { + $schema = new Schema($this->someInputType); + $validationResult = SchemaValidator::validate($schema, [SchemaValidator::noInputTypesAsOutputFieldsRule()]); + $this->checkValidationResult($validationResult, 'query'); + + $schema = new Schema(null, $this->someInputType); + $validationResult = SchemaValidator::validate($schema, [SchemaValidator::noInputTypesAsOutputFieldsRule()]); + $this->checkValidationResult($validationResult, 'mutation'); + } + + public function testRejectsASchemaThatUsesAnInputTypeAsAField() + { + $kinds = [ + 'GraphQL\Type\Definition\ObjectType', + 'GraphQL\Type\Definition\InterfaceType', + ]; + foreach ($kinds as $kind) { + $someOutputType = new $kind([ + 'name' => 'SomeOutputType', + 'fields' => [ + 'sneaky' => ['type' => function() {return $this->someInputType;}] + ] + ]); + + $schema = new Schema($someOutputType); + $validationResult = SchemaValidator::validate($schema, [SchemaValidator::noInputTypesAsOutputFieldsRule()]); + + $this->assertSame(false, $validationResult->isValid); + $this->assertSame(1, count($validationResult->errors)); + $this->assertSame( + 'Field SomeOutputType.sneaky is of type SomeInputType, which is an ' . + 'input type, but field types must be output types!', + $validationResult->errors[0]->message + ); + } + } + + public function testAcceptsASchemaThatSimplyHasAnInputTypeAsAFieldArg() + { + $this->expectToAcceptSchemaWithNormalInputArg(SchemaValidator::noInputTypesAsOutputFieldsRule()); + } + + private function expectToAcceptSchemaWithNormalInputArg($rule) + { + $someOutputType = new ObjectType([ + 'name' => 'SomeOutputType', + 'fields' => [ + 'fieldWithArg' => [ + 'args' => ['someArg' => ['type' => $this->someInputType]], + 'type' => Type::float() + ] + ] + ]); + + $schema = new Schema($someOutputType); + $validationResult = SchemaValidator::validate($schema, [$rule]); + $this->assertSame(true, $validationResult->isValid); + } + + private function checkValidationResult($validationResult, $operationType) + { + $this->assertEquals(false, $validationResult->isValid); + $this->assertEquals(1, count($validationResult->errors)); + $this->assertEquals( + "Schema $operationType type SomeInputType must be an object type!", + $validationResult->errors[0]->message + ); + } + + + // Rule: NoOutputTypesAsInputArgs + public function testAcceptsASchemaThatSimplyHasAnInputTypeAsAFieldArg2() + { + $this->expectToAcceptSchemaWithNormalInputArg(SchemaValidator::noOutputTypesAsInputArgsRule()); + } + + public function testRejectsASchemaWithAnObjectTypeAsAnInputFieldArg() + { + // rejects a schema with an object type as an input field arg + $someOutputType = new ObjectType([ + 'name' => 'SomeOutputType', + 'fields' => ['f' => ['type' => Type::float()]] + ]); + $this->assertRejectingFieldArgOfType($someOutputType); + } + + public function testRejectsASchemaWithAUnionTypeAsAnInputFieldArg() + { + // rejects a schema with a union type as an input field arg + $unionType = new UnionType([ + 'name' => 'UnionType', + 'types' => [ + new ObjectType([ + 'name' => 'SomeOutputType', + 'fields' => [ 'f' => [ 'type' => Type::float() ] ] + ]) + ] + ]); + $this->assertRejectingFieldArgOfType($unionType); + } + + public function testRejectsASchemaWithAnInterfaceTypeAsAnInputFieldArg() + { + // rejects a schema with an interface type as an input field arg + $interfaceType = new InterfaceType([ + 'name' => 'InterfaceType', + 'fields' => [] + ]); + + $this->assertRejectingFieldArgOfType($interfaceType); + } + + public function testRejectsASchemaWithAListOfObjectsAsAnInputFieldArg() + { + // rejects a schema with a list of objects as an input field arg + $listObjects = new ListOfType(new ObjectType([ + 'name' => 'SomeInputType', + 'fields' => ['f' => ['type' => Type::float()]] + ])); + $this->assertRejectingFieldArgOfType($listObjects); + } + + public function testRejectsASchemaWithANonnullObjectAsAnInputFieldArg() + { + // rejects a schema with a nonnull object as an input field arg + $nonNullObject = new NonNull(new ObjectType([ + 'name' => 'SomeOutputType', + 'fields' => [ 'f' => [ 'type' => Type::float() ] ] + ])); + + $this->assertRejectingFieldArgOfType($nonNullObject); + } + + public function testAcceptsSchemaWithListOfInputTypeAsInputFieldArg() + { + // accepts a schema with a list of input type as an input field arg + $this->assertAcceptingFieldArgOfType(new ListOfType(new InputObjectType([ + 'name' => 'SomeInputType' + ]))); + } + + public function testAcceptsSchemaWithNonnullInputTypeAsInputFieldArg() + { + // accepts a schema with a nonnull input type as an input field arg + $this->assertAcceptingFieldArgOfType(new NonNull(new InputObjectType([ + 'name' => 'SomeInputType' + ]))); + } + + private function assertRejectingFieldArgOfType($fieldArgType) + { + $schema = $this->schemaWithFieldArgOfType($fieldArgType); + $validationResult = SchemaValidator::validate($schema, [SchemaValidator::noOutputTypesAsInputArgsRule()]); + $this->expectRejectionBecauseFieldIsNotInputType($validationResult, $fieldArgType); + } + + private function assertAcceptingFieldArgOfType($fieldArgType) + { + $schema = $this->schemaWithFieldArgOfType($fieldArgType); + $validationResult = SchemaValidator::validate($schema, [SchemaValidator::noOutputTypesAsInputArgsRule()]); + $this->assertSame(true, $validationResult->isValid); + } + + private function schemaWithFieldArgOfType($argType) + { + $someIncorrectInputType = new InputObjectType([ + 'name' => 'SomeIncorrectInputType', + 'fields' => [ + 'val' => ['type' => function() use ($argType) {return $argType;} ] + ] + ]); + + $queryType = new ObjectType([ + 'name' => 'QueryType', + 'fields' => [ + 'f2' => [ + 'type' => Type::float(), + 'args' => ['arg' => [ 'type' => $someIncorrectInputType] ] + ] + ] + ]); + + return new Schema($queryType); + } + + private function expectRejectionBecauseFieldIsNotInputType($validationResult, $fieldTypeName) + { + $this->assertSame(false, $validationResult->isValid); + $this->assertSame(1, count($validationResult->errors)); + $this->assertSame( + "Input field SomeIncorrectInputType.val has type $fieldTypeName, " . + "which is not an input type!", + $validationResult->errors[0]->message + ); + } + + + // Rule: InterfacePossibleTypesMustImplementTheInterface + + public function testAcceptsInterfaceWithSubtypeDeclaredUsingOurInfra() + { + // accepts an interface with a subtype declared using our infra + $this->assertAcceptingAnInterfaceWithANormalSubtype(SchemaValidator::interfacePossibleTypesMustImplementTheInterfaceRule()); + } + + public function testRejectsWhenAPossibleTypeDoesNotImplementTheInterface() + { + // rejects when a possible type does not implement the interface + $InterfaceType = new InterfaceType([ + 'name' => 'InterfaceType', + 'fields' => [] + ]); + + $SubType = new ObjectType([ + 'name' => 'SubType', + 'fields' => [], + 'interfaces' => [] + ]); + + InterfaceType::addImplementationToInterfaces($SubType, [$InterfaceType]); + + // Sanity check. + $this->assertEquals(1, count($InterfaceType->getPossibleTypes())); + $this->assertEquals($SubType, $InterfaceType->getPossibleTypes()[0]); + + + $schema = new Schema($InterfaceType); + $validationResult = SchemaValidator::validate( + $schema, + [SchemaValidator::interfacePossibleTypesMustImplementTheInterfaceRule()] + ); + $this->assertSame(false, $validationResult->isValid); + $this->assertSame(1, count($validationResult->errors)); + $this->assertSame( + 'SubType is a possible type of interface InterfaceType but does not ' . + 'implement it!', + $validationResult->errors[0]->message + ); + } + + + private function assertAcceptingAnInterfaceWithANormalSubtype($rule) + { + $interfaceType = new InterfaceType([ + 'name' => 'InterfaceType', + 'fields' => [] + ]); + + $subType = new ObjectType([ + 'name' => 'SubType', + 'fields' => [], + 'interfaces' => [$interfaceType] + ]); + + $schema = new Schema($interfaceType, $subType); + + $validationResult = SchemaValidator::validate($schema, [$rule]); + $this->assertSame(true, $validationResult->isValid); + } + + + // Rule: TypesInterfacesMustShowThemAsPossible + + public function testAcceptsInterfaceWithASubtypeDeclaredUsingOurInfra() + { + // accepts an interface with a subtype declared using our infra + $this->assertAcceptingAnInterfaceWithANormalSubtype(SchemaValidator::typesInterfacesMustShowThemAsPossibleRule()); + } + + public function testRejectsWhenAnImplementationIsNotAPossibleType() + { + // rejects when an implementation is not a possible type + $interfaceType = new InterfaceType([ + 'name' => 'InterfaceType', + 'fields' => [] + ]); + + $subType = new ObjectType([ + 'name' => 'SubType', + 'fields' => [], + 'interfaces' => [] + ]); + + $tmp = new \ReflectionObject($subType); + $prop = $tmp->getProperty('_interfaces'); + $prop->setAccessible(true); + $prop->setValue($subType, [$interfaceType]); + + // Sanity check the test. + $this->assertEquals([$interfaceType], $subType->getInterfaces()); + $this->assertSame(false, $interfaceType->isPossibleType($subType)); + + // Need to make sure SubType is in the schema! We rely on + // possibleTypes to be able to see it unless it's explicitly used. + $schema = new Schema($interfaceType, $subType); + + // Another sanity check. + $this->assertSame($subType, $schema->getType('SubType')); + + $validationResult = SchemaValidator::validate($schema, [SchemaValidator::typesInterfacesMustShowThemAsPossibleRule()]); + $this->assertSame(false, $validationResult->isValid); + $this->assertSame(1, count($validationResult->errors)); + $this->assertSame( + 'SubType implements interface InterfaceType, but InterfaceType does ' . + 'not list it as possible!', + $validationResult->errors[0]->message + ); + + +/* + + var validationResult = validateSchema( + schema, + [TypesInterfacesMustShowThemAsPossible] + ); + expect(validationResult.isValid).to.equal(false); + expect(validationResult.errors.length).to.equal(1); + expect(validationResult.errors[0].message).to.equal( + 'SubType implements interface InterfaceType, but InterfaceType does ' + + 'not list it as possible!' + ); + */ + } +} diff --git a/tests/Validator/ArgumentsOfCorrectTypeTest.php b/tests/Validator/ArgumentsOfCorrectTypeTest.php new file mode 100644 index 0000000..e8c962f --- /dev/null +++ b/tests/Validator/ArgumentsOfCorrectTypeTest.php @@ -0,0 +1,813 @@ +expectPassesRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + intArgField(intArg: 2) + } + } + '); + } + + public function testGoodBooleanValue() + { + $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + booleanArgField(booleanArg: true) + } + } + '); + } + + public function testGoodStringValue() + { + $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + stringArgField(stringArg: "foo") + } + } + '); + } + + public function testGoodFloatValue() + { + $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + floatArgField(floatArg: 1.1) + } + } + '); + } + + public function testIntIntoFloat() + { + $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + floatArgField(floatArg: 1) + } + } + '); + } + + public function testIntIntoID() + { + $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + idArgField(idArg: 1) + } + } + '); + } + + public function testStringIntoID() + { + $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + idArgField(idArg: "someIdString") + } + } + '); + } + + public function testGoodEnumValue() + { + $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + { + dog { + doesKnowCommand(dogCommand: SIT) + } + } + '); + } + + // Invalid String values + public function testIntIntoString() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + stringArgField(stringArg: 1) + } + } + ', [ + $this->badValue('stringArg', 'String', '1', 4, 39) + ]); + } + + public function testFloatIntoString() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + stringArgField(stringArg: 1.0) + } + } + ', [ + $this->badValue('stringArg', 'String', '1.0', 4, 39) + ]); + } + + public function testBooleanIntoString() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + stringArgField(stringArg: true) + } + } + ', [ + $this->badValue('stringArg', 'String', 'true', 4, 39) + ]); + } + + public function testUnquotedStringIntoString() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + stringArgField(stringArg: BAR) + } + } + ', [ + $this->badValue('stringArg', 'String', 'BAR', 4, 39) + ]); + } + + // Invalid Int values + public function testStringIntoInt() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + intArgField(intArg: "3") + } + } + ', [ + $this->badValue('intArg', 'Int', '"3"', 4, 33) + ]); + } + + public function testBigIntIntoInt() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + intArgField(intArg: 829384293849283498239482938) + } + } + ', [ + $this->badValue('intArg', 'Int', '829384293849283498239482938', 4, 33) + ]); + } + + public function testUnquotedStringIntoInt() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + intArgField(intArg: FOO) + } + } + ', [ + $this->badValue('intArg', 'Int', 'FOO', 4, 33) + ]); + } + + public function testSimpleFloatIntoInt() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + intArgField(intArg: 3.0) + } + } + ', [ + $this->badValue('intArg', 'Int', '3.0', 4, 33) + ]); + } + + public function testFloatIntoInt() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + intArgField(intArg: 3.333) + } + } + ', [ + $this->badValue('intArg', 'Int', '3.333', 4, 33) + ]); + } + + // Invalid Float values + public function testStringIntoFloat() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + floatArgField(floatArg: "3.333") + } + } + ', [ + $this->badValue('floatArg', 'Float', '"3.333"', 4, 37) + ]); + } + + public function testBooleanIntoFloat() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + floatArgField(floatArg: true) + } + } + ', [ + $this->badValue('floatArg', 'Float', 'true', 4, 37) + ]); + } + + public function testUnquotedIntoFloat() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + floatArgField(floatArg: FOO) + } + } + ', [ + $this->badValue('floatArg', 'Float', 'FOO', 4, 37) + ]); + } + + // Invalid Boolean value + public function testIntIntoBoolean() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + booleanArgField(booleanArg: 2) + } + } + ', [ + $this->badValue('booleanArg', 'Boolean', '2', 4, 41) + ]); + } + + public function testFloatIntoBoolean() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + booleanArgField(booleanArg: 1.0) + } + } + ', [ + $this->badValue('booleanArg', 'Boolean', '1.0', 4, 41) + ]); + } + + public function testStringIntoBoolean() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + booleanArgField(booleanArg: "true") + } + } + ', [ + $this->badValue('booleanArg', 'Boolean', '"true"', 4, 41) + ]); + } + + public function testUnquotedIntoBoolean() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + booleanArgField(booleanArg: TRUE) + } + } + ', [ + $this->badValue('booleanArg', 'Boolean', 'TRUE', 4, 41) + ]); + } + + // Invalid ID value + public function testFloatIntoID() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + idArgField(idArg: 1.0) + } + } + ', [ + $this->badValue('idArg', 'ID', '1.0', 4, 31) + ]); + } + + public function testBooleanIntoID() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + idArgField(idArg: true) + } + } + ', [ + $this->badValue('idArg', 'ID', 'true', 4, 31) + ]); + } + + public function testUnquotedIntoID() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + idArgField(idArg: SOMETHING) + } + } + ', [ + $this->badValue('idArg', 'ID', 'SOMETHING', 4, 31) + ]); + } + + // Invalid Enum value + public function testIntIntoEnum() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + dog { + doesKnowCommand(dogCommand: 2) + } + } + ', [ + $this->badValue('dogCommand', 'DogCommand', '2', 4, 41) + ]); + } + + public function testFloatIntoEnum() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + dog { + doesKnowCommand(dogCommand: 1.0) + } + } + ', [ + $this->badValue('dogCommand', 'DogCommand', '1.0', 4, 41) + ]); + } + + public function testStringIntoEnum() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + dog { + doesKnowCommand(dogCommand: "SIT") + } + } + ', [ + $this->badValue('dogCommand', 'DogCommand', '"SIT"', 4, 41) + ]); + } + + public function testBooleanIntoEnum() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + dog { + doesKnowCommand(dogCommand: true) + } + } + ', [ + $this->badValue('dogCommand', 'DogCommand', 'true', 4, 41) + ]); + } + + public function testUnknownEnumValueIntoEnum() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + dog { + doesKnowCommand(dogCommand: JUGGLE) + } + } + ', [ + $this->badValue('dogCommand', 'DogCommand', 'JUGGLE', 4, 41) + ]); + } + + public function testDifferentCaseEnumValueIntoEnum() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + dog { + doesKnowCommand(dogCommand: sit) + } + } + ', [ + $this->badValue('dogCommand', 'DogCommand', 'sit', 4, 41) + ]); + } + + // Valid List value + public function testGoodListValue() + { + $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + stringListArgField(stringListArg: ["one", "two"]) + } + } + '); + } + + public function testEmptyListValue() + { + $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + stringListArgField(stringListArg: []) + } + } + '); + } + + public function testSingleValueIntoList() + { + $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + stringListArgField(stringListArg: "one") + } + } + '); + } + + // Invalid List value + public function testIncorrectItemtype() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + stringListArgField(stringListArg: ["one", 2]) + } + } + ', [ + $this->badValue('stringListArg', '[String]', '["one", 2]', 4, 47), + ]); + } + + public function testSingleValueOfIncorrectType() + { + $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + stringListArgField(stringListArg: 1) + } + } + ', [ + $this->badValue('stringListArg', '[String]', '1', 4, 47), + ]); + } + + // Valid non-nullable value + public function testArgOnOptionalArg() + { + $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + { + dog { + isHousetrained(atOtherHomes: true) + } + } + '); + } + + public function testNoArgOnOptionalArg() + { + $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + { + dog { + isHousetrained + } + } + '); + } + + public function testMultipleArgs() + { + $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + multipleReqs(req1: 1, req2: 2) + } + } + '); + } + + public function testMultipleArgsReverseOrder() + { + $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + multipleReqs(req2: 2, req1: 1) + } + } + '); + } + + public function testNoArgsOnMultipleOptional() + { + $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + multipleOpts + } + } + '); + } + + public function testOneArgOnMultipleOptional() + { + $this->expectPassesRule(new ArgumentsOfCorrectType, ' + { + complicatedArgs { + multipleOpts(opt1: 1) + } + } + '); + } + + public function testSecondArgOnMultipleOptional() + { + $this->expectPassesRule(new ArgumentsOfCorrectType, ' + { + complicatedArgs { + multipleOpts(opt2: 1) + } + } + '); + } + + public function testMultipleReqsOnMixedList() + { + $this->expectPassesRule(new ArgumentsOfCorrectType, ' + { + complicatedArgs { + multipleOptAndReq(req1: 3, req2: 4) + } + } + '); + } + + public function testMultipleReqsAndOneOptOnMixedList() + { + $this->expectPassesRule(new ArgumentsOfCorrectType, ' + { + complicatedArgs { + multipleOptAndReq(req1: 3, req2: 4, opt1: 5) + } + } + '); + } + + public function testAllReqsAndOptsOnMixedList() + { + $this->expectPassesRule(new ArgumentsOfCorrectType, ' + { + complicatedArgs { + multipleOptAndReq(req1: 3, req2: 4, opt1: 5, opt2: 6) + } + } + '); + } + + // Invalid non-nullable value + public function testIncorrectValueType() + { + $this->expectFailsRule(new ArgumentsOfCorrectType, ' + { + complicatedArgs { + multipleReqs(req2: "two", req1: "one") + } + } + ', [ + $this->badValue('req2', 'Int!', '"two"', 4, 32), + $this->badValue('req1', 'Int!', '"one"', 4, 45), + ]); + } + + public function testMissingOneNonNullableArgument() + { + $this->expectFailsRule(new ArgumentsOfCorrectType, ' + { + complicatedArgs { + multipleReqs(req2: 2) + } + } + ', [ + $this->missingArg('multipleReqs', 'req1', 'Int!', 4, 13) + ]); + } + + public function testMissingMultipleNonNullableArguments() + { + $this->expectFailsRule(new ArgumentsOfCorrectType, ' + { + complicatedArgs { + multipleReqs + } + } + ', [ + $this->missingArg('multipleReqs', 'req1', 'Int!', 4, 13), + $this->missingArg('multipleReqs', 'req2', 'Int!', 4, 13), + ]); + } + + public function testIncorrectValueAndMissingArgument() + { + $this->expectFailsRule(new ArgumentsOfCorrectType, ' + { + complicatedArgs { + multipleReqs(req1: "one") + } + } + ', [ + $this->missingArg('multipleReqs', 'req2', 'Int!', 4, 13), + $this->badValue('req1', 'Int!', '"one"', 4, 32), + ]); + } + + + // Valid input object value + public function testOptionalArgDespiteRequiredFieldInType() + { + $this->expectPassesRule(new ArgumentsOfCorrectType, ' + { + complicatedArgs { + complexArgField + } + } + '); + } + + public function testPartialObjectOnlyRequired() + { + $this->expectPassesRule(new ArgumentsOfCorrectType, ' + { + complicatedArgs { + complexArgField(complexArg: { requiredField: true }) + } + } + '); + } + + public function testPartialObjectRequiredFieldCanBeFalsey() + { + $this->expectPassesRule(new ArgumentsOfCorrectType, ' + { + complicatedArgs { + complexArgField(complexArg: { requiredField: false }) + } + } + '); + } + + public function testPartialObjectIncludingRequired() + { + $this->expectPassesRule(new ArgumentsOfCorrectType, ' + { + complicatedArgs { + complexArgField(complexArg: { requiredField: true, intField: 4 }) + } + } + '); + } + + public function testFullObject() + { + $this->expectPassesRule(new ArgumentsOfCorrectType, ' + { + complicatedArgs { + complexArgField(complexArg: { + requiredField: true, + intField: 4, + stringField: "foo", + booleanField: false, + stringListField: ["one", "two"] + }) + } + } + '); + } + + public function testFullObjectWithFieldsInDifferentOrder() + { + $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + { + complicatedArgs { + complexArgField(complexArg: { + stringListField: ["one", "two"], + booleanField: false, + requiredField: true, + stringField: "foo", + intField: 4, + }) + } + } + '); + } + + // Invalid input object value + public function testPartialObjectMissingRequired() + { + $this->expectFailsRule(new ArgumentsOfCorrectType, ' + { + complicatedArgs { + complexArgField(complexArg: { intField: 4 }) + } + } + ', [ + $this->badValue('complexArg', 'ComplexInput', '{intField: 4}', 4, 41), + ]); + } + + public function testPartialObjectInvalidFieldType() + { + $this->expectFailsRule(new ArgumentsOfCorrectType, ' + { + complicatedArgs { + complexArgField(complexArg: { + stringListField: ["one", 2], + requiredField: true, + }) + } + } + ', [ + $this->badValue( + 'complexArg', + 'ComplexInput', + '{stringListField: ["one", 2], requiredField: true}', + 4, + 41 + ), + ]); + } + + public function testPartialObjectUnknownFieldArg() + { + $this->expectFailsRule(new ArgumentsOfCorrectType, ' + { + complicatedArgs { + complexArgField(complexArg: { + requiredField: true, + unknownField: "value" + }) + } + } + ', [ + $this->badValue( + 'complexArg', + 'ComplexInput', + '{requiredField: true, unknownField: "value"}', + 4, + 41 + ), + ]); + } +} diff --git a/tests/Validator/DefaultValuesOfCorrectTypeTest.php b/tests/Validator/DefaultValuesOfCorrectTypeTest.php new file mode 100644 index 0000000..3bfddec --- /dev/null +++ b/tests/Validator/DefaultValuesOfCorrectTypeTest.php @@ -0,0 +1,109 @@ +expectPassesRule(new DefaultValuesOfCorrectType, ' + query NullableValues($a: Int, $b: String, $c: ComplexInput) { + dog { name } + } + '); + } + + public function testRequiredVariablesWithoutDefaultValues() + { + $this->expectPassesRule(new DefaultValuesOfCorrectType, ' + query RequiredValues($a: Int!, $b: String!) { + dog { name } + } + '); + } + + public function testVariablesWithValidDefaultValues() + { + $this->expectPassesRule(new DefaultValuesOfCorrectType, ' + query WithDefaultValues( + $a: Int = 1, + $b: String = "ok", + $c: ComplexInput = { requiredField: true, intField: 3 } + ) { + dog { name } + } + '); + } + + 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) + ]); + } + + 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), + $this->badValue('b', 'String', '4', 4, 22), + $this->badValue('c', 'ComplexInput', '"notverycomplex"', 5, 28) + ]); + } + + public function testComplexVariablesMissingRequiredField() + { + $this->expectFailsRule(new DefaultValuesOfCorrectType, ' + query MissingRequiredField($a: ComplexInput = {intField: 3}) { + dog { name } + } + ', [ + $this->badValue('a', 'ComplexInput', '{intField: 3}', 2, 53) + ]); + } + + public function testListVariablesWithInvalidItem() + { + $this->expectFailsRule(new DefaultValuesOfCorrectType, ' + query InvalidItem($a: [String] = ["one", 2]) { + dog { name } + } + ', [ + $this->badValue('a', '[String]', '["one", 2]', 2, 40) + ]); + } + + private function defaultForNonNullArg($varName, $typeName, $guessTypeName, $line, $column) + { + return new FormattedError( + Messages::defaultForNonNullArgMessage($varName, $typeName, $guessTypeName), + [ new SourceLocation($line, $column) ] + ); + } + + private function badValue($varName, $typeName, $val, $line, $column) + { + return new FormattedError( + Messages::badValueForDefaultArgMessage($varName, $typeName, $val), + [ new SourceLocation($line, $column) ] + ); + } +} diff --git a/tests/Validator/FieldsOnCorrectTypeTest.php b/tests/Validator/FieldsOnCorrectTypeTest.php new file mode 100644 index 0000000..b723750 --- /dev/null +++ b/tests/Validator/FieldsOnCorrectTypeTest.php @@ -0,0 +1,192 @@ +expectPassesRule(new FieldsOnCorrectType(), ' + fragment objectFieldSelection on Dog { + __typename + name + } + '); + } + + public function testAliasedObjectFieldSelection() + { + $this->expectPassesRule(new FieldsOnCorrectType, ' + fragment aliasedObjectFieldSelection on Dog { + tn : __typename + otherName : name + } + '); + } + + public function testInterfaceFieldSelection() + { + $this->expectPassesRule(new FieldsOnCorrectType, ' + fragment interfaceFieldSelection on Pet { + __typename + name + } + '); + } + + public function testAliasedInterfaceFieldSelection() + { + $this->expectPassesRule(new FieldsOnCorrectType, ' + fragment interfaceFieldSelection on Pet { + otherName : name + } + '); + } + + public function testLyingAliasSelection() + { + $this->expectPassesRule(new FieldsOnCorrectType, ' + fragment lyingAliasSelection on Dog { + name : nickname + } + '); + } + + public function testFieldNotDefinedOnFragment() + { + $this->expectFailsRule(new FieldsOnCorrectType, ' + fragment fieldNotDefined on Dog { + meowVolume + }', + [$this->undefinedField('meowVolume', 'Dog', 3, 9)] + ); + } + + public function testFieldNotDefinedDeeplyOnlyReportsFirst() + { + $this->expectFailsRule(new FieldsOnCorrectType, ' + fragment deepFieldNotDefined on Dog { + unknown_field { + deeper_unknown_field + } + }', + [$this->undefinedField('unknown_field', 'Dog', 3, 9)] + ); + } + + public function testSubFieldNotDefined() + { + $this->expectFailsRule(new FieldsOnCorrectType, ' + fragment subFieldNotDefined on Human { + pets { + unknown_field + } + }', + [$this->undefinedField('unknown_field', 'Pet', 4, 11)] + ); + } + + public function testFieldNotDefinedOnInlineFragment() + { + $this->expectFailsRule(new FieldsOnCorrectType, ' + fragment fieldNotDefined on Pet { + ... on Dog { + meowVolume + } + }', + [$this->undefinedField('meowVolume', 'Dog', 4, 11)] + ); + } + + public function testAliasedFieldTargetNotDefined() + { + $this->expectFailsRule(new FieldsOnCorrectType, ' + fragment aliasedFieldTargetNotDefined on Dog { + volume : mooVolume + }', + [$this->undefinedField('mooVolume', 'Dog', 3, 9)] + ); + } + + public function testAliasedLyingFieldTargetNotDefined() + { + $this->expectFailsRule(new FieldsOnCorrectType, ' + fragment aliasedLyingFieldTargetNotDefined on Dog { + barkVolume : kawVolume + }', + [$this->undefinedField('kawVolume', 'Dog', 3, 9)] + ); + } + + public function testNotDefinedOnInterface() + { + $this->expectFailsRule(new FieldsOnCorrectType, ' + fragment notDefinedOnInterface on Pet { + tailLength + }', + [$this->undefinedField('tailLength', 'Pet', 3, 9)] + ); + } + + public function testDefinedOnImplmentorsButNotOnInterface() + { + $this->expectFailsRule(new FieldsOnCorrectType, ' + fragment definedOnImplementorsButNotInterface on Pet { + nickname + }', + [$this->undefinedField('nickname', 'Pet', 3, 9)] + ); + } + + public function testMetaFieldSelectionOnUnion() + { + $this->expectPassesRule(new FieldsOnCorrectType, ' + fragment directFieldSelectionOnUnion on CatOrDog { + __typename + }' + ); + } + + public function testDirectFieldSelectionOnUnion() + { + $this->expectFailsRule(new FieldsOnCorrectType, ' + fragment directFieldSelectionOnUnion on CatOrDog { + directField + }', + [$this->undefinedField('directField', 'CatOrDog', 3, 9)] + ); + } + + public function testDefinedOnImplementorsQueriedOnUnion() + { + $this->expectFailsRule(new FieldsOnCorrectType, ' + fragment definedOnImplementorsQueriedOnUnion on CatOrDog { + name + }', + [$this->undefinedField('name', 'CatOrDog', 3, 9)] + ); + } + + public function testValidFieldInInlineFragment() + { + $this->expectPassesRule(new FieldsOnCorrectType, ' + fragment objectFieldSelection on Pet { + ... on Dog { + name + } + } + '); + } + + private function undefinedField($field, $type, $line, $column) + { + return new FormattedError( + Messages::undefinedFieldMessage($field, $type), + [new SourceLocation($line, $column)] + ); + } +} diff --git a/tests/Validator/FragmentsOnCompositeTypesTest.php b/tests/Validator/FragmentsOnCompositeTypesTest.php new file mode 100644 index 0000000..aaa15d3 --- /dev/null +++ b/tests/Validator/FragmentsOnCompositeTypesTest.php @@ -0,0 +1,103 @@ +expectPassesRule(new FragmentsOnCompositeTypes, ' + fragment validFragment on Dog { + barks + } + '); + } + + public function testInterfaceIsValidFragmentType() + { + $this->expectPassesRule(new FragmentsOnCompositeTypes, ' + fragment validFragment on Pet { + name + } + '); + } + + public function testObjectIsValidInlineFragmentType() + { + $this->expectPassesRule(new FragmentsOnCompositeTypes, ' + fragment validFragment on Pet { + ... on Dog { + barks + } + } + '); + } + + public function testUnionIsValidFragmentType() + { + $this->expectPassesRule(new FragmentsOnCompositeTypes, ' + fragment validFragment on CatOrDog { + __typename + } + '); + } + + public function testScalarIsInvalidFragmentType() + { + $this->expectFailsRule(new FragmentsOnCompositeTypes, ' + fragment scalarFragment on Boolean { + bad + } + ', + [$this->error('scalarFragment', 'Boolean', 2, 34)]); + } + + public function testEnumIsInvalidFragmentType() + { + $this->expectFailsRule(new FragmentsOnCompositeTypes, ' + fragment scalarFragment on FurColor { + bad + } + ', + [$this->error('scalarFragment', 'FurColor', 2, 34)]); + } + + public function testInputObjectIsInvalidFragmentType() + { + $this->expectFailsRule(new FragmentsOnCompositeTypes, ' + fragment inputFragment on ComplexInput { + stringField + } + ', + [$this->error('inputFragment', 'ComplexInput', 2, 33)]); + } + + public function testScalarIsInvalidInlineFragmentType() + { + $this->expectFailsRule(new FragmentsOnCompositeTypes, ' + fragment invalidFragment on Pet { + ... on String { + barks + } + } + ', + [new FormattedError( + Messages::inlineFragmentOnNonCompositeErrorMessage('String'), + [new SourceLocation(3, 16)] + )] + ); + } + + private function error($fragName, $typeName, $line, $column) + { + return new FormattedError( + Messages::fragmentOnNonCompositeErrorMessage($fragName, $typeName), + [ new SourceLocation($line, $column) ] + ); + } +} diff --git a/tests/Validator/KnownArgumentNamesTest.php b/tests/Validator/KnownArgumentNamesTest.php new file mode 100644 index 0000000..7357169 --- /dev/null +++ b/tests/Validator/KnownArgumentNamesTest.php @@ -0,0 +1,134 @@ +expectPassesRule(new KnownArgumentNames, ' + fragment argOnRequiredArg on Dog { + doesKnowCommand(dogCommand: SIT) + } + '); + } + + public function testMultipleArgsAreKnown() + { + $this->expectPassesRule(new KnownArgumentNames, ' + fragment multipleArgs on ComplicatedArgs { + multipleReqs(req1: 1, req2: 2) + } + '); + } + + public function testMultipleArgsInReverseOrderAreKnown() + { + $this->expectPassesRule(new KnownArgumentNames, ' + fragment multipleArgsReverseOrder on ComplicatedArgs { + multipleReqs(req2: 2, req1: 1) + } + '); + } + + public function testNoArgsOnOptionalArg() + { + $this->expectPassesRule(new KnownArgumentNames, ' + fragment noArgOnOptionalArg on Dog { + isHousetrained + } + '); + } + + public function testArgsAreKnownDeeply() + { + $this->expectPassesRule(new KnownArgumentNames, ' + { + dog { + doesKnowCommand(dogCommand: SIT) + } + human { + pet { + ... on Dog { + doesKnowCommand(dogCommand: SIT) + } + } + } + } + '); + } + + public function testInvalidArgName() + { + $this->expectFailsRule(new KnownArgumentNames, ' + fragment invalidArgName on Dog { + doesKnowCommand(unknown: true) + } + ', [ + $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', 3, 25), + ]); + } + + public function testUnknownArgsAmongstKnownArgs() + { + $this->expectFailsRule(new KnownArgumentNames, ' + fragment oneGoodArgOneInvalidArg on Dog { + doesKnowCommand(whoknows: 1, dogCommand: SIT, unknown: true) + } + ', [ + $this->unknownArg('whoknows', 'doesKnowCommand', 'Dog', 3, 25), + $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', 3, 55), + ]); + } + + public function testUnknownArgsDeeply() + { + $this->expectFailsRule(new KnownArgumentNames, ' + { + dog { + doesKnowCommand(unknown: true) + } + human { + pet { + ... on Dog { + doesKnowCommand(unknown: true) + } + } + } + } + ', [ + $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', 4, 27), + $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', 9, 31), + ]); + } + + public function testArgsMayBeOnObjectButNotInterface() + { + $this->expectFailsRule(new KnownArgumentNames, ' + fragment nameSometimesHasArg on Being { + name(surname: true) + ... on Human { + name(surname: true) + } + ... on Dog { + name(surname: true) + } + } + ', [ + $this->unknownArg('surname', 'name', 'Being', 3, 14), + $this->unknownArg('surname', 'name', 'Dog', 8, 16) + ]); + } + + private function unknownArg($argName, $fieldName, $typeName, $line, $column) + { + return new FormattedError( + Messages::unknownArgMessage($argName, $fieldName, $typeName), + [new SourceLocation($line, $column)] + ); + } +} diff --git a/tests/Validator/KnownDirectivesTest.php b/tests/Validator/KnownDirectivesTest.php new file mode 100644 index 0000000..8983db7 --- /dev/null +++ b/tests/Validator/KnownDirectivesTest.php @@ -0,0 +1,100 @@ +expectPassesRule(new KnownDirectives, ' + query Foo { + name + ...Frag + } + + fragment Frag on Dog { + name + } + '); + } + + public function testWithKnownDirectives() + { + $this->expectPassesRule(new KnownDirectives, ' + { + dog @if: true { + name + } + human @unless: false { + name + } + } + '); + } + + public function testWithUnknownDirective() + { + $this->expectFailsRule(new KnownDirectives, ' + { + dog @unknown: "directive" { + name + } + } + ', [ + $this->unknownDirective('unknown', 3, 13) + ]); + } + + public function testWithManyUnknownDirectives() + { + $this->expectFailsRule(new KnownDirectives, ' + { + dog @unknown: "directive" { + name + } + human @unknown: "directive" { + name + pets @unknown: "directive" { + name + } + } + } + ', [ + $this->unknownDirective('unknown', 3, 13), + $this->unknownDirective('unknown', 6, 15), + $this->unknownDirective('unknown', 8, 16) + ]); + } + + public function testWithMisplacedDirectives() + { + $this->expectFailsRule(new KnownDirectives, ' + query Foo @if: true { + name + ...Frag + } + ', [ + $this->misplacedDirective('if', 'operation', 2, 17) + ]); + } + + private function unknownDirective($directiveName, $line, $column) + { + return new FormattedError( + Messages::unknownDirectiveMessage($directiveName), + [ new SourceLocation($line, $column) ] + ); + } + + function misplacedDirective($directiveName, $placement, $line, $column) + { + return new FormattedError( + Messages::misplacedDirectiveMessage($directiveName, $placement), + [new SourceLocation($line, $column)] + ); + } +} diff --git a/tests/Validator/KnownFragmentNamesTest.php b/tests/Validator/KnownFragmentNamesTest.php new file mode 100644 index 0000000..2ff97c0 --- /dev/null +++ b/tests/Validator/KnownFragmentNamesTest.php @@ -0,0 +1,65 @@ +expectPassesRule(new KnownFragmentNames, ' + { + human(id: 4) { + ...HumanFields1 + ... on Human { + ...HumanFields2 + } + } + } + fragment HumanFields1 on Human { + name + ...HumanFields3 + } + fragment HumanFields2 on Human { + name + } + fragment HumanFields3 on Human { + name + } + '); + } + + public function testUnknownFragmentNamesAreInvalid() + { + $this->expectFailsRule(new KnownFragmentNames, ' + { + human(id: 4) { + ...UnknownFragment1 + ... on Human { + ...UnknownFragment2 + } + } + } + fragment HumanFields on Human { + name + ...UnknownFragment3 + } + ', [ + $this->undefFrag('UnknownFragment1', 4, 14), + $this->undefFrag('UnknownFragment2', 6, 16), + $this->undefFrag('UnknownFragment3', 12, 12) + ]); + } + + private function undefFrag($fragName, $line, $column) + { + return new FormattedError( + "Undefined fragment $fragName.", + [new SourceLocation($line, $column)] + ); + } +} diff --git a/tests/Validator/KnownTypeNamesTest.php b/tests/Validator/KnownTypeNamesTest.php new file mode 100644 index 0000000..2ffd19f --- /dev/null +++ b/tests/Validator/KnownTypeNamesTest.php @@ -0,0 +1,52 @@ +expectPassesRule(new KnownTypeNames, ' + query Foo($var: String, $required: [String!]!) { + user(id: 4) { + pets { ... on Pet { name }, ...PetFields } + } + } + fragment PetFields on Pet { + name + } + '); + } + + public function testUnknownTypeNamesAreInvalid() + { + $this->expectFailsRule(new KnownTypeNames, ' + query Foo($var: JumbledUpLetters) { + user(id: 4) { + name + pets { ... on Badger { name }, ...PetFields } + } + } + fragment PetFields on Peettt { + name + } + ', [ + $this->unknownType('JumbledUpLetters', 2, 23), + $this->unknownType('Badger', 5, 25), + $this->unknownType('Peettt', 8, 29) + ]); + } + + private function unknownType($typeName, $line, $column) + { + return new FormattedError( + Messages::unknownTypeMessage($typeName), + [new SourceLocation($line, $column)] + ); + } +} diff --git a/tests/Validator/NoFragmentCyclesTest.php b/tests/Validator/NoFragmentCyclesTest.php new file mode 100644 index 0000000..4b5daf8 --- /dev/null +++ b/tests/Validator/NoFragmentCyclesTest.php @@ -0,0 +1,189 @@ +expectPassesRule(new NoFragmentCycles(), ' + fragment fragA on Dog { ...fragB } + fragment fragB on Dog { name } + '); + } + + public function testSpreadingTwiceIsNotCircular() + { + $this->expectPassesRule(new NoFragmentCycles, ' + fragment fragA on Dog { ...fragB, ...fragB } + fragment fragB on Dog { name } + '); + } + + public function testSpreadingTwiceIndirectlyIsNotCircular() + { + $this->expectPassesRule(new NoFragmentCycles, ' + fragment fragA on Dog { ...fragB, ...fragC } + fragment fragB on Dog { ...fragC } + fragment fragC on Dog { name } + '); + } + + public function testDoubleSpreadWithinAbstractTypes() + { + $this->expectPassesRule(new NoFragmentCycles, ' + fragment nameFragment on Pet { + ... on Dog { name } + ... on Cat { name } + } + + fragment spreadsInAnon on Pet { + ... on Dog { ...nameFragment } + ... on Cat { ...nameFragment } + } + '); + } + + public function testSpreadingRecursivelyWithinFieldFails() + { + $this->expectFailsRule(new NoFragmentCycles, ' + fragment fragA on Human { relatives { ...fragA } }, + ', [ + $this->cycleError('fragA', [], 2, 45) + ]); + } + + public function testNoSpreadingItselfDirectly() + { + $this->expectFailsRule(new NoFragmentCycles, ' + fragment fragA on Dog { ...fragA } + ', [ + $this->cycleError('fragA', [], 2, 31) + ]); + } + + public function testNoSpreadingItselfDirectlyWithinInlineFragment() + { + $this->expectFailsRule(new NoFragmentCycles, ' + fragment fragA on Pet { + ... on Dog { + ...fragA + } + } + ', [ + $this->cycleError('fragA', [], 4, 11) + ]); + } + + public function testNoSpreadingItselfIndirectly() + { + $this->expectFailsRule(new NoFragmentCycles, ' + fragment fragA on Dog { ...fragB } + fragment fragB on Dog { ...fragA } + ', [ + new FormattedError( + Messages::cycleErrorMessage('fragA', ['fragB']), + [ new SourceLocation(2, 31), new SourceLocation(3, 31) ] + ) + ]); + } + + public function testNoSpreadingItselfIndirectlyReportsOppositeOrder() + { + $this->expectFailsRule(new NoFragmentCycles, ' + fragment fragB on Dog { ...fragA } + fragment fragA on Dog { ...fragB } + ', [ + new FormattedError( + Messages::cycleErrorMessage('fragB', ['fragA']), + [new SourceLocation(2, 31), new SourceLocation(3, 31)] + ) + ]); + } + + public function testNoSpreadingItselfIndirectlyWithinInlineFragment() + { + $this->expectFailsRule(new NoFragmentCycles, ' + fragment fragA on Pet { + ... on Dog { + ...fragB + } + } + fragment fragB on Pet { + ... on Dog { + ...fragA + } + } + ', [ + new FormattedError( + Messages::cycleErrorMessage('fragA', ['fragB']), + [new SourceLocation(4, 11), new SourceLocation(9, 11)] + ) + ]); + } + + public function testNoSpreadingItselfDeeply() + { + $this->expectFailsRule(new NoFragmentCycles, ' + fragment fragA on Dog { ...fragB } + fragment fragB on Dog { ...fragC } + fragment fragC on Dog { ...fragO } + fragment fragX on Dog { ...fragY } + fragment fragY on Dog { ...fragZ } + fragment fragZ on Dog { ...fragO } + fragment fragO on Dog { ...fragA, ...fragX } + ', [ + new FormattedError( + Messages::cycleErrorMessage('fragA', ['fragB', 'fragC', 'fragO']), + [ + new SourceLocation(2, 31), + new SourceLocation(3, 31), + new SourceLocation(4, 31), + new SourceLocation(8, 31), + ] + ), + new FormattedError( + Messages::cycleErrorMessage('fragX', ['fragY', 'fragZ', 'fragO']), + [ + new SourceLocation(5, 31), + new SourceLocation(6, 31), + new SourceLocation(7, 31), + new SourceLocation(8, 41), + ] + ) + ]); + } + + public function testNoSpreadingItselfDeeplyTwoPathsNewRule() + { + $this->expectFailsRule(new NoFragmentCycles, ' + fragment fragA on Dog { ...fragB, ...fragC } + fragment fragB on Dog { ...fragA } + fragment fragC on Dog { ...fragA } + ', [ + new FormattedError( + 'Cannot spread fragment fragA within itself via fragB.', + [new SourceLocation(2, 31), new SourceLocation(3, 31)] + ), + new FormattedError( + 'Cannot spread fragment fragA within itself via fragC.', + [new SourceLocation(2, 41), new SourceLocation(4, 31)] + ) + ]); + } + + private function cycleError($fargment, $spreadNames, $line, $column) + { + return new FormattedError( + Messages::cycleErrorMessage($fargment, $spreadNames), + [new SourceLocation($line, $column)] + ); + } +} diff --git a/tests/Validator/NoUndefinedVariablesTest.php b/tests/Validator/NoUndefinedVariablesTest.php new file mode 100644 index 0000000..acf0fe9 --- /dev/null +++ b/tests/Validator/NoUndefinedVariablesTest.php @@ -0,0 +1,318 @@ +expectPassesRule(new NoUndefinedVariables(), ' + query Foo($a: String, $b: String, $c: String) { + field(a: $a, b: $b, c: $c) + } + '); + } + + public function testAllVariablesDeeplyDefined() + { + $this->expectPassesRule(new NoUndefinedVariables, ' + query Foo($a: String, $b: String, $c: String) { + field(a: $a) { + field(b: $b) { + field(c: $c) + } + } + } + '); + } + + public function testAllVariablesDeeplyInInlineFragmentsDefined() + { + $this->expectPassesRule(new NoUndefinedVariables, ' + query Foo($a: String, $b: String, $c: String) { + ... on Type { + field(a: $a) { + field(b: $b) { + ... on Type { + field(c: $c) + } + } + } + } + } + '); + } + + public function testAllVariablesInFragmentsDeeplyDefined() + { + $this->expectPassesRule(new NoUndefinedVariables, ' + query Foo($a: String, $b: String, $c: String) { + ...FragA + } + fragment FragA on Type { + field(a: $a) { + ...FragB + } + } + fragment FragB on Type { + field(b: $b) { + ...FragC + } + } + fragment FragC on Type { + field(c: $c) + } + '); + } + + public function testVariableWithinSingleFragmentDefinedInMultipleOperations() + { + // variable within single fragment defined in multiple operations + $this->expectPassesRule(new NoUndefinedVariables, ' + query Foo($a: String) { + ...FragA + } + query Bar($a: String) { + ...FragA + } + fragment FragA on Type { + field(a: $a) + } + '); + } + + public function testVariableWithinFragmentsDefinedInOperations() + { + $this->expectPassesRule(new NoUndefinedVariables, ' + query Foo($a: String) { + ...FragA + } + query Bar($b: String) { + ...FragB + } + fragment FragA on Type { + field(a: $a) + } + fragment FragB on Type { + field(b: $b) + } + '); + } + + public function testVariableWithinRecursiveFragmentDefined() + { + $this->expectPassesRule(new NoUndefinedVariables, ' + query Foo($a: String) { + ...FragA + } + fragment FragA on Type { + field(a: $a) { + ...FragA + } + } + '); + } + + public function testVariableNotDefined() + { + $this->expectFailsRule(new NoUndefinedVariables, ' + query Foo($a: String, $b: String, $c: String) { + field(a: $a, b: $b, c: $c, d: $d) + } + ', [ + $this->undefVar('d', 3, 39) + ]); + } + + public function testVariableNotDefinedByUnNamedQuery() + { + $this->expectFailsRule(new NoUndefinedVariables, ' + { + field(a: $a) + } + ', [ + $this->undefVar('a', 3, 18) + ]); + } + + public function testMultipleVariablesNotDefined() + { + $this->expectFailsRule(new NoUndefinedVariables, ' + query Foo($b: String) { + field(a: $a, b: $b, c: $c) + } + ', [ + $this->undefVar('a', 3, 18), + $this->undefVar('c', 3, 32) + ]); + } + + public function testVariableInFragmentNotDefinedByUnNamedQuery() + { + $this->expectFailsRule(new NoUndefinedVariables, ' + { + ...FragA + } + fragment FragA on Type { + field(a: $a) + } + ', [ + $this->undefVar('a', 6, 18) + ]); + } + + public function testVariableInFragmentNotDefinedByOperation() + { + $this->expectFailsRule(new NoUndefinedVariables, ' + query Foo($a: String, $b: String) { + ...FragA + } + fragment FragA on Type { + field(a: $a) { + ...FragB + } + } + fragment FragB on Type { + field(b: $b) { + ...FragC + } + } + fragment FragC on Type { + field(c: $c) + } + ', [ + $this->undefVarByOp('c', 16, 18, 'Foo', 2, 7) + ]); + } + + public function testMultipleVariablesInFragmentsNotDefined() + { + $this->expectFailsRule(new NoUndefinedVariables, ' + query Foo($b: String) { + ...FragA + } + fragment FragA on Type { + field(a: $a) { + ...FragB + } + } + fragment FragB on Type { + field(b: $b) { + ...FragC + } + } + fragment FragC on Type { + field(c: $c) + } + ', [ + $this->undefVarByOp('a', 6, 18, 'Foo', 2, 7), + $this->undefVarByOp('c', 16, 18, 'Foo', 2, 7) + ]); + } + + public function testSingleVariableInFragmentNotDefinedByMultipleOperations() + { + $this->expectFailsRule(new NoUndefinedVariables, ' + query Foo($a: String) { + ...FragAB + } + query Bar($a: String) { + ...FragAB + } + fragment FragAB on Type { + field(a: $a, b: $b) + } + ', [ + $this->undefVarByOp('b', 9, 25, 'Foo', 2, 7), + $this->undefVarByOp('b', 9, 25, 'Bar', 5, 7) + ]); + } + + public function testVariablesInFragmentNotDefinedByMultipleOperations() + { + $this->expectFailsRule(new NoUndefinedVariables, ' + query Foo($b: String) { + ...FragAB + } + query Bar($a: String) { + ...FragAB + } + fragment FragAB on Type { + field(a: $a, b: $b) + } + ', [ + $this->undefVarByOp('a', 9, 18, 'Foo', 2, 7), + $this->undefVarByOp('b', 9, 25, 'Bar', 5, 7) + ]); + } + + public function testVariableInFragmentUsedByOtherOperation() + { + $this->expectFailsRule(new NoUndefinedVariables, ' + query Foo($b: String) { + ...FragA + } + query Bar($a: String) { + ...FragB + } + fragment FragA on Type { + field(a: $a) + } + fragment FragB on Type { + field(b: $b) + } + ', [ + $this->undefVarByOp('a', 9, 18, 'Foo', 2, 7), + $this->undefVarByOp('b', 12, 18, 'Bar', 5, 7) + ]); + } + + public function testMultipleUndefinedVariablesProduceMultipleErrors() + { + $this->expectFailsRule(new NoUndefinedVariables, ' + query Foo($b: String) { + ...FragAB + } + query Bar($a: String) { + ...FragAB + } + fragment FragAB on Type { + field1(a: $a, b: $b) + ...FragC + field3(a: $a, b: $b) + } + fragment FragC on Type { + field2(c: $c) + } + ', [ + $this->undefVarByOp('a', 9, 19, 'Foo', 2, 7), + $this->undefVarByOp('c', 14, 19, 'Foo', 2, 7), + $this->undefVarByOp('a', 11, 19, 'Foo', 2, 7), + $this->undefVarByOp('b', 9, 26, 'Bar', 5, 7), + $this->undefVarByOp('c', 14, 19, 'Bar', 5, 7), + $this->undefVarByOp('b', 11, 26, 'Bar', 5, 7), + ]); + } + + + private function undefVar($varName, $line, $column) + { + return new FormattedError( + Messages::undefinedVarMessage($varName), + [new SourceLocation($line, $column)] + ); + } + + private function undefVarByOp($varName, $l1, $c1, $opName, $l2, $c2) + { + return new FormattedError( + Messages::undefinedVarByOpMessage($varName, $opName), + [new SourceLocation($l1, $c1), new SourceLocation($l2, $c2)] + ); + } +} diff --git a/tests/Validator/NoUnusedFragmentsTest.php b/tests/Validator/NoUnusedFragmentsTest.php new file mode 100644 index 0000000..b201421 --- /dev/null +++ b/tests/Validator/NoUnusedFragmentsTest.php @@ -0,0 +1,140 @@ +expectPassesRule(new NoUnusedFragments(), ' + { + human(id: 4) { + ...HumanFields1 + ... on Human { + ...HumanFields2 + } + } + } + fragment HumanFields1 on Human { + name + ...HumanFields3 + } + fragment HumanFields2 on Human { + name + } + fragment HumanFields3 on Human { + name + } + '); + } + + public function testAllFragmentNamesAreUsedByMultipleOperations() + { + $this->expectPassesRule(new NoUnusedFragments, ' + query Foo { + human(id: 4) { + ...HumanFields1 + } + } + query Bar { + human(id: 4) { + ...HumanFields2 + } + } + fragment HumanFields1 on Human { + name + ...HumanFields3 + } + fragment HumanFields2 on Human { + name + } + fragment HumanFields3 on Human { + name + } + '); + } + + public function testContainsUnknownFragments() + { + $this->expectFailsRule(new NoUnusedFragments, ' + query Foo { + human(id: 4) { + ...HumanFields1 + } + } + query Bar { + human(id: 4) { + ...HumanFields2 + } + } + fragment HumanFields1 on Human { + name + ...HumanFields3 + } + fragment HumanFields2 on Human { + name + } + fragment HumanFields3 on Human { + name + } + fragment Unused1 on Human { + name + } + fragment Unused2 on Human { + name + } + ', [ + $this->unusedFrag('Unused1', 22, 7), + $this->unusedFrag('Unused2', 25, 7), + ]); + } + + public function testContainsUnknownFragmentsWithRefCycle() + { + $this->expectFailsRule(new NoUnusedFragments, ' + query Foo { + human(id: 4) { + ...HumanFields1 + } + } + query Bar { + human(id: 4) { + ...HumanFields2 + } + } + fragment HumanFields1 on Human { + name + ...HumanFields3 + } + fragment HumanFields2 on Human { + name + } + fragment HumanFields3 on Human { + name + } + fragment Unused1 on Human { + name + ...Unused2 + } + fragment Unused2 on Human { + name + ...Unused1 + } + ', [ + $this->unusedFrag('Unused1', 22, 7), + $this->unusedFrag('Unused2', 26, 7), + ]); + } + + private function unusedFrag($fragName, $line, $column) + { + return new FormattedError( + Messages::unusedFragMessage($fragName), + [new SourceLocation($line, $column)] + ); + } +} diff --git a/tests/Validator/NoUnusedVariablesTest.php b/tests/Validator/NoUnusedVariablesTest.php new file mode 100644 index 0000000..1e8398a --- /dev/null +++ b/tests/Validator/NoUnusedVariablesTest.php @@ -0,0 +1,221 @@ +expectPassesRule(new NoUnusedVariables(), ' + query Foo($a: String, $b: String, $c: String) { + field(a: $a, b: $b, c: $c) + } + '); + } + + public function testUsesAllVariablesDeeply() + { + $this->expectPassesRule(new NoUnusedVariables, ' + query Foo($a: String, $b: String, $c: String) { + field(a: $a) { + field(b: $b) { + field(c: $c) + } + } + } + '); + } + + public function testUsesAllVariablesDeeplyInInlineFragments() + { + $this->expectPassesRule(new NoUnusedVariables, ' + query Foo($a: String, $b: String, $c: String) { + ... on Type { + field(a: $a) { + field(b: $b) { + ... on Type { + field(c: $c) + } + } + } + } + } + '); + } + + public function testUsesAllVariablesInFragments() + { + $this->expectPassesRule(new NoUnusedVariables, ' + query Foo($a: String, $b: String, $c: String) { + ...FragA + } + fragment FragA on Type { + field(a: $a) { + ...FragB + } + } + fragment FragB on Type { + field(b: $b) { + ...FragC + } + } + fragment FragC on Type { + field(c: $c) + } + '); + } + + public function testVariableUsedByFragmentInMultipleOperations() + { + $this->expectPassesRule(new NoUnusedVariables, ' + query Foo($a: String) { + ...FragA + } + query Bar($b: String) { + ...FragB + } + fragment FragA on Type { + field(a: $a) + } + fragment FragB on Type { + field(b: $b) + } + '); + } + + public function testVariableUsedByRecursiveFragment() + { + $this->expectPassesRule(new NoUnusedVariables, ' + query Foo($a: String) { + ...FragA + } + fragment FragA on Type { + field(a: $a) { + ...FragA + } + } + '); + } + + public function testVariableNotUsed() + { + $this->expectFailsRule(new NoUnusedVariables, ' + query Foo($a: String, $b: String, $c: String) { + field(a: $a, b: $b) + } + ', [ + $this->unusedVar('c', 2, 41) + ]); + } + + public function testMultipleVariablesNotUsed() + { + $this->expectFailsRule(new NoUnusedVariables, ' + query Foo($a: String, $b: String, $c: String) { + field(b: $b) + } + ', [ + $this->unusedVar('a', 2, 17), + $this->unusedVar('c', 2, 41) + ]); + } + + public function testVariableNotUsedInFragments() + { + $this->expectFailsRule(new NoUnusedVariables, ' + query Foo($a: String, $b: String, $c: String) { + ...FragA + } + fragment FragA on Type { + field(a: $a) { + ...FragB + } + } + fragment FragB on Type { + field(b: $b) { + ...FragC + } + } + fragment FragC on Type { + field + } + ', [ + $this->unusedVar('c', 2, 41) + ]); + } + + public function testMultipleVariablesNotUsed2() + { + $this->expectFailsRule(new NoUnusedVariables, ' + query Foo($a: String, $b: String, $c: String) { + ...FragA + } + fragment FragA on Type { + field { + ...FragB + } + } + fragment FragB on Type { + field(b: $b) { + ...FragC + } + } + fragment FragC on Type { + field + } + ', [ + $this->unusedVar('a', 2, 17), + $this->unusedVar('c', 2, 41) + ]); + } + + public function testVariableNotUsedByUnreferencedFragment() + { + $this->expectFailsRule(new NoUnusedVariables, ' + query Foo($b: String) { + ...FragA + } + fragment FragA on Type { + field(a: $a) + } + fragment FragB on Type { + field(b: $b) + } + ', [ + $this->unusedVar('b', 2, 17) + ]); + } + + public function testVariableNotUsedByFragmentUsedByOtherOperation() + { + $this->expectFailsRule(new NoUnusedVariables, ' + query Foo($b: String) { + ...FragA + } + query Bar($a: String) { + ...FragB + } + fragment FragA on Type { + field(a: $a) + } + fragment FragB on Type { + field(b: $b) + } + ', [ + $this->unusedVar('b', 2, 17), + $this->unusedVar('a', 5, 17) + ]); + } + + private function unusedVar($varName, $line, $column) + { + return new FormattedError( + Messages::unusedVariableMessage($varName), + [new SourceLocation($line, $column)] + ); + } +} diff --git a/tests/Validator/OverlappingFieldsCanBeMergedTest.php b/tests/Validator/OverlappingFieldsCanBeMergedTest.php new file mode 100644 index 0000000..7d32219 --- /dev/null +++ b/tests/Validator/OverlappingFieldsCanBeMergedTest.php @@ -0,0 +1,426 @@ +expectPassesRule(new OverlappingFieldsCanBeMerged(), ' + fragment uniqueFields on Dog { + name + nickname + } + '); + } + + public function testIdenticalFields() + { + $this->expectPassesRule(new OverlappingFieldsCanBeMerged, ' + fragment mergeIdenticalFields on Dog { + name + name + } + '); + } + + public function testIdenticalFieldsWithIdenticalArgs() + { + $this->expectPassesRule(new OverlappingFieldsCanBeMerged, ' + fragment mergeIdenticalFieldsWithIdenticalArgs on Dog { + doesKnowCommand(dogCommand: SIT) + doesKnowCommand(dogCommand: SIT) + } + '); + } + + public function testIdenticalFieldsWithIdenticalDirectives() + { + $this->expectPassesRule(new OverlappingFieldsCanBeMerged, ' + fragment mergeSameFieldsWithSameDirectives on Dog { + name @if:true + name @if:true + } + '); + } + + public function testDifferentArgsWithDifferentAliases() + { + $this->expectPassesRule(new OverlappingFieldsCanBeMerged, ' + fragment differentArgsWithDifferentAliases on Dog { + knowsSit : doesKnowCommand(dogCommand: SIT) + knowsDown : doesKnowCommand(dogCommand: DOWN) + } + '); + } + + public function testDifferentDirectivesWithDifferentAliases() + { + $this->expectPassesRule(new OverlappingFieldsCanBeMerged, ' + fragment differentDirectivesWithDifferentAliases on Dog { + nameIfTrue : name @if:true + nameIfFalse : name @if:false + } + '); + } + + public function testSameAliasesWithDifferentFieldTargets() + { + $this->expectFailsRule(new OverlappingFieldsCanBeMerged, ' + fragment sameAliasesWithDifferentFieldTargets on Dog { + fido : name + fido : nickname + } + ', [ + new FormattedError( + Messages::fieldsConflictMessage('fido', 'name and nickname are different fields'), + [new SourceLocation(3, 9), new SourceLocation(4, 9)] + ) + ]); + } + + public function testAliasMaskingDirectFieldAccess() + { + $this->expectFailsRule(new OverlappingFieldsCanBeMerged, ' + fragment aliasMaskingDirectFieldAccess on Dog { + name : nickname + name + } + ', [ + new FormattedError( + Messages::fieldsConflictMessage('name', 'nickname and name are different fields'), + [new SourceLocation(3, 9), new SourceLocation(4, 9)] + ) + ]); + } + + public function testConflictingArgs() + { + $this->expectFailsRule(new OverlappingFieldsCanBeMerged, ' + fragment conflictingArgs on Dog { + doesKnowCommand(dogCommand: SIT) + doesKnowCommand(dogCommand: HEEL) + } + ', [ + new FormattedError( + Messages::fieldsConflictMessage('doesKnowCommand', 'they have differing arguments'), + [new SourceLocation(3,9), new SourceLocation(4,9)] + ) + ]); + } + + public function testConflictingDirectives() + { + $this->expectFailsRule(new OverlappingFieldsCanBeMerged, ' + fragment conflictingDirectiveArgs on Dog { + name @if: true + name @unless: false + } + ', [ + new FormattedError( + Messages::fieldsConflictMessage('name', 'they have differing directives'), + [new SourceLocation(3, 9), new SourceLocation(4, 9)] + ) + ]); + } + + public function testConflictingArgsWithMatchingDirectives() + { + $this->expectFailsRule(new OverlappingFieldsCanBeMerged, ' + fragment conflictingArgsWithMatchingDirectiveArgs on Dog { + doesKnowCommand(dogCommand: SIT) @if:true + doesKnowCommand(dogCommand: HEEL) @if:true + } + ', [ + new FormattedError( + Messages::fieldsConflictMessage('doesKnowCommand', 'they have differing arguments'), + [new SourceLocation(3, 9), new SourceLocation(4, 9)] + ) + ]); + } + + public function testConflictingDirectivesWithMatchingArgs() + { + $this->expectFailsRule(new OverlappingFieldsCanBeMerged, ' + fragment conflictingDirectiveArgsWithMatchingArgs on Dog { + doesKnowCommand(dogCommand: SIT) @if: true + doesKnowCommand(dogCommand: SIT) @unless: false + } + ', [ + new FormattedError( + Messages::fieldsConflictMessage('doesKnowCommand', 'they have differing directives'), + [new SourceLocation(3, 9), new SourceLocation(4, 9)] + ) + ]); + } + + public function testEncountersConflictInFragments() + { + $this->expectFailsRule(new OverlappingFieldsCanBeMerged, ' + { + ...A + ...B + } + fragment A on Type { + x: a + } + fragment B on Type { + x: b + } + ', [ + new FormattedError( + Messages::fieldsConflictMessage('x', 'a and b are different fields'), + [new SourceLocation(7, 9), new SourceLocation(10, 9)] + ) + ]); + } + + public function testReportsEachConflictOnce() + { + $this->expectFailsRule(new OverlappingFieldsCanBeMerged, ' + { + f1 { + ...A + ...B + } + f2 { + ...B + ...A + } + f3 { + ...A + ...B + x: c + } + } + fragment A on Type { + x: a + } + fragment B on Type { + x: b + } + ', [ + new FormattedError( + Messages::fieldsConflictMessage('x', 'a and b are different fields'), + [new SourceLocation(18, 9), new SourceLocation(21, 9)] + ), + new FormattedError( + Messages::fieldsConflictMessage('x', 'a and c are different fields'), + [new SourceLocation(18, 9), new SourceLocation(14, 11)] + ), + new FormattedError( + Messages::fieldsConflictMessage('x', 'b and c are different fields'), + [new SourceLocation(21, 9), new SourceLocation(14, 11)] + ) + ]); + } + + public function testDeepConflict() + { + $this->expectFailsRule(new OverlappingFieldsCanBeMerged, ' + { + field { + x: a + }, + field { + x: b + } + } + ', [ + new FormattedError( + Messages::fieldsConflictMessage('field', [['x', 'a and b are different fields']]), + [ + new SourceLocation(3, 9), + new SourceLocation(6,9), + new SourceLocation(4, 11), + new SourceLocation(7, 11) + ] + ) + ]); + } + + public function testDeepConflictWithMultipleIssues() + { + $this->expectFailsRule(new OverlappingFieldsCanBeMerged, ' + { + field { + x: a + y: c + }, + field { + x: b + y: d + } + } + ', [ + new FormattedError( + Messages::fieldsConflictMessage('field', [ + ['x', 'a and b are different fields'], + ['y', 'c and d are different fields'] + ]), + [ + new SourceLocation(3,9), + new SourceLocation(7,9), + new SourceLocation(4,11), + new SourceLocation(8,11), + new SourceLocation(5,11), + new SourceLocation(9,11) + ] + ) + ]); + } + + public function testVeryDeepConflict() + { + $this->expectFailsRule(new OverlappingFieldsCanBeMerged, ' + { + field { + deepField { + x: a + } + }, + field { + deepField { + x: b + } + } + } + ', [ + new FormattedError( + Messages::fieldsConflictMessage('field', [['deepField', [['x', 'a and b are different fields']]]]), + [ + new SourceLocation(3,9), + new SourceLocation(8,9), + new SourceLocation(4,11), + new SourceLocation(9,11), + new SourceLocation(5,13), + new SourceLocation(10,13) + ] + ) + ]); + } + + public function testReportsDeepConflictToNearestCommonAncestor() + { + $this->expectFailsRule(new OverlappingFieldsCanBeMerged, ' + { + field { + deepField { + x: a + } + deepField { + x: b + } + }, + field { + deepField { + y + } + } + } + ', [ + new FormattedError( + Messages::fieldsConflictMessage('deepField', [['x', 'a and b are different fields']]), + [ + new SourceLocation(4,11), + new SourceLocation(7,11), + new SourceLocation(5,13), + new SourceLocation(8,13) + ] + ) + ]); + } + + // return types must be unambiguous + public function testConflictingScalarReturnTypes() + { + $this->expectFailsRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + { + boxUnion { + ...on IntBox { + scalar + } + ...on StringBox { + scalar + } + } + } + ', [ + new FormattedError( + Messages::fieldsConflictMessage('scalar', 'they return differing types Int and String'), + [ new SourceLocation(5,15), new SourceLocation(8,15) ] + ) + ]); + } + + public function testSameWrappedScalarReturnTypes() + { + $this->expectPassesRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + { + boxUnion { + ...on NonNullStringBox1 { + scalar + } + ...on NonNullStringBox2 { + scalar + } + } + } + '); + } + + private function getTestSchema() + { + $StringBox = new ObjectType([ + 'name' => 'StringBox', + 'fields' => [ + 'scalar' => [ 'type' => Type::string() ] + ] + ]); + + $IntBox = new ObjectType([ + 'name' => 'IntBox', + 'fields' => [ + 'scalar' => ['type' => Type::int() ] + ] + ]); + + $NonNullStringBox1 = new ObjectType([ + 'name' => 'NonNullStringBox1', + 'fields' => [ + 'scalar' => [ 'type' => Type::nonNull(Type::string()) ] + ] + ]); + + $NonNullStringBox2 = new ObjectType([ + 'name' => 'NonNullStringBox2', + 'fields' => [ + 'scalar' => ['type' => Type::nonNull(Type::string())] + ] + ]); + + $BoxUnion = new UnionType([ + 'name' => 'BoxUnion', + 'types' => [ $StringBox, $IntBox, $NonNullStringBox1, $NonNullStringBox2 ] + ]); + + $schema = new Schema(new ObjectType([ + 'name' => 'QueryRoot', + 'fields' => [ + 'boxUnion' => ['type' => $BoxUnion ] + ] + ])); + + return $schema; + } +} diff --git a/tests/Validator/PossibleFragmentSpreadsTest.php b/tests/Validator/PossibleFragmentSpreadsTest.php new file mode 100644 index 0000000..6176e78 --- /dev/null +++ b/tests/Validator/PossibleFragmentSpreadsTest.php @@ -0,0 +1,226 @@ +expectPassesRule(new PossibleFragmentSpreads(), ' + fragment objectWithinObject on Dog { ...dogFragment } + fragment dogFragment on Dog { barkVolume } + '); + } + + public function testOfTheSameObjectWithInlineFragment() + { + $this->expectPassesRule(new PossibleFragmentSpreads, ' + fragment objectWithinObjectAnon on Dog { ... on Dog { barkVolume } } + '); + } + + public function testObjectIntoAnImplementedInterface() + { + $this->expectPassesRule(new PossibleFragmentSpreads, ' + fragment objectWithinInterface on Pet { ...dogFragment } + fragment dogFragment on Dog { barkVolume } + '); + } + + public function testObjectIntoContainingUnion() + { + $this->expectPassesRule(new PossibleFragmentSpreads, ' + fragment objectWithinUnion on CatOrDog { ...dogFragment } + fragment dogFragment on Dog { barkVolume } + '); + } + + public function testUnionIntoContainedObject() + { + $this->expectPassesRule(new PossibleFragmentSpreads, ' + fragment unionWithinObject on Dog { ...catOrDogFragment } + fragment catOrDogFragment on CatOrDog { __typename } + '); + } + + public function testUnionIntoOverlappingInterface() + { + $this->expectPassesRule(new PossibleFragmentSpreads, ' + fragment unionWithinInterface on Pet { ...catOrDogFragment } + fragment catOrDogFragment on CatOrDog { __typename } + '); + } + + public function testUnionIntoOverlappingUnion() + { + $this->expectPassesRule(new PossibleFragmentSpreads, ' + fragment unionWithinUnion on DogOrHuman { ...catOrDogFragment } + fragment catOrDogFragment on CatOrDog { __typename } + '); + } + + public function testInterfaceIntoImplementedObject() + { + $this->expectPassesRule(new PossibleFragmentSpreads, ' + fragment interfaceWithinObject on Dog { ...petFragment } + fragment petFragment on Pet { name } + '); + } + + public function testInterfaceIntoOverlappingInterface() + { + $this->expectPassesRule(new PossibleFragmentSpreads, ' + fragment interfaceWithinInterface on Pet { ...beingFragment } + fragment beingFragment on Being { name } + '); + } + + public function testInterfaceIntoOverlappingInterfaceInInlineFragment() + { + $this->expectPassesRule(new PossibleFragmentSpreads, ' + fragment interfaceWithinInterface on Pet { ... on Being { name } } + '); + } + + public function testInterfaceIntoOverlappingUnion() + { + $this->expectPassesRule(new PossibleFragmentSpreads, ' + fragment interfaceWithinUnion on CatOrDog { ...petFragment } + fragment petFragment on Pet { name } + '); + } + + public function testDifferentObjectIntoObject() + { + $this->expectFailsRule(new PossibleFragmentSpreads, ' + fragment invalidObjectWithinObject on Cat { ...dogFragment } + fragment dogFragment on Dog { barkVolume } + ', + [$this->error('dogFragment', 'Cat', 'Dog', 2, 51)] + ); + } + + public function testDifferentObjectIntoObjectInInlineFragment() + { + $this->expectFailsRule(new PossibleFragmentSpreads, ' + fragment invalidObjectWithinObjectAnon on Cat { + ... on Dog { barkVolume } + } + ', + [$this->errorAnon('Cat', 'Dog', 3, 9)] + ); + } + + public function testObjectIntoNotImplementingInterface() + { + $this->expectFailsRule(new PossibleFragmentSpreads, ' + fragment invalidObjectWithinInterface on Pet { ...humanFragment } + fragment humanFragment on Human { pets { name } } + ', + [$this->error('humanFragment', 'Pet', 'Human', 2, 54)] + ); + } + + public function testObjectIntoNotContainingUnion() + { + $this->expectFailsRule(new PossibleFragmentSpreads, ' + fragment invalidObjectWithinUnion on CatOrDog { ...humanFragment } + fragment humanFragment on Human { pets { name } } + ', + [$this->error('humanFragment', 'CatOrDog', 'Human', 2, 55)] + ); + } + + public function testUnionIntoNotContainedObject() + { + $this->expectFailsRule(new PossibleFragmentSpreads, ' + fragment invalidUnionWithinObject on Human { ...catOrDogFragment } + fragment catOrDogFragment on CatOrDog { __typename } + ', + [$this->error('catOrDogFragment', 'Human', 'CatOrDog', 2, 52)] + ); + } + + public function testUnionIntoNonOverlappingInterface() + { + $this->expectFailsRule(new PossibleFragmentSpreads, ' + fragment invalidUnionWithinInterface on Pet { ...humanOrAlienFragment } + fragment humanOrAlienFragment on HumanOrAlien { __typename } + ', + [$this->error('humanOrAlienFragment', 'Pet', 'HumanOrAlien', 2, 53)] + ); + } + + public function testUnionIntoNonOverlappingUnion() + { + $this->expectFailsRule(new PossibleFragmentSpreads, ' + fragment invalidUnionWithinUnion on CatOrDog { ...humanOrAlienFragment } + fragment humanOrAlienFragment on HumanOrAlien { __typename } + ', + [$this->error('humanOrAlienFragment', 'CatOrDog', 'HumanOrAlien', 2, 54)] + ); + } + + public function testInterfaceIntoNonImplementingObject() + { + $this->expectFailsRule(new PossibleFragmentSpreads, ' + fragment invalidInterfaceWithinObject on Cat { ...intelligentFragment } + fragment intelligentFragment on Intelligent { iq } + ', + [$this->error('intelligentFragment', 'Cat', 'Intelligent', 2, 54)] + ); + } + + public function testInterfaceIntoNonOverlappingInterface() + { + $this->expectFailsRule(new PossibleFragmentSpreads, ' + fragment invalidInterfaceWithinInterface on Pet { + ...intelligentFragment + } + fragment intelligentFragment on Intelligent { iq } + ', + [$this->error('intelligentFragment', 'Pet', 'Intelligent', 3, 9)] + ); + } + + public function testInterfaceIntoNonOverlappingInterfaceInInlineFragment() + { + $this->expectFailsRule(new PossibleFragmentSpreads, ' + fragment invalidInterfaceWithinInterfaceAnon on Pet { + ...on Intelligent { iq } + } + ', + [$this->errorAnon('Pet', 'Intelligent', 3, 9)] + ); + } + + public function testInterfaceIntoNonOverlappingUnion() + { + $this->expectFailsRule(new PossibleFragmentSpreads, ' + fragment invalidInterfaceWithinUnion on HumanOrAlien { ...petFragment } + fragment petFragment on Pet { name } + ', + [$this->error('petFragment', 'HumanOrAlien', 'Pet', 2, 62)] + ); + } + + private function error($fragName, $parentType, $fragType, $line, $column) + { + return new FormattedError( + Messages::typeIncompatibleSpreadMessage($fragName, $parentType, $fragType), + [new SourceLocation($line, $column)] + ); + } + + private function errorAnon($parentType, $fragType, $line, $column) + { + return new FormattedError( + Messages::typeIncompatibleAnonSpreadMessage($parentType, $fragType), + [new SourceLocation($line, $column)] + ); + } +} diff --git a/tests/Validator/ScalarLeafsTest.php b/tests/Validator/ScalarLeafsTest.php new file mode 100644 index 0000000..b8fbf5f --- /dev/null +++ b/tests/Validator/ScalarLeafsTest.php @@ -0,0 +1,117 @@ +expectPassesRule(new ScalarLeafs, ' + fragment scalarSelection on Dog { + barks + } + '); + } + + public function testObjectTypeMissingSelection() + { + $this->expectFailsRule(new ScalarLeafs, ' + query directQueryOnObjectWithoutSubFields { + human + } + ', [$this->missingObjSubselection('human', 'Human', 3, 9)]); + } + + public function testInterfaceTypeMissingSelection() + { + $this->expectFailsRule(new ScalarLeafs, ' + { + human { pets } + } + ', [$this->missingObjSubselection('pets', '[Pet]', 3, 17)]); + } + + public function testValidScalarSelectionWithArgs() + { + $this->expectPassesRule(new ScalarLeafs, ' + fragment scalarSelectionWithArgs on Dog { + doesKnowCommand(dogCommand: SIT) + } + '); + } + + public function testScalarSelectionNotAllowedOnBoolean() + { + $this->expectFailsRule(new ScalarLeafs, ' + fragment scalarSelectionsNotAllowedOnBoolean on Dog { + barks { sinceWhen } + } + ', + [$this->noScalarSubselection('barks', 'Boolean', 3, 15)]); + } + + public function testScalarSelectionNotAllowedOnEnum() + { + $this->expectFailsRule(new ScalarLeafs, ' + fragment scalarSelectionsNotAllowedOnEnum on Cat { + furColor { inHexdec } + } + ', + [$this->noScalarSubselection('furColor', 'FurColor', 3, 18)] + ); + } + + public function testScalarSelectionNotAllowedWithArgs() + { + $this->expectFailsRule(new ScalarLeafs, ' + fragment scalarSelectionsNotAllowedWithArgs on Dog { + doesKnowCommand(dogCommand: SIT) { sinceWhen } + } + ', + [$this->noScalarSubselection('doesKnowCommand', 'Boolean', 3, 42)] + ); + } + + public function testScalarSelectionNotAllowedWithDirectives() + { + $this->expectFailsRule(new ScalarLeafs, ' + fragment scalarSelectionsNotAllowedWithDirectives on Dog { + name @if: true { isAlsoHumanName } + } + ', + [$this->noScalarSubselection('name', 'String', 3, 24)] + ); + } + + public function testScalarSelectionNotAllowedWithDirectivesAndArgs() + { + $this->expectFailsRule(new ScalarLeafs, ' + fragment scalarSelectionsNotAllowedWithDirectivesAndArgs on Dog { + doesKnowCommand(dogCommand: SIT) @if: true { sinceWhen } + } + ', + [$this->noScalarSubselection('doesKnowCommand', 'Boolean', 3, 52)] + ); + } + + private function noScalarSubselection($field, $type, $line, $column) + { + return new FormattedError( + Messages::noSubselectionAllowedMessage($field, $type), + [new SourceLocation($line, $column)] + ); + } + + private function missingObjSubselection($field, $type, $line, $column) + { + return new FormattedError( + Messages::requiredSubselectionMessage($field, $type), + [new SourceLocation($line, $column)] + ); + } +} diff --git a/tests/Validator/TestCase.php b/tests/Validator/TestCase.php new file mode 100644 index 0000000..158237a --- /dev/null +++ b/tests/Validator/TestCase.php @@ -0,0 +1,307 @@ + 'Being', + 'fields' => [ + 'name' => [ 'type' => Type::string() ] + ], + ]); + + $Pet = new InterfaceType([ + 'name' => 'Pet', + 'fields' => [ + 'name' => [ 'type' => Type::string() ] + ], + ]); + + $DogCommand = new EnumType([ + 'name' => 'DogCommand', + 'values' => [ + 'SIT' => ['value' => 0], + 'HEEL' => ['value' => 1], + 'DOWN' => ['value' => 3] + ] + ]); + + $Dog = new ObjectType([ + 'name' => 'Dog', + 'fields' => [ + 'name' => ['type' => Type::string()], + 'nickname' => ['type' => Type::string()], + 'barkVolume' => ['type' => Type::int()], + 'barks' => ['type' => Type::boolean()], + 'doesKnowCommand' => [ + 'type' => Type::boolean(), + 'args' => ['dogCommand' => ['type' => $DogCommand]] + ], + 'isHousetrained' => [ + 'type' => Type::boolean(), + 'args' => ['atOtherHomes' => ['type' => Type::boolean(), 'defaultValue' => true]] + ], + 'isAtLocation' => [ + 'type' => Type::boolean(), + 'args' => ['x' => ['type' => Type::int()], 'y' => ['type' => Type::int()]] + ] + ], + 'interfaces' => [$Being, $Pet] + ]); + + $FurColor = new EnumType([ + 'name' => 'FurColor', + 'values' => [ + 'BROWN' => [ 'value' => 0 ], + 'BLACK' => [ 'value' => 1 ], + 'TAN' => [ 'value' => 2 ], + 'SPOTTED' => [ 'value' => 3 ], + ], + ]); + + $Cat = new ObjectType([ + 'name' => 'Cat', + 'fields' => [ + 'name' => ['type' => Type::string()], + 'nickname' => ['type' => Type::string()], + 'meows' => ['type' => Type::boolean()], + 'meowVolume' => ['type' => Type::int()], + 'furColor' => ['type' => $FurColor] + ], + 'interfaces' => [$Being, $Pet] + ]); + + $CatOrDog = new UnionType([ + 'name' => 'CatOrDog', + 'types' => [$Dog, $Cat], + 'resolveType' => function($value) { + // not used for validation + return null; + } + ]); + + $Intelligent = new InterfaceType([ + 'name' => 'Intelligent', + 'fields' => [ + 'iq' => ['type' => Type::int()] + ] + ]); + + $Human = $this->humanType = new ObjectType([ + 'name' => 'Human', + 'interfaces' => [$Being, $Intelligent], + 'fields' => [ + 'name' => [ + 'args' => ['surname' => ['type' => Type::boolean()]], + 'type' => Type::string() + ], + 'pets' => ['type' => Type::listOf($Pet)], + 'relatives' => ['type' => function() {return Type::listOf($this->humanType); }] + ] + ]); + + $Alien = new ObjectType([ + 'name' => 'Alien', + 'interfaces' => [$Being, $Intelligent], + 'fields' => [ + 'iq' => ['type' => Type::int()], + 'numEyes' => ['type' => Type::int()] + ] + ]); + + $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; + } + ]); + + $ComplexInput = new InputObjectType([ + 'name' => 'ComplexInput', + 'fields' => [ + 'requiredField' => ['type' => Type::nonNull(Type::boolean())], + 'intField' => ['type' => Type::int()], + 'stringField' => ['type' => Type::string()], + 'booleanField' => ['type' => Type::boolean()], + 'stringListField' => ['type' => Type::listOf(Type::string())] + ] + ]); + + $ComplicatedArgs = new ObjectType([ + 'name' => 'ComplicatedArgs', + // TODO List + // TODO Coercion + // TODO NotNulls + 'fields' => [ + 'intArgField' => [ + 'type' => Type::string(), + 'args' => ['intArg' => ['type' => Type::int()]], + ], + 'nonNullIntArgField' => [ + 'type' => Type::string(), + 'args' => [ 'nonNullIntArg' => [ 'type' => Type::nonNull(Type::int())]], + ], + 'stringArgField' => [ + 'type' => Type::string(), + 'args' => [ 'stringArg' => [ 'type' => Type::string()]], + ], + 'booleanArgField' => [ + 'type' => Type::string(), + 'args' => ['booleanArg' => [ 'type' => Type::boolean() ]], + ], + 'enumArgField' => [ + 'type' => Type::string(), + 'args' => [ 'enumArg' => ['type' => $FurColor ]], + ], + 'floatArgField' => [ + 'type' => Type::string(), + 'args' => [ 'floatArg' => [ 'type' => Type::float()]], + ], + 'idArgField' => [ + 'type' => Type::string(), + 'args' => [ 'idArg' => [ 'type' => Type::id() ]], + ], + 'stringListArgField' => [ + 'type' => Type::string(), + 'args' => [ 'stringListArg' => [ 'type' => Type::listOf(Type::string())]], + ], + 'complexArgField' => [ + 'type' => Type::string(), + 'args' => [ 'complexArg' => [ 'type' => $ComplexInput ]], + ], + 'multipleReqs' => [ + 'type' => Type::string(), + 'args' => [ + 'req1' => [ 'type' => Type::nonNull(Type::int())], + 'req2' => [ 'type' => Type::nonNull(Type::int())], + ], + ], + 'multipleOpts' => [ + 'type' => Type::string(), + 'args' => [ + 'opt1' => [ + 'type' => Type::int(), + 'defaultValue' => 0, + ], + 'opt2' => [ + 'type' => Type::int(), + 'defaultValue' => 0, + ], + ], + ], + 'multipleOptAndReq' => [ + 'type' => Type::string(), + 'args' => [ + 'req1' => [ 'type' => Type::nonNull(Type::int())], + 'req2' => [ 'type' => Type::nonNull(Type::int())], + 'opt1' => [ + 'type' => Type::int(), + 'defaultValue' => 0, + ], + 'opt2' => [ + 'type' => Type::int(), + 'defaultValue' => 0, + ], + ], + ], + ] + ]); + + $queryRoot = new ObjectType([ + 'name' => 'QueryRoot', + 'fields' => [ + 'human' => [ + 'args' => ['id' => ['type' => Type::id()]], + 'type' => $Human + ], + 'alien' => ['type' => $Alien], + 'dog' => ['type' => $Dog], + 'cat' => ['type' => $Cat], + 'pet' => ['type' => $Pet], + 'catOrDog' => ['type' => $CatOrDog], + 'dogOrHuman' => ['type' => $DogOrHuman], + 'humanOrAlien' => ['type' => $HumanOrAlien], + 'complicatedArgs' => ['type' => $ComplicatedArgs] + ] + ]); + + $defaultSchema = new Schema($queryRoot); + return $defaultSchema; + } + + function expectValid($schema, $rules, $queryString) + { + $this->assertEquals( + ['isValid' => true, 'errors' => null], + DocumentValidator::validate($schema, Parser::parse($queryString), $rules) + ); + } + + function expectInvalid($schema, $rules, $queryString, $errors) + { + $result = DocumentValidator::validate($schema, Parser::parse($queryString), $rules); + + $this->assertEquals(false, $result['isValid'], 'GraphQL should not validate'); + $this->assertEquals($errors, $result['errors']); + + return $result; + } + + function expectPassesRule($rule, $queryString) + { + $this->expectValid($this->getDefaultSchema(), [$rule], $queryString); + } + + function expectFailsRule($rule, $queryString, $errors) + { + return $this->expectInvalid($this->getDefaultSchema(), [$rule], $queryString, $errors); + } + + function expectPassesRuleWithSchema($schema, $rule, $queryString) + { + $this->expectValid($schema, [$rule], $queryString); + } + + function expectFailsRuleWithSchema($schema, $rule, $queryString, $errors) + { + $this->expectInvalid($schema, [$rule], $queryString, $errors); + } + + function expectPassesCompleteValidation($queryString) + { + $this->expectValid($this->getDefaultSchema(), $this->getAllRules(), $queryString); + } + + function expectFailsCompleteValidation($queryString, $errors) + { + $this->expectInvalid($this->getDefaultSchema(), $this->getAllRules(), $queryString, $errors); + } +} diff --git a/tests/Validator/VariablesAreInputTypesTest.php b/tests/Validator/VariablesAreInputTypesTest.php new file mode 100644 index 0000000..4a00571 --- /dev/null +++ b/tests/Validator/VariablesAreInputTypesTest.php @@ -0,0 +1,42 @@ +expectPassesRule(new VariablesAreInputTypes(), ' + query Foo($a: String, $b: [Boolean!]!, $c: ComplexInput) { + field(a: $a, b: $b, c: $c) + } + '); + } + + public function testOutputTypesAreInvalid() + { + $this->expectFailsRule(new VariablesAreInputTypes, ' + query Foo($a: Dog, $b: [[DogOrCat!]]!, $c: Pet) { + field(a: $a, b: $b, c: $c) + } + ', [ + new FormattedError( + Messages::nonInputTypeOnVarMessage('a', 'Dog'), + [new SourceLocation(2, 21)] + ), + new FormattedError( + Messages::nonInputTypeOnVarMessage('b', '[[DogOrCat!]]!'), + [new SourceLocation(2, 30)] + ), + new FormattedError( + Messages::nonInputTypeOnVarMessage('c', 'Pet'), + [new SourceLocation(2, 50)] + ) + ] + ); + } +} \ No newline at end of file diff --git a/tests/Validator/VariablesInAllowedPositionTest.php b/tests/Validator/VariablesInAllowedPositionTest.php new file mode 100644 index 0000000..126c012 --- /dev/null +++ b/tests/Validator/VariablesInAllowedPositionTest.php @@ -0,0 +1,330 @@ + Boolean + $this->expectPassesRule(new VariablesInAllowedPosition(), ' + query Query($booleanArg: Boolean) + { + complicatedArgs { + booleanArgField(booleanArg: $booleanArg) + } + } + '); + } + + public function testBooleanXBooleanWithinFragment() + { + // Boolean => Boolean within fragment + $this->expectPassesRule(new VariablesInAllowedPosition, ' + fragment booleanArgFrag on ComplicatedArgs { + booleanArgField(booleanArg: $booleanArg) + } + query Query($booleanArg: Boolean) + { + complicatedArgs { + ...booleanArgFrag + } + } + '); + + $this->expectPassesRule(new VariablesInAllowedPosition, ' + query Query($booleanArg: Boolean) + { + complicatedArgs { + ...booleanArgFrag + } + } + fragment booleanArgFrag on ComplicatedArgs { + booleanArgField(booleanArg: $booleanArg) + } + '); + } + + public function testBooleanNonNullXBoolean() + { + // Boolean! => Boolean + $this->expectPassesRule(new VariablesInAllowedPosition, ' + query Query($nonNullBooleanArg: Boolean!) + { + complicatedArgs { + booleanArgField(booleanArg: $nonNullBooleanArg) + } + } + '); + } + + public function testBooleanNonNullXBooleanWithinFragment() + { + // Boolean! => Boolean within fragment + $this->expectPassesRule(new VariablesInAllowedPosition, ' + fragment booleanArgFrag on ComplicatedArgs { + booleanArgField(booleanArg: $nonNullBooleanArg) + } + + query Query($nonNullBooleanArg: Boolean!) + { + complicatedArgs { + ...booleanArgFrag + } + } + '); + } + + public function testIntXIntNonNullWithDefault() + { + // Int => Int! with default + $this->expectPassesRule(new VariablesInAllowedPosition, ' + query Query($intArg: Int = 1) + { + complicatedArgs { + nonNullIntArgField(nonNullIntArg: $intArg) + } + } + '); + } + + public function testListOfStringXListOfString() + { + // [String] => [String] + $this->expectPassesRule(new VariablesInAllowedPosition, ' + query Query($stringListVar: [String]) + { + complicatedArgs { + stringListArgField(stringListArg: $stringListVar) + } + } + '); + } + + public function testListOfStringNonNullXListOfString() + { + // [String!] => [String] + $this->expectPassesRule(new VariablesInAllowedPosition, ' + query Query($stringListVar: [String!]) + { + complicatedArgs { + stringListArgField(stringListArg: $stringListVar) + } + } + '); + } + + public function testStringXListOfStringInItemPosition() + { + // String => [String] in item position + $this->expectPassesRule(new VariablesInAllowedPosition, ' + query Query($stringVar: String) + { + complicatedArgs { + stringListArgField(stringListArg: [$stringVar]) + } + } + '); + } + + public function testStringNonNullXListOfStringInItemPosition() + { + // String! => [String] in item position + $this->expectPassesRule(new VariablesInAllowedPosition, ' + query Query($stringVar: String!) + { + complicatedArgs { + stringListArgField(stringListArg: [$stringVar]) + } + } + '); + } + + public function testComplexInputXComplexInput() + { + // ComplexInput => ComplexInput + $this->expectPassesRule(new VariablesInAllowedPosition, ' + query Query($complexVar: ComplexInput) + { + complicatedArgs { + complexArgField(complexArg: $ComplexInput) + } + } + '); + } + + public function testComplexInputXComplexInputInFieldPosition() + { + // ComplexInput => ComplexInput in field position + $this->expectPassesRule(new VariablesInAllowedPosition, ' + query Query($boolVar: Boolean = false) + { + complicatedArgs { + complexArgField(complexArg: {requiredArg: $boolVar}) + } + } + '); + } + + public function testBooleanNonNullXBooleanNonNullInDirective() + { + // Boolean! => Boolean! in directive + $this->expectPassesRule(new VariablesInAllowedPosition, ' + query Query($boolVar: Boolean!) + { + dog @if: $boolVar + } + '); + } + + public function testBooleanXBooleanNonNullInDirectiveWithDefault() + { + // Boolean => Boolean! in directive with default + $this->expectPassesRule(new VariablesInAllowedPosition, ' + query Query($boolVar: Boolean = false) + { + dog @if: $boolVar + } + '); + } + + public function testIntXIntNonNull() + { + // Int => Int! + $this->expectFailsRule(new VariablesInAllowedPosition, ' + query Query($intArg: Int) + { + complicatedArgs { + nonNullIntArgField(nonNullIntArg: $intArg) + } + } + ', [ + new FormattedError( + Messages::badVarPosMessage('intArg', 'Int', 'Int!'), + [new SourceLocation(5, 45)] + ) + ]); + } + + public function testIntXIntNonNullWithinFragment() + { + // Int => Int! within fragment + $this->expectFailsRule(new VariablesInAllowedPosition, ' + fragment nonNullIntArgFieldFrag on ComplicatedArgs { + nonNullIntArgField(nonNullIntArg: $intArg) + } + + query Query($intArg: Int) + { + complicatedArgs { + ...nonNullIntArgFieldFrag + } + } + ', [ + new FormattedError( + Messages::badVarPosMessage('intArg', 'Int', 'Int!'), + [new SourceLocation(3, 43)] + ) + ]); + } + + public function testIntXIntNonNullWithinNestedFragment() + { + // Int => Int! within nested fragment + $this->expectFailsRule(new VariablesInAllowedPosition, ' + fragment outerFrag on ComplicatedArgs { + ...nonNullIntArgFieldFrag + } + + fragment nonNullIntArgFieldFrag on ComplicatedArgs { + nonNullIntArgField(nonNullIntArg: $intArg) + } + + query Query($intArg: Int) + { + complicatedArgs { + ...outerFrag + } + } + ', [ + new FormattedError( + Messages::badVarPosMessage('intArg', 'Int', 'Int!'), + [new SourceLocation(7,43)] + ) + ]); + } + + public function testStringOverBoolean() + { + // String over Boolean + $this->expectFailsRule(new VariablesInAllowedPosition, ' + query Query($stringVar: String) + { + complicatedArgs { + booleanArgField(booleanArg: $stringVar) + } + } + ', [ + new FormattedError( + Messages::badVarPosMessage('stringVar', 'String', 'Boolean'), + [new SourceLocation(5,39)] + ) + ]); + } + + public function testStringXListOfString() + { + // String => [String] + $this->expectFailsRule(new VariablesInAllowedPosition, ' + query Query($stringVar: String) + { + complicatedArgs { + stringListArgField(stringListArg: $stringVar) + } + } + ', [ + new FormattedError( + Messages::badVarPosMessage('stringVar', 'String', '[String]'), + [new SourceLocation(5,45)] + ) + ]); + } + + public function testBooleanXBooleanNonNullInDirective() + { + // Boolean => Boolean! in directive + $this->expectFailsRule(new VariablesInAllowedPosition, ' + query Query($boolVar: Boolean) + { + dog @if: $boolVar + } + ', [ + new FormattedError( + Messages::badVarPosMessage('boolVar', 'Boolean', 'Boolean!'), + [new SourceLocation(4,18)] + ) + ]); + } + + public function testStringXBooleanNonNullInDirective() + { + // String => Boolean! in directive + $this->expectFailsRule(new VariablesInAllowedPosition, ' + query Query($stringVar: String) + { + dog @if: $stringVar + } + ', [ + new FormattedError( + Messages::badVarPosMessage('stringVar', 'String', 'Boolean!'), + [new SourceLocation(4,18)] + ) + ]); + } + +}