diff --git a/.gitignore b/.gitignore index 99361ac..fa07e1e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .phpcs-cache composer.lock +composer.phar phpcs.xml phpstan.neon vendor/ diff --git a/.travis.yml b/.travis.yml index 95f327c..98acdaa 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,12 @@ php: - 7.2 - nightly +env: + matrix: + - EXECUTOR=coroutine + - EXECUTOR= + + cache: directories: - $HOME/.composer/cache diff --git a/composer.json b/composer.json index 27bad9f..c6e6abd 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "config": { "preferred-install": "dist", "sort-packages": true -}, + }, "autoload": { "psr-4": { "GraphQL\\": "src/" diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 197feb9..3006277 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,7 +2,7 @@ diff --git a/src/Executor/ExecutionResult.php b/src/Executor/ExecutionResult.php index 6ac7d41..e9dbe4e 100644 --- a/src/Executor/ExecutionResult.php +++ b/src/Executor/ExecutionResult.php @@ -58,7 +58,7 @@ class ExecutionResult implements JsonSerializable * @param Error[] $errors * @param mixed[] $extensions */ - public function __construct(?array $data = null, array $errors = [], array $extensions = []) + public function __construct($data = null, array $errors = [], array $extensions = []) { $this->data = $data; $this->errors = $errors; diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index 4804b95..bf07cdd 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -5,90 +5,66 @@ declare(strict_types=1); namespace GraphQL\Executor; use ArrayAccess; -use ArrayObject; use Closure; -use Exception; -use GraphQL\Error\Error; -use GraphQL\Error\InvariantViolation; -use GraphQL\Error\Warning; use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter; use GraphQL\Executor\Promise\Promise; use GraphQL\Executor\Promise\PromiseAdapter; use GraphQL\Language\AST\DocumentNode; -use GraphQL\Language\AST\FieldNode; -use GraphQL\Language\AST\FragmentDefinitionNode; -use GraphQL\Language\AST\FragmentSpreadNode; -use GraphQL\Language\AST\InlineFragmentNode; -use GraphQL\Language\AST\NodeKind; -use GraphQL\Language\AST\OperationDefinitionNode; -use GraphQL\Language\AST\SelectionSetNode; -use GraphQL\Type\Definition\AbstractType; -use GraphQL\Type\Definition\Directive; -use GraphQL\Type\Definition\FieldDefinition; -use GraphQL\Type\Definition\InterfaceType; -use GraphQL\Type\Definition\LeafType; -use GraphQL\Type\Definition\ListOfType; -use GraphQL\Type\Definition\NonNull; -use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\ResolveInfo; -use GraphQL\Type\Definition\Type; -use GraphQL\Type\Introspection; use GraphQL\Type\Schema; -use GraphQL\Utils\TypeInfo; -use GraphQL\Utils\Utils; -use RuntimeException; -use SplObjectStorage; -use stdClass; -use Throwable; -use Traversable; -use function array_keys; -use function array_merge; -use function array_reduce; -use function array_values; -use function get_class; use function is_array; use function is_object; -use function is_string; -use function sprintf; /** * Implements the "Evaluating requests" section of the GraphQL specification. */ class Executor { - /** @var object */ - private static $UNDEFINED; - /** @var callable|string[] */ private static $defaultFieldResolver = [self::class, 'defaultFieldResolver']; /** @var PromiseAdapter */ - private static $promiseAdapter; + private static $defaultPromiseAdapter; - /** @var ExecutionContext */ - private $exeContext; + /** @var callable */ + private static $implementationFactory = [ReferenceExecutor::class, 'create']; - /** @var SplObjectStorage */ - private $subFieldCache; - - private function __construct(ExecutionContext $context) + public static function getDefaultFieldResolver() : callable { - if (! self::$UNDEFINED) { - self::$UNDEFINED = Utils::undefined(); - } - - $this->exeContext = $context; - $this->subFieldCache = new SplObjectStorage(); + return self::$defaultFieldResolver; } /** - * Custom default resolve function - * - * @throws Exception + * Custom default resolve function. */ - public static function setDefaultFieldResolver(callable $fn) + public static function setDefaultFieldResolver(callable $fieldResolver) { - self::$defaultFieldResolver = $fn; + self::$defaultFieldResolver = $fieldResolver; + } + + public static function getPromiseAdapter() : PromiseAdapter + { + return self::$defaultPromiseAdapter ?: (self::$defaultPromiseAdapter = new SyncPromiseAdapter()); + } + + public static function setPromiseAdapter(?PromiseAdapter $defaultPromiseAdapter = null) + { + self::$defaultPromiseAdapter = $defaultPromiseAdapter; + } + + public static function getImplementationFactory() : callable + { + return self::$implementationFactory; + } + + /** + * Custom executor implementation factory. + * + * Will be called with as + */ + public static function setImplementationFactory(callable $implementationFactory) + { + self::$implementationFactory = $implementationFactory; } /** @@ -108,7 +84,7 @@ class Executor */ public static function execute( Schema $schema, - DocumentNode $ast, + DocumentNode $documentNode, $rootValue = null, $contextValue = null, $variableValues = null, @@ -116,11 +92,13 @@ class Executor ?callable $fieldResolver = null ) { // TODO: deprecate (just always use SyncAdapter here) and have `promiseToExecute()` for other cases - $promiseAdapter = self::getPromiseAdapter(); - $result = self::promiseToExecute( + + $promiseAdapter = static::getPromiseAdapter(); + + $result = static::promiseToExecute( $promiseAdapter, $schema, - $ast, + $documentNode, $rootValue, $contextValue, $variableValues, @@ -128,7 +106,6 @@ class Executor $fieldResolver ); - // Wait for promised results when using sync promises if ($promiseAdapter instanceof SyncPromiseAdapter) { $result = $promiseAdapter->wait($result); } @@ -136,19 +113,6 @@ class Executor return $result; } - /** - * @return PromiseAdapter - */ - public static function getPromiseAdapter() - { - return self::$promiseAdapter ?: (self::$promiseAdapter = new SyncPromiseAdapter()); - } - - public static function setPromiseAdapter(?PromiseAdapter $promiseAdapter = null) - { - self::$promiseAdapter = $promiseAdapter; - } - /** * Same as execute(), but requires promise adapter and returns a promise which is always * fulfilled with an instance of ExecutionResult and never rejected. @@ -167,1348 +131,30 @@ class Executor public static function promiseToExecute( PromiseAdapter $promiseAdapter, Schema $schema, - DocumentNode $ast, + DocumentNode $documentNode, $rootValue = null, $contextValue = null, $variableValues = null, $operationName = null, ?callable $fieldResolver = null ) { - $exeContext = self::buildExecutionContext( + $factory = self::$implementationFactory; + + /** @var ExecutorImplementation $executor */ + $executor = $factory( + $promiseAdapter, $schema, - $ast, + $documentNode, $rootValue, $contextValue, $variableValues, $operationName, - $fieldResolver, - $promiseAdapter + $fieldResolver ?: self::$defaultFieldResolver ); - if (is_array($exeContext)) { - return $promiseAdapter->createFulfilled(new ExecutionResult(null, $exeContext)); - } - - $executor = new self($exeContext); - return $executor->doExecute(); } - /** - * Constructs an ExecutionContext object from the arguments passed to - * execute, which we will pass throughout the other execution methods. - * - * @param mixed[] $rootValue - * @param mixed[] $contextValue - * @param mixed[]|Traversable $rawVariableValues - * @param string|null $operationName - * - * @return ExecutionContext|Error[] - */ - private static function buildExecutionContext( - Schema $schema, - DocumentNode $documentNode, - $rootValue, - $contextValue, - $rawVariableValues, - $operationName = null, - ?callable $fieldResolver = null, - ?PromiseAdapter $promiseAdapter = null - ) { - $errors = []; - $fragments = []; - /** @var OperationDefinitionNode $operation */ - $operation = null; - $hasMultipleAssumedOperations = false; - - foreach ($documentNode->definitions as $definition) { - switch ($definition->kind) { - case NodeKind::OPERATION_DEFINITION: - if (! $operationName && $operation) { - $hasMultipleAssumedOperations = true; - } - if (! $operationName || - (isset($definition->name) && $definition->name->value === $operationName)) { - $operation = $definition; - } - break; - case NodeKind::FRAGMENT_DEFINITION: - $fragments[$definition->name->value] = $definition; - break; - } - } - - if (! $operation) { - if ($operationName) { - $errors[] = new Error(sprintf('Unknown operation named "%s".', $operationName)); - } else { - $errors[] = new Error('Must provide an operation.'); - } - } elseif ($hasMultipleAssumedOperations) { - $errors[] = new Error( - 'Must provide operation name if query contains multiple operations.' - ); - } - - $variableValues = null; - if ($operation) { - $coercedVariableValues = Values::getVariableValues( - $schema, - $operation->variableDefinitions ?: [], - $rawVariableValues ?: [] - ); - - if (empty($coercedVariableValues['errors'])) { - $variableValues = $coercedVariableValues['coerced']; - } else { - $errors = array_merge($errors, $coercedVariableValues['errors']); - } - } - - if (! empty($errors)) { - return $errors; - } - - Utils::invariant($operation, 'Has operation if no errors.'); - Utils::invariant($variableValues !== null, 'Has variables if no errors.'); - - return new ExecutionContext( - $schema, - $fragments, - $rootValue, - $contextValue, - $operation, - $variableValues, - $errors, - $fieldResolver ?: self::$defaultFieldResolver, - $promiseAdapter ?: self::getPromiseAdapter() - ); - } - - /** - * @return Promise - */ - private function doExecute() - { - // Return a Promise that will eventually resolve to the data described by - // The "Response" section of the GraphQL specification. - // - // If errors are encountered while executing a GraphQL field, only that - // field and its descendants will be omitted, and sibling fields will still - // be executed. An execution which encounters errors will still result in a - // resolved Promise. - $data = $this->executeOperation($this->exeContext->operation, $this->exeContext->rootValue); - $result = $this->buildResponse($data); - - // Note: we deviate here from the reference implementation a bit by always returning promise - // But for the "sync" case it is always fulfilled - return $this->isPromise($result) - ? $result - : $this->exeContext->promises->createFulfilled($result); - } - - /** - * @param mixed|Promise|null $data - * - * @return ExecutionResult|Promise - */ - private function buildResponse($data) - { - if ($this->isPromise($data)) { - return $data->then(function ($resolved) { - return $this->buildResponse($resolved); - }); - } - if ($data !== null) { - $data = (array) $data; - } - - return new ExecutionResult($data, $this->exeContext->errors); - } - - /** - * Implements the "Evaluating operations" section of the spec. - * - * @param mixed[] $rootValue - * - * @return Promise|stdClass|mixed[] - */ - private function executeOperation(OperationDefinitionNode $operation, $rootValue) - { - $type = $this->getOperationRootType($this->exeContext->schema, $operation); - $fields = $this->collectFields($type, $operation->selectionSet, new ArrayObject(), new ArrayObject()); - - $path = []; - - // Errors from sub-fields of a NonNull type may propagate to the top level, - // at which point we still log the error and null the parent field, which - // in this case is the entire response. - // - // Similar to completeValueCatchingError. - try { - $result = $operation->operation === 'mutation' ? - $this->executeFieldsSerially($type, $rootValue, $path, $fields) : - $this->executeFields($type, $rootValue, $path, $fields); - - if ($this->isPromise($result)) { - return $result->then( - null, - function ($error) { - $this->exeContext->addError($error); - - return $this->exeContext->promises->createFulfilled(null); - } - ); - } - - return $result; - } catch (Error $error) { - $this->exeContext->addError($error); - - return null; - } - } - - /** - * Extracts the root type of the operation from the schema. - * - * @return ObjectType - * - * @throws Error - */ - private function getOperationRootType(Schema $schema, OperationDefinitionNode $operation) - { - switch ($operation->operation) { - case 'query': - $queryType = $schema->getQueryType(); - if (! $queryType) { - throw new Error( - 'Schema does not define the required query root type.', - [$operation] - ); - } - - return $queryType; - case 'mutation': - $mutationType = $schema->getMutationType(); - if (! $mutationType) { - throw new Error( - 'Schema is not configured for mutations.', - [$operation] - ); - } - - return $mutationType; - case 'subscription': - $subscriptionType = $schema->getSubscriptionType(); - if (! $subscriptionType) { - throw new Error( - 'Schema is not configured for subscriptions.', - [$operation] - ); - } - - return $subscriptionType; - default: - throw new Error( - 'Can only execute queries, mutations and subscriptions.', - [$operation] - ); - } - } - - /** - * Given a selectionSet, adds all of the fields in that selection to - * the passed in map of fields, and returns it at the end. - * - * CollectFields requires the "runtime type" of an object. For a field which - * returns an Interface or Union type, the "runtime type" will be the actual - * Object type returned by that field. - * - * @param ArrayObject $fields - * @param ArrayObject $visitedFragmentNames - * - * @return ArrayObject - */ - private function collectFields( - ObjectType $runtimeType, - SelectionSetNode $selectionSet, - $fields, - $visitedFragmentNames - ) { - $exeContext = $this->exeContext; - foreach ($selectionSet->selections as $selection) { - switch ($selection->kind) { - case NodeKind::FIELD: - if (! $this->shouldIncludeNode($selection)) { - break; - } - $name = self::getFieldEntryKey($selection); - if (! isset($fields[$name])) { - $fields[$name] = new ArrayObject(); - } - $fields[$name][] = $selection; - break; - case NodeKind::INLINE_FRAGMENT: - if (! $this->shouldIncludeNode($selection) || - ! $this->doesFragmentConditionMatch($selection, $runtimeType) - ) { - break; - } - $this->collectFields( - $runtimeType, - $selection->selectionSet, - $fields, - $visitedFragmentNames - ); - break; - case NodeKind::FRAGMENT_SPREAD: - $fragName = $selection->name->value; - if (! empty($visitedFragmentNames[$fragName]) || ! $this->shouldIncludeNode($selection)) { - break; - } - $visitedFragmentNames[$fragName] = true; - - /** @var FragmentDefinitionNode|null $fragment */ - $fragment = $exeContext->fragments[$fragName] ?? null; - if (! $fragment || ! $this->doesFragmentConditionMatch($fragment, $runtimeType)) { - break; - } - $this->collectFields( - $runtimeType, - $fragment->selectionSet, - $fields, - $visitedFragmentNames - ); - break; - } - } - - return $fields; - } - - /** - * Determines if a field should be included based on the @include and @skip - * directives, where @skip has higher precedence than @include. - * - * @param FragmentSpreadNode|FieldNode|InlineFragmentNode $node - * - * @return bool - */ - private function shouldIncludeNode($node) - { - $variableValues = $this->exeContext->variableValues; - $skipDirective = Directive::skipDirective(); - - $skip = Values::getDirectiveValues( - $skipDirective, - $node, - $variableValues - ); - - if (isset($skip['if']) && $skip['if'] === true) { - return false; - } - - $includeDirective = Directive::includeDirective(); - - $include = Values::getDirectiveValues( - $includeDirective, - $node, - $variableValues - ); - - if (isset($include['if']) && $include['if'] === false) { - return false; - } - - return true; - } - - /** - * Implements the logic to compute the key of a given fields entry - * - * @return string - */ - private static function getFieldEntryKey(FieldNode $node) - { - return $node->alias ? $node->alias->value : $node->name->value; - } - - /** - * Determines if a fragment is applicable to the given type. - * - * @param FragmentDefinitionNode|InlineFragmentNode $fragment - * - * @return bool - */ - private function doesFragmentConditionMatch( - $fragment, - ObjectType $type - ) { - $typeConditionNode = $fragment->typeCondition; - - if ($typeConditionNode === null) { - return true; - } - - $conditionalType = TypeInfo::typeFromAST($this->exeContext->schema, $typeConditionNode); - if ($conditionalType === $type) { - return true; - } - if ($conditionalType instanceof AbstractType) { - return $this->exeContext->schema->isPossibleType($conditionalType, $type); - } - - return false; - } - - /** - * Implements the "Evaluating selection sets" section of the spec - * for "write" mode. - * - * @param mixed[] $sourceValue - * @param mixed[] $path - * @param ArrayObject $fields - * - * @return Promise|stdClass|mixed[] - */ - private function executeFieldsSerially(ObjectType $parentType, $sourceValue, $path, $fields) - { - $result = $this->promiseReduce( - array_keys($fields->getArrayCopy()), - function ($results, $responseName) use ($path, $parentType, $sourceValue, $fields) { - $fieldNodes = $fields[$responseName]; - $fieldPath = $path; - $fieldPath[] = $responseName; - $result = $this->resolveField($parentType, $sourceValue, $fieldNodes, $fieldPath); - if ($result === self::$UNDEFINED) { - return $results; - } - $promise = $this->getPromise($result); - if ($promise) { - return $promise->then(static function ($resolvedResult) use ($responseName, $results) { - $results[$responseName] = $resolvedResult; - - return $results; - }); - } - $results[$responseName] = $result; - - return $results; - }, - [] - ); - if ($this->isPromise($result)) { - return $result->then(static function ($resolvedResults) { - return self::fixResultsIfEmptyArray($resolvedResults); - }); - } - - return self::fixResultsIfEmptyArray($result); - } - - /** - * Resolves the field on the given source object. In particular, this - * figures out the value that the field returns by calling its resolve function, - * then calls completeValue to complete promises, serialize scalars, or execute - * the sub-selection-set for objects. - * - * @param object|null $source - * @param FieldNode[] $fieldNodes - * @param mixed[] $path - * - * @return mixed[]|Exception|mixed|null - */ - private function resolveField(ObjectType $parentType, $source, $fieldNodes, $path) - { - $exeContext = $this->exeContext; - $fieldNode = $fieldNodes[0]; - - $fieldName = $fieldNode->name->value; - $fieldDef = $this->getFieldDef($exeContext->schema, $parentType, $fieldName); - - if (! $fieldDef) { - return self::$UNDEFINED; - } - - $returnType = $fieldDef->getType(); - - // The resolve function's optional third argument is a collection of - // information about the current execution state. - $info = new ResolveInfo([ - 'fieldName' => $fieldName, - 'fieldNodes' => $fieldNodes, - 'returnType' => $returnType, - 'parentType' => $parentType, - 'path' => $path, - 'schema' => $exeContext->schema, - 'fragments' => $exeContext->fragments, - 'rootValue' => $exeContext->rootValue, - 'operation' => $exeContext->operation, - 'variableValues' => $exeContext->variableValues, - ]); - - if ($fieldDef->resolveFn !== null) { - $resolveFn = $fieldDef->resolveFn; - } elseif ($parentType->resolveFieldFn !== null) { - $resolveFn = $parentType->resolveFieldFn; - } else { - $resolveFn = $this->exeContext->fieldResolver; - } - - // The resolve function's optional third argument is a context value that - // is provided to every resolve function within an execution. It is commonly - // used to represent an authenticated user, or request-specific caches. - $context = $exeContext->contextValue; - - // Get the resolve function, regardless of if its result is normal - // or abrupt (error). - $result = $this->resolveOrError( - $fieldDef, - $fieldNode, - $resolveFn, - $source, - $context, - $info - ); - - $result = $this->completeValueCatchingError( - $returnType, - $fieldNodes, - $info, - $path, - $result - ); - - return $result; - } - - /** - * This method looks up the field on the given type definition. - * 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. - * - * @param string $fieldName - * - * @return FieldDefinition - */ - private function getFieldDef(Schema $schema, ObjectType $parentType, $fieldName) - { - static $schemaMetaFieldDef, $typeMetaFieldDef, $typeNameMetaFieldDef; - - $schemaMetaFieldDef = $schemaMetaFieldDef ?: Introspection::schemaMetaFieldDef(); - $typeMetaFieldDef = $typeMetaFieldDef ?: Introspection::typeMetaFieldDef(); - $typeNameMetaFieldDef = $typeNameMetaFieldDef ?: Introspection::typeNameMetaFieldDef(); - - if ($fieldName === $schemaMetaFieldDef->name && $schema->getQueryType() === $parentType) { - return $schemaMetaFieldDef; - } elseif ($fieldName === $typeMetaFieldDef->name && $schema->getQueryType() === $parentType) { - return $typeMetaFieldDef; - } elseif ($fieldName === $typeNameMetaFieldDef->name) { - return $typeNameMetaFieldDef; - } - - $tmp = $parentType->getFields(); - - return $tmp[$fieldName] ?? null; - } - - /** - * Isolates the "ReturnOrAbrupt" behavior to not de-opt the `resolveField` - * function. Returns the result of resolveFn or the abrupt-return Error object. - * - * @param FieldDefinition $fieldDef - * @param FieldNode $fieldNode - * @param callable $resolveFn - * @param mixed $source - * @param mixed $context - * @param ResolveInfo $info - * - * @return Throwable|Promise|mixed - */ - private function resolveOrError($fieldDef, $fieldNode, $resolveFn, $source, $context, $info) - { - try { - // Build hash of arguments from the field.arguments AST, using the - // variables scope to fulfill any variable references. - $args = Values::getArgumentValues( - $fieldDef, - $fieldNode, - $this->exeContext->variableValues - ); - - return $resolveFn($source, $args, $context, $info); - } catch (Exception $error) { - return $error; - } catch (Throwable $error) { - return $error; - } - } - - /** - * This is a small wrapper around completeValue which detects and logs errors - * in the execution context. - * - * @param FieldNode[] $fieldNodes - * @param string[] $path - * @param mixed $result - * - * @return mixed[]|Promise|null - */ - private function completeValueCatchingError( - Type $returnType, - $fieldNodes, - ResolveInfo $info, - $path, - $result - ) { - $exeContext = $this->exeContext; - - // If the field type is non-nullable, then it is resolved without any - // protection from errors. - if ($returnType instanceof NonNull) { - return $this->completeValueWithLocatedError( - $returnType, - $fieldNodes, - $info, - $path, - $result - ); - } - - // Otherwise, error protection is applied, logging the error and resolving - // a null value for this field if one is encountered. - try { - $completed = $this->completeValueWithLocatedError( - $returnType, - $fieldNodes, - $info, - $path, - $result - ); - - $promise = $this->getPromise($completed); - if ($promise) { - return $promise->then( - null, - function ($error) use ($exeContext) { - $exeContext->addError($error); - - return $this->exeContext->promises->createFulfilled(null); - } - ); - } - - return $completed; - } catch (Error $err) { - // If `completeValueWithLocatedError` returned abruptly (threw an error), log the error - // and return null. - $exeContext->addError($err); - - return null; - } - } - - /** - * This is a small wrapper around completeValue which annotates errors with - * location information. - * - * @param FieldNode[] $fieldNodes - * @param string[] $path - * @param mixed $result - * - * @return mixed[]|mixed|Promise|null - * - * @throws Error - */ - public function completeValueWithLocatedError( - Type $returnType, - $fieldNodes, - ResolveInfo $info, - $path, - $result - ) { - try { - $completed = $this->completeValue( - $returnType, - $fieldNodes, - $info, - $path, - $result - ); - $promise = $this->getPromise($completed); - if ($promise) { - return $promise->then( - null, - function ($error) use ($fieldNodes, $path) { - return $this->exeContext->promises->createRejected(Error::createLocatedError( - $error, - $fieldNodes, - $path - )); - } - ); - } - - return $completed; - } catch (Exception $error) { - throw Error::createLocatedError($error, $fieldNodes, $path); - } catch (Throwable $error) { - throw Error::createLocatedError($error, $fieldNodes, $path); - } - } - - /** - * 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 `serialize` method of GraphQL type - * definition. - * - * If the field is an abstract type, determine the runtime type of the value - * and then complete based on that type - * - * Otherwise, the field type expects a sub-selection set, and will complete the - * value by evaluating all sub-selections. - * - * @param FieldNode[] $fieldNodes - * @param string[] $path - * @param mixed $result - * - * @return mixed[]|mixed|Promise|null - * - * @throws Error - * @throws Throwable - */ - private function completeValue( - Type $returnType, - $fieldNodes, - ResolveInfo $info, - $path, - &$result - ) { - $promise = $this->getPromise($result); - - // If result is a Promise, apply-lift over completeValue. - if ($promise) { - return $promise->then(function (&$resolved) use ($returnType, $fieldNodes, $info, $path) { - return $this->completeValue($returnType, $fieldNodes, $info, $path, $resolved); - }); - } - - if ($result instanceof Exception || $result instanceof Throwable) { - throw $result; - } - - // If field type is NonNull, complete for inner type, and throw field error - // if result is null. - if ($returnType instanceof NonNull) { - $completed = $this->completeValue( - $returnType->getWrappedType(), - $fieldNodes, - $info, - $path, - $result - ); - if ($completed === null) { - throw new InvariantViolation( - 'Cannot return null for non-nullable field ' . $info->parentType . '.' . $info->fieldName . '.' - ); - } - - return $completed; - } - - // If result is null-like, return null. - if ($result === null) { - return null; - } - - // If field type is List, complete each item in the list with the inner type - if ($returnType instanceof ListOfType) { - return $this->completeListValue($returnType, $fieldNodes, $info, $path, $result); - } - - // Account for invalid schema definition when typeLoader returns different - // instance than `resolveType` or $field->getType() or $arg->getType() - if ($returnType !== $this->exeContext->schema->getType($returnType->name)) { - $hint = ''; - if ($this->exeContext->schema->getConfig()->typeLoader) { - $hint = sprintf( - 'Make sure that type loader returns the same instance as defined in %s.%s', - $info->parentType, - $info->fieldName - ); - } - throw new InvariantViolation( - sprintf( - 'Schema must contain unique named types but contains multiple types named "%s". %s ' . - '(see http://webonyx.github.io/graphql-php/type-system/#type-registry).', - $returnType, - $hint - ) - ); - } - - // If field type is Scalar or Enum, serialize to a valid value, returning - // null if serialization is not possible. - if ($returnType instanceof LeafType) { - return $this->completeLeafValue($returnType, $result); - } - - if ($returnType instanceof AbstractType) { - return $this->completeAbstractValue($returnType, $fieldNodes, $info, $path, $result); - } - - // Field type must be Object, Interface or Union and expect sub-selections. - if ($returnType instanceof ObjectType) { - return $this->completeObjectValue($returnType, $fieldNodes, $info, $path, $result); - } - - throw new RuntimeException(sprintf('Cannot complete value of unexpected type "%s".', $returnType)); - } - - /** - * @param mixed $value - * - * @return bool - */ - private function isPromise($value) - { - return $value instanceof Promise || $this->exeContext->promises->isThenable($value); - } - - /** - * Only returns the value if it acts like a Promise, i.e. has a "then" function, - * otherwise returns null. - * - * @param mixed $value - * - * @return Promise|null - */ - private function getPromise($value) - { - if ($value === null || $value instanceof Promise) { - return $value; - } - if ($this->exeContext->promises->isThenable($value)) { - $promise = $this->exeContext->promises->convertThenable($value); - if (! $promise instanceof Promise) { - throw new InvariantViolation(sprintf( - '%s::convertThenable is expected to return instance of GraphQL\Executor\Promise\Promise, got: %s', - get_class($this->exeContext->promises), - Utils::printSafe($promise) - )); - } - - return $promise; - } - - return null; - } - - /** - * Similar to array_reduce(), however the reducing callback may return - * a Promise, in which case reduction will continue after each promise resolves. - * - * If the callback does not return a Promise, then this function will also not - * return a Promise. - * - * @param mixed[] $values - * @param Promise|mixed|null $initialValue - * - * @return mixed[] - */ - private function promiseReduce(array $values, callable $callback, $initialValue) - { - return array_reduce( - $values, - function ($previous, $value) use ($callback) { - $promise = $this->getPromise($previous); - if ($promise) { - return $promise->then(static function ($resolved) use ($callback, $value) { - return $callback($resolved, $value); - }); - } - - return $callback($previous, $value); - }, - $initialValue - ); - } - - /** - * Complete a list value by completing each item in the list with the - * inner type - * - * @param FieldNode[] $fieldNodes - * @param mixed[] $path - * @param mixed $result - * - * @return mixed[]|Promise - * - * @throws Exception - */ - private function completeListValue(ListOfType $returnType, $fieldNodes, ResolveInfo $info, $path, &$result) - { - $itemType = $returnType->getWrappedType(); - Utils::invariant( - is_array($result) || $result instanceof Traversable, - 'User Error: expected iterable, but did not find one for field ' . $info->parentType . '.' . $info->fieldName . '.' - ); - $containsPromise = false; - - $i = 0; - $completedItems = []; - foreach ($result as $item) { - $fieldPath = $path; - $fieldPath[] = $i++; - $completedItem = $this->completeValueCatchingError($itemType, $fieldNodes, $info, $fieldPath, $item); - if (! $containsPromise && $this->getPromise($completedItem)) { - $containsPromise = true; - } - $completedItems[] = $completedItem; - } - - return $containsPromise ? $this->exeContext->promises->all($completedItems) : $completedItems; - } - - /** - * Complete a Scalar or Enum by serializing to a valid value, throwing if serialization is not possible. - * - * @param mixed $result - * - * @return mixed - * - * @throws Exception - */ - private function completeLeafValue(LeafType $returnType, &$result) - { - try { - return $returnType->serialize($result); - } catch (Exception $error) { - throw new InvariantViolation( - 'Expected a value of type "' . Utils::printSafe($returnType) . '" but received: ' . Utils::printSafe($result), - 0, - $error - ); - } catch (Throwable $error) { - throw new InvariantViolation( - 'Expected a value of type "' . Utils::printSafe($returnType) . '" but received: ' . Utils::printSafe($result), - 0, - $error - ); - } - } - - /** - * Complete a value of an abstract type by determining the runtime object type - * of that value, then complete the value for that type. - * - * @param FieldNode[] $fieldNodes - * @param mixed[] $path - * @param mixed[] $result - * - * @return mixed - * - * @throws Error - */ - private function completeAbstractValue(AbstractType $returnType, $fieldNodes, ResolveInfo $info, $path, &$result) - { - $exeContext = $this->exeContext; - $runtimeType = $returnType->resolveType($result, $exeContext->contextValue, $info); - - if ($runtimeType === null) { - $runtimeType = self::defaultTypeResolver($result, $exeContext->contextValue, $info, $returnType); - } - - $promise = $this->getPromise($runtimeType); - if ($promise) { - return $promise->then(function ($resolvedRuntimeType) use ( - $returnType, - $fieldNodes, - $info, - $path, - &$result - ) { - return $this->completeObjectValue( - $this->ensureValidRuntimeType( - $resolvedRuntimeType, - $returnType, - $info, - $result - ), - $fieldNodes, - $info, - $path, - $result - ); - }); - } - - return $this->completeObjectValue( - $this->ensureValidRuntimeType( - $runtimeType, - $returnType, - $info, - $result - ), - $fieldNodes, - $info, - $path, - $result - ); - } - - /** - * If a resolveType function is not given, then a default resolve behavior is - * used which attempts two strategies: - * - * First, See if the provided value has a `__typename` field defined, if so, use - * that value as name of the resolved type. - * - * Otherwise, test each possible type for the abstract type by calling - * isTypeOf for the object being coerced, returning the first type that matches. - * - * @param mixed|null $value - * @param mixed|null $context - * - * @return ObjectType|Promise|null - */ - private function defaultTypeResolver($value, $context, ResolveInfo $info, AbstractType $abstractType) - { - // First, look for `__typename`. - if ($value !== null && - (is_array($value) || $value instanceof ArrayAccess) && - isset($value['__typename']) && - is_string($value['__typename']) - ) { - return $value['__typename']; - } - - if ($abstractType instanceof InterfaceType && $info->schema->getConfig()->typeLoader) { - Warning::warnOnce( - sprintf( - 'GraphQL Interface Type `%s` returned `null` from it`s `resolveType` function ' . - 'for value: %s. Switching to slow resolution method using `isTypeOf` ' . - 'of all possible implementations. It requires full schema scan and degrades query performance significantly. ' . - ' Make sure your `resolveType` always returns valid implementation or throws.', - $abstractType->name, - Utils::printSafe($value) - ), - Warning::WARNING_FULL_SCHEMA_SCAN - ); - } - - // Otherwise, test each possible type. - $possibleTypes = $info->schema->getPossibleTypes($abstractType); - $promisedIsTypeOfResults = []; - - foreach ($possibleTypes as $index => $type) { - $isTypeOfResult = $type->isTypeOf($value, $context, $info); - - if ($isTypeOfResult === null) { - continue; - } - - $promise = $this->getPromise($isTypeOfResult); - if ($promise) { - $promisedIsTypeOfResults[$index] = $promise; - } elseif ($isTypeOfResult) { - return $type; - } - } - - if (! empty($promisedIsTypeOfResults)) { - return $this->exeContext->promises->all($promisedIsTypeOfResults) - ->then(static function ($isTypeOfResults) use ($possibleTypes) { - foreach ($isTypeOfResults as $index => $result) { - if ($result) { - return $possibleTypes[$index]; - } - } - - return null; - }); - } - - return null; - } - - /** - * Complete an Object value by executing all sub-selections. - * - * @param FieldNode[] $fieldNodes - * @param mixed[] $path - * @param mixed $result - * - * @return mixed[]|Promise|stdClass - * - * @throws Error - */ - private function completeObjectValue(ObjectType $returnType, $fieldNodes, ResolveInfo $info, $path, &$result) - { - // If there is an isTypeOf predicate function, call it with the - // current result. If isTypeOf returns false, then raise an error rather - // than continuing execution. - $isTypeOf = $returnType->isTypeOf($result, $this->exeContext->contextValue, $info); - - if ($isTypeOf !== null) { - $promise = $this->getPromise($isTypeOf); - if ($promise) { - return $promise->then(function ($isTypeOfResult) use ( - $returnType, - $fieldNodes, - $info, - $path, - &$result - ) { - if (! $isTypeOfResult) { - throw $this->invalidReturnTypeError($returnType, $result, $fieldNodes); - } - - return $this->collectAndExecuteSubfields( - $returnType, - $fieldNodes, - $info, - $path, - $result - ); - }); - } - if (! $isTypeOf) { - throw $this->invalidReturnTypeError($returnType, $result, $fieldNodes); - } - } - - return $this->collectAndExecuteSubfields( - $returnType, - $fieldNodes, - $info, - $path, - $result - ); - } - - /** - * @param mixed[] $result - * @param FieldNode[] $fieldNodes - * - * @return Error - */ - private function invalidReturnTypeError( - ObjectType $returnType, - $result, - $fieldNodes - ) { - return new Error( - 'Expected value of type "' . $returnType->name . '" but got: ' . Utils::printSafe($result) . '.', - $fieldNodes - ); - } - - /** - * @param FieldNode[] $fieldNodes - * @param mixed[] $path - * @param mixed[] $result - * - * @return mixed[]|Promise|stdClass - * - * @throws Error - */ - private function collectAndExecuteSubfields( - ObjectType $returnType, - $fieldNodes, - ResolveInfo $info, - $path, - &$result - ) { - $subFieldNodes = $this->collectSubFields($returnType, $fieldNodes); - - return $this->executeFields($returnType, $result, $path, $subFieldNodes); - } - - private function collectSubFields(ObjectType $returnType, $fieldNodes) : ArrayObject - { - if (! isset($this->subFieldCache[$returnType])) { - $this->subFieldCache[$returnType] = new SplObjectStorage(); - } - if (! isset($this->subFieldCache[$returnType][$fieldNodes])) { - // Collect sub-fields to execute to complete this value. - $subFieldNodes = new ArrayObject(); - $visitedFragmentNames = new ArrayObject(); - - foreach ($fieldNodes as $fieldNode) { - if (! isset($fieldNode->selectionSet)) { - continue; - } - - $subFieldNodes = $this->collectFields( - $returnType, - $fieldNode->selectionSet, - $subFieldNodes, - $visitedFragmentNames - ); - } - $this->subFieldCache[$returnType][$fieldNodes] = $subFieldNodes; - } - - return $this->subFieldCache[$returnType][$fieldNodes]; - } - - /** - * Implements the "Evaluating selection sets" section of the spec - * for "read" mode. - * - * @param mixed|null $source - * @param mixed[] $path - * @param ArrayObject $fields - * - * @return Promise|stdClass|mixed[] - */ - private function executeFields(ObjectType $parentType, $source, $path, $fields) - { - $containsPromise = false; - $finalResults = []; - - foreach ($fields as $responseName => $fieldNodes) { - $fieldPath = $path; - $fieldPath[] = $responseName; - $result = $this->resolveField($parentType, $source, $fieldNodes, $fieldPath); - if ($result === self::$UNDEFINED) { - continue; - } - if (! $containsPromise && $this->getPromise($result)) { - $containsPromise = true; - } - $finalResults[$responseName] = $result; - } - - // If there are no promises, we can just return the object - if (! $containsPromise) { - return self::fixResultsIfEmptyArray($finalResults); - } - - // Otherwise, results is a map from field name to the result - // of resolving that field, which is possibly a promise. Return - // a promise that will return this same map, but with any - // promises replaced with the values they resolved to. - return $this->promiseForAssocArray($finalResults); - } - - /** - * @see https://github.com/webonyx/graphql-php/issues/59 - * - * @param mixed[] $results - * - * @return stdClass|mixed[] - */ - private static function fixResultsIfEmptyArray($results) - { - if ($results === []) { - return new stdClass(); - } - - return $results; - } - - /** - * This function transforms a PHP `array` into - * a `Promise>` - * - * In other words it returns a promise which resolves to normal PHP associative array which doesn't contain - * any promises. - * - * @param (string|Promise)[] $assoc - * - * @return mixed - */ - private function promiseForAssocArray(array $assoc) - { - $keys = array_keys($assoc); - $valuesAndPromises = array_values($assoc); - - $promise = $this->exeContext->promises->all($valuesAndPromises); - - return $promise->then(static function ($values) use ($keys) { - $resolvedResults = []; - foreach ($values as $i => $value) { - $resolvedResults[$keys[$i]] = $value; - } - - return self::fixResultsIfEmptyArray($resolvedResults); - }); - } - - /** - * @param string|ObjectType|null $runtimeTypeOrName - * @param FieldNode[] $fieldNodes - * @param mixed $result - * - * @return ObjectType - */ - private function ensureValidRuntimeType( - $runtimeTypeOrName, - AbstractType $returnType, - ResolveInfo $info, - &$result - ) { - $runtimeType = is_string($runtimeTypeOrName) ? - $this->exeContext->schema->getType($runtimeTypeOrName) : - $runtimeTypeOrName; - - if (! $runtimeType instanceof ObjectType) { - throw new InvariantViolation( - sprintf( - 'Abstract type %s must resolve to an Object type at ' . - 'runtime for field %s.%s with value "%s", received "%s". ' . - 'Either the %s type should provide a "resolveType" ' . - 'function or each possible type should provide an "isTypeOf" function.', - $returnType, - $info->parentType, - $info->fieldName, - Utils::printSafe($result), - Utils::printSafe($runtimeType), - $returnType - ) - ); - } - - if (! $this->exeContext->schema->isPossibleType($returnType, $runtimeType)) { - throw new InvariantViolation( - sprintf('Runtime Object type "%s" is not a possible type for "%s".', $runtimeType, $returnType) - ); - } - - if ($runtimeType !== $this->exeContext->schema->getType($runtimeType->name)) { - throw new InvariantViolation( - sprintf( - 'Schema must contain unique named types but contains multiple types named "%s". ' . - 'Make sure that `resolveType` function of abstract type "%s" returns the same ' . - 'type instance as referenced anywhere else within the schema ' . - '(see http://webonyx.github.io/graphql-php/type-system/#type-registry).', - $runtimeType, - $returnType - ) - ); - } - - return $runtimeType; - } - /** * 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 @@ -1536,7 +182,6 @@ class Executor } } - // Using instanceof vs is_callable() because it is 2-10 times faster return $property instanceof Closure ? $property($source, $args, $context, $info) : $property; } } diff --git a/src/Executor/ExecutorImplementation.php b/src/Executor/ExecutorImplementation.php new file mode 100644 index 0000000..46cb2b7 --- /dev/null +++ b/src/Executor/ExecutorImplementation.php @@ -0,0 +1,15 @@ +exeContext = $context; + $this->subFieldCache = new SplObjectStorage(); + } + + public static function create( + PromiseAdapter $promiseAdapter, + Schema $schema, + DocumentNode $documentNode, + $rootValue, + $contextValue, + $variableValues, + ?string $operationName, + callable $fieldResolver + ) { + $exeContext = self::buildExecutionContext( + $schema, + $documentNode, + $rootValue, + $contextValue, + $variableValues, + $operationName, + $fieldResolver, + $promiseAdapter + ); + + if (is_array($exeContext)) { + return new class($promiseAdapter->createFulfilled(new ExecutionResult(null, $exeContext))) implements ExecutorImplementation + { + /** @var Promise */ + private $result; + + public function __construct(Promise $result) + { + $this->result = $result; + } + + public function doExecute() : Promise + { + return $this->result; + } + }; + } + + return new self($exeContext); + } + + /** + * Constructs an ExecutionContext object from the arguments passed to + * execute, which we will pass throughout the other execution methods. + * + * @param mixed[] $rootValue + * @param mixed[] $contextValue + * @param mixed[]|Traversable $rawVariableValues + * @param string|null $operationName + * + * @return ExecutionContext|Error[] + */ + private static function buildExecutionContext( + Schema $schema, + DocumentNode $documentNode, + $rootValue, + $contextValue, + $rawVariableValues, + $operationName = null, + ?callable $fieldResolver = null, + ?PromiseAdapter $promiseAdapter = null + ) { + $errors = []; + $fragments = []; + /** @var OperationDefinitionNode $operation */ + $operation = null; + $hasMultipleAssumedOperations = false; + foreach ($documentNode->definitions as $definition) { + switch ($definition->kind) { + case NodeKind::OPERATION_DEFINITION: + if (! $operationName && $operation) { + $hasMultipleAssumedOperations = true; + } + if (! $operationName || + (isset($definition->name) && $definition->name->value === $operationName)) { + $operation = $definition; + } + break; + case NodeKind::FRAGMENT_DEFINITION: + $fragments[$definition->name->value] = $definition; + break; + } + } + if (! $operation) { + if ($operationName) { + $errors[] = new Error(sprintf('Unknown operation named "%s".', $operationName)); + } else { + $errors[] = new Error('Must provide an operation.'); + } + } elseif ($hasMultipleAssumedOperations) { + $errors[] = new Error( + 'Must provide operation name if query contains multiple operations.' + ); + } + $variableValues = null; + if ($operation) { + [$coercionErrors, $coercedVariableValues] = Values::getVariableValues( + $schema, + $operation->variableDefinitions ?: [], + $rawVariableValues ?: [] + ); + if (empty($coercionErrors)) { + $variableValues = $coercedVariableValues; + } else { + $errors = array_merge($errors, $coercionErrors); + } + } + if (! empty($errors)) { + return $errors; + } + Utils::invariant($operation, 'Has operation if no errors.'); + Utils::invariant($variableValues !== null, 'Has variables if no errors.'); + return new ExecutionContext( + $schema, + $fragments, + $rootValue, + $contextValue, + $operation, + $variableValues, + $errors, + $fieldResolver, + $promiseAdapter + ); + } + + public function doExecute() : Promise + { + // Return a Promise that will eventually resolve to the data described by + // The "Response" section of the GraphQL specification. + // + // If errors are encountered while executing a GraphQL field, only that + // field and its descendants will be omitted, and sibling fields will still + // be executed. An execution which encounters errors will still result in a + // resolved Promise. + $data = $this->executeOperation($this->exeContext->operation, $this->exeContext->rootValue); + $result = $this->buildResponse($data); + // Note: we deviate here from the reference implementation a bit by always returning promise + // But for the "sync" case it is always fulfilled + return $this->isPromise($result) + ? $result + : $this->exeContext->promises->createFulfilled($result); + } + + /** + * @param mixed|Promise|null $data + * + * @return ExecutionResult|Promise + */ + private function buildResponse($data) + { + if ($this->isPromise($data)) { + return $data->then(function ($resolved) { + return $this->buildResponse($resolved); + }); + } + if ($data !== null) { + $data = (array) $data; + } + return new ExecutionResult($data, $this->exeContext->errors); + } + + /** + * Implements the "Evaluating operations" section of the spec. + * + * @param mixed[] $rootValue + * + * @return Promise|stdClass|mixed[] + */ + private function executeOperation(OperationDefinitionNode $operation, $rootValue) + { + $type = $this->getOperationRootType($this->exeContext->schema, $operation); + $fields = $this->collectFields($type, $operation->selectionSet, new ArrayObject(), new ArrayObject()); + $path = []; + // Errors from sub-fields of a NonNull type may propagate to the top level, + // at which point we still log the error and null the parent field, which + // in this case is the entire response. + // + // Similar to completeValueCatchingError. + try { + $result = $operation->operation === 'mutation' ? + $this->executeFieldsSerially($type, $rootValue, $path, $fields) : + $this->executeFields($type, $rootValue, $path, $fields); + if ($this->isPromise($result)) { + return $result->then( + null, + function ($error) { + $this->exeContext->addError($error); + return $this->exeContext->promises->createFulfilled(null); + } + ); + } + return $result; + } catch (Error $error) { + $this->exeContext->addError($error); + return null; + } + } + + /** + * Extracts the root type of the operation from the schema. + * + * @return ObjectType + * + * @throws Error + */ + private function getOperationRootType(Schema $schema, OperationDefinitionNode $operation) + { + switch ($operation->operation) { + case 'query': + $queryType = $schema->getQueryType(); + if (! $queryType) { + throw new Error( + 'Schema does not define the required query root type.', + [$operation] + ); + } + return $queryType; + case 'mutation': + $mutationType = $schema->getMutationType(); + if (! $mutationType) { + throw new Error( + 'Schema is not configured for mutations.', + [$operation] + ); + } + return $mutationType; + case 'subscription': + $subscriptionType = $schema->getSubscriptionType(); + if (! $subscriptionType) { + throw new Error( + 'Schema is not configured for subscriptions.', + [$operation] + ); + } + return $subscriptionType; + default: + throw new Error( + 'Can only execute queries, mutations and subscriptions.', + [$operation] + ); + } + } + + /** + * Given a selectionSet, adds all of the fields in that selection to + * the passed in map of fields, and returns it at the end. + * + * CollectFields requires the "runtime type" of an object. For a field which + * returns an Interface or Union type, the "runtime type" will be the actual + * Object type returned by that field. + * + * @param ArrayObject $fields + * @param ArrayObject $visitedFragmentNames + * + * @return ArrayObject + */ + private function collectFields( + ObjectType $runtimeType, + SelectionSetNode $selectionSet, + $fields, + $visitedFragmentNames + ) { + $exeContext = $this->exeContext; + foreach ($selectionSet->selections as $selection) { + switch ($selection->kind) { + case NodeKind::FIELD: + if (! $this->shouldIncludeNode($selection)) { + break; + } + $name = self::getFieldEntryKey($selection); + if (! isset($fields[$name])) { + $fields[$name] = new ArrayObject(); + } + $fields[$name][] = $selection; + break; + case NodeKind::INLINE_FRAGMENT: + if (! $this->shouldIncludeNode($selection) || + ! $this->doesFragmentConditionMatch($selection, $runtimeType) + ) { + break; + } + $this->collectFields( + $runtimeType, + $selection->selectionSet, + $fields, + $visitedFragmentNames + ); + break; + case NodeKind::FRAGMENT_SPREAD: + $fragName = $selection->name->value; + if (! empty($visitedFragmentNames[$fragName]) || ! $this->shouldIncludeNode($selection)) { + break; + } + $visitedFragmentNames[$fragName] = true; + /** @var FragmentDefinitionNode|null $fragment */ + $fragment = $exeContext->fragments[$fragName] ?? null; + if (! $fragment || ! $this->doesFragmentConditionMatch($fragment, $runtimeType)) { + break; + } + $this->collectFields( + $runtimeType, + $fragment->selectionSet, + $fields, + $visitedFragmentNames + ); + break; + } + } + return $fields; + } + + /** + * Determines if a field should be included based on the @include and @skip + * directives, where @skip has higher precedence than @include. + * + * @param FragmentSpreadNode|FieldNode|InlineFragmentNode $node + * + * @return bool + */ + private function shouldIncludeNode($node) + { + $variableValues = $this->exeContext->variableValues; + $skipDirective = Directive::skipDirective(); + $skip = Values::getDirectiveValues( + $skipDirective, + $node, + $variableValues + ); + if (isset($skip['if']) && $skip['if'] === true) { + return false; + } + $includeDirective = Directive::includeDirective(); + $include = Values::getDirectiveValues( + $includeDirective, + $node, + $variableValues + ); + if (isset($include['if']) && $include['if'] === false) { + return false; + } + return true; + } + + /** + * Implements the logic to compute the key of a given fields entry + * + * @return string + */ + private static function getFieldEntryKey(FieldNode $node) + { + return $node->alias ? $node->alias->value : $node->name->value; + } + + /** + * Determines if a fragment is applicable to the given type. + * + * @param FragmentDefinitionNode|InlineFragmentNode $fragment + * + * @return bool + */ + private function doesFragmentConditionMatch( + $fragment, + ObjectType $type + ) { + $typeConditionNode = $fragment->typeCondition; + if ($typeConditionNode === null) { + return true; + } + $conditionalType = TypeInfo::typeFromAST($this->exeContext->schema, $typeConditionNode); + if ($conditionalType === $type) { + return true; + } + if ($conditionalType instanceof AbstractType) { + return $this->exeContext->schema->isPossibleType($conditionalType, $type); + } + return false; + } + + /** + * Implements the "Evaluating selection sets" section of the spec + * for "write" mode. + * + * @param mixed[] $sourceValue + * @param mixed[] $path + * @param ArrayObject $fields + * + * @return Promise|stdClass|mixed[] + */ + private function executeFieldsSerially(ObjectType $parentType, $sourceValue, $path, $fields) + { + $result = $this->promiseReduce( + array_keys($fields->getArrayCopy()), + function ($results, $responseName) use ($path, $parentType, $sourceValue, $fields) { + $fieldNodes = $fields[$responseName]; + $fieldPath = $path; + $fieldPath[] = $responseName; + $result = $this->resolveField($parentType, $sourceValue, $fieldNodes, $fieldPath); + if ($result === self::$UNDEFINED) { + return $results; + } + $promise = $this->getPromise($result); + if ($promise) { + return $promise->then(static function ($resolvedResult) use ($responseName, $results) { + $results[$responseName] = $resolvedResult; + return $results; + }); + } + $results[$responseName] = $result; + return $results; + }, + [] + ); + if ($this->isPromise($result)) { + return $result->then(static function ($resolvedResults) { + return self::fixResultsIfEmptyArray($resolvedResults); + }); + } + return self::fixResultsIfEmptyArray($result); + } + + /** + * Resolves the field on the given source object. In particular, this + * figures out the value that the field returns by calling its resolve function, + * then calls completeValue to complete promises, serialize scalars, or execute + * the sub-selection-set for objects. + * + * @param object|null $source + * @param FieldNode[] $fieldNodes + * @param mixed[] $path + * + * @return mixed[]|Exception|mixed|null + */ + private function resolveField(ObjectType $parentType, $source, $fieldNodes, $path) + { + $exeContext = $this->exeContext; + $fieldNode = $fieldNodes[0]; + $fieldName = $fieldNode->name->value; + $fieldDef = $this->getFieldDef($exeContext->schema, $parentType, $fieldName); + if (! $fieldDef) { + return self::$UNDEFINED; + } + $returnType = $fieldDef->getType(); + // The resolve function's optional third argument is a collection of + // information about the current execution state. + $info = new ResolveInfo( + $fieldName, + $fieldNodes, + $returnType, + $parentType, + $path, + $exeContext->schema, + $exeContext->fragments, + $exeContext->rootValue, + $exeContext->operation, + $exeContext->variableValues + ); + if ($fieldDef->resolveFn !== null) { + $resolveFn = $fieldDef->resolveFn; + } elseif ($parentType->resolveFieldFn !== null) { + $resolveFn = $parentType->resolveFieldFn; + } else { + $resolveFn = $this->exeContext->fieldResolver; + } + // The resolve function's optional third argument is a context value that + // is provided to every resolve function within an execution. It is commonly + // used to represent an authenticated user, or request-specific caches. + $context = $exeContext->contextValue; + // Get the resolve function, regardless of if its result is normal + // or abrupt (error). + $result = $this->resolveOrError( + $fieldDef, + $fieldNode, + $resolveFn, + $source, + $context, + $info + ); + $result = $this->completeValueCatchingError( + $returnType, + $fieldNodes, + $info, + $path, + $result + ); + return $result; + } + + /** + * This method looks up the field on the given type definition. + * 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. + * + * @param string $fieldName + * + * @return FieldDefinition + */ + private function getFieldDef(Schema $schema, ObjectType $parentType, $fieldName) + { + static $schemaMetaFieldDef, $typeMetaFieldDef, $typeNameMetaFieldDef; + $schemaMetaFieldDef = $schemaMetaFieldDef ?: Introspection::schemaMetaFieldDef(); + $typeMetaFieldDef = $typeMetaFieldDef ?: Introspection::typeMetaFieldDef(); + $typeNameMetaFieldDef = $typeNameMetaFieldDef ?: Introspection::typeNameMetaFieldDef(); + if ($fieldName === $schemaMetaFieldDef->name && $schema->getQueryType() === $parentType) { + return $schemaMetaFieldDef; + } elseif ($fieldName === $typeMetaFieldDef->name && $schema->getQueryType() === $parentType) { + return $typeMetaFieldDef; + } elseif ($fieldName === $typeNameMetaFieldDef->name) { + return $typeNameMetaFieldDef; + } + $tmp = $parentType->getFields(); + return $tmp[$fieldName] ?? null; + } + + /** + * Isolates the "ReturnOrAbrupt" behavior to not de-opt the `resolveField` + * function. Returns the result of resolveFn or the abrupt-return Error object. + * + * @param FieldDefinition $fieldDef + * @param FieldNode $fieldNode + * @param callable $resolveFn + * @param mixed $source + * @param mixed $context + * @param ResolveInfo $info + * + * @return Throwable|Promise|mixed + */ + private function resolveOrError($fieldDef, $fieldNode, $resolveFn, $source, $context, $info) + { + try { + // Build hash of arguments from the field.arguments AST, using the + // variables scope to fulfill any variable references. + $args = Values::getArgumentValues( + $fieldDef, + $fieldNode, + $this->exeContext->variableValues + ); + return $resolveFn($source, $args, $context, $info); + } catch (Exception $error) { + return $error; + } catch (Throwable $error) { + return $error; + } + } + + /** + * This is a small wrapper around completeValue which detects and logs errors + * in the execution context. + * + * @param FieldNode[] $fieldNodes + * @param string[] $path + * @param mixed $result + * + * @return mixed[]|Promise|null + */ + private function completeValueCatchingError( + Type $returnType, + $fieldNodes, + ResolveInfo $info, + $path, + $result + ) { + $exeContext = $this->exeContext; + // If the field type is non-nullable, then it is resolved without any + // protection from errors. + if ($returnType instanceof NonNull) { + return $this->completeValueWithLocatedError( + $returnType, + $fieldNodes, + $info, + $path, + $result + ); + } + // Otherwise, error protection is applied, logging the error and resolving + // a null value for this field if one is encountered. + try { + $completed = $this->completeValueWithLocatedError( + $returnType, + $fieldNodes, + $info, + $path, + $result + ); + $promise = $this->getPromise($completed); + if ($promise) { + return $promise->then( + null, + function ($error) use ($exeContext) { + $exeContext->addError($error); + return $this->exeContext->promises->createFulfilled(null); + } + ); + } + return $completed; + } catch (Error $err) { + // If `completeValueWithLocatedError` returned abruptly (threw an error), log the error + // and return null. + $exeContext->addError($err); + return null; + } + } + + /** + * This is a small wrapper around completeValue which annotates errors with + * location information. + * + * @param FieldNode[] $fieldNodes + * @param string[] $path + * @param mixed $result + * + * @return mixed[]|mixed|Promise|null + * + * @throws Error + */ + public function completeValueWithLocatedError( + Type $returnType, + $fieldNodes, + ResolveInfo $info, + $path, + $result + ) { + try { + $completed = $this->completeValue( + $returnType, + $fieldNodes, + $info, + $path, + $result + ); + $promise = $this->getPromise($completed); + if ($promise) { + return $promise->then( + null, + function ($error) use ($fieldNodes, $path) { + return $this->exeContext->promises->createRejected(Error::createLocatedError( + $error, + $fieldNodes, + $path + )); + } + ); + } + return $completed; + } catch (Exception $error) { + throw Error::createLocatedError($error, $fieldNodes, $path); + } catch (Throwable $error) { + throw Error::createLocatedError($error, $fieldNodes, $path); + } + } + + /** + * 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 `serialize` method of GraphQL type + * definition. + * + * If the field is an abstract type, determine the runtime type of the value + * and then complete based on that type + * + * Otherwise, the field type expects a sub-selection set, and will complete the + * value by evaluating all sub-selections. + * + * @param FieldNode[] $fieldNodes + * @param string[] $path + * @param mixed $result + * + * @return mixed[]|mixed|Promise|null + * + * @throws Error + * @throws Throwable + */ + private function completeValue( + Type $returnType, + $fieldNodes, + ResolveInfo $info, + $path, + &$result + ) { + $promise = $this->getPromise($result); + // If result is a Promise, apply-lift over completeValue. + if ($promise) { + return $promise->then(function (&$resolved) use ($returnType, $fieldNodes, $info, $path) { + return $this->completeValue($returnType, $fieldNodes, $info, $path, $resolved); + }); + } + if ($result instanceof Exception || $result instanceof Throwable) { + throw $result; + } + // If field type is NonNull, complete for inner type, and throw field error + // if result is null. + if ($returnType instanceof NonNull) { + $completed = $this->completeValue( + $returnType->getWrappedType(), + $fieldNodes, + $info, + $path, + $result + ); + if ($completed === null) { + throw new InvariantViolation( + 'Cannot return null for non-nullable field ' . $info->parentType . '.' . $info->fieldName . '.' + ); + } + return $completed; + } + // If result is null-like, return null. + if ($result === null) { + return null; + } + // If field type is List, complete each item in the list with the inner type + if ($returnType instanceof ListOfType) { + return $this->completeListValue($returnType, $fieldNodes, $info, $path, $result); + } + // Account for invalid schema definition when typeLoader returns different + // instance than `resolveType` or $field->getType() or $arg->getType() + if ($returnType !== $this->exeContext->schema->getType($returnType->name)) { + $hint = ''; + if ($this->exeContext->schema->getConfig()->typeLoader) { + $hint = sprintf( + 'Make sure that type loader returns the same instance as defined in %s.%s', + $info->parentType, + $info->fieldName + ); + } + throw new InvariantViolation( + sprintf( + 'Schema must contain unique named types but contains multiple types named "%s". %s ' . + '(see http://webonyx.github.io/graphql-php/type-system/#type-registry).', + $returnType, + $hint + ) + ); + } + // If field type is Scalar or Enum, serialize to a valid value, returning + // null if serialization is not possible. + if ($returnType instanceof LeafType) { + return $this->completeLeafValue($returnType, $result); + } + if ($returnType instanceof AbstractType) { + return $this->completeAbstractValue($returnType, $fieldNodes, $info, $path, $result); + } + // Field type must be Object, Interface or Union and expect sub-selections. + if ($returnType instanceof ObjectType) { + return $this->completeObjectValue($returnType, $fieldNodes, $info, $path, $result); + } + throw new RuntimeException(sprintf('Cannot complete value of unexpected type "%s".', $returnType)); + } + + /** + * @param mixed $value + * + * @return bool + */ + private function isPromise($value) + { + return $value instanceof Promise || $this->exeContext->promises->isThenable($value); + } + + /** + * Only returns the value if it acts like a Promise, i.e. has a "then" function, + * otherwise returns null. + * + * @param mixed $value + * + * @return Promise|null + */ + private function getPromise($value) + { + if ($value === null || $value instanceof Promise) { + return $value; + } + if ($this->exeContext->promises->isThenable($value)) { + $promise = $this->exeContext->promises->convertThenable($value); + if (! $promise instanceof Promise) { + throw new InvariantViolation(sprintf( + '%s::convertThenable is expected to return instance of GraphQL\Executor\Promise\Promise, got: %s', + get_class($this->exeContext->promises), + Utils::printSafe($promise) + )); + } + return $promise; + } + return null; + } + + /** + * Similar to array_reduce(), however the reducing callback may return + * a Promise, in which case reduction will continue after each promise resolves. + * + * If the callback does not return a Promise, then this function will also not + * return a Promise. + * + * @param mixed[] $values + * @param Promise|mixed|null $initialValue + * + * @return mixed[] + */ + private function promiseReduce(array $values, callable $callback, $initialValue) + { + return array_reduce( + $values, + function ($previous, $value) use ($callback) { + $promise = $this->getPromise($previous); + if ($promise) { + return $promise->then(static function ($resolved) use ($callback, $value) { + return $callback($resolved, $value); + }); + } + return $callback($previous, $value); + }, + $initialValue + ); + } + + /** + * Complete a list value by completing each item in the list with the + * inner type + * + * @param FieldNode[] $fieldNodes + * @param mixed[] $path + * @param mixed $result + * + * @return mixed[]|Promise + * + * @throws Exception + */ + private function completeListValue(ListOfType $returnType, $fieldNodes, ResolveInfo $info, $path, &$result) + { + $itemType = $returnType->getWrappedType(); + Utils::invariant( + is_array($result) || $result instanceof Traversable, + 'User Error: expected iterable, but did not find one for field ' . $info->parentType . '.' . $info->fieldName . '.' + ); + $containsPromise = false; + $i = 0; + $completedItems = []; + foreach ($result as $item) { + $fieldPath = $path; + $fieldPath[] = $i++; + $completedItem = $this->completeValueCatchingError($itemType, $fieldNodes, $info, $fieldPath, $item); + if (! $containsPromise && $this->getPromise($completedItem)) { + $containsPromise = true; + } + $completedItems[] = $completedItem; + } + return $containsPromise ? $this->exeContext->promises->all($completedItems) : $completedItems; + } + + /** + * Complete a Scalar or Enum by serializing to a valid value, throwing if serialization is not possible. + * + * @param mixed $result + * + * @return mixed + * + * @throws Exception + */ + private function completeLeafValue(LeafType $returnType, &$result) + { + try { + return $returnType->serialize($result); + } catch (Exception $error) { + throw new InvariantViolation( + 'Expected a value of type "' . Utils::printSafe($returnType) . '" but received: ' . Utils::printSafe($result), + 0, + $error + ); + } catch (Throwable $error) { + throw new InvariantViolation( + 'Expected a value of type "' . Utils::printSafe($returnType) . '" but received: ' . Utils::printSafe($result), + 0, + $error + ); + } + } + + /** + * Complete a value of an abstract type by determining the runtime object type + * of that value, then complete the value for that type. + * + * @param FieldNode[] $fieldNodes + * @param mixed[] $path + * @param mixed[] $result + * + * @return mixed + * + * @throws Error + */ + private function completeAbstractValue(AbstractType $returnType, $fieldNodes, ResolveInfo $info, $path, &$result) + { + $exeContext = $this->exeContext; + $runtimeType = $returnType->resolveType($result, $exeContext->contextValue, $info); + if ($runtimeType === null) { + $runtimeType = self::defaultTypeResolver($result, $exeContext->contextValue, $info, $returnType); + } + $promise = $this->getPromise($runtimeType); + if ($promise) { + return $promise->then(function ($resolvedRuntimeType) use ( + $returnType, + $fieldNodes, + $info, + $path, + &$result + ) { + return $this->completeObjectValue( + $this->ensureValidRuntimeType( + $resolvedRuntimeType, + $returnType, + $info, + $result + ), + $fieldNodes, + $info, + $path, + $result + ); + }); + } + return $this->completeObjectValue( + $this->ensureValidRuntimeType( + $runtimeType, + $returnType, + $info, + $result + ), + $fieldNodes, + $info, + $path, + $result + ); + } + + /** + * If a resolveType function is not given, then a default resolve behavior is + * used which attempts two strategies: + * + * First, See if the provided value has a `__typename` field defined, if so, use + * that value as name of the resolved type. + * + * Otherwise, test each possible type for the abstract type by calling + * isTypeOf for the object being coerced, returning the first type that matches. + * + * @param mixed|null $value + * @param mixed|null $context + * + * @return ObjectType|Promise|null + */ + private function defaultTypeResolver($value, $context, ResolveInfo $info, AbstractType $abstractType) + { + // First, look for `__typename`. + if ($value !== null && + (is_array($value) || $value instanceof ArrayAccess) && + isset($value['__typename']) && + is_string($value['__typename']) + ) { + return $value['__typename']; + } + if ($abstractType instanceof InterfaceType && $info->schema->getConfig()->typeLoader) { + Warning::warnOnce( + sprintf( + 'GraphQL Interface Type `%s` returned `null` from it`s `resolveType` function ' . + 'for value: %s. Switching to slow resolution method using `isTypeOf` ' . + 'of all possible implementations. It requires full schema scan and degrades query performance significantly. ' . + ' Make sure your `resolveType` always returns valid implementation or throws.', + $abstractType->name, + Utils::printSafe($value) + ), + Warning::WARNING_FULL_SCHEMA_SCAN + ); + } + // Otherwise, test each possible type. + $possibleTypes = $info->schema->getPossibleTypes($abstractType); + $promisedIsTypeOfResults = []; + foreach ($possibleTypes as $index => $type) { + $isTypeOfResult = $type->isTypeOf($value, $context, $info); + if ($isTypeOfResult === null) { + continue; + } + $promise = $this->getPromise($isTypeOfResult); + if ($promise) { + $promisedIsTypeOfResults[$index] = $promise; + } elseif ($isTypeOfResult) { + return $type; + } + } + if (! empty($promisedIsTypeOfResults)) { + return $this->exeContext->promises->all($promisedIsTypeOfResults) + ->then(static function ($isTypeOfResults) use ($possibleTypes) { + foreach ($isTypeOfResults as $index => $result) { + if ($result) { + return $possibleTypes[$index]; + } + } + return null; + }); + } + return null; + } + + /** + * Complete an Object value by executing all sub-selections. + * + * @param FieldNode[] $fieldNodes + * @param mixed[] $path + * @param mixed $result + * + * @return mixed[]|Promise|stdClass + * + * @throws Error + */ + private function completeObjectValue(ObjectType $returnType, $fieldNodes, ResolveInfo $info, $path, &$result) + { + // If there is an isTypeOf predicate function, call it with the + // current result. If isTypeOf returns false, then raise an error rather + // than continuing execution. + $isTypeOf = $returnType->isTypeOf($result, $this->exeContext->contextValue, $info); + if ($isTypeOf !== null) { + $promise = $this->getPromise($isTypeOf); + if ($promise) { + return $promise->then(function ($isTypeOfResult) use ( + $returnType, + $fieldNodes, + $info, + $path, + &$result + ) { + if (! $isTypeOfResult) { + throw $this->invalidReturnTypeError($returnType, $result, $fieldNodes); + } + return $this->collectAndExecuteSubfields( + $returnType, + $fieldNodes, + $info, + $path, + $result + ); + }); + } + if (! $isTypeOf) { + throw $this->invalidReturnTypeError($returnType, $result, $fieldNodes); + } + } + return $this->collectAndExecuteSubfields( + $returnType, + $fieldNodes, + $info, + $path, + $result + ); + } + + /** + * @param mixed[] $result + * @param FieldNode[] $fieldNodes + * + * @return Error + */ + private function invalidReturnTypeError( + ObjectType $returnType, + $result, + $fieldNodes + ) { + return new Error( + 'Expected value of type "' . $returnType->name . '" but got: ' . Utils::printSafe($result) . '.', + $fieldNodes + ); + } + + /** + * @param FieldNode[] $fieldNodes + * @param mixed[] $path + * @param mixed[] $result + * + * @return mixed[]|Promise|stdClass + * + * @throws Error + */ + private function collectAndExecuteSubfields( + ObjectType $returnType, + $fieldNodes, + ResolveInfo $info, + $path, + &$result + ) { + $subFieldNodes = $this->collectSubFields($returnType, $fieldNodes); + return $this->executeFields($returnType, $result, $path, $subFieldNodes); + } + + private function collectSubFields(ObjectType $returnType, $fieldNodes) : ArrayObject + { + if (! isset($this->subFieldCache[$returnType])) { + $this->subFieldCache[$returnType] = new SplObjectStorage(); + } + if (! isset($this->subFieldCache[$returnType][$fieldNodes])) { + // Collect sub-fields to execute to complete this value. + $subFieldNodes = new ArrayObject(); + $visitedFragmentNames = new ArrayObject(); + foreach ($fieldNodes as $fieldNode) { + if (! isset($fieldNode->selectionSet)) { + continue; + } + $subFieldNodes = $this->collectFields( + $returnType, + $fieldNode->selectionSet, + $subFieldNodes, + $visitedFragmentNames + ); + } + $this->subFieldCache[$returnType][$fieldNodes] = $subFieldNodes; + } + return $this->subFieldCache[$returnType][$fieldNodes]; + } + + /** + * Implements the "Evaluating selection sets" section of the spec + * for "read" mode. + * + * @param mixed|null $source + * @param mixed[] $path + * @param ArrayObject $fields + * + * @return Promise|stdClass|mixed[] + */ + private function executeFields(ObjectType $parentType, $source, $path, $fields) + { + $containsPromise = false; + $finalResults = []; + foreach ($fields as $responseName => $fieldNodes) { + $fieldPath = $path; + $fieldPath[] = $responseName; + $result = $this->resolveField($parentType, $source, $fieldNodes, $fieldPath); + if ($result === self::$UNDEFINED) { + continue; + } + if (! $containsPromise && $this->getPromise($result)) { + $containsPromise = true; + } + $finalResults[$responseName] = $result; + } + // If there are no promises, we can just return the object + if (! $containsPromise) { + return self::fixResultsIfEmptyArray($finalResults); + } + // Otherwise, results is a map from field name to the result + // of resolving that field, which is possibly a promise. Return + // a promise that will return this same map, but with any + // promises replaced with the values they resolved to. + return $this->promiseForAssocArray($finalResults); + } + + /** + * @see https://github.com/webonyx/graphql-php/issues/59 + * + * @param mixed[] $results + * + * @return stdClass|mixed[] + */ + private static function fixResultsIfEmptyArray($results) + { + if ($results === []) { + return new stdClass(); + } + return $results; + } + + /** + * This function transforms a PHP `array` into + * a `Promise>` + * + * In other words it returns a promise which resolves to normal PHP associative array which doesn't contain + * any promises. + * + * @param (string|Promise)[] $assoc + * + * @return mixed + */ + private function promiseForAssocArray(array $assoc) + { + $keys = array_keys($assoc); + $valuesAndPromises = array_values($assoc); + $promise = $this->exeContext->promises->all($valuesAndPromises); + return $promise->then(static function ($values) use ($keys) { + $resolvedResults = []; + foreach ($values as $i => $value) { + $resolvedResults[$keys[$i]] = $value; + } + return self::fixResultsIfEmptyArray($resolvedResults); + }); + } + + /** + * @param string|ObjectType|null $runtimeTypeOrName + * @param FieldNode[] $fieldNodes + * @param mixed $result + * + * @return ObjectType + */ + private function ensureValidRuntimeType( + $runtimeTypeOrName, + AbstractType $returnType, + ResolveInfo $info, + &$result + ) { + $runtimeType = is_string($runtimeTypeOrName) ? + $this->exeContext->schema->getType($runtimeTypeOrName) : + $runtimeTypeOrName; + if (! $runtimeType instanceof ObjectType) { + throw new InvariantViolation( + sprintf( + 'Abstract type %s must resolve to an Object type at ' . + 'runtime for field %s.%s with value "%s", received "%s". ' . + 'Either the %s type should provide a "resolveType" ' . + 'function or each possible type should provide an "isTypeOf" function.', + $returnType, + $info->parentType, + $info->fieldName, + Utils::printSafe($result), + Utils::printSafe($runtimeType), + $returnType + ) + ); + } + if (! $this->exeContext->schema->isPossibleType($returnType, $runtimeType)) { + throw new InvariantViolation( + sprintf('Runtime Object type "%s" is not a possible type for "%s".', $runtimeType, $returnType) + ); + } + if ($runtimeType !== $this->exeContext->schema->getType($runtimeType->name)) { + throw new InvariantViolation( + sprintf( + 'Schema must contain unique named types but contains multiple types named "%s". ' . + 'Make sure that `resolveType` function of abstract type "%s" returns the same ' . + 'type instance as referenced anywhere else within the schema ' . + '(see http://webonyx.github.io/graphql-php/type-system/#type-registry).', + $runtimeType, + $returnType + ) + ); + } + return $runtimeType; + } +} diff --git a/src/Executor/Values.php b/src/Executor/Values.php index cfb0886..2f74e8c 100644 --- a/src/Executor/Values.php +++ b/src/Executor/Values.php @@ -12,6 +12,7 @@ use GraphQL\Language\AST\FieldDefinitionNode; use GraphQL\Language\AST\FieldNode; use GraphQL\Language\AST\FragmentSpreadNode; use GraphQL\Language\AST\InlineFragmentNode; +use GraphQL\Language\AST\Node; use GraphQL\Language\AST\NodeList; use GraphQL\Language\AST\ValueNode; use GraphQL\Language\AST\VariableDefinitionNode; @@ -107,7 +108,11 @@ class Values } } - return ['errors' => $errors, 'coerced' => $errors ? null : $coercedValues]; + if (! empty($errors)) { + return [$errors, null]; + } + + return [null, $coercedValues]; } /** @@ -154,58 +159,73 @@ class Values */ public static function getArgumentValues($def, $node, $variableValues = null) { - $argDefs = $def->args; - $argNodes = $node->arguments; - - if (empty($argDefs) || $argNodes === null) { + if (empty($def->args)) { return []; } - $coercedValues = []; + $argumentNodes = $node->arguments; + if (empty($argumentNodes)) { + return []; + } - /** @var ArgumentNode[] $argNodeMap */ - $argNodeMap = $argNodes ? Utils::keyMap( - $argNodes, - static function (ArgumentNode $arg) { - return $arg->name->value; - } - ) : []; + $argumentValueMap = []; + foreach ($argumentNodes as $argumentNode) { + $argumentValueMap[$argumentNode->name->value] = $argumentNode->value; + } - foreach ($argDefs as $argDef) { - $name = $argDef->name; - $argType = $argDef->getType(); - $argumentNode = $argNodeMap[$name] ?? null; + return static::getArgumentValuesForMap($def, $argumentValueMap, $variableValues, $node); + } - if (! $argumentNode) { - if ($argDef->defaultValueExists()) { - $coercedValues[$name] = $argDef->defaultValue; + /** + * @param FieldDefinition|Directive $fieldDefinition + * @param ArgumentNode[] $argumentValueMap + * @param mixed[] $variableValues + * @param Node|null $referenceNode + * + * @return mixed[] + * + * @throws Error + */ + public static function getArgumentValuesForMap($fieldDefinition, $argumentValueMap, $variableValues = null, $referenceNode = null) + { + $argumentDefinitions = $fieldDefinition->args; + $coercedValues = []; + + foreach ($argumentDefinitions as $argumentDefinition) { + $name = $argumentDefinition->name; + $argType = $argumentDefinition->getType(); + $argumentValueNode = $argumentValueMap[$name] ?? null; + + if (! $argumentValueNode) { + if ($argumentDefinition->defaultValueExists()) { + $coercedValues[$name] = $argumentDefinition->defaultValue; } elseif ($argType instanceof NonNull) { throw new Error( 'Argument "' . $name . '" of required type ' . '"' . Utils::printSafe($argType) . '" was not provided.', - [$node] + $referenceNode ); } - } elseif ($argumentNode->value instanceof VariableNode) { - $variableName = $argumentNode->value->name->value; + } elseif ($argumentValueNode instanceof VariableNode) { + $variableName = $argumentValueNode->name->value; if ($variableValues && array_key_exists($variableName, $variableValues)) { // Note: this does not check that this variable value is correct. // This assumes that this query has been validated and the variable // usage here is of the correct type. $coercedValues[$name] = $variableValues[$variableName]; - } elseif ($argDef->defaultValueExists()) { - $coercedValues[$name] = $argDef->defaultValue; + } elseif ($argumentDefinition->defaultValueExists()) { + $coercedValues[$name] = $argumentDefinition->defaultValue; } elseif ($argType instanceof NonNull) { throw new Error( 'Argument "' . $name . '" of required type "' . Utils::printSafe($argType) . '" was ' . 'provided the variable "$' . $variableName . '" which was not provided ' . 'a runtime value.', - [$argumentNode->value] + [$argumentValueNode] ); } } else { - $valueNode = $argumentNode->value; + $valueNode = $argumentValueNode; $coercedValue = AST::valueFromAST($valueNode, $argType, $variableValues); if (Utils::isInvalid($coercedValue)) { // Note: ValuesOfCorrectType validation should catch this before @@ -213,7 +233,7 @@ class Values // continue with an invalid argument value. throw new Error( 'Argument "' . $name . '" has invalid value ' . Printer::doPrint($valueNode) . '.', - [$argumentNode->value] + [$argumentValueNode] ); } $coercedValues[$name] = $coercedValue; diff --git a/src/Experimental/Executor/Collector.php b/src/Experimental/Executor/Collector.php new file mode 100644 index 0000000..34f1e0e --- /dev/null +++ b/src/Experimental/Executor/Collector.php @@ -0,0 +1,279 @@ +schema = $schema; + $this->runtime = $runtime; + } + + public function initialize(DocumentNode $documentNode, ?string $operationName = null) + { + $hasMultipleAssumedOperations = false; + + foreach ($documentNode->definitions as $definitionNode) { + /** @var DefinitionNode|Node $definitionNode */ + + if ($definitionNode->kind === NodeKind::OPERATION_DEFINITION) { + /** @var OperationDefinitionNode $definitionNode */ + if ($operationName === null && $this->operation !== null) { + $hasMultipleAssumedOperations = true; + } + if ($operationName === null || + (isset($definitionNode->name) && $definitionNode->name->value === $operationName) + ) { + $this->operation = $definitionNode; + } + } elseif ($definitionNode->kind === NodeKind::FRAGMENT_DEFINITION) { + /** @var FragmentDefinitionNode $definitionNode */ + $this->fragments[$definitionNode->name->value] = $definitionNode; + } + } + + if ($this->operation === null) { + if ($operationName !== null) { + $this->runtime->addError(new Error(sprintf('Unknown operation named "%s".', $operationName))); + } else { + $this->runtime->addError(new Error('Must provide an operation.')); + } + return; + } + + if ($hasMultipleAssumedOperations) { + $this->runtime->addError(new Error('Must provide operation name if query contains multiple operations.')); + return; + } + + if ($this->operation->operation === 'query') { + $this->rootType = $this->schema->getQueryType(); + } elseif ($this->operation->operation === 'mutation') { + $this->rootType = $this->schema->getMutationType(); + } elseif ($this->operation->operation === 'subscription') { + $this->rootType = $this->schema->getSubscriptionType(); + } else { + $this->runtime->addError(new Error(sprintf('Cannot initialize collector with operation type "%s".', $this->operation->operation))); + } + } + + /** + * @return Generator + */ + public function collectFields(ObjectType $runtimeType, ?SelectionSetNode $selectionSet) + { + $this->fields = []; + $this->visitedFragments = []; + + $this->doCollectFields($runtimeType, $selectionSet); + + foreach ($this->fields as $resultName => $fieldNodes) { + $fieldNode = $fieldNodes[0]; + $fieldName = $fieldNode->name->value; + + $argumentValueMap = null; + if (! empty($fieldNode->arguments)) { + foreach ($fieldNode->arguments as $argumentNode) { + $argumentValueMap = $argumentValueMap ?? []; + $argumentValueMap[$argumentNode->name->value] = $argumentNode->value; + } + } + + if ($fieldName !== Introspection::TYPE_NAME_FIELD_NAME && + ! ($runtimeType === $this->schema->getQueryType() && ($fieldName === Introspection::SCHEMA_FIELD_NAME || $fieldName === Introspection::TYPE_FIELD_NAME)) && + ! $runtimeType->hasField($fieldName) + ) { + // do not emit error + continue; + } + + yield new CoroutineContextShared($fieldNodes, $fieldName, $resultName, $argumentValueMap); + } + } + + private function doCollectFields(ObjectType $runtimeType, ?SelectionSetNode $selectionSet) + { + if ($selectionSet === null) { + return; + } + + foreach ($selectionSet->selections as $selection) { + /** @var FieldNode|FragmentSpreadNode|InlineFragmentNode $selection */ + + if (! empty($selection->directives)) { + foreach ($selection->directives as $directiveNode) { + if ($directiveNode->name->value === Directive::SKIP_NAME) { + /** @var ValueNode|null $condition */ + $condition = null; + foreach ($directiveNode->arguments as $argumentNode) { + if ($argumentNode->name->value === Directive::IF_ARGUMENT_NAME) { + $condition = $argumentNode->value; + break; + } + } + + if ($condition === null) { + $this->runtime->addError(new Error( + sprintf('@%s directive is missing "%s" argument.', Directive::SKIP_NAME, Directive::IF_ARGUMENT_NAME), + $selection + )); + } else { + if ($this->runtime->evaluate($condition, Type::boolean()) === true) { + continue 2; // !!! advances outer loop + } + } + } elseif ($directiveNode->name->value === Directive::INCLUDE_NAME) { + /** @var ValueNode|null $condition */ + $condition = null; + foreach ($directiveNode->arguments as $argumentNode) { + if ($argumentNode->name->value === Directive::IF_ARGUMENT_NAME) { + $condition = $argumentNode->value; + break; + } + } + + if ($condition === null) { + $this->runtime->addError(new Error( + sprintf('@%s directive is missing "%s" argument.', Directive::INCLUDE_NAME, Directive::IF_ARGUMENT_NAME), + $selection + )); + } else { + if ($this->runtime->evaluate($condition, Type::boolean()) !== true) { + continue 2; // !!! advances outer loop + } + } + } + } + } + + if ($selection->kind === NodeKind::FIELD) { + /** @var FieldNode $selection */ + + $resultName = $selection->alias ? $selection->alias->value : $selection->name->value; + + if (! isset($this->fields[$resultName])) { + $this->fields[$resultName] = []; + } + + $this->fields[$resultName][] = $selection; + } elseif ($selection->kind === NodeKind::FRAGMENT_SPREAD) { + /** @var FragmentSpreadNode $selection */ + + $fragmentName = $selection->name->value; + + if (isset($this->visitedFragments[$fragmentName])) { + continue; + } elseif (! isset($this->fragments[$fragmentName])) { + $this->runtime->addError(new Error( + sprintf('Fragment "%s" does not exist.', $fragmentName), + $selection + )); + continue; + } + + $this->visitedFragments[$fragmentName] = true; + + $fragmentDefinition = $this->fragments[$fragmentName]; + $conditionTypeName = $fragmentDefinition->typeCondition->name->value; + + if (! $this->schema->hasType($conditionTypeName)) { + $this->runtime->addError(new Error( + sprintf('Cannot spread fragment "%s", type "%s" does not exist.', $fragmentName, $conditionTypeName), + $selection + )); + continue; + } + + $conditionType = $this->schema->getType($conditionTypeName); + + if ($conditionType instanceof ObjectType) { + if ($runtimeType->name !== $conditionType->name) { + continue; + } + } elseif ($conditionType instanceof AbstractType) { + if (! $this->schema->isPossibleType($conditionType, $runtimeType)) { + continue; + } + } + + $this->doCollectFields($runtimeType, $fragmentDefinition->selectionSet); + } elseif ($selection->kind === NodeKind::INLINE_FRAGMENT) { + /** @var InlineFragmentNode $selection */ + + if ($selection->typeCondition !== null) { + $conditionTypeName = $selection->typeCondition->name->value; + + if (! $this->schema->hasType($conditionTypeName)) { + $this->runtime->addError(new Error( + sprintf('Cannot spread inline fragment, type "%s" does not exist.', $conditionTypeName), + $selection + )); + continue; + } + + $conditionType = $this->schema->getType($conditionTypeName); + + if ($conditionType instanceof ObjectType) { + if ($runtimeType->name !== $conditionType->name) { + continue; + } + } elseif ($conditionType instanceof AbstractType) { + if (! $this->schema->isPossibleType($conditionType, $runtimeType)) { + continue; + } + } + } + + $this->doCollectFields($runtimeType, $selection->selectionSet); + } + } + } +} diff --git a/src/Experimental/Executor/CoroutineContext.php b/src/Experimental/Executor/CoroutineContext.php new file mode 100644 index 0000000..910b41d --- /dev/null +++ b/src/Experimental/Executor/CoroutineContext.php @@ -0,0 +1,57 @@ +shared = $shared; + $this->type = $type; + $this->value = $value; + $this->result = $result; + $this->path = $path; + $this->nullFence = $nullFence; + } +} diff --git a/src/Experimental/Executor/CoroutineContextShared.php b/src/Experimental/Executor/CoroutineContextShared.php new file mode 100644 index 0000000..bbc5488 --- /dev/null +++ b/src/Experimental/Executor/CoroutineContextShared.php @@ -0,0 +1,62 @@ +fieldNodes = $fieldNodes; + $this->fieldName = $fieldName; + $this->resultName = $resultName; + $this->argumentValueMap = $argumentValueMap; + } +} diff --git a/src/Experimental/Executor/CoroutineExecutor.php b/src/Experimental/Executor/CoroutineExecutor.php new file mode 100644 index 0000000..45503eb --- /dev/null +++ b/src/Experimental/Executor/CoroutineExecutor.php @@ -0,0 +1,931 @@ +schema = $schema; + $this->fieldResolver = $fieldResolver; + $this->promiseAdapter = $promiseAdapter; + $this->rootValue = $rootValue; + $this->contextValue = $contextValue; + $this->rawVariableValues = $rawVariableValues; + $this->documentNode = $documentNode; + $this->operationName = $operationName; + } + + public static function create( + PromiseAdapter $promiseAdapter, + Schema $schema, + DocumentNode $documentNode, + $rootValue, + $contextValue, + $variableValues, + ?string $operationName, + callable $fieldResolver + ) { + return new static( + $promiseAdapter, + $schema, + $documentNode, + $rootValue, + $contextValue, + $variableValues, + $operationName, + $fieldResolver + ); + } + + private static function resultToArray($value, $emptyObjectAsStdClass = true) + { + if ($value instanceof stdClass) { + $array = []; + foreach ($value as $propertyName => $propertyValue) { + $array[$propertyName] = self::resultToArray($propertyValue); + } + if ($emptyObjectAsStdClass && empty($array)) { + return new stdClass(); + } + return $array; + } + + if (is_array($value)) { + $array = []; + foreach ($value as $item) { + $array[] = self::resultToArray($item); + } + return $array; + } + + return $value; + } + + public function doExecute() : Promise + { + $this->rootResult = new stdClass(); + $this->errors = []; + $this->queue = new SplQueue(); + $this->schedule = new SplQueue(); + $this->pending = 0; + + $this->collector = new Collector($this->schema, $this); + $this->collector->initialize($this->documentNode, $this->operationName); + + if (! empty($this->errors)) { + return $this->promiseAdapter->createFulfilled($this->finishExecute(null, $this->errors)); + } + + [$errors, $coercedVariableValues] = Values::getVariableValues( + $this->schema, + $this->collector->operation->variableDefinitions ?: [], + $this->rawVariableValues ?: [] + ); + + if (! empty($errors)) { + return $this->promiseAdapter->createFulfilled($this->finishExecute(null, $errors)); + } + + $this->variableValues = $coercedVariableValues; + + foreach ($this->collector->collectFields($this->collector->rootType, $this->collector->operation->selectionSet) as $shared) { + /** @var CoroutineContextShared $shared */ + + // !!! assign to keep object keys sorted + $this->rootResult->{$shared->resultName} = null; + + $ctx = new CoroutineContext( + $shared, + $this->collector->rootType, + $this->rootValue, + $this->rootResult, + [$shared->resultName] + ); + + $fieldDefinition = $this->findFieldDefinition($ctx); + if (! $fieldDefinition->getType() instanceof NonNull) { + $ctx->nullFence = [$shared->resultName]; + } + + if ($this->collector->operation->operation === 'mutation' && ! $this->queue->isEmpty()) { + $this->schedule->enqueue($ctx); + } else { + $this->queue->enqueue(new Strand($this->spawn($ctx))); + } + } + + $this->run(); + + if ($this->pending > 0) { + return $this->promiseAdapter->create(function (callable $resolve) { + $this->doResolve = $resolve; + }); + } + + return $this->promiseAdapter->createFulfilled($this->finishExecute($this->rootResult, $this->errors)); + } + + /** + * @param object|null $value + * @param Error[] $errors + */ + private function finishExecute($value, array $errors) : ExecutionResult + { + $this->rootResult = null; + $this->errors = null; + $this->queue = null; + $this->schedule = null; + $this->pending = null; + $this->collector = null; + $this->variableValues = null; + + if ($value !== null) { + $value = self::resultToArray($value, false); + } + + return new ExecutionResult($value, $errors); + } + + /** + * @internal + */ + public function evaluate(ValueNode $valueNode, InputType $type) + { + return AST::valueFromAST($valueNode, $type, $this->variableValues); + } + + /** + * @internal + */ + public function addError($error) + { + $this->errors[] = $error; + } + + private function run() + { + RUN: + while (! $this->queue->isEmpty()) { + /** @var Strand $strand */ + $strand = $this->queue->dequeue(); + + try { + if ($strand->success !== null) { + RESUME: + + if ($strand->success) { + $strand->current->send($strand->value); + } else { + $strand->current->throw($strand->value); + } + + $strand->success = null; + $strand->value = null; + } + + START: + if ($strand->current->valid()) { + $value = $strand->current->current(); + + if ($value instanceof Generator) { + $strand->stack[$strand->depth++] = $strand->current; + $strand->current = $value; + goto START; + } elseif ($this->promiseAdapter->isThenable($value)) { + // !!! increment pending before calling ->then() as it may invoke the callback right away + ++$this->pending; + + $this->promiseAdapter + ->convertThenable($value) + ->then( + function ($value) use ($strand) { + $strand->success = true; + $strand->value = $value; + $this->queue->enqueue($strand); + $this->done(); + }, + function (Throwable $throwable) use ($strand) { + $strand->success = false; + $strand->value = $throwable; + $this->queue->enqueue($strand); + $this->done(); + } + ); + continue; + } else { + $strand->success = true; + $strand->value = $value; + goto RESUME; + } + } + + $strand->success = true; + $strand->value = $strand->current->getReturn(); + } catch (Throwable $reason) { + $strand->success = false; + $strand->value = $reason; + } + + if ($strand->depth <= 0) { + continue; + } + + $current = &$strand->stack[--$strand->depth]; + $strand->current = $current; + $current = null; + goto RESUME; + } + + if ($this->pending > 0 || $this->schedule->isEmpty()) { + return; + } + + /** @var CoroutineContext $ctx */ + $ctx = $this->schedule->dequeue(); + $this->queue->enqueue(new Strand($this->spawn($ctx))); + goto RUN; + } + + private function done() + { + --$this->pending; + + $this->run(); + + if ($this->pending > 0) { + return; + } + + $doResolve = $this->doResolve; + $doResolve($this->finishExecute($this->rootResult, $this->errors)); + } + + private function spawn(CoroutineContext $ctx) + { + // short-circuit evaluation for __typename + if ($ctx->shared->fieldName === Introspection::TYPE_NAME_FIELD_NAME) { + $ctx->result->{$ctx->shared->resultName} = $ctx->type->name; + return; + } + + try { + if ($ctx->shared->typeGuard1 === $ctx->type) { + $resolve = $ctx->shared->resolveIfType1; + $ctx->resolveInfo = clone $ctx->shared->resolveInfoIfType1; + $ctx->resolveInfo->path = $ctx->path; + $arguments = $ctx->shared->argumentsIfType1; + $returnType = $ctx->resolveInfo->returnType; + } else { + $fieldDefinition = $this->findFieldDefinition($ctx); + + if ($fieldDefinition->resolveFn !== null) { + $resolve = $fieldDefinition->resolveFn; + } elseif ($ctx->type->resolveFieldFn !== null) { + $resolve = $ctx->type->resolveFieldFn; + } else { + $resolve = $this->fieldResolver; + } + + $returnType = $fieldDefinition->getType(); + + $ctx->resolveInfo = new ResolveInfo( + $ctx->shared->fieldName, + $ctx->shared->fieldNodes, + $returnType, + $ctx->type, + $ctx->path, + $this->schema, + $this->collector->fragments, + $this->rootValue, + $this->collector->operation, + $this->variableValues + ); + + $arguments = Values::getArgumentValuesForMap( + $fieldDefinition, + $ctx->shared->argumentValueMap, + $this->variableValues + ); + + // !!! assign only in batch when no exception can be thrown in-between + $ctx->shared->typeGuard1 = $ctx->type; + $ctx->shared->resolveIfType1 = $resolve; + $ctx->shared->argumentsIfType1 = $arguments; + $ctx->shared->resolveInfoIfType1 = $ctx->resolveInfo; + } + + $value = $resolve($ctx->value, $arguments, $this->contextValue, $ctx->resolveInfo); + + if (! $this->completeValueFast($ctx, $returnType, $value, $ctx->path, $returnValue)) { + $returnValue = yield $this->completeValue( + $ctx, + $returnType, + $value, + $ctx->path, + $ctx->nullFence + ); + } + } catch (Throwable $reason) { + $this->addError(Error::createLocatedError( + $reason, + $ctx->shared->fieldNodes, + $ctx->path + )); + + $returnValue = self::$undefined; + } + + if ($returnValue !== self::$undefined) { + $ctx->result->{$ctx->shared->resultName} = $returnValue; + } elseif ($ctx->resolveInfo !== null && $ctx->resolveInfo->returnType instanceof NonNull) { // !!! $ctx->resolveInfo might not have been initialized yet + $result =& $this->rootResult; + foreach ($ctx->nullFence ?? [] as $key) { + if (is_string($key)) { + $result =& $result->{$key}; + } else { + $result =& $result[$key]; + } + } + $result = null; + } + } + + private function findFieldDefinition(CoroutineContext $ctx) + { + if ($ctx->shared->fieldName === Introspection::SCHEMA_FIELD_NAME && $ctx->type === $this->schema->getQueryType()) { + return Introspection::schemaMetaFieldDef(); + } + + if ($ctx->shared->fieldName === Introspection::TYPE_FIELD_NAME && $ctx->type === $this->schema->getQueryType()) { + return Introspection::typeMetaFieldDef(); + } + + if ($ctx->shared->fieldName === Introspection::TYPE_NAME_FIELD_NAME) { + return Introspection::typeNameMetaFieldDef(); + } + + return $ctx->type->getField($ctx->shared->fieldName); + } + + /** + * @param mixed $value + * @param string[] $path + * @param mixed $returnValue + */ + private function completeValueFast(CoroutineContext $ctx, Type $type, $value, array $path, &$returnValue) : bool + { + // special handling of Throwable inherited from JS reference implementation, but makes no sense in this PHP + if ($this->promiseAdapter->isThenable($value) || $value instanceof Throwable) { + return false; + } + + $nonNull = false; + if ($type instanceof NonNull) { + $nonNull = true; + $type = $type->getWrappedType(); + } + + if (! $type instanceof LeafType) { + return false; + } + + if ($type !== $this->schema->getType($type->name)) { + $hint = ''; + if ($this->schema->getConfig()->typeLoader) { + $hint = sprintf( + 'Make sure that type loader returns the same instance as defined in %s.%s', + $ctx->type, + $ctx->shared->fieldName + ); + } + $this->addError(Error::createLocatedError( + new InvariantViolation( + sprintf( + 'Schema must contain unique named types but contains multiple types named "%s". %s ' . + '(see http://webonyx.github.io/graphql-php/type-system/#type-registry).', + $type->name, + $hint + ) + ), + $ctx->shared->fieldNodes, + $path + )); + + $value = null; + } + + if ($value === null) { + $returnValue = null; + } else { + try { + $returnValue = $type->serialize($value); + } catch (Throwable $error) { + $this->addError(Error::createLocatedError( + new InvariantViolation( + 'Expected a value of type "' . Utils::printSafe($type) . '" but received: ' . Utils::printSafe($value), + 0, + $error + ), + $ctx->shared->fieldNodes, + $path + )); + $returnValue = null; + } + } + + if ($nonNull && $returnValue === null) { + $this->addError(Error::createLocatedError( + new InvariantViolation(sprintf( + 'Cannot return null for non-nullable field %s.%s.', + $ctx->type->name, + $ctx->shared->fieldName + )), + $ctx->shared->fieldNodes, + $path + )); + + $returnValue = self::$undefined; + } + + return true; + } + + /** + * @param mixed $value + * @param string[] $path + * @param string[]|null $nullFence + * + * @return mixed + */ + private function completeValue(CoroutineContext $ctx, Type $type, $value, array $path, ?array $nullFence) + { + $nonNull = false; + $returnValue = null; + + if ($type instanceof NonNull) { + $nonNull = true; + $type = $type->getWrappedType(); + } else { + $nullFence = $path; + } + + // !!! $value might be promise, yield to resolve + try { + if ($this->promiseAdapter->isThenable($value)) { + $value = yield $value; + } + } catch (Throwable $reason) { + $this->addError(Error::createLocatedError( + $reason, + $ctx->shared->fieldNodes, + $path + )); + if ($nonNull) { + $returnValue = self::$undefined; + } else { + $returnValue = null; + } + goto CHECKED_RETURN; + } + + if ($value === null) { + $returnValue = $value; + goto CHECKED_RETURN; + } elseif ($value instanceof Throwable) { + // special handling of Throwable inherited from JS reference implementation, but makes no sense in this PHP + $this->addError(Error::createLocatedError( + $value, + $ctx->shared->fieldNodes, + $path + )); + if ($nonNull) { + $returnValue = self::$undefined; + } else { + $returnValue = null; + } + goto CHECKED_RETURN; + } + + if ($type instanceof ListOfType) { + $returnValue = []; + $index = -1; + $itemType = $type->getWrappedType(); + foreach ($value as $itemValue) { + ++$index; + + $itemPath = $path; + $itemPath[] = $index; // !!! use arrays COW semantics + + try { + if (! $this->completeValueFast($ctx, $itemType, $itemValue, $itemPath, $itemReturnValue)) { + $itemReturnValue = yield $this->completeValue($ctx, $itemType, $itemValue, $itemPath, $nullFence); + } + } catch (Throwable $reason) { + $this->addError(Error::createLocatedError( + $reason, + $ctx->shared->fieldNodes, + $itemPath + )); + $itemReturnValue = null; + } + if ($itemReturnValue === self::$undefined) { + $returnValue = self::$undefined; + goto CHECKED_RETURN; + } + $returnValue[$index] = $itemReturnValue; + } + + goto CHECKED_RETURN; + } else { + if ($type !== $this->schema->getType($type->name)) { + $hint = ''; + if ($this->schema->getConfig()->typeLoader) { + $hint = sprintf( + 'Make sure that type loader returns the same instance as defined in %s.%s', + $ctx->type, + $ctx->shared->fieldName + ); + } + $this->addError(Error::createLocatedError( + new InvariantViolation( + sprintf( + 'Schema must contain unique named types but contains multiple types named "%s". %s ' . + '(see http://webonyx.github.io/graphql-php/type-system/#type-registry).', + $type->name, + $hint + ) + ), + $ctx->shared->fieldNodes, + $path + )); + + $returnValue = null; + goto CHECKED_RETURN; + } + + if ($type instanceof LeafType) { + try { + $returnValue = $type->serialize($value); + } catch (Throwable $error) { + $this->addError(Error::createLocatedError( + new InvariantViolation( + 'Expected a value of type "' . Utils::printSafe($type) . '" but received: ' . Utils::printSafe($value), + 0, + $error + ), + $ctx->shared->fieldNodes, + $path + )); + $returnValue = null; + } + goto CHECKED_RETURN; + } elseif ($type instanceof CompositeType) { + /** @var ObjectType|null $objectType */ + $objectType = null; + if ($type instanceof InterfaceType || $type instanceof UnionType) { + $objectType = $type->resolveType($value, $this->contextValue, $ctx->resolveInfo); + + if ($objectType === null) { + $objectType = yield $this->resolveTypeSlow($ctx, $value, $type); + } + + // !!! $objectType->resolveType() might return promise, yield to resolve + $objectType = yield $objectType; + if (is_string($objectType)) { + $objectType = $this->schema->getType($objectType); + } + + if ($objectType === null) { + $this->addError(Error::createLocatedError( + sprintf( + 'Composite type "%s" did not resolve concrete object type for value: %s.', + $type->name, + Utils::printSafe($value) + ), + $ctx->shared->fieldNodes, + $path + )); + + $returnValue = self::$undefined; + goto CHECKED_RETURN; + } elseif (! $objectType instanceof ObjectType) { + $this->addError(Error::createLocatedError( + new InvariantViolation(sprintf( + 'Abstract type %s must resolve to an Object type at ' . + 'runtime for field %s.%s with value "%s", received "%s". ' . + 'Either the %s type should provide a "resolveType" ' . + 'function or each possible type should provide an "isTypeOf" function.', + $type, + $ctx->resolveInfo->parentType, + $ctx->resolveInfo->fieldName, + Utils::printSafe($value), + Utils::printSafe($objectType), + $type + )), + $ctx->shared->fieldNodes, + $path + )); + + $returnValue = null; + goto CHECKED_RETURN; + } elseif (! $this->schema->isPossibleType($type, $objectType)) { + $this->addError(Error::createLocatedError( + new InvariantViolation(sprintf( + 'Runtime Object type "%s" is not a possible type for "%s".', + $objectType, + $type + )), + $ctx->shared->fieldNodes, + $path + )); + + $returnValue = null; + goto CHECKED_RETURN; + } elseif ($objectType !== $this->schema->getType($objectType->name)) { + $this->addError(Error::createLocatedError( + new InvariantViolation( + sprintf( + 'Schema must contain unique named types but contains multiple types named "%s". ' . + 'Make sure that `resolveType` function of abstract type "%s" returns the same ' . + 'type instance as referenced anywhere else within the schema ' . + '(see http://webonyx.github.io/graphql-php/type-system/#type-registry).', + $objectType, + $type + ) + ), + $ctx->shared->fieldNodes, + $path + )); + + $returnValue = null; + goto CHECKED_RETURN; + } + } elseif ($type instanceof ObjectType) { + $objectType = $type; + } else { + $this->addError(Error::createLocatedError( + sprintf( + 'Unexpected field type "%s".', + Utils::printSafe($type) + ), + $ctx->shared->fieldNodes, + $path + )); + + $returnValue = self::$undefined; + goto CHECKED_RETURN; + } + + $typeCheck = $objectType->isTypeOf($value, $this->contextValue, $ctx->resolveInfo); + if ($typeCheck !== null) { + // !!! $objectType->isTypeOf() might return promise, yield to resolve + $typeCheck = yield $typeCheck; + if (! $typeCheck) { + $this->addError(Error::createLocatedError( + sprintf('Expected value of type "%s" but got: %s.', $type->name, Utils::printSafe($value)), + $ctx->shared->fieldNodes, + $path + )); + + $returnValue = null; + goto CHECKED_RETURN; + } + } + + $returnValue = new stdClass(); + + if ($ctx->shared->typeGuard2 === $objectType) { + foreach ($ctx->shared->childContextsIfType2 as $childCtx) { + $childCtx = clone $childCtx; + $childCtx->type = $objectType; + $childCtx->value = $value; + $childCtx->result = $returnValue; + $childCtx->path = $path; + $childCtx->path[] = $childCtx->shared->resultName; // !!! uses array COW semantics + $childCtx->nullFence = $nullFence; + $childCtx->resolveInfo = null; + + $this->queue->enqueue(new Strand($this->spawn($childCtx))); + + // !!! assign null to keep object keys sorted + $returnValue->{$childCtx->shared->resultName} = null; + } + } else { + $childContexts = []; + + foreach ($this->collector->collectFields($objectType, $ctx->shared->mergedSelectionSet ?? $this->mergeSelectionSets($ctx)) as $childShared) { + /** @var CoroutineContextShared $childShared */ + + $childPath = $path; + $childPath[] = $childShared->resultName; // !!! uses array COW semantics + $childCtx = new CoroutineContext( + $childShared, + $objectType, + $value, + $returnValue, + $childPath, + $nullFence + ); + + $childContexts[] = $childCtx; + + $this->queue->enqueue(new Strand($this->spawn($childCtx))); + + // !!! assign null to keep object keys sorted + $returnValue->{$childShared->resultName} = null; + } + + $ctx->shared->typeGuard2 = $objectType; + $ctx->shared->childContextsIfType2 = $childContexts; + } + + goto CHECKED_RETURN; + } else { + $this->addError(Error::createLocatedError( + sprintf('Unhandled type "%s".', Utils::printSafe($type)), + $ctx->shared->fieldNodes, + $path + )); + + $returnValue = null; + goto CHECKED_RETURN; + } + } + + CHECKED_RETURN: + if ($nonNull && $returnValue === null) { + $this->addError(Error::createLocatedError( + new InvariantViolation(sprintf( + 'Cannot return null for non-nullable field %s.%s.', + $ctx->type->name, + $ctx->shared->fieldName + )), + $ctx->shared->fieldNodes, + $path + )); + + return self::$undefined; + } + + return $returnValue; + } + + private function mergeSelectionSets(CoroutineContext $ctx) + { + $selections = []; + + foreach ($ctx->shared->fieldNodes as $fieldNode) { + if ($fieldNode->selectionSet === null) { + continue; + } + + foreach ($fieldNode->selectionSet->selections as $selection) { + $selections[] = $selection; + } + } + + return $ctx->shared->mergedSelectionSet = new SelectionSetNode(['selections' => $selections]); + } + + private function resolveTypeSlow(CoroutineContext $ctx, $value, AbstractType $abstractType) + { + if ($value !== null && + is_array($value) && + isset($value['__typename']) && + is_string($value['__typename']) + ) { + return $this->schema->getType($value['__typename']); + } + + if ($abstractType instanceof InterfaceType && $this->schema->getConfig()->typeLoader) { + Warning::warnOnce( + sprintf( + 'GraphQL Interface Type `%s` returned `null` from it`s `resolveType` function ' . + 'for value: %s. Switching to slow resolution method using `isTypeOf` ' . + 'of all possible implementations. It requires full schema scan and degrades query performance significantly. ' . + ' Make sure your `resolveType` always returns valid implementation or throws.', + $abstractType->name, + Utils::printSafe($value) + ), + Warning::WARNING_FULL_SCHEMA_SCAN + ); + } + + $possibleTypes = $this->schema->getPossibleTypes($abstractType); + + // to be backward-compatible with old executor, ->isTypeOf() is called for all possible types, + // it cannot short-circuit when the match is found + + $selectedType = null; + foreach ($possibleTypes as $type) { + $typeCheck = yield $type->isTypeOf($value, $this->contextValue, $ctx->resolveInfo); + if ($selectedType !== null || $typeCheck !== true) { + continue; + } + + $selectedType = $type; + } + + return $selectedType; + } +} diff --git a/src/Experimental/Executor/Runtime.php b/src/Experimental/Executor/Runtime.php new file mode 100644 index 0000000..f8dc14a --- /dev/null +++ b/src/Experimental/Executor/Runtime.php @@ -0,0 +1,18 @@ +current = $coroutine; + $this->stack = []; + $this->depth = 0; + } +} diff --git a/src/Language/AST/VariableNode.php b/src/Language/AST/VariableNode.php index be72ca9..f8983b9 100644 --- a/src/Language/AST/VariableNode.php +++ b/src/Language/AST/VariableNode.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace GraphQL\Language\AST; -class VariableNode extends Node +class VariableNode extends Node implements ValueNode { /** @var string */ public $kind = NodeKind::VARIABLE; diff --git a/src/Type/Definition/Directive.php b/src/Type/Definition/Directive.php index 754d442..ab7d5d5 100644 --- a/src/Type/Definition/Directive.php +++ b/src/Type/Definition/Directive.php @@ -8,6 +8,8 @@ use GraphQL\Language\AST\DirectiveDefinitionNode; use GraphQL\Language\DirectiveLocation; use GraphQL\Utils\Utils; use function array_key_exists; +use function array_keys; +use function in_array; use function is_array; /** @@ -17,6 +19,12 @@ class Directive { public const DEFAULT_DEPRECATION_REASON = 'No longer supported'; + const INCLUDE_NAME = 'include'; + const IF_ARGUMENT_NAME = 'if'; + const SKIP_NAME = 'skip'; + const DEPRECATED_NAME = 'deprecated'; + const REASON_ARGUMENT_NAME = 'reason'; + /** @var Directive[] */ public static $internalDirectives; @@ -72,7 +80,6 @@ class Directive public static function includeDirective() { $internal = self::getInternalDirectives(); - return $internal['include']; } @@ -84,7 +91,7 @@ class Directive if (! self::$internalDirectives) { self::$internalDirectives = [ 'include' => new self([ - 'name' => 'include', + 'name' => self::INCLUDE_NAME, 'description' => 'Directs the executor to include this field or fragment only when the `if` argument is true.', 'locations' => [ DirectiveLocation::FIELD, @@ -92,14 +99,14 @@ class Directive DirectiveLocation::INLINE_FRAGMENT, ], 'args' => [new FieldArgument([ - 'name' => 'if', + 'name' => self::IF_ARGUMENT_NAME, 'type' => Type::nonNull(Type::boolean()), 'description' => 'Included when true.', ]), ], ]), 'skip' => new self([ - 'name' => 'skip', + 'name' => self::SKIP_NAME, 'description' => 'Directs the executor to skip this field or fragment when the `if` argument is true.', 'locations' => [ DirectiveLocation::FIELD, @@ -107,21 +114,21 @@ class Directive DirectiveLocation::INLINE_FRAGMENT, ], 'args' => [new FieldArgument([ - 'name' => 'if', + 'name' => self::IF_ARGUMENT_NAME, 'type' => Type::nonNull(Type::boolean()), 'description' => 'Skipped when true.', ]), ], ]), 'deprecated' => new self([ - 'name' => 'deprecated', + 'name' => self::DEPRECATED_NAME, 'description' => 'Marks an element of a GraphQL schema as no longer supported.', 'locations' => [ DirectiveLocation::FIELD_DEFINITION, DirectiveLocation::ENUM_VALUE, ], 'args' => [new FieldArgument([ - 'name' => 'reason', + 'name' => self::REASON_ARGUMENT_NAME, 'type' => Type::string(), 'description' => 'Explains why this element was deprecated, usually also including a ' . @@ -133,30 +140,24 @@ class Directive ]), ]; } - return self::$internalDirectives; } - /** * @return Directive */ public static function skipDirective() { $internal = self::getInternalDirectives(); - return $internal['skip']; } - /** * @return Directive */ public static function deprecatedDirective() { $internal = self::getInternalDirectives(); - return $internal['deprecated']; } - /** * @return bool */ diff --git a/src/Type/Definition/FieldDefinition.php b/src/Type/Definition/FieldDefinition.php index 42ef1cc..34ad66f 100644 --- a/src/Type/Definition/FieldDefinition.php +++ b/src/Type/Definition/FieldDefinition.php @@ -59,7 +59,7 @@ class FieldDefinition public $config; /** @var OutputType */ - private $type; + public $type; /** @var callable|string */ private $complexityFn; diff --git a/src/Type/Definition/InterfaceType.php b/src/Type/Definition/InterfaceType.php index 7c18323..ff01a48 100644 --- a/src/Type/Definition/InterfaceType.php +++ b/src/Type/Definition/InterfaceType.php @@ -74,6 +74,20 @@ class InterfaceType extends Type implements AbstractType, OutputType, CompositeT return $this->fields[$name]; } + /** + * @param string $name + * + * @return bool + */ + public function hasField($name) + { + if ($this->fields === null) { + $this->getFields(); + } + + return isset($this->fields[$name]); + } + /** * @return FieldDefinition[] */ diff --git a/src/Type/Definition/ObjectType.php b/src/Type/Definition/ObjectType.php index ba0260a..ac40258 100644 --- a/src/Type/Definition/ObjectType.php +++ b/src/Type/Definition/ObjectType.php @@ -125,6 +125,20 @@ class ObjectType extends Type implements OutputType, CompositeType, NamedType return $this->fields[$name]; } + /** + * @param string $name + * + * @return bool + */ + public function hasField($name) + { + if ($this->fields === null) { + $this->getFields(); + } + + return isset($this->fields[$name]); + } + /** * @return FieldDefinition[] * diff --git a/src/Type/Definition/ResolveInfo.php b/src/Type/Definition/ResolveInfo.php index a31abff..8a752b1 100644 --- a/src/Type/Definition/ResolveInfo.php +++ b/src/Type/Definition/ResolveInfo.php @@ -11,7 +11,6 @@ use GraphQL\Language\AST\InlineFragmentNode; use GraphQL\Language\AST\OperationDefinitionNode; use GraphQL\Language\AST\SelectionSetNode; use GraphQL\Type\Schema; -use GraphQL\Utils\Utils; use function array_merge_recursive; /** @@ -56,7 +55,7 @@ class ResolveInfo * Path to this field from the very root value * * @api - * @var mixed[]|null + * @var string[] */ public $path; @@ -100,12 +99,28 @@ class ResolveInfo */ public $variableValues; - /** - * @param mixed[] $values - */ - public function __construct(array $values) - { - Utils::assign($this, $values); + public function __construct( + string $fieldName, + $fieldNodes, + $returnType, + ObjectType $parentType, + $path, + Schema $schema, + $fragments, + $rootValue, + ?OperationDefinitionNode $operation, + $variableValues + ) { + $this->fieldName = $fieldName; + $this->fieldNodes = $fieldNodes; + $this->returnType = $returnType; + $this->parentType = $parentType; + $this->path = $path; + $this->schema = $schema; + $this->fragments = $fragments; + $this->rootValue = $rootValue; + $this->operation = $operation; + $this->variableValues = $variableValues; } /** @@ -156,14 +171,12 @@ class ResolveInfo return $fields; } - /** * @return bool[] */ private function foldSelectionSet(SelectionSetNode $selectionSet, int $descend) : array { $fields = []; - foreach ($selectionSet->selections as $selectionNode) { if ($selectionNode instanceof FieldNode) { $fields[$selectionNode->name->value] = $descend > 0 && ! empty($selectionNode->selectionSet) @@ -186,7 +199,6 @@ class ResolveInfo ); } } - return $fields; } } diff --git a/src/Type/Introspection.php b/src/Type/Introspection.php index c19c0c4..f0cacee 100644 --- a/src/Type/Introspection.php +++ b/src/Type/Introspection.php @@ -35,6 +35,10 @@ use const E_USER_DEPRECATED; class Introspection { + const SCHEMA_FIELD_NAME = '__schema'; + const TYPE_FIELD_NAME = '__type'; + const TYPE_NAME_FIELD_NAME = '__typename'; + /** @var Type[] */ private static $map = []; @@ -691,9 +695,9 @@ EOD; public static function schemaMetaFieldDef() { - if (! isset(self::$map['__schema'])) { - self::$map['__schema'] = FieldDefinition::create([ - 'name' => '__schema', + if (! isset(self::$map[self::SCHEMA_FIELD_NAME])) { + self::$map[self::SCHEMA_FIELD_NAME] = FieldDefinition::create([ + 'name' => self::SCHEMA_FIELD_NAME, 'type' => Type::nonNull(self::_schema()), 'description' => 'Access the current type schema of this server.', 'args' => [], @@ -708,14 +712,14 @@ EOD; ]); } - return self::$map['__schema']; + return self::$map[self::SCHEMA_FIELD_NAME]; } public static function typeMetaFieldDef() { - if (! isset(self::$map['__type'])) { - self::$map['__type'] = FieldDefinition::create([ - 'name' => '__type', + if (! isset(self::$map[self::TYPE_FIELD_NAME])) { + self::$map[self::TYPE_FIELD_NAME] = FieldDefinition::create([ + 'name' => self::TYPE_FIELD_NAME, 'type' => self::_type(), 'description' => 'Request the type information of a single type.', 'args' => [ @@ -727,14 +731,14 @@ EOD; ]); } - return self::$map['__type']; + return self::$map[self::TYPE_FIELD_NAME]; } public static function typeNameMetaFieldDef() { - if (! isset(self::$map['__typename'])) { - self::$map['__typename'] = FieldDefinition::create([ - 'name' => '__typename', + if (! isset(self::$map[self::TYPE_NAME_FIELD_NAME])) { + self::$map[self::TYPE_NAME_FIELD_NAME] = FieldDefinition::create([ + 'name' => self::TYPE_NAME_FIELD_NAME, 'type' => Type::nonNull(Type::string()), 'description' => 'The name of the current Object type at runtime.', 'args' => [], @@ -749,6 +753,6 @@ EOD; ]); } - return self::$map['__typename']; + return self::$map[self::TYPE_NAME_FIELD_NAME]; } } diff --git a/src/Type/Schema.php b/src/Type/Schema.php index 019d5c1..bb17e82 100644 --- a/src/Type/Schema.php +++ b/src/Type/Schema.php @@ -309,6 +309,16 @@ class Schema return $this->resolvedTypes[$name]; } + /** + * @param string $name + * + * @return bool + */ + public function hasType($name) + { + return $this->getType($name) !== null; + } + /** * @param string $typeName * diff --git a/src/Validator/Rules/QueryComplexity.php b/src/Validator/Rules/QueryComplexity.php index 8a90652..6a3da59 100644 --- a/src/Validator/Rules/QueryComplexity.php +++ b/src/Validator/Rules/QueryComplexity.php @@ -186,37 +186,30 @@ class QueryComplexity extends QuerySecurityRule if ($directiveNode->name->value === 'deprecated') { return false; } - - $variableValuesResult = Values::getVariableValues( + [$errors, $variableValues] = Values::getVariableValues( $this->context->getSchema(), $this->variableDefs, $this->getRawVariableValues() ); - - if (! empty($variableValuesResult['errors'])) { + if (! empty($errors)) { throw new Error(implode( "\n\n", array_map( static function ($error) { return $error->getMessage(); }, - $variableValuesResult['errors'] + $errors ) )); } - $variableValues = $variableValuesResult['coerced']; - if ($directiveNode->name->value === 'include') { $directive = Directive::includeDirective(); /** @var bool $directiveArgsIf */ $directiveArgsIf = Values::getArgumentValues($directive, $directiveNode, $variableValues)['if']; - return ! $directiveArgsIf; } - $directive = Directive::skipDirective(); $directiveArgsIf = Values::getArgumentValues($directive, $directiveNode, $variableValues); - return $directiveArgsIf['if']; } } @@ -243,24 +236,23 @@ class QueryComplexity extends QuerySecurityRule $args = []; if ($fieldDef instanceof FieldDefinition) { - $variableValuesResult = Values::getVariableValues( + [$errors, $variableValues] = Values::getVariableValues( $this->context->getSchema(), $this->variableDefs, $rawVariableValues ); - if (! empty($variableValuesResult['errors'])) { + if (! empty($errors)) { throw new Error(implode( "\n\n", array_map( static function ($error) { return $error->getMessage(); }, - $variableValuesResult['errors'] + $errors ) )); } - $variableValues = $variableValuesResult['coerced']; $args = Values::getArgumentValues($fieldDef, $node, $variableValues); } diff --git a/tests/Executor/DeferredFieldsTest.php b/tests/Executor/DeferredFieldsTest.php index 6847fda..e68388c 100644 --- a/tests/Executor/DeferredFieldsTest.php +++ b/tests/Executor/DeferredFieldsTest.php @@ -13,7 +13,9 @@ use GraphQL\Type\Definition\Type; use GraphQL\Type\Schema; use GraphQL\Utils\Utils; use PHPUnit\Framework\TestCase; +use function count; use function in_array; +use function json_encode; class DeferredFieldsTest extends TestCase { @@ -27,7 +29,7 @@ class DeferredFieldsTest extends TestCase private $categoryType; /** @var */ - private $path; + private $paths; /** @var mixed[][] */ private $storyDataSource; @@ -68,7 +70,7 @@ class DeferredFieldsTest extends TestCase ['id' => 3, 'name' => 'Category #3', 'topStoryId' => 9], ]; - $this->path = []; + $this->paths = []; $this->userType = new ObjectType([ 'name' => 'User', 'fields' => function () { @@ -76,7 +78,7 @@ class DeferredFieldsTest extends TestCase 'name' => [ 'type' => Type::string(), 'resolve' => function ($user, $args, $context, ResolveInfo $info) { - $this->path[] = $info->path; + $this->paths[] = $info->path; return $user['name']; }, @@ -84,10 +86,10 @@ class DeferredFieldsTest extends TestCase 'bestFriend' => [ 'type' => $this->userType, 'resolve' => function ($user, $args, $context, ResolveInfo $info) { - $this->path[] = $info->path; + $this->paths[] = $info->path; return new Deferred(function () use ($user) { - $this->path[] = 'deferred-for-best-friend-of-' . $user['id']; + $this->paths[] = 'deferred-for-best-friend-of-' . $user['id']; return Utils::find( $this->userDataSource, @@ -108,7 +110,7 @@ class DeferredFieldsTest extends TestCase 'title' => [ 'type' => Type::string(), 'resolve' => function ($entry, $args, $context, ResolveInfo $info) { - $this->path[] = $info->path; + $this->paths[] = $info->path; return $entry['title']; }, @@ -116,10 +118,10 @@ class DeferredFieldsTest extends TestCase 'author' => [ 'type' => $this->userType, 'resolve' => function ($story, $args, $context, ResolveInfo $info) { - $this->path[] = $info->path; + $this->paths[] = $info->path; return new Deferred(function () use ($story) { - $this->path[] = 'deferred-for-story-' . $story['id'] . '-author'; + $this->paths[] = 'deferred-for-story-' . $story['id'] . '-author'; return Utils::find( $this->userDataSource, @@ -139,7 +141,7 @@ class DeferredFieldsTest extends TestCase 'name' => [ 'type' => Type::string(), 'resolve' => function ($category, $args, $context, ResolveInfo $info) { - $this->path[] = $info->path; + $this->paths[] = $info->path; return $category['name']; }, @@ -148,7 +150,7 @@ class DeferredFieldsTest extends TestCase 'stories' => [ 'type' => Type::listOf($this->storyType), 'resolve' => function ($category, $args, $context, ResolveInfo $info) { - $this->path[] = $info->path; + $this->paths[] = $info->path; return Utils::filter( $this->storyDataSource, @@ -161,10 +163,10 @@ class DeferredFieldsTest extends TestCase 'topStory' => [ 'type' => $this->storyType, 'resolve' => function ($category, $args, $context, ResolveInfo $info) { - $this->path[] = $info->path; + $this->paths[] = $info->path; return new Deferred(function () use ($category) { - $this->path[] = 'deferred-for-category-' . $category['id'] . '-topStory'; + $this->paths[] = 'deferred-for-category-' . $category['id'] . '-topStory'; return Utils::find( $this->storyDataSource, @@ -184,7 +186,7 @@ class DeferredFieldsTest extends TestCase 'topStories' => [ 'type' => Type::listOf($this->storyType), 'resolve' => function ($val, $args, $context, ResolveInfo $info) { - $this->path[] = $info->path; + $this->paths[] = $info->path; return Utils::filter( $this->storyDataSource, @@ -197,7 +199,7 @@ class DeferredFieldsTest extends TestCase 'featuredCategory' => [ 'type' => $this->categoryType, 'resolve' => function ($val, $args, $context, ResolveInfo $info) { - $this->path[] = $info->path; + $this->paths[] = $info->path; return $this->categoryDataSource[0]; }, @@ -205,7 +207,7 @@ class DeferredFieldsTest extends TestCase 'categories' => [ 'type' => Type::listOf($this->categoryType), 'resolve' => function ($val, $args, $context, ResolveInfo $info) { - $this->path[] = $info->path; + $this->paths[] = $info->path; return $this->categoryDataSource; }, @@ -264,7 +266,7 @@ class DeferredFieldsTest extends TestCase $result = Executor::execute($schema, $query); self::assertEquals($expected, $result->toArray()); - $expectedPath = [ + $expectedPaths = [ ['topStories'], ['topStories', 0, 'title'], ['topStories', 0, 'author'], @@ -305,7 +307,10 @@ class DeferredFieldsTest extends TestCase ['featuredCategory', 'stories', 2, 'author', 'name'], ['featuredCategory', 'stories', 3, 'author', 'name'], ]; - self::assertEquals($expectedPath, $this->path); + self::assertCount(count($expectedPaths), $this->paths); + foreach ($expectedPaths as $expectedPath) { + self::assertTrue(in_array($expectedPath, $this->paths, true), 'Missing path: ' . json_encode($expectedPath)); + } } public function testNestedDeferredFields() : void @@ -349,7 +354,7 @@ class DeferredFieldsTest extends TestCase $result = Executor::execute($schema, $query); self::assertEquals($expected, $result->toArray()); - $expectedPath = [ + $expectedPaths = [ ['categories'], ['categories', 0, 'name'], ['categories', 0, 'topStory'], @@ -382,7 +387,10 @@ class DeferredFieldsTest extends TestCase ['categories', 1, 'topStory', 'author', 'bestFriend', 'name'], ['categories', 2, 'topStory', 'author', 'bestFriend', 'name'], ]; - self::assertEquals($expectedPath, $this->path); + self::assertCount(count($expectedPaths), $this->paths); + foreach ($expectedPaths as $expectedPath) { + self::assertTrue(in_array($expectedPath, $this->paths, true), 'Missing path: ' . json_encode($expectedPath)); + } } public function testComplexRecursiveDeferredFields() : void @@ -394,7 +402,7 @@ class DeferredFieldsTest extends TestCase 'sync' => [ 'type' => Type::string(), 'resolve' => function ($v, $a, $c, ResolveInfo $info) { - $this->path[] = $info->path; + $this->paths[] = $info->path; return 'sync'; }, @@ -402,10 +410,10 @@ class DeferredFieldsTest extends TestCase 'deferred' => [ 'type' => Type::string(), 'resolve' => function ($v, $a, $c, ResolveInfo $info) { - $this->path[] = $info->path; + $this->paths[] = $info->path; return new Deferred(function () use ($info) { - $this->path[] = ['!dfd for: ', $info->path]; + $this->paths[] = ['!dfd for: ', $info->path]; return 'deferred'; }); @@ -414,7 +422,7 @@ class DeferredFieldsTest extends TestCase 'nest' => [ 'type' => $complexType, 'resolve' => function ($v, $a, $c, ResolveInfo $info) { - $this->path[] = $info->path; + $this->paths[] = $info->path; return []; }, @@ -422,10 +430,10 @@ class DeferredFieldsTest extends TestCase 'deferredNest' => [ 'type' => $complexType, 'resolve' => function ($v, $a, $c, ResolveInfo $info) { - $this->path[] = $info->path; + $this->paths[] = $info->path; return new Deferred(function () use ($info) { - $this->path[] = ['!dfd nest for: ', $info->path]; + $this->paths[] = ['!dfd nest for: ', $info->path]; return []; }); @@ -497,7 +505,7 @@ class DeferredFieldsTest extends TestCase self::assertEquals($expected, $result->toArray()); - $expectedPath = [ + $expectedPaths = [ ['nest'], ['nest', 'sync'], ['nest', 'deferred'], @@ -531,6 +539,9 @@ class DeferredFieldsTest extends TestCase ['!dfd for: ', ['deferredNest', 'deferredNest', 'deferred']], ]; - self::assertEquals($expectedPath, $this->path); + self::assertCount(count($expectedPaths), $this->paths); + foreach ($expectedPaths as $expectedPath) { + self::assertTrue(in_array($expectedPath, $this->paths, true), 'Missing path: ' . json_encode($expectedPath)); + } } } diff --git a/tests/Executor/ExecutorTest.php b/tests/Executor/ExecutorTest.php index 4a4bbb1..784eccf 100644 --- a/tests/Executor/ExecutorTest.php +++ b/tests/Executor/ExecutorTest.php @@ -866,7 +866,7 @@ class ExecutorTest extends TestCase ...Frag } - fragment Frag on DataType { + fragment Frag on Type { a, ...Frag } diff --git a/tests/Executor/NonNullTest.php b/tests/Executor/NonNullTest.php index 8601cc5..86c7084 100644 --- a/tests/Executor/NonNullTest.php +++ b/tests/Executor/NonNullTest.php @@ -14,7 +14,10 @@ use GraphQL\Language\SourceLocation; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; use GraphQL\Type\Schema; +use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\TestCase; +use function count; +use function json_encode; class NonNullTest extends TestCase { @@ -370,10 +373,24 @@ class NonNullTest extends TestCase ], ]; - self::assertArraySubset( - $expected, - Executor::execute($this->schema, $ast, $this->throwingData, null, [], 'Q')->toArray() - ); + $result = Executor::execute($this->schema, $ast, $this->throwingData, null, [], 'Q')->toArray(); + + self::assertEquals($expected['data'], $result['data']); + + self::assertCount(count($expected['errors']), $result['errors']); + foreach ($expected['errors'] as $expectedError) { + $found = false; + foreach ($result['errors'] as $error) { + try { + self::assertArraySubset($expectedError, $error); + $found = true; + break; + } catch (ExpectationFailedException $e) { + continue; + } + } + self::assertTrue($found, 'Did not find error: ' . json_encode($expectedError)); + } } public function testNullsTheFirstNullableObjectAfterAFieldThrowsInALongChainOfFieldsThatAreNonNull() : void diff --git a/tests/Executor/ValuesTest.php b/tests/Executor/ValuesTest.php index f972960..6b158ae 100644 --- a/tests/Executor/ValuesTest.php +++ b/tests/Executor/ValuesTest.php @@ -25,7 +25,7 @@ class ValuesTest extends TestCase { $this->expectInputVariablesMatchOutputVariables(['idInput' => '123456789']); self::assertEquals( - ['errors' => [], 'coerced' => ['idInput' => '123456789']], + [null, ['idInput' => '123456789']], $this->runTestCase(['idInput' => 123456789]), 'Integer ID was not converted to string' ); @@ -35,7 +35,7 @@ class ValuesTest extends TestCase { self::assertEquals( $variables, - $this->runTestCase($variables)['coerced'], + $this->runTestCase($variables)[1], 'Output variables did not match input variables' . "\n" . var_export($variables, true) . "\n" ); } @@ -148,7 +148,8 @@ class ValuesTest extends TestCase private function expectGraphQLError($variables) : void { $result = $this->runTestCase($variables); - self::assertGreaterThan(0, count($result['errors'])); + self::assertNotNull($result[0]); + self::assertGreaterThan(0, count($result[0])); } public function testFloatForIDVariableThrowsError() : void diff --git a/tests/Experimental/Executor/CollectorTest.php b/tests/Experimental/Executor/CollectorTest.php new file mode 100644 index 0000000..c078cab --- /dev/null +++ b/tests/Experimental/Executor/CollectorTest.php @@ -0,0 +1,380 @@ +variableValues = $variableValues; + } + + public function evaluate(ValueNode $valueNode, InputType $type) + { + return AST::valueFromAST($valueNode, $type, $this->variableValues); + } + + public function addError($error) + { + $this->errors[] = $error; + } + }; + + $collector = new Collector($schema, $runtime); + $collector->initialize($documentNode, $operationName); + + $pipeline = []; + foreach ($collector->collectFields($collector->rootType, $collector->operation->selectionSet) as $shared) { + $execution = new stdClass(); + if (! empty($shared->fieldNodes)) { + $execution->fieldNodes = array_map(static function (Node $node) { + return $node->toArray(true); + }, $shared->fieldNodes); + } + if (! empty($shared->fieldName)) { + $execution->fieldName = $shared->fieldName; + } + if (! empty($shared->resultName)) { + $execution->resultName = $shared->resultName; + } + if (! empty($shared->argumentValueMap)) { + $execution->argumentValueMap = []; + foreach ($shared->argumentValueMap as $argumentName => $valueNode) { + /** @var Node $valueNode */ + $execution->argumentValueMap[$argumentName] = $valueNode->toArray(true); + } + } + + $pipeline[] = $execution; + } + if (strncmp($operationName, 'ShouldEmitError', strlen('ShouldEmitError')) === 0) { + self::assertNotEmpty($runtime->errors, 'There should be errors.'); + } else { + self::assertEmpty($runtime->errors, 'There must be no errors. Got: ' . json_encode($runtime->errors, JSON_PRETTY_PRINT)); + + if (strncmp($operationName, 'ShouldNotEmit', strlen('ShouldNotEmit')) === 0) { + self::assertEmpty($pipeline, 'No instructions should be emitted.'); + } else { + self::assertNotEmpty($pipeline, 'There should be some instructions emitted.'); + } + } + + $result = []; + if (! empty($runtime->errors)) { + $result['errors'] = array_map( + FormattedError::prepareFormatter(null, false), + $runtime->errors + ); + } + if (! empty($pipeline)) { + $result['pipeline'] = $pipeline; + } + + $json = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + + $fileName = __DIR__ . DIRECTORY_SEPARATOR . basename(__FILE__, '.php') . 'Snapshots' . DIRECTORY_SEPARATOR . $operationName . '.json'; + if (! file_exists($fileName)) { + file_put_contents($fileName, $json); + } + + self::assertStringEqualsFile($fileName, $json); + } + + public function provideForTestCollectFields() + { + $testCases = [ + [ + StarWarsSchema::build(), + 'query ShouldEmitFieldWithoutArguments { + human { + name + } + }', + null, + ], + [ + StarWarsSchema::build(), + 'query ShouldEmitFieldThatHasArguments($id: ID!) { + human(id: $id) { + name + } + }', + null, + ], + [ + StarWarsSchema::build(), + 'query ShouldEmitForInlineFragment($id: ID!) { + ...HumanById + } + fragment HumanById on Query { + human(id: $id) { + ... on Human { + name + } + } + }', + null, + ], + [ + StarWarsSchema::build(), + 'query ShouldEmitObjectFieldForFragmentSpread($id: ID!) { + human(id: $id) { + ...HumanName + } + } + fragment HumanName on Human { + name + }', + null, + ], + [ + StarWarsSchema::build(), + 'query ShouldEmitTypeName { + queryTypeName: __typename + __typename + }', + null, + ], + [ + StarWarsSchema::build(), + 'query ShouldEmitIfIncludeConditionTrue($id: ID!, $condition: Boolean!) { + droid(id: $id) @include(if: $condition) { + id + } + }', + ['condition' => true], + ], + [ + StarWarsSchema::build(), + 'query ShouldNotEmitIfIncludeConditionFalse($id: ID!, $condition: Boolean!) { + droid(id: $id) @include(if: $condition) { + id + } + }', + ['condition' => false], + ], + [ + StarWarsSchema::build(), + 'query ShouldNotEmitIfSkipConditionTrue($id: ID!, $condition: Boolean!) { + droid(id: $id) @skip(if: $condition) { + id + } + }', + ['condition' => true], + ], + [ + StarWarsSchema::build(), + 'query ShouldEmitIfSkipConditionFalse($id: ID!, $condition: Boolean!) { + droid(id: $id) @skip(if: $condition) { + id + } + }', + ['condition' => false], + ], + [ + StarWarsSchema::build(), + 'query ShouldNotEmitIncludeSkipTT($id: ID!, $includeCondition: Boolean!, $skipCondition: Boolean!) { + droid(id: $id) @include(if: $includeCondition) @skip(if: $skipCondition) { + id + } + }', + ['includeCondition' => true, 'skipCondition' => true], + ], + [ + StarWarsSchema::build(), + 'query ShouldEmitIncludeSkipTF($id: ID!, $includeCondition: Boolean!, $skipCondition: Boolean!) { + droid(id: $id) @include(if: $includeCondition) @skip(if: $skipCondition) { + id + } + }', + ['includeCondition' => true, 'skipCondition' => false], + ], + [ + StarWarsSchema::build(), + 'query ShouldNotEmitIncludeSkipFT($id: ID!, $includeCondition: Boolean!, $skipCondition: Boolean!) { + droid(id: $id) @include(if: $includeCondition) @skip(if: $skipCondition) { + id + } + }', + ['includeCondition' => false, 'skipCondition' => true], + ], + [ + StarWarsSchema::build(), + 'query ShouldNotEmitIncludeSkipFF($id: ID!, $includeCondition: Boolean!, $skipCondition: Boolean!) { + droid(id: $id) @include(if: $includeCondition) @skip(if: $skipCondition) { + id + } + }', + ['includeCondition' => false, 'skipCondition' => false], + ], + [ + StarWarsSchema::build(), + 'query ShouldNotEmitSkipAroundInlineFragment { + ... on Query @skip(if: true) { + hero(episode: 5) { + name + } + } + }', + null, + ], + [ + StarWarsSchema::build(), + 'query ShouldEmitSkipAroundInlineFragment { + ... on Query @skip(if: false) { + hero(episode: 5) { + name + } + } + }', + null, + ], + [ + StarWarsSchema::build(), + 'query ShouldEmitIncludeAroundInlineFragment { + ... on Query @include(if: true) { + hero(episode: 5) { + name + } + } + }', + null, + ], + [ + StarWarsSchema::build(), + 'query ShouldNotEmitIncludeAroundInlineFragment { + ... on Query @include(if: false) { + hero(episode: 5) { + name + } + } + }', + null, + ], + [ + StarWarsSchema::build(), + 'query ShouldNotEmitSkipFragmentSpread { + ...Hero @skip(if: true) + } + fragment Hero on Query { + hero(episode: 5) { + name + } + }', + null, + ], + [ + StarWarsSchema::build(), + 'query ShouldEmitSkipFragmentSpread { + ...Hero @skip(if: false) + } + fragment Hero on Query { + hero(episode: 5) { + name + } + }', + null, + ], + [ + StarWarsSchema::build(), + 'query ShouldEmitIncludeFragmentSpread { + ...Hero @include(if: true) + } + fragment Hero on Query { + hero(episode: 5) { + name + } + }', + null, + ], + [ + StarWarsSchema::build(), + 'query ShouldNotEmitIncludeFragmentSpread { + ...Hero @include(if: false) + } + fragment Hero on Query { + hero(episode: 5) { + name + } + }', + null, + ], + [ + StarWarsSchema::build(), + 'query ShouldEmitSingleInstrictionForSameResultName($id: ID!) { + human(id: $id) { + name + name: secretBackstory + } + }', + null, + ], + ]; + + $data = []; + foreach ($testCases as [$schema, $query, $variableValues]) { + $documentNode = Parser::parse($query, ['noLocation' => true]); + $operationName = null; + foreach ($documentNode->definitions as $definitionNode) { + /** @var Node $definitionNode */ + if ($definitionNode->kind === NodeKind::OPERATION_DEFINITION) { + /** @var OperationDefinitionNode $definitionNode */ + self::assertNotNull($definitionNode->name); + $operationName = $definitionNode->name->value; + break; + } + } + + self::assertArrayNotHasKey($operationName, $data); + + $data[$operationName] = [$schema, $documentNode, $operationName, $variableValues]; + } + + return $data; + } +} diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitFieldThatHasArguments.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitFieldThatHasArguments.json new file mode 100644 index 0000000..c958b63 --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitFieldThatHasArguments.json @@ -0,0 +1,57 @@ +{ + "pipeline": [ + { + "fieldNodes": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "human" + }, + "arguments": [ + { + "kind": "Argument", + "value": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "id" + } + }, + "name": { + "kind": "Name", + "value": "id" + } + } + ], + "directives": [], + "selectionSet": { + "kind": "SelectionSet", + "selections": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "name" + }, + "arguments": [], + "directives": [] + } + ] + } + } + ], + "fieldName": "human", + "resultName": "human", + "argumentValueMap": { + "id": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "id" + } + } + } + } + ] +} diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitFieldWithoutArguments.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitFieldWithoutArguments.json new file mode 100644 index 0000000..56f3fba --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitFieldWithoutArguments.json @@ -0,0 +1,33 @@ +{ + "pipeline": [ + { + "fieldNodes": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "human" + }, + "arguments": [], + "directives": [], + "selectionSet": { + "kind": "SelectionSet", + "selections": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "name" + }, + "arguments": [], + "directives": [] + } + ] + } + } + ], + "fieldName": "human", + "resultName": "human" + } + ] +} diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitForInlineFragment.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitForInlineFragment.json new file mode 100644 index 0000000..c33c446 --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitForInlineFragment.json @@ -0,0 +1,73 @@ +{ + "pipeline": [ + { + "fieldNodes": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "human" + }, + "arguments": [ + { + "kind": "Argument", + "value": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "id" + } + }, + "name": { + "kind": "Name", + "value": "id" + } + } + ], + "directives": [], + "selectionSet": { + "kind": "SelectionSet", + "selections": [ + { + "kind": "InlineFragment", + "typeCondition": { + "kind": "NamedType", + "name": { + "kind": "Name", + "value": "Human" + } + }, + "directives": [], + "selectionSet": { + "kind": "SelectionSet", + "selections": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "name" + }, + "arguments": [], + "directives": [] + } + ] + } + } + ] + } + } + ], + "fieldName": "human", + "resultName": "human", + "argumentValueMap": { + "id": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "id" + } + } + } + } + ] +} diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitIfIncludeConditionTrue.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitIfIncludeConditionTrue.json new file mode 100644 index 0000000..63d8ed0 --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitIfIncludeConditionTrue.json @@ -0,0 +1,81 @@ +{ + "pipeline": [ + { + "fieldNodes": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "droid" + }, + "arguments": [ + { + "kind": "Argument", + "value": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "id" + } + }, + "name": { + "kind": "Name", + "value": "id" + } + } + ], + "directives": [ + { + "kind": "Directive", + "name": { + "kind": "Name", + "value": "include" + }, + "arguments": [ + { + "kind": "Argument", + "value": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "condition" + } + }, + "name": { + "kind": "Name", + "value": "if" + } + } + ] + } + ], + "selectionSet": { + "kind": "SelectionSet", + "selections": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "id" + }, + "arguments": [], + "directives": [] + } + ] + } + } + ], + "fieldName": "droid", + "resultName": "droid", + "argumentValueMap": { + "id": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "id" + } + } + } + } + ] +} diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitIfSkipConditionFalse.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitIfSkipConditionFalse.json new file mode 100644 index 0000000..1664b63 --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitIfSkipConditionFalse.json @@ -0,0 +1,81 @@ +{ + "pipeline": [ + { + "fieldNodes": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "droid" + }, + "arguments": [ + { + "kind": "Argument", + "value": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "id" + } + }, + "name": { + "kind": "Name", + "value": "id" + } + } + ], + "directives": [ + { + "kind": "Directive", + "name": { + "kind": "Name", + "value": "skip" + }, + "arguments": [ + { + "kind": "Argument", + "value": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "condition" + } + }, + "name": { + "kind": "Name", + "value": "if" + } + } + ] + } + ], + "selectionSet": { + "kind": "SelectionSet", + "selections": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "id" + }, + "arguments": [], + "directives": [] + } + ] + } + } + ], + "fieldName": "droid", + "resultName": "droid", + "argumentValueMap": { + "id": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "id" + } + } + } + } + ] +} diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitIncludeAroundInlineFragment.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitIncludeAroundInlineFragment.json new file mode 100644 index 0000000..8a54286 --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitIncludeAroundInlineFragment.json @@ -0,0 +1,51 @@ +{ + "pipeline": [ + { + "fieldNodes": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "hero" + }, + "arguments": [ + { + "kind": "Argument", + "value": { + "kind": "IntValue", + "value": "5" + }, + "name": { + "kind": "Name", + "value": "episode" + } + } + ], + "directives": [], + "selectionSet": { + "kind": "SelectionSet", + "selections": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "name" + }, + "arguments": [], + "directives": [] + } + ] + } + } + ], + "fieldName": "hero", + "resultName": "hero", + "argumentValueMap": { + "episode": { + "kind": "IntValue", + "value": "5" + } + } + } + ] +} diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitIncludeFragmentSpread.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitIncludeFragmentSpread.json new file mode 100644 index 0000000..8a54286 --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitIncludeFragmentSpread.json @@ -0,0 +1,51 @@ +{ + "pipeline": [ + { + "fieldNodes": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "hero" + }, + "arguments": [ + { + "kind": "Argument", + "value": { + "kind": "IntValue", + "value": "5" + }, + "name": { + "kind": "Name", + "value": "episode" + } + } + ], + "directives": [], + "selectionSet": { + "kind": "SelectionSet", + "selections": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "name" + }, + "arguments": [], + "directives": [] + } + ] + } + } + ], + "fieldName": "hero", + "resultName": "hero", + "argumentValueMap": { + "episode": { + "kind": "IntValue", + "value": "5" + } + } + } + ] +} diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitIncludeSkipTF.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitIncludeSkipTF.json new file mode 100644 index 0000000..bfc5e2d --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitIncludeSkipTF.json @@ -0,0 +1,104 @@ +{ + "pipeline": [ + { + "fieldNodes": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "droid" + }, + "arguments": [ + { + "kind": "Argument", + "value": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "id" + } + }, + "name": { + "kind": "Name", + "value": "id" + } + } + ], + "directives": [ + { + "kind": "Directive", + "name": { + "kind": "Name", + "value": "include" + }, + "arguments": [ + { + "kind": "Argument", + "value": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "includeCondition" + } + }, + "name": { + "kind": "Name", + "value": "if" + } + } + ] + }, + { + "kind": "Directive", + "name": { + "kind": "Name", + "value": "skip" + }, + "arguments": [ + { + "kind": "Argument", + "value": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "skipCondition" + } + }, + "name": { + "kind": "Name", + "value": "if" + } + } + ] + } + ], + "selectionSet": { + "kind": "SelectionSet", + "selections": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "id" + }, + "arguments": [], + "directives": [] + } + ] + } + } + ], + "fieldName": "droid", + "resultName": "droid", + "argumentValueMap": { + "id": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "id" + } + } + } + } + ] +} diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitObjectFieldForFragmentSpread.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitObjectFieldForFragmentSpread.json new file mode 100644 index 0000000..a0572c8 --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitObjectFieldForFragmentSpread.json @@ -0,0 +1,56 @@ +{ + "pipeline": [ + { + "fieldNodes": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "human" + }, + "arguments": [ + { + "kind": "Argument", + "value": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "id" + } + }, + "name": { + "kind": "Name", + "value": "id" + } + } + ], + "directives": [], + "selectionSet": { + "kind": "SelectionSet", + "selections": [ + { + "kind": "FragmentSpread", + "name": { + "kind": "Name", + "value": "HumanName" + }, + "directives": [] + } + ] + } + } + ], + "fieldName": "human", + "resultName": "human", + "argumentValueMap": { + "id": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "id" + } + } + } + } + ] +} diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitSingleInstrictionForSameResultName.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitSingleInstrictionForSameResultName.json new file mode 100644 index 0000000..8be2b1f --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitSingleInstrictionForSameResultName.json @@ -0,0 +1,70 @@ +{ + "pipeline": [ + { + "fieldNodes": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "human" + }, + "arguments": [ + { + "kind": "Argument", + "value": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "id" + } + }, + "name": { + "kind": "Name", + "value": "id" + } + } + ], + "directives": [], + "selectionSet": { + "kind": "SelectionSet", + "selections": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "name" + }, + "arguments": [], + "directives": [] + }, + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "secretBackstory" + }, + "alias": { + "kind": "Name", + "value": "name" + }, + "arguments": [], + "directives": [] + } + ] + } + } + ], + "fieldName": "human", + "resultName": "human", + "argumentValueMap": { + "id": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "id" + } + } + } + } + ] +} diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitSkipAroundInlineFragment.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitSkipAroundInlineFragment.json new file mode 100644 index 0000000..8a54286 --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitSkipAroundInlineFragment.json @@ -0,0 +1,51 @@ +{ + "pipeline": [ + { + "fieldNodes": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "hero" + }, + "arguments": [ + { + "kind": "Argument", + "value": { + "kind": "IntValue", + "value": "5" + }, + "name": { + "kind": "Name", + "value": "episode" + } + } + ], + "directives": [], + "selectionSet": { + "kind": "SelectionSet", + "selections": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "name" + }, + "arguments": [], + "directives": [] + } + ] + } + } + ], + "fieldName": "hero", + "resultName": "hero", + "argumentValueMap": { + "episode": { + "kind": "IntValue", + "value": "5" + } + } + } + ] +} diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitSkipFragmentSpread.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitSkipFragmentSpread.json new file mode 100644 index 0000000..8a54286 --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitSkipFragmentSpread.json @@ -0,0 +1,51 @@ +{ + "pipeline": [ + { + "fieldNodes": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "hero" + }, + "arguments": [ + { + "kind": "Argument", + "value": { + "kind": "IntValue", + "value": "5" + }, + "name": { + "kind": "Name", + "value": "episode" + } + } + ], + "directives": [], + "selectionSet": { + "kind": "SelectionSet", + "selections": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "name" + }, + "arguments": [], + "directives": [] + } + ] + } + } + ], + "fieldName": "hero", + "resultName": "hero", + "argumentValueMap": { + "episode": { + "kind": "IntValue", + "value": "5" + } + } + } + ] +} diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitTypeName.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitTypeName.json new file mode 100644 index 0000000..f90461e --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldEmitTypeName.json @@ -0,0 +1,38 @@ +{ + "pipeline": [ + { + "fieldNodes": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "__typename" + }, + "alias": { + "kind": "Name", + "value": "queryTypeName" + }, + "arguments": [], + "directives": [] + } + ], + "fieldName": "__typename", + "resultName": "queryTypeName" + }, + { + "fieldNodes": [ + { + "kind": "Field", + "name": { + "kind": "Name", + "value": "__typename" + }, + "arguments": [], + "directives": [] + } + ], + "fieldName": "__typename", + "resultName": "__typename" + } + ] +} diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIfIncludeConditionFalse.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIfIncludeConditionFalse.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIfIncludeConditionFalse.json @@ -0,0 +1 @@ +[] diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIfSkipConditionTrue.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIfSkipConditionTrue.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIfSkipConditionTrue.json @@ -0,0 +1 @@ +[] diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIncludeAroundInlineFragment.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIncludeAroundInlineFragment.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIncludeAroundInlineFragment.json @@ -0,0 +1 @@ +[] diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIncludeFragmentSpread.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIncludeFragmentSpread.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIncludeFragmentSpread.json @@ -0,0 +1 @@ +[] diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIncludeSkipFF.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIncludeSkipFF.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIncludeSkipFF.json @@ -0,0 +1 @@ +[] diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIncludeSkipFT.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIncludeSkipFT.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIncludeSkipFT.json @@ -0,0 +1 @@ +[] diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIncludeSkipTT.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIncludeSkipTT.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitIncludeSkipTT.json @@ -0,0 +1 @@ +[] diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitSkipAroundInlineFragment.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitSkipAroundInlineFragment.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitSkipAroundInlineFragment.json @@ -0,0 +1 @@ +[] diff --git a/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitSkipFragmentSpread.json b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitSkipFragmentSpread.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/Experimental/Executor/CollectorTestSnapshots/ShouldNotEmitSkipFragmentSpread.json @@ -0,0 +1 @@ +[] diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..1e5c871 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,15 @@ +