diff --git a/src/Error/Error.php b/src/Error/Error.php index e31ccf1..56241d9 100644 --- a/src/Error/Error.php +++ b/src/Error/Error.php @@ -74,21 +74,36 @@ class Error extends \Exception implements \JsonSerializable */ public static function createLocatedError($error, $nodes = null, $path = null) { - if ($error instanceof self) { + if ($error instanceof self && $error->path) { return $error; } - if ($error instanceof \Exception) { + $source = $positions = $originalError = null; + + if ($error instanceof self) { $message = $error->getMessage(); - $previous = $error; + $originalError = $error; + $nodes = $error->nodes ?: $nodes; + $source = $error->source; + $positions = $error->positions; + } else if ($error instanceof \Exception) { + $message = $error->getMessage(); + $originalError = $error; } else { $message = (string) $error; - $previous = null; } - return new static($message, $nodes, null, null, $path, $previous); + return new static( + $message ?: 'An unknown error occurred.', + $nodes, + $source, + $positions, + $path, + $originalError + ); } + /** * @param Error $error * @return array diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index f81212a..e7de7a0 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -338,7 +338,7 @@ class Executor : null; if ($skipAST) { - $argValues = Values::getArgumentValues($skipDirective->args, $skipAST->arguments, $exeContext->variableValues); + $argValues = Values::getArgumentValues($skipDirective, $skipAST, $exeContext->variableValues); if (isset($argValues['if']) && $argValues['if'] === true) { return false; } @@ -352,7 +352,7 @@ class Executor : null; if ($includeAST) { - $argValues = Values::getArgumentValues($includeDirective->args, $includeAST->arguments, $exeContext->variableValues); + $argValues = Values::getArgumentValues($includeDirective, $includeAST, $exeContext->variableValues); if (isset($argValues['if']) && $argValues['if'] === false) { return false; } @@ -410,14 +410,6 @@ class Executor $returnType = $fieldDef->getType(); - // Build hash of arguments from the field.arguments AST, using the - // variables scope to fulfill any variable references. - $args = Values::getArgumentValues( - $fieldDef->args, - $fieldAST->arguments, - $exeContext->variableValues - ); - // The resolve function's optional third argument is a collection of // information about the current execution state. $info = new ResolveInfo([ @@ -449,7 +441,15 @@ class Executor // Get the resolve function, regardless of if its result is normal // or abrupt (error). - $result = self::resolveOrError($resolveFn, $source, $args, $context, $info); + $result = self::resolveOrError( + $exeContext, + $fieldDef, + $fieldAST, + $resolveFn, + $source, + $context, + $info + ); $result = self::completeValueCatchingError( $exeContext, @@ -463,11 +463,30 @@ class Executor return $result; } - // Isolates the "ReturnOrAbrupt" behavior to not de-opt the `resolveField` - // function. Returns the result of resolveFn or the abrupt-return Error object. - private static function resolveOrError($resolveFn, $source, $args, $context, $info) + /** + * Isolates the "ReturnOrAbrupt" behavior to not de-opt the `resolveField` + * function. Returns the result of resolveFn or the abrupt-return Error object. + * + * @param ExecutionContext $exeContext + * @param FieldDefinition $fieldDef + * @param Field $fieldAST + * @param callable $resolveFn + * @param mixed $source + * @param mixed $context + * @param ResolveInfo $info + * @return \Exception|mixed + */ + private static function resolveOrError($exeContext, $fieldDef, $fieldAST, $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, + $fieldAST, + $exeContext->variableValues + ); + return call_user_func($resolveFn, $source, $args, $context, $info); } catch (\Exception $error) { return $error; @@ -783,8 +802,9 @@ class Executor $i = 0; $tmp = []; foreach ($result as $item) { - $path[] = $i++; - $tmp[] = self::completeValueCatchingError($exeContext, $itemType, $fieldASTs, $info, $path, $item); + $fieldPath = $path; + $fieldPath[] = $i++; + $tmp[] = self::completeValueCatchingError($exeContext, $itemType, $fieldASTs, $info, $fieldPath, $item); } return $tmp; } diff --git a/src/Executor/Values.php b/src/Executor/Values.php index 18bba17..42f4e3d 100644 --- a/src/Executor/Values.php +++ b/src/Executor/Values.php @@ -5,11 +5,16 @@ namespace GraphQL\Executor; use GraphQL\Error\Error; use GraphQL\Error\InvariantViolation; use GraphQL\Language\AST\Argument; +use GraphQL\Language\AST\Field; use GraphQL\Language\AST\NullValue; +use GraphQL\Language\AST\Value; +use GraphQL\Language\AST\Variable; use GraphQL\Language\AST\VariableDefinition; use GraphQL\Language\Printer; use GraphQL\Schema; +use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\FieldArgument; +use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InputType; use GraphQL\Type\Definition\LeafType; @@ -17,6 +22,7 @@ use GraphQL\Type\Definition\ListOfType; use GraphQL\Type\Definition\NonNull; use GraphQL\Type\Definition\Type; use GraphQL\Utils; +use GraphQL\Validator\DocumentValidator; class Values { @@ -33,50 +39,126 @@ class Values */ public static function getVariableValues(Schema $schema, $definitionASTs, array $inputs) { - $values = []; - foreach ($definitionASTs as $defAST) { - $varName = $defAST->variable->name->value; - $values[$varName] = self::getvariableValue($schema, $defAST, isset($inputs[$varName]) ? $inputs[$varName] : null); + $coercedValues = []; + foreach ($definitionASTs as $definitionAST) { + $varName = $definitionAST->variable->name->value; + $varType = Utils\TypeInfo::typeFromAST($schema, $definitionAST->type); + + if (!Type::isInputType($varType)) { + throw new Error( + 'Variable "$'.$varName.'" expected value of type ' . + '"' . Printer::doPrint($definitionAST->type) . '" which cannot be used as an input type.', + [$definitionAST->type] + ); + } + + if (!array_key_exists($varName, $inputs)) { + $defaultValue = $definitionAST->defaultValue; + if ($defaultValue) { + $coercedValues[$varName] = Utils\AST::valueFromAST($defaultValue, $varType); + } + if ($varType instanceof NonNull) { + throw new Error( + 'Variable "$'.$varName .'" of required type ' . + '"'. Utils::printSafe($varType) . '" was not provided.', + [$definitionAST] + ); + } + } else { + $value = $inputs[$varName]; + $errors = self::isValidPHPValue($value, $varType); + if (!empty($errors)) { + $message = "\n" . implode("\n", $errors); + throw new Error( + 'Variable "$' . $varName . '" got invalid value ' . + json_encode($value) . '.' . $message, + [$definitionAST] + ); + } + + $coercedValue = self::coerceValue($varType, $value); + Utils::invariant($coercedValue !== Utils::undefined(), 'Should have reported error.'); + $coercedValues[$varName] = $coercedValue; + } } - return $values; + return $coercedValues; } /** * Prepares an object map of argument values given a list of argument * definitions and list of argument AST nodes. * - * @param FieldArgument[] $argDefs - * @param Argument[] $argASTs + * @param FieldDefinition|Directive $def + * @param Field|\GraphQL\Language\AST\Directive $node * @param $variableValues * @return array + * @throws Error */ - public static function getArgumentValues($argDefs, $argASTs, $variableValues) + public static function getArgumentValues($def, $node, $variableValues) { - if (!$argDefs) { + $argDefs = $def->args; + $argASTs = $node->arguments; + + if (!$argDefs || null === $argASTs) { return []; } - $argASTMap = $argASTs ? Utils::keyMap($argASTs, function ($arg) { + + $coercedValues = []; + $undefined = Utils::undefined(); + + /** @var Argument[] $argASTMap */ + $argASTMap = $argASTs ? Utils::keyMap($argASTs, function (Argument $arg) { return $arg->name->value; }) : []; - $result = []; + foreach ($argDefs as $argDef) { $name = $argDef->name; - $valueAST = isset($argASTMap[$name]) ? $argASTMap[$name]->value : null; - $value = Utils\AST::valueFromAST($valueAST, $argDef->getType(), $variableValues); + $argType = $argDef->getType(); + $argumentAST = isset($argASTMap[$name]) ? $argASTMap[$name] : null; - if (null === $value && null === $argDef->defaultValue && !$argDef->defaultValueExists()) { - continue; - } + if (!$argumentAST) { + 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 ($argumentAST->value instanceof Variable) { + $variableName = $argumentAST->value->name->value; - if (null === $value) { - $value = $argDef->defaultValue; + 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.', + [ $argumentAST->value ] + ); + } + } else { + $valueAST = $argumentAST->value; + $coercedValue = Utils\AST::valueFromAST($valueAST, $argType, $variableValues); + if ($coercedValue === $undefined) { + $errors = DocumentValidator::isValidLiteralValue($argType, $valueAST); + $message = !empty($errors) ? ("\n" . implode("\n", $errors)) : ''; + throw new Error( + 'Argument "' . $name . '" got invalid value ' . Printer::doPrint($valueAST) . '.' . $message, + [ $argumentAST->value ] + ); + } + $coercedValues[$name] = $coercedValue; } - if (NullValue::getNullValue() === $value) { - $value = null; - } - $result[$name] = $value; } - return $result; + return $coercedValues; } /** @@ -92,56 +174,6 @@ class Values return Utils\AST::valueFromAST($valueAST, $type, $variables); } - /** - * Given a variable definition, and any value of input, return a value which - * adheres to the variable definition, or throw an error. - */ - private static function getVariableValue(Schema $schema, VariableDefinition $definitionAST, $input) - { - $type = Utils\TypeInfo::typeFromAST($schema, $definitionAST->type); - $variable = $definitionAST->variable; - - if (!$type || !Type::isInputType($type)) { - $printed = Printer::doPrint($definitionAST->type); - throw new Error( - "Variable \"\${$variable->name->value}\" expected value of type " . - "\"$printed\" which cannot be used as an input type.", - [ $definitionAST ] - ); - } - - $inputType = $type; - $errors = self::isValidPHPValue($input, $inputType); - - if (empty($errors)) { - if (null === $input) { - $defaultValue = $definitionAST->defaultValue; - if ($defaultValue) { - $value = Utils\AST::valueFromAST($defaultValue, $inputType); - return $value === NullValue::getNullValue() ? null : $value; - } - } - return self::coerceValue($inputType, $input); - } - - if (null === $input) { - $printed = Printer::doPrint($definitionAST->type); - - throw new Error( - "Variable \"\${$variable->name->value}\" of required type " . - "\"$printed\" was not provided.", - [ $definitionAST ] - ); - } - $message = $errors ? "\n" . implode("\n", $errors) : ''; - $val = json_encode($input); - throw new Error( - "Variable \"\${$variable->name->value}\" got invalid value ". - "{$val}.{$message}", - [ $definitionAST ] - ); - } - /** * Given a PHP value and a GraphQL type, determine if the value will be * accepted for that type. This is primarily useful for validating the @@ -155,14 +187,10 @@ class Values { // A value must be provided if the type is non-null. if ($type instanceof NonNull) { - $ofType = $type->getWrappedType(); if (null === $value) { - if ($ofType->name) { - return [ "Expected \"{$ofType->name}!\", found null." ]; - } - return [ 'Expected non-null value, found null.' ]; + return ['Expected "' . Utils::printSafe($type) . '", found null.']; } - return self::isValidPHPValue($value, $ofType); + return self::isValidPHPValue($value, $type->getWrappedType()); } if (null === $value) { @@ -235,9 +263,16 @@ class Values */ private static function coerceValue(Type $type, $value) { + $undefined = Utils::undefined(); + if ($value === $undefined) { + return $undefined; + } + if ($type instanceof NonNull) { - // Note: we're not checking that the result of coerceValue is non-null. - // We only call this function after calling isValidPHPValue. + if ($value === null) { + // Intentionally return no value. + return $undefined; + } return self::coerceValue($type->getWrappedType(), $value); } @@ -248,32 +283,57 @@ class Values if ($type instanceof ListOfType) { $itemType = $type->getWrappedType(); if (is_array($value) || $value instanceof \Traversable) { - return Utils::map($value, function($item) use ($itemType) { - return Values::coerceValue($itemType, $item); - }); + $coercedValues = []; + foreach ($value as $item) { + $itemValue = self::coerceValue($itemType, $item); + if ($undefined === $itemValue) { + // Intentionally return no value. + return $undefined; + } + $coercedValues[] = $itemValue; + } + return $coercedValues; } else { - return [self::coerceValue($itemType, $value)]; + $coercedValue = self::coerceValue($itemType, $value); + if ($coercedValue === $undefined) { + // Intentionally return no value. + return $undefined; + } + return [$coercedValue]; } } if ($type instanceof InputObjectType) { + $coercedObj = []; $fields = $type->getFields(); - $obj = []; foreach ($fields as $fieldName => $field) { - $fieldValue = self::coerceValue($field->getType(), isset($value[$fieldName]) ? $value[$fieldName] : null); - if (null === $fieldValue) { - $fieldValue = $field->defaultValue; + if (!array_key_exists($fieldName, $value)) { + if ($field->defaultValueExists()) { + $coercedObj[$fieldName] = $field->defaultValue; + } else if ($field->getType() instanceof NonNull) { + // Intentionally return no value. + return $undefined; + } + continue; } - if (null !== $fieldValue) { - $obj[$fieldName] = $fieldValue; + $fieldValue = self::coerceValue($field->getType(), $value[$fieldName]); + if ($fieldValue === $undefined) { + // Intentionally return no value. + return $undefined; } + $coercedObj[$fieldName] = $fieldValue; } - return $obj; - + return $coercedObj; } if ($type instanceof LeafType) { - return $type->parseValue($value); + $parsed = $type->parseValue($value); + if (null === $parsed) { + // null or invalid values represent a failure to parse correctly, + // in which case no value is returned. + return $undefined; + } + return $parsed; } throw new InvariantViolation('Must be input type'); diff --git a/src/Language/AST/NullValue.php b/src/Language/AST/NullValue.php index e700bed..1b7f423 100644 --- a/src/Language/AST/NullValue.php +++ b/src/Language/AST/NullValue.php @@ -4,10 +4,4 @@ namespace GraphQL\Language\AST; class NullValue extends Node implements Value { public $kind = NodeType::NULL; - - public static function getNullValue() - { - static $nullValue; - return $nullValue ?: $nullValue = new \stdClass(); - } } diff --git a/src/Utils.php b/src/Utils.php index 17f32f0..903ffc9 100644 --- a/src/Utils.php +++ b/src/Utils.php @@ -9,6 +9,12 @@ use \Traversable, \InvalidArgumentException; class Utils { + public static function undefined() + { + static $undefined; + return $undefined ?: $undefined = new \stdClass(); + } + /** * @param object $obj * @param array $vars @@ -231,11 +237,7 @@ class Utils public static function printSafe($var) { if ($var instanceof Type) { - // FIXME: Replace with schema printer call - if ($var instanceof WrappingType) { - $var = $var->getWrappedType(true); - } - return $var->name; + return $var->toString(); } if (is_object($var)) { return 'instance of ' . get_class($var); diff --git a/src/Utils/AST.php b/src/Utils/AST.php index 190e6d2..30183c0 100644 --- a/src/Utils/AST.php +++ b/src/Utils/AST.php @@ -4,6 +4,7 @@ namespace GraphQL\Utils; use GraphQL\Error\InvariantViolation; use GraphQL\Language\AST\BooleanValue; use GraphQL\Language\AST\EnumValue; +use GraphQL\Language\AST\Field; use GraphQL\Language\AST\FloatValue; use GraphQL\Language\AST\IntValue; use GraphQL\Language\AST\ListValue; @@ -12,6 +13,7 @@ use GraphQL\Language\AST\NullValue; use GraphQL\Language\AST\ObjectField; use GraphQL\Language\AST\ObjectValue; use GraphQL\Language\AST\StringValue; +use GraphQL\Language\AST\Value; use GraphQL\Language\AST\Variable; use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\IDType; @@ -178,6 +180,9 @@ class AST * A GraphQL type must be provided, which will be used to interpret different * GraphQL Value literals. * + * Returns `null` when the value could not be validly coerced according to + * the provided type. + * * | GraphQL Value | PHP Value | * | -------------------- | ------------- | * | Input Object | Assoc Array | @@ -186,7 +191,7 @@ class AST * | String | String | * | Int / Float | Int / Float | * | Enum Value | Mixed | - * | Null Value | null | + * | Null Value | stdClass | instance of NullValue::getNullValue() * * @param $valueAST * @param InputType $type @@ -196,30 +201,33 @@ class AST */ public static function valueFromAST($valueAST, InputType $type, $variables = null) { - if ($type instanceof NonNull) { - // Note: we're not checking that the result of valueFromAST is non-null. - // We're assuming that this query has been validated and the value used - // here is of the correct type. - return self::valueFromAST($valueAST, $type->getWrappedType(), $variables); - } + $undefined = Utils::undefined(); if (!$valueAST) { // When there is no AST, then there is also no value. // Importantly, this is different from returning the GraphQL null value. - return ; + return $undefined; + } + + if ($type instanceof NonNull) { + if ($valueAST instanceof NullValue) { + // Invalid: intentionally return no value. + return $undefined; + } + return self::valueFromAST($valueAST, $type->getWrappedType(), $variables); } if ($valueAST instanceof NullValue) { // This is explicitly returning the value null. - return NullValue::getNullValue(); + return null; } if ($valueAST instanceof Variable) { $variableName = $valueAST->name->value; - if (!$variables || !isset($variables[$variableName])) { + if (!$variables || !array_key_exists($variableName, $variables)) { // No valid return value. - return ; + return $undefined; } // Note: we're not doing any checking that this variable is correct. We're // assuming that this query has been validated and the variable usage here @@ -229,51 +237,99 @@ class AST if ($type instanceof ListOfType) { $itemType = $type->getWrappedType(); - $items = $valueAST instanceof ListValue ? $valueAST->values : [$valueAST]; - $result = []; - foreach ($items as $itemAST) { - $value = self::valueFromAST($itemAST, $itemType, $variables); - if ($value === NullValue::getNullValue()) { - $value = null; + + if ($valueAST instanceof ListValue) { + $coercedValues = []; + $itemASTs = $valueAST->values; + foreach ($itemASTs as $itemAST) { + if (self::isMissingVariable($itemAST, $variables)) { + // If an array contains a missing variable, it is either coerced to + // null or if the item type is non-null, it considered invalid. + if ($itemType instanceof NonNull) { + // Invalid: intentionally return no value. + return $undefined; + } + $coercedValues[] = null; + } else { + $itemValue = self::valueFromAST($itemAST, $itemType, $variables); + if ($undefined === $itemValue) { + // Invalid: intentionally return no value. + return $undefined; + } + $coercedValues[] = $itemValue; + } } - $result[] = $value; + return $coercedValues; } - return $result; + $coercedValue = self::valueFromAST($valueAST, $itemType, $variables); + if ($undefined === $coercedValue) { + // Invalid: intentionally return no value. + return $undefined; + } + return [$coercedValue]; } if ($type instanceof InputObjectType) { - $fields = $type->getFields(); if (!$valueAST instanceof ObjectValue) { - // No valid return value. - return ; + // Invalid: intentionally return no value. + return $undefined; } + + $coercedObj = []; + $fields = $type->getFields(); $fieldASTs = Utils::keyMap($valueAST->fields, function($field) {return $field->name->value;}); - $values = []; foreach ($fields as $field) { - $fieldAST = isset($fieldASTs[$field->name]) ? $fieldASTs[$field->name] : null; + /** @var Value $fieldAST */ + $fieldName = $field->name; + $fieldAST = isset($fieldASTs[$fieldName]) ? $fieldASTs[$fieldName] : null; + + if (!$fieldAST || self::isMissingVariable($fieldAST->value, $variables)) { + if ($field->defaultValueExists()) { + $coercedObj[$fieldName] = $field->defaultValue; + } else if ($field->getType() instanceof NonNull) { + // Invalid: intentionally return no value. + return $undefined; + } + continue ; + } + $fieldValue = self::valueFromAST($fieldAST ? $fieldAST->value : null, $field->getType(), $variables); - // If field is not in AST and defaultValue was never set for this field - do not include it in result - if (null === $fieldValue && null === $field->defaultValue && !$field->defaultValueExists()) { - continue; + if ($undefined === $fieldValue) { + // Invalid: intentionally return no value. + return $undefined; } - - // Set Explicit null value or default value: - if (NullValue::getNullValue() === $fieldValue) { - $fieldValue = null; - } else if (null === $fieldValue) { - $fieldValue = $field->defaultValue; - } - - $values[$field->name] = $fieldValue; + $coercedObj[$fieldName] = $fieldValue; } - return $values; + return $coercedObj; } if ($type instanceof LeafType) { - return $type->parseLiteral($valueAST); + $parsed = $type->parseLiteral($valueAST); + + if (null === $parsed) { + // null represent a failure to parse correctly, + // in which case no value is returned. + return $undefined; + } + + return $parsed; } throw new InvariantViolation('Must be input type'); } + + + /** + * Returns true if the provided valueAST is a variable which is not defined + * in the set of variables. + * @param $valueAST + * @param $variables + * @return bool + */ + private static function isMissingVariable($valueAST, $variables) + { + return $valueAST instanceof Variable && + (!$variables || !array_key_exists($valueAST->name->value, $variables)); + } } diff --git a/src/Validator/DocumentValidator.php b/src/Validator/DocumentValidator.php index f61a440..02ee918 100644 --- a/src/Validator/DocumentValidator.php +++ b/src/Validator/DocumentValidator.php @@ -157,14 +157,10 @@ class DocumentValidator { // A value must be provided if the type is non-null. if ($type instanceof NonNull) { - $wrappedType = $type->getWrappedType(); if (!$valueAST || $valueAST instanceof NullValue) { - if ($wrappedType->name) { - return [ "Expected \"{$wrappedType->name}!\", found null." ]; - } - return ['Expected non-null value, found null.']; + return [ 'Expected "' . Utils::printSafe($type) . '", found null.' ]; } - return static::isValidLiteralValue($wrappedType, $valueAST); + return static::isValidLiteralValue($type->getWrappedType(), $valueAST); } if (!$valueAST || $valueAST instanceof NullValue) { diff --git a/src/Validator/Rules/QueryComplexity.php b/src/Validator/Rules/QueryComplexity.php index 9646897..5ef6055 100644 --- a/src/Validator/Rules/QueryComplexity.php +++ b/src/Validator/Rules/QueryComplexity.php @@ -199,7 +199,7 @@ class QueryComplexity extends AbstractQuerySecurity $this->variableDefs, $rawVariableValues ); - $args = Values::getArgumentValues($fieldDef->args, $node->arguments, $variableValues); + $args = Values::getArgumentValues($fieldDef, $node, $variableValues); } return $args; diff --git a/tests/Executor/AbstractTest.php b/tests/Executor/AbstractTest.php index 13df495..9babcb9 100644 --- a/tests/Executor/AbstractTest.php +++ b/tests/Executor/AbstractTest.php @@ -251,12 +251,11 @@ class AbstractTest extends \PHPUnit_Framework_TestCase null ] ], - 'errors' => [ - FormattedError::create( - 'Runtime Object type "Human" is not a possible type for "Pet".', - [new SourceLocation(2, 11)] - ) - ] + 'errors' => [[ + 'message' => 'Runtime Object type "Human" is not a possible type for "Pet".', + 'locations' => [['line' => 2, 'column' => 11]], + 'path' => ['pets', 2] + ]] ]; $actual = GraphQL::execute($schema, $query); @@ -349,12 +348,11 @@ class AbstractTest extends \PHPUnit_Framework_TestCase null ] ], - 'errors' => [ - FormattedError::create( - 'Runtime Object type "Human" is not a possible type for "Pet".', - [new SourceLocation(2, 11)] - ) - ] + 'errors' => [[ + 'message' => 'Runtime Object type "Human" is not a possible type for "Pet".', + 'locations' => [['line' => 2, 'column' => 11]], + 'path' => ['pets', 2] + ]] ]; $this->assertEquals($expected, $result); } diff --git a/tests/Executor/VariablesTest.php b/tests/Executor/VariablesTest.php index cb8cdb9..1a2b9c5 100644 --- a/tests/Executor/VariablesTest.php +++ b/tests/Executor/VariablesTest.php @@ -81,19 +81,24 @@ class VariablesTest extends \PHPUnit_Framework_TestCase $result = Executor::execute($this->schema(), $ast)->toArray(); $expected = [ - 'data' => ['fieldWithObjectInput' => null] + 'data' => ['fieldWithObjectInput' => null], + 'errors' => [[ + 'message' => 'Argument "input" got invalid value ["foo", "bar", "baz"].' . "\n" . + 'Expected "TestInputObject", found not an object.', + 'path' => ['fieldWithObjectInput'] + ]] ]; - $this->assertEquals($expected, $result); + $this->assertArraySubset($expected, $result); // properly runs parseLiteral on complex scalar types $doc = ' { - fieldWithObjectInput(input: {a: "foo", d: "SerializedValue"}) + fieldWithObjectInput(input: {c: "foo", d: "SerializedValue"}) } '; $ast = Parser::parse($doc); $this->assertEquals( - ['data' => ['fieldWithObjectInput' => '{"a":"foo","d":"DeserializedValue"}']], + ['data' => ['fieldWithObjectInput' => '{"c":"foo","d":"DeserializedValue"}']], Executor::execute($this->schema(), $ast)->toArray() ); } @@ -332,6 +337,24 @@ class VariablesTest extends \PHPUnit_Framework_TestCase // Describe: Handles non-nullable scalars + /** + * @it allows non-nullable inputs to be omitted given a default + */ + public function testAllowsNonNullableInputsToBeOmittedGivenADefault() + { + $doc = ' + query SetsNonNullable($value: String = "default") { + fieldWithNonNullableStringInput(input: $value) + } + '; + $ast = Parser::parse($doc); + $expected = [ + 'data' => ['fieldWithNonNullableStringInput' => '"default"'] + ]; + $this->assertEquals($expected, Executor::execute($this->schema(), $ast)->toArray()); + + } + /** * @it does not allow non-nullable inputs to be omitted in a variable */ @@ -368,13 +391,15 @@ class VariablesTest extends \PHPUnit_Framework_TestCase $ast = Parser::parse($doc); try { - Executor::execute($this->schema(), $ast, null, ['value' => null]); + Executor::execute($this->schema(), $ast, null, null, ['value' => null]); $this->fail('Expected exception not thrown'); } catch (Error $e) { - $expected = FormattedError::create( - 'Variable "$value" of required type "String!" was not provided.', - [new SourceLocation(2, 31)] - ); + $expected = [ + 'message' => + 'Variable "$value" got invalid value null.' . "\n". + 'Expected "String!", found null.', + 'locations' => [['line' => 2, 'column' => 31]] + ]; $this->assertEquals($expected, Error::formatError($e)); } } @@ -411,9 +436,9 @@ class VariablesTest extends \PHPUnit_Framework_TestCase } /** - * @it passes along null for non-nullable inputs if explcitly set in the query + * @it reports error for missing non-nullable inputs */ - public function testPassesAlongNullForNonNullableInputsIfExplcitlySetInTheQuery() + public function testReportsErrorForMissingNonNullableInputs() { $doc = ' { @@ -421,7 +446,44 @@ class VariablesTest extends \PHPUnit_Framework_TestCase } '; $ast = Parser::parse($doc); - $expected = ['data' => ['fieldWithNonNullableStringInput' => null]]; + $expected = [ + 'data' => ['fieldWithNonNullableStringInput' => null], + 'errors' => [[ + 'message' => 'Argument "input" of required type "String!" was not provided.', + 'locations' => [ [ 'line' => 3, 'column' => 9 ] ], + 'path' => [ 'fieldWithNonNullableStringInput' ] + ]] + ]; + $this->assertEquals($expected, Executor::execute($this->schema(), $ast)->toArray()); + } + + /** + * @it reports error for non-provided variables for non-nullable inputs + */ + public function testReportsErrorForNonProvidedVariablesForNonNullableInputs() + { + // Note: this test would typically fail validation before encountering + // this execution error, however for queries which previously validated + // and are being run against a new schema which have introduced a breaking + // change to make a formerly non-required argument required, this asserts + // failure before allowing the underlying code to receive a non-null value. + $doc = ' + { + fieldWithNonNullableStringInput(input: $foo) + } + '; + $ast = Parser::parse($doc); + + $expected = [ + 'data' => ['fieldWithNonNullableStringInput' => null], + 'errors' => [[ + 'message' => + 'Argument "input" of required type "String!" was provided the ' . + 'variable "$foo" which was not provided a runtime value.', + 'locations' => [['line' => 3, 'column' => 48]], + 'path' => ['fieldWithNonNullableStringInput'] + ]] + ]; $this->assertEquals($expected, Executor::execute($this->schema(), $ast)->toArray()); } @@ -485,7 +547,8 @@ class VariablesTest extends \PHPUnit_Framework_TestCase '; $ast = Parser::parse($doc); $expected = FormattedError::create( - 'Variable "$input" of required type "[String]!" was not provided.', + 'Variable "$input" got invalid value null.' . "\n" . + 'Expected "[String]!", found null.', [new SourceLocation(2, 17)] ); @@ -596,7 +659,8 @@ class VariablesTest extends \PHPUnit_Framework_TestCase '; $ast = Parser::parse($doc); $expected = FormattedError::create( - 'Variable "$input" of required type "[String!]!" was not provided.', + 'Variable "$input" got invalid value null.' . "\n" . + 'Expected "[String!]!", found null.', [new SourceLocation(2, 17)] ); try { @@ -663,11 +727,12 @@ class VariablesTest extends \PHPUnit_Framework_TestCase Executor::execute($this->schema(), $ast, null, null, $vars); $this->fail('Expected exception not thrown'); } catch (Error $error) { - $expected = FormattedError::create( - 'Variable "$input" expected value of type "TestType!" which cannot ' . - 'be used as an input type.', - [new SourceLocation(2, 17)] - ); + $expected = [ + 'message' => + 'Variable "$input" expected value of type "TestType!" which cannot ' . + 'be used as an input type.', + 'locations' => [['line' => 2, 'column' => 25]] + ]; $this->assertEquals($expected, Error::formatError($error)); } } @@ -692,7 +757,7 @@ class VariablesTest extends \PHPUnit_Framework_TestCase $expected = FormattedError::create( 'Variable "$input" expected value of type "UnknownType!" which ' . 'cannot be used as an input type.', - [new SourceLocation(2, 17)] + [new SourceLocation(2, 25)] ); $this->assertEquals($expected, Error::formatError($error)); } @@ -715,9 +780,9 @@ class VariablesTest extends \PHPUnit_Framework_TestCase } /** - * @it when nullable variable provided + * @it when omitted variable provided */ - public function testWhenNullableVariableProvided() + public function testWhenOmittedVariableProvided() { $ast = Parser::parse('query optionalVariable($optional: String) { fieldWithDefaultArgumentValue(input: $optional) @@ -730,16 +795,27 @@ class VariablesTest extends \PHPUnit_Framework_TestCase } /** - * @it when argument provided cannot be parsed + * @it not when argument cannot be coerced */ - public function testWhenArgumentProvidedCannotBeParsed() + public function testNotWhenArgumentCannotBeCoerced() { $ast = Parser::parse('{ fieldWithDefaultArgumentValue(input: WRONG_TYPE) }'); + $expected = [ + 'data' => ['fieldWithDefaultArgumentValue' => null], + 'errors' => [[ + 'message' => + 'Argument "input" got invalid value WRONG_TYPE.' . "\n" . + 'Expected type "String", found WRONG_TYPE.', + 'locations' => [ [ 'line' => 2, 'column' => 50 ] ], + 'path' => [ 'fieldWithDefaultArgumentValue' ] + ]] + ]; + $this->assertEquals( - ['data' => ['fieldWithDefaultArgumentValue' => '"Hello World"']], + $expected, Executor::execute($this->schema(), $ast)->toArray() ); } diff --git a/tests/Utils/ValueFromAstTest.php b/tests/Utils/ValueFromAstTest.php new file mode 100644 index 0000000..15799da --- /dev/null +++ b/tests/Utils/ValueFromAstTest.php @@ -0,0 +1,244 @@ +assertEquals($expected, AST::valueFromAST(Parser::parseValue($valueText), $type)); + } + + private function runTestCaseWithVars($variables, $type, $valueText, $expected) + { + $this->assertEquals($expected, AST::valueFromAST(Parser::parseValue($valueText), $type, $variables)); + } + + /** + * @it rejects empty input + */ + public function testRejectsEmptyInput() + { + $this->assertEquals(Utils::undefined(), AST::valueFromAST(null, Type::boolean())); + } + + /** + * @it converts according to input coercion rules + */ + public function testConvertsAccordingToInputCoercionRules() + { + $this->runTestCase(Type::boolean(), 'true', true); + $this->runTestCase(Type::boolean(), 'false', false); + $this->runTestCase(Type::int(), '123', 123); + $this->runTestCase(Type::float(), '123', 123); + $this->runTestCase(Type::float(), '123.456', 123.456); + $this->runTestCase(Type::string(), '"abc123"', 'abc123'); + $this->runTestCase(Type::id(), '123456', '123456'); + $this->runTestCase(Type::id(), '"123456"', '123456'); + } + + /** + * @it does not convert when input coercion rules reject a value + */ + public function testDoesNotConvertWhenInputCoercionRulesRejectAValue() + { + $undefined = Utils::undefined(); + + $this->runTestCase(Type::boolean(), '123', $undefined); + $this->runTestCase(Type::int(), '123.456', $undefined); + $this->runTestCase(Type::int(), 'true', $undefined); + $this->runTestCase(Type::int(), '"123"', $undefined); + $this->runTestCase(Type::float(), '"123"', $undefined); + $this->runTestCase(Type::string(), '123', $undefined); + $this->runTestCase(Type::string(), 'true', $undefined); + $this->runTestCase(Type::id(), '123.456', $undefined); + } + + /** + * @it converts enum values according to input coercion rules + */ + public function testConvertsEnumValuesAccordingToInputCoercionRules() + { + $testEnum = new EnumType([ + 'name' => 'TestColor', + 'values' => [ + 'RED' => ['value' => 1], + 'GREEN' => ['value' => 2], + 'BLUE' => ['value' => 3], + ] + ]); + + $this->runTestCase($testEnum, 'RED', 1); + $this->runTestCase($testEnum, 'BLUE', 3); + $this->runTestCase($testEnum, '3', Utils::undefined()); + $this->runTestCase($testEnum, '"BLUE"', Utils::undefined()); + $this->runTestCase($testEnum, 'null', null); + } + + /** + * @it coerces to null unless non-null + */ + public function testCoercesToNullUnlessNonNull() + { + $this->runTestCase(Type::boolean(), 'null', null); + $this->runTestCase(Type::nonNull(Type::boolean()), 'null', Utils::undefined()); + } + + /** + * @it coerces lists of values + */ + public function testCoercesListsOfValues() + { + $listOfBool = Type::listOf(Type::boolean()); + $undefined = Utils::undefined(); + + $this->runTestCase($listOfBool, 'true', [ true ]); + $this->runTestCase($listOfBool, '123', $undefined); + $this->runTestCase($listOfBool, 'null', null); + $this->runTestCase($listOfBool, '[true, false]', [ true, false ]); + $this->runTestCase($listOfBool, '[true, 123]', $undefined); + $this->runTestCase($listOfBool, '[true, null]', [ true, null ]); + $this->runTestCase($listOfBool, '{ true: true }', $undefined); + } + + /** + * @it coerces non-null lists of values + */ + public function testCoercesNonNullListsOfValues() + { + $nonNullListOfBool = Type::nonNull(Type::listOf(Type::boolean())); + $undefined = Utils::undefined(); + + $this->runTestCase($nonNullListOfBool, 'true', [ true ]); + $this->runTestCase($nonNullListOfBool, '123', $undefined); + $this->runTestCase($nonNullListOfBool, 'null', $undefined); + $this->runTestCase($nonNullListOfBool, '[true, false]', [ true, false ]); + $this->runTestCase($nonNullListOfBool, '[true, 123]', $undefined); + $this->runTestCase($nonNullListOfBool, '[true, null]', [ true, null ]); + } + + /** + * @it coerces lists of non-null values + */ + public function testCoercesListsOfNonNullValues() + { + $listOfNonNullBool = Type::listOf(Type::nonNull(Type::boolean())); + $undefined = Utils::undefined(); + + $this->runTestCase($listOfNonNullBool, 'true', [ true ]); + $this->runTestCase($listOfNonNullBool, '123', $undefined); + $this->runTestCase($listOfNonNullBool, 'null', null); + $this->runTestCase($listOfNonNullBool, '[true, false]', [ true, false ]); + $this->runTestCase($listOfNonNullBool, '[true, 123]', $undefined); + $this->runTestCase($listOfNonNullBool, '[true, null]', $undefined); + } + + /** + * @it coerces non-null lists of non-null values + */ + public function testCoercesNonNullListsOfNonNullValues() + { + $nonNullListOfNonNullBool = Type::nonNull(Type::listOf(Type::nonNull(Type::boolean()))); + $undefined = Utils::undefined(); + + $this->runTestCase($nonNullListOfNonNullBool, 'true', [ true ]); + $this->runTestCase($nonNullListOfNonNullBool, '123', $undefined); + $this->runTestCase($nonNullListOfNonNullBool, 'null', $undefined); + $this->runTestCase($nonNullListOfNonNullBool, '[true, false]', [ true, false ]); + $this->runTestCase($nonNullListOfNonNullBool, '[true, 123]', $undefined); + $this->runTestCase($nonNullListOfNonNullBool, '[true, null]', $undefined); + } + + private $inputObj; + + private function inputObj() + { + return $this->inputObj ?: $this->inputObj = new InputObjectType([ + 'name' => 'TestInput', + 'fields' => [ + 'int' => [ 'type' => Type::int(), 'defaultValue' => 42 ], + 'bool' => [ 'type' => Type::boolean() ], + 'requiredBool' => [ 'type' => Type::nonNull(Type::boolean()) ], + ] + ]); + } + + /** + * @it coerces input objects according to input coercion rules + */ + public function testCoercesInputObjectsAccordingToInputCoercionRules() + { + $testInputObj = $this->inputObj(); + $undefined = Utils::undefined(); + + $this->runTestCase($testInputObj, 'null', null); + $this->runTestCase($testInputObj, '123', $undefined); + $this->runTestCase($testInputObj, '[]', $undefined); + $this->runTestCase($testInputObj, '{ int: 123, requiredBool: false }', ['int' => 123, 'requiredBool' => false]); + $this->runTestCase($testInputObj, '{ bool: true, requiredBool: false }', [ 'int' => 42, 'bool' => true, 'requiredBool' => false ]); + $this->runTestCase($testInputObj, '{ int: true, requiredBool: true }', $undefined); + $this->runTestCase($testInputObj, '{ requiredBool: null }', $undefined); + $this->runTestCase($testInputObj, '{ bool: true }', $undefined); + } + + /** + * @it accepts variable values assuming already coerced + */ + public function testAcceptsVariableValuesAssumingAlreadyCoerced() + { + $this->runTestCaseWithVars([], Type::boolean(), '$var', Utils::undefined()); + $this->runTestCaseWithVars([ 'var' => true ], Type::boolean(), '$var', true); + $this->runTestCaseWithVars([ 'var' => null ], Type::boolean(), '$var', null); + } + + /** + * @it asserts variables are provided as items in lists + */ + public function testAssertsVariablesAreProvidedAsItemsInLists() + { + $listOfBool = Type::listOf(Type::boolean()); + $listOfNonNullBool = Type::listOf(Type::nonNull(Type::boolean())); + + $this->runTestCaseWithVars([], $listOfBool, '[ $foo ]', [ null ]); + $this->runTestCaseWithVars([], $listOfNonNullBool, '[ $foo ]', Utils::undefined()); + $this->runTestCaseWithVars([ 'foo' => true ], $listOfNonNullBool, '[ $foo ]', [ true ]); + // Note: variables are expected to have already been coerced, so we + // do not expect the singleton wrapping behavior for variables. + $this->runTestCaseWithVars([ 'foo' => true ], $listOfNonNullBool, '$foo', true); + $this->runTestCaseWithVars([ 'foo' => [ true ] ], $listOfNonNullBool, '$foo', [ true ]); + } + + /** + * @it omits input object fields for unprovided variables + */ + public function testOmitsInputObjectFieldsForUnprovidedVariables() + { + $testInputObj = $this->inputObj(); + + $this->runTestCaseWithVars( + [], + $testInputObj, + '{ int: $foo, bool: $foo, requiredBool: true }', + [ 'int' => 42, 'requiredBool' => true ] + ); + $this->runTestCaseWithVars( + [], + $testInputObj, + '{ requiredBool: $foo }', + Utils::undefined() + ); + $this->runTestCaseWithVars( + [ 'foo' => true ], + $testInputObj, + '{ requiredBool: $foo }', + [ 'int' => 42, 'requiredBool' => true ] + ); + } +}