diff --git a/src/Executor/ExecutionContext.php b/src/Executor/ExecutionContext.php index f31a444..3de1a2e 100644 --- a/src/Executor/ExecutionContext.php +++ b/src/Executor/ExecutionContext.php @@ -1,7 +1,11 @@ schema = $schema; - $this->fragments = $fragments; - $this->rootValue = $root; - $this->contextValue = $contextValue; - $this->operation = $operation; + ) { + $this->schema = $schema; + $this->fragments = $fragments; + $this->rootValue = $root; + $this->contextValue = $contextValue; + $this->operation = $operation; $this->variableValues = $variables; - $this->errors = $errors ?: []; - $this->fieldResolver = $fieldResolver; - $this->promises = $promiseAdapter; + $this->errors = $errors ?: []; + $this->fieldResolver = $fieldResolver; + $this->promises = $promiseAdapter; } public function addError(Error $error) { $this->errors[] = $error; + return $this; } } diff --git a/src/Executor/ExecutionResult.php b/src/Executor/ExecutionResult.php index d998b96..043ddc8 100644 --- a/src/Executor/ExecutionResult.php +++ b/src/Executor/ExecutionResult.php @@ -1,7 +1,12 @@ data = $data; - $this->errors = $errors; + $this->data = $data; + $this->errors = $errors; $this->extensions = $extensions; } @@ -77,12 +78,12 @@ class ExecutionResult implements \JsonSerializable * ); * * @api - * @param callable $errorFormatter * @return $this */ public function setErrorFormatter(callable $errorFormatter) { $this->errorFormatter = $errorFormatter; + return $this; } @@ -97,15 +98,23 @@ class ExecutionResult implements \JsonSerializable * } * * @api - * @param callable $handler * @return $this */ public function setErrorsHandler(callable $handler) { $this->errorsHandler = $handler; + return $this; } + /** + * @return mixed[] + */ + public function jsonSerialize() + { + return $this->toArray(); + } + /** * Converts GraphQL query result to spec-compliant serializable array using provided * errors handler and formatter. @@ -118,40 +127,31 @@ class ExecutionResult implements \JsonSerializable * * @api * @param bool|int $debug - * @return array + * @return mixed[] */ public function toArray($debug = false) { $result = []; - if (!empty($this->errors)) { - $errorsHandler = $this->errorsHandler ?: function(array $errors, callable $formatter) { + if (! empty($this->errors)) { + $errorsHandler = $this->errorsHandler ?: function (array $errors, callable $formatter) { return array_map($formatter, $errors); }; + $result['errors'] = $errorsHandler( $this->errors, FormattedError::prepareFormatter($this->errorFormatter, $debug) ); } - if (null !== $this->data) { + if ($this->data !== null) { $result['data'] = $this->data; } - if (!empty($this->extensions)) { + if (! empty($this->extensions)) { $result['extensions'] = (array) $this->extensions; } return $result; } - - /** - * Part of \JsonSerializable interface - * - * @return array - */ - public function jsonSerialize() - { - return $this->toArray(); - } } diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index 97d3ae3..0517746 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -1,11 +1,16 @@ exeContext = $context; } /** * Custom default resolve function * - * @param $fn * @throws \Exception */ public static function setDefaultFieldResolver(callable $fn) @@ -78,13 +85,10 @@ class Executor * execution are collected in `$result->errors`. * * @api - * @param Schema $schema - * @param DocumentNode $ast - * @param $rootValue - * @param $contextValue - * @param array|\ArrayAccess $variableValues - * @param null $operationName - * @param callable $fieldResolver + * @param mixed|null $rootValue + * @param mixed[]|null $contextValue + * @param mixed[]|\ArrayAccess|null $variableValues + * @param string|null $operationName * * @return ExecutionResult|Promise */ @@ -95,12 +99,11 @@ class Executor $contextValue = null, $variableValues = null, $operationName = null, - callable $fieldResolver = null - ) - { + ?callable $fieldResolver = null + ) { // TODO: deprecate (just always use SyncAdapter here) and have `promiseToExecute()` for other cases $promiseAdapter = self::getPromiseAdapter(); - $result = self::promiseToExecute( + $result = self::promiseToExecute( $promiseAdapter, $schema, $ast, @@ -119,6 +122,19 @@ 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. @@ -126,14 +142,10 @@ class Executor * Useful for async PHP platforms. * * @api - * @param PromiseAdapter $promiseAdapter - * @param Schema $schema - * @param DocumentNode $ast - * @param null $rootValue - * @param null $contextValue - * @param null $variableValues - * @param null $operationName - * @param callable|null $fieldResolver + * @param mixed[]|null $rootValue + * @param mixed[]|null $contextValue + * @param mixed[]|null $variableValues + * @param string|null $operationName * @return Promise */ public static function promiseToExecute( @@ -144,9 +156,8 @@ class Executor $contextValue = null, $variableValues = null, $operationName = null, - callable $fieldResolver = null - ) - { + ?callable $fieldResolver = null + ) { $exeContext = self::buildExecutionContext( $schema, $ast, @@ -163,6 +174,7 @@ class Executor } $executor = new self($exeContext); + return $executor->doExecute(); } @@ -170,14 +182,10 @@ class Executor * Constructs an ExecutionContext object from the arguments passed to * execute, which we will pass throughout the other execution methods. * - * @param Schema $schema - * @param DocumentNode $documentNode - * @param $rootValue - * @param $contextValue - * @param array|\Traversable $rawVariableValues - * @param string $operationName - * @param callable $fieldResolver - * @param PromiseAdapter $promiseAdapter + * @param mixed[] $rootValue + * @param mixed[] $contextValue + * @param mixed[]|\Traversable $rawVariableValues + * @param string|null $operationName * * @return ExecutionContext|Error[] */ @@ -188,23 +196,22 @@ class Executor $contextValue, $rawVariableValues, $operationName = null, - callable $fieldResolver = null, - PromiseAdapter $promiseAdapter = null - ) - { - $errors = []; + ?callable $fieldResolver = null, + ?PromiseAdapter $promiseAdapter = null + ) { + $errors = []; $fragments = []; /** @var OperationDefinitionNode $operation */ - $operation = null; + $operation = null; $hasMultipleAssumedOperations = false; foreach ($documentNode->definitions as $definition) { switch ($definition->kind) { case NodeKind::OPERATION_DEFINITION: - if (!$operationName && $operation) { + if (! $operationName && $operation) { $hasMultipleAssumedOperations = true; } - if (!$operationName || + if (! $operationName || (isset($definition->name) && $definition->name->value === $operationName)) { $operation = $definition; } @@ -215,17 +222,16 @@ class Executor } } - if (!$operation) { + if (! $operation) { if ($operationName) { - $errors[] = new Error("Unknown operation named \"$operationName\"."); + $errors[] = new Error(sprintf('Unknown operation named "%s".', $operationName)); } else { $errors[] = new Error('Must provide an operation.'); } - } else if ($hasMultipleAssumedOperations) { + } elseif ($hasMultipleAssumedOperations) { $errors[] = new Error( 'Must provide operation name if query contains multiple operations.' ); - } $variableValues = null; @@ -263,30 +269,6 @@ class Executor ); } - /** - * @var ExecutionContext - */ - private $exeContext; - - /** - * @var PromiseAdapter - */ - private $promises; - - /** - * Executor constructor. - * - * @param ExecutionContext $context - */ - private function __construct(ExecutionContext $context) - { - if (!self::$UNDEFINED) { - self::$UNDEFINED = Utils::undefined(); - } - - $this->exeContext = $context; - } - /** * @return Promise */ @@ -302,18 +284,24 @@ class Executor $result = $this->exeContext->promises->create(function (callable $resolve) { return $resolve($this->executeOperation($this->exeContext->operation, $this->exeContext->rootValue)); }); + return $result - ->then(null, function ($error) { - // 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. - $this->exeContext->addError($error); - return null; - }) + ->then( + null, + function ($error) { + // 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. + $this->exeContext->addError($error); + + return null; + } + ) ->then(function ($data) { - if ($data !== null){ + if ($data !== null) { $data = (array) $data; } + return new ExecutionResult($data, $this->exeContext->errors); }); } @@ -321,13 +309,12 @@ class Executor /** * Implements the "Evaluating operations" section of the spec. * - * @param OperationDefinitionNode $operation - * @param $rootValue - * @return Promise|\stdClass|array + * @param mixed[] $rootValue + * @return Promise|\stdClass|mixed[] */ private function executeOperation(OperationDefinitionNode $operation, $rootValue) { - $type = $this->getOperationRootType($this->exeContext->schema, $operation); + $type = $this->getOperationRootType($this->exeContext->schema, $operation); $fields = $this->collectFields($type, $operation->selectionSet, new \ArrayObject(), new \ArrayObject()); $path = []; @@ -344,15 +331,20 @@ class Executor $promise = $this->getPromise($result); if ($promise) { - return $promise->then(null, function($error) { - $this->exeContext->addError($error); - return null; - }); - } - return $result; + return $promise->then( + null, + function ($error) { + $this->exeContext->addError($error); + return null; + } + ); + } + + return $result; } catch (Error $error) { $this->exeContext->addError($error); + return null; } } @@ -360,8 +352,6 @@ class Executor /** * Extracts the root type of the operation from the schema. * - * @param Schema $schema - * @param OperationDefinitionNode $operation * @return ObjectType * @throws Error */ @@ -370,30 +360,33 @@ class Executor switch ($operation->operation) { case 'query': $queryType = $schema->getQueryType(); - if (!$queryType) { + if (! $queryType) { throw new Error( 'Schema does not define the required query root type.', [$operation] ); } + return $queryType; case 'mutation': $mutationType = $schema->getMutationType(); - if (!$mutationType) { + if (! $mutationType) { throw new Error( 'Schema is not configured for mutations.', [$operation] ); } + return $mutationType; case 'subscription': $subscriptionType = $schema->getSubscriptionType(); - if (!$subscriptionType) { + if (! $subscriptionType) { throw new Error( 'Schema is not configured for subscriptions.', - [ $operation ] + [$operation] ); } + return $subscriptionType; default: throw new Error( @@ -403,130 +396,6 @@ class Executor } } - /** - * Implements the "Evaluating selection sets" section of the spec - * for "write" mode. - * - * @param ObjectType $parentType - * @param $sourceValue - * @param $path - * @param $fields - * @return Promise|\stdClass|array - */ - private function executeFieldsSerially(ObjectType $parentType, $sourceValue, $path, $fields) - { - $prevPromise = $this->exeContext->promises->createFulfilled([]); - - $process = function ($results, $responseName, $path, $parentType, $sourceValue, $fieldNodes) { - $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(function ($resolvedResult) use ($responseName, $results) { - $results[$responseName] = $resolvedResult; - return $results; - }); - } - $results[$responseName] = $result; - return $results; - }; - - foreach ($fields as $responseName => $fieldNodes) { - $prevPromise = $prevPromise->then(function ($resolvedResults) use ($process, $responseName, $path, $parentType, $sourceValue, $fieldNodes) { - return $process($resolvedResults, $responseName, $path, $parentType, $sourceValue, $fieldNodes); - }); - } - - return $prevPromise->then(function ($resolvedResults) { - return self::fixResultsIfEmptyArray($resolvedResults); - }); - } - - /** - * Implements the "Evaluating selection sets" section of the spec - * for "read" mode. - * - * @param ObjectType $parentType - * @param $source - * @param $path - * @param $fields - * @return Promise|\stdClass|array - */ - 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); - } - - /** - * 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 array $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(function($values) use ($keys) { - $resolvedResults = []; - foreach ($values as $i => $value) { - $resolvedResults[$keys[$i]] = $value; - } - return self::fixResultsIfEmptyArray($resolvedResults); - }); - } - - /** - * @see https://github.com/webonyx/graphql-php/issues/59 - * - * @param $results - * @return \stdClass|array - */ - private static function fixResultsIfEmptyArray($results) - { - if ([] === $results) { - $results = new \stdClass(); - } - - return $results; - } - /** * Given a selectionSet, adds all of the fields in that selection to * the passed in map of fields, and returns it at the end. @@ -535,10 +404,8 @@ class Executor * returns an Interface or Union type, the "runtime type" will be the actual * Object type returned by that field. * - * @param ObjectType $runtimeType - * @param SelectionSetNode $selectionSet - * @param $fields - * @param $visitedFragmentNames + * @param ArrayObject $fields + * @param ArrayObject $visitedFragmentNames * * @return \ArrayObject */ @@ -547,26 +414,25 @@ class Executor SelectionSetNode $selectionSet, $fields, $visitedFragmentNames - ) - { + ) { $exeContext = $this->exeContext; foreach ($selectionSet->selections as $selection) { switch ($selection->kind) { case NodeKind::FIELD: - if (!$this->shouldIncludeNode($selection)) { - continue; + if (! $this->shouldIncludeNode($selection)) { + break; } $name = self::getFieldEntryKey($selection); - if (!isset($fields[$name])) { + if (! isset($fields[$name])) { $fields[$name] = new \ArrayObject(); } $fields[$name][] = $selection; break; case NodeKind::INLINE_FRAGMENT: - if (!$this->shouldIncludeNode($selection) || - !$this->doesFragmentConditionMatch($selection, $runtimeType) + if (! $this->shouldIncludeNode($selection) || + ! $this->doesFragmentConditionMatch($selection, $runtimeType) ) { - continue; + break; } $this->collectFields( $runtimeType, @@ -577,15 +443,15 @@ class Executor break; case NodeKind::FRAGMENT_SPREAD: $fragName = $selection->name->value; - if (!empty($visitedFragmentNames[$fragName]) || !$this->shouldIncludeNode($selection)) { - continue; + if (! empty($visitedFragmentNames[$fragName]) || ! $this->shouldIncludeNode($selection)) { + break; } $visitedFragmentNames[$fragName] = true; /** @var FragmentDefinitionNode|null $fragment */ - $fragment = isset($exeContext->fragments[$fragName]) ? $exeContext->fragments[$fragName] : null; - if (!$fragment || !$this->doesFragmentConditionMatch($fragment, $runtimeType)) { - continue; + $fragment = $exeContext->fragments[$fragName] ?? null; + if (! $fragment || ! $this->doesFragmentConditionMatch($fragment, $runtimeType)) { + break; } $this->collectFields( $runtimeType, @@ -596,6 +462,7 @@ class Executor break; } } + return $fields; } @@ -603,13 +470,13 @@ class Executor * 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 + * @param FragmentSpreadNode|FieldNode|InlineFragmentNode $node * @return bool */ private function shouldIncludeNode($node) { $variableValues = $this->exeContext->variableValues; - $skipDirective = Directive::skipDirective(); + $skipDirective = Directive::skipDirective(); $skip = Values::getDirectiveValues( $skipDirective, @@ -632,21 +499,33 @@ class Executor 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 $fragment - * @param ObjectType $type + * @param FragmentDefinitionNode|InlineFragmentNode $fragment * @return bool */ - private function doesFragmentConditionMatch(/* FragmentDefinitionNode | InlineFragmentNode*/ $fragment, ObjectType $type) - { + private function doesFragmentConditionMatch( + $fragment, + ObjectType $type + ) { $typeConditionNode = $fragment->typeCondition; - if (!$typeConditionNode) { + if ($typeConditionNode === null) { return true; } @@ -657,18 +536,59 @@ class Executor if ($conditionalType instanceof AbstractType) { return $this->exeContext->schema->isPossibleType($conditionalType, $type); } + return false; } /** - * Implements the logic to compute the key of a given fields entry + * Implements the "Evaluating selection sets" section of the spec + * for "write" mode. * - * @param FieldNode $node - * @return string + * @param mixed[] $sourceValue + * @param mixed[] $path + * @param ArrayObject $fields + * @return Promise|\stdClass|mixed[] */ - private static function getFieldEntryKey(FieldNode $node) + private function executeFieldsSerially(ObjectType $parentType, $sourceValue, $path, $fields) { - return $node->alias ? $node->alias->value : $node->name->value; + $prevPromise = $this->exeContext->promises->createFulfilled([]); + + $process = function ($results, $responseName, $path, $parentType, $sourceValue, $fieldNodes) { + $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(function ($resolvedResult) use ($responseName, $results) { + $results[$responseName] = $resolvedResult; + + return $results; + }); + } + $results[$responseName] = $result; + + return $results; + }; + + foreach ($fields as $responseName => $fieldNodes) { + $prevPromise = $prevPromise->then(function ($resolvedResults) use ( + $process, + $responseName, + $path, + $parentType, + $sourceValue, + $fieldNodes + ) { + return $process($resolvedResults, $responseName, $path, $parentType, $sourceValue, $fieldNodes); + }); + } + + return $prevPromise->then(function ($resolvedResults) { + return self::fixResultsIfEmptyArray($resolvedResults); + }); } /** @@ -677,22 +597,21 @@ class Executor * then calls completeValue to complete promises, serialize scalars, or execute * the sub-selection-set for objects. * - * @param ObjectType $parentType - * @param $source - * @param $fieldNodes - * @param $path + * @param object|null $source + * @param FieldNode[] $fieldNodes + * @param mixed[] $path * - * @return array|\Exception|mixed|null + * @return mixed[]|\Exception|mixed|null */ private function resolveField(ObjectType $parentType, $source, $fieldNodes, $path) { $exeContext = $this->exeContext; - $fieldNode = $fieldNodes[0]; + $fieldNode = $fieldNodes[0]; $fieldName = $fieldNode->name->value; - $fieldDef = $this->getFieldDef($exeContext->schema, $parentType, $fieldName); + $fieldDef = $this->getFieldDef($exeContext->schema, $parentType, $fieldName); - if (!$fieldDef) { + if (! $fieldDef) { return self::$UNDEFINED; } @@ -701,22 +620,21 @@ class Executor // 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, + '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 (isset($fieldDef->resolveFn)) { + if ($fieldDef->resolveFn !== null) { $resolveFn = $fieldDef->resolveFn; - } else if (isset($parentType->resolveFieldFn)) { + } elseif ($parentType->resolveFieldFn !== null) { $resolveFn = $parentType->resolveFieldFn; } else { $resolveFn = $this->exeContext->fieldResolver; @@ -749,16 +667,50 @@ class Executor 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 + * @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) @@ -784,12 +736,10 @@ class Executor * This is a small wrapper around completeValue which detects and logs errors * in the execution context. * - * @param Type $returnType - * @param $fieldNodes - * @param ResolveInfo $info - * @param $path - * @param $result - * @return array|null|Promise + * @param FieldNode[] $fieldNodes + * @param string[] $path + * @param mixed $result + * @return mixed[]|Promise|null */ private function completeValueCatchingError( Type $returnType, @@ -797,8 +747,7 @@ class Executor ResolveInfo $info, $path, $result - ) - { + ) { $exeContext = $this->exeContext; // If the field type is non-nullable, then it is resolved without any @@ -826,31 +775,34 @@ class Executor $promise = $this->getPromise($completed); if ($promise) { - return $promise->then(null, function ($error) use ($exeContext) { - $exeContext->addError($error); - return $this->exeContext->promises->createFulfilled(null); - }); + 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 Type $returnType - * @param $fieldNodes - * @param ResolveInfo $info - * @param $path - * @param $result - * @return array|null|Promise + * @param FieldNode[] $fieldNodes + * @param string[] $path + * @param mixed $result + * @return mixed[]|mixed|Promise|null * @throws Error */ public function completeValueWithLocatedError( @@ -859,8 +811,7 @@ class Executor ResolveInfo $info, $path, $result - ) - { + ) { try { $completed = $this->completeValue( $returnType, @@ -869,12 +820,20 @@ class Executor $path, $result ); - $promise = $this->getPromise($completed); + $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 $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); @@ -904,12 +863,10 @@ class Executor * Otherwise, the field type expects a sub-selection set, and will complete the * value by evaluating all sub-selections. * - * @param Type $returnType * @param FieldNode[] $fieldNodes - * @param ResolveInfo $info - * @param array $path - * @param $result - * @return array|null|Promise + * @param string[] $path + * @param mixed $result + * @return mixed[]|mixed|Promise|null * @throws Error * @throws \Throwable */ @@ -919,8 +876,7 @@ class Executor ResolveInfo $info, $path, &$result - ) - { + ) { $promise = $this->getPromise($result); // If result is a Promise, apply-lift over completeValue. @@ -949,11 +905,12 @@ class Executor 'Cannot return null for non-nullable field ' . $info->parentType . '.' . $info->fieldName . '.' ); } + return $completed; } // If result is null-like, return null. - if (null === $result) { + if ($result === null) { return null; } @@ -965,14 +922,21 @@ class Executor // 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 = ""; + $hint = ''; if ($this->exeContext->schema->getConfig()->typeLoader) { - $hint = "Make sure that type loader returns the same instance as defined in {$info->parentType}.{$info->fieldName}"; + $hint = sprintf( + 'Make sure that type loader returns the same instance as defined in %s.%s', + $info->parentType, + $info->fieldName + ); } throw new InvariantViolation( - "Schema must contain unique named types but contains multiple types named \"$returnType\". ". - "$hint ". - "(see http://webonyx.github.io/graphql-php/type-system/#type-registry)." + 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 + ) ); } @@ -991,104 +955,129 @@ class Executor return $this->completeObjectValue($returnType, $fieldNodes, $info, $path, $result); } - throw new \RuntimeException("Cannot complete value of unexpected type \"{$returnType}\"."); + throw new \RuntimeException(sprintf('Cannot complete value of unexpected type "%s".', $returnType)); } /** - * If a resolve function is not given, then a default resolve behavior is used - * which takes the property of the source object of the same name as the field - * and returns it as the result, or if it's a function, returns the result - * of calling that function while passing along args and context. + * Only returns the value if it acts like a Promise, i.e. has a "then" function, + * otherwise returns null. * - * @param $source - * @param $args - * @param $context - * @param ResolveInfo $info - * - * @return mixed|null + * @param mixed $value + * @return Promise|null */ - public static function defaultFieldResolver($source, $args, $context, ResolveInfo $info) + private function getPromise($value) { - $fieldName = $info->fieldName; - $property = null; + 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) + )); + } - if (is_array($source) || $source instanceof \ArrayAccess) { - if (isset($source[$fieldName])) { - $property = $source[$fieldName]; - } - } else if (is_object($source)) { - if (isset($source->{$fieldName})) { - $property = $source->{$fieldName}; - } + return $promise; } - return $property instanceof \Closure ? $property($source, $args, $context, $info) : $property; + return null; } /** - * 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. + * Complete a list value by completing each item in the list with the + * inner type * - * @param Schema $schema - * @param ObjectType $parentType - * @param $fieldName - * - * @return FieldDefinition + * @param FieldNode[] $fieldNodes + * @param mixed[] $path + * @param mixed $result + * @return mixed[]|Promise + * @throws \Exception */ - private function getFieldDef(Schema $schema, ObjectType $parentType, $fieldName) + private function completeListValue(ListOfType $returnType, $fieldNodes, ResolveInfo $info, $path, &$result) { - static $schemaMetaFieldDef, $typeMetaFieldDef, $typeNameMetaFieldDef; + $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; - $schemaMetaFieldDef = $schemaMetaFieldDef ?: Introspection::schemaMetaFieldDef(); - $typeMetaFieldDef = $typeMetaFieldDef ?: Introspection::typeMetaFieldDef(); - $typeNameMetaFieldDef = $typeNameMetaFieldDef ?: Introspection::typeNameMetaFieldDef(); - - if ($fieldName === $schemaMetaFieldDef->name && $schema->getQueryType() === $parentType) { - return $schemaMetaFieldDef; - } else if ($fieldName === $typeMetaFieldDef->name && $schema->getQueryType() === $parentType) { - return $typeMetaFieldDef; - } else if ($fieldName === $typeNameMetaFieldDef->name) { - return $typeNameMetaFieldDef; + $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; } - $tmp = $parentType->getFields(); - return isset($tmp[$fieldName]) ? $tmp[$fieldName] : null; + 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 AbstractType $returnType - * @param $fieldNodes - * @param ResolveInfo $info - * @param array $path - * @param $result + * @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; + $exeContext = $this->exeContext; $runtimeType = $returnType->resolveType($result, $exeContext->contextValue, $info); - if (null === $runtimeType) { + 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 $promise->then(function ($resolvedRuntimeType) use ( + $returnType, + $fieldNodes, + $info, + $path, + &$result + ) { return $this->completeObjectValue( $this->ensureValidRuntimeType( $resolvedRuntimeType, $returnType, - $fieldNodes, $info, $result ), @@ -1104,7 +1093,6 @@ class Executor $this->ensureValidRuntimeType( $runtimeType, $returnType, - $fieldNodes, $info, $result ), @@ -1116,125 +1104,86 @@ class Executor } /** - * @param string|ObjectType|null $runtimeTypeOrName - * @param AbstractType $returnType - * @param $fieldNodes - * @param ResolveInfo $info - * @param $result - * @return ObjectType - * @throws Error - */ - private function ensureValidRuntimeType( - $runtimeTypeOrName, - AbstractType $returnType, - $fieldNodes, - ResolveInfo $info, - &$result - ) - { - $runtimeType = is_string($runtimeTypeOrName) ? - $this->exeContext->schema->getType($runtimeTypeOrName) : - $runtimeTypeOrName; - - if (!$runtimeType instanceof ObjectType) { - throw new InvariantViolation( - "Abstract type {$returnType} must resolve to an Object type at " . - "runtime for field {$info->parentType}.{$info->fieldName} with " . - 'value "' . Utils::printSafe($result) . '", received "'. Utils::printSafe($runtimeType) . '".' . - 'Either the ' . $returnType . ' type should provide a "resolveType" ' . - 'function or each possible types should provide an "isTypeOf" function.' - ); - } - - if (!$this->exeContext->schema->isPossibleType($returnType, $runtimeType)) { - throw new InvariantViolation( - "Runtime Object type \"$runtimeType\" is not a possible type for \"$returnType\"." - ); - } - - if ($runtimeType !== $this->exeContext->schema->getType($runtimeType->name)) { - throw new InvariantViolation( - "Schema must contain unique named types but contains multiple types named \"$runtimeType\". ". - "Make sure that `resolveType` function of abstract type \"{$returnType}\" returns the same ". - "type instance as referenced anywhere else within the schema " . - "(see http://webonyx.github.io/graphql-php/type-system/#type-registry)." - ); - } - - return $runtimeType; - } - - /** - * Complete a list value by completing each item in the list with the - * inner type + * If a resolveType function is not given, then a default resolve behavior is + * used which attempts two strategies: * - * @param ListOfType $returnType - * @param $fieldNodes - * @param ResolveInfo $info - * @param array $path - * @param $result - * @return array|Promise - * @throws \Exception + * 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 completeListValue(ListOfType $returnType, $fieldNodes, ResolveInfo $info, $path, &$result) + private function defaultTypeResolver($value, $context, ResolveInfo $info, AbstractType $abstractType) { - $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; + // First, look for `__typename`. + if ($value !== null && + is_array($value) && + isset($value['__typename']) && + is_string($value['__typename']) + ) { + return $value['__typename']; + } - $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; + 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; } - $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 LeafType $returnType - * @param $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 - ); + $promise = $this->getPromise($isTypeOfResult); + if ($promise) { + $promisedIsTypeOfResults[$index] = $promise; + } elseif ($isTypeOfResult) { + return $type; + } } + + if (! empty($promisedIsTypeOfResults)) { + return $this->exeContext->promises->all($promisedIsTypeOfResults) + ->then(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 ObjectType $returnType - * @param $fieldNodes - * @param ResolveInfo $info - * @param array $path - * @param $result - * @return array|Promise|\stdClass + * @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) @@ -1244,11 +1193,17 @@ class Executor // than continuing execution. $isTypeOf = $returnType->isTypeOf($result, $this->exeContext->contextValue, $info); - if (null !== $isTypeOf) { + if ($isTypeOf !== null) { $promise = $this->getPromise($isTypeOf); if ($promise) { - return $promise->then(function($isTypeOfResult) use ($returnType, $fieldNodes, $info, $path, &$result) { - if (!$isTypeOfResult) { + return $promise->then(function ($isTypeOfResult) use ( + $returnType, + $fieldNodes, + $info, + $path, + &$result + ) { + if (! $isTypeOfResult) { throw $this->invalidReturnTypeError($returnType, $result, $fieldNodes); } @@ -1261,7 +1216,7 @@ class Executor ); }); } - if (!$isTypeOf) { + if (! $isTypeOf) { throw $this->invalidReturnTypeError($returnType, $result, $fieldNodes); } } @@ -1276,8 +1231,7 @@ class Executor } /** - * @param ObjectType $returnType - * @param array $result + * @param mixed[] $result * @param FieldNode[] $fieldNodes * @return Error */ @@ -1285,22 +1239,18 @@ class Executor ObjectType $returnType, $result, $fieldNodes - ) - { + ) { return new Error( 'Expected value of type "' . $returnType->name . '" but got: ' . Utils::printSafe($result) . '.', $fieldNodes ); } - /** - * @param ObjectType $returnType * @param FieldNode[] $fieldNodes - * @param ResolveInfo $info - * @param array $path - * @param array $result - * @return array|Promise|\stdClass + * @param mixed[] $path + * @param mixed[] $result + * @return mixed[]|Promise|\stdClass * @throws Error */ private function collectAndExecuteSubfields( @@ -1309,119 +1259,189 @@ class Executor ResolveInfo $info, $path, &$result - ) - { + ) { // Collect sub-fields to execute to complete this value. - $subFieldNodes = new \ArrayObject(); + $subFieldNodes = new \ArrayObject(); $visitedFragmentNames = new \ArrayObject(); foreach ($fieldNodes as $fieldNode) { - if (isset($fieldNode->selectionSet)) { - $subFieldNodes = $this->collectFields( - $returnType, - $fieldNode->selectionSet, - $subFieldNodes, - $visitedFragmentNames - ); + if (! isset($fieldNode->selectionSet)) { + continue; } + + $subFieldNodes = $this->collectFields( + $returnType, + $fieldNode->selectionSet, + $subFieldNodes, + $visitedFragmentNames + ); } return $this->executeFields($returnType, $result, $path, $subFieldNodes); } /** - * If a resolveType function is not given, then a default resolve behavior is - * used which attempts two strategies: + * Implements the "Evaluating selection sets" section of the spec + * for "read" mode. * - * First, See if the provided value has a `__typename` field defined, if so, use - * that value as name of the resolved type. - * - * Otherwise, test each possible type for the abstract type by calling - * isTypeOf for the object being coerced, returning the first type that matches. - * - * @param $value - * @param $context - * @param ResolveInfo $info - * @param AbstractType $abstractType - * @return ObjectType|Promise|null + * @param mixed|null $source + * @param mixed[] $path + * @param ArrayObject $fields + * @return Promise|\stdClass|mixed[] */ - private function defaultTypeResolver($value, $context, ResolveInfo $info, AbstractType $abstractType) + private function executeFields(ObjectType $parentType, $source, $path, $fields) { - // First, look for `__typename`. - if ( - $value !== null && - is_array($value) && - isset($value['__typename']) && - is_string($value['__typename']) - ) { - return $value['__typename']; - } + $containsPromise = false; + $finalResults = []; - if ($abstractType instanceof InterfaceType && $info->schema->getConfig()->typeLoader) { - Warning::warnOnce( - "GraphQL Interface Type `{$abstractType->name}` returned `null` from it`s `resolveType` function ". - 'for value: ' . Utils::printSafe($value) . '. Switching to slow resolution method using `isTypeOf` ' . - 'of all possible implementations. It requires full schema scan and degrades query performance significantly. '. - ' Make sure your `resolveType` always returns valid implementation or throws.', - Warning::WARNING_FULL_SCHEMA_SCAN - ); - } - - // Otherwise, test each possible type. - $possibleTypes = $info->schema->getPossibleTypes($abstractType); - $promisedIsTypeOfResults = []; - - foreach ($possibleTypes as $index => $type) { - $isTypeOfResult = $type->isTypeOf($value, $context, $info); - - if (null !== $isTypeOfResult) { - $promise = $this->getPromise($isTypeOfResult); - if ($promise) { - $promisedIsTypeOfResults[$index] = $promise; - } else if ($isTypeOfResult) { - return $type; - } + 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 (!empty($promisedIsTypeOfResults)) { - return $this->exeContext->promises->all($promisedIsTypeOfResults) - ->then(function($isTypeOfResults) use ($possibleTypes) { - foreach ($isTypeOfResults as $index => $result) { - if ($result) { - return $possibleTypes[$index]; - } - } - return null; - }); + // If there are no promises, we can just return the object + if (! $containsPromise) { + return self::fixResultsIfEmptyArray($finalResults); } - return null; + // 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); } /** - * Only returns the value if it acts like a Promise, i.e. has a "then" function, - * otherwise returns null. + * @see https://github.com/webonyx/graphql-php/issues/59 * - * @param mixed $value - * @return Promise|null + * @param mixed[] $results + * @return \stdClass|mixed[] */ - private function getPromise($value) + private static function fixResultsIfEmptyArray($results) { - if (null === $value || $value instanceof Promise) { - return $value; + if ($results === []) { + return new \stdClass(); } - 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 $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(function ($values) use ($keys) { + $resolvedResults = []; + foreach ($values as $i => $value) { + $resolvedResults[$keys[$i]] = $value; } - return $promise; + + 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 %1$s must resolve to an Object type at ' . + 'runtime for field %s.%s with value "%s", received "%s".' . + 'Either the %1$s type should provide a "resolveType" ' . + 'function or each possible types should provide an "isTypeOf" function.', + $returnType, + $info->parentType, + $info->fieldName, + Utils::printSafe($result), + Utils::printSafe($runtimeType) + ) + ); } - return null; + + 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 + * and returns it as the result, or if it's a function, returns the result + * of calling that function while passing along args and context. + * + * @param mixed $source + * @param mixed[] $args + * @param mixed[]|null $context + * + * @return mixed|null + */ + public static function defaultFieldResolver($source, $args, $context, ResolveInfo $info) + { + $fieldName = $info->fieldName; + $property = null; + + if (is_array($source) || $source instanceof \ArrayAccess) { + if (isset($source[$fieldName])) { + $property = $source[$fieldName]; + } + } elseif (is_object($source)) { + if (isset($source->{$fieldName})) { + $property = $source->{$fieldName}; + } + } + + return $property instanceof \Closure ? $property($source, $args, $context, $info) : $property; } } diff --git a/src/Executor/Promise/Adapter/ReactPromiseAdapter.php b/src/Executor/Promise/Adapter/ReactPromiseAdapter.php index 747ed42..4feb3c0 100644 --- a/src/Executor/Promise/Adapter/ReactPromiseAdapter.php +++ b/src/Executor/Promise/Adapter/ReactPromiseAdapter.php @@ -1,4 +1,7 @@ adoptedPromise; + return new Promise($adoptedPromise->then($onFulfilled, $onRejected), $this); } @@ -41,6 +48,7 @@ class ReactPromiseAdapter implements PromiseAdapter public function create(callable $resolver) { $promise = new ReactPromise($resolver); + return new Promise($promise, $this); } @@ -49,7 +57,8 @@ class ReactPromiseAdapter implements PromiseAdapter */ public function createFulfilled($value = null) { - $promise = \React\Promise\resolve($value); + $promise = resolve($value); + return new Promise($promise, $this); } @@ -58,7 +67,8 @@ class ReactPromiseAdapter implements PromiseAdapter */ public function createRejected($reason) { - $promise = \React\Promise\reject($reason); + $promise = reject($reason); + return new Promise($promise, $this); } @@ -68,11 +78,14 @@ class ReactPromiseAdapter implements PromiseAdapter public function all(array $promisesOrValues) { // TODO: rework with generators when PHP minimum required version is changed to 5.5+ - $promisesOrValues = Utils::map($promisesOrValues, function ($item) { - return $item instanceof Promise ? $item->adoptedPromise : $item; - }); + $promisesOrValues = Utils::map( + $promisesOrValues, + function ($item) { + return $item instanceof Promise ? $item->adoptedPromise : $item; + } + ); - $promise = \React\Promise\all($promisesOrValues)->then(function($values) use ($promisesOrValues) { + $promise = all($promisesOrValues)->then(function ($values) use ($promisesOrValues) { $orderedResults = []; foreach ($promisesOrValues as $key => $value) { @@ -81,6 +94,7 @@ class ReactPromiseAdapter implements PromiseAdapter return $orderedResults; }); + return new Promise($promise, $this); } } diff --git a/src/Executor/Promise/Adapter/SyncPromise.php b/src/Executor/Promise/Adapter/SyncPromise.php index 9376bf7..fd0f3ba 100644 --- a/src/Executor/Promise/Adapter/SyncPromise.php +++ b/src/Executor/Promise/Adapter/SyncPromise.php @@ -1,72 +1,49 @@ isEmpty()) { - $task = $q->dequeue(); - $task(); - } - } - + /** @var string */ public $state = self::PENDING; + /** @var ExecutionResult|Throwable */ public $result; /** * Promises created in `then` method of this promise and awaiting for resolution of this promise - * @var array + * @var mixed[][] */ private $waiting = []; - public function reject($reason) + public static function runQueue() { - if (!$reason instanceof \Exception && !$reason instanceof \Throwable) { - throw new \Exception('SyncPromise::reject() has to be called with an instance of \Throwable'); + $q = self::$queue; + while ($q && ! $q->isEmpty()) { + $task = $q->dequeue(); + $task(); } - - switch ($this->state) { - case self::PENDING: - $this->state = self::REJECTED; - $this->result = $reason; - $this->enqueueWaitingPromises(); - break; - case self::REJECTED: - if ($reason !== $this->result) { - throw new \Exception("Cannot change rejection reason"); - } - break; - case self::FULFILLED: - throw new \Exception("Cannot reject fulfilled promise"); - } - return $this; } public function resolve($value) @@ -74,56 +51,67 @@ class SyncPromise switch ($this->state) { case self::PENDING: if ($value === $this) { - throw new \Exception("Cannot resolve promise with self"); + throw new \Exception('Cannot resolve promise with self'); } if (is_object($value) && method_exists($value, 'then')) { $value->then( - function($resolvedValue) { + function ($resolvedValue) { $this->resolve($resolvedValue); }, - function($reason) { + function ($reason) { $this->reject($reason); } ); + return $this; } - $this->state = self::FULFILLED; + $this->state = self::FULFILLED; $this->result = $value; $this->enqueueWaitingPromises(); break; case self::FULFILLED: if ($this->result !== $value) { - throw new \Exception("Cannot change value of fulfilled promise"); + throw new \Exception('Cannot change value of fulfilled promise'); } break; case self::REJECTED: - throw new \Exception("Cannot resolve rejected promise"); + throw new \Exception('Cannot resolve rejected promise'); } + return $this; } - public function then(callable $onFulfilled = null, callable $onRejected = null) + public function reject($reason) { - if ($this->state === self::REJECTED && !$onRejected) { - return $this; - } - if ($this->state === self::FULFILLED && !$onFulfilled) { - return $this; - } - $tmp = new self(); - $this->waiting[] = [$tmp, $onFulfilled, $onRejected]; - - if ($this->state !== self::PENDING) { - $this->enqueueWaitingPromises(); + if (! $reason instanceof \Exception && ! $reason instanceof \Throwable) { + throw new \Exception('SyncPromise::reject() has to be called with an instance of \Throwable'); } - return $tmp; + switch ($this->state) { + case self::PENDING: + $this->state = self::REJECTED; + $this->result = $reason; + $this->enqueueWaitingPromises(); + break; + case self::REJECTED: + if ($reason !== $this->result) { + throw new \Exception('Cannot change rejection reason'); + } + break; + case self::FULFILLED: + throw new \Exception('Cannot reject fulfilled promise'); + } + + return $this; } private function enqueueWaitingPromises() { - Utils::invariant($this->state !== self::PENDING, 'Cannot enqueue derived promises when parent is still pending'); + Utils::invariant( + $this->state !== self::PENDING, + 'Cannot enqueue derived promises when parent is still pending' + ); foreach ($this->waiting as $descriptor) { self::getQueue()->enqueue(function () use ($descriptor) { @@ -138,7 +126,7 @@ class SyncPromise } catch (\Throwable $e) { $promise->reject($e); } - } else if ($this->state === self::REJECTED) { + } elseif ($this->state === self::REJECTED) { try { if ($onRejected) { $promise->resolve($onRejected($this->result)); @@ -155,4 +143,27 @@ class SyncPromise } $this->waiting = []; } + + public static function getQueue() + { + return self::$queue ?: self::$queue = new \SplQueue(); + } + + public function then(?callable $onFulfilled = null, ?callable $onRejected = null) + { + if ($this->state === self::REJECTED && ! $onRejected) { + return $this; + } + if ($this->state === self::FULFILLED && ! $onFulfilled) { + return $this; + } + $tmp = new self(); + $this->waiting[] = [$tmp, $onFulfilled, $onRejected]; + + if ($this->state !== self::PENDING) { + $this->enqueueWaitingPromises(); + } + + return $tmp; + } } diff --git a/src/Executor/Promise/Adapter/SyncPromiseAdapter.php b/src/Executor/Promise/Adapter/SyncPromiseAdapter.php index e0791bc..3ec9246 100644 --- a/src/Executor/Promise/Adapter/SyncPromiseAdapter.php +++ b/src/Executor/Promise/Adapter/SyncPromiseAdapter.php @@ -1,19 +1,20 @@ promise, $this); } /** * @inheritdoc */ - public function then(Promise $promise, callable $onFulfilled = null, callable $onRejected = null) + public function then(Promise $promise, ?callable $onFulfilled = null, ?callable $onRejected = null) { - /** @var SyncPromise $promise */ - $promise = $promise->adoptedPromise; - return new Promise($promise->then($onFulfilled, $onRejected), $this); + /** @var SyncPromise $adoptedPromise */ + $adoptedPromise = $promise->adoptedPromise; + + return new Promise($adoptedPromise->then($onFulfilled, $onRejected), $this); } /** @@ -55,8 +58,14 @@ class SyncPromiseAdapter implements PromiseAdapter try { $resolver( - [$promise, 'resolve'], - [$promise, 'reject'] + [ + $promise, + 'resolve', + ], + [ + $promise, + 'reject', + ] ); } catch (\Exception $e) { $promise->reject($e); @@ -73,6 +82,7 @@ class SyncPromiseAdapter implements PromiseAdapter public function createFulfilled($value = null) { $promise = new SyncPromise(); + return new Promise($promise->resolve($value), $this); } @@ -82,6 +92,7 @@ class SyncPromiseAdapter implements PromiseAdapter public function createRejected($reason) { $promise = new SyncPromise(); + return new Promise($promise->reject($reason), $this); } @@ -92,20 +103,22 @@ class SyncPromiseAdapter implements PromiseAdapter { $all = new SyncPromise(); - $total = count($promisesOrValues); - $count = 0; + $total = count($promisesOrValues); + $count = 0; $result = []; foreach ($promisesOrValues as $index => $promiseOrValue) { if ($promiseOrValue instanceof Promise) { $result[$index] = null; $promiseOrValue->then( - function($value) use ($index, &$count, $total, &$result, $all) { + function ($value) use ($index, &$count, $total, &$result, $all) { $result[$index] = $value; $count++; - if ($count >= $total) { - $all->resolve($result); + if ($count < $total) { + return; } + + $all->resolve($result); }, [$all, 'reject'] ); @@ -117,24 +130,23 @@ class SyncPromiseAdapter implements PromiseAdapter if ($count === $total) { $all->resolve($result); } + return new Promise($all, $this); } /** * Synchronously wait when promise completes * - * @param Promise $promise - * @return mixed + * @return ExecutionResult */ public function wait(Promise $promise) { $this->beforeWait($promise); - $dfdQueue = Deferred::getQueue(); + $dfdQueue = Deferred::getQueue(); $promiseQueue = SyncPromise::getQueue(); - while ( - $promise->adoptedPromise->state === SyncPromise::PENDING && - !($dfdQueue->isEmpty() && $promiseQueue->isEmpty()) + while ($promise->adoptedPromise->state === SyncPromise::PENDING && + ! ($dfdQueue->isEmpty() && $promiseQueue->isEmpty()) ) { Deferred::runQueue(); SyncPromise::runQueue(); @@ -146,17 +158,16 @@ class SyncPromiseAdapter implements PromiseAdapter if ($syncPromise->state === SyncPromise::FULFILLED) { return $syncPromise->result; - } else if ($syncPromise->state === SyncPromise::REJECTED) { + } elseif ($syncPromise->state === SyncPromise::REJECTED) { throw $syncPromise->result; } - throw new InvariantViolation("Could not resolve promise"); + throw new InvariantViolation('Could not resolve promise'); } /** * Execute just before starting to run promise completion * - * @param Promise $promise */ protected function beforeWait(Promise $promise) { @@ -165,7 +176,6 @@ class SyncPromiseAdapter implements PromiseAdapter /** * Execute while running promise completion * - * @param Promise $promise */ protected function onWait(Promise $promise) { diff --git a/src/Executor/Promise/Promise.php b/src/Executor/Promise/Promise.php index 7bb3a6e..8079663 100644 --- a/src/Executor/Promise/Promise.php +++ b/src/Executor/Promise/Promise.php @@ -1,6 +1,10 @@ adapter = $adapter; + $this->adapter = $adapter; $this->adoptedPromise = $adoptedPromise; } /** - * @param callable|null $onFulfilled - * @param callable|null $onRejected * * @return Promise */ - public function then(callable $onFulfilled = null, callable $onRejected = null) + public function then(?callable $onFulfilled = null, ?callable $onRejected = null) { return $this->adapter->then($this, $onFulfilled, $onRejected); } diff --git a/src/Executor/Promise/PromiseAdapter.php b/src/Executor/Promise/PromiseAdapter.php index f325119..74582fa 100644 --- a/src/Executor/Promise/PromiseAdapter.php +++ b/src/Executor/Promise/PromiseAdapter.php @@ -1,4 +1,7 @@ variable->name->value; /** @var InputType|Type $varType */ $varType = TypeInfo::typeFromAST($schema, $varDefNode->type); - if (!Type::isInputType($varType)) { + if (! Type::isInputType($varType)) { $errors[] = new Error( - "Variable \"\$$varName\" expected value of type " . - '"' . Printer::doPrint($varDefNode->type) . '" which cannot be used as an input type.', + sprintf( + 'Variable "$%s" expected value of type "%s" which cannot be used as an input type.', + $varName, + Printer::doPrint($varDefNode->type) + ), [$varDefNode->type] ); } else { - if (!array_key_exists($varName, $inputs)) { + if (! array_key_exists($varName, $inputs)) { if ($varType instanceof NonNull) { $errors[] = new Error( - "Variable \"\$$varName\" of required type " . - "\"{$varType}\" was not provided.", + sprintf( + 'Variable "$%s" of required type "%s" was not provided.', + $varName, + $varType + ), [$varDefNode] ); - } else if ($varDefNode->defaultValue) { + } elseif ($varDefNode->defaultValue) { $coercedValues[$varName] = AST::valueFromAST($varDefNode->defaultValue, $varType); } } else { - $value = $inputs[$varName]; + $value = $inputs[$varName]; $coerced = Value::coerceValue($value, $varType, $varDefNode); /** @var Error[] $coercionErrors */ $coercionErrors = $coerced['errors']; - if ($coercionErrors) { - $messagePrelude = "Variable \"\$$varName\" got invalid value " . Utils::printSafeJson($value) . '; '; + if (! empty($coercionErrors)) { + $messagePrelude = sprintf( + 'Variable "$%s" got invalid value %s; ', + $varName, + Utils::printSafeJson($value) + ); - foreach($coercionErrors as $error) { + foreach ($coercionErrors as $error) { $errors[] = new Error( $messagePrelude . $error->getMessage(), $error->getNodes(), @@ -87,86 +104,10 @@ class Values } } } + return ['errors' => $errors, 'coerced' => $errors ? null : $coercedValues]; } - /** - * Prepares an object map of argument values given a list of argument - * definitions and list of argument AST nodes. - * - * @param FieldDefinition|Directive $def - * @param FieldNode|\GraphQL\Language\AST\DirectiveNode $node - * @param $variableValues - * @return array - * @throws Error - */ - public static function getArgumentValues($def, $node, $variableValues = null) - { - $argDefs = $def->args; - $argNodes = $node->arguments; - - if (!$argDefs || null === $argNodes) { - return []; - } - - $coercedValues = []; - - /** @var ArgumentNode[] $argNodeMap */ - $argNodeMap = $argNodes ? Utils::keyMap($argNodes, function (ArgumentNode $arg) { - return $arg->name->value; - }) : []; - - foreach ($argDefs as $argDef) { - $name = $argDef->name; - $argType = $argDef->getType(); - $argumentNode = isset($argNodeMap[$name]) ? $argNodeMap[$name] : null; - - if (!$argumentNode) { - if ($argDef->defaultValueExists()) { - $coercedValues[$name] = $argDef->defaultValue; - } else if ($argType instanceof NonNull) { - throw new Error( - 'Argument "' . $name . '" of required type ' . - '"' . Utils::printSafe($argType) . '" was not provided.', - [$node] - ); - } - } else if ($argumentNode->value instanceof VariableNode) { - $variableName = $argumentNode->value->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]; - } else if ($argDef->defaultValueExists()) { - $coercedValues[$name] = $argDef->defaultValue; - } else if ($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 ] - ); - } - } else { - $valueNode = $argumentNode->value; - $coercedValue = AST::valueFromAST($valueNode, $argType, $variableValues); - if (Utils::isInvalid($coercedValue)) { - // Note: ValuesOfCorrectType validation should catch this before - // execution. This is a runtime check to ensure execution does not - // continue with an invalid argument value. - throw new Error( - 'Argument "' . $name . '" has invalid value ' . Printer::doPrint($valueNode) . '.', - [ $argumentNode->value ] - ); - } - $coercedValues[$name] = $coercedValue; - } - } - return $coercedValues; - } - /** * Prepares an object map of argument values given a directive definition * and a AST node which may contain directives. Optionally also accepts a map @@ -174,33 +115,116 @@ class Values * * If the directive does not exist on the node, returns undefined. * - * @param Directive $directiveDef - * @param FragmentSpreadNode | FieldNode | InlineFragmentNode | EnumValueDefinitionNode | FieldDefinitionNode $node - * @param array|null $variableValues + * @param FragmentSpreadNode|FieldNode|InlineFragmentNode|EnumValueDefinitionNode|FieldDefinitionNode $node + * @param mixed[]|null $variableValues * - * @return array|null + * @return mixed[]|null */ public static function getDirectiveValues(Directive $directiveDef, $node, $variableValues = null) { if (isset($node->directives) && $node->directives instanceof NodeList) { - $directiveNode = Utils::find($node->directives, function(DirectiveNode $directive) use ($directiveDef) { - return $directive->name->value === $directiveDef->name; - }); + $directiveNode = Utils::find( + $node->directives, + function (DirectiveNode $directive) use ($directiveDef) { + return $directive->name->value === $directiveDef->name; + } + ); if ($directiveNode) { return self::getArgumentValues($directiveDef, $directiveNode, $variableValues); } } + return null; } + /** + * Prepares an object map of argument values given a list of argument + * definitions and list of argument AST nodes. + * + * @param FieldDefinition|Directive $def + * @param FieldNode|DirectiveNode $node + * @param mixed[] $variableValues + * @return mixed[] + * @throws Error + */ + public static function getArgumentValues($def, $node, $variableValues = null) + { + $argDefs = $def->args; + $argNodes = $node->arguments; + + if (empty($argDefs) || $argNodes === null) { + return []; + } + + $coercedValues = []; + + /** @var ArgumentNode[] $argNodeMap */ + $argNodeMap = $argNodes ? Utils::keyMap( + $argNodes, + function (ArgumentNode $arg) { + return $arg->name->value; + } + ) : []; + + foreach ($argDefs as $argDef) { + $name = $argDef->name; + $argType = $argDef->getType(); + $argumentNode = $argNodeMap[$name] ?? null; + + if (! $argumentNode) { + if ($argDef->defaultValueExists()) { + $coercedValues[$name] = $argDef->defaultValue; + } elseif ($argType instanceof NonNull) { + throw new Error( + 'Argument "' . $name . '" of required type ' . + '"' . Utils::printSafe($argType) . '" was not provided.', + [$node] + ); + } + } elseif ($argumentNode->value instanceof VariableNode) { + $variableName = $argumentNode->value->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 ($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] + ); + } + } else { + $valueNode = $argumentNode->value; + $coercedValue = AST::valueFromAST($valueNode, $argType, $variableValues); + if (Utils::isInvalid($coercedValue)) { + // Note: ValuesOfCorrectType validation should catch this before + // execution. This is a runtime check to ensure execution does not + // continue with an invalid argument value. + throw new Error( + 'Argument "' . $name . '" has invalid value ' . Printer::doPrint($valueNode) . '.', + [$argumentNode->value] + ); + } + $coercedValues[$name] = $coercedValue; + } + } + + return $coercedValues; + } + /** * @deprecated as of 8.0 (Moved to \GraphQL\Utils\AST::valueFromAST) * - * @param $valueNode - * @param InputType $type - * @param null $variables - * @return array|null|\stdClass + * @param ValueNode $valueNode + * @param null $variables + * @return mixed[]|null|\stdClass */ public static function valueFromAST($valueNode, InputType $type, $variables = null) { @@ -209,15 +233,19 @@ class Values /** * @deprecated as of 0.12 (Use coerceValue() directly for richer information) - * @param $value - * @param InputType $type - * @return array + * @param mixed[] $value + * @return string[] */ public static function isValidPHPValue($value, InputType $type) { $errors = Value::coerceValue($value, $type)['errors']; + return $errors - ? array_map(function(/*\Throwable */$error) { return $error->getMessage(); }, $errors) - : []; + ? array_map( + function (Throwable $error) { + return $error->getMessage(); + }, + $errors + ) : []; } } diff --git a/src/Server/Helper.php b/src/Server/Helper.php index d7e62a8..1e25a8a 100644 --- a/src/Server/Helper.php +++ b/src/Server/Helper.php @@ -189,7 +189,7 @@ class Helper * @api * @param ServerConfig $config * @param OperationParams[] $operations - * @return ExecutionResult[]|Promise + * @return ExecutionResult|ExecutionResult[]|Promise */ public function executeBatch(ServerConfig $config, array $operations) { diff --git a/src/Utils/AST.php b/src/Utils/AST.php index 7535239..fc46d12 100644 --- a/src/Utils/AST.php +++ b/src/Utils/AST.php @@ -282,7 +282,7 @@ class AST * @api * @param $valueNode * @param InputType $type - * @param null $variables + * @param mixed[]|null $variables * @return array|null|\stdClass * @throws \Exception */ diff --git a/src/Utils/Utils.php b/src/Utils/Utils.php index ee202ae..1d9c0f4 100644 --- a/src/Utils/Utils.php +++ b/src/Utils/Utils.php @@ -59,7 +59,7 @@ class Utils /** * @param array|Traversable $traversable * @param callable $predicate - * @return null + * @return mixed|null */ public static function find($traversable, callable $predicate) {