diff --git a/src/Executor/Values.php b/src/Executor/Values.php index 5068f53..5c57c35 100644 --- a/src/Executor/Values.php +++ b/src/Executor/Values.php @@ -4,24 +4,15 @@ namespace GraphQL\Executor; use GraphQL\Error; use GraphQL\Language\AST\Argument; -use GraphQL\Language\AST\ListType; -use GraphQL\Language\AST\ListValue; -use GraphQL\Language\AST\Node; -use GraphQL\Language\AST\ObjectValue; -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\EnumType; use GraphQL\Type\Definition\FieldArgument; -use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InputType; use GraphQL\Type\Definition\ListOfType; use GraphQL\Type\Definition\NonNull; -use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\ScalarType; use GraphQL\Type\Definition\Type; use GraphQL\Utils; @@ -70,7 +61,7 @@ class Values foreach ($argDefs as $argDef) { $name = $argDef->name; $valueAST = isset($argASTMap[$name]) ? $argASTMap[$name]->value : null; - $value = self::valueFromAST($valueAST, $argDef->getType(), $variableValues); + $value = Utils\AST::valueFromAST($valueAST, $argDef->getType(), $variableValues); if (null === $value) { $value = $argDef->defaultValue; @@ -84,57 +75,7 @@ class Values public static function valueFromAST($valueAST, InputType $type, $variables = null) { - if ($type instanceof NonNull) { - return self::valueFromAST($valueAST, $type->getWrappedType(), $variables); - } - - if (!$valueAST) { - return null; - } - - if ($valueAST instanceof Variable) { - $variableName = $valueAST->name->value; - - if (!$variables || !isset($variables[$variableName])) { - return null; - } - return $variables[$variableName]; - } - - if ($type instanceof ListOfType) { - $itemType = $type->getWrappedType(); - if ($valueAST instanceof ListValue) { - return array_map(function($itemAST) use ($itemType, $variables) { - return Values::valueFromAST($itemAST, $itemType, $variables); - }, $valueAST->values); - } else { - return [self::valueFromAST($valueAST, $itemType, $variables)]; - } - } - - if ($type instanceof InputObjectType) { - $fields = $type->getFields(); - if (!$valueAST instanceof ObjectValue) { - return null; - } - $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; - $fieldValue = self::valueFromAST($fieldAST ? $fieldAST->value : null, $field->getType(), $variables); - - if (null === $fieldValue) { - $fieldValue = $field->defaultValue; - } - if (null !== $fieldValue) { - $values[$field->name] = $fieldValue; - } - } - return $values; - } - - Utils::invariant($type instanceof ScalarType || $type instanceof EnumType, 'Must be input type'); - return $type->parseLiteral($valueAST); + return Utils\AST::valueFromAST($valueAST, $type, $variables); } /** @@ -154,24 +95,38 @@ class Values [ $definitionAST ] ); } - if (self::isValidValue($input, $type)) { + + $inputType = $type; + $errors = self::isValidPHPValue($input, $inputType); + + if (empty($errors)) { if (null === $input) { $defaultValue = $definitionAST->defaultValue; if ($defaultValue) { - return self::valueFromAST($defaultValue, $type); + return Utils\AST::valueFromAST($defaultValue, $inputType); } } - return self::coerceValue($type, $input); + 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 \${$definitionAST->variable->name->value} expected value of type " . - Printer::doPrint($definitionAST->type) . " but got: " . json_encode($input) . '.', - [$definitionAST] + "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 @@ -179,63 +134,89 @@ class Values * * @param $value * @param Type $type - * @return bool + * @return array */ - private static function isValidValue($value, Type $type) + private static function isValidPHPValue($value, InputType $type) { + // A value must be provided if the type is non-null. if ($type instanceof NonNull) { + $ofType = $type->getWrappedType(); if (null === $value) { - return false; + if ($ofType->name) { + return [ "Expected \"{$ofType->name}!\", found null." ]; + } + return [ 'Expected non-null value, found null.' ]; } - return self::isValidValue($value, $type->getWrappedType()); + return self::isValidPHPValue($value, $ofType); } - if ($value === null) { - return true; + if (null === $value) { + return []; } + // Lists accept a non-list value as a list of one. if ($type instanceof ListOfType) { $itemType = $type->getWrappedType(); if (is_array($value)) { - foreach ($value as $item) { - if (!self::isValidValue($item, $itemType)) { - return false; - } - } - return true; - } else { - return self::isValidValue($value, $itemType); + return array_reduce( + $value, + function ($acc, $item, $index) use ($itemType) { + $errors = self::isValidPHPValue($item, $itemType); + return array_merge($acc, Utils::map($errors, function ($error) use ($index) { + return "In element #$index: $error"; + })); + }, + [] + ); } + return self::isValidPHPValue($value, $itemType); } + // Input objects check each defined field. if ($type instanceof InputObjectType) { - if (!is_array($value)) { - return false; + if (!is_object($value) && !is_array($value)) { + return ["Expected \"{$type->name}\", found not an object."]; } $fields = $type->getFields(); - $fieldMap = []; - - // Ensure every defined field is valid. - foreach ($fields as $fieldName => $field) { - /** @var FieldDefinition $field */ - if (!self::isValidValue(isset($value[$fieldName]) ? $value[$fieldName] : null, $field->getType())) { - return false; - } - $fieldMap[$field->name] = $field; - } + $errors = []; // Ensure every provided field is defined. - $diff = array_diff_key($value, $fieldMap); - - if (!empty($diff)) { - return false; + $props = is_object($value) ? get_object_vars($value) : $value; + foreach ($props as $providedField => $tmp) { + if (!isset($fields[$providedField])) { + $errors[] = "In field \"{$providedField}\": Unknown field."; + } } - return true; + // Ensure every defined field is valid. + foreach ($fields as $fieldName => $tmp) { + $newErrors = self::isValidPHPValue($value[$fieldName], $fields[$fieldName]->getType()); + $errors = array_merge( + $errors, + Utils::map($newErrors, function ($error) use ($fieldName) { + return "In field \"{$fieldName}\": {$error}"; + }) + ); + } + return $errors; } - Utils::invariant($type instanceof ScalarType || $type instanceof EnumType, 'Must be input type'); - return null !== $type->parseValue($value); + Utils::invariant( + $type instanceof ScalarType || $type instanceof EnumType, + 'Must be input type' + ); + + // Scalar/Enum input checks to ensure the type can parse the value to + // a non-null value. + $parseResult = $type->parseValue($value); + if (null === $parseResult) { + $v = json_encode($value); + return [ + "Expected type \"{$type->name}\", found $v." + ]; + } + + return []; } /** @@ -245,7 +226,7 @@ class Values { if ($type instanceof NonNull) { // Note: we're not checking that the result of coerceValue is non-null. - // We only call this function after calling isValidValue. + // We only call this function after calling isValidPHPValue. return self::coerceValue($type->getWrappedType(), $value); } diff --git a/src/Utils/AST.php b/src/Utils/AST.php new file mode 100644 index 0000000..b58a14c --- /dev/null +++ b/src/Utils/AST.php @@ -0,0 +1,207 @@ +getWrappedType()); + } + + if ($value === null) { + return null; + } + + // Check if $value is associative array, assuming that associative array always has first key as string + // (can make such assumption because GraphQL field names can never be integers, so mixed arrays are not valid anyway) + $isAssoc = false; + if (is_array($value)) { + if (!empty($value)) { + reset($value); + $isAssoc = is_string(key($value)); + } else { + $isAssoc = ($type instanceof InputObjectType) || !($type instanceof ListOfType); + } + } + + // Convert PHP array to GraphQL list. If the GraphQLType is a list, but + // the value is not an array, convert the value using the list's item type. + if (is_array($value) && !$isAssoc) { + $itemType = $type instanceof ListOfType ? $type->getWrappedType() : null; + return new ListValue([ + 'values' => Utils::map($value, function ($item) use ($itemType) { + $itemValue = self::astFromValue($item, $itemType); + Utils::invariant($itemValue, 'Could not create AST item.'); + return $itemValue; + }) + ]); + } else if ($type instanceof ListOfType) { + // Because GraphQL will accept single values as a "list of one" when + // expecting a list, if there's a non-array value and an expected list type, + // create an AST using the list's item type. + return self::astFromValue($value, $type->getWrappedType()); + } + + if (is_bool($value)) { + return new BooleanValue(['value' => $value]); + } + + if (is_int($value)) { + if ($type instanceof FloatType) { + return new FloatValue(['value' => (string)(float)$value]); + } + return new IntValue(['value' => $value]); + } else if (is_float($value)) { + $tmp = (int) $value; + if ($tmp == $value && (!$type instanceof FloatType)) { + return new IntValue(['value' => (string)$tmp]); + } else { + return new FloatValue(['value' => (string)$value]); + } + } + + // PHP strings can be Enum values or String values. Use the + // GraphQLType to differentiate if possible. + if (is_string($value)) { + if ($type instanceof EnumType && preg_match('/^[_a-zA-Z][_a-zA-Z0-9]*$/', $value)) { + return new EnumValue(['value' => $value]); + } + + // Use json_encode, which uses the same string encoding as GraphQL, + // then remove the quotes. + return new StringValue([ + 'value' => mb_substr(json_encode($value), 1, -1) + ]); + } + + // last remaining possible type + Utils::invariant(is_object($value) || $isAssoc); + + // Populate the fields of the input object by creating ASTs from each value + // in the PHP object. + $fields = []; + $tmp = $isAssoc ? $value : get_object_vars($value); + foreach ($tmp as $fieldName => $objValue) { + $fieldType = null; + if ($type instanceof InputObjectType) { + $tmp = $type->getFields(); + $fieldDef = isset($tmp[$fieldName]) ? $tmp[$fieldName] : null; + $fieldType = $fieldDef ? $fieldDef->getType() : null; + } + + $fieldValue = self::astFromValue($objValue, $fieldType); + + if ($fieldValue) { + $fields[] = new ObjectField([ + 'name' => new Name(['value' => $fieldName]), + 'value' => $fieldValue + ]); + } + } + return new ObjectValue([ + 'fields' => $fields + ]); + } + + /** + * @param $valueAST + * @param InputType $type + * @param null $variables + * @return array|null + * @throws \Exception + */ + public static function valueFromAST($valueAST, InputType $type, $variables = null) + { + if ($type instanceof NonNull) { + return self::valueFromAST($valueAST, $type->getWrappedType(), $variables); + } + + if (!$valueAST) { + return null; + } + + if ($valueAST instanceof Variable) { + $variableName = $valueAST->name->value; + + if (!$variables || !isset($variables[$variableName])) { + return null; + } + return $variables[$variableName]; + } + + if ($type instanceof ListOfType) { + $itemType = $type->getWrappedType(); + if ($valueAST instanceof ListValue) { + return array_map(function($itemAST) use ($itemType, $variables) { + return self::valueFromAST($itemAST, $itemType, $variables); + }, $valueAST->values); + } else { + return [self::valueFromAST($valueAST, $itemType, $variables)]; + } + } + + if ($type instanceof InputObjectType) { + $fields = $type->getFields(); + if (!$valueAST instanceof ObjectValue) { + return null; + } + $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; + $fieldValue = self::valueFromAST($fieldAST ? $fieldAST->value : null, $field->getType(), $variables); + + if (null === $fieldValue) { + $fieldValue = $field->defaultValue; + } + if (null !== $fieldValue) { + $values[$field->name] = $fieldValue; + } + } + return $values; + } + + Utils::invariant($type instanceof ScalarType || $type instanceof EnumType, 'Must be input type'); + return $type->parseLiteral($valueAST); + } +} diff --git a/src/Validator/Rules/VariablesInAllowedPosition.php b/src/Validator/Rules/VariablesInAllowedPosition.php index cd568b4..3d23cc6 100644 --- a/src/Validator/Rules/VariablesInAllowedPosition.php +++ b/src/Validator/Rules/VariablesInAllowedPosition.php @@ -19,16 +19,14 @@ class VariablesInAllowedPosition { static function badVarPosMessage($varName, $varType, $expectedType) { - return "Variable \$$varName of type $varType used in position expecting ". - "type $expectedType."; + return "Variable \"\$$varName\" of type \"$varType\" used in position expecting ". + "type \"$expectedType\"."; } public $varDefMap; public function __invoke(ValidationContext $context) { - $varDefMap = []; - return [ Node::OPERATION_DEFINITION => [ 'enter' => function () { diff --git a/tests/Utils/AstFromValueTest.php b/tests/Utils/AstFromValueTest.php new file mode 100644 index 0000000..355811d --- /dev/null +++ b/tests/Utils/AstFromValueTest.php @@ -0,0 +1,175 @@ +assertEquals(new BooleanValue(['value' => true]), AST::astFromValue(true)); + $this->assertEquals(new BooleanValue(['value' => false]), AST::astFromValue(false)); + } + + /** + * @it converts numeric values to ASTs + */ + public function testConvertsNumericValuesToASTs() + { + $this->assertEquals(new IntValue(['value' => '123']), AST::astFromValue(123)); + // $this->assertEquals(new IntValue(['value' => 123]), AST::astFromValue(123.0)); // doesn't make sense for PHP because it has float and int natively unlike JS + $this->assertEquals(new FloatValue(['value' => '123.5']), AST::astFromValue(123.5)); + $this->assertEquals(new IntValue(['value' => '10000']), AST::astFromValue(1e4)); + $this->assertEquals(new FloatValue(['value' => '1.0E+40']), AST::astFromValue(1e40)); // Note: js version will produce 1e+40, both values are valid GraphQL floats + } + + /** + * @it converts numeric values to Float ASTs + */ + public function testConvertsNumericValuesToFloatASTs() + { + $this->assertEquals(new FloatValue(['value' => '123.0']), AST::astFromValue(123, Type::float())); + $this->assertEquals(new FloatValue(['value' => '123.0']), AST::astFromValue(123.0, Type::float())); + $this->assertEquals(new FloatValue(['value' => '123.5']), AST::astFromValue(123.5, Type::float())); + $this->assertEquals(new FloatValue(['value' => '10000.0']), AST::astFromValue(1e4, Type::float())); + $this->assertEquals(new FloatValue(['value' => '1e+40']), AST::astFromValue(1e40, Type::float())); + } + + /** + * @it converts string values to ASTs + */ + public function testConvertsStringValuesToASTs() + { + $this->assertEquals(new StringValue(['value' => 'hello']), AST::astFromValue('hello')); + $this->assertEquals(new StringValue(['value' => 'VALUE']), AST::astFromValue('VALUE')); + $this->assertEquals(new StringValue(['value' => 'VA\\nLUE']), AST::astFromValue("VA\nLUE")); + $this->assertEquals(new StringValue(['value' => '123']), AST::astFromValue('123')); + } + + /** + * @it converts string values to Enum ASTs if possible + */ + public function testConvertsStringValuesToEnumASTsIfPossible() + { + $this->assertEquals(new EnumValue(['value' => 'hello']), AST::astFromValue('hello', $this->myEnum())); + $this->assertEquals(new EnumValue(['value' => 'HELLO']), AST::astFromValue('HELLO', $this->myEnum())); + $this->assertEquals(new EnumValue(['value' => 'VALUE']), AST::astFromValue('VALUE', $this->myEnum())); + $this->assertEquals(new StringValue(['value' => 'VA\\nLUE']), AST::astFromValue("VA\nLUE", $this->myEnum())); + $this->assertEquals(new StringValue(['value' => '123']), AST::astFromValue("123", $this->myEnum())); + } + + /** + * @it converts array values to List ASTs + */ + public function testConvertsArrayValuesToListASTs() + { + $value1 = new ListValue([ + 'values' => [ + new StringValue(['value' => 'FOO']), + new StringValue(['value' => 'BAR']) + ] + ]); + $this->assertEquals($value1, AST::astFromValue(['FOO', 'BAR'])); + + $value2 = new ListValue([ + 'values' => [ + new EnumValue(['value' => 'FOO']), + new EnumValue(['value' => 'BAR']), + ] + ]); + $this->assertEquals($value2, AST::astFromValue(['FOO', 'BAR'], Type::listOf($this->myEnum()))); + } + + /** + * @it converts list singletons + */ + public function testConvertsListSingletons() + { + $this->assertEquals(new EnumValue(['value' => 'FOO']), AST::astFromValue('FOO', Type::listOf($this->myEnum()))); + } + + /** + * @it converts input objects + */ + public function testConvertsInputObjects() + { + $expected = new ObjectValue([ + 'fields' => [ + $this->objectField('foo', new IntValue(['value' => 3])), + $this->objectField('bar', new StringValue(['value' => 'HELLO'])) + ] + ]); + + $data = ['foo' => 3, 'bar' => 'HELLO']; + $this->assertEquals($expected, AST::astFromValue($data)); + $this->assertEquals($expected, AST::astFromValue((object) $data)); + + $expected = new ObjectValue([ + 'fields' => [ + $this->objectField('foo', new FloatValue(['value' => '3.0'])), + $this->objectField('bar', new EnumValue(['value' => 'HELLO'])), + ] + ]); + $this->assertEquals($expected, AST::astFromValue($data, $this->inputObj())); + $this->assertEquals($expected, AST::astFromValue((object) $data, $this->inputObj())); + } + + /** + * @return EnumType + */ + private function myEnum() + { + return new EnumType([ + 'name' => 'MyEnum', + 'values' => [ + 'HELLO' => [], + 'GOODBYE' => [], + ] + ]); + } + + /** + * @return InputObjectField + */ + private function inputObj() + { + return new InputObjectType([ + 'name' => 'MyInputObj', + 'fields' => [ + 'foo' => ['type' => Type::float()], + 'bar' => ['type' => $this->myEnum()] + ] + ]); + } + + /** + * @param $name + * @param $value + * @return ObjectField + */ + private function objectField($name, $value) + { + return new ObjectField([ + 'name' => new Name(['value' => $name]), + 'value' => $value + ]); + } +}