From eb9ac66af8c493a04c29816fe5946981851a1ac0 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Thu, 8 Feb 2018 15:13:31 +0100 Subject: [PATCH 01/50] Fix how TypeInfo handles inline fragments without type ref: graphql/graphql-js#1041 --- src/Utils/TypeInfo.php | 2 +- tests/Language/VisitorTest.php | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Utils/TypeInfo.php b/src/Utils/TypeInfo.php index fa97d1b97..cb002349a 100644 --- a/src/Utils/TypeInfo.php +++ b/src/Utils/TypeInfo.php @@ -331,7 +331,7 @@ function enter(Node $node) case NodeKind::INLINE_FRAGMENT: case NodeKind::FRAGMENT_DEFINITION: $typeConditionNode = $node->typeCondition; - $outputType = $typeConditionNode ? self::typeFromAST($schema, $typeConditionNode) : $this->getType(); + $outputType = $typeConditionNode ? self::typeFromAST($schema, $typeConditionNode) : Type::getNamedType($this->getType()); $this->typeStack[] = Type::isOutputType($outputType) ? $outputType : null; // push break; diff --git a/tests/Language/VisitorTest.php b/tests/Language/VisitorTest.php index 2a856639e..d65c8aa36 100644 --- a/tests/Language/VisitorTest.php +++ b/tests/Language/VisitorTest.php @@ -1127,7 +1127,7 @@ public function testMaintainsTypeInfoDuringVisit() $typeInfo = new TypeInfo(TestCase::getDefaultSchema()); - $ast = Parser::parse('{ human(id: 4) { name, pets { name }, unknown } }'); + $ast = Parser::parse('{ human(id: 4) { name, pets { ... { name } }, unknown } }'); Visitor::visit($ast, Visitor::visitWithTypeInfo($typeInfo, [ 'enter' => function ($node) use ($typeInfo, &$visited) { $parentType = $typeInfo->getParentType(); @@ -1179,10 +1179,14 @@ public function testMaintainsTypeInfoDuringVisit() ['enter', 'Name', 'pets', 'Human', '[Pet]', null], ['leave', 'Name', 'pets', 'Human', '[Pet]', null], ['enter', 'SelectionSet', null, 'Pet', '[Pet]', null], + ['enter', 'InlineFragment', null, 'Pet', 'Pet', null], + ['enter', 'SelectionSet', null, 'Pet', 'Pet', null], ['enter', 'Field', null, 'Pet', 'String', null], ['enter', 'Name', 'name', 'Pet', 'String', null], ['leave', 'Name', 'name', 'Pet', 'String', null], ['leave', 'Field', null, 'Pet', 'String', null], + ['leave', 'SelectionSet', null, 'Pet', 'Pet', null], + ['leave', 'InlineFragment', null, 'Pet', 'Pet', null], ['leave', 'SelectionSet', null, 'Pet', '[Pet]', null], ['leave', 'Field', null, 'Human', '[Pet]', null], ['enter', 'Field', null, 'Human', null, null], From 46816a7cdad856ac310c721c0d5c37de8dc54e23 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Thu, 8 Feb 2018 15:30:30 +0100 Subject: [PATCH 02/50] Uniform parsing of queries with short-hand syntax with regular queries --- src/Language/Parser.php | 2 +- tests/Language/ParserTest.php | 77 ++++++++++++++++++++++++++- tests/Language/kitchen-sink-noloc.ast | 1 + tests/Language/kitchen-sink.ast | 1 + 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/Language/Parser.php b/src/Language/Parser.php index 2a7c7f0f1..b15b048b8 100644 --- a/src/Language/Parser.php +++ b/src/Language/Parser.php @@ -370,7 +370,7 @@ function parseOperationDefinition() return new OperationDefinitionNode([ 'operation' => 'query', 'name' => null, - 'variableDefinitions' => null, + 'variableDefinitions' => new NodeList([]), 'directives' => new NodeList([]), 'selectionSet' => $this->parseSelectionSet(), 'loc' => $this->loc($start) diff --git a/tests/Language/ParserTest.php b/tests/Language/ParserTest.php index d901bf688..1a872c0d8 100644 --- a/tests/Language/ParserTest.php +++ b/tests/Language/ParserTest.php @@ -276,7 +276,7 @@ public function testParseCreatesAst() 'loc' => $loc(0, 40), 'operation' => 'query', 'name' => null, - 'variableDefinitions' => null, + 'variableDefinitions' => [], 'directives' => [], 'selectionSet' => [ 'kind' => NodeKind::SELECTION_SET, @@ -350,6 +350,81 @@ public function testParseCreatesAst() $this->assertEquals($expected, $this->nodeToArray($result)); } + /** + * @it creates ast from nameless query without variables + */ + public function testParseCreatesAstFromNamelessQueryWithoutVariables() + { + $source = new Source('query { + node { + id + } +} +'); + $result = Parser::parse($source); + + $loc = function($start, $end) use ($source) { + return [ + 'start' => $start, + 'end' => $end + ]; + }; + + $expected = [ + 'kind' => NodeKind::DOCUMENT, + 'loc' => $loc(0, 30), + 'definitions' => [ + [ + 'kind' => NodeKind::OPERATION_DEFINITION, + 'loc' => $loc(0, 29), + 'operation' => 'query', + 'name' => null, + 'variableDefinitions' => [], + 'directives' => [], + 'selectionSet' => [ + 'kind' => NodeKind::SELECTION_SET, + 'loc' => $loc(6, 29), + 'selections' => [ + [ + 'kind' => NodeKind::FIELD, + 'loc' => $loc(10, 27), + 'alias' => null, + 'name' => [ + 'kind' => NodeKind::NAME, + 'loc' => $loc(10, 14), + 'value' => 'node' + ], + 'arguments' => [], + 'directives' => [], + 'selectionSet' => [ + 'kind' => NodeKind::SELECTION_SET, + 'loc' => $loc(15, 27), + 'selections' => [ + [ + 'kind' => NodeKind::FIELD, + 'loc' => $loc(21, 23), + 'alias' => null, + 'name' => [ + 'kind' => NodeKind::NAME, + 'loc' => $loc(21, 23), + 'value' => 'id' + ], + 'arguments' => [], + 'directives' => [], + 'selectionSet' => null + ] + ] + ] + ] + ] + ] + ] + ] + ]; + + $this->assertEquals($expected, $this->nodeToArray($result)); + } + /** * @it allows parsing without source location information */ diff --git a/tests/Language/kitchen-sink-noloc.ast b/tests/Language/kitchen-sink-noloc.ast index 0f375606c..323658687 100644 --- a/tests/Language/kitchen-sink-noloc.ast +++ b/tests/Language/kitchen-sink-noloc.ast @@ -575,6 +575,7 @@ { "kind": "OperationDefinition", "operation": "query", + "variableDefinitions": [], "directives": [], "selectionSet": { "kind": "SelectionSet", diff --git a/tests/Language/kitchen-sink.ast b/tests/Language/kitchen-sink.ast index 9c89af728..c0128f12d 100644 --- a/tests/Language/kitchen-sink.ast +++ b/tests/Language/kitchen-sink.ast @@ -1127,6 +1127,7 @@ "end": 1086 }, "operation": "query", + "variableDefinitions": [], "directives": [], "selectionSet": { "kind": "SelectionSet", From 8747ff8954d65533da7a0f8d99fd16c4b5ba1201 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Thu, 8 Feb 2018 14:58:08 +0100 Subject: [PATCH 03/50] RFC: Block String This RFC adds a new form of `StringValue`, the multi-line string, similar to that found in Python and Scala. A multi-line string starts and ends with a triple-quote: ``` """This is a triple-quoted string and it can contain multiple lines""" ``` Multi-line strings are useful for typing literal bodies of text where new lines should be interpretted literally. In fact, the only escape sequence used is `\"""` and `\` is otherwise allowed unescaped. This is beneficial when writing documentation within strings which may reference the back-slash often: ``` """ In a multi-line string \n and C:\\ are unescaped. """ ``` The primary value of multi-line strings are to write long-form input directly in query text, in tools like GraphiQL, and as a prerequisite to another pending RFC to allow docstring style documentation in the Schema Definition Language. Ref: graphql/graphql-js#926 --- src/Language/AST/StringValueNode.php | 5 + src/Language/Lexer.php | 139 +++++++++++++++++---- src/Language/Parser.php | 2 + src/Language/Printer.php | 3 + src/Language/Token.php | 2 + src/Utils/BlockString.php | 61 +++++++++ tests/Language/LexerTest.php | 100 ++++++++++++++- tests/Language/ParserTest.php | 3 +- tests/Language/PrinterTest.php | 4 +- tests/Language/VisitorTest.php | 6 + tests/Language/kitchen-sink-noloc.ast | 15 ++- tests/Language/kitchen-sink.ast | 99 +++++++++------ tests/Language/kitchen-sink.graphql | 6 +- tests/Language/schema-kitchen-sink.graphql | 8 +- 14 files changed, 381 insertions(+), 72 deletions(-) create mode 100644 src/Utils/BlockString.php diff --git a/src/Language/AST/StringValueNode.php b/src/Language/AST/StringValueNode.php index d729b5a11..457e4b7ab 100644 --- a/src/Language/AST/StringValueNode.php +++ b/src/Language/AST/StringValueNode.php @@ -9,4 +9,9 @@ class StringValueNode extends Node implements ValueNode * @var string */ public $value; + + /** + * @var boolean|null + */ + public $block; } diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php index 6c1bc8206..c00e20a7f 100644 --- a/src/Language/Lexer.php +++ b/src/Language/Lexer.php @@ -3,6 +3,7 @@ use GraphQL\Error\SyntaxError; use GraphQL\Utils\Utils; +use GraphQL\Utils\BlockString; /** * A Lexer is a stateful stream generator in that every time @@ -201,7 +202,15 @@ private function readToken(Token $prev) ->readNumber($line, $col, $prev); // " case 34: - return $this->moveStringCursor(-1, -1 * $bytes) + list(,$nextCode) = $this->readChar(); + list(,$nextNextCode) = $this->moveStringCursor(1, 1)->readChar(); + + if ($nextCode === 34 && $nextNextCode === 34) { + return $this->moveStringCursor(-2, (-1 * $bytes) - 1) + ->readBlockString($line, $col, $prev); + } + + return $this->moveStringCursor(-2, (-1 * $bytes) - 1) ->readString($line, $col, $prev); } @@ -370,12 +379,28 @@ private function readString($line, $col, Token $prev) $value = ''; while ( - $code && + $code !== null && // not LineTerminator - $code !== 10 && $code !== 13 && - // not Quote (") - $code !== 34 + $code !== 10 && $code !== 13 ) { + // Closing Quote (") + if ($code === 34) { + $value .= $chunk; + + // Skip quote + $this->moveStringCursor(1, 1); + + return new Token( + Token::STRING, + $start, + $this->position, + $line, + $col, + $prev, + $value + ); + } + $this->assertValidStringCharacterCode($code, $this->position); $this->moveStringCursor(1, $bytes); @@ -421,27 +446,83 @@ private function readString($line, $col, Token $prev) list ($char, $code, $bytes) = $this->readChar(); } - if ($code !== 34) { - throw new SyntaxError( - $this->source, - $this->position, - 'Unterminated string.' - ); - } + throw new SyntaxError( + $this->source, + $this->position, + 'Unterminated string.' + ); + } - $value .= $chunk; + /** + * Reads a block string token from the source file. + * + * """("?"?(\\"""|\\(?!=""")|[^"\\]))*""" + */ + private function readBlockString($line, $col, Token $prev) + { + $start = $this->position; - // Skip trailing quote: - $this->moveStringCursor(1, 1); + // Skip leading quotes and read first string char: + list ($char, $code, $bytes) = $this->moveStringCursor(3, 3)->readChar(); - return new Token( - Token::STRING, - $start, + $chunk = ''; + $value = ''; + + while ($code !== null) { + // Closing Triple-Quote (""") + if ($code === 34) { + // Move 2 quotes + list(,$nextCode) = $this->moveStringCursor(1, 1)->readChar(); + list(,$nextNextCode) = $this->moveStringCursor(1, 1)->readChar(); + + if ($nextCode === 34 && $nextNextCode === 34) { + $value .= $chunk; + + $this->moveStringCursor(1, 1); + + return new Token( + Token::BLOCK_STRING, + $start, + $this->position, + $line, + $col, + $prev, + BlockString::value($value) + ); + } else { + // move cursor back to before the first quote + $this->moveStringCursor(-2, -2); + } + } + + $this->assertValidBlockStringCharacterCode($code, $this->position); + $this->moveStringCursor(1, $bytes); + + list(,$nextCode) = $this->readChar(); + list(,$nextNextCode) = $this->moveStringCursor(1, 1)->readChar(); + list(,$nextNextNextCode) = $this->moveStringCursor(1, 1)->readChar(); + + // Escape Triple-Quote (\""") + if ($code === 92 && + $nextCode === 34 && + $nextNextCode === 34 && + $nextNextNextCode === 34 + ) { + $this->moveStringCursor(1, 1); + $value .= $chunk . '"""'; + $chunk = ''; + } else { + $this->moveStringCursor(-2, -2); + $chunk .= $char; + } + + list ($char, $code, $bytes) = $this->readChar(); + } + + throw new SyntaxError( + $this->source, $this->position, - $line, - $col, - $prev, - $value + 'Unterminated string.' ); } @@ -457,6 +538,18 @@ private function assertValidStringCharacterCode($code, $position) } } + private function assertValidBlockStringCharacterCode($code, $position) + { + // SourceCharacter + if ($code < 0x0020 && $code !== 0x0009 && $code !== 0x000A && $code !== 0x000D) { + throw new SyntaxError( + $this->source, + $position, + 'Invalid character within String: ' . Utils::printCharCode($code) + ); + } + } + /** * Reads from body starting at startPosition until it finds a non-whitespace * or commented character, then places cursor to the position of that character. @@ -537,7 +630,7 @@ private function readChar($advance = false, $byteStreamPosition = null) $byteStreamPosition = $this->byteStreamPosition; } - $code = 0; + $code = null; $utf8char = ''; $bytes = 0; $positionOffset = 0; diff --git a/src/Language/Parser.php b/src/Language/Parser.php index b15b048b8..2a8b53215 100644 --- a/src/Language/Parser.php +++ b/src/Language/Parser.php @@ -655,9 +655,11 @@ function parseValueLiteral($isConst) 'loc' => $this->loc($token) ]); case Token::STRING: + case Token::BLOCK_STRING: $this->lexer->advance(); return new StringValueNode([ 'value' => $token->value, + 'block' => $token->kind === Token::BLOCK_STRING, 'loc' => $this->loc($token) ]); case Token::NAME: diff --git a/src/Language/Printer.php b/src/Language/Printer.php index 7e7336b2f..25d2a2c06 100644 --- a/src/Language/Printer.php +++ b/src/Language/Printer.php @@ -139,6 +139,9 @@ public function printAST($ast) return $node->value; }, NodeKind::STRING => function(StringValueNode $node) { + if ($node->block) { + return "\"\"\"\n" . str_replace('"""', '\\"""', $node->value) . "\n\"\"\""; + } return json_encode($node->value); }, NodeKind::BOOLEAN => function(BooleanValueNode $node) { diff --git a/src/Language/Token.php b/src/Language/Token.php index f908a5d25..f98d686ff 100644 --- a/src/Language/Token.php +++ b/src/Language/Token.php @@ -27,6 +27,7 @@ class Token const INT = 'Int'; const FLOAT = 'Float'; const STRING = 'String'; + const BLOCK_STRING = 'BlockString'; const COMMENT = 'Comment'; /** @@ -57,6 +58,7 @@ public static function getKindDescription($kind) $description[self::INT] = 'Int'; $description[self::FLOAT] = 'Float'; $description[self::STRING] = 'String'; + $description[self::BLOCK_STRING] = 'BlockString'; $description[self::COMMENT] = 'Comment'; return $description[$kind]; diff --git a/src/Utils/BlockString.php b/src/Utils/BlockString.php new file mode 100644 index 000000000..eac943d91 --- /dev/null +++ b/src/Utils/BlockString.php @@ -0,0 +1,61 @@ + 0 && trim($lines[0], " \t") === '') { + array_shift($lines); + } + while (count($lines) > 0 && trim($lines[count($lines) - 1], " \t") === '') { + array_pop($lines); + } + + // Return a string of the lines joined with U+000A. + return implode("\n", $lines); + } + + private static function leadingWhitespace($str) { + $i = 0; + while ($i < mb_strlen($str) && ($str[$i] === ' ' || $str[$i] === '\t')) { + $i++; + } + + return $i; + } +} \ No newline at end of file diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index e818fa291..e73cf627d 100644 --- a/tests/Language/LexerTest.php +++ b/tests/Language/LexerTest.php @@ -223,7 +223,101 @@ public function testLexesStrings() ], (array) $this->lexOne('"\u1234\u5678\u90AB\uCDEF"')); } - public function reportsUsefulErrors() { + /** + * @it lexes block strings + */ + public function testLexesBlockString() + { + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 12, + 'value' => 'simple' + ], (array) $this->lexOne('"""simple"""')); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 19, + 'value' => ' white space ' + ], (array) $this->lexOne('""" white space """')); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 22, + 'value' => 'contains " quote' + ], (array) $this->lexOne('"""contains " quote"""')); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 31, + 'value' => 'contains """ triplequote' + ], (array) $this->lexOne('"""contains \\""" triplequote"""')); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 16, + 'value' => "multi\nline" + ], (array) $this->lexOne("\"\"\"multi\nline\"\"\"")); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 28, + 'value' => "multi\nline\nnormalized" + ], (array) $this->lexOne("\"\"\"multi\rline\r\nnormalized\"\"\"")); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 32, + 'value' => 'unescaped \\n\\r\\b\\t\\f\\u1234' + ], (array) $this->lexOne('"""unescaped \\n\\r\\b\\t\\f\\u1234"""')); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 19, + 'value' => 'slashes \\\\ \\/' + ], (array) $this->lexOne('"""slashes \\\\ \\/"""')); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 68, + 'value' => "spans\n multiple\n lines" + ], (array) $this->lexOne("\"\"\" + + spans + multiple + lines + + \"\"\"")); + } + + public function reportsUsefulBlockStringErrors() { + return [ + ['"""', "Syntax Error GraphQL (1:4) Unterminated string.\n\n1: \"\"\"\n ^\n"], + ['"""no end quote', "Syntax Error GraphQL (1:16) Unterminated string.\n\n1: \"\"\"no end quote\n ^\n"], + ['"""contains unescaped ' . json_decode('"\u0007"') . ' control char"""', "Syntax Error GraphQL (1:23) Invalid character within String: \"\\u0007\""], + ['"""null-byte is not ' . json_decode('"\u0000"') . ' end of file"""', "Syntax Error GraphQL (1:21) Invalid character within String: \"\\u0000\""], + ]; + } + + /** + * @dataProvider reportsUsefulBlockStringErrors + * @it lex reports useful block string errors + */ + public function testReportsUsefulBlockStringErrors($str, $expectedMessage) + { + $this->setExpectedException(SyntaxError::class, $expectedMessage); + $this->lexOne($str); + } + + public function reportsUsefulStringErrors() { return [ ['"', "Syntax Error GraphQL (1:2) Unterminated string.\n\n1: \"\n ^\n"], ['"no end quote', "Syntax Error GraphQL (1:14) Unterminated string.\n\n1: \"no end quote\n ^\n"], @@ -243,10 +337,10 @@ public function reportsUsefulErrors() { } /** - * @dataProvider reportsUsefulErrors + * @dataProvider reportsUsefulStringErrors * @it lex reports useful string errors */ - public function testReportsUsefulErrors($str, $expectedMessage) + public function testLexReportsUsefulStringErrors($str, $expectedMessage) { $this->setExpectedException(SyntaxError::class, $expectedMessage); $this->lexOne($str); diff --git a/tests/Language/ParserTest.php b/tests/Language/ParserTest.php index 1a872c0d8..71fd7e419 100644 --- a/tests/Language/ParserTest.php +++ b/tests/Language/ParserTest.php @@ -497,7 +497,8 @@ public function testParsesListValues() [ 'kind' => NodeKind::STRING, 'loc' => ['start' => 5, 'end' => 10], - 'value' => 'abc' + 'value' => 'abc', + 'block' => false ] ] ], $this->nodeToArray(Parser::parseValue('[123 "abc"]'))); diff --git a/tests/Language/PrinterTest.php b/tests/Language/PrinterTest.php index 8b599106b..a8d0ae27e 100644 --- a/tests/Language/PrinterTest.php +++ b/tests/Language/PrinterTest.php @@ -146,7 +146,9 @@ public function testPrintsKitchenSink() } fragment frag on Friend { - foo(size: $size, bar: $b, obj: {key: "value"}) + foo(size: $size, bar: $b, obj: {key: "value", block: """ + block string uses \""" + """}) } { diff --git a/tests/Language/VisitorTest.php b/tests/Language/VisitorTest.php index d65c8aa36..c65141eea 100644 --- a/tests/Language/VisitorTest.php +++ b/tests/Language/VisitorTest.php @@ -615,6 +615,12 @@ public function testVisitsKitchenSink() [ 'enter', 'StringValue', 'value', 'ObjectField' ], [ 'leave', 'StringValue', 'value', 'ObjectField' ], [ 'leave', 'ObjectField', 0, null ], + [ 'enter', 'ObjectField', 1, null ], + [ 'enter', 'Name', 'name', 'ObjectField' ], + [ 'leave', 'Name', 'name', 'ObjectField' ], + [ 'enter', 'StringValue', 'value', 'ObjectField' ], + [ 'leave', 'StringValue', 'value', 'ObjectField' ], + [ 'leave', 'ObjectField', 1, null ], [ 'leave', 'ObjectValue', 'value', 'Argument' ], [ 'leave', 'Argument', 2, null ], [ 'leave', 'Field', 0, null ], diff --git a/tests/Language/kitchen-sink-noloc.ast b/tests/Language/kitchen-sink-noloc.ast index 323658687..d1427c12b 100644 --- a/tests/Language/kitchen-sink-noloc.ast +++ b/tests/Language/kitchen-sink-noloc.ast @@ -556,7 +556,20 @@ }, "value": { "kind": "StringValue", - "value": "value" + "value": "value", + "block": false + } + }, + { + "kind": "ObjectField", + "name": { + "kind": "Name", + "value": "block" + }, + "value": { + "kind": "StringValue", + "value": "block string uses \"\"\"", + "block": true } } ] diff --git a/tests/Language/kitchen-sink.ast b/tests/Language/kitchen-sink.ast index c0128f12d..606b03c25 100644 --- a/tests/Language/kitchen-sink.ast +++ b/tests/Language/kitchen-sink.ast @@ -2,7 +2,7 @@ "kind": "Document", "loc": { "start": 0, - "end": 1087 + "end": 1136 }, "definitions": [ { @@ -959,7 +959,7 @@ "kind": "FragmentDefinition", "loc": { "start": 942, - "end": 1018 + "end": 1067 }, "name": { "kind": "Name", @@ -989,14 +989,14 @@ "kind": "SelectionSet", "loc": { "start": 966, - "end": 1018 + "end": 1067 }, "selections": [ { "kind": "Field", "loc": { "start": 970, - "end": 1016 + "end": 1065 }, "name": { "kind": "Name", @@ -1071,13 +1071,13 @@ "kind": "Argument", "loc": { "start": 996, - "end": 1015 + "end": 1064 }, "value": { "kind": "ObjectValue", "loc": { "start": 1001, - "end": 1015 + "end": 1064 }, "fields": [ { @@ -1100,7 +1100,32 @@ "start": 1007, "end": 1014 }, - "value": "value" + "value": "value", + "block": false + } + }, + { + "kind": "ObjectField", + "loc": { + "start": 1016, + "end": 1063 + }, + "name": { + "kind": "Name", + "loc": { + "start": 1016, + "end": 1021 + }, + "value": "block" + }, + "value": { + "kind": "StringValue", + "loc": { + "start": 1023, + "end": 1063 + }, + "value": "block string uses \"\"\"", + "block": true } } ] @@ -1123,8 +1148,8 @@ { "kind": "OperationDefinition", "loc": { - "start": 1020, - "end": 1086 + "start": 1069, + "end": 1135 }, "operation": "query", "variableDefinitions": [], @@ -1132,21 +1157,21 @@ "selectionSet": { "kind": "SelectionSet", "loc": { - "start": 1020, - "end": 1086 + "start": 1069, + "end": 1135 }, "selections": [ { "kind": "Field", "loc": { - "start": 1024, - "end": 1075 + "start": 1073, + "end": 1124 }, "name": { "kind": "Name", "loc": { - "start": 1024, - "end": 1031 + "start": 1073, + "end": 1080 }, "value": "unnamed" }, @@ -1154,22 +1179,22 @@ { "kind": "Argument", "loc": { - "start": 1032, - "end": 1044 + "start": 1081, + "end": 1093 }, "value": { "kind": "BooleanValue", "loc": { - "start": 1040, - "end": 1044 + "start": 1089, + "end": 1093 }, "value": true }, "name": { "kind": "Name", "loc": { - "start": 1032, - "end": 1038 + "start": 1081, + "end": 1087 }, "value": "truthy" } @@ -1177,22 +1202,22 @@ { "kind": "Argument", "loc": { - "start": 1046, - "end": 1059 + "start": 1095, + "end": 1108 }, "value": { "kind": "BooleanValue", "loc": { - "start": 1054, - "end": 1059 + "start": 1103, + "end": 1108 }, "value": false }, "name": { "kind": "Name", "loc": { - "start": 1046, - "end": 1052 + "start": 1095, + "end": 1101 }, "value": "falsey" } @@ -1200,21 +1225,21 @@ { "kind": "Argument", "loc": { - "start": 1061, - "end": 1074 + "start": 1110, + "end": 1123 }, "value": { "kind": "NullValue", "loc": { - "start": 1070, - "end": 1074 + "start": 1119, + "end": 1123 } }, "name": { "kind": "Name", "loc": { - "start": 1061, - "end": 1068 + "start": 1110, + "end": 1117 }, "value": "nullish" } @@ -1225,14 +1250,14 @@ { "kind": "Field", "loc": { - "start": 1079, - "end": 1084 + "start": 1128, + "end": 1133 }, "name": { "kind": "Name", "loc": { - "start": 1079, - "end": 1084 + "start": 1128, + "end": 1133 }, "value": "query" }, diff --git a/tests/Language/kitchen-sink.graphql b/tests/Language/kitchen-sink.graphql index 993de9ad0..53bb320b6 100644 --- a/tests/Language/kitchen-sink.graphql +++ b/tests/Language/kitchen-sink.graphql @@ -48,7 +48,11 @@ subscription StoryLikeSubscription($input: StoryLikeSubscribeInput) { } fragment frag on Friend { - foo(size: $size, bar: $b, obj: {key: "value"}) + foo(size: $size, bar: $b, obj: {key: "value", block: """ + + block string uses \""" + + """}) } { diff --git a/tests/Language/schema-kitchen-sink.graphql b/tests/Language/schema-kitchen-sink.graphql index 0544266fa..7771a3518 100644 --- a/tests/Language/schema-kitchen-sink.graphql +++ b/tests/Language/schema-kitchen-sink.graphql @@ -1,9 +1,7 @@ -# Copyright (c) 2015, Facebook, Inc. -# All rights reserved. +# Copyright (c) 2015-present, Facebook, Inc. # -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. An additional grant -# of patent rights can be found in the PATENTS file in the same directory. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. schema { query: QueryType From e65638f6f42d81666a3bde2702d72a251df3612b Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Thu, 8 Feb 2018 16:47:44 +0100 Subject: [PATCH 04/50] Improvements to printing block strings ref: graphql/graphql-js#f9e67c403a4667372684ee8c3e82e1f0ba27031b --- src/Language/Printer.php | 13 ++++++++++- tests/Language/PrinterTest.php | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/Language/Printer.php b/src/Language/Printer.php index 25d2a2c06..247cf4988 100644 --- a/src/Language/Printer.php +++ b/src/Language/Printer.php @@ -140,7 +140,7 @@ public function printAST($ast) }, NodeKind::STRING => function(StringValueNode $node) { if ($node->block) { - return "\"\"\"\n" . str_replace('"""', '\\"""', $node->value) . "\n\"\"\""; + return $this->printBlockString($node->value); } return json_encode($node->value); }, @@ -310,4 +310,15 @@ function($x) { return !!$x;} ) : ''; } + + /** + * Print a block string in the indented block form by adding a leading and + * trailing blank line. However, if a block string starts with whitespace and is + * a single-line, adding a leading blank line would strip that whitespace. + */ + private function printBlockString($value) { + return ($value[0] === ' ' || $value[0] === "\t") && strpos($value, "\n") === false + ? '"""' . str_replace('"""', '\\"""', $value) . '"""' + : $this->indent("\"\"\"\n" . str_replace('"""', '\\"""', $value)) . "\n\"\"\""; + } } diff --git a/tests/Language/PrinterTest.php b/tests/Language/PrinterTest.php index a8d0ae27e..301abff3d 100644 --- a/tests/Language/PrinterTest.php +++ b/tests/Language/PrinterTest.php @@ -92,6 +92,46 @@ public function testCorrectlyPrintsOpsWithoutName() $this->assertEquals($expected, Printer::doPrint($mutationAstWithArtifacts)); } + /** + * @it correctly prints single-line block strings with leading space + */ + public function testCorrectlyPrintsSingleLineBlockStringsWithLeadingSpace() + { + $mutationAstWithArtifacts = Parser::parse( + '{ field(arg: """ space-led value""") }' + ); + $expected = '{ + field(arg: """ space-led value""") +} +'; + $this->assertEquals($expected, Printer::doPrint($mutationAstWithArtifacts)); + } + + /** + * @it correctly prints block strings with a first line indentation + */ + public function testCorrectlyPrintsBlockStringsWithAFirstLineIndentation() + { + $mutationAstWithArtifacts = Parser::parse( + '{ + field(arg: """ + first + line + indentation + """) +}' + ); + $expected = '{ + field(arg: """ + first + line + indentation + """) +} +'; + $this->assertEquals($expected, Printer::doPrint($mutationAstWithArtifacts)); + } + /** * @it prints kitchen sink */ From 022c49001142f4c3ae1382181d31299feef45541 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Thu, 8 Feb 2018 19:33:54 +0100 Subject: [PATCH 05/50] RFC: Descriptions as strings As discussed in facebook/graphql#90 This proposes replacing leading comment blocks as descriptions in the schema definition language with leading strings (typically block strings). While I think there is some reduced ergonomics of using a string literal instead of a comment to write descriptions (unless perhaps you are accustomed to Python or Clojure), there are some compelling advantages: * Descriptions are first-class in the AST of the schema definition language. * Comments can remain "ignored" characters. * No ambiguity between commented out regions and descriptions. Specific to this reference implementation, since this is a breaking change and comment descriptions in the experimental SDL have fairly wide usage, I've left the comment description implementation intact and allow it to be enabled via an option. This should help with allowing upgrading with minimal impact on existing codebases and aid in automated transforms. BREAKING CHANGE: This does not parse descriptions from comments by default anymore and the value of description in Nodes changed from string to StringValueNode --- src/Language/AST/DirectiveDefinitionNode.php | 5 + src/Language/AST/EnumTypeDefinitionNode.php | 2 +- src/Language/AST/EnumValueDefinitionNode.php | 2 +- src/Language/AST/FieldDefinitionNode.php | 2 +- .../AST/InputObjectTypeDefinitionNode.php | 2 +- src/Language/AST/InputValueDefinitionNode.php | 2 +- .../AST/InterfaceTypeDefinitionNode.php | 2 +- src/Language/AST/ObjectTypeDefinitionNode.php | 2 +- src/Language/AST/ScalarTypeDefinitionNode.php | 2 +- src/Language/AST/UnionTypeDefinitionNode.php | 2 +- src/Language/Lexer.php | 11 +- src/Language/Parser.php | 107 ++++---- src/Language/Printer.php | 119 +++++--- src/Language/Visitor.php | 20 +- src/Utils/BuildSchema.php | 52 ++-- src/Utils/SchemaPrinter.php | 164 ++++++++---- tests/Language/SchemaParserTest.php | 142 +++++++--- tests/Language/SchemaPrinterTest.php | 4 + tests/Language/schema-kitchen-sink.graphql | 4 + tests/Utils/BuildSchemaTest.php | 86 +++--- tests/Utils/SchemaPrinterTest.php | 253 +++++++++++++++++- 21 files changed, 704 insertions(+), 281 deletions(-) diff --git a/src/Language/AST/DirectiveDefinitionNode.php b/src/Language/AST/DirectiveDefinitionNode.php index 1e8008469..84b649b64 100644 --- a/src/Language/AST/DirectiveDefinitionNode.php +++ b/src/Language/AST/DirectiveDefinitionNode.php @@ -22,4 +22,9 @@ class DirectiveDefinitionNode extends Node implements TypeSystemDefinitionNode * @var NameNode[] */ public $locations; + + /** + * @var StringValueNode|null + */ + public $description; } diff --git a/src/Language/AST/EnumTypeDefinitionNode.php b/src/Language/AST/EnumTypeDefinitionNode.php index 3d1113cc1..71ca5087f 100644 --- a/src/Language/AST/EnumTypeDefinitionNode.php +++ b/src/Language/AST/EnumTypeDefinitionNode.php @@ -24,7 +24,7 @@ class EnumTypeDefinitionNode extends Node implements TypeDefinitionNode public $values; /** - * @var string + * @var StringValueNode|null */ public $description; } diff --git a/src/Language/AST/EnumValueDefinitionNode.php b/src/Language/AST/EnumValueDefinitionNode.php index 45e6b2d67..dd1c535df 100644 --- a/src/Language/AST/EnumValueDefinitionNode.php +++ b/src/Language/AST/EnumValueDefinitionNode.php @@ -19,7 +19,7 @@ class EnumValueDefinitionNode extends Node public $directives; /** - * @var string + * @var StringValueNode|null */ public $description; } diff --git a/src/Language/AST/FieldDefinitionNode.php b/src/Language/AST/FieldDefinitionNode.php index 97639d3a4..d081d7f32 100644 --- a/src/Language/AST/FieldDefinitionNode.php +++ b/src/Language/AST/FieldDefinitionNode.php @@ -29,7 +29,7 @@ class FieldDefinitionNode extends Node public $directives; /** - * @var string + * @var StringValueNode|null */ public $description; } diff --git a/src/Language/AST/InputObjectTypeDefinitionNode.php b/src/Language/AST/InputObjectTypeDefinitionNode.php index 17e0d0754..c56cecaa3 100644 --- a/src/Language/AST/InputObjectTypeDefinitionNode.php +++ b/src/Language/AST/InputObjectTypeDefinitionNode.php @@ -24,7 +24,7 @@ class InputObjectTypeDefinitionNode extends Node implements TypeDefinitionNode public $fields; /** - * @var string + * @var StringValueNode|null */ public $description; } diff --git a/src/Language/AST/InputValueDefinitionNode.php b/src/Language/AST/InputValueDefinitionNode.php index 7dc65c493..47a060386 100644 --- a/src/Language/AST/InputValueDefinitionNode.php +++ b/src/Language/AST/InputValueDefinitionNode.php @@ -29,7 +29,7 @@ class InputValueDefinitionNode extends Node public $directives; /** - * @var string + * @var StringValueNode|null */ public $description; } diff --git a/src/Language/AST/InterfaceTypeDefinitionNode.php b/src/Language/AST/InterfaceTypeDefinitionNode.php index 60d2fd527..ff9bc1f52 100644 --- a/src/Language/AST/InterfaceTypeDefinitionNode.php +++ b/src/Language/AST/InterfaceTypeDefinitionNode.php @@ -24,7 +24,7 @@ class InterfaceTypeDefinitionNode extends Node implements TypeDefinitionNode public $fields = []; /** - * @var string + * @var StringValueNode|null */ public $description; } diff --git a/src/Language/AST/ObjectTypeDefinitionNode.php b/src/Language/AST/ObjectTypeDefinitionNode.php index 82d77c44f..addf20a11 100644 --- a/src/Language/AST/ObjectTypeDefinitionNode.php +++ b/src/Language/AST/ObjectTypeDefinitionNode.php @@ -29,7 +29,7 @@ class ObjectTypeDefinitionNode extends Node implements TypeDefinitionNode public $fields; /** - * @var string + * @var StringValueNode|null */ public $description; } diff --git a/src/Language/AST/ScalarTypeDefinitionNode.php b/src/Language/AST/ScalarTypeDefinitionNode.php index 483fb89e9..058841a87 100644 --- a/src/Language/AST/ScalarTypeDefinitionNode.php +++ b/src/Language/AST/ScalarTypeDefinitionNode.php @@ -19,7 +19,7 @@ class ScalarTypeDefinitionNode extends Node implements TypeDefinitionNode public $directives; /** - * @var string + * @var StringValueNode|null */ public $description; } diff --git a/src/Language/AST/UnionTypeDefinitionNode.php b/src/Language/AST/UnionTypeDefinitionNode.php index 7653b75a1..0eae11aa4 100644 --- a/src/Language/AST/UnionTypeDefinitionNode.php +++ b/src/Language/AST/UnionTypeDefinitionNode.php @@ -24,7 +24,7 @@ class UnionTypeDefinitionNode extends Node implements TypeDefinitionNode public $types = []; /** - * @var string + * @var StringValueNode|null */ public $description; } diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php index c00e20a7f..5ea799279 100644 --- a/src/Language/Lexer.php +++ b/src/Language/Lexer.php @@ -92,13 +92,18 @@ public function __construct(Source $source, array $options = []) */ public function advance() { - $token = $this->lastToken = $this->token; + $this->lastToken = $this->token; + $token = $this->token = $this->lookahead(); + return $token; + } + public function lookahead() + { + $token = $this->token; if ($token->kind !== Token::EOF) { do { - $token = $token->next = $this->readToken($token); + $token = $token->next ?: ($token->next = $this->readToken($token)); } while ($token->kind === Token::COMMENT); - $this->token = $token; } return $token; } diff --git a/src/Language/Parser.php b/src/Language/Parser.php index 2a8b53215..bfc89e8a8 100644 --- a/src/Language/Parser.php +++ b/src/Language/Parser.php @@ -340,7 +340,7 @@ function parseDefinition() case 'fragment': return $this->parseFragmentDefinition(); - // Note: the Type System IDL is an experimental non-spec addition. + // Note: The schema definition language is an experimental addition. case 'schema': case 'scalar': case 'type': @@ -354,6 +354,11 @@ function parseDefinition() } } + // Note: The schema definition language is an experimental addition. + if ($this->peekDescription()) { + return $this->parseTypeSystemDefinition(); + } + throw $this->unexpected(); } @@ -656,12 +661,7 @@ function parseValueLiteral($isConst) ]); case Token::STRING: case Token::BLOCK_STRING: - $this->lexer->advance(); - return new StringValueNode([ - 'value' => $token->value, - 'block' => $token->kind === Token::BLOCK_STRING, - 'loc' => $this->loc($token) - ]); + return $this->parseStringLiteral(); case Token::NAME: if ($token->value === 'true' || $token->value === 'false') { $this->lexer->advance(); @@ -692,6 +692,20 @@ function parseValueLiteral($isConst) throw $this->unexpected(); } + /** + * @return StringValueNode + */ + function parseStringLiteral() { + $token = $this->lexer->token; + $this->lexer->advance(); + + return new StringValueNode([ + 'value' => $token->value, + 'block' => $token->kind === Token::BLOCK_STRING, + 'loc' => $this->loc($token) + ]); + } + /** * @return BooleanValueNode|EnumValueNode|FloatValueNode|IntValueNode|StringValueNode|VariableNode * @throws SyntaxError @@ -852,8 +866,13 @@ function parseNamedType() */ function parseTypeSystemDefinition() { - if ($this->peek(Token::NAME)) { - switch ($this->lexer->token->value) { + // Many definitions begin with a description and require a lookahead. + $keywordToken = $this->peekDescription() + ? $this->lexer->lookahead() + : $this->lexer->token; + + if ($keywordToken->kind === Token::NAME) { + switch ($keywordToken->value) { case 'schema': return $this->parseSchemaDefinition(); case 'scalar': return $this->parseScalarTypeDefinition(); case 'type': return $this->parseObjectTypeDefinition(); @@ -869,6 +888,22 @@ function parseTypeSystemDefinition() throw $this->unexpected(); } + /** + * @return bool + */ + function peekDescription() { + return $this->peek(Token::STRING) || $this->peek(Token::BLOCK_STRING); + } + + /** + * @return StringValueNode|null + */ + function parseDescription() { + if ($this->peekDescription()) { + return $this->parseStringLiteral(); + } + } + /** * @return SchemaDefinitionNode * @throws SyntaxError @@ -916,12 +951,11 @@ function parseOperationTypeDefinition() function parseScalarTypeDefinition() { $start = $this->lexer->token; + $description = $this->parseDescription(); $this->expectKeyword('scalar'); $name = $this->parseName(); $directives = $this->parseDirectives(); - $description = $this->getDescriptionFromAdjacentCommentTokens($start); - return new ScalarTypeDefinitionNode([ 'name' => $name, 'directives' => $directives, @@ -937,6 +971,7 @@ function parseScalarTypeDefinition() function parseObjectTypeDefinition() { $start = $this->lexer->token; + $description = $this->parseDescription(); $this->expectKeyword('type'); $name = $this->parseName(); $interfaces = $this->parseImplementsInterfaces(); @@ -948,8 +983,6 @@ function parseObjectTypeDefinition() Token::BRACE_R ); - $description = $this->getDescriptionFromAdjacentCommentTokens($start); - return new ObjectTypeDefinitionNode([ 'name' => $name, 'interfaces' => $interfaces, @@ -982,14 +1015,13 @@ function parseImplementsInterfaces() function parseFieldDefinition() { $start = $this->lexer->token; + $description = $this->parseDescription(); $name = $this->parseName(); $args = $this->parseArgumentDefs(); $this->expect(Token::COLON); $type = $this->parseTypeReference(); $directives = $this->parseDirectives(); - $description = $this->getDescriptionFromAdjacentCommentTokens($start); - return new FieldDefinitionNode([ 'name' => $name, 'arguments' => $args, @@ -1018,6 +1050,7 @@ function parseArgumentDefs() function parseInputValueDef() { $start = $this->lexer->token; + $description = $this->parseDescription(); $name = $this->parseName(); $this->expect(Token::COLON); $type = $this->parseTypeReference(); @@ -1026,7 +1059,6 @@ function parseInputValueDef() $defaultValue = $this->parseConstValue(); } $directives = $this->parseDirectives(); - $description = $this->getDescriptionFromAdjacentCommentTokens($start); return new InputValueDefinitionNode([ 'name' => $name, 'type' => $type, @@ -1044,6 +1076,7 @@ function parseInputValueDef() function parseInterfaceTypeDefinition() { $start = $this->lexer->token; + $description = $this->parseDescription(); $this->expectKeyword('interface'); $name = $this->parseName(); $directives = $this->parseDirectives(); @@ -1053,8 +1086,6 @@ function parseInterfaceTypeDefinition() Token::BRACE_R ); - $description = $this->getDescriptionFromAdjacentCommentTokens($start); - return new InterfaceTypeDefinitionNode([ 'name' => $name, 'directives' => $directives, @@ -1071,14 +1102,13 @@ function parseInterfaceTypeDefinition() function parseUnionTypeDefinition() { $start = $this->lexer->token; + $description = $this->parseDescription(); $this->expectKeyword('union'); $name = $this->parseName(); $directives = $this->parseDirectives(); $this->expect(Token::EQUALS); $types = $this->parseUnionMembers(); - $description = $this->getDescriptionFromAdjacentCommentTokens($start); - return new UnionTypeDefinitionNode([ 'name' => $name, 'directives' => $directives, @@ -1114,6 +1144,7 @@ function parseUnionMembers() function parseEnumTypeDefinition() { $start = $this->lexer->token; + $description = $this->parseDescription(); $this->expectKeyword('enum'); $name = $this->parseName(); $directives = $this->parseDirectives(); @@ -1123,8 +1154,6 @@ function parseEnumTypeDefinition() Token::BRACE_R ); - $description = $this->getDescriptionFromAdjacentCommentTokens($start); - return new EnumTypeDefinitionNode([ 'name' => $name, 'directives' => $directives, @@ -1140,11 +1169,10 @@ function parseEnumTypeDefinition() function parseEnumValueDefinition() { $start = $this->lexer->token; + $description = $this->parseDescription(); $name = $this->parseName(); $directives = $this->parseDirectives(); - $description = $this->getDescriptionFromAdjacentCommentTokens($start); - return new EnumValueDefinitionNode([ 'name' => $name, 'directives' => $directives, @@ -1160,6 +1188,7 @@ function parseEnumValueDefinition() function parseInputObjectTypeDefinition() { $start = $this->lexer->token; + $description = $this->parseDescription(); $this->expectKeyword('input'); $name = $this->parseName(); $directives = $this->parseDirectives(); @@ -1169,8 +1198,6 @@ function parseInputObjectTypeDefinition() Token::BRACE_R ); - $description = $this->getDescriptionFromAdjacentCommentTokens($start); - return new InputObjectTypeDefinitionNode([ 'name' => $name, 'directives' => $directives, @@ -1206,6 +1233,7 @@ function parseTypeExtensionDefinition() function parseDirectiveDefinition() { $start = $this->lexer->token; + $description = $this->parseDescription(); $this->expectKeyword('directive'); $this->expect(Token::AT); $name = $this->parseName(); @@ -1217,7 +1245,8 @@ function parseDirectiveDefinition() 'name' => $name, 'arguments' => $args, 'locations' => $locations, - 'loc' => $this->loc($start) + 'loc' => $this->loc($start), + 'description' => $description ]); } @@ -1234,28 +1263,4 @@ function parseDirectiveLocations() } while ($this->skip(Token::PIPE)); return $locations; } - - /** - * @param Token $nameToken - * @return null|string - */ - private function getDescriptionFromAdjacentCommentTokens(Token $nameToken) - { - $description = null; - - $currentToken = $nameToken; - $previousToken = $currentToken->prev; - - while ($previousToken->kind == Token::COMMENT - && ($previousToken->line + 1) == $currentToken->line - ) { - $description = $previousToken->value . $description; - - // walk the tokens backwards until no longer adjacent comments - $currentToken = $previousToken; - $previousToken = $currentToken->prev; - } - - return $description; - } } diff --git a/src/Language/Printer.php b/src/Language/Printer.php index 247cf4988..edf55101d 100644 --- a/src/Language/Printer.php +++ b/src/Language/Printer.php @@ -138,9 +138,9 @@ public function printAST($ast) NodeKind::FLOAT => function(FloatValueNode $node) { return $node->value; }, - NodeKind::STRING => function(StringValueNode $node) { + NodeKind::STRING => function(StringValueNode $node, $key) { if ($node->block) { - return $this->printBlockString($node->value); + return $this->printBlockString($node->value, $key === 'description'); } return json_encode($node->value); }, @@ -192,74 +192,101 @@ public function printAST($ast) }, NodeKind::SCALAR_TYPE_DEFINITION => function(ScalarTypeDefinitionNode $def) { - return $this->join(['scalar', $def->name, $this->join($def->directives, ' ')], ' '); + return $this->join([ + $def->description, + $this->join(['scalar', $def->name, $this->join($def->directives, ' ')], ' ') + ], "\n"); }, NodeKind::OBJECT_TYPE_DEFINITION => function(ObjectTypeDefinitionNode $def) { return $this->join([ - 'type', - $def->name, - $this->wrap('implements ', $this->join($def->interfaces, ', ')), - $this->join($def->directives, ' '), - $this->block($def->fields) - ], ' '); + $def->description, + $this->join([ + 'type', + $def->name, + $this->wrap('implements ', $this->join($def->interfaces, ', ')), + $this->join($def->directives, ' '), + $this->block($def->fields) + ], ' ') + ], "\n"); }, NodeKind::FIELD_DEFINITION => function(FieldDefinitionNode $def) { - return $def->name + return $this->join([ + $def->description, + $def->name . $this->wrap('(', $this->join($def->arguments, ', '), ')') . ': ' . $def->type - . $this->wrap(' ', $this->join($def->directives, ' ')); + . $this->wrap(' ', $this->join($def->directives, ' ')) + ], "\n"); }, NodeKind::INPUT_VALUE_DEFINITION => function(InputValueDefinitionNode $def) { return $this->join([ - $def->name . ': ' . $def->type, - $this->wrap('= ', $def->defaultValue), - $this->join($def->directives, ' ') - ], ' '); + $def->description, + $this->join([ + $def->name . ': ' . $def->type, + $this->wrap('= ', $def->defaultValue), + $this->join($def->directives, ' ') + ], ' ') + ], "\n"); }, NodeKind::INTERFACE_TYPE_DEFINITION => function(InterfaceTypeDefinitionNode $def) { return $this->join([ - 'interface', - $def->name, - $this->join($def->directives, ' '), - $this->block($def->fields) - ], ' '); + $def->description, + $this->join([ + 'interface', + $def->name, + $this->join($def->directives, ' '), + $this->block($def->fields) + ], ' ') + ], "\n"); }, NodeKind::UNION_TYPE_DEFINITION => function(UnionTypeDefinitionNode $def) { return $this->join([ - 'union', - $def->name, - $this->join($def->directives, ' '), - '= ' . $this->join($def->types, ' | ') - ], ' '); + $def->description, + $this->join([ + 'union', + $def->name, + $this->join($def->directives, ' '), + '= ' . $this->join($def->types, ' | ') + ], ' ') + ], "\n"); }, NodeKind::ENUM_TYPE_DEFINITION => function(EnumTypeDefinitionNode $def) { return $this->join([ - 'enum', - $def->name, - $this->join($def->directives, ' '), - $this->block($def->values) - ], ' '); + $def->description, + $this->join([ + 'enum', + $def->name, + $this->join($def->directives, ' '), + $this->block($def->values) + ], ' ') + ], "\n"); }, NodeKind::ENUM_VALUE_DEFINITION => function(EnumValueDefinitionNode $def) { return $this->join([ - $def->name, - $this->join($def->directives, ' ') - ], ' '); + $def->description, + $this->join([$def->name, $this->join($def->directives, ' ')], ' ') + ], "\n"); }, NodeKind::INPUT_OBJECT_TYPE_DEFINITION => function(InputObjectTypeDefinitionNode $def) { return $this->join([ - 'input', - $def->name, - $this->join($def->directives, ' '), - $this->block($def->fields) - ], ' '); + $def->description, + $this->join([ + 'input', + $def->name, + $this->join($def->directives, ' '), + $this->block($def->fields) + ], ' ') + ], "\n"); }, NodeKind::TYPE_EXTENSION_DEFINITION => function(TypeExtensionDefinitionNode $def) { return "extend {$def->definition}"; }, NodeKind::DIRECTIVE_DEFINITION => function(DirectiveDefinitionNode $def) { - return 'directive @' . $def->name . $this->wrap('(', $this->join($def->arguments, ', '), ')') - . ' on ' . $this->join($def->locations, ' | '); + return $this->join([ + $def->description, + 'directive @' . $def->name . $this->wrap('(', $this->join($def->arguments, ', '), ')') + . ' on ' . $this->join($def->locations, ' | ') + ], "\n"); } ] ]); @@ -316,9 +343,13 @@ function($x) { return !!$x;} * trailing blank line. However, if a block string starts with whitespace and is * a single-line, adding a leading blank line would strip that whitespace. */ - private function printBlockString($value) { - return ($value[0] === ' ' || $value[0] === "\t") && strpos($value, "\n") === false - ? '"""' . str_replace('"""', '\\"""', $value) . '"""' - : $this->indent("\"\"\"\n" . str_replace('"""', '\\"""', $value)) . "\n\"\"\""; + private function printBlockString($value, $isDescription) { + return (($value[0] === ' ' || $value[0] === "\t") && strpos($value, "\n") === false) + ? ('"""' . str_replace('"""', '\\"""', $value) . '"""') + : ( + $isDescription + ? ("\"\"\"\n" . str_replace('"""', '\\"""', $value) . "\n\"\"\"") + : ($this->indent("\"\"\"\n" . str_replace('"""', '\\"""', $value)) . "\n\"\"\"") + ); } } diff --git a/src/Language/Visitor.php b/src/Language/Visitor.php index 9fadc8c43..9ddca60c2 100644 --- a/src/Language/Visitor.php +++ b/src/Language/Visitor.php @@ -133,17 +133,17 @@ class Visitor NodeKind::SCHEMA_DEFINITION => ['directives', 'operationTypes'], NodeKind::OPERATION_TYPE_DEFINITION => ['type'], - NodeKind::SCALAR_TYPE_DEFINITION => ['name', 'directives'], - NodeKind::OBJECT_TYPE_DEFINITION => ['name', 'interfaces', 'directives', 'fields'], - NodeKind::FIELD_DEFINITION => ['name', 'arguments', 'type', 'directives'], - NodeKind::INPUT_VALUE_DEFINITION => ['name', 'type', 'defaultValue', 'directives'], - NodeKind::INTERFACE_TYPE_DEFINITION => [ 'name', 'directives', 'fields' ], - NodeKind::UNION_TYPE_DEFINITION => [ 'name', 'directives', 'types' ], - NodeKind::ENUM_TYPE_DEFINITION => [ 'name', 'directives', 'values' ], - NodeKind::ENUM_VALUE_DEFINITION => [ 'name', 'directives' ], - NodeKind::INPUT_OBJECT_TYPE_DEFINITION => [ 'name', 'directives', 'fields' ], + NodeKind::SCALAR_TYPE_DEFINITION => ['description', 'name', 'directives'], + NodeKind::OBJECT_TYPE_DEFINITION => ['description', 'name', 'interfaces', 'directives', 'fields'], + NodeKind::FIELD_DEFINITION => ['description', 'name', 'arguments', 'type', 'directives'], + NodeKind::INPUT_VALUE_DEFINITION => ['description', 'name', 'type', 'defaultValue', 'directives'], + NodeKind::INTERFACE_TYPE_DEFINITION => ['description', 'name', 'directives', 'fields'], + NodeKind::UNION_TYPE_DEFINITION => ['description', 'name', 'directives', 'types'], + NodeKind::ENUM_TYPE_DEFINITION => ['description', 'name', 'directives', 'values'], + NodeKind::ENUM_VALUE_DEFINITION => ['description', 'name', 'directives'], + NodeKind::INPUT_OBJECT_TYPE_DEFINITION => ['description', 'name', 'directives', 'fields'], NodeKind::TYPE_EXTENSION_DEFINITION => [ 'definition' ], - NodeKind::DIRECTIVE_DEFINITION => [ 'name', 'arguments', 'locations' ] + NodeKind::DIRECTIVE_DEFINITION => ['description', 'name', 'arguments', 'locations'] ]; /** diff --git a/src/Utils/BuildSchema.php b/src/Utils/BuildSchema.php index d75a11f69..078427ad3 100644 --- a/src/Utils/BuildSchema.php +++ b/src/Utils/BuildSchema.php @@ -41,7 +41,7 @@ class BuildSchema /** * @param Type $innerType * @param TypeNode $inputTypeNode - * @return Type + * @return Type */ private function buildWrappedType(Type $innerType, TypeNode $inputTypeNode) { @@ -75,15 +75,21 @@ private function getNamedTypeNode(TypeNode $typeNode) * Given that AST it constructs a GraphQL\Type\Schema. The resulting schema * has no resolve methods, so execution will use default resolvers. * + * Accepts options as a third argument: + * + * - commentDescriptions: + * Provide true to use preceding comments as the description. + * + * * @api * @param DocumentNode $ast * @param callable $typeConfigDecorator * @return Schema * @throws Error */ - public static function buildAST(DocumentNode $ast, callable $typeConfigDecorator = null) + public static function buildAST(DocumentNode $ast, callable $typeConfigDecorator = null, array $options = []) { - $builder = new self($ast, $typeConfigDecorator); + $builder = new self($ast, $typeConfigDecorator, $options); return $builder->buildSchema(); } @@ -92,14 +98,16 @@ public static function buildAST(DocumentNode $ast, callable $typeConfigDecorator private $nodeMap; private $typeConfigDecorator; private $loadedTypeDefs; + private $options; - public function __construct(DocumentNode $ast, callable $typeConfigDecorator = null) + public function __construct(DocumentNode $ast, callable $typeConfigDecorator = null, array $options = []) { $this->ast = $ast; $this->typeConfigDecorator = $typeConfigDecorator; $this->loadedTypeDefs = []; + $this->options = $options; } - + public function buildSchema() { $schemaDef = null; @@ -584,17 +592,28 @@ private function getDeprecationReason($node) } /** - * Given an ast node, returns its string description based on a contiguous - * block full-line of comments preceding it. + * Given an ast node, returns its string description. */ public function getDescription($node) + { + if ($node->description) { + return $node->description->value; + } + if (isset($this->options['commentDescriptions'])) { + $rawValue = $this->getLeadingCommentBlock($node); + if ($rawValue !== null) { + return BlockString::value("\n" . $rawValue); + } + } + } + + public function getLeadingCommentBlock($node) { $loc = $node->loc; if (!$loc || !$loc->startToken) { return ; } $comments = []; - $minSpaces = null; $token = $loc->startToken->prev; while ( $token && @@ -604,22 +623,17 @@ public function getDescription($node) $token->line !== $token->prev->line ) { $value = $token->value; - $spaces = $this->leadingSpaces($value); - if ($minSpaces === null || $spaces < $minSpaces) { - $minSpaces = $spaces; - } $comments[] = $value; $token = $token->prev; } - return implode("\n", array_map(function($comment) use ($minSpaces) { - return mb_substr(str_replace("\n", '', $comment), $minSpaces); - }, array_reverse($comments))); + + return implode("\n", array_reverse($comments)); } /** * A helper function to build a GraphQLSchema directly from a source * document. - * + * * @api * @param DocumentNode|Source|string $source * @param callable $typeConfigDecorator @@ -631,12 +645,6 @@ public static function build($source, callable $typeConfigDecorator = null) return self::buildAST($doc, $typeConfigDecorator); } - // Count the number of spaces on the starting side of a string. - private function leadingSpaces($str) - { - return strlen($str) - strlen(ltrim($str)); - } - public function cannotExecuteSchema() { throw new Error( diff --git a/src/Utils/SchemaPrinter.php b/src/Utils/SchemaPrinter.php index 1e1d9cb47..93c667e1a 100644 --- a/src/Utils/SchemaPrinter.php +++ b/src/Utils/SchemaPrinter.php @@ -19,15 +19,24 @@ class SchemaPrinter { /** + * Accepts options as a second argument: + * + * - commentDescriptions: + * Provide true to use preceding comments as the description. * @api * @param Schema $schema * @return string */ - public static function doPrint(Schema $schema) + public static function doPrint(Schema $schema, array $options = []) { - return self::printFilteredSchema($schema, function($n) { - return !self::isSpecDirective($n); - }, 'self::isDefinedType'); + return self::printFilteredSchema( + $schema, + function($n) { + return !self::isSpecDirective($n); + }, + 'self::isDefinedType', + $options + ); } /** @@ -35,9 +44,14 @@ public static function doPrint(Schema $schema) * @param Schema $schema * @return string */ - public static function printIntrosepctionSchema(Schema $schema) + public static function printIntrosepctionSchema(Schema $schema, array $options = []) { - return self::printFilteredSchema($schema, [__CLASS__, 'isSpecDirective'], [__CLASS__, 'isIntrospectionType']); + return self::printFilteredSchema( + $schema, + [__CLASS__, 'isSpecDirective'], + [__CLASS__, 'isIntrospectionType'], + $options + ); } private static function isSpecDirective($directiveName) @@ -70,7 +84,7 @@ private static function isBuiltInScalar($typename) ); } - private static function printFilteredSchema(Schema $schema, $directiveFilter, $typeFilter) + private static function printFilteredSchema(Schema $schema, $directiveFilter, $typeFilter, $options) { $directives = array_filter($schema->getDirectives(), function($directive) use ($directiveFilter) { return $directiveFilter($directive->name); @@ -82,8 +96,8 @@ private static function printFilteredSchema(Schema $schema, $directiveFilter, $t return implode("\n\n", array_filter(array_merge( [self::printSchemaDefinition($schema)], - array_map('self::printDirective', $directives), - array_map('self::printType', $types) + array_map(function($directive) use ($options) { return self::printDirective($directive, $options); }, $directives), + array_map(function($type) use ($options) { return self::printType($type, $options); }, $types) ))) . "\n"; } @@ -112,7 +126,7 @@ private static function printSchemaDefinition(Schema $schema) return "schema {\n" . implode("\n", $operationTypes) . "\n}"; } - + /** * GraphQL schema define root types for each type of operation. These types are * the same as any other type and can be named in any manner, however there is @@ -145,93 +159,93 @@ private static function isSchemaOfCommonNames(Schema $schema) return true; } - public static function printType(Type $type) + public static function printType(Type $type, array $options = []) { if ($type instanceof ScalarType) { - return self::printScalar($type); + return self::printScalar($type, $options); } else if ($type instanceof ObjectType) { - return self::printObject($type); + return self::printObject($type, $options); } else if ($type instanceof InterfaceType) { - return self::printInterface($type); + return self::printInterface($type, $options); } else if ($type instanceof UnionType) { - return self::printUnion($type); + return self::printUnion($type, $options); } else if ($type instanceof EnumType) { - return self::printEnum($type); + return self::printEnum($type, $options); } Utils::invariant($type instanceof InputObjectType); - return self::printInputObject($type); + return self::printInputObject($type, $options); } - private static function printScalar(ScalarType $type) + private static function printScalar(ScalarType $type, array $options) { - return self::printDescription($type) . "scalar {$type->name}"; + return self::printDescription($options, $type) . "scalar {$type->name}"; } - private static function printObject(ObjectType $type) + private static function printObject(ObjectType $type, array $options) { $interfaces = $type->getInterfaces(); $implementedInterfaces = !empty($interfaces) ? ' implements ' . implode(', ', array_map(function($i) { return $i->name; }, $interfaces)) : ''; - return self::printDescription($type) . + return self::printDescription($options, $type) . "type {$type->name}$implementedInterfaces {\n" . - self::printFields($type) . "\n" . + self::printFields($options, $type) . "\n" . "}"; } - private static function printInterface(InterfaceType $type) + private static function printInterface(InterfaceType $type, array $options) { - return self::printDescription($type) . + return self::printDescription($options, $type) . "interface {$type->name} {\n" . - self::printFields($type) . "\n" . + self::printFields($options, $type) . "\n" . "}"; } - private static function printUnion(UnionType $type) + private static function printUnion(UnionType $type, array $options) { - return self::printDescription($type) . + return self::printDescription($options, $type) . "union {$type->name} = " . implode(" | ", $type->getTypes()); } - private static function printEnum(EnumType $type) + private static function printEnum(EnumType $type, array $options) { - return self::printDescription($type) . + return self::printDescription($options, $type) . "enum {$type->name} {\n" . - self::printEnumValues($type->getValues()) . "\n" . + self::printEnumValues($type->getValues(), $options) . "\n" . "}"; } - private static function printEnumValues($values) + private static function printEnumValues($values, $options) { - return implode("\n", array_map(function($value, $i) { - return self::printDescription($value, ' ', !$i) . ' ' . + return implode("\n", array_map(function($value, $i) use ($options) { + return self::printDescription($options, $value, ' ', !$i) . ' ' . $value->name . self::printDeprecated($value); }, $values, array_keys($values))); } - private static function printInputObject(InputObjectType $type) + private static function printInputObject(InputObjectType $type, array $options) { $fields = array_values($type->getFields()); - return self::printDescription($type) . + return self::printDescription($options, $type) . "input {$type->name} {\n" . - implode("\n", array_map(function($f, $i) { - return self::printDescription($f, ' ', !$i) . ' ' . self::printInputValue($f); + implode("\n", array_map(function($f, $i) use ($options) { + return self::printDescription($options, $f, ' ', !$i) . ' ' . self::printInputValue($f); }, $fields, array_keys($fields))) . "\n" . "}"; } - private static function printFields($type) + private static function printFields($options, $type) { $fields = array_values($type->getFields()); - return implode("\n", array_map(function($f, $i) { - return self::printDescription($f, ' ', !$i) . ' ' . - $f->name . self::printArgs($f->args, ' ') . ': ' . + return implode("\n", array_map(function($f, $i) use ($options) { + return self::printDescription($options, $f, ' ', !$i) . ' ' . + $f->name . self::printArgs($options, $f->args, ' ') . ': ' . (string) $f->getType() . self::printDeprecated($f); }, $fields, array_keys($fields))); } - private static function printArgs($args, $indentation = '') + private static function printArgs($options, $args, $indentation = '') { if (count($args) === 0) { return ''; @@ -242,8 +256,8 @@ private static function printArgs($args, $indentation = '') return '(' . implode(', ', array_map('self::printInputValue', $args)) . ')'; } - return "(\n" . implode("\n", array_map(function($arg, $i) use ($indentation) { - return self::printDescription($arg, ' ' . $indentation, !$i) . ' ' . $indentation . + return "(\n" . implode("\n", array_map(function($arg, $i) use ($indentation, $options) { + return self::printDescription($options, $arg, ' ' . $indentation, !$i) . ' ' . $indentation . self::printInputValue($arg); }, $args, array_keys($args))) . "\n" . $indentation . ')'; } @@ -257,10 +271,10 @@ private static function printInputValue($arg) return $argDecl; } - private static function printDirective($directive) + private static function printDirective($directive, $options) { - return self::printDescription($directive) . - 'directive @' . $directive->name . self::printArgs($directive->args) . + return self::printDescription($options, $directive) . + 'directive @' . $directive->name . self::printArgs($options, $directive->args) . ' on ' . implode(' | ', $directive->locations); } @@ -277,34 +291,74 @@ private static function printDeprecated($fieldOrEnumVal) Printer::doPrint(AST::astFromValue($reason, Type::string())) . ')'; } - private static function printDescription($def, $indentation = '', $firstInBlock = true) + private static function printDescription($options, $def, $indentation = '', $firstInBlock = true) { if (!$def->description) { return ''; } - $lines = explode("\n", $def->description); + $lines = self::descriptionLines($def->description, 120 - strlen($indentation)); + if (isset($options['commentDescriptions'])) { + return self::printDescriptionWithComments($lines, $indentation, $firstInBlock); + } + + $description = ($indentation && !$firstInBlock) ? "\n" : ''; + if (count($lines) === 1 && mb_strlen($lines[0]) < 70) { + $description .= $indentation . '"""' . self::escapeQuote($lines[0]) . "\"\"\"\n"; + return $description; + } + + $description .= $indentation . "\"\"\"\n"; + foreach ($lines as $line) { + $description .= $indentation . self::escapeQuote($line) . "\n"; + } + $description .= $indentation . "\"\"\"\n"; + + return $description; + } + + private static function escapeQuote($line) + { + return str_replace('"""', '\\"""', $line); + } + + private static function printDescriptionWithComments($lines, $indentation, $firstInBlock) + { $description = $indentation && !$firstInBlock ? "\n" : ''; foreach ($lines as $line) { if ($line === '') { $description .= $indentation . "#\n"; + } else { + $description .= $indentation . '# ' . $line . "\n"; + } + } + + return $description; + } + + private static function descriptionLines($description, $maxLen) { + $lines = []; + $rawLines = explode("\n", $description); + foreach($rawLines as $line) { + if ($line === '') { + $lines[] = $line; } else { // For > 120 character long lines, cut at space boundaries into sublines // of ~80 chars. - $sublines = self::breakLine($line, 120 - strlen($indentation)); + $sublines = self::breakLine($line, $maxLen); foreach ($sublines as $subline) { - $description .= $indentation . '# ' . $subline . "\n"; + $lines[] = $subline; } } } - return $description; + return $lines; } - private static function breakLine($line, $len) + private static function breakLine($line, $maxLen) { - if (strlen($line) < $len + 5) { + if (strlen($line) < $maxLen + 5) { return [$line]; } - preg_match_all("/((?: |^).{15," . ($len - 40) . "}(?= |$))/", $line, $parts); + preg_match_all("/((?: |^).{15," . ($maxLen - 40) . "}(?= |$))/", $line, $parts); $parts = $parts[0]; return array_map(function($part) { return trim($part); diff --git a/tests/Language/SchemaParserTest.php b/tests/Language/SchemaParserTest.php index 7b0324e5e..81d8a3f5d 100644 --- a/tests/Language/SchemaParserTest.php +++ b/tests/Language/SchemaParserTest.php @@ -45,6 +45,93 @@ public function testSimpleType() $this->assertEquals($expected, TestUtils::nodeToArray($doc)); } + /** + * @it parses type with description string + */ + public function testParsesTypeWithDescriptionString() + { + $body = ' +"Description" +type Hello { + world: String +}'; + $doc = Parser::parse($body); + $loc = function($start, $end) {return TestUtils::locArray($start, $end);}; + + $expected = [ + 'kind' => NodeKind::DOCUMENT, + 'definitions' => [ + [ + 'kind' => NodeKind::OBJECT_TYPE_DEFINITION, + 'name' => $this->nameNode('Hello', $loc(20, 25)), + 'interfaces' => [], + 'directives' => [], + 'fields' => [ + $this->fieldNode( + $this->nameNode('world', $loc(30, 35)), + $this->typeNode('String', $loc(37, 43)), + $loc(30, 43) + ) + ], + 'loc' => $loc(1, 45), + 'description' => [ + 'kind' => NodeKind::STRING, + 'value' => 'Description', + 'loc' => $loc(1, 14), + 'block' => false + ] + ] + ], + 'loc' => $loc(0, 45) + ]; + $this->assertEquals($expected, TestUtils::nodeToArray($doc)); + } + + /** + * @it parses type with description multi-linestring + */ + public function testParsesTypeWithDescriptionMultiLineString() + { + $body = ' +""" +Description +""" +# Even with comments between them +type Hello { + world: String +}'; + $doc = Parser::parse($body); + $loc = function($start, $end) {return TestUtils::locArray($start, $end);}; + + $expected = [ + 'kind' => NodeKind::DOCUMENT, + 'definitions' => [ + [ + 'kind' => NodeKind::OBJECT_TYPE_DEFINITION, + 'name' => $this->nameNode('Hello', $loc(60, 65)), + 'interfaces' => [], + 'directives' => [], + 'fields' => [ + $this->fieldNode( + $this->nameNode('world', $loc(70, 75)), + $this->typeNode('String', $loc(77, 83)), + $loc(70, 83) + ) + ], + 'loc' => $loc(1, 85), + 'description' => [ + 'kind' => NodeKind::STRING, + 'value' => 'Description', + 'loc' => $loc(1, 20), + 'block' => true + ] + ] + ], + 'loc' => $loc(0, 85) + ]; + $this->assertEquals($expected, TestUtils::nodeToArray($doc)); + } + /** * @it Simple extension */ @@ -87,6 +174,20 @@ public function testSimpleExtension() $this->assertEquals($expected, TestUtils::nodeToArray($doc)); } + /** + * @it Extension do not include descriptions + * @expectedException \GraphQL\Error\SyntaxError + * @expectedExceptionMessage Syntax Error GraphQL (2:1) + */ + public function testExtensionDoNotIncludeDescriptions() { + $body = ' +"Description" +extend type Hello { + world: String +}'; + Parser::parse($body); + } + /** * @it Simple non-null type */ @@ -664,47 +765,6 @@ public function testSimpleInputObjectWithArgsShouldFail() Parser::parse($body); } - /** - * @it Simple type - */ - public function testSimpleTypeDescriptionInComments() - { - $body = ' -# This is a simple type description. -# It is multiline *and includes formatting*. -type Hello { - # And this is a field description - world: String -}'; - $doc = Parser::parse($body); - $loc = function($start, $end) {return TestUtils::locArray($start, $end);}; - - $fieldNode = $this->fieldNode( - $this->nameNode('world', $loc(134, 139)), - $this->typeNode('String', $loc(141, 147)), - $loc(134, 147) - ); - $fieldNode['description'] = " And this is a field description\n"; - $expected = [ - 'kind' => NodeKind::DOCUMENT, - 'definitions' => [ - [ - 'kind' => NodeKind::OBJECT_TYPE_DEFINITION, - 'name' => $this->nameNode('Hello', $loc(88, 93)), - 'interfaces' => [], - 'directives' => [], - 'fields' => [ - $fieldNode - ], - 'loc' => $loc(83, 149), - 'description' => " This is a simple type description.\n It is multiline *and includes formatting*.\n" - ] - ], - 'loc' => $loc(0, 149) - ]; - $this->assertEquals($expected, TestUtils::nodeToArray($doc)); - } - private function typeNode($name, $loc) { return [ diff --git a/tests/Language/SchemaPrinterTest.php b/tests/Language/SchemaPrinterTest.php index a649cedcd..3b9a09817 100644 --- a/tests/Language/SchemaPrinterTest.php +++ b/tests/Language/SchemaPrinterTest.php @@ -56,6 +56,10 @@ public function testPrintsKitchenSink() mutation: MutationType } +""" +This is a description +of the `Foo` type. +""" type Foo implements Bar { one: Type two(argument: InputType!): Type diff --git a/tests/Language/schema-kitchen-sink.graphql b/tests/Language/schema-kitchen-sink.graphql index 7771a3518..4b3fbaa15 100644 --- a/tests/Language/schema-kitchen-sink.graphql +++ b/tests/Language/schema-kitchen-sink.graphql @@ -8,6 +8,10 @@ schema { mutation: MutationType } +""" +This is a description +of the `Foo` type. +""" type Foo implements Bar { one: Type two(argument: InputType!): Type diff --git a/tests/Utils/BuildSchemaTest.php b/tests/Utils/BuildSchemaTest.php index 951d3582b..095f315a8 100644 --- a/tests/Utils/BuildSchemaTest.php +++ b/tests/Utils/BuildSchemaTest.php @@ -17,11 +17,11 @@ class BuildSchemaTest extends \PHPUnit_Framework_TestCase { // Describe: Schema Builder - private function cycleOutput($body) + private function cycleOutput($body, $options = []) { $ast = Parser::parse($body); - $schema = BuildSchema::buildAST($ast); - return "\n" . SchemaPrinter::doPrint($schema); + $schema = BuildSchema::buildAST($ast, null, $options); + return "\n" . SchemaPrinter::doPrint($schema, $options); } /** @@ -35,7 +35,7 @@ public function testUseBuiltSchemaForLimitedExecution() str: String } ')); - + $result = GraphQL::execute($schema, '{ str }', ['str' => 123]); $this->assertEquals($result['data'], ['str' => 123]); } @@ -110,6 +110,42 @@ public function testWithDirectives() * @it Supports descriptions */ public function testSupportsDescriptions() + { + $body = ' +schema { + query: Hello +} + +"""This is a directive""" +directive @foo( + """It has an argument""" + arg: Int +) on FIELD + +"""With an enum""" +enum Color { + RED + + """Not a creative color""" + GREEN + BLUE +} + +"""What a great type""" +type Hello { + """And a field to boot""" + str: String +} +'; + + $output = $this->cycleOutput($body); + $this->assertEquals($body, $output); + } + + /** + * @it Supports descriptions + */ + public function testSupportsOptionForCommentDescriptions() { $body = ' schema { @@ -137,7 +173,7 @@ enum Color { str: String } '; - $output = $this->cycleOutput($body); + $output = $this->cycleOutput($body, [ 'commentDescriptions' => true ]); $this->assertEquals($body, $output); } @@ -1115,44 +1151,4 @@ interface Hello { $this->assertArrayHasKey('Hello', $types); $this->assertArrayHasKey('World', $types); } - - public function testScalarDescription() - { - $schemaDef = ' -# An ISO-8601 encoded UTC date string. -scalar Date - -type Query { - now: Date - test: String -} -'; - $q = ' -{ - __type(name: "Date") { - name - description - } - strType: __type(name: "String") { - name - description - } -} -'; - $schema = BuildSchema::build($schemaDef); - $result = GraphQL::executeQuery($schema, $q)->toArray(); - $expected = ['data' => [ - '__type' => [ - 'name' => 'Date', - 'description' => 'An ISO-8601 encoded UTC date string.' - ], - 'strType' => [ - 'name' => 'String', - 'description' => 'The `String` scalar type represents textual data, represented as UTF-8' . "\n" . - 'character sequences. The String type is most often used by GraphQL to'. "\n" . - 'represent free-form human-readable text.' - ] - ]]; - $this->assertEquals($expected, $result); - } } diff --git a/tests/Utils/SchemaPrinterTest.php b/tests/Utils/SchemaPrinterTest.php index 49314f00d..0acf0c2a8 100644 --- a/tests/Utils/SchemaPrinterTest.php +++ b/tests/Utils/SchemaPrinterTest.php @@ -650,6 +650,257 @@ public function testPrintIntrospectionSchema() query: Root } +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include( + """Included when true.""" + if: Boolean! +) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip( + """Skipped when true.""" + if: Boolean! +) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +"""Marks an element of a GraphQL schema as no longer supported.""" +directive @deprecated( + """ + Explains why this element was deprecated, usually also including a suggestion + for how to access supported similar data. Formatted in + [Markdown](https://daringfireball.net/projects/markdown/). + """ + reason: String = "No longer supported" +) on FIELD_DEFINITION | ENUM_VALUE + +""" +A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. + +In some cases, you need to provide options to alter GraphQL's execution behavior +in ways field arguments will not suffice, such as conditionally including or +skipping a field. Directives provide this by describing additional information +to the executor. +""" +type __Directive { + name: String! + description: String + locations: [__DirectiveLocation!]! + args: [__InputValue!]! + onOperation: Boolean! @deprecated(reason: "Use `locations`.") + onFragment: Boolean! @deprecated(reason: "Use `locations`.") + onField: Boolean! @deprecated(reason: "Use `locations`.") +} + +""" +A Directive can be adjacent to many parts of the GraphQL language, a +__DirectiveLocation describes one such possible adjacencies. +""" +enum __DirectiveLocation { + """Location adjacent to a query operation.""" + QUERY + + """Location adjacent to a mutation operation.""" + MUTATION + + """Location adjacent to a subscription operation.""" + SUBSCRIPTION + + """Location adjacent to a field.""" + FIELD + + """Location adjacent to a fragment definition.""" + FRAGMENT_DEFINITION + + """Location adjacent to a fragment spread.""" + FRAGMENT_SPREAD + + """Location adjacent to an inline fragment.""" + INLINE_FRAGMENT + + """Location adjacent to a schema definition.""" + SCHEMA + + """Location adjacent to a scalar definition.""" + SCALAR + + """Location adjacent to an object type definition.""" + OBJECT + + """Location adjacent to a field definition.""" + FIELD_DEFINITION + + """Location adjacent to an argument definition.""" + ARGUMENT_DEFINITION + + """Location adjacent to an interface definition.""" + INTERFACE + + """Location adjacent to a union definition.""" + UNION + + """Location adjacent to an enum definition.""" + ENUM + + """Location adjacent to an enum value definition.""" + ENUM_VALUE + + """Location adjacent to an input object type definition.""" + INPUT_OBJECT + + """Location adjacent to an input object field definition.""" + INPUT_FIELD_DEFINITION +} + +""" +One possible value for a given Enum. Enum values are unique values, not a +placeholder for a string or numeric value. However an Enum value is returned in +a JSON response as a string. +""" +type __EnumValue { + name: String! + description: String + isDeprecated: Boolean! + deprecationReason: String +} + +""" +Object and Interface types are described by a list of Fields, each of which has +a name, potentially a list of arguments, and a return type. +""" +type __Field { + name: String! + description: String + args: [__InputValue!]! + type: __Type! + isDeprecated: Boolean! + deprecationReason: String +} + +""" +Arguments provided to Fields or Directives and the input fields of an +InputObject are represented as Input Values which describe their type and +optionally a default value. +""" +type __InputValue { + name: String! + description: String + type: __Type! + + """ + A GraphQL-formatted string representing the default value for this input value. + """ + defaultValue: String +} + +""" +A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all +available types and directives on the server, as well as the entry points for +query, mutation, and subscription operations. +""" +type __Schema { + """A list of all types supported by this server.""" + types: [__Type!]! + + """The type that query operations will be rooted at.""" + queryType: __Type! + + """ + If this server supports mutation, the type that mutation operations will be rooted at. + """ + mutationType: __Type + + """ + If this server support subscription, the type that subscription operations will be rooted at. + """ + subscriptionType: __Type + + """A list of all directives supported by this server.""" + directives: [__Directive!]! +} + +""" +The fundamental unit of any GraphQL Schema is the type. There are many kinds of +types in GraphQL as represented by the `__TypeKind` enum. + +Depending on the kind of a type, certain fields describe information about that +type. Scalar types provide no information beyond a name and description, while +Enum types provide their values. Object and Interface types provide the fields +they describe. Abstract types, Union and Interface, provide the Object types +possible at runtime. List and NonNull types compose other types. +""" +type __Type { + kind: __TypeKind! + name: String + description: String + fields(includeDeprecated: Boolean = false): [__Field!] + interfaces: [__Type!] + possibleTypes: [__Type!] + enumValues(includeDeprecated: Boolean = false): [__EnumValue!] + inputFields: [__InputValue!] + ofType: __Type +} + +"""An enum describing what kind of type a given `__Type` is.""" +enum __TypeKind { + """Indicates this type is a scalar.""" + SCALAR + + """ + Indicates this type is an object. `fields` and `interfaces` are valid fields. + """ + OBJECT + + """ + Indicates this type is an interface. `fields` and `possibleTypes` are valid fields. + """ + INTERFACE + + """Indicates this type is a union. `possibleTypes` is a valid field.""" + UNION + + """Indicates this type is an enum. `enumValues` is a valid field.""" + ENUM + + """ + Indicates this type is an input object. `inputFields` is a valid field. + """ + INPUT_OBJECT + + """Indicates this type is a list. `ofType` is a valid field.""" + LIST + + """Indicates this type is a non-null. `ofType` is a valid field.""" + NON_NULL +} + +EOT; + $this->assertEquals($introspectionSchema, $output); + } + + /** + * @it Print Introspection Schema with comment description + */ + public function testPrintIntrospectionSchemaWithCommentDescription() + { + $root = new ObjectType([ + 'name' => 'Root', + 'fields' => [ + 'onlyField' => ['type' => Type::string()] + ] + ]); + + $schema = new Schema(['query' => $root]); + $output = SchemaPrinter::printIntrosepctionSchema($schema, [ + 'commentDescriptions' => true + ]); + $introspectionSchema = <<<'EOT' +schema { + query: Root +} + # Directs the executor to include this field or fragment only when the `if` argument is true. directive @include( # Included when true. @@ -845,6 +1096,6 @@ enum __TypeKind { } EOT; - $this->assertEquals($output, $introspectionSchema); + $this->assertEquals($introspectionSchema, $output); } } \ No newline at end of file From 7705e50e441d293a7a24b3e1b189299c43ceb9fe Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Thu, 8 Feb 2018 19:49:40 +0100 Subject: [PATCH 06/50] Fix print of block string with leading space and quotation ref: graphql/graphql-js#1190 --- src/Language/Printer.php | 15 +++++++-------- tests/Language/PrinterTest.php | 21 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/Language/Printer.php b/src/Language/Printer.php index edf55101d..0e5566d49 100644 --- a/src/Language/Printer.php +++ b/src/Language/Printer.php @@ -307,12 +307,14 @@ public function wrap($start, $maybeString, $end = '') */ public function block($array) { - return $array && $this->length($array) ? $this->indent("{\n" . $this->join($array, "\n")) . "\n}" : '{}'; + return ($array && $this->length($array)) + ? "{\n" . $this->indent($this->join($array, "\n")) . "\n}" + : '{}'; } public function indent($maybeString) { - return $maybeString ? str_replace("\n", "\n ", $maybeString) : ''; + return $maybeString ? ' ' . str_replace("\n", "\n ", $maybeString) : ''; } public function manyList($start, $list, $separator, $end) @@ -344,12 +346,9 @@ function($x) { return !!$x;} * a single-line, adding a leading blank line would strip that whitespace. */ private function printBlockString($value, $isDescription) { + $escaped = str_replace('"""', '\\"""', $value); return (($value[0] === ' ' || $value[0] === "\t") && strpos($value, "\n") === false) - ? ('"""' . str_replace('"""', '\\"""', $value) . '"""') - : ( - $isDescription - ? ("\"\"\"\n" . str_replace('"""', '\\"""', $value) . "\n\"\"\"") - : ($this->indent("\"\"\"\n" . str_replace('"""', '\\"""', $value)) . "\n\"\"\"") - ); + ? ('"""' . preg_replace('/"$/', "\"\n", $escaped) . '"""') + : ("\"\"\"\n" . ($isDescription ? $escaped : $this->indent($escaped)) . "\n\"\"\""); } } diff --git a/tests/Language/PrinterTest.php b/tests/Language/PrinterTest.php index 301abff3d..42bc0bcf1 100644 --- a/tests/Language/PrinterTest.php +++ b/tests/Language/PrinterTest.php @@ -106,7 +106,7 @@ public function testCorrectlyPrintsSingleLineBlockStringsWithLeadingSpace() '; $this->assertEquals($expected, Printer::doPrint($mutationAstWithArtifacts)); } - + /** * @it correctly prints block strings with a first line indentation */ @@ -132,6 +132,25 @@ public function testCorrectlyPrintsBlockStringsWithAFirstLineIndentation() $this->assertEquals($expected, Printer::doPrint($mutationAstWithArtifacts)); } + /** + * @it correctly prints single-line with leading space and quotation + */ + public function testCorrectlyPrintsSingleLineStringsWithLeadingSpaceAndQuotation() + { + $mutationAstWithArtifacts = Parser::parse( + '{ + field(arg: """ space-led value "quoted string" + """) +}' + ); + $expected = '{ + field(arg: """ space-led value "quoted string" + """) +} +'; + $this->assertEquals($expected, Printer::doPrint($mutationAstWithArtifacts)); + } + /** * @it prints kitchen sink */ From 4e26de3588ef6657e9bc0aa31227789be82057a6 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Fri, 9 Feb 2018 12:54:34 +0100 Subject: [PATCH 07/50] Support for union types when using buildSchema * Adds support for resolving union/interface types when using a generated schema * Move resolveType __typename checking into defaultResolveType * Clean up existing tests and improve error messages ref: graphql/graphql-js#947 # Conflicts: # src/Utils/BuildSchema.php # tests/Utils/BuildSchemaTest.php --- src/Executor/Executor.php | 45 ++++-- src/Type/Definition/ObjectType.php | 9 -- src/Type/Definition/UnionType.php | 9 -- src/Utils/BuildSchema.php | 64 ++++---- tests/Executor/AbstractPromiseTest.php | 4 - tests/Executor/UnionInterfaceTest.php | 8 +- tests/Type/DefinitionTest.php | 7 +- tests/Type/ValidationTest.php | 118 --------------- tests/Utils/BuildSchemaTest.php | 140 +++++++++++++++++- tests/Utils/FindBreakingChangesTest.php | 29 +--- tests/Utils/SchemaPrinterTest.php | 7 +- .../OverlappingFieldsCanBeMergedTest.php | 3 - tests/Validator/TestCase.php | 16 -- 13 files changed, 198 insertions(+), 261 deletions(-) diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index 998038de5..b2ba19aad 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -1053,15 +1053,6 @@ private function completeAbstractValue(AbstractType $returnType, $fieldNodes, Re $runtimeType = $returnType->resolveType($result, $exeContext->contextValue, $info); if (null === $runtimeType) { - if ($returnType instanceof InterfaceType && $info->schema->getConfig()->typeLoader) { - Warning::warnOnce( - "GraphQL Interface Type `{$returnType->name}` returned `null` from it`s `resolveType` function ". - 'for value: ' . Utils::printSafe($result) . '. 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 - ); - } $runtimeType = self::defaultTypeResolver($result, $exeContext->contextValue, $info, $returnType); } @@ -1122,9 +1113,11 @@ private function ensureValidRuntimeType( 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) . '".' + "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.' ); } @@ -1307,7 +1300,12 @@ private function collectAndExecuteSubfields( /** * If a resolveType function is not given, then a default resolve behavior is - * used which tests each possible type for the abstract type by calling + * used which attempts two strategies: + * + * First, See if the provided value has a `__typename` field defined, if so, use + * that value as name of the resolved type. + * + * Otherwise, test each possible type for the abstract type by calling * isTypeOf for the object being coerced, returning the first type that matches. * * @param $value @@ -1318,6 +1316,27 @@ private function collectAndExecuteSubfields( */ private function defaultTypeResolver($value, $context, ResolveInfo $info, AbstractType $abstractType) { + // First, look for `__typename`. + if ( + $value !== null && + is_array($value) && + isset($value['__typename']) && + is_string($value['__typename']) + ) { + return $value['__typename']; + } + + 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 = []; diff --git a/src/Type/Definition/ObjectType.php b/src/Type/Definition/ObjectType.php index 43d23559b..d77a731e1 100644 --- a/src/Type/Definition/ObjectType.php +++ b/src/Type/Definition/ObjectType.php @@ -240,15 +240,6 @@ public function assertValid() "{$this->name} may declare it implements {$iface->name} only once." ); $implemented[$iface->name] = true; - if (!isset($iface->config['resolveType'])) { - Utils::invariant( - isset($this->config['isTypeOf']), - "Interface Type {$iface->name} does not provide a \"resolveType\" " . - "function and implementing Type {$this->name} does not provide a " . - '"isTypeOf" function. There is no way to resolve this implementing ' . - 'type during execution.' - ); - } } } } diff --git a/src/Type/Definition/UnionType.php b/src/Type/Definition/UnionType.php index 49855b76f..06d57fc0a 100644 --- a/src/Type/Definition/UnionType.php +++ b/src/Type/Definition/UnionType.php @@ -158,15 +158,6 @@ public function assertValid() "{$this->name} can include {$objType->name} type only once." ); $includedTypeNames[$objType->name] = true; - if (!isset($this->config['resolveType'])) { - Utils::invariant( - isset($objType->config['isTypeOf']) && is_callable($objType->config['isTypeOf']), - "Union type \"{$this->name}\" does not provide a \"resolveType\" " . - "function and possible type \"{$objType->name}\" does not provide an " . - '"isTypeOf" function. There is no way to resolve this possible type ' . - 'during execution.' - ); - } } } } diff --git a/src/Utils/BuildSchema.php b/src/Utils/BuildSchema.php index 078427ad3..9dc966c75 100644 --- a/src/Utils/BuildSchema.php +++ b/src/Utils/BuildSchema.php @@ -216,21 +216,21 @@ public function buildSchema() $directives = array_map([$this, 'getDirective'], $directiveDefs); // If specified directives were not explicitly declared, add them. - $skip = array_reduce($directives, function($hasSkip, $directive) { + $skip = array_reduce($directives, function ($hasSkip, $directive) { return $hasSkip || $directive->name == 'skip'; }); if (!$skip) { $directives[] = Directive::skipDirective(); } - $include = array_reduce($directives, function($hasInclude, $directive) { + $include = array_reduce($directives, function ($hasInclude, $directive) { return $hasInclude || $directive->name == 'include'; }); if (!$include) { $directives[] = Directive::includeDirective(); } - $deprecated = array_reduce($directives, function($hasDeprecated, $directive) { + $deprecated = array_reduce($directives, function ($hasDeprecated, $directive) { return $hasDeprecated || $directive->name == 'deprecated'; }); if (!$deprecated) { @@ -245,12 +245,12 @@ public function buildSchema() 'subscription' => $subscriptionTypeName ? $this->getObjectType($this->nodeMap[$subscriptionTypeName]) : null, - 'typeLoader' => function($name) { + 'typeLoader' => function ($name) { return $this->typeDefNamed($name); }, 'directives' => $directives, 'astNode' => $schemaDef, - 'types' => function() { + 'types' => function () { $types = []; foreach ($this->nodeMap as $name => $def) { if (!isset($this->loadedTypeDefs[$name])) { @@ -269,7 +269,7 @@ private function getDirective(DirectiveDefinitionNode $directiveNode) return new Directive([ 'name' => $directiveNode->name->value, 'description' => $this->getDescription($directiveNode), - 'locations' => Utils::map($directiveNode->locations, function($node) { + 'locations' => Utils::map($directiveNode->locations, function ($node) { return $node->value; }), 'args' => $directiveNode->arguments ? FieldArgument::createMap($this->makeInputValues($directiveNode->arguments)) : null, @@ -342,7 +342,7 @@ private function typeDefNamed($typeName) $config = $fn($config, $this->nodeMap[$typeName], $this->nodeMap); } catch (\Exception $e) { throw new Error( - "Type config decorator passed to " . (static::class) . " threw an error ". + "Type config decorator passed to " . (static::class) . " threw an error " . "when building $typeName type: {$e->getMessage()}", null, null, @@ -352,7 +352,7 @@ private function typeDefNamed($typeName) ); } catch (\Throwable $e) { throw new Error( - "Type config decorator passed to " . (static::class) . " threw an error ". + "Type config decorator passed to " . (static::class) . " threw an error " . "when building $typeName type: {$e->getMessage()}", null, null, @@ -363,7 +363,7 @@ private function typeDefNamed($typeName) } if (!is_array($config) || isset($config[0])) { throw new Error( - "Type config decorator passed to " . (static::class) . " is expected to return an array, but got ". + "Type config decorator passed to " . (static::class) . " is expected to return an array, but got " . Utils::getVariableType($config) ); } @@ -433,10 +433,10 @@ private function makeTypeDefConfig(ObjectTypeDefinitionNode $def) return [ 'name' => $typeName, 'description' => $this->getDescription($def), - 'fields' => function() use ($def) { + 'fields' => function () use ($def) { return $this->makeFieldDefMap($def); }, - 'interfaces' => function() use ($def) { + 'interfaces' => function () use ($def) { return $this->makeImplementedInterfaces($def); }, 'astNode' => $def @@ -450,7 +450,7 @@ private function makeFieldDefMap($def) function ($field) { return $field->name->value; }, - function($field) { + function ($field) { return [ 'type' => $this->produceOutputType($field->type), 'description' => $this->getDescription($field), @@ -479,7 +479,7 @@ private function makeInputValues($values) function ($value) { return $value->name->value; }, - function($value) { + function ($value) { $type = $this->produceInputType($value->type); $config = [ 'name' => $value->name->value, @@ -501,13 +501,10 @@ private function makeInterfaceDefConfig(InterfaceTypeDefinitionNode $def) return [ 'name' => $typeName, 'description' => $this->getDescription($def), - 'fields' => function() use ($def) { + 'fields' => function () use ($def) { return $this->makeFieldDefMap($def); }, - 'astNode' => $def, - 'resolveType' => function() { - $this->cannotExecuteSchema(); - } + 'astNode' => $def ]; } @@ -519,10 +516,10 @@ private function makeEnumDefConfig(EnumTypeDefinitionNode $def) 'astNode' => $def, 'values' => Utils::keyValMap( $def->values, - function($enumValue) { + function ($enumValue) { return $enumValue->name->value; }, - function($enumValue) { + function ($enumValue) { return [ 'description' => $this->getDescription($enumValue), 'deprecationReason' => $this->getDeprecationReason($enumValue), @@ -538,11 +535,10 @@ private function makeUnionDefConfig(UnionTypeDefinitionNode $def) return [ 'name' => $def->name->value, 'description' => $this->getDescription($def), - 'types' => Utils::map($def->types, function($typeNode) { + 'types' => Utils::map($def->types, function ($typeNode) { return $this->produceObjectType($typeNode); }), - 'astNode' => $def, - 'resolveType' => [$this, 'cannotExecuteSchema'] + 'astNode' => $def ]; } @@ -552,17 +548,17 @@ private function makeScalarDefConfig(ScalarTypeDefinitionNode $def) 'name' => $def->name->value, 'description' => $this->getDescription($def), 'astNode' => $def, - 'serialize' => function() { + 'serialize' => function () { return false; }, // Note: validation calls the parse functions to determine if a // literal value is correct. Returning null would cause use of custom // scalars to always fail validation. Returning false causes them to // always pass validation. - 'parseValue' => function() { + 'parseValue' => function () { return false; }, - 'parseLiteral' => function() { + 'parseLiteral' => function () { return false; } ]; @@ -573,7 +569,9 @@ private function makeInputObjectDefConfig(InputObjectTypeDefinitionNode $def) return [ 'name' => $def->name->value, 'description' => $this->getDescription($def), - 'fields' => function() use ($def) { return $this->makeInputValues($def->fields); }, + 'fields' => function () use ($def) { + return $this->makeInputValues($def->fields); + }, 'astNode' => $def, ]; } @@ -611,7 +609,7 @@ public function getLeadingCommentBlock($node) { $loc = $node->loc; if (!$loc || !$loc->startToken) { - return ; + return; } $comments = []; $token = $loc->startToken->prev; @@ -644,12 +642,4 @@ public static function build($source, callable $typeConfigDecorator = null) $doc = $source instanceof DocumentNode ? $source : Parser::parse($source); return self::buildAST($doc, $typeConfigDecorator); } - - public function cannotExecuteSchema() - { - throw new Error( - 'Generated Schema cannot use Interface or Union types for execution.' - ); - } - -} \ No newline at end of file +} diff --git a/tests/Executor/AbstractPromiseTest.php b/tests/Executor/AbstractPromiseTest.php index 5b0f576e8..7652d7e39 100644 --- a/tests/Executor/AbstractPromiseTest.php +++ b/tests/Executor/AbstractPromiseTest.php @@ -87,9 +87,7 @@ public function testIsTypeOfUsedToResolveRuntimeTypeForInterface() } }'; - Warning::suppress(Warning::WARNING_FULL_SCHEMA_SCAN); $result = GraphQL::execute($schema, $query); - Warning::enable(Warning::WARNING_FULL_SCHEMA_SCAN); $expected = [ 'data' => [ @@ -174,9 +172,7 @@ public function testIsTypeOfCanBeRejected() } }'; - Warning::suppress(Warning::WARNING_FULL_SCHEMA_SCAN); $result = GraphQL::execute($schema, $query); - Warning::enable(Warning::WARNING_FULL_SCHEMA_SCAN); $expected = [ 'data' => [ diff --git a/tests/Executor/UnionInterfaceTest.php b/tests/Executor/UnionInterfaceTest.php index 8fc3d8a6e..d2b0f14b4 100644 --- a/tests/Executor/UnionInterfaceTest.php +++ b/tests/Executor/UnionInterfaceTest.php @@ -256,9 +256,7 @@ public function testExecutesUsingInterfaceTypes() ] ]; - Warning::suppress(Warning::WARNING_FULL_SCHEMA_SCAN); $this->assertEquals($expected, Executor::execute($this->schema, $ast, $this->john)->toArray()); - Warning::enable(Warning::WARNING_FULL_SCHEMA_SCAN); } /** @@ -294,9 +292,7 @@ public function testExecutesInterfaceTypesWithInlineFragments() ] ]; - Warning::suppress(Warning::WARNING_FULL_SCHEMA_SCAN); - $this->assertEquals($expected, Executor::execute($this->schema, $ast, $this->john)->toArray()); - Warning::enable(Warning::WARNING_FULL_SCHEMA_SCAN); + $this->assertEquals($expected, Executor::execute($this->schema, $ast, $this->john)->toArray(true)); } /** @@ -351,9 +347,7 @@ public function testAllowsFragmentConditionsToBeAbstractTypes() ] ]; - Warning::suppress(Warning::WARNING_FULL_SCHEMA_SCAN); $this->assertEquals($expected, Executor::execute($this->schema, $ast, $this->john)->toArray()); - Warning::enable(Warning::WARNING_FULL_SCHEMA_SCAN); } /** diff --git a/tests/Type/DefinitionTest.php b/tests/Type/DefinitionTest.php index f0f3fe379..1622220b3 100644 --- a/tests/Type/DefinitionTest.php +++ b/tests/Type/DefinitionTest.php @@ -74,10 +74,7 @@ class DefinitionTest extends \PHPUnit_Framework_TestCase public function setUp() { - $this->objectType = new ObjectType([ - 'name' => 'Object', - 'isTypeOf' => function() {return true;} - ]); + $this->objectType = new ObjectType(['name' => 'Object']); $this->interfaceType = new InterfaceType(['name' => 'Interface']); $this->unionType = new UnionType(['name' => 'Union', 'types' => [$this->objectType]]); $this->enumType = new EnumType(['name' => 'Enum']); @@ -363,7 +360,6 @@ public function testIncludesInterfaceSubtypesInTheTypeMap() 'f' => ['type' => Type::int()] ], 'interfaces' => [$someInterface], - 'isTypeOf' => function() {return true;} ]); $schema = new Schema([ @@ -391,7 +387,6 @@ public function testIncludesInterfacesThunkSubtypesInTheTypeMap() 'f' => ['type' => Type::int()] ], 'interfaces' => function() use (&$someInterface) { return [$someInterface]; }, - 'isTypeOf' => function() {return true;} ]); $someInterface = new InterfaceType([ diff --git a/tests/Type/ValidationTest.php b/tests/Type/ValidationTest.php index 255247031..6d689c67b 100644 --- a/tests/Type/ValidationTest.php +++ b/tests/Type/ValidationTest.php @@ -58,24 +58,15 @@ public function setUp() $this->ObjectWithIsTypeOf = new ObjectType([ 'name' => 'ObjectWithIsTypeOf', - 'isTypeOf' => function() { - return true; - }, 'fields' => [ 'f' => [ 'type' => Type::string() ]] ]); $this->SomeUnionType = new UnionType([ 'name' => 'SomeUnion', - 'resolveType' => function() { - return null; - }, 'types' => [ $this->SomeObjectType ] ]); $this->SomeInterfaceType = new InterfaceType([ 'name' => 'SomeInterface', - 'resolveType' => function() { - return null; - }, 'fields' => [ 'f' => ['type' => Type::string() ]] ]); @@ -404,7 +395,6 @@ public function testRejectsASchemaWhichHaveSameNamedObjectsImplementingAnInterfa { $AnotherInterface = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function() {}, 'fields' => [ 'f' => [ 'type' => Type::string() ]], ]); @@ -736,8 +726,6 @@ public function testAcceptsAnObjectTypeWithArrayInterfaces() { $AnotherInterfaceType = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => ['f' => ['type' => Type::string()]] ]); @@ -756,8 +744,6 @@ public function testAcceptsAnObjectTypeWithInterfacesAsAFunctionReturningAnArray { $AnotherInterfaceType = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => ['f' => ['type' => Type::string()]] ]); @@ -795,14 +781,11 @@ public function testRejectsAnObjectThatDeclareItImplementsSameInterfaceMoreThanO { $NonUniqInterface = new InterfaceType([ 'name' => 'NonUniqInterface', - 'resolveType' => function () { - }, 'fields' => ['f' => ['type' => Type::string()]], ]); $AnotherInterface = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function(){}, 'fields' => ['f' => ['type' => Type::string()]], ]); @@ -851,9 +834,6 @@ public function testAcceptsAUnionTypeWithArrayTypes() { $schema = $this->schemaWithFieldType(new UnionType([ 'name' => 'SomeUnion', - 'resolveType' => function () { - return null; - }, 'types' => [$this->SomeObjectType], ])); $schema->assertValid(); @@ -866,9 +846,6 @@ public function testAcceptsAUnionTypeWithFunctionReturningAnArrayOfTypes() { $schema = $this->schemaWithFieldType(new UnionType([ 'name' => 'SomeUnion', - 'resolveType' => function () { - return null; - }, 'types' => function () { return [$this->SomeObjectType]; }, @@ -887,7 +864,6 @@ public function testRejectsAUnionTypeWithoutTypes() ); $this->schemaWithFieldType(new UnionType([ 'name' => 'SomeUnion', - 'resolveType' => function() {return null;} ])); } @@ -898,8 +874,6 @@ public function testRejectsAUnionTypeWithemptyTypes() { $schema = $this->schemaWithFieldType(new UnionType([ 'name' => 'SomeUnion', - 'resolveType' => function () { - }, 'types' => [] ])); @@ -921,8 +895,6 @@ public function testRejectsAUnionTypeWithIncorrectlyTypedTypes() ); $this->schemaWithFieldType(new UnionType([ 'name' => 'SomeUnion', - 'resolveType' => function () { - }, 'types' => $this->SomeObjectType ])); } @@ -934,7 +906,6 @@ public function testRejectsAUnionTypeWithDuplicatedMemberType() { $schema = $this->schemaWithFieldType(new UnionType([ 'name' => 'SomeUnion', - 'resolveType' => function(){}, 'types' => [ $this->SomeObjectType, $this->SomeObjectType, @@ -1193,8 +1164,6 @@ public function testAcceptsAnInterfaceTypeDefiningResolveType() { $AnotherInterfaceType = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => ['f' => ['type' => Type::string()]] ]); @@ -1234,8 +1203,6 @@ public function testAcceptsAnInterfaceTypeDefiningResolveTypeWithImplementingTyp { $AnotherInterfaceType = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => ['f' => ['type' => Type::string()]] ]); @@ -1270,32 +1237,6 @@ public function testRejectsAnInterfaceTypeWithAnIncorrectTypeForResolveType() $type->assertValid(); } - /** - * @it rejects an Interface type not defining resolveType with implementing type not defining isTypeOf - */ - public function testRejectsAnInterfaceTypeNotDefiningResolveTypeWithImplementingTypeNotDefiningIsTypeOf() - { - $InterfaceTypeWithoutResolveType = new InterfaceType([ - 'name' => 'InterfaceTypeWithoutResolveType', - 'fields' => ['f' => ['type' => Type::string()]] - ]); - - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'interfaces' => [$InterfaceTypeWithoutResolveType], - 'fields' => ['f' => ['type' => Type::string()]] - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'Interface Type InterfaceTypeWithoutResolveType does not provide a "resolveType" function and implementing '. - 'Type SomeObject does not provide a "isTypeOf" function. There is no way to resolve this implementing type '. - 'during execution.' - ); - - $schema->assertValid(); - } - // DESCRIBE: Type System: Union types must be resolvable /** @@ -1305,8 +1246,6 @@ public function testAcceptsAUnionTypeDefiningResolveType() { $schema = $this->schemaWithFieldType(new UnionType([ 'name' => 'SomeUnion', - 'resolveType' => function () { - }, 'types' => [$this->SomeObjectType], ])); $schema->assertValid(); @@ -1332,8 +1271,6 @@ public function testAcceptsAUnionTypeDefiningResolveTypeOfObjectTypesDefiningIsT { $schema = $this->schemaWithFieldType(new UnionType([ 'name' => 'SomeUnion', - 'resolveType' => function () { - }, 'types' => [$this->ObjectWithIsTypeOf], ])); $schema->assertValid(); @@ -1358,25 +1295,6 @@ public function testRejectsAUnionTypeWithAnIncorrectTypeForResolveType() $schema->assertValid(); } - /** - * @it rejects a Union type not defining resolveType of Object types not defining isTypeOf - */ - public function testRejectsAUnionTypeNotDefiningResolveTypeOfObjectTypesNotDefiningIsTypeOf() - { - $schema = $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'types' => [$this->SomeObjectType], - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'Union type "SomeUnion" does not provide a "resolveType" function and possible type "SomeObject" '. - 'does not provide an "isTypeOf" function. There is no way to resolve this possible type during execution.' - ); - - $schema->assertValid(); - } - // DESCRIBE: Type System: Scalar types must be serializable /** @@ -1747,8 +1665,6 @@ public function testAcceptsAnObjectImplementingAnInterface() { $AnotherInterfaceType = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => ['f' => ['type' => Type::string()]] ]); @@ -2085,8 +2001,6 @@ public function testAcceptsAnObjectWhichImplementsAnInterface() { $AnotherInterface = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => [ 'field' => [ 'type' => Type::string(), @@ -2121,8 +2035,6 @@ public function testAcceptsAnObjectWhichImplementsAnInterfaceAlongWithMoreFields { $AnotherInterface = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => [ 'field' => [ 'type' => Type::string(), @@ -2158,8 +2070,6 @@ public function testAcceptsAnObjectWhichImplementsAnInterfaceFieldAlongWithAddit { $AnotherInterface = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => [ 'field' => [ 'type' => Type::string(), @@ -2195,8 +2105,6 @@ public function testRejectsAnObjectWhichImplementsAnInterfaceFieldAlongWithAddit { $AnotherInterface = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => [ 'field' => [ 'type' => Type::string(), @@ -2238,8 +2146,6 @@ public function testRejectsAnObjectMissingAnInterfaceField() { $AnotherInterface = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => [ 'field' => [ 'type' => Type::string(), @@ -2274,8 +2180,6 @@ public function testRejectsAnObjectWithAnIncorrectlyTypedInterfaceField() { $AnotherInterface = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => [ 'field' => ['type' => Type::string()] ] @@ -2318,8 +2222,6 @@ public function testRejectsAnObjectWithADifferentlyTypedInterfaceField() $AnotherInterface = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => [ 'field' => ['type' => $TypeA] ] @@ -2350,8 +2252,6 @@ public function testAcceptsAnObjectWithASubtypedInterfaceFieldForInterface() { $AnotherInterface = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => function () use (&$AnotherInterface) { return [ 'field' => ['type' => $AnotherInterface] @@ -2380,8 +2280,6 @@ public function testAcceptsAnObjectWithASubtypedInterfaceFieldForUnion() { $AnotherInterface = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => [ 'field' => ['type' => $this->SomeUnionType] ] @@ -2406,8 +2304,6 @@ public function testRejectsAnObjectMissingAnInterfaceArgument() { $AnotherInterface = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => [ 'field' => [ 'type' => Type::string(), @@ -2445,8 +2341,6 @@ public function testRejectsAnObjectWithAnIncorrectlyTypedInterfaceArgument() { $AnotherInterface = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => [ 'field' => [ 'type' => Type::string(), @@ -2487,8 +2381,6 @@ public function testAcceptsAnObjectWithAnEquivalentlyModifiedInterfaceFieldType( { $AnotherInterface = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => [ 'field' => ['type' => Type::nonNull(Type::listOf(Type::string()))] ] @@ -2513,8 +2405,6 @@ public function testRejectsAnObjectWithANonListInterfaceFieldListType() { $AnotherInterface = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => [ 'field' => ['type' => Type::listOf(Type::string())] ] @@ -2545,8 +2435,6 @@ public function testRejectsAnObjectWithAListInterfaceFieldNonListType() { $AnotherInterface = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => [ 'field' => ['type' => Type::string()] ] @@ -2575,8 +2463,6 @@ public function testAcceptsAnObjectWithASubsetNonNullInterfaceFieldType() { $AnotherInterface = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => [ 'field' => ['type' => Type::string()] ] @@ -2601,8 +2487,6 @@ public function testRejectsAnObjectWithASupersetNullableInterfaceFieldType() { $AnotherInterface = new InterfaceType([ 'name' => 'AnotherInterface', - 'resolveType' => function () { - }, 'fields' => [ 'field' => ['type' => Type::nonNull(Type::string())] ] @@ -2820,8 +2704,6 @@ private function schemaWithUnionOfType($type) { $BadUnionType = new UnionType([ 'name' => 'BadUnion', - 'resolveType' => function () { - }, 'types' => [$type], ]); return new Schema([ diff --git a/tests/Utils/BuildSchemaTest.php b/tests/Utils/BuildSchemaTest.php index 095f315a8..7f0c47e8b 100644 --- a/tests/Utils/BuildSchemaTest.php +++ b/tests/Utils/BuildSchemaTest.php @@ -36,8 +36,8 @@ public function testUseBuiltSchemaForLimitedExecution() } ')); - $result = GraphQL::execute($schema, '{ str }', ['str' => 123]); - $this->assertEquals($result['data'], ['str' => 123]); + $result = GraphQL::executeQuery($schema, '{ str }', ['str' => 123]); + $this->assertEquals(['str' => 123], $result->toArray(true)['data']); } /** @@ -52,7 +52,7 @@ public function testBuildSchemaDirectlyFromSource() } "); - $result = GraphQL::execute( + $result = GraphQL::executeQuery( $schema, '{ add(x: 34, y: 55) }', [ @@ -61,7 +61,7 @@ public function testBuildSchemaDirectlyFromSource() } ] ); - $this->assertEquals($result, ['data' => ['add' => 89]]); + $this->assertEquals(['data' => ['add' => 89]], $result->toArray(true)); } /** @@ -447,6 +447,135 @@ public function testMultipleUnion() $this->assertEquals($output, $body); } + /** + * @it Specifying Union type using __typename + */ + public function testSpecifyingUnionTypeUsingTypename() + { + $schema = BuildSchema::buildAST(Parser::parse(' + schema { + query: Root + } + + type Root { + fruits: [Fruit] + } + + union Fruit = Apple | Banana + + type Apple { + color: String + } + + type Banana { + length: Int + } + ')); + $query = ' + { + fruits { + ... on Apple { + color + } + ... on Banana { + length + } + } + } + '; + $root = [ + 'fruits' => [ + [ + 'color' => 'green', + '__typename' => 'Apple', + ], + [ + 'length' => 5, + '__typename' => 'Banana', + ] + ] + ]; + $expected = [ + 'data' => [ + 'fruits' => [ + ['color' => 'green'], + ['length' => 5], + ] + ] + ]; + + $result = GraphQL::executeQuery($schema, $query, $root); + $this->assertEquals($expected, $result->toArray(true)); + } + + /** + * @it Specifying Interface type using __typename + */ + public function testSpecifyingInterfaceUsingTypename() + { + $schema = BuildSchema::buildAST(Parser::parse(' + schema { + query: Root + } + + type Root { + characters: [Character] + } + + interface Character { + name: String! + } + + type Human implements Character { + name: String! + totalCredits: Int + } + + type Droid implements Character { + name: String! + primaryFunction: String + } + ')); + $query = ' + { + characters { + name + ... on Human { + totalCredits + } + ... on Droid { + primaryFunction + } + } + } + '; + $root = [ + 'characters' => [ + [ + 'name' => 'Han Solo', + 'totalCredits' => 10, + '__typename' => 'Human', + ], + [ + 'name' => 'R2-D2', + 'primaryFunction' => 'Astromech', + '__typename' => 'Droid', + ] + ] + ]; + $expected = [ + 'data' => [ + 'characters' => [ + ['name' => 'Han Solo', 'totalCredits' => 10], + ['name' => 'R2-D2', 'primaryFunction' => 'Astromech'], + ] + ] + ]; + + $result = GraphQL::executeQuery($schema, $query, $root); + $this->assertEquals($expected, $result->toArray(true)); + } + /** * @it CustomScalar */ @@ -1093,9 +1222,8 @@ interface Hello { $this->assertInstanceOf(InterfaceTypeDefinitionNode::class, $node); $this->assertEquals('Hello', $defaultConfig['name']); $this->assertInstanceOf(\Closure::class, $defaultConfig['fields']); - $this->assertInstanceOf(\Closure::class, $defaultConfig['resolveType']); $this->assertArrayHasKey('description', $defaultConfig); - $this->assertCount(5, $defaultConfig); + $this->assertCount(4, $defaultConfig); $this->assertEquals(array_keys($allNodesMap), ['Query', 'Color', 'Hello']); $this->assertEquals('My description of Hello', $schema->getType('Hello')->description); } diff --git a/tests/Utils/FindBreakingChangesTest.php b/tests/Utils/FindBreakingChangesTest.php index 2fc0f6f5f..b48f961aa 100644 --- a/tests/Utils/FindBreakingChangesTest.php +++ b/tests/Utils/FindBreakingChangesTest.php @@ -1,8 +1,4 @@ 'Type1', - 'types' => [new ObjectType(['name' => 'blah'])], + 'types' => [$objectType], ]); $oldSchema = new Schema([ @@ -510,16 +505,12 @@ public function testDetectsIfTypeWasRemovedFromUnion() $oldUnionType = new UnionType([ 'name' => 'UnionType1', 'types' => [$type1, $type2], - 'resolveType' => function () { - } ]); $newUnionType = new UnionType([ 'name' => 'UnionType1', 'types' => [$type1a, $type3], - 'resolveType' => function () { - } ]); $oldSchema = new Schema([ @@ -978,8 +969,6 @@ public function testDetectsRemovalOfInterfaces() 'fields' => [ 'field1' => Type::string() ], - 'resolveType' => function () { - } ]); $oldType = new ObjectType([ 'name' => 'Type1', @@ -1099,15 +1088,11 @@ public function testDetectsAllBreakingChanges() $unionTypeThatLosesATypeOld = new UnionType([ 'name' => 'UnionTypeThatLosesAType', 'types' => [$typeInUnion1, $typeInUnion2], - 'resolveType' => function () { - } ]); $unionTypeThatLosesATypeNew = new UnionType([ 'name' => 'UnionTypeThatLosesAType', 'types' => [$typeInUnion1], - 'resolveType' => function () { - } ]); $enumTypeThatLosesAValueOld = new EnumType([ @@ -1132,8 +1117,6 @@ public function testDetectsAllBreakingChanges() 'fields' => [ 'field1' => Type::string() ], - 'resolveType' => function () { - } ]); $typeThatLosesInterfaceOld = new ObjectType([ @@ -1353,15 +1336,11 @@ public function testDetectsAdditionsToUnionType() $oldUnionType = new UnionType([ 'name' => 'UnionType1', 'types' => [$type1], - 'resolveType' => function () { - } ]); $newUnionType = new UnionType([ 'name' => 'UnionType1', 'types' => [$type1a, $type2], - 'resolveType' => function () { - } ]); $oldSchema = new Schema([ @@ -1452,15 +1431,11 @@ public function testFindsAllDangerousChanges() $unionTypeThatGainsATypeOld = new UnionType([ 'name' => 'UnionType1', 'types' => [$typeInUnion1], - 'resolveType' => function () { - } ]); $unionTypeThatGainsATypeNew = new UnionType([ 'name' => 'UnionType1', 'types' => [$typeInUnion1, $typeInUnion2], - 'resolveType' => function () { - } ]); $oldSchema = new Schema([ @@ -1498,4 +1473,4 @@ public function testFindsAllDangerousChanges() $this->assertEquals($expectedDangerousChanges, FindBreakingChanges::findDangerousChanges($oldSchema, $newSchema)); } -} \ No newline at end of file +} diff --git a/tests/Utils/SchemaPrinterTest.php b/tests/Utils/SchemaPrinterTest.php index 0acf0c2a8..5dac50578 100644 --- a/tests/Utils/SchemaPrinterTest.php +++ b/tests/Utils/SchemaPrinterTest.php @@ -360,7 +360,6 @@ public function testPrintInterface() { $fooType = new InterfaceType([ 'name' => 'Foo', - 'resolveType' => function() { return null; }, 'fields' => ['str' => ['type' => Type::string()]] ]); @@ -406,13 +405,11 @@ public function testPrintMultipleInterface() { $fooType = new InterfaceType([ 'name' => 'Foo', - 'resolveType' => function() { return null; }, 'fields' => ['str' => ['type' => Type::string()]] ]); $baazType = new InterfaceType([ 'name' => 'Baaz', - 'resolveType' => function() { return null; }, 'fields' => ['int' => ['type' => Type::int()]] ]); @@ -476,13 +473,11 @@ public function testPrintUnions() $singleUnion = new UnionType([ 'name' => 'SingleUnion', - 'resolveType' => function() { return null; }, 'types' => [$fooType] ]); $multipleUnion = new UnionType([ 'name' => 'MultipleUnion', - 'resolveType' => function() { return null; }, 'types' => [$fooType, $barType] ]); @@ -1098,4 +1093,4 @@ enum __TypeKind { EOT; $this->assertEquals($introspectionSchema, $output); } -} \ No newline at end of file +} diff --git a/tests/Validator/OverlappingFieldsCanBeMergedTest.php b/tests/Validator/OverlappingFieldsCanBeMergedTest.php index 48d305372..c9900e74b 100644 --- a/tests/Validator/OverlappingFieldsCanBeMergedTest.php +++ b/tests/Validator/OverlappingFieldsCanBeMergedTest.php @@ -795,7 +795,6 @@ private function getTestSchema() $SomeBox = new InterfaceType([ 'name' => 'SomeBox', - 'resolveType' => function() use (&$StringBox) {return $StringBox;}, 'fields' => function() use (&$SomeBox) { return [ 'deepBox' => ['type' => $SomeBox], @@ -837,7 +836,6 @@ private function getTestSchema() $NonNullStringBox1 = new InterfaceType([ 'name' => 'NonNullStringBox1', - 'resolveType' => function() use (&$StringBox) {return $StringBox;}, 'fields' => [ 'scalar' => [ 'type' => Type::nonNull(Type::string()) ] ] @@ -855,7 +853,6 @@ private function getTestSchema() $NonNullStringBox2 = new InterfaceType([ 'name' => 'NonNullStringBox2', - 'resolveType' => function() use (&$StringBox) {return $StringBox;}, 'fields' => [ 'scalar' => ['type' => Type::nonNull(Type::string())] ] diff --git a/tests/Validator/TestCase.php b/tests/Validator/TestCase.php index 387012aa7..c96a08dee 100644 --- a/tests/Validator/TestCase.php +++ b/tests/Validator/TestCase.php @@ -67,7 +67,6 @@ public static function getDefaultSchema() $Dog = new ObjectType([ 'name' => 'Dog', - 'isTypeOf' => function() {return true;}, 'fields' => [ 'name' => [ 'type' => Type::string(), @@ -94,7 +93,6 @@ public static function getDefaultSchema() $Cat = new ObjectType([ 'name' => 'Cat', - 'isTypeOf' => function() {return true;}, 'fields' => function() use (&$FurColor) { return [ 'name' => [ @@ -113,10 +111,6 @@ public static function getDefaultSchema() $CatOrDog = new UnionType([ 'name' => 'CatOrDog', 'types' => [$Dog, $Cat], - 'resolveType' => function($value) { - // not used for validation - return null; - } ]); $Intelligent = new InterfaceType([ @@ -129,7 +123,6 @@ public static function getDefaultSchema() $Human = null; $Human = new ObjectType([ 'name' => 'Human', - 'isTypeOf' => function() {return true;}, 'interfaces' => [$Being, $Intelligent], 'fields' => function() use (&$Human, $Pet) { return [ @@ -146,7 +139,6 @@ public static function getDefaultSchema() $Alien = new ObjectType([ 'name' => 'Alien', - 'isTypeOf' => function() {return true;}, 'interfaces' => [$Being, $Intelligent], 'fields' => [ 'iq' => ['type' => Type::int()], @@ -161,19 +153,11 @@ public static function getDefaultSchema() $DogOrHuman = new UnionType([ 'name' => 'DogOrHuman', 'types' => [$Dog, $Human], - 'resolveType' => function() { - // not used for validation - return null; - } ]); $HumanOrAlien = new UnionType([ 'name' => 'HumanOrAlien', 'types' => [$Human, $Alien], - 'resolveType' => function() { - // not used for validation - return null; - } ]); $FurColor = new EnumType([ From 98e397ce447d29fba53dd4b1acb571650d2bca7c Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Thu, 8 Feb 2018 16:50:52 +0100 Subject: [PATCH 08/50] Add additional number lexing test ref: graphql/graphql-js#72421378550cf51b13c6db59b8fc912591fd1a4b --- tests/Language/LexerTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index e73cf627d..946c39a6f 100644 --- a/tests/Language/LexerTest.php +++ b/tests/Language/LexerTest.php @@ -423,6 +423,7 @@ public function reportsUsefulNumberErrors() [ '00', "Syntax Error GraphQL (1:2) Invalid number, unexpected digit after 0: \"0\"\n\n1: 00\n ^\n"], [ '+1', "Syntax Error GraphQL (1:1) Cannot parse the unexpected character \"+\".\n\n1: +1\n ^\n"], [ '1.', "Syntax Error GraphQL (1:3) Invalid number, expected digit but got: \n\n1: 1.\n ^\n"], + [ '1.e1', "Syntax Error GraphQL (1:3) Invalid number, expected digit but got: \"e\"\n\n1: 1.e1\n ^\n"], [ '.123', "Syntax Error GraphQL (1:1) Cannot parse the unexpected character \".\".\n\n1: .123\n ^\n"], [ '1.A', "Syntax Error GraphQL (1:3) Invalid number, expected digit but got: \"A\"\n\n1: 1.A\n ^\n"], [ '-A', "Syntax Error GraphQL (1:2) Invalid number, expected digit but got: \"A\"\n\n1: -A\n ^\n"], From 1fdb3da7fbce5f01bc569f872c251779b84fd8ba Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Thu, 8 Feb 2018 16:52:56 +0100 Subject: [PATCH 09/50] Remove notes about subscription being experimental ref: graphql/graphql-js#bf4a25a33a62280e82680518adc279e34ec816e0 --- src/Language/Parser.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Language/Parser.php b/src/Language/Parser.php index bfc89e8a8..c0c139cd4 100644 --- a/src/Language/Parser.php +++ b/src/Language/Parser.php @@ -409,7 +409,6 @@ function parseOperationType() switch ($operationToken->value) { case 'query': return 'query'; case 'mutation': return 'mutation'; - // Note: subscription is an experimental non-spec addition. case 'subscription': return 'subscription'; } From 17a8c26fc95df8dca9b8806e28d67a5aac2a6a82 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Fri, 9 Feb 2018 13:49:10 +0100 Subject: [PATCH 10/50] Simplify operationTypes validation ref: graphql/graphql-js#999 # Conflicts: # src/Utils/BuildSchema.php --- src/Utils/BuildSchema.php | 117 ++++++++++++++++++-------------------- 1 file changed, 54 insertions(+), 63 deletions(-) diff --git a/src/Utils/BuildSchema.php b/src/Utils/BuildSchema.php index 9dc966c75..6faf6e3c1 100644 --- a/src/Utils/BuildSchema.php +++ b/src/Utils/BuildSchema.php @@ -13,7 +13,7 @@ use GraphQL\Language\AST\NodeKind; use GraphQL\Language\AST\ObjectTypeDefinitionNode; use GraphQL\Language\AST\ScalarTypeDefinitionNode; -use GraphQL\Language\AST\TypeDefinitionNode; +use GraphQL\Language\AST\SchemaDefinitionNode; use GraphQL\Language\AST\TypeNode; use GraphQL\Language\AST\UnionTypeDefinitionNode; use GraphQL\Language\Parser; @@ -110,6 +110,7 @@ public function __construct(DocumentNode $ast, callable $typeConfigDecorator = n public function buildSchema() { + /** @var SchemaDefinitionNode $schemaDef */ $schemaDef = null; $typeDefs = []; $this->nodeMap = []; @@ -141,61 +142,13 @@ public function buildSchema() } } - $queryTypeName = null; - $mutationTypeName = null; - $subscriptionTypeName = null; - if ($schemaDef) { - foreach ($schemaDef->operationTypes as $operationType) { - $typeName = $operationType->type->name->value; - if ($operationType->operation === 'query') { - if ($queryTypeName) { - throw new Error('Must provide only one query type in schema.'); - } - if (!isset($this->nodeMap[$typeName])) { - throw new Error( - 'Specified query type "' . $typeName . '" not found in document.' - ); - } - $queryTypeName = $typeName; - } else if ($operationType->operation === 'mutation') { - if ($mutationTypeName) { - throw new Error('Must provide only one mutation type in schema.'); - } - if (!isset($this->nodeMap[$typeName])) { - throw new Error( - 'Specified mutation type "' . $typeName . '" not found in document.' - ); - } - $mutationTypeName = $typeName; - } else if ($operationType->operation === 'subscription') { - if ($subscriptionTypeName) { - throw new Error('Must provide only one subscription type in schema.'); - } - if (!isset($this->nodeMap[$typeName])) { - throw new Error( - 'Specified subscription type "' . $typeName . '" not found in document.' - ); - } - $subscriptionTypeName = $typeName; - } - } - } else { - if (isset($this->nodeMap['Query'])) { - $queryTypeName = 'Query'; - } - if (isset($this->nodeMap['Mutation'])) { - $mutationTypeName = 'Mutation'; - } - if (isset($this->nodeMap['Subscription'])) { - $subscriptionTypeName = 'Subscription'; - } - } - - if (!$queryTypeName) { - throw new Error( - 'Must provide schema definition with query type or a type named Query.' - ); - } + $operationTypes = $schemaDef + ? $this->getOperationTypes($schemaDef) + : [ + 'query' => isset($this->nodeMap['Query']) ? 'Query' : null, + 'mutation' => isset($this->nodeMap['Mutation']) ? 'Mutation' : null, + 'subscription' => isset($this->nodeMap['Subscription']) ? 'Subscription' : null, + ]; $this->innerTypeMap = [ 'String' => Type::string(), @@ -237,13 +190,19 @@ public function buildSchema() $directives[] = Directive::deprecatedDirective(); } + if (!isset($operationTypes['query'])) { + throw new Error( + 'Must provide schema definition with query type or a type named Query.' + ); + } + $schema = new Schema([ - 'query' => $this->getObjectType($this->nodeMap[$queryTypeName]), - 'mutation' => $mutationTypeName ? - $this->getObjectType($this->nodeMap[$mutationTypeName]) : + 'query' => $this->getObjectType($operationTypes['query']), + 'mutation' => isset($operationTypes['mutation']) ? + $this->getObjectType($operationTypes['mutation']) : null, - 'subscription' => $subscriptionTypeName ? - $this->getObjectType($this->nodeMap[$subscriptionTypeName]) : + 'subscription' => isset($operationTypes['subscription']) ? + $this->getObjectType($operationTypes['subscription']) : null, 'typeLoader' => function ($name) { return $this->typeDefNamed($name); @@ -264,6 +223,33 @@ public function buildSchema() return $schema; } + /** + * @param SchemaDefinitionNode $schemaDef + * @return array + * @throws Error + */ + private function getOperationTypes($schemaDef) + { + $opTypes = []; + + foreach ($schemaDef->operationTypes as $operationType) { + $typeName = $operationType->type->name->value; + $operation = $operationType->operation; + + if (isset($opTypes[$operation])) { + throw new Error("Must provide only one $operation type in schema."); + } + + if (!isset($this->nodeMap[$typeName])) { + throw new Error("Specified $operation type \"$typeName\" not found in document."); + } + + $opTypes[$operation] = $typeName; + } + + return $opTypes; + } + private function getDirective(DirectiveDefinitionNode $directiveNode) { return new Directive([ @@ -277,9 +263,14 @@ private function getDirective(DirectiveDefinitionNode $directiveNode) ]); } - private function getObjectType(TypeDefinitionNode $typeNode) + /** + * @param string $name + * @return CustomScalarType|EnumType|InputObjectType|UnionType + * @throws Error + */ + private function getObjectType($name) { - $type = $this->typeDefNamed($typeNode->name->value); + $type = $this->typeDefNamed($name); Utils::invariant( $type instanceof ObjectType, 'AST must provide object type.' From 2123946dbd14fdadad78821ee7ecf3de772bc119 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Fri, 9 Feb 2018 14:17:34 +0100 Subject: [PATCH 11/50] Add warnings for nullable changes ref: https://github.com/graphql/graphql-js/commit/db4cfdc31d1b4a824a95118196843841bccfdf4f ref: graphql/graphql-js#1096 # Conflicts: # tests/Utils/FindBreakingChangesTest.php --- src/Utils/FindBreakingChanges.php | 114 ++++++++++++----------- tests/Utils/FindBreakingChangesTest.php | 115 +++++++++++++++++++++++- 2 files changed, 175 insertions(+), 54 deletions(-) diff --git a/src/Utils/FindBreakingChanges.php b/src/Utils/FindBreakingChanges.php index c747a71f4..63acbef15 100644 --- a/src/Utils/FindBreakingChanges.php +++ b/src/Utils/FindBreakingChanges.php @@ -34,37 +34,42 @@ class FindBreakingChanges const DANGEROUS_CHANGE_ARG_DEFAULT_VALUE = 'ARG_DEFAULT_VALUE_CHANGE'; const DANGEROUS_CHANGE_VALUE_ADDED_TO_ENUM = 'VALUE_ADDED_TO_ENUM'; const DANGEROUS_CHANGE_TYPE_ADDED_TO_UNION = 'TYPE_ADDED_TO_UNION'; + const DANGEROUS_CHANGE_NULLABLE_INPUT_FIELD_ADDED = 'NULLABLE_INPUT_FIELD_ADDED'; + const DANGEROUS_CHANGE_NULLABLE_ARG_ADDED = 'NULLABLE_ARG_ADDED'; /** * Given two schemas, returns an Array containing descriptions of all the types - * of potentially dangerous changes covered by the other functions down below. + * of breaking changes covered by the other functions down below. * * @return array */ - public static function findDangerousChanges(Schema $oldSchema, Schema $newSchema) + public static function findBreakingChanges(Schema $oldSchema, Schema $newSchema) { - return array_merge(self::findArgChanges($oldSchema, $newSchema)['dangerousChanges'], - self::findValuesAddedToEnums($oldSchema, $newSchema), - self::findTypesAddedToUnions($oldSchema, $newSchema) + return array_merge( + self::findRemovedTypes($oldSchema, $newSchema), + self::findTypesThatChangedKind($oldSchema, $newSchema), + self::findFieldsThatChangedTypeOnObjectOrInterfaceTypes($oldSchema, $newSchema), + self::findFieldsThatChangedTypeOnInputObjectTypes($oldSchema, $newSchema)['breakingChanges'], + self::findTypesRemovedFromUnions($oldSchema, $newSchema), + self::findValuesRemovedFromEnums($oldSchema, $newSchema), + self::findArgChanges($oldSchema, $newSchema)['breakingChanges'], + self::findInterfacesRemovedFromObjectTypes($oldSchema, $newSchema) ); } /** * Given two schemas, returns an Array containing descriptions of all the types - * of breaking changes covered by the other functions down below. + * of potentially dangerous changes covered by the other functions down below. * * @return array */ - public static function findBreakingChanges(Schema $oldSchema, Schema $newSchema) + public static function findDangerousChanges(Schema $oldSchema, Schema $newSchema) { return array_merge( - self::findRemovedTypes($oldSchema, $newSchema), - self::findTypesThatChangedKind($oldSchema, $newSchema), - self::findFieldsThatChangedType($oldSchema, $newSchema), - self::findTypesRemovedFromUnions($oldSchema, $newSchema), - self::findValuesRemovedFromEnums($oldSchema, $newSchema), - self::findArgChanges($oldSchema, $newSchema)['breakingChanges'], - self::findInterfacesRemovedFromObjectTypes($oldSchema, $newSchema) + self::findArgChanges($oldSchema, $newSchema)['dangerousChanges'], + self::findValuesAddedToEnums($oldSchema, $newSchema), + self::findTypesAddedToUnions($oldSchema, $newSchema), + self::findFieldsThatChangedTypeOnInputObjectTypes($oldSchema, $newSchema)['dangerousChanges'] ); } @@ -191,17 +196,24 @@ public static function findArgChanges( $oldArgs = $oldTypeFields[$fieldName]->args; $oldArgDef = Utils::find( $oldArgs, function ($arg) use ($newArgDef) { - return $arg->name === $newArgDef->name; - } + return $arg->name === $newArgDef->name; + } ); - if (!$oldArgDef && $newArgDef->getType() instanceof NonNull) { + if (!$oldArgDef) { $newTypeName = $newTypeDefinition->name; $newArgName = $newArgDef->name; - $breakingChanges[] = [ - 'type' => self::BREAKING_CHANGE_NON_NULL_ARG_ADDED, - 'description' => "A non-null arg ${newArgName} on ${newTypeName}->${fieldName} was added." - ]; + if ($newArgDef->getType() instanceof NonNull) { + $breakingChanges[] = [ + 'type' => self::BREAKING_CHANGE_NON_NULL_ARG_ADDED, + 'description' => "A non-null arg ${newArgName} on ${newTypeName}->${fieldName} was added." + ]; + } else { + $dangerousChanges[] = [ + 'type' => self::DANGEROUS_CHANGE_NULLABLE_ARG_ADDED, + 'description' => "A nullable arg ${newArgName} on ${newTypeName}->${fieldName} was added." + ]; + } } } } @@ -236,36 +248,18 @@ private static function typeKindName(Type $type) throw new \TypeError('unknown type ' . $type->name); } - /** - * Given two schemas, returns an Array containing descriptions of any breaking - * changes in the newSchema related to the fields on a type. This includes if - * a field has been removed from a type, if a field has changed type, or if - * a non-null field is added to an input type. - * - * @return array - */ - public static function findFieldsThatChangedType( - Schema $oldSchema, Schema $newSchema - ) - { - return array_merge( - self::findFieldsThatChangedTypeOnObjectOrInterfaceTypes($oldSchema, $newSchema), - self::findFieldsThatChangedTypeOnInputObjectTypes($oldSchema, $newSchema) - ); - } - /** * @param Schema $oldSchema * @param Schema $newSchema * * @return array */ - private static function findFieldsThatChangedTypeOnObjectOrInterfaceTypes(Schema $oldSchema, Schema $newSchema) + public static function findFieldsThatChangedTypeOnObjectOrInterfaceTypes(Schema $oldSchema, Schema $newSchema) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); - $breakingFieldChanges = []; + $breakingChanges = []; foreach ($oldTypeMap as $typeName => $oldType) { $newType = isset($newTypeMap[$typeName]) ? $newTypeMap[$typeName] : null; if (!($oldType instanceof ObjectType || $oldType instanceof InterfaceType) || !($newType instanceof $oldType)) { @@ -275,7 +269,7 @@ private static function findFieldsThatChangedTypeOnObjectOrInterfaceTypes(Schema $newTypeFieldsDef = $newType->getFields(); foreach ($oldTypeFieldsDef as $fieldName => $fieldDefinition) { if (!isset($newTypeFieldsDef[$fieldName])) { - $breakingFieldChanges[] = ['type' => self::BREAKING_CHANGE_FIELD_REMOVED, 'description' => "${typeName}->${fieldName} was removed."]; + $breakingChanges[] = ['type' => self::BREAKING_CHANGE_FIELD_REMOVED, 'description' => "${typeName}->${fieldName} was removed."]; } else { $oldFieldType = $oldTypeFieldsDef[$fieldName]->getType(); $newfieldType = $newTypeFieldsDef[$fieldName]->getType(); @@ -284,12 +278,12 @@ private static function findFieldsThatChangedTypeOnObjectOrInterfaceTypes(Schema $oldFieldTypeString = self::isNamedType($oldFieldType) ? $oldFieldType->name : $oldFieldType; $newFieldTypeString = self::isNamedType($newfieldType) ? $newfieldType->name : $newfieldType; - $breakingFieldChanges[] = ['type' => self::BREAKING_CHANGE_FIELD_CHANGED, 'description' => "${typeName}->${fieldName} changed type from ${oldFieldTypeString} to ${newFieldTypeString}."]; + $breakingChanges[] = ['type' => self::BREAKING_CHANGE_FIELD_CHANGED, 'description' => "${typeName}->${fieldName} changed type from ${oldFieldTypeString} to ${newFieldTypeString}."]; } } } } - return $breakingFieldChanges; + return $breakingChanges; } /** @@ -305,7 +299,8 @@ public static function findFieldsThatChangedTypeOnInputObjectTypes( $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); - $breakingFieldChanges = []; + $breakingChanges = []; + $dangerousChanges = []; foreach ($oldTypeMap as $typeName => $oldType) { $newType = isset($newTypeMap[$typeName]) ? $newTypeMap[$typeName] : null; if (!($oldType instanceof InputObjectType) || !($newType instanceof InputObjectType)) { @@ -315,7 +310,10 @@ public static function findFieldsThatChangedTypeOnInputObjectTypes( $newTypeFieldsDef = $newType->getFields(); foreach ($oldTypeFieldsDef as $fieldName => $fieldDefinition) { if (!isset($newTypeFieldsDef[$fieldName])) { - $breakingFieldChanges[] = ['type' => self::BREAKING_CHANGE_FIELD_REMOVED, 'description' => "${typeName}->${fieldName} was removed."]; + $breakingChanges[] = [ + 'type' => self::BREAKING_CHANGE_FIELD_REMOVED, + 'description' => "${typeName}->${fieldName} was removed." + ]; } else { $oldFieldType = $oldTypeFieldsDef[$fieldName]->getType(); $newfieldType = $newTypeFieldsDef[$fieldName]->getType(); @@ -323,18 +321,32 @@ public static function findFieldsThatChangedTypeOnInputObjectTypes( if (!$isSafe) { $oldFieldTypeString = self::isNamedType($oldFieldType) ? $oldFieldType->name : $oldFieldType; $newFieldTypeString = self::isNamedType($newfieldType) ? $newfieldType->name : $newfieldType; - $breakingFieldChanges[] = ['type' => self::BREAKING_CHANGE_FIELD_CHANGED, 'description' => "${typeName}->${fieldName} changed type from ${oldFieldTypeString} to ${newFieldTypeString}."]; + $breakingChanges[] = [ + 'type' => self::BREAKING_CHANGE_FIELD_CHANGED, + 'description' => "${typeName}->${fieldName} changed type from ${oldFieldTypeString} to ${newFieldTypeString}."]; } } } + // Check if a field was added to the input object type foreach ($newTypeFieldsDef as $fieldName => $fieldDef) { - if (!isset($oldTypeFieldsDef[$fieldName]) && $fieldDef->getType() instanceof NonNull) { + if (!isset($oldTypeFieldsDef[$fieldName])) { $newTypeName = $newType->name; - $breakingFieldChanges[] = ['type' => self::BREAKING_CHANGE_NON_NULL_INPUT_FIELD_ADDED, 'description' => "A non-null field ${fieldName} on input type ${newTypeName} was added."]; + if ($fieldDef->getType() instanceof NonNull) { + $breakingChanges[] = [ + 'type' => self::BREAKING_CHANGE_NON_NULL_INPUT_FIELD_ADDED, + 'description' => "A non-null field ${fieldName} on input type ${newTypeName} was added." + ]; + } else { + $dangerousChanges[] = [ + 'type' => self::DANGEROUS_CHANGE_NULLABLE_INPUT_FIELD_ADDED, + 'description' => "A nullable field ${fieldName} on input type ${newTypeName} was added." + ]; + } } } } - return $breakingFieldChanges; + + return ['breakingChanges' => $breakingChanges, 'dangerousChanges' => $dangerousChanges]; } @@ -580,4 +592,4 @@ private static function isNamedType(Type $type) $type instanceof InputObjectType ); } -} \ No newline at end of file +} diff --git a/tests/Utils/FindBreakingChangesTest.php b/tests/Utils/FindBreakingChangesTest.php index b48f961aa..bfc79a961 100644 --- a/tests/Utils/FindBreakingChangesTest.php +++ b/tests/Utils/FindBreakingChangesTest.php @@ -254,7 +254,7 @@ public function testShouldDetectFieldChangesAndDeletions() ]) ]); - $this->assertEquals($expectedFieldChanges, FindBreakingChanges::findFieldsThatChangedType($oldSchema, $newSchema)); + $this->assertEquals($expectedFieldChanges, FindBreakingChanges::findFieldsThatChangedTypeOnObjectOrInterfaceTypes($oldSchema, $newSchema)); } @@ -424,7 +424,7 @@ public function testShouldDetectInputFieldChanges() ], ]; - $this->assertEquals($expectedFieldChanges, FindBreakingChanges::findFieldsThatChangedType($oldSchema, $newSchema)); + $this->assertEquals($expectedFieldChanges, FindBreakingChanges::findFieldsThatChangedTypeOnInputObjectTypes($oldSchema, $newSchema)['breakingChanges']); } public function testDetectsNonNullFieldAddedToInputType() @@ -468,7 +468,7 @@ public function testDetectsNonNullFieldAddedToInputType() 'type' => FindBreakingChanges::BREAKING_CHANGE_NON_NULL_INPUT_FIELD_ADDED, 'description' => 'A non-null field requiredField on input type InputType1 was added.' ], - FindBreakingChanges::findFieldsThatChangedType($oldSchema, $newSchema)[0] + FindBreakingChanges::findFieldsThatChangedTypeOnInputObjectTypes($oldSchema, $newSchema)['breakingChanges'][0] ); } @@ -1366,6 +1366,55 @@ public function testDetectsAdditionsToUnionType() ); } + /** + * @it should detect if a nullable field was added to an input + */ + public function testShouldDetectIfANullableFieldWasAddedToAnInput() + { + $oldInputType = new InputObjectType([ + 'name' => 'InputType1', + 'fields' => [ + 'field1' => [ + 'type' => Type::string(), + ], + ], + ]); + $newInputType = new InputObjectType([ + 'name' => 'InputType1', + 'fields' => [ + 'field1' => [ + 'type' => Type::string(), + ], + 'field2' => [ + 'type' => Type::int(), + ], + ], + ]); + + $oldSchema = new Schema([ + 'query' => $this->queryType, + 'types' => [ + $oldInputType, + ] + ]); + + $newSchema = new Schema([ + 'query' => $this->queryType, + 'types' => [ + $newInputType, + ] + ]); + + $expectedFieldChanges = [ + [ + 'description' => 'A nullable field field2 on input type InputType1 was added.', + 'type' => FindBreakingChanges::DANGEROUS_CHANGE_NULLABLE_INPUT_FIELD_ADDED + ], + ]; + + $this->assertEquals($expectedFieldChanges, FindBreakingChanges::findFieldsThatChangedTypeOnInputObjectTypes($oldSchema, $newSchema)['dangerousChanges']); + } + public function testFindsAllDangerousChanges() { $enumThatGainsAValueOld = new EnumType([ @@ -1473,4 +1522,64 @@ public function testFindsAllDangerousChanges() $this->assertEquals($expectedDangerousChanges, FindBreakingChanges::findDangerousChanges($oldSchema, $newSchema)); } + + /** + * @it should detect if a nullable field argument was added + */ + public function testShouldDetectIfANullableFieldArgumentWasAdded() + { + $oldType = new ObjectType([ + 'name' => 'Type1', + 'fields' => [ + 'field1' => [ + 'type' => Type::string(), + 'args' => [ + 'arg1' => [ + 'type' => Type::string(), + ], + ], + ], + ], + ]); + + $newType = new ObjectType([ + 'name' => 'Type1', + 'fields' => [ + 'field1' => [ + 'type' => Type::string(), + 'args' => [ + 'arg1' => [ + 'type' => Type::string(), + ], + 'arg2' => [ + 'type' => Type::string(), + ], + ], + ], + ], + ]); + + $oldSchema = new Schema([ + 'query' => $this->queryType, + 'types' => [ + $oldType, + ] + ]); + + $newSchema = new Schema([ + 'query' => $this->queryType, + 'types' => [ + $newType, + ] + ]); + + $expectedFieldChanges = [ + [ + 'description' => 'A nullable arg arg2 on Type1->field1 was added.', + 'type' => FindBreakingChanges::DANGEROUS_CHANGE_NULLABLE_ARG_ADDED + ], + ]; + + $this->assertEquals($expectedFieldChanges, FindBreakingChanges::findArgChanges($oldSchema, $newSchema)['dangerousChanges']); + } } From 27ce24b5fe051ae7cd095de989845efc2641a4c2 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Fri, 9 Feb 2018 11:26:22 +0100 Subject: [PATCH 12/50] Fix parsing of default values in build-schema * Generalizes building a value from an AST, since "scalar" could be misleading, and supporting variable values within custom scalar literals can be valuable. * Replaces isNullish with isInvalid since `null` is a meaningful value as a result of literal parsing. * Provide reasonable default version of 'parseLiteral' ref: https://github.com/graphql/graphql-js/commit/714ee980aa17a4de42013d49a9839d43493d109e ref: https://github.com/graphql/graphql-js/pull/903 # Conflicts: # src/Utils/BuildSchema.php # tests/Utils/BuildSchemaTest.php --- examples/01-blog/Blog/Type/Scalar/UrlType.php | 15 +-- src/Executor/Values.php | 62 +++++----- src/Language/AST/ListValueNode.php | 2 +- src/Language/AST/ObjectValueNode.php | 2 +- src/Type/Definition/BooleanType.php | 14 ++- src/Type/Definition/CustomScalarType.php | 14 ++- src/Type/Definition/EnumType.php | 8 +- src/Type/Definition/FloatType.php | 14 +-- src/Type/Definition/IDType.php | 12 +- src/Type/Definition/IntType.php | 16 +-- src/Type/Definition/LeafType.php | 18 ++- src/Type/Definition/ScalarType.php | 8 +- src/Type/Definition/StringType.php | 14 +-- src/Utils/AST.php | 80 +++++++++++-- src/Utils/BuildSchema.php | 14 +-- src/Utils/Utils.php | 13 ++- src/Validator/DocumentValidator.php | 18 ++- tests/Executor/TestClasses.php | 7 +- tests/Utils/AstFromValueUntypedTest.php | 110 ++++++++++++++++++ tests/Utils/BuildSchemaTest.php | 20 ++++ 20 files changed, 332 insertions(+), 129 deletions(-) create mode 100644 tests/Utils/AstFromValueUntypedTest.php diff --git a/examples/01-blog/Blog/Type/Scalar/UrlType.php b/examples/01-blog/Blog/Type/Scalar/UrlType.php index 361e27ff8..c539121ef 100644 --- a/examples/01-blog/Blog/Type/Scalar/UrlType.php +++ b/examples/01-blog/Blog/Type/Scalar/UrlType.php @@ -42,20 +42,21 @@ public function parseValue($value) /** * Parses an externally provided literal value to use as an input (e.g. in Query AST) * - * @param $ast Node + * @param Node $valueNode + * @param array|null $variables * @return null|string * @throws Error */ - public function parseLiteral($ast) + public function parseLiteral($valueNode, array $variables = null) { // Note: throwing GraphQL\Error\Error vs \UnexpectedValueException to benefit from GraphQL // error location in query: - if (!($ast instanceof StringValueNode)) { - throw new Error('Query error: Can only parse strings got: ' . $ast->kind, [$ast]); + if (!($valueNode instanceof StringValueNode)) { + throw new Error('Query error: Can only parse strings got: ' . $valueNode->kind, [$valueNode]); } - if (!is_string($ast->value) || !filter_var($ast->value, FILTER_VALIDATE_URL)) { - throw new Error('Query error: Not a valid URL', [$ast]); + if (!is_string($valueNode->value) || !filter_var($valueNode->value, FILTER_VALIDATE_URL)) { + throw new Error('Query error: Not a valid URL', [$valueNode]); } - return $ast->value; + return $valueNode->value; } } diff --git a/src/Executor/Values.php b/src/Executor/Values.php index 06c9532fd..49bcc3a33 100644 --- a/src/Executor/Values.php +++ b/src/Executor/Values.php @@ -1,9 +1,7 @@ parseValue($value); - if (null === $parseResult && !$type->isValidValue($value)) { - $v = Utils::printSafeJson($value); - return [ - "Expected type \"{$type->name}\", found $v." - ]; - } - return []; - } catch (\Exception $e) { - return [ - "Expected type \"{$type->name}\", found " . Utils::printSafeJson($value) . ': ' . - $e->getMessage() - ]; - } catch (\Throwable $e) { + Utils::invariant($type instanceof EnumType || $type instanceof ScalarType, 'Must be input type'); + + + try { + // Scalar/Enum input checks to ensure the type can parse the value to + // a non-null value. + + if (!$type->isValidValue($value)) { + $v = Utils::printSafeJson($value); return [ - "Expected type \"{$type->name}\", found " . Utils::printSafeJson($value) . ': ' . - $e->getMessage() + "Expected type \"{$type->name}\", found $v." ]; } + } catch (\Exception $e) { + return [ + "Expected type \"{$type->name}\", found " . Utils::printSafeJson($value) . ': ' . + $e->getMessage() + ]; + } catch (\Throwable $e) { + return [ + "Expected type \"{$type->name}\", found " . Utils::printSafeJson($value) . ': ' . + $e->getMessage() + ]; } - throw new InvariantViolation('Must be input type'); + + return []; } /** @@ -370,16 +370,12 @@ private static function coerceValue(Type $type, $value) return $coercedObj; } - if ($type instanceof LeafType) { - $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; + Utils::invariant($type instanceof EnumType || $type instanceof ScalarType, 'Must be input type'); + + if ($type->isValidValue($value)) { + return $type->parseValue($value); } - throw new InvariantViolation('Must be input type'); + return $undefined; } } diff --git a/src/Language/AST/ListValueNode.php b/src/Language/AST/ListValueNode.php index 1a435128b..bcd56db10 100644 --- a/src/Language/AST/ListValueNode.php +++ b/src/Language/AST/ListValueNode.php @@ -7,7 +7,7 @@ class ListValueNode extends Node implements ValueNode public $kind = NodeKind::LST; /** - * @var ValueNode[] + * @var ValueNode[]|NodeList */ public $values; } diff --git a/src/Language/AST/ObjectValueNode.php b/src/Language/AST/ObjectValueNode.php index 2dd38cadb..cc763e942 100644 --- a/src/Language/AST/ObjectValueNode.php +++ b/src/Language/AST/ObjectValueNode.php @@ -6,7 +6,7 @@ class ObjectValueNode extends Node implements ValueNode public $kind = NodeKind::OBJECT; /** - * @var ObjectFieldNode[] + * @var ObjectFieldNode[]|NodeList */ public $fields; } diff --git a/src/Type/Definition/BooleanType.php b/src/Type/Definition/BooleanType.php index 64746a9e1..2a1adc72d 100644 --- a/src/Type/Definition/BooleanType.php +++ b/src/Type/Definition/BooleanType.php @@ -2,6 +2,7 @@ namespace GraphQL\Type\Definition; use GraphQL\Language\AST\BooleanValueNode; +use GraphQL\Utils\Utils; /** * Class BooleanType @@ -34,18 +35,19 @@ public function serialize($value) */ public function parseValue($value) { - return is_bool($value) ? $value : null; + return is_bool($value) ? $value : Utils::undefined(); } /** - * @param $ast + * @param $valueNode + * @param array|null $variables * @return bool|null */ - public function parseLiteral($ast) + public function parseLiteral($valueNode, array $variables = null) { - if ($ast instanceof BooleanValueNode) { - return (bool) $ast->value; + if ($valueNode instanceof BooleanValueNode) { + return (bool) $valueNode->value; } - return null; + return Utils::undefined(); } } diff --git a/src/Type/Definition/CustomScalarType.php b/src/Type/Definition/CustomScalarType.php index 49bc8beee..14e1d5315 100644 --- a/src/Type/Definition/CustomScalarType.php +++ b/src/Type/Definition/CustomScalarType.php @@ -1,6 +1,7 @@ config['parseValue'])) { return call_user_func($this->config['parseValue'], $value); } else { - return null; + return $value; } } /** * @param $valueNode + * @param array|null $variables * @return mixed */ - public function parseLiteral(/* GraphQL\Language\AST\ValueNode */ $valueNode) + public function parseLiteral(/* GraphQL\Language\AST\ValueNode */ $valueNode, array $variables = null) { if (isset($this->config['parseLiteral'])) { - return call_user_func($this->config['parseLiteral'], $valueNode); + return call_user_func($this->config['parseLiteral'], $valueNode, $variables); } else { - return null; + return AST::valueFromASTUntyped($valueNode, $variables); } } diff --git a/src/Type/Definition/EnumType.php b/src/Type/Definition/EnumType.php index 120bfee1a..035c86f21 100644 --- a/src/Type/Definition/EnumType.php +++ b/src/Type/Definition/EnumType.php @@ -122,9 +122,10 @@ public function isValidValue($value) /** * @param $valueNode + * @param array|null $variables * @return bool */ - public function isValidLiteral($valueNode) + public function isValidLiteral($valueNode, array $variables = null) { return $valueNode instanceof EnumValueNode && $this->getNameLookup()->offsetExists($valueNode->value); } @@ -136,14 +137,15 @@ public function isValidLiteral($valueNode) public function parseValue($value) { $lookup = $this->getNameLookup(); - return isset($lookup[$value]) ? $lookup[$value]->value : null; + return isset($lookup[$value]) ? $lookup[$value]->value : Utils::undefined(); } /** * @param $value + * @param array|null $variables * @return null */ - public function parseLiteral($value) + public function parseLiteral($value, array $variables = null) { if ($value instanceof EnumValueNode) { $lookup = $this->getNameLookup(); diff --git a/src/Type/Definition/FloatType.php b/src/Type/Definition/FloatType.php index 092f2583f..826b017ec 100644 --- a/src/Type/Definition/FloatType.php +++ b/src/Type/Definition/FloatType.php @@ -1,7 +1,6 @@ value; + if ($valueNode instanceof FloatValueNode || $valueNode instanceof IntValueNode) { + return (float) $valueNode->value; } - return null; + return Utils::undefined(); } } diff --git a/src/Type/Definition/IDType.php b/src/Type/Definition/IDType.php index 591297355..47ed8979c 100644 --- a/src/Type/Definition/IDType.php +++ b/src/Type/Definition/IDType.php @@ -1,7 +1,6 @@ value; + if ($valueNode instanceof StringValueNode || $valueNode instanceof IntValueNode) { + return $valueNode->value; } - return null; + return Utils::undefined(); } } diff --git a/src/Type/Definition/IntType.php b/src/Type/Definition/IntType.php index 4473ce5d7..5444e2ede 100644 --- a/src/Type/Definition/IntType.php +++ b/src/Type/Definition/IntType.php @@ -1,7 +1,6 @@ = self::MIN_INT ? $value : null; + return $isInt && $value <= self::MAX_INT && $value >= self::MIN_INT ? $value : Utils::undefined(); } /** - * @param $ast + * @param $valueNode + * @param array|null $variables * @return int|null */ - public function parseLiteral($ast) + public function parseLiteral($valueNode, array $variables = null) { - if ($ast instanceof IntValueNode) { - $val = (int) $ast->value; - if ($ast->value === (string) $val && self::MIN_INT <= $val && $val <= self::MAX_INT) { + if ($valueNode instanceof IntValueNode) { + $val = (int) $valueNode->value; + if ($valueNode->value === (string) $val && self::MIN_INT <= $val && $val <= self::MAX_INT) { return $val; } } - return null; + return Utils::undefined(); } } diff --git a/src/Type/Definition/LeafType.php b/src/Type/Definition/LeafType.php index a0bd30fc2..2ec8efc5b 100644 --- a/src/Type/Definition/LeafType.php +++ b/src/Type/Definition/LeafType.php @@ -1,6 +1,8 @@ parseValue($value); + return !Utils::isInvalid($this->parseValue($value)); } /** @@ -56,10 +55,11 @@ public function isValidValue($value) * Equivalent to checking for if the parsedLiteral is nullish. * * @param $valueNode + * @param array|null $variables * @return bool */ - public function isValidLiteral($valueNode) + public function isValidLiteral($valueNode, array $variables = null) { - return null !== $this->parseLiteral($valueNode); + return !Utils::isInvalid($this->parseLiteral($valueNode, $variables)); } } diff --git a/src/Type/Definition/StringType.php b/src/Type/Definition/StringType.php index 0e0784b28..98dab8277 100644 --- a/src/Type/Definition/StringType.php +++ b/src/Type/Definition/StringType.php @@ -1,7 +1,6 @@ value; + if ($valueNode instanceof StringValueNode) { + return $valueNode->value; } - return null; + return Utils::undefined(); } } diff --git a/src/Utils/AST.php b/src/Utils/AST.php index bc3a0e480..6fd6a9b69 100644 --- a/src/Utils/AST.php +++ b/src/Utils/AST.php @@ -30,9 +30,9 @@ use GraphQL\Type\Definition\LeafType; use GraphQL\Type\Definition\ListOfType; use GraphQL\Type\Definition\NonNull; +use GraphQL\Type\Definition\ScalarType; use GraphQL\Type\Definition\Type; use GraphQL\Type\Schema; -use GraphQL\Utils\Utils; /** * Various utilities dealing with AST @@ -383,19 +383,77 @@ public static function valueFromAST($valueNode, InputType $type, $variables = nu return $coercedObj; } - if ($type instanceof LeafType) { - $parsed = $type->parseLiteral($valueNode); - - if (null === $parsed && !$type->isValidLiteral($valueNode)) { - // Invalid values represent a failure to parse correctly, in which case - // no value is returned. - return $undefined; - } + if (!$type instanceof ScalarType && !$type instanceof EnumType) { + throw new InvariantViolation('Must be input type'); + } - return $parsed; + if ($type->isValidLiteral($valueNode, $variables)) { + return $type->parseLiteral($valueNode, $variables); } - throw new InvariantViolation('Must be input type'); + return $undefined; + } + + /** + * Produces a PHP value given a GraphQL Value AST. + * + * Unlike `valueFromAST()`, no type is provided. The resulting JavaScript value + * will reflect the provided GraphQL value AST. + * + * | GraphQL Value | PHP Value | + * | -------------------- | ------------- | + * | Input Object | Assoc Array | + * | List | Array | + * | Boolean | Boolean | + * | String | String | + * | Int / Float | Int / Float | + * | Enum | Mixed | + * | Null | null | + * + * @api + * @param Node $valueNode + * @param array|null $variables + * @return mixed + * @throws \Exception + */ + public static function valueFromASTUntyped($valueNode, array $variables = null) { + switch (true) { + case $valueNode instanceof NullValueNode: + return null; + case $valueNode instanceof IntValueNode: + return intval($valueNode->value, 10); + case $valueNode instanceof FloatValueNode: + return floatval($valueNode->value); + case $valueNode instanceof StringValueNode: + case $valueNode instanceof EnumValueNode: + case $valueNode instanceof BooleanValueNode: + return $valueNode->value; + case $valueNode instanceof ListValueNode: + return array_map( + function($node) use ($variables) { + return self::valueFromASTUntyped($node, $variables); + }, + iterator_to_array($valueNode->values) + ); + case $valueNode instanceof ObjectValueNode: + return array_combine( + array_map( + function($field) { return $field->name->value; }, + iterator_to_array($valueNode->fields) + ), + array_map( + function($field) use ($variables) { return self::valueFromASTUntyped($field->value, $variables); }, + iterator_to_array($valueNode->fields) + ) + ); + case $valueNode instanceof VariableNode: + $variableName = $valueNode->name->value; + return ($variables && isset($variables[$variableName]) && !Utils::isInvalid($variables[$variableName])) + ? $variables[$variableName] + : null; + default: + throw new InvariantViolation('Unexpected value kind: ' . $valueNode->kind); + } } /** diff --git a/src/Utils/BuildSchema.php b/src/Utils/BuildSchema.php index 6faf6e3c1..b04ef406c 100644 --- a/src/Utils/BuildSchema.php +++ b/src/Utils/BuildSchema.php @@ -539,19 +539,9 @@ private function makeScalarDefConfig(ScalarTypeDefinitionNode $def) 'name' => $def->name->value, 'description' => $this->getDescription($def), 'astNode' => $def, - 'serialize' => function () { - return false; + 'serialize' => function($value) { + return $value; }, - // Note: validation calls the parse functions to determine if a - // literal value is correct. Returning null would cause use of custom - // scalars to always fail validation. Returning false causes them to - // always pass validation. - 'parseValue' => function () { - return false; - }, - 'parseLiteral' => function () { - return false; - } ]; } diff --git a/src/Utils/Utils.php b/src/Utils/Utils.php index fa1183a5e..cb019ef49 100644 --- a/src/Utils/Utils.php +++ b/src/Utils/Utils.php @@ -15,12 +15,23 @@ public static function undefined() return $undefined ?: $undefined = new \stdClass(); } + /** + * Check if the value is invalid + * + * @param mixed $value + * @return bool + */ + public static function isInvalid($value) + { + return self::undefined() === $value; + } + /** * @param object $obj * @param array $vars * @param array $requiredKeys * - * @return array + * @return object */ public static function assign($obj, array $vars, array $requiredKeys = []) { diff --git a/src/Validator/DocumentValidator.php b/src/Validator/DocumentValidator.php index 31b76494e..1a1e83cc8 100644 --- a/src/Validator/DocumentValidator.php +++ b/src/Validator/DocumentValidator.php @@ -2,7 +2,6 @@ namespace GraphQL\Validator; use GraphQL\Error\Error; -use GraphQL\Error\InvariantViolation; use GraphQL\Language\AST\ListValueNode; use GraphQL\Language\AST\DocumentNode; use GraphQL\Language\AST\NodeKind; @@ -11,11 +10,12 @@ use GraphQL\Language\Printer; use GraphQL\Language\Visitor; use GraphQL\Type\Schema; +use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\InputObjectType; -use GraphQL\Type\Definition\LeafType; use GraphQL\Type\Definition\ListOfType; use GraphQL\Type\Definition\NonNull; use GraphQL\Type\Definition\Type; +use GraphQL\Type\Definition\ScalarType; use GraphQL\Utils\Utils; use GraphQL\Utils\TypeInfo; use GraphQL\Validator\Rules\AbstractValidationRule; @@ -306,17 +306,15 @@ public static function isValidLiteralValue(Type $type, $valueNode) return $errors; } - if ($type instanceof LeafType) { - // Scalars must parse to a non-null value - if (!$type->isValidLiteral($valueNode)) { - $printed = Printer::doPrint($valueNode); - return [ "Expected type \"{$type->name}\", found $printed." ]; - } + Utils::invariant($type instanceof ScalarType || $type instanceof EnumType, 'Must be input type'); - return []; + // Scalars determine if a literal values is valid. + if (!$type->isValidLiteral($valueNode)) { + $printed = Printer::doPrint($valueNode); + return [ "Expected type \"{$type->name}\", found $printed." ]; } - throw new InvariantViolation('Must be input type'); + return []; } /** diff --git a/tests/Executor/TestClasses.php b/tests/Executor/TestClasses.php index 6e53b5055..ef86938e1 100644 --- a/tests/Executor/TestClasses.php +++ b/tests/Executor/TestClasses.php @@ -2,6 +2,7 @@ namespace GraphQL\Tests\Executor; use GraphQL\Type\Definition\ScalarType; +use GraphQL\Utils\Utils; class Dog { @@ -65,15 +66,15 @@ public function parseValue($value) if ($value === 'SerializedValue') { return 'DeserializedValue'; } - return null; + return Utils::undefined(); } - public function parseLiteral($valueNode) + public function parseLiteral($valueNode, array $variables = null) { if ($valueNode->value === 'SerializedValue') { return 'DeserializedValue'; } - return null; + return Utils::undefined(); } } diff --git a/tests/Utils/AstFromValueUntypedTest.php b/tests/Utils/AstFromValueUntypedTest.php new file mode 100644 index 000000000..53c81da16 --- /dev/null +++ b/tests/Utils/AstFromValueUntypedTest.php @@ -0,0 +1,110 @@ +assertEquals( + $expected, + AST::valueFromASTUntyped(Parser::parseValue($valueText), $variables) + ); + } + + /** + * @it parses simple values + */ + public function testParsesSimpleValues() + { + $this->assertTestCase('null', null); + $this->assertTestCase('true', true); + $this->assertTestCase('false', false); + $this->assertTestCase('123', 123); + $this->assertTestCase('123.456', 123.456); + $this->assertTestCase('abc123', 'abc123'); + } + + /** + * @it parses lists of values + */ + public function testParsesListsOfValues() + { + $this->assertTestCase('[true, false]', [true, false]); + $this->assertTestCase('[true, 123.45]', [true, 123.45]); + $this->assertTestCase('[true, null]', [true, null]); + $this->assertTestCase('[true, ["foo", 1.2]]', [true, ['foo', 1.2]]); + } + + /** + * @it parses input objects + */ + public function testParsesInputObjects() + { + $this->assertTestCase( + '{ int: 123, bool: false }', + ['int' => 123, 'bool' => false] + ); + + $this->assertTestCase( + '{ foo: [ { bar: "baz"} ] }', + ['foo' => [['bar' => 'baz']]] + ); + } + + /** + * @it parses enum values as plain strings + */ + public function testParsesEnumValuesAsPlainStrings() + { + $this->assertTestCase( + 'TEST_ENUM_VALUE', + 'TEST_ENUM_VALUE' + ); + + $this->assertTestCase( + '[TEST_ENUM_VALUE]', + ['TEST_ENUM_VALUE'] + ); + } + + /** + * @it parses enum values as plain strings + */ + public function testParsesVariables() + { + $this->assertTestCase( + '$testVariable', + 'foo', + ['testVariable' => 'foo'] + ); + $this->assertTestCase( + '[$testVariable]', + ['foo'], + ['testVariable' => 'foo'] + ); + $this->assertTestCase( + '{a:[$testVariable]}', + ['a' => ['foo']], + ['testVariable' => 'foo'] + ); + $this->assertTestCase( + '$testVariable', + null, + ['testVariable' => null] + ); + $this->assertTestCase( + '$testVariable', + null, + [] + ); + $this->assertTestCase( + '$testVariable', + null, + null + ); + } +} diff --git a/tests/Utils/BuildSchemaTest.php b/tests/Utils/BuildSchemaTest.php index 7f0c47e8b..11054fd80 100644 --- a/tests/Utils/BuildSchemaTest.php +++ b/tests/Utils/BuildSchemaTest.php @@ -636,6 +636,26 @@ public function testSimpleArgumentFieldWithDefault() $this->assertEquals($output, $body); } + /** + * @it Custom scalar argument field with default + */ + public function testCustomScalarArgumentFieldWithDefault() + { + $body = ' +schema { + query: Hello +} + +scalar CustomScalar + +type Hello { + str(int: CustomScalar = 2): String +} +'; + $output = $this->cycleOutput($body); + $this->assertEquals($output, $body); + } + /** * @it Simple type with mutation */ From 48c33302a8ccaf32ca50ac27ec57abb4bb807f88 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Fri, 9 Feb 2018 14:30:05 +0100 Subject: [PATCH 13/50] (Potentially Breaking) Allow serializing scalars as null. This changes the check for null/undefined to a check for undefined to determine if scalar serialization was successful or not, allowing `null` to be returned from serialize() without indicating error. This is potentially breaking for any existing custom scalar which returned `null` from `serialize()` to indicate failure. To account for this change, it should either throw an error or return `undefined`. ref: graphql/graphql-js#1104 --- src/Executor/Executor.php | 2 +- src/Type/Definition/EnumType.php | 6 +++++- src/Utils/AST.php | 14 +++++++------- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index b2ba19aad..c21db83d5 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -1187,7 +1187,7 @@ private function completeLeafValue(LeafType $returnType, &$result) { $serializedResult = $returnType->serialize($result); - if ($serializedResult === null) { + if (Utils::isInvalid($serializedResult)) { throw new InvariantViolation( 'Expected a value of type "'. Utils::printSafe($returnType) . '" but received: ' . Utils::printSafe($result) ); diff --git a/src/Type/Definition/EnumType.php b/src/Type/Definition/EnumType.php index 035c86f21..1f188753e 100644 --- a/src/Type/Definition/EnumType.php +++ b/src/Type/Definition/EnumType.php @@ -108,7 +108,11 @@ public function getValue($name) public function serialize($value) { $lookup = $this->getValueLookup(); - return isset($lookup[$value]) ? $lookup[$value]->name : null; + if (isset($lookup[$value])) { + return $lookup[$value]->name; + } + + return Utils::undefined(); } /** diff --git a/src/Utils/AST.php b/src/Utils/AST.php index 6fd6a9b69..ea38aa460 100644 --- a/src/Utils/AST.php +++ b/src/Utils/AST.php @@ -205,15 +205,15 @@ static function astFromValue($value, InputType $type) return new ObjectValueNode(['fields' => $fieldNodes]); } + Utils::invariant( + $type instanceof ScalarType || $type instanceof EnumType, + "Must provide Input Type, cannot use: " . Utils::printSafe($type) + ); + // Since value is an internally represented value, it must be serialized // to an externally represented value before converting into an AST. - if ($type instanceof LeafType) { - $serialized = $type->serialize($value); - } else { - throw new InvariantViolation("Must provide Input Type, cannot use: " . Utils::printSafe($type)); - } - - if (null === $serialized) { + $serialized = $type->serialize($value); + if (null === $serialized || Utils::isInvalid($serialized)) { return null; } From 2cbccb87db75f6b68ceb0dbb7997b3821c4ee065 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Fri, 9 Feb 2018 16:14:04 +0100 Subject: [PATCH 14/50] Remove duplicated code from buildASTSchema and extendSchema ref: graphql/graphql-js#1000 BREAKING CHANGE: SchemaBuilder::build() and buildAST() and constructor removed the typedecorator, as not needed anymore as library can now resolve union and interfaces from generated schemas. --- src/Utils/ASTDefinitionBuilder.php | 437 +++++++++++++++++++++++++++ src/Utils/BuildSchema.php | 465 ++--------------------------- tests/Utils/BuildSchemaTest.php | 129 +------- 3 files changed, 461 insertions(+), 570 deletions(-) create mode 100644 src/Utils/ASTDefinitionBuilder.php diff --git a/src/Utils/ASTDefinitionBuilder.php b/src/Utils/ASTDefinitionBuilder.php new file mode 100644 index 000000000..754163306 --- /dev/null +++ b/src/Utils/ASTDefinitionBuilder.php @@ -0,0 +1,437 @@ +typeDefintionsMap = $typeDefintionsMap; + $this->options = $options; + $this->resolveType = $resolveType; + + $this->cache = [ + 'String' => Type::string(), + 'Int' => Type::int(), + 'Float' => Type::float(), + 'Boolean' => Type::boolean(), + 'ID' => Type::id(), + '__Schema' => Introspection::_schema(), + '__Directive' => Introspection::_directive(), + '__DirectiveLocation' => Introspection::_directiveLocation(), + '__Type' => Introspection::_type(), + '__Field' => Introspection::_field(), + '__InputValue' => Introspection::_inputValue(), + '__EnumValue' => Introspection::_enumValue(), + '__TypeKind' => Introspection::_typeKind(), + ]; + } + + /** + * @param Type $innerType + * @param TypeNode|ListTypeNode|NonNullTypeNode $inputTypeNode + * @return Type + */ + private function buildWrappedType(Type $innerType, TypeNode $inputTypeNode) + { + if ($inputTypeNode->kind == NodeKind::LIST_TYPE) { + return Type::listOf($this->buildWrappedType($innerType, $inputTypeNode->type)); + } + if ($inputTypeNode->kind == NodeKind::NON_NULL_TYPE) { + $wrappedType = $this->buildWrappedType($innerType, $inputTypeNode->type); + Utils::invariant(!($wrappedType instanceof NonNull), 'No nesting nonnull.'); + return Type::nonNull($wrappedType); + } + return $innerType; + } + + /** + * @param TypeNode|ListTypeNode|NonNullTypeNode $typeNode + * @return TypeNode + */ + private function getNamedTypeNode(TypeNode $typeNode) + { + $namedType = $typeNode; + while ($namedType->kind === NodeKind::LIST_TYPE || $namedType->kind === NodeKind::NON_NULL_TYPE) { + $namedType = $namedType->type; + } + return $namedType; + } + + /** + * @param string $typeName + * @param NamedTypeNode|null $typeNode + * @return Type + * @throws Error + */ + private function internalBuildType($typeName, $typeNode = null) { + if (!isset($this->cache[$typeName])) { + if (isset($this->typeDefintionsMap[$typeName])) { + $this->cache[$typeName] = $this->makeSchemaDef($this->typeDefintionsMap[$typeName]); + } else { + $fn = $this->resolveType; + $this->cache[$typeName] = $fn($typeName, $typeNode); + } + } + + return $this->cache[$typeName]; + } + + /** + * @param string|NamedTypeNode $ref + * @return Type + * @throws Error + */ + public function buildType($ref) + { + if (is_string($ref)) { + return $this->internalBuildType($ref); + } + + return $this->internalBuildType($ref->name->value, $ref); + } + + /** + * @param TypeNode $typeNode + * @return InputType|Type + * @throws Error + */ + public function buildInputType(TypeNode $typeNode) + { + $type = $this->internalBuildWrappedType($typeNode); + Utils::invariant(Type::isInputType($type), 'Expected Input type.'); + return $type; + } + + /** + * @param TypeNode $typeNode + * @return OutputType|Type + * @throws Error + */ + public function buildOutputType(TypeNode $typeNode) + { + $type = $this->internalBuildWrappedType($typeNode); + Utils::invariant(Type::isOutputType($type), 'Expected Output type.'); + return $type; + } + + /** + * @param TypeNode|string $typeNode + * @return ObjectType|Type + * @throws Error + */ + public function buildObjectType($typeNode) + { + $type = $this->buildType($typeNode); + Utils::invariant($type instanceof ObjectType, 'Expected Object type.' . get_class($type)); + return $type; + } + + /** + * @param TypeNode|string $typeNode + * @return InterfaceType|Type + * @throws Error + */ + public function buildInterfaceType($typeNode) + { + $type = $this->buildType($typeNode); + Utils::invariant($type instanceof InterfaceType, 'Expected Interface type.'); + return $type; + } + + /** + * @param TypeNode $typeNode + * @return Type + * @throws Error + */ + private function internalBuildWrappedType(TypeNode $typeNode) + { + $typeDef = $this->buildType($this->getNamedTypeNode($typeNode)); + return $this->buildWrappedType($typeDef, $typeNode); + } + + public function buildDirective(DirectiveDefinitionNode $directiveNode) + { + return new Directive([ + 'name' => $directiveNode->name->value, + 'description' => $this->getDescription($directiveNode), + 'locations' => Utils::map($directiveNode->locations, function ($node) { + return $node->value; + }), + 'args' => $directiveNode->arguments ? FieldArgument::createMap($this->makeInputValues($directiveNode->arguments)) : null, + 'astNode' => $directiveNode, + ]); + } + + public function buildField(FieldDefinitionNode $field) + { + return [ + 'type' => $this->buildOutputType($field->type), + 'description' => $this->getDescription($field), + 'args' => $this->makeInputValues($field->arguments), + 'deprecationReason' => $this->getDeprecationReason($field), + 'astNode' => $field + ]; + } + + private function makeSchemaDef($def) + { + if (!$def) { + throw new Error('def must be defined.'); + } + switch ($def->kind) { + case NodeKind::OBJECT_TYPE_DEFINITION: + return $this->makeTypeDef($def); + case NodeKind::INTERFACE_TYPE_DEFINITION: + return $this->makeInterfaceDef($def); + case NodeKind::ENUM_TYPE_DEFINITION: + return $this->makeEnumDef($def); + case NodeKind::UNION_TYPE_DEFINITION: + return $this->makeUnionDef($def); + case NodeKind::SCALAR_TYPE_DEFINITION: + return $this->makeScalarDef($def); + case NodeKind::INPUT_OBJECT_TYPE_DEFINITION: + return $this->makeInputObjectDef($def); + default: + throw new Error("Type kind of {$def->kind} not supported."); + } + } + + private function makeTypeDef(ObjectTypeDefinitionNode $def) + { + $typeName = $def->name->value; + return new ObjectType([ + 'name' => $typeName, + 'description' => $this->getDescription($def), + 'fields' => function () use ($def) { + return $this->makeFieldDefMap($def); + }, + 'interfaces' => function () use ($def) { + return $this->makeImplementedInterfaces($def); + }, + 'astNode' => $def + ]); + } + + private function makeFieldDefMap($def) + { + return Utils::keyValMap( + $def->fields, + function ($field) { + return $field->name->value; + }, + function ($field) { + return $this->buildField($field); + } + ); + } + + private function makeImplementedInterfaces(ObjectTypeDefinitionNode $def) + { + if (isset($def->interfaces)) { + return Utils::map($def->interfaces, function ($iface) { + return $this->buildInterfaceType($iface); + }); + } + return null; + } + + private function makeInputValues($values) + { + return Utils::keyValMap( + $values, + function ($value) { + return $value->name->value; + }, + function ($value) { + $type = $this->buildInputType($value->type); + $config = [ + 'name' => $value->name->value, + 'type' => $type, + 'description' => $this->getDescription($value), + 'astNode' => $value + ]; + if (isset($value->defaultValue)) { + $config['defaultValue'] = AST::valueFromAST($value->defaultValue, $type); + } + return $config; + } + ); + } + + private function makeInterfaceDef(InterfaceTypeDefinitionNode $def) + { + $typeName = $def->name->value; + return new InterfaceType([ + 'name' => $typeName, + 'description' => $this->getDescription($def), + 'fields' => function () use ($def) { + return $this->makeFieldDefMap($def); + }, + 'astNode' => $def + ]); + } + + private function makeEnumDef(EnumTypeDefinitionNode $def) + { + return new EnumType([ + 'name' => $def->name->value, + 'description' => $this->getDescription($def), + 'astNode' => $def, + 'values' => Utils::keyValMap( + $def->values, + function ($enumValue) { + return $enumValue->name->value; + }, + function ($enumValue) { + return [ + 'description' => $this->getDescription($enumValue), + 'deprecationReason' => $this->getDeprecationReason($enumValue), + 'astNode' => $enumValue + ]; + } + ) + ]); + } + + private function makeUnionDef(UnionTypeDefinitionNode $def) + { + return new UnionType([ + 'name' => $def->name->value, + 'description' => $this->getDescription($def), + 'types' => Utils::map($def->types, function ($typeNode) { + return $this->buildObjectType($typeNode); + }), + 'astNode' => $def + ]); + } + + private function makeScalarDef(ScalarTypeDefinitionNode $def) + { + return new CustomScalarType([ + 'name' => $def->name->value, + 'description' => $this->getDescription($def), + 'astNode' => $def, + 'serialize' => function($value) { + return $value; + }, + ]); + } + + private function makeInputObjectDef(InputObjectTypeDefinitionNode $def) + { + return new InputObjectType([ + 'name' => $def->name->value, + 'description' => $this->getDescription($def), + 'fields' => function () use ($def) { + return $this->makeInputValues($def->fields); + }, + 'astNode' => $def, + ]); + } + + /** + * Given a collection of directives, returns the string value for the + * deprecation reason. + * + * @param EnumValueDefinitionNode | FieldDefinitionNode $node + * @return string + */ + private function getDeprecationReason($node) + { + $deprecated = Values::getDirectiveValues(Directive::deprecatedDirective(), $node); + return isset($deprecated['reason']) ? $deprecated['reason'] : null; + } + + /** + * Given an ast node, returns its string description. + */ + private function getDescription($node) + { + if ($node->description) { + return $node->description->value; + } + if (isset($this->options['commentDescriptions'])) { + $rawValue = $this->getLeadingCommentBlock($node); + if ($rawValue !== null) { + return BlockString::value("\n" . $rawValue); + } + } + + return null; + } + + private function getLeadingCommentBlock($node) + { + $loc = $node->loc; + if (!$loc || !$loc->startToken) { + return; + } + $comments = []; + $token = $loc->startToken->prev; + while ( + $token && + $token->kind === Token::COMMENT && + $token->next && $token->prev && + $token->line + 1 === $token->next->line && + $token->line !== $token->prev->line + ) { + $value = $token->value; + $comments[] = $value; + $token = $token->prev; + } + + return implode("\n", array_reverse($comments)); + } +} diff --git a/src/Utils/BuildSchema.php b/src/Utils/BuildSchema.php index b04ef406c..9d764cd6b 100644 --- a/src/Utils/BuildSchema.php +++ b/src/Utils/BuildSchema.php @@ -2,35 +2,13 @@ namespace GraphQL\Utils; use GraphQL\Error\Error; -use GraphQL\Executor\Values; -use GraphQL\Language\AST\DirectiveDefinitionNode; use GraphQL\Language\AST\DocumentNode; -use GraphQL\Language\AST\EnumTypeDefinitionNode; -use GraphQL\Language\AST\EnumValueDefinitionNode; -use GraphQL\Language\AST\FieldDefinitionNode; -use GraphQL\Language\AST\InputObjectTypeDefinitionNode; -use GraphQL\Language\AST\InterfaceTypeDefinitionNode; use GraphQL\Language\AST\NodeKind; -use GraphQL\Language\AST\ObjectTypeDefinitionNode; -use GraphQL\Language\AST\ScalarTypeDefinitionNode; use GraphQL\Language\AST\SchemaDefinitionNode; -use GraphQL\Language\AST\TypeNode; -use GraphQL\Language\AST\UnionTypeDefinitionNode; use GraphQL\Language\Parser; use GraphQL\Language\Source; -use GraphQL\Language\Token; use GraphQL\Type\Schema; use GraphQL\Type\Definition\Directive; -use GraphQL\Type\Definition\EnumType; -use GraphQL\Type\Definition\InputObjectType; -use GraphQL\Type\Definition\InterfaceType; -use GraphQL\Type\Definition\FieldArgument; -use GraphQL\Type\Definition\NonNull; -use GraphQL\Type\Definition\ObjectType; -use GraphQL\Type\Definition\CustomScalarType; -use GraphQL\Type\Definition\Type; -use GraphQL\Type\Definition\UnionType; -use GraphQL\Type\Introspection; /** * Build instance of `GraphQL\Type\Schema` out of type language definition (string or parsed AST) @@ -38,33 +16,6 @@ */ class BuildSchema { - /** - * @param Type $innerType - * @param TypeNode $inputTypeNode - * @return Type - */ - private function buildWrappedType(Type $innerType, TypeNode $inputTypeNode) - { - if ($inputTypeNode->kind == NodeKind::LIST_TYPE) { - return Type::listOf($this->buildWrappedType($innerType, $inputTypeNode->type)); - } - if ($inputTypeNode->kind == NodeKind::NON_NULL_TYPE) { - $wrappedType = $this->buildWrappedType($innerType, $inputTypeNode->type); - Utils::invariant(!($wrappedType instanceof NonNull), 'No nesting nonnull.'); - return Type::nonNull($wrappedType); - } - return $innerType; - } - - private function getNamedTypeNode(TypeNode $typeNode) - { - $namedType = $typeNode; - while ($namedType->kind === NodeKind::LIST_TYPE || $namedType->kind === NodeKind::NON_NULL_TYPE) { - $namedType = $namedType->type; - } - return $namedType; - } - /** * This takes the ast of a schema document produced by the parse function in * GraphQL\Language\Parser. @@ -75,7 +26,7 @@ private function getNamedTypeNode(TypeNode $typeNode) * Given that AST it constructs a GraphQL\Type\Schema. The resulting schema * has no resolve methods, so execution will use default resolvers. * - * Accepts options as a third argument: + * Accepts options as a second argument: * * - commentDescriptions: * Provide true to use preceding comments as the description. @@ -83,27 +34,24 @@ private function getNamedTypeNode(TypeNode $typeNode) * * @api * @param DocumentNode $ast - * @param callable $typeConfigDecorator + * @param array $options * @return Schema * @throws Error */ - public static function buildAST(DocumentNode $ast, callable $typeConfigDecorator = null, array $options = []) + public static function buildAST(DocumentNode $ast, array $options = []) { - $builder = new self($ast, $typeConfigDecorator, $options); + $builder = new self($ast, $options); return $builder->buildSchema(); } private $ast; - private $innerTypeMap; private $nodeMap; - private $typeConfigDecorator; private $loadedTypeDefs; private $options; - public function __construct(DocumentNode $ast, callable $typeConfigDecorator = null, array $options = []) + public function __construct(DocumentNode $ast, array $options = []) { $this->ast = $ast; - $this->typeConfigDecorator = $typeConfigDecorator; $this->loadedTypeDefs = []; $this->options = $options; } @@ -150,23 +98,15 @@ public function buildSchema() 'subscription' => isset($this->nodeMap['Subscription']) ? 'Subscription' : null, ]; - $this->innerTypeMap = [ - 'String' => Type::string(), - 'Int' => Type::int(), - 'Float' => Type::float(), - 'Boolean' => Type::boolean(), - 'ID' => Type::id(), - '__Schema' => Introspection::_schema(), - '__Directive' => Introspection::_directive(), - '__DirectiveLocation' => Introspection::_directiveLocation(), - '__Type' => Introspection::_type(), - '__Field' => Introspection::_field(), - '__InputValue' => Introspection::_inputValue(), - '__EnumValue' => Introspection::_enumValue(), - '__TypeKind' => Introspection::_typeKind(), - ]; + $defintionBuilder = new ASTDefinitionBuilder( + $this->nodeMap, + $this->options, + function($typeName) { throw new Error('Type "'. $typeName . '" not found in document.'); } + ); - $directives = array_map([$this, 'getDirective'], $directiveDefs); + $directives = array_map(function($def) use ($defintionBuilder) { + return $defintionBuilder->buildDirective($def); + }, $directiveDefs); // If specified directives were not explicitly declared, add them. $skip = array_reduce($directives, function ($hasSkip, $directive) { @@ -197,23 +137,23 @@ public function buildSchema() } $schema = new Schema([ - 'query' => $this->getObjectType($operationTypes['query']), + 'query' => $defintionBuilder->buildObjectType($operationTypes['query']), 'mutation' => isset($operationTypes['mutation']) ? - $this->getObjectType($operationTypes['mutation']) : + $defintionBuilder->buildObjectType($operationTypes['mutation']) : null, 'subscription' => isset($operationTypes['subscription']) ? - $this->getObjectType($operationTypes['subscription']) : + $defintionBuilder->buildObjectType($operationTypes['subscription']) : null, - 'typeLoader' => function ($name) { - return $this->typeDefNamed($name); + 'typeLoader' => function ($name) use ($defintionBuilder) { + return $defintionBuilder->buildType($name); }, 'directives' => $directives, 'astNode' => $schemaDef, - 'types' => function () { + 'types' => function () use ($defintionBuilder) { $types = []; foreach ($this->nodeMap as $name => $def) { if (!isset($this->loadedTypeDefs[$name])) { - $types[] = $this->typeDefNamed($def->name->value); + $types[] = $defintionBuilder->buildType($def->name->value); } } return $types; @@ -250,377 +190,18 @@ private function getOperationTypes($schemaDef) return $opTypes; } - private function getDirective(DirectiveDefinitionNode $directiveNode) - { - return new Directive([ - 'name' => $directiveNode->name->value, - 'description' => $this->getDescription($directiveNode), - 'locations' => Utils::map($directiveNode->locations, function ($node) { - return $node->value; - }), - 'args' => $directiveNode->arguments ? FieldArgument::createMap($this->makeInputValues($directiveNode->arguments)) : null, - 'astNode' => $directiveNode - ]); - } - - /** - * @param string $name - * @return CustomScalarType|EnumType|InputObjectType|UnionType - * @throws Error - */ - private function getObjectType($name) - { - $type = $this->typeDefNamed($name); - Utils::invariant( - $type instanceof ObjectType, - 'AST must provide object type.' - ); - return $type; - } - - private function produceType(TypeNode $typeNode) - { - $typeName = $this->getNamedTypeNode($typeNode)->name->value; - $typeDef = $this->typeDefNamed($typeName); - return $this->buildWrappedType($typeDef, $typeNode); - } - - private function produceInputType(TypeNode $typeNode) - { - $type = $this->produceType($typeNode); - Utils::invariant(Type::isInputType($type), 'Expected Input type.'); - return $type; - } - - private function produceOutputType(TypeNode $typeNode) - { - $type = $this->produceType($typeNode); - Utils::invariant(Type::isOutputType($type), 'Expected Input type.'); - return $type; - } - - private function produceObjectType(TypeNode $typeNode) - { - $type = $this->produceType($typeNode); - Utils::invariant($type instanceof ObjectType, 'Expected Object type.'); - return $type; - } - - private function produceInterfaceType(TypeNode $typeNode) - { - $type = $this->produceType($typeNode); - Utils::invariant($type instanceof InterfaceType, 'Expected Interface type.'); - return $type; - } - - private function typeDefNamed($typeName) - { - if (isset($this->innerTypeMap[$typeName])) { - return $this->innerTypeMap[$typeName]; - } - - if (!isset($this->nodeMap[$typeName])) { - throw new Error('Type "' . $typeName . '" not found in document.'); - } - - $this->loadedTypeDefs[$typeName] = true; - - $config = $this->makeSchemaDefConfig($this->nodeMap[$typeName]); - - if ($this->typeConfigDecorator) { - $fn = $this->typeConfigDecorator; - try { - $config = $fn($config, $this->nodeMap[$typeName], $this->nodeMap); - } catch (\Exception $e) { - throw new Error( - "Type config decorator passed to " . (static::class) . " threw an error " . - "when building $typeName type: {$e->getMessage()}", - null, - null, - null, - null, - $e - ); - } catch (\Throwable $e) { - throw new Error( - "Type config decorator passed to " . (static::class) . " threw an error " . - "when building $typeName type: {$e->getMessage()}", - null, - null, - null, - null, - $e - ); - } - if (!is_array($config) || isset($config[0])) { - throw new Error( - "Type config decorator passed to " . (static::class) . " is expected to return an array, but got " . - Utils::getVariableType($config) - ); - } - } - - $innerTypeDef = $this->makeSchemaDef($this->nodeMap[$typeName], $config); - - if (!$innerTypeDef) { - throw new Error("Nothing constructed for $typeName."); - } - $this->innerTypeMap[$typeName] = $innerTypeDef; - return $innerTypeDef; - } - - private function makeSchemaDefConfig($def) - { - if (!$def) { - throw new Error('def must be defined.'); - } - switch ($def->kind) { - case NodeKind::OBJECT_TYPE_DEFINITION: - return $this->makeTypeDefConfig($def); - case NodeKind::INTERFACE_TYPE_DEFINITION: - return $this->makeInterfaceDefConfig($def); - case NodeKind::ENUM_TYPE_DEFINITION: - return $this->makeEnumDefConfig($def); - case NodeKind::UNION_TYPE_DEFINITION: - return $this->makeUnionDefConfig($def); - case NodeKind::SCALAR_TYPE_DEFINITION: - return $this->makeScalarDefConfig($def); - case NodeKind::INPUT_OBJECT_TYPE_DEFINITION: - return $this->makeInputObjectDefConfig($def); - default: - throw new Error("Type kind of {$def->kind} not supported."); - } - } - - private function makeSchemaDef($def, array $config = null) - { - if (!$def) { - throw new Error('def must be defined.'); - } - - $config = $config ?: $this->makeSchemaDefConfig($def); - - switch ($def->kind) { - case NodeKind::OBJECT_TYPE_DEFINITION: - return new ObjectType($config); - case NodeKind::INTERFACE_TYPE_DEFINITION: - return new InterfaceType($config); - case NodeKind::ENUM_TYPE_DEFINITION: - return new EnumType($config); - case NodeKind::UNION_TYPE_DEFINITION: - return new UnionType($config); - case NodeKind::SCALAR_TYPE_DEFINITION: - return new CustomScalarType($config); - case NodeKind::INPUT_OBJECT_TYPE_DEFINITION: - return new InputObjectType($config); - default: - throw new Error("Type kind of {$def->kind} not supported."); - } - } - - private function makeTypeDefConfig(ObjectTypeDefinitionNode $def) - { - $typeName = $def->name->value; - return [ - 'name' => $typeName, - 'description' => $this->getDescription($def), - 'fields' => function () use ($def) { - return $this->makeFieldDefMap($def); - }, - 'interfaces' => function () use ($def) { - return $this->makeImplementedInterfaces($def); - }, - 'astNode' => $def - ]; - } - - private function makeFieldDefMap($def) - { - return Utils::keyValMap( - $def->fields, - function ($field) { - return $field->name->value; - }, - function ($field) { - return [ - 'type' => $this->produceOutputType($field->type), - 'description' => $this->getDescription($field), - 'args' => $this->makeInputValues($field->arguments), - 'deprecationReason' => $this->getDeprecationReason($field), - 'astNode' => $field - ]; - } - ); - } - - private function makeImplementedInterfaces(ObjectTypeDefinitionNode $def) - { - if (isset($def->interfaces)) { - return Utils::map($def->interfaces, function ($iface) { - return $this->produceInterfaceType($iface); - }); - } - return null; - } - - private function makeInputValues($values) - { - return Utils::keyValMap( - $values, - function ($value) { - return $value->name->value; - }, - function ($value) { - $type = $this->produceInputType($value->type); - $config = [ - 'name' => $value->name->value, - 'type' => $type, - 'description' => $this->getDescription($value), - 'astNode' => $value - ]; - if (isset($value->defaultValue)) { - $config['defaultValue'] = AST::valueFromAST($value->defaultValue, $type); - } - return $config; - } - ); - } - - private function makeInterfaceDefConfig(InterfaceTypeDefinitionNode $def) - { - $typeName = $def->name->value; - return [ - 'name' => $typeName, - 'description' => $this->getDescription($def), - 'fields' => function () use ($def) { - return $this->makeFieldDefMap($def); - }, - 'astNode' => $def - ]; - } - - private function makeEnumDefConfig(EnumTypeDefinitionNode $def) - { - return [ - 'name' => $def->name->value, - 'description' => $this->getDescription($def), - 'astNode' => $def, - 'values' => Utils::keyValMap( - $def->values, - function ($enumValue) { - return $enumValue->name->value; - }, - function ($enumValue) { - return [ - 'description' => $this->getDescription($enumValue), - 'deprecationReason' => $this->getDeprecationReason($enumValue), - 'astNode' => $enumValue - ]; - } - ) - ]; - } - - private function makeUnionDefConfig(UnionTypeDefinitionNode $def) - { - return [ - 'name' => $def->name->value, - 'description' => $this->getDescription($def), - 'types' => Utils::map($def->types, function ($typeNode) { - return $this->produceObjectType($typeNode); - }), - 'astNode' => $def - ]; - } - - private function makeScalarDefConfig(ScalarTypeDefinitionNode $def) - { - return [ - 'name' => $def->name->value, - 'description' => $this->getDescription($def), - 'astNode' => $def, - 'serialize' => function($value) { - return $value; - }, - ]; - } - - private function makeInputObjectDefConfig(InputObjectTypeDefinitionNode $def) - { - return [ - 'name' => $def->name->value, - 'description' => $this->getDescription($def), - 'fields' => function () use ($def) { - return $this->makeInputValues($def->fields); - }, - 'astNode' => $def, - ]; - } - - /** - * Given a collection of directives, returns the string value for the - * deprecation reason. - * - * @param EnumValueDefinitionNode | FieldDefinitionNode $node - * @return string - */ - private function getDeprecationReason($node) - { - $deprecated = Values::getDirectiveValues(Directive::deprecatedDirective(), $node); - return isset($deprecated['reason']) ? $deprecated['reason'] : null; - } - - /** - * Given an ast node, returns its string description. - */ - public function getDescription($node) - { - if ($node->description) { - return $node->description->value; - } - if (isset($this->options['commentDescriptions'])) { - $rawValue = $this->getLeadingCommentBlock($node); - if ($rawValue !== null) { - return BlockString::value("\n" . $rawValue); - } - } - } - - public function getLeadingCommentBlock($node) - { - $loc = $node->loc; - if (!$loc || !$loc->startToken) { - return; - } - $comments = []; - $token = $loc->startToken->prev; - while ( - $token && - $token->kind === Token::COMMENT && - $token->next && $token->prev && - $token->line + 1 === $token->next->line && - $token->line !== $token->prev->line - ) { - $value = $token->value; - $comments[] = $value; - $token = $token->prev; - } - - return implode("\n", array_reverse($comments)); - } - /** * A helper function to build a GraphQLSchema directly from a source * document. * * @api * @param DocumentNode|Source|string $source - * @param callable $typeConfigDecorator + * @param array $options * @return Schema */ - public static function build($source, callable $typeConfigDecorator = null) + public static function build($source, array $options = []) { $doc = $source instanceof DocumentNode ? $source : Parser::parse($source); - return self::buildAST($doc, $typeConfigDecorator); + return self::buildAST($doc, $options); } } diff --git a/tests/Utils/BuildSchemaTest.php b/tests/Utils/BuildSchemaTest.php index 11054fd80..b77220aa2 100644 --- a/tests/Utils/BuildSchemaTest.php +++ b/tests/Utils/BuildSchemaTest.php @@ -20,7 +20,7 @@ class BuildSchemaTest extends \PHPUnit_Framework_TestCase private function cycleOutput($body, $options = []) { $ast = Parser::parse($body); - $schema = BuildSchema::buildAST($ast, null, $options); + $schema = BuildSchema::buildAST($ast, $options); return "\n" . SchemaPrinter::doPrint($schema, $options); } @@ -1172,131 +1172,4 @@ public function testForbidsDuplicateTypeDefinitions() $this->setExpectedException('GraphQL\Error\Error', 'Type "Repeated" was defined more than once.'); BuildSchema::buildAST($doc); } - - public function testSupportsTypeConfigDecorator() - { - $body = ' -schema { - query: Query -} - -type Query { - str: String - color: Color - hello: Hello -} - -enum Color { - RED - GREEN - BLUE -} - -interface Hello { - world: String -} -'; - $doc = Parser::parse($body); - - $decorated = []; - $calls = []; - - $typeConfigDecorator = function($defaultConfig, $node, $allNodesMap) use (&$decorated, &$calls) { - $decorated[] = $defaultConfig['name']; - $calls[] = [$defaultConfig, $node, $allNodesMap]; - return ['description' => 'My description of ' . $node->name->value] + $defaultConfig; - }; - - $schema = BuildSchema::buildAST($doc, $typeConfigDecorator); - $schema->getTypeMap(); - $this->assertEquals(['Query', 'Color', 'Hello'], $decorated); - - list($defaultConfig, $node, $allNodesMap) = $calls[0]; - $this->assertInstanceOf(ObjectTypeDefinitionNode::class, $node); - $this->assertEquals('Query', $defaultConfig['name']); - $this->assertInstanceOf(\Closure::class, $defaultConfig['fields']); - $this->assertInstanceOf(\Closure::class, $defaultConfig['interfaces']); - $this->assertArrayHasKey('description', $defaultConfig); - $this->assertCount(5, $defaultConfig); - $this->assertEquals(array_keys($allNodesMap), ['Query', 'Color', 'Hello']); - $this->assertEquals('My description of Query', $schema->getType('Query')->description); - - - list($defaultConfig, $node, $allNodesMap) = $calls[1]; - $this->assertInstanceOf(EnumTypeDefinitionNode::class, $node); - $this->assertEquals('Color', $defaultConfig['name']); - $enumValue = [ - 'description' => '', - 'deprecationReason' => '' - ]; - $this->assertArraySubset([ - 'RED' => $enumValue, - 'GREEN' => $enumValue, - 'BLUE' => $enumValue, - ], $defaultConfig['values']); - $this->assertCount(4, $defaultConfig); // 3 + astNode - $this->assertEquals(array_keys($allNodesMap), ['Query', 'Color', 'Hello']); - $this->assertEquals('My description of Color', $schema->getType('Color')->description); - - list($defaultConfig, $node, $allNodesMap) = $calls[2]; - $this->assertInstanceOf(InterfaceTypeDefinitionNode::class, $node); - $this->assertEquals('Hello', $defaultConfig['name']); - $this->assertInstanceOf(\Closure::class, $defaultConfig['fields']); - $this->assertArrayHasKey('description', $defaultConfig); - $this->assertCount(4, $defaultConfig); - $this->assertEquals(array_keys($allNodesMap), ['Query', 'Color', 'Hello']); - $this->assertEquals('My description of Hello', $schema->getType('Hello')->description); - } - - public function testCreatesTypesLazily() - { - $body = ' -schema { - query: Query -} - -type Query { - str: String - color: Color - hello: Hello -} - -enum Color { - RED - GREEN - BLUE -} - -interface Hello { - world: String -} - -type World implements Hello { - world: String -} -'; - $doc = Parser::parse($body); - $created = []; - - $typeConfigDecorator = function($config, $node) use (&$created) { - $created[] = $node->name->value; - return $config; - }; - - $schema = BuildSchema::buildAST($doc, $typeConfigDecorator); - $this->assertEquals(['Query'], $created); - - $schema->getType('Color'); - $this->assertEquals(['Query', 'Color'], $created); - - $schema->getType('Hello'); - $this->assertEquals(['Query', 'Color', 'Hello'], $created); - - $types = $schema->getTypeMap(); - $this->assertEquals(['Query', 'Color', 'Hello', 'World'], $created); - $this->assertArrayHasKey('Query', $types); - $this->assertArrayHasKey('Color', $types); - $this->assertArrayHasKey('Hello', $types); - $this->assertArrayHasKey('World', $types); - } } From c4f11a577e6dab67174d8ff586f3a55a19159d74 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Fri, 9 Feb 2018 16:26:19 +0100 Subject: [PATCH 15/50] Allow to extend GraphQL errors with additional properties ref: graphql/graphql-js#928 --- src/Error/Error.php | 27 +++++++++++++++++++++++++-- src/Error/FormattedError.php | 4 ++++ tests/ErrorTest.php | 19 +++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/Error/Error.php b/src/Error/Error.php index 1e6f3f85c..ccbd323e8 100644 --- a/src/Error/Error.php +++ b/src/Error/Error.php @@ -73,6 +73,11 @@ class Error extends \Exception implements \JsonSerializable, ClientAware */ protected $category; + /** + * @var array + */ + protected $extensions; + /** * Given an arbitrary Error, presumably thrown while attempting to execute a * GraphQL operation, produce a new GraphQLError aware of the location in the @@ -95,6 +100,7 @@ public static function createLocatedError($error, $nodes = null, $path = null) } $source = $positions = $originalError = null; + $extensions = []; if ($error instanceof self) { $message = $error->getMessage(); @@ -102,6 +108,7 @@ public static function createLocatedError($error, $nodes = null, $path = null) $nodes = $error->nodes ?: $nodes; $source = $error->source; $positions = $error->positions; + $extensions = $error->extensions; } else if ($error instanceof \Exception || $error instanceof \Throwable) { $message = $error->getMessage(); $originalError = $error; @@ -115,7 +122,8 @@ public static function createLocatedError($error, $nodes = null, $path = null) $source, $positions, $path, - $originalError + $originalError, + $extensions ); } @@ -136,6 +144,7 @@ public static function formatError(Error $error) * @param array|null $positions * @param array|null $path * @param \Throwable $previous + * @param array $extensions */ public function __construct( $message, @@ -143,7 +152,8 @@ public function __construct( Source $source = null, $positions = null, $path = null, - $previous = null + $previous = null, + array $extensions = [] ) { parent::__construct($message, 0, $previous); @@ -156,6 +166,7 @@ public function __construct( $this->source = $source; $this->positions = $positions; $this->path = $path; + $this->extensions = $extensions; if ($previous instanceof ClientAware) { $this->isClientSafe = $previous->isClientSafe(); @@ -260,6 +271,14 @@ public function getPath() return $this->path; } + /** + * @return array + */ + public function getExtensions() + { + return $this->extensions; + } + /** * Returns array representation of error suitable for serialization * @@ -272,6 +291,10 @@ public function toSerializableArray() 'message' => $this->getMessage() ]; + if ($this->getExtensions()) { + $arr = array_merge($this->getExtensions(), $arr); + } + $locations = Utils::map($this->getLocations(), function(SourceLocation $loc) { return $loc->toSerializableArray(); }); diff --git a/src/Error/FormattedError.php b/src/Error/FormattedError.php index c7c63c4fd..5ed4adb26 100644 --- a/src/Error/FormattedError.php +++ b/src/Error/FormattedError.php @@ -66,6 +66,10 @@ public static function createFromException($e, $debug = false, $internalErrorMes } if ($e instanceof Error) { + if ($e->getExtensions()) { + $formattedError = array_merge($e->getExtensions(), $formattedError); + } + $locations = Utils::map($e->getLocations(), function(SourceLocation $loc) { return $loc->toSerializableArray(); }); diff --git a/tests/ErrorTest.php b/tests/ErrorTest.php index 5104a423d..8a3997048 100644 --- a/tests/ErrorTest.php +++ b/tests/ErrorTest.php @@ -110,4 +110,23 @@ public function testSerializesToIncludePath() $this->assertEquals([ 'path', 3, 'to', 'field' ], $e->path); $this->assertEquals(['message' => 'msg', 'path' => [ 'path', 3, 'to', 'field' ]], $e->toSerializableArray()); } + + /** + * @it default error formatter includes extension fields + */ + public function testDefaultErrorFormatterIncludesExtensionFields() + { + $e = new Error( + 'msg', + null, + null, + null, + null, + null, + ['foo' => 'bar'] + ); + + $this->assertEquals(['foo' => 'bar'], $e->getExtensions()); + $this->assertEquals(['message' => 'msg', 'foo' => 'bar'], $e->toSerializableArray()); + } } From 1da38016148288ab2b005069dd9a653077acb3b6 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Fri, 9 Feb 2018 18:03:31 +0100 Subject: [PATCH 16/50] Add predicates to for built-in types ref: graphql/graphql-js#924 --- src/Type/Definition/Directive.php | 9 +++++ src/Type/Definition/Type.php | 37 ++++++++++++++++++++ src/Type/Introspection.php | 9 +++++ src/Utils/ASTDefinitionBuilder.php | 16 +-------- src/Utils/SchemaPrinter.php | 55 +++++++----------------------- 5 files changed, 69 insertions(+), 57 deletions(-) diff --git a/src/Type/Definition/Directive.php b/src/Type/Definition/Directive.php index 546f02df8..63ea075d3 100644 --- a/src/Type/Definition/Directive.php +++ b/src/Type/Definition/Directive.php @@ -74,6 +74,15 @@ public static function deprecatedDirective() return $internal['deprecated']; } + /** + * @param Directive $directive + * @return bool + */ + public static function isSpecifiedDirective(Directive $directive) + { + return in_array($directive->name, array_keys(self::getInternalDirectives())); + } + /** * @return array */ diff --git a/src/Type/Definition/Type.php b/src/Type/Definition/Type.php index 2b2c83fb1..89bac9b4c 100644 --- a/src/Type/Definition/Type.php +++ b/src/Type/Definition/Type.php @@ -2,7 +2,9 @@ namespace GraphQL\Type\Definition; use GraphQL\Error\InvariantViolation; +use GraphQL\Language\AST\NamedType; use GraphQL\Language\AST\TypeDefinitionNode; +use GraphQL\Type\Introspection; /** * Registry of standard GraphQL types @@ -23,6 +25,11 @@ abstract class Type implements \JsonSerializable */ private static $internalTypes; + /** + * @var array + */ + private static $builtInTypes; + /** * @api * @return IDType @@ -107,6 +114,8 @@ private static function getInternalType($name = null) } /** + * Returns all builtin scalar types + * * @return Type[] */ public static function getInternalTypes() @@ -114,6 +123,34 @@ public static function getInternalTypes() return self::getInternalType(); } + /** + * Returns all builtin in types including base scalar and + * introspection types + * + * @return Type[] + */ + public static function getAllBuiltInTypes() + { + if (null === self::$builtInTypes) { + self::$builtInTypes = array_merge( + Introspection::getTypes(), + self::getInternalTypes() + ); + } + return self::$builtInTypes; + } + + /** + * Checks if the type is a builtin type + * + * @param Type $type + * @return bool + */ + public static function isBuiltInType(Type $type) + { + return in_array($type->name, array_keys(self::getAllBuiltInTypes())); + } + /** * @api * @param Type $type diff --git a/src/Type/Introspection.php b/src/Type/Introspection.php index 7f0734dc8..3c87a9079 100644 --- a/src/Type/Introspection.php +++ b/src/Type/Introspection.php @@ -240,6 +240,15 @@ public static function getTypes() ]; } + /** + * @param Type $type + * @return bool + */ + public static function isIntrospectionType(Type $type) + { + return in_array($type->name, array_keys(self::getTypes())); + } + public static function _schema() { if (!isset(self::$map['__Schema'])) { diff --git a/src/Utils/ASTDefinitionBuilder.php b/src/Utils/ASTDefinitionBuilder.php index 754163306..ed73dd52d 100644 --- a/src/Utils/ASTDefinitionBuilder.php +++ b/src/Utils/ASTDefinitionBuilder.php @@ -61,21 +61,7 @@ public function __construct(array $typeDefintionsMap, $options, callable $resolv $this->options = $options; $this->resolveType = $resolveType; - $this->cache = [ - 'String' => Type::string(), - 'Int' => Type::int(), - 'Float' => Type::float(), - 'Boolean' => Type::boolean(), - 'ID' => Type::id(), - '__Schema' => Introspection::_schema(), - '__Directive' => Introspection::_directive(), - '__DirectiveLocation' => Introspection::_directiveLocation(), - '__Type' => Introspection::_type(), - '__Field' => Introspection::_field(), - '__InputValue' => Introspection::_inputValue(), - '__EnumValue' => Introspection::_enumValue(), - '__TypeKind' => Introspection::_typeKind(), - ]; + $this->cache = Type::getAllBuiltInTypes(); } /** diff --git a/src/Utils/SchemaPrinter.php b/src/Utils/SchemaPrinter.php index 93c667e1a..d80e104dc 100644 --- a/src/Utils/SchemaPrinter.php +++ b/src/Utils/SchemaPrinter.php @@ -2,8 +2,8 @@ namespace GraphQL\Utils; use GraphQL\Language\Printer; +use GraphQL\Type\Introspection; use GraphQL\Type\Schema; -use GraphQL\Type\Definition\CompositeType; use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InterfaceType; @@ -31,10 +31,12 @@ public static function doPrint(Schema $schema, array $options = []) { return self::printFilteredSchema( $schema, - function($n) { - return !self::isSpecDirective($n); + function($type) { + return !Directive::isSpecifiedDirective($type); + }, + function ($type) { + return !Type::isBuiltInType($type); }, - 'self::isDefinedType', $options ); } @@ -48,51 +50,20 @@ public static function printIntrosepctionSchema(Schema $schema, array $options = { return self::printFilteredSchema( $schema, - [__CLASS__, 'isSpecDirective'], - [__CLASS__, 'isIntrospectionType'], + [Directive::class, 'isSpecifiedDirective'], + [Introspection::class, 'isIntrospectionType'], $options ); } - private static function isSpecDirective($directiveName) - { - return ( - $directiveName === 'skip' || - $directiveName === 'include' || - $directiveName === 'deprecated' - ); - } - - private static function isDefinedType($typename) - { - return !self::isIntrospectionType($typename) && !self::isBuiltInScalar($typename); - } - - private static function isIntrospectionType($typename) - { - return strpos($typename, '__') === 0; - } - - private static function isBuiltInScalar($typename) - { - return ( - $typename === Type::STRING || - $typename === Type::BOOLEAN || - $typename === Type::INT || - $typename === Type::FLOAT || - $typename === Type::ID - ); - } - private static function printFilteredSchema(Schema $schema, $directiveFilter, $typeFilter, $options) { $directives = array_filter($schema->getDirectives(), function($directive) use ($directiveFilter) { - return $directiveFilter($directive->name); + return $directiveFilter($directive); }); - $typeMap = $schema->getTypeMap(); - $types = array_filter(array_keys($typeMap), $typeFilter); - sort($types); - $types = array_map(function($typeName) use ($typeMap) { return $typeMap[$typeName]; }, $types); + $types = $schema->getTypeMap(); + ksort($types); + $types = array_filter($types, $typeFilter); return implode("\n\n", array_filter(array_merge( [self::printSchemaDefinition($schema)], @@ -364,4 +335,4 @@ private static function breakLine($line, $maxLen) return trim($part); }, $parts); } -} \ No newline at end of file +} From d6add77540d3133fe4829da6fc863c5cd48efbe2 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Fri, 9 Feb 2018 17:19:50 +0100 Subject: [PATCH 17/50] Add Docs --- UPGRADE.md | 47 ++++++++++++++++++++++++++- docs/reference.md | 54 +++++++++++++++++++++++++------ docs/type-system/type-language.md | 31 ++---------------- 3 files changed, 94 insertions(+), 38 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 50b451887..8126010b7 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,4 +1,49 @@ -## Upgrade v0.10.x > dev-master +## Upgrade v0.11.x > dev-master + +### Breaking: Descriptions in comments are not used as descriptions by default anymore +Descriptions now need to be inside Strings or BlockStrings in order to be picked up as +description. If you want to keep the old behaviour you can supply the option `commentDescriptions` +to BuildSchema::buildAST(), BuildSchema::build() or Printer::doPrint(). + +Here is the official way now to define descriptions in the graphQL language: + +Old: + +```graphql +# Description +type Dog { + ... +} +``` + +New: + +```graphql +"Description" +type Dog { + ... +} + +""" +Long Description +""" +type Dog { + ... +} +``` + +### Breaking: Custom types need to return `Utils::undefined()` or throw on invalid value +As null might be a valid value custom types need to return now `Utils::undefined()` or throw an +Exception inside `parseLiteral()`, `parseValue()` and `serialize()`. + +Returning null from any of these methods will now be treated as valid result. + +### Breaking: TypeConfigDecorator was removed from BuildSchema +TypeConfigDecorator was used as second argument in `BuildSchema::build()` and `BuildSchema::buildAST()` to +enable generated schemas with Unions or Interfaces to be used for resolving. This was fixed in a more +generalised approach so that the TypeConfigDecorator is not needed anymore and can be removed. + +The concrete Types are now resolved based on the `__typename` field. ### Possibly Breaking: AST to array serialization excludes nulls Most users won't be affected. It *may* affect you only if you do your own manipulations diff --git a/docs/reference.md b/docs/reference.md index 7cdc37fca..aa033dc9e 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1352,7 +1352,7 @@ static function setWarningHandler(callable $warningHandler = null) * @api * @param bool|int $suppress */ -static function suppress($suppress = false) +static function suppress($suppress = true) ``` ```php @@ -1367,7 +1367,7 @@ static function suppress($suppress = false) * @api * @param bool|int $enable */ -static function enable($enable = false) +static function enable($enable = true) ``` # GraphQL\Error\ClientAware This interface is used for [default error formatting](error-handling.md). @@ -1697,7 +1697,7 @@ function setPersistentQueryLoader(callable $persistentQueryLoader) * @param bool|int $set * @return $this */ -function setDebug($set = false) +function setDebug($set = true) ``` ```php @@ -1927,13 +1927,19 @@ See [section in docs](type-system/type-language.md) for details. * Given that AST it constructs a GraphQL\Type\Schema. The resulting schema * has no resolve methods, so execution will use default resolvers. * + * Accepts options as a second argument: + * + * - commentDescriptions: + * Provide true to use preceding comments as the description. + * + * * @api * @param DocumentNode $ast - * @param callable $typeConfigDecorator + * @param array $options * @return Schema * @throws Error */ -static function buildAST(GraphQL\Language\AST\DocumentNode $ast, callable $typeConfigDecorator = null) +static function buildAST(GraphQL\Language\AST\DocumentNode $ast, array $options = []) ``` ```php @@ -1943,10 +1949,10 @@ static function buildAST(GraphQL\Language\AST\DocumentNode $ast, callable $typeC * * @api * @param DocumentNode|Source|string $source - * @param callable $typeConfigDecorator + * @param array $options * @return Schema */ -static function build($source, callable $typeConfigDecorator = null) +static function build($source, array $options = []) ``` # GraphQL\Utils\AST Various utilities dealing with AST @@ -2049,6 +2055,32 @@ static function astFromValue($value, GraphQL\Type\Definition\InputType $type) static function valueFromAST($valueNode, GraphQL\Type\Definition\InputType $type, $variables = null) ``` +```php +/** + * Produces a PHP value given a GraphQL Value AST. + * + * Unlike `valueFromAST()`, no type is provided. The resulting JavaScript value + * will reflect the provided GraphQL value AST. + * + * | GraphQL Value | PHP Value | + * | -------------------- | ------------- | + * | Input Object | Assoc Array | + * | List | Array | + * | Boolean | Boolean | + * | String | String | + * | Int / Float | Int / Float | + * | Enum | Mixed | + * | Null | null | + * + * @api + * @param Node $valueNode + * @param array|null $variables + * @return mixed + * @throws \Exception + */ +static function valueFromASTUntyped($valueNode, array $variables = null) +``` + ```php /** * Returns type definition for given AST Type node @@ -2079,11 +2111,15 @@ Given an instance of Schema, prints it in GraphQL type language. **Class Methods:** ```php /** + * Accepts options as a second argument: + * + * - commentDescriptions: + * Provide true to use preceding comments as the description. * @api * @param Schema $schema * @return string */ -static function doPrint(GraphQL\Type\Schema $schema) +static function doPrint(GraphQL\Type\Schema $schema, array $options = []) ``` ```php @@ -2092,5 +2128,5 @@ static function doPrint(GraphQL\Type\Schema $schema) * @param Schema $schema * @return string */ -static function printIntrosepctionSchema(GraphQL\Type\Schema $schema) +static function printIntrosepctionSchema(GraphQL\Type\Schema $schema, array $options = []) ``` diff --git a/docs/type-system/type-language.md b/docs/type-system/type-language.md index bf00dff54..57c99601f 100644 --- a/docs/type-system/type-language.md +++ b/docs/type-system/type-language.md @@ -33,36 +33,11 @@ $contents = file_get_contents('schema.graphql'); $schema = BuildSchema::build($contents); ``` -By default, such schema is created without any resolvers. As a result, it doesn't support **Interfaces** and **Unions** -because it is impossible to resolve actual implementations during execution. +By default, such schema is created without any resolvers. -Also, we have to rely on [default field resolver](../data-fetching.md#default-field-resolver) and **root value** in +We have to rely on [default field resolver](../data-fetching.md#default-field-resolver) and **root value** in order to execute a query against this schema. -# Defining resolvers -Since 0.10.0 - -In order to enable **Interfaces**, **Unions** and custom field resolvers you can pass the second argument: -**type config decorator** to schema builder. - -It accepts default type config produced by the builder and is expected to add missing options like -[**resolveType**](interfaces.md#configuration-options) for interface types or -[**resolveField**](object-types.md#configuration-options) for object types. - -```php - Date: Sun, 11 Feb 2018 13:15:51 +0100 Subject: [PATCH 18/50] Fix KnownDirectives validator to support all directives --- src/Validator/DocumentValidator.php | 5 +- src/Validator/Rules/KnownDirectives.php | 23 +++- tests/Language/VisitorTest.php | 4 +- .../Validator/ArgumentsOfCorrectTypeTest.php | 2 +- tests/Validator/KnownDirectivesTest.php | 101 +++++++++++++++++- .../OverlappingFieldsCanBeMergedTest.php | 32 +++--- tests/Validator/QuerySecuritySchema.php | 2 +- tests/Validator/TestCase.php | 101 +++++++++++++++--- tests/Validator/ValidationTest.php | 2 +- 9 files changed, 221 insertions(+), 51 deletions(-) diff --git a/src/Validator/DocumentValidator.php b/src/Validator/DocumentValidator.php index 1a1e83cc8..1750c84a8 100644 --- a/src/Validator/DocumentValidator.php +++ b/src/Validator/DocumentValidator.php @@ -266,9 +266,9 @@ public static function isValidLiteralValue(Type $type, $valueNode) } } return $errors; - } else { - return static::isValidLiteralValue($itemType, $valueNode); } + + return static::isValidLiteralValue($itemType, $valueNode); } // Input objects check each defined field and look for undefined fields. @@ -278,6 +278,7 @@ public static function isValidLiteralValue(Type $type, $valueNode) } $fields = $type->getFields(); + $errors = []; // Ensure every provided field is defined. diff --git a/src/Validator/Rules/KnownDirectives.php b/src/Validator/Rules/KnownDirectives.php index 3593f6236..3043cb60c 100644 --- a/src/Validator/Rules/KnownDirectives.php +++ b/src/Validator/Rules/KnownDirectives.php @@ -1,10 +1,9 @@ getLocationForAppliedNode($appliedTo); + $candidateLocation = $this->getDirectiveLocationForASTPath($ancestors); if (!$candidateLocation) { $context->reportError(new Error( @@ -58,8 +56,9 @@ public function getVisitor(ValidationContext $context) ]; } - private function getLocationForAppliedNode(Node $appliedTo) + private function getDirectiveLocationForASTPath(array $ancestors) { + $appliedTo = $ancestors[count($ancestors) - 1]; switch ($appliedTo->kind) { case NodeKind::OPERATION_DEFINITION: switch ($appliedTo->operation) { @@ -72,6 +71,20 @@ private function getLocationForAppliedNode(Node $appliedTo) case NodeKind::FRAGMENT_SPREAD: return DirectiveLocation::FRAGMENT_SPREAD; case NodeKind::INLINE_FRAGMENT: return DirectiveLocation::INLINE_FRAGMENT; case NodeKind::FRAGMENT_DEFINITION: return DirectiveLocation::FRAGMENT_DEFINITION; + case NodeKind::SCHEMA_DEFINITION: return DirectiveLocation::SCHEMA; + case NodeKind::SCALAR_TYPE_DEFINITION: return DirectiveLocation::SCALAR; + case NodeKind::OBJECT_TYPE_DEFINITION: return DirectiveLocation::OBJECT; + case NodeKind::FIELD_DEFINITION: return DirectiveLocation::FIELD_DEFINITION; + case NodeKind::INTERFACE_TYPE_DEFINITION: return DirectiveLocation::IFACE; + case NodeKind::UNION_TYPE_DEFINITION: return DirectiveLocation::UNION; + case NodeKind::ENUM_TYPE_DEFINITION: return DirectiveLocation::ENUM; + case NodeKind::ENUM_VALUE_DEFINITION: return DirectiveLocation::ENUM_VALUE; + case NodeKind::INPUT_OBJECT_TYPE_DEFINITION: return DirectiveLocation::INPUT_OBJECT; + case NodeKind::INPUT_VALUE_DEFINITION: + $parentNode = $ancestors[count($ancestors) - 3]; + return $parentNode instanceof InputObjectTypeDefinitionNode + ? DirectiveLocation::INPUT_FIELD_DEFINITION + : DirectiveLocation::ARGUMENT_DEFINITION; } } } diff --git a/tests/Language/VisitorTest.php b/tests/Language/VisitorTest.php index c65141eea..5cfd0b192 100644 --- a/tests/Language/VisitorTest.php +++ b/tests/Language/VisitorTest.php @@ -1131,7 +1131,7 @@ public function testMaintainsTypeInfoDuringVisit() { $visited = []; - $typeInfo = new TypeInfo(TestCase::getDefaultSchema()); + $typeInfo = new TypeInfo(TestCase::getTestSchema()); $ast = Parser::parse('{ human(id: 4) { name, pets { ... { name } }, unknown } }'); Visitor::visit($ast, Visitor::visitWithTypeInfo($typeInfo, [ @@ -1213,7 +1213,7 @@ public function testMaintainsTypeInfoDuringVisit() public function testMaintainsTypeInfoDuringEdit() { $visited = []; - $typeInfo = new TypeInfo(TestCase::getDefaultSchema()); + $typeInfo = new TypeInfo(TestCase::getTestSchema()); $ast = Parser::parse( '{ human(id: 4) { name, pets }, alien }' diff --git a/tests/Validator/ArgumentsOfCorrectTypeTest.php b/tests/Validator/ArgumentsOfCorrectTypeTest.php index 5aefb8cc5..1f1c9dfbb 100644 --- a/tests/Validator/ArgumentsOfCorrectTypeTest.php +++ b/tests/Validator/ArgumentsOfCorrectTypeTest.php @@ -615,7 +615,7 @@ public function testGoodListValue() $this->expectPassesRule(new ArgumentsOfCorrectType(), ' { complicatedArgs { - stringListArgField(stringListArg: ["one", "two"]) + stringListArgField(stringListArg: ["one", null, "two"]) } } '); diff --git a/tests/Validator/KnownDirectivesTest.php b/tests/Validator/KnownDirectivesTest.php index 50fe21509..d581d1c9e 100644 --- a/tests/Validator/KnownDirectivesTest.php +++ b/tests/Validator/KnownDirectivesTest.php @@ -89,12 +89,16 @@ public function testWithManyUnknownDirectives() public function testWithWellPlacedDirectives() { $this->expectPassesRule(new KnownDirectives, ' - query Foo { + query Foo @onQuery { name @include(if: true) ...Frag @include(if: true) skippedField @skip(if: true) ...SkippedFrag @skip(if: true) } + + mutation Bar @onMutation { + someField + } '); } @@ -105,16 +109,103 @@ public function testWithMisplacedDirectives() { $this->expectFailsRule(new KnownDirectives, ' query Foo @include(if: true) { - name @operationOnly - ...Frag @operationOnly + name @onQuery + ...Frag @onQuery + } + + mutation Bar @onQuery { + someField } ', [ $this->misplacedDirective('include', 'QUERY', 2, 17), - $this->misplacedDirective('operationOnly', 'FIELD', 3, 14), - $this->misplacedDirective('operationOnly', 'FRAGMENT_SPREAD', 4, 17), + $this->misplacedDirective('onQuery', 'FIELD', 3, 14), + $this->misplacedDirective('onQuery', 'FRAGMENT_SPREAD', 4, 17), + $this->misplacedDirective('onQuery', 'MUTATION', 7, 20), ]); } + // within schema language + + /** + * @it with well placed directives + */ + public function testWSLWithWellPlacedDirectives() + { + $this->expectPassesRule(new KnownDirectives, ' + type MyObj implements MyInterface @onObject { + myField(myArg: Int @onArgumentDefinition): String @onFieldDefinition + } + + scalar MyScalar @onScalar + + interface MyInterface @onInterface { + myField(myArg: Int @onArgumentDefinition): String @onFieldDefinition + } + + union MyUnion @onUnion = MyObj | Other + + enum MyEnum @onEnum { + MY_VALUE @onEnumValue + } + + input MyInput @onInputObject { + myField: Int @onInputFieldDefinition + } + + schema @onSchema { + query: MyQuery + } + '); + } + + /** + * @it with misplaced directives + */ + public function testWSLWithMisplacedDirectives() + { + $this->expectFailsRule(new KnownDirectives, ' + type MyObj implements MyInterface @onInterface { + myField(myArg: Int @onInputFieldDefinition): String @onInputFieldDefinition + } + + scalar MyScalar @onEnum + + interface MyInterface @onObject { + myField(myArg: Int @onInputFieldDefinition): String @onInputFieldDefinition + } + + union MyUnion @onEnumValue = MyObj | Other + + enum MyEnum @onScalar { + MY_VALUE @onUnion + } + + input MyInput @onEnum { + myField: Int @onArgumentDefinition + } + + schema @onObject { + query: MyQuery + } + ', + [ + $this->misplacedDirective('onInterface', 'OBJECT', 2, 43), + $this->misplacedDirective('onInputFieldDefinition', 'ARGUMENT_DEFINITION', 3, 30), + $this->misplacedDirective('onInputFieldDefinition', 'FIELD_DEFINITION', 3, 63), + $this->misplacedDirective('onEnum', 'SCALAR', 6, 25), + $this->misplacedDirective('onObject', 'INTERFACE', 8, 31), + $this->misplacedDirective('onInputFieldDefinition', 'ARGUMENT_DEFINITION', 9, 30), + $this->misplacedDirective('onInputFieldDefinition', 'FIELD_DEFINITION', 9, 63), + $this->misplacedDirective('onEnumValue', 'UNION', 12, 23), + $this->misplacedDirective('onScalar', 'ENUM', 14, 21), + $this->misplacedDirective('onUnion', 'ENUM_VALUE', 15, 20), + $this->misplacedDirective('onEnum', 'INPUT_OBJECT', 18, 23), + $this->misplacedDirective('onArgumentDefinition', 'INPUT_FIELD_DEFINITION', 19, 24), + $this->misplacedDirective('onObject', 'SCHEMA', 22, 16), + ] + ); + } + private function unknownDirective($directiveName, $line, $column) { return FormattedError::create( diff --git a/tests/Validator/OverlappingFieldsCanBeMergedTest.php b/tests/Validator/OverlappingFieldsCanBeMergedTest.php index c9900e74b..4c659603e 100644 --- a/tests/Validator/OverlappingFieldsCanBeMergedTest.php +++ b/tests/Validator/OverlappingFieldsCanBeMergedTest.php @@ -2,13 +2,11 @@ namespace GraphQL\Tests\Validator; use GraphQL\Error\FormattedError; -use GraphQL\Language\Source; use GraphQL\Language\SourceLocation; -use GraphQL\Schema; +use GraphQL\Type\Schema; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; -use GraphQL\Type\Definition\UnionType; use GraphQL\Validator\Rules\OverlappingFieldsCanBeMerged; class OverlappingFieldsCanBeMergedTest extends TestCase @@ -445,7 +443,7 @@ public function testConflictingReturnTypesWhichPotentiallyOverlap() // type IntBox and the interface type NonNullStringBox1. While that // condition does not exist in the current schema, the schema could // expand in the future to allow this. Thus it is invalid. - $this->expectFailsRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectFailsRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ...on IntBox { @@ -476,7 +474,7 @@ public function testCompatibleReturnShapesOnDifferentReturnTypes() // In this case `deepBox` returns `SomeBox` in the first usage, and // `StringBox` in the second usage. These return types are not the same! // however this is valid because the return *shapes* are compatible. - $this->expectPassesRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectPassesRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ... on SomeBox { @@ -499,7 +497,7 @@ public function testCompatibleReturnShapesOnDifferentReturnTypes() */ public function testDisallowsDifferingReturnTypesDespiteNoOverlap() { - $this->expectFailsRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectFailsRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ... on IntBox { @@ -527,7 +525,7 @@ public function testDisallowsDifferingReturnTypesDespiteNoOverlap() */ public function testDisallowsDifferingReturnTypeNullabilityDespiteNoOverlap() { - $this->expectFailsRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectFailsRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ... on NonNullStringBox1 { @@ -555,7 +553,7 @@ public function testDisallowsDifferingReturnTypeNullabilityDespiteNoOverlap() */ public function testDisallowsDifferingReturnTypeListDespiteNoOverlap() { - $this->expectFailsRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectFailsRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ... on IntBox { @@ -582,7 +580,7 @@ public function testDisallowsDifferingReturnTypeListDespiteNoOverlap() ]); - $this->expectFailsRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectFailsRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ... on IntBox { @@ -611,7 +609,7 @@ public function testDisallowsDifferingReturnTypeListDespiteNoOverlap() public function testDisallowsDifferingSubfields() { - $this->expectFailsRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectFailsRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ... on IntBox { @@ -645,7 +643,7 @@ public function testDisallowsDifferingSubfields() */ public function testDisallowsDifferingDeepReturnTypesDespiteNoOverlap() { - $this->expectFailsRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectFailsRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ... on IntBox { @@ -681,7 +679,7 @@ public function testDisallowsDifferingDeepReturnTypesDespiteNoOverlap() */ public function testAllowsNonConflictingOverlapingTypes() { - $this->expectPassesRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectPassesRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ... on IntBox { @@ -700,7 +698,7 @@ public function testAllowsNonConflictingOverlapingTypes() */ public function testSameWrappedScalarReturnTypes() { - $this->expectPassesRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectPassesRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ...on NonNullStringBox1 { @@ -719,7 +717,7 @@ public function testSameWrappedScalarReturnTypes() */ public function testAllowsInlineTypelessFragments() { - $this->expectPassesRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectPassesRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { a ... { @@ -734,7 +732,7 @@ public function testAllowsInlineTypelessFragments() */ public function testComparesDeepTypesIncludingList() { - $this->expectFailsRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectFailsRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { connection { ...edgeID @@ -773,7 +771,7 @@ public function testComparesDeepTypesIncludingList() */ public function testIgnoresUnknownTypes() { - $this->expectPassesRuleWithSchema($this->getTestSchema(), new OverlappingFieldsCanBeMerged, ' + $this->expectPassesRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' { someBox { ...on UnknownType { @@ -787,7 +785,7 @@ public function testIgnoresUnknownTypes() '); } - private function getTestSchema() + private function getSchema() { $StringBox = null; $IntBox = null; diff --git a/tests/Validator/QuerySecuritySchema.php b/tests/Validator/QuerySecuritySchema.php index 771af77e2..024990af3 100644 --- a/tests/Validator/QuerySecuritySchema.php +++ b/tests/Validator/QuerySecuritySchema.php @@ -1,7 +1,7 @@ ['type' => $CatOrDog], 'dogOrHuman' => ['type' => $DogOrHuman], 'humanOrAlien' => ['type' => $HumanOrAlien], - 'complicatedArgs' => ['type' => $ComplicatedArgs] + 'complicatedArgs' => ['type' => $ComplicatedArgs], ] ]); - $defaultSchema = new Schema([ + $testSchema = new Schema([ 'query' => $queryRoot, - 'directives' => array_merge(GraphQL::getInternalDirectives(), [ + 'directives' => [ + Directive::includeDirective(), + Directive::skipDirective(), new Directive([ - 'name' => 'operationOnly', - 'locations' => [ 'QUERY' ], - ]) - ]) + 'name' => 'onQuery', + 'locations' => ['QUERY'], + ]), + new Directive([ + 'name' => 'onMutation', + 'locations' => ['MUTATION'], + ]), + new Directive([ + 'name' => 'onSubscription', + 'locations' => ['SUBSCRIPTION'], + ]), + new Directive([ + 'name' => 'onField', + 'locations' => ['FIELD'], + ]), + new Directive([ + 'name' => 'onFragmentDefinition', + 'locations' => ['FRAGMENT_DEFINITION'], + ]), + new Directive([ + 'name' => 'onFragmentSpread', + 'locations' => ['FRAGMENT_SPREAD'], + ]), + new Directive([ + 'name' => 'onInlineFragment', + 'locations' => ['INLINE_FRAGMENT'], + ]), + new Directive([ + 'name' => 'onSchema', + 'locations' => ['SCHEMA'], + ]), + new Directive([ + 'name' => 'onScalar', + 'locations' => ['SCALAR'], + ]), + new Directive([ + 'name' => 'onObject', + 'locations' => ['OBJECT'], + ]), + new Directive([ + 'name' => 'onFieldDefinition', + 'locations' => ['FIELD_DEFINITION'], + ]), + new Directive([ + 'name' => 'onArgumentDefinition', + 'locations' => ['ARGUMENT_DEFINITION'], + ]), + new Directive([ + 'name' => 'onInterface', + 'locations' => ['INTERFACE'], + ]), + new Directive([ + 'name' => 'onUnion', + 'locations' => ['UNION'], + ]), + new Directive([ + 'name' => 'onEnum', + 'locations' => ['ENUM'], + ]), + new Directive([ + 'name' => 'onEnumValue', + 'locations' => ['ENUM_VALUE'], + ]), + new Directive([ + 'name' => 'onInputObject', + 'locations' => ['INPUT_OBJECT'], + ]), + new Directive([ + 'name' => 'onInputFieldDefinition', + 'locations' => ['INPUT_FIELD_DEFINITION'], + ]), + ], ]); - return $defaultSchema; + return $testSchema; } function expectValid($schema, $rules, $queryString) @@ -313,12 +380,12 @@ function expectInvalid($schema, $rules, $queryString, $expectedErrors) function expectPassesRule($rule, $queryString) { - $this->expectValid($this->getDefaultSchema(), [$rule], $queryString); + $this->expectValid($this->getTestSchema(), [$rule], $queryString); } function expectFailsRule($rule, $queryString, $errors) { - return $this->expectInvalid($this->getDefaultSchema(), [$rule], $queryString, $errors); + return $this->expectInvalid($this->getTestSchema(), [$rule], $queryString, $errors); } function expectPassesRuleWithSchema($schema, $rule, $queryString) @@ -333,11 +400,11 @@ function expectFailsRuleWithSchema($schema, $rule, $queryString, $errors) function expectPassesCompleteValidation($queryString) { - $this->expectValid($this->getDefaultSchema(), DocumentValidator::allRules(), $queryString); + $this->expectValid($this->getTestSchema(), DocumentValidator::allRules(), $queryString); } function expectFailsCompleteValidation($queryString, $errors) { - $this->expectInvalid($this->getDefaultSchema(), DocumentValidator::allRules(), $queryString, $errors); + $this->expectInvalid($this->getTestSchema(), DocumentValidator::allRules(), $queryString, $errors); } } diff --git a/tests/Validator/ValidationTest.php b/tests/Validator/ValidationTest.php index d5fd855cf..a105bb2cd 100644 --- a/tests/Validator/ValidationTest.php +++ b/tests/Validator/ValidationTest.php @@ -45,6 +45,6 @@ public function testPassesValidationWithEmptyRules() 'locations' => [ ['line' => 1, 'column' => 2] ] ]; $this->expectFailsCompleteValidation($query, [$expectedError]); - $this->expectValid($this->getDefaultSchema(), [], $query); + $this->expectValid($this->getTestSchema(), [], $query); } } From d70a9a5e538ce0028bd50b27501ba979071b2761 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Sun, 11 Feb 2018 13:27:26 +0100 Subject: [PATCH 19/50] Update to match SDL changes This changes the parsing grammar and validation rules to more correctly implement the current state of the GraphQL SDL proposal (facebook/graphql#90) ref: graphql/graphl-js#1102 --- docs/reference.md | 20 +-- docs/type-system/directives.md | 2 +- src/Executor/Executor.php | 5 - src/Language/AST/NodeKind.php | 4 +- src/Language/AST/ObjectTypeExtensionNode.php | 30 ++++ .../AST/TypeExtensionDefinitionNode.php | 15 -- src/Language/AST/TypeExtensionNode.php | 10 ++ src/Language/AST/TypeSystemDefinitionNode.php | 5 +- src/Language/DirectiveLocation.php | 60 +++++++ src/Language/Parser.php | 170 +++++++++++++----- src/Language/Printer.php | 14 +- src/Language/Visitor.php | 2 +- src/Type/Definition/Directive.php | 30 +--- src/Type/Definition/DirectiveLocation.php | 27 --- src/Type/Definition/ObjectType.php | 4 +- src/Type/Introspection.php | 5 +- src/Utils/ASTDefinitionBuilder.php | 1 - src/Validator/DocumentValidator.php | 2 + src/Validator/Rules/ExecutableDefinitions.php | 47 +++++ src/Validator/Rules/KnownDirectives.php | 7 +- tests/Executor/ExecutorTest.php | 15 +- tests/Language/SchemaParserTest.php | 119 +++++++++--- tests/Language/SchemaPrinterTest.php | 4 +- tests/Language/schema-kitchen-sink.graphql | 4 +- tests/Utils/BuildSchemaTest.php | 4 +- tests/Validator/ExecutableDefinitionsTest.php | 79 ++++++++ tests/Validator/KnownDirectivesTest.php | 2 + tests/Validator/TestCase.php | 27 +++ tools/gendocs.php | 2 +- 29 files changed, 522 insertions(+), 194 deletions(-) create mode 100644 src/Language/AST/ObjectTypeExtensionNode.php delete mode 100644 src/Language/AST/TypeExtensionDefinitionNode.php create mode 100644 src/Language/AST/TypeExtensionNode.php create mode 100644 src/Language/DirectiveLocation.php delete mode 100644 src/Type/Definition/DirectiveLocation.php create mode 100644 src/Validator/Rules/ExecutableDefinitions.php create mode 100644 tests/Validator/ExecutableDefinitionsTest.php diff --git a/docs/reference.md b/docs/reference.md index aa033dc9e..1f86412e8 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -374,28 +374,28 @@ public $variableValues; */ function getFieldSelection($depth = 0) ``` -# GraphQL\Type\Definition\DirectiveLocation +# GraphQL\Language\DirectiveLocation List of available directive locations **Class Constants:** ```php -const IFACE = "INTERFACE"; -const SUBSCRIPTION = "SUBSCRIPTION"; -const FRAGMENT_SPREAD = "FRAGMENT_SPREAD"; const QUERY = "QUERY"; const MUTATION = "MUTATION"; +const SUBSCRIPTION = "SUBSCRIPTION"; +const FIELD = "FIELD"; const FRAGMENT_DEFINITION = "FRAGMENT_DEFINITION"; -const INPUT_OBJECT = "INPUT_OBJECT"; +const FRAGMENT_SPREAD = "FRAGMENT_SPREAD"; const INLINE_FRAGMENT = "INLINE_FRAGMENT"; -const UNION = "UNION"; +const SCHEMA = "SCHEMA"; const SCALAR = "SCALAR"; +const OBJECT = "OBJECT"; const FIELD_DEFINITION = "FIELD_DEFINITION"; const ARGUMENT_DEFINITION = "ARGUMENT_DEFINITION"; +const IFACE = "INTERFACE"; +const UNION = "UNION"; const ENUM = "ENUM"; -const OBJECT = "OBJECT"; const ENUM_VALUE = "ENUM_VALUE"; -const FIELD = "FIELD"; -const SCHEMA = "SCHEMA"; +const INPUT_OBJECT = "INPUT_OBJECT"; const INPUT_FIELD_DEFINITION = "INPUT_FIELD_DEFINITION"; ``` @@ -936,7 +936,7 @@ const UNION_TYPE_DEFINITION = "UnionTypeDefinition"; const ENUM_TYPE_DEFINITION = "EnumTypeDefinition"; const ENUM_VALUE_DEFINITION = "EnumValueDefinition"; const INPUT_OBJECT_TYPE_DEFINITION = "InputObjectTypeDefinition"; -const TYPE_EXTENSION_DEFINITION = "TypeExtensionDefinition"; +const OBJECT_TYPE_EXTENSION = "ObjectTypeExtension"; const DIRECTIVE_DEFINITION = "DirectiveDefinition"; ``` diff --git a/docs/type-system/directives.md b/docs/type-system/directives.md index 6b12588bf..454ce4c9e 100644 --- a/docs/type-system/directives.md +++ b/docs/type-system/directives.md @@ -35,9 +35,9 @@ In **graphql-php** custom directive is an instance of `GraphQL\Type\Definition\D ```php name->value] = $definition; break; - default: - throw new Error( - "GraphQL cannot execute a request containing a {$definition->kind}.", - [$definition] - ); } } diff --git a/src/Language/AST/NodeKind.php b/src/Language/AST/NodeKind.php index e79761863..22287c45d 100644 --- a/src/Language/AST/NodeKind.php +++ b/src/Language/AST/NodeKind.php @@ -65,7 +65,7 @@ class NodeKind // Type Extensions - const TYPE_EXTENSION_DEFINITION = 'TypeExtensionDefinition'; + const OBJECT_TYPE_EXTENSION = 'ObjectTypeExtension'; // Directive Definitions @@ -127,7 +127,7 @@ class NodeKind NodeKind::INPUT_OBJECT_TYPE_DEFINITION =>InputObjectTypeDefinitionNode::class, // Type Extensions - NodeKind::TYPE_EXTENSION_DEFINITION => TypeExtensionDefinitionNode::class, + NodeKind::OBJECT_TYPE_EXTENSION => ObjectTypeExtensionNode::class, // Directive Definitions NodeKind::DIRECTIVE_DEFINITION => DirectiveDefinitionNode::class diff --git a/src/Language/AST/ObjectTypeExtensionNode.php b/src/Language/AST/ObjectTypeExtensionNode.php new file mode 100644 index 000000000..c8eab0ae9 --- /dev/null +++ b/src/Language/AST/ObjectTypeExtensionNode.php @@ -0,0 +1,30 @@ + self::QUERY, + self::MUTATION => self::MUTATION, + self::SUBSCRIPTION => self::SUBSCRIPTION, + self::FIELD => self::FIELD, + self::FRAGMENT_DEFINITION => self::FRAGMENT_DEFINITION, + self::FRAGMENT_SPREAD => self::FRAGMENT_SPREAD, + self::INLINE_FRAGMENT => self::INLINE_FRAGMENT, + self::SCHEMA => self::SCHEMA, + self::SCALAR => self::SCALAR, + self::OBJECT => self::OBJECT, + self::FIELD_DEFINITION => self::FIELD_DEFINITION, + self::ARGUMENT_DEFINITION => self::ARGUMENT_DEFINITION, + self::IFACE => self::IFACE, + self::UNION => self::UNION, + self::ENUM => self::ENUM, + self::ENUM_VALUE => self::ENUM_VALUE, + self::INPUT_OBJECT => self::INPUT_OBJECT, + self::INPUT_FIELD_DEFINITION => self::INPUT_FIELD_DEFINITION, + ]; + + /** + * @param string $name + * @return bool + */ + public static function has($name) + { + return isset(self::$locations[$name]); + } +} diff --git a/src/Language/Parser.php b/src/Language/Parser.php index c0c139cd4..b5a16fc96 100644 --- a/src/Language/Parser.php +++ b/src/Language/Parser.php @@ -36,7 +36,8 @@ use GraphQL\Language\AST\SchemaDefinitionNode; use GraphQL\Language\AST\SelectionSetNode; use GraphQL\Language\AST\StringValueNode; -use GraphQL\Language\AST\TypeExtensionDefinitionNode; +use GraphQL\Language\AST\ObjectTypeExtensionNode; +use GraphQL\Language\AST\TypeExtensionNode; use GraphQL\Language\AST\TypeSystemDefinitionNode; use GraphQL\Language\AST\UnionTypeDefinitionNode; use GraphQL\Language\AST\VariableNode; @@ -393,7 +394,7 @@ function parseOperationDefinition() 'operation' => $operation, 'name' => $name, 'variableDefinitions' => $this->parseVariableDefinitions(), - 'directives' => $this->parseDirectives(), + 'directives' => $this->parseDirectives(false), 'selectionSet' => $this->parseSelectionSet(), 'loc' => $this->loc($start) ]); @@ -494,6 +495,7 @@ function parseSelection() /** * @return FieldNode + * @throws SyntaxError */ function parseField() { @@ -511,20 +513,23 @@ function parseField() return new FieldNode([ 'alias' => $alias, 'name' => $name, - 'arguments' => $this->parseArguments(), - 'directives' => $this->parseDirectives(), + 'arguments' => $this->parseArguments(false), + 'directives' => $this->parseDirectives(false), 'selectionSet' => $this->peek(Token::BRACE_L) ? $this->parseSelectionSet() : null, 'loc' => $this->loc($start) ]); } /** + * @param bool $isConst * @return ArgumentNode[]|NodeList + * @throws SyntaxError */ - function parseArguments() + function parseArguments($isConst) { + $item = $isConst ? 'parseConstArgument' : 'parseArgument'; return $this->peek(Token::PAREN_L) ? - $this->many(Token::PAREN_L, [$this, 'parseArgument'], Token::PAREN_R) : + $this->many(Token::PAREN_L, [$this, $item], Token::PAREN_R) : new NodeList([]); } @@ -547,6 +552,25 @@ function parseArgument() ]); } + /** + * @return ArgumentNode + * @throws SyntaxError + */ + function parseConstArgument() + { + $start = $this->lexer->token; + $name = $this->parseName(); + + $this->expect(Token::COLON); + $value = $this->parseConstValue(); + + return new ArgumentNode([ + 'name' => $name, + 'value' => $value, + 'loc' => $this->loc($start) + ]); + } + // Implements the parsing rules in the Fragments section. /** @@ -561,7 +585,7 @@ function parseFragment() if ($this->peek(Token::NAME) && $this->lexer->token->value !== 'on') { return new FragmentSpreadNode([ 'name' => $this->parseFragmentName(), - 'directives' => $this->parseDirectives(), + 'directives' => $this->parseDirectives(false), 'loc' => $this->loc($start) ]); } @@ -574,7 +598,7 @@ function parseFragment() return new InlineFragmentNode([ 'typeCondition' => $typeCondition, - 'directives' => $this->parseDirectives(), + 'directives' => $this->parseDirectives(false), 'selectionSet' => $this->parseSelectionSet(), 'loc' => $this->loc($start) ]); @@ -596,7 +620,7 @@ function parseFragmentDefinition() return new FragmentDefinitionNode([ 'name' => $name, 'typeCondition' => $typeCondition, - 'directives' => $this->parseDirectives(), + 'directives' => $this->parseDirectives(false), 'selectionSet' => $this->parseSelectionSet(), 'loc' => $this->loc($start) ]); @@ -775,28 +799,31 @@ function parseObjectField($isConst) // Implements the parsing rules in the Directives section. /** + * @param bool $isConst * @return DirectiveNode[]|NodeList + * @throws SyntaxError */ - function parseDirectives() + function parseDirectives($isConst) { $directives = []; while ($this->peek(Token::AT)) { - $directives[] = $this->parseDirective(); + $directives[] = $this->parseDirective($isConst); } return new NodeList($directives); } /** + * @param bool $isConst * @return DirectiveNode * @throws SyntaxError */ - function parseDirective() + function parseDirective($isConst) { $start = $this->lexer->token; $this->expect(Token::AT); return new DirectiveNode([ 'name' => $this->parseName(), - 'arguments' => $this->parseArguments(), + 'arguments' => $this->parseArguments($isConst), 'loc' => $this->loc($start) ]); } @@ -849,7 +876,7 @@ function parseNamedType() * TypeSystemDefinition : * - SchemaDefinition * - TypeDefinition - * - TypeExtensionDefinition + * - TypeExtension * - DirectiveDefinition * * TypeDefinition : @@ -879,12 +906,12 @@ function parseTypeSystemDefinition() case 'union': return $this->parseUnionTypeDefinition(); case 'enum': return $this->parseEnumTypeDefinition(); case 'input': return $this->parseInputObjectTypeDefinition(); - case 'extend': return $this->parseTypeExtensionDefinition(); + case 'extend': return $this->parseTypeExtension(); case 'directive': return $this->parseDirectiveDefinition(); } } - throw $this->unexpected(); + throw $this->unexpected($keywordToken); } /** @@ -911,7 +938,7 @@ function parseSchemaDefinition() { $start = $this->lexer->token; $this->expectKeyword('schema'); - $directives = $this->parseDirectives(); + $directives = $this->parseDirectives(true); $operationTypes = $this->many( Token::BRACE_L, @@ -953,7 +980,7 @@ function parseScalarTypeDefinition() $description = $this->parseDescription(); $this->expectKeyword('scalar'); $name = $this->parseName(); - $directives = $this->parseDirectives(); + $directives = $this->parseDirectives(true); return new ScalarTypeDefinitionNode([ 'name' => $name, @@ -974,13 +1001,8 @@ function parseObjectTypeDefinition() $this->expectKeyword('type'); $name = $this->parseName(); $interfaces = $this->parseImplementsInterfaces(); - $directives = $this->parseDirectives(); - - $fields = $this->any( - Token::BRACE_L, - [$this, 'parseFieldDefinition'], - Token::BRACE_R - ); + $directives = $this->parseDirectives(true); + $fields = $this->parseFieldDefinitions(); return new ObjectTypeDefinitionNode([ 'name' => $name, @@ -1007,6 +1029,19 @@ function parseImplementsInterfaces() return $types; } + /** + * @return FieldDefinitionNode[]|NodeList + * @throws SyntaxError + */ + function parseFieldDefinitions() + { + return $this->many( + Token::BRACE_L, + [$this, 'parseFieldDefinition'], + Token::BRACE_R + ); + } + /** * @return FieldDefinitionNode * @throws SyntaxError @@ -1019,7 +1054,7 @@ function parseFieldDefinition() $args = $this->parseArgumentDefs(); $this->expect(Token::COLON); $type = $this->parseTypeReference(); - $directives = $this->parseDirectives(); + $directives = $this->parseDirectives(true); return new FieldDefinitionNode([ 'name' => $name, @@ -1057,7 +1092,7 @@ function parseInputValueDef() if ($this->skip(Token::EQUALS)) { $defaultValue = $this->parseConstValue(); } - $directives = $this->parseDirectives(); + $directives = $this->parseDirectives(true); return new InputValueDefinitionNode([ 'name' => $name, 'type' => $type, @@ -1078,12 +1113,8 @@ function parseInterfaceTypeDefinition() $description = $this->parseDescription(); $this->expectKeyword('interface'); $name = $this->parseName(); - $directives = $this->parseDirectives(); - $fields = $this->any( - Token::BRACE_L, - [$this, 'parseFieldDefinition'], - Token::BRACE_R - ); + $directives = $this->parseDirectives(true); + $fields = $this->parseFieldDefinitions(); return new InterfaceTypeDefinitionNode([ 'name' => $name, @@ -1104,7 +1135,7 @@ function parseUnionTypeDefinition() $description = $this->parseDescription(); $this->expectKeyword('union'); $name = $this->parseName(); - $directives = $this->parseDirectives(); + $directives = $this->parseDirectives(true); $this->expect(Token::EQUALS); $types = $this->parseUnionMembers(); @@ -1146,7 +1177,7 @@ function parseEnumTypeDefinition() $description = $this->parseDescription(); $this->expectKeyword('enum'); $name = $this->parseName(); - $directives = $this->parseDirectives(); + $directives = $this->parseDirectives(true); $values = $this->many( Token::BRACE_L, [$this, 'parseEnumValueDefinition'], @@ -1164,13 +1195,14 @@ function parseEnumTypeDefinition() /** * @return EnumValueDefinitionNode + * @throws SyntaxError */ function parseEnumValueDefinition() { $start = $this->lexer->token; $description = $this->parseDescription(); $name = $this->parseName(); - $directives = $this->parseDirectives(); + $directives = $this->parseDirectives(true); return new EnumValueDefinitionNode([ 'name' => $name, @@ -1190,8 +1222,8 @@ function parseInputObjectTypeDefinition() $description = $this->parseDescription(); $this->expectKeyword('input'); $name = $this->parseName(); - $directives = $this->parseDirectives(); - $fields = $this->any( + $directives = $this->parseDirectives(true); + $fields = $this->many( Token::BRACE_L, [$this, 'parseInputValueDef'], Token::BRACE_R @@ -1207,17 +1239,51 @@ function parseInputObjectTypeDefinition() } /** - * @return TypeExtensionDefinitionNode + * @return TypeExtensionNode * @throws SyntaxError */ - function parseTypeExtensionDefinition() + function parseTypeExtension() { + $keywordToken = $this->lexer->lookahead(); + + if ($keywordToken->kind === Token::NAME) { + switch ($keywordToken->value) { + case 'type': + return $this->parseObjectTypeExtension(); + } + } + + throw $this->unexpected($keywordToken); + } + + /** + * @return ObjectTypeExtensionNode + * @throws SyntaxError + */ + function parseObjectTypeExtension() { $start = $this->lexer->token; $this->expectKeyword('extend'); - $definition = $this->parseObjectTypeDefinition(); + $this->expectKeyword('type'); + $name = $this->parseName(); + $interfaces = $this->parseImplementsInterfaces(); + $directives = $this->parseDirectives(true); + $fields = $this->peek(Token::BRACE_L) + ? $this->parseFieldDefinitions() + : []; + + if ( + count($interfaces) === 0 && + count($directives) === 0 && + count($fields) === 0 + ) { + throw $this->unexpected(); + } - return new TypeExtensionDefinitionNode([ - 'definition' => $definition, + return new ObjectTypeExtensionNode([ + 'name' => $name, + 'interfaces' => $interfaces, + 'directives' => $directives, + 'fields' => $fields, 'loc' => $this->loc($start) ]); } @@ -1251,6 +1317,7 @@ function parseDirectiveDefinition() /** * @return NameNode[] + * @throws SyntaxError */ function parseDirectiveLocations() { @@ -1258,8 +1325,23 @@ function parseDirectiveLocations() $this->skip(Token::PIPE); $locations = []; do { - $locations[] = $this->parseName(); + $locations[] = $this->parseDirectiveLocation(); } while ($this->skip(Token::PIPE)); return $locations; } + + /** + * @return NameNode + * @throws SyntaxError + */ + function parseDirectiveLocation() + { + $start = $this->lexer->token; + $name = $this->parseName(); + if (DirectiveLocation::has($name->value)) { + return $name; + } + + throw $this->unexpected($start); + } } diff --git a/src/Language/Printer.php b/src/Language/Printer.php index 0e5566d49..b835b4d96 100644 --- a/src/Language/Printer.php +++ b/src/Language/Printer.php @@ -35,7 +35,7 @@ use GraphQL\Language\AST\SchemaDefinitionNode; use GraphQL\Language\AST\SelectionSetNode; use GraphQL\Language\AST\StringValueNode; -use GraphQL\Language\AST\TypeExtensionDefinitionNode; +use GraphQL\Language\AST\ObjectTypeExtensionNode; use GraphQL\Language\AST\UnionTypeDefinitionNode; use GraphQL\Language\AST\VariableDefinitionNode; use GraphQL\Utils\Utils; @@ -278,8 +278,14 @@ public function printAST($ast) ], ' ') ], "\n"); }, - NodeKind::TYPE_EXTENSION_DEFINITION => function(TypeExtensionDefinitionNode $def) { - return "extend {$def->definition}"; + NodeKind::OBJECT_TYPE_EXTENSION => function(ObjectTypeExtensionNode $def) { + return $this->join([ + 'extend type', + $def->name, + $this->wrap('implements ', $this->join($def->interfaces, ', ')), + $this->join($def->directives, ' '), + $this->block($def->fields), + ], ' '); }, NodeKind::DIRECTIVE_DEFINITION => function(DirectiveDefinitionNode $def) { return $this->join([ @@ -309,7 +315,7 @@ public function block($array) { return ($array && $this->length($array)) ? "{\n" . $this->indent($this->join($array, "\n")) . "\n}" - : '{}'; + : ''; } public function indent($maybeString) diff --git a/src/Language/Visitor.php b/src/Language/Visitor.php index 9ddca60c2..fb149cda6 100644 --- a/src/Language/Visitor.php +++ b/src/Language/Visitor.php @@ -142,7 +142,7 @@ class Visitor NodeKind::ENUM_TYPE_DEFINITION => ['description', 'name', 'directives', 'values'], NodeKind::ENUM_VALUE_DEFINITION => ['description', 'name', 'directives'], NodeKind::INPUT_OBJECT_TYPE_DEFINITION => ['description', 'name', 'directives', 'fields'], - NodeKind::TYPE_EXTENSION_DEFINITION => [ 'definition' ], + NodeKind::OBJECT_TYPE_EXTENSION => [ 'name', 'interfaces', 'directives', 'fields' ], NodeKind::DIRECTIVE_DEFINITION => ['description', 'name', 'arguments', 'locations'] ]; diff --git a/src/Type/Definition/Directive.php b/src/Type/Definition/Directive.php index 63ea075d3..fcf6c2a09 100644 --- a/src/Type/Definition/Directive.php +++ b/src/Type/Definition/Directive.php @@ -2,6 +2,7 @@ namespace GraphQL\Type\Definition; use GraphQL\Language\AST\DirectiveDefinitionNode; +use GraphQL\Language\DirectiveLocation; /** * Class Directive @@ -18,35 +19,6 @@ class Directive // Schema Definitions - - /** - * @var array - * @deprecated as of 8.0 (use DirectiveLocation constants directly) - */ - public static $directiveLocations = [ - // Operations: - DirectiveLocation::QUERY => DirectiveLocation::QUERY, - DirectiveLocation::MUTATION => DirectiveLocation::MUTATION, - DirectiveLocation::SUBSCRIPTION => DirectiveLocation::SUBSCRIPTION, - DirectiveLocation::FIELD => DirectiveLocation::FIELD, - DirectiveLocation::FRAGMENT_DEFINITION => DirectiveLocation::FRAGMENT_DEFINITION, - DirectiveLocation::FRAGMENT_SPREAD => DirectiveLocation::FRAGMENT_SPREAD, - DirectiveLocation::INLINE_FRAGMENT => DirectiveLocation::INLINE_FRAGMENT, - - // Schema Definitions - DirectiveLocation::SCHEMA => DirectiveLocation::SCHEMA, - DirectiveLocation::SCALAR => DirectiveLocation::SCALAR, - DirectiveLocation::OBJECT => DirectiveLocation::OBJECT, - DirectiveLocation::FIELD_DEFINITION => DirectiveLocation::FIELD_DEFINITION, - DirectiveLocation::ARGUMENT_DEFINITION => DirectiveLocation::ARGUMENT_DEFINITION, - DirectiveLocation::IFACE => DirectiveLocation::IFACE, - DirectiveLocation::UNION => DirectiveLocation::UNION, - DirectiveLocation::ENUM => DirectiveLocation::ENUM, - DirectiveLocation::ENUM_VALUE => DirectiveLocation::ENUM_VALUE, - DirectiveLocation::INPUT_OBJECT => DirectiveLocation::INPUT_OBJECT, - DirectiveLocation::INPUT_FIELD_DEFINITION => DirectiveLocation::INPUT_FIELD_DEFINITION - ]; - /** * @return Directive */ diff --git a/src/Type/Definition/DirectiveLocation.php b/src/Type/Definition/DirectiveLocation.php deleted file mode 100644 index fee4d7a4f..000000000 --- a/src/Type/Definition/DirectiveLocation.php +++ /dev/null @@ -1,27 +0,0 @@ - [ 'type' => Type::nonNull(self::_type()), - 'resolve' => function ($field) { + 'resolve' => function (FieldDefinition $field) { return $field->getType(); } ], diff --git a/src/Utils/ASTDefinitionBuilder.php b/src/Utils/ASTDefinitionBuilder.php index ed73dd52d..764979621 100644 --- a/src/Utils/ASTDefinitionBuilder.php +++ b/src/Utils/ASTDefinitionBuilder.php @@ -30,7 +30,6 @@ use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; use GraphQL\Type\Definition\UnionType; -use GraphQL\Type\Introspection; class ASTDefinitionBuilder { diff --git a/src/Validator/DocumentValidator.php b/src/Validator/DocumentValidator.php index 1750c84a8..2b6e2424a 100644 --- a/src/Validator/DocumentValidator.php +++ b/src/Validator/DocumentValidator.php @@ -22,6 +22,7 @@ use GraphQL\Validator\Rules\ArgumentsOfCorrectType; use GraphQL\Validator\Rules\DefaultValuesOfCorrectType; use GraphQL\Validator\Rules\DisableIntrospection; +use GraphQL\Validator\Rules\ExecutableDefinitions; use GraphQL\Validator\Rules\FieldsOnCorrectType; use GraphQL\Validator\Rules\FragmentsOnCompositeTypes; use GraphQL\Validator\Rules\KnownArgumentNames; @@ -122,6 +123,7 @@ public static function defaultRules() { if (null === self::$defaultRules) { self::$defaultRules = [ + ExecutableDefinitions::class => new ExecutableDefinitions(), UniqueOperationNames::class => new UniqueOperationNames(), LoneAnonymousOperation::class => new LoneAnonymousOperation(), KnownTypeNames::class => new KnownTypeNames(), diff --git a/src/Validator/Rules/ExecutableDefinitions.php b/src/Validator/Rules/ExecutableDefinitions.php new file mode 100644 index 000000000..f512d6dae --- /dev/null +++ b/src/Validator/Rules/ExecutableDefinitions.php @@ -0,0 +1,47 @@ + function (DocumentNode $node) use ($context) { + /** @var Node $definition */ + foreach ($node->definitions as $definition) { + if ( + !$definition instanceof OperationDefinitionNode && + !$definition instanceof FragmentDefinitionNode + ) { + $context->reportError(new Error( + self::nonExecutableDefinitionMessage($definition->name->value), + [$definition->name] + )); + } + } + + return Visitor::skipNode(); + } + ]; + } +} diff --git a/src/Validator/Rules/KnownDirectives.php b/src/Validator/Rules/KnownDirectives.php index 3043cb60c..9d48aa538 100644 --- a/src/Validator/Rules/KnownDirectives.php +++ b/src/Validator/Rules/KnownDirectives.php @@ -5,8 +5,8 @@ use GraphQL\Language\AST\DirectiveNode; use GraphQL\Language\AST\InputObjectTypeDefinitionNode; use GraphQL\Language\AST\NodeKind; +use GraphQL\Language\DirectiveLocation; use GraphQL\Validator\ValidationContext; -use GraphQL\Type\Definition\DirectiveLocation; class KnownDirectives extends AbstractValidationRule { @@ -37,7 +37,7 @@ public function getVisitor(ValidationContext $context) self::unknownDirectiveMessage($node->name->value), [$node] )); - return ; + return; } $candidateLocation = $this->getDirectiveLocationForASTPath($ancestors); @@ -73,7 +73,8 @@ private function getDirectiveLocationForASTPath(array $ancestors) case NodeKind::FRAGMENT_DEFINITION: return DirectiveLocation::FRAGMENT_DEFINITION; case NodeKind::SCHEMA_DEFINITION: return DirectiveLocation::SCHEMA; case NodeKind::SCALAR_TYPE_DEFINITION: return DirectiveLocation::SCALAR; - case NodeKind::OBJECT_TYPE_DEFINITION: return DirectiveLocation::OBJECT; + case NodeKind::OBJECT_TYPE_DEFINITION: + case NodeKind::OBJECT_TYPE_EXTENSION: return DirectiveLocation::OBJECT; case NodeKind::FIELD_DEFINITION: return DirectiveLocation::FIELD_DEFINITION; case NodeKind::INTERFACE_TYPE_DEFINITION: return DirectiveLocation::IFACE; case NodeKind::UNION_TYPE_DEFINITION: return DirectiveLocation::UNION; diff --git a/tests/Executor/ExecutorTest.php b/tests/Executor/ExecutorTest.php index c38045e43..bd6a16ddc 100644 --- a/tests/Executor/ExecutorTest.php +++ b/tests/Executor/ExecutorTest.php @@ -965,14 +965,14 @@ public function testFailsWhenAnIsTypeOfCheckIsNotMet() } /** - * @it fails to execute a query containing a type definition + * @it executes ignoring invalid non-executable definitions */ - public function testFailsToExecuteQueryContainingTypeDefinition() + public function testExecutesIgnoringInvalidNonExecutableDefinitions() { $query = Parser::parse(' { foo } - type Query { foo: String } + type Query { bar: String } '); $schema = new Schema([ @@ -988,12 +988,9 @@ public function testFailsToExecuteQueryContainingTypeDefinition() $result = Executor::execute($schema, $query); $expected = [ - 'errors' => [ - [ - 'message' => 'GraphQL cannot execute a request containing a ObjectTypeDefinition.', - 'locations' => [['line' => 4, 'column' => 7]], - ] - ] + 'data' => [ + 'foo' => null, + ], ]; $this->assertArraySubset($expected, $result->toArray()); diff --git a/tests/Language/SchemaParserTest.php b/tests/Language/SchemaParserTest.php index 81d8a3f5d..77e2e92c9 100644 --- a/tests/Language/SchemaParserTest.php +++ b/tests/Language/SchemaParserTest.php @@ -150,21 +150,16 @@ public function testSimpleExtension() 'kind' => NodeKind::DOCUMENT, 'definitions' => [ [ - 'kind' => NodeKind::TYPE_EXTENSION_DEFINITION, - 'definition' => [ - 'kind' => NodeKind::OBJECT_TYPE_DEFINITION, - 'name' => $this->nameNode('Hello', $loc(13, 18)), - 'interfaces' => [], - 'directives' => [], - 'fields' => [ - $this->fieldNode( - $this->nameNode('world', $loc(23, 28)), - $this->typeNode('String', $loc(30, 36)), - $loc(23, 36) - ) - ], - 'loc' => $loc(8, 38), - 'description' => null + 'kind' => NodeKind::OBJECT_TYPE_EXTENSION, + 'name' => $this->nameNode('Hello', $loc(13, 18)), + 'interfaces' => [], + 'directives' => [], + 'fields' => [ + $this->fieldNode( + $this->nameNode('world', $loc(23, 28)), + $this->typeNode('String', $loc(30, 36)), + $loc(23, 36) + ) ], 'loc' => $loc(1, 38) ] @@ -174,16 +169,59 @@ public function testSimpleExtension() $this->assertEquals($expected, TestUtils::nodeToArray($doc)); } + /** + * @it Extension without fields + */ + public function testExtensionWithoutFields() + { + $body = 'extend type Hello implements Greeting'; + $doc = Parser::parse($body); + $loc = function($start, $end) { + return TestUtils::locArray($start, $end); + }; + $expected = [ + 'kind' => NodeKind::DOCUMENT, + 'definitions' => [ + [ + 'kind' => NodeKind::OBJECT_TYPE_EXTENSION, + 'name' => $this->nameNode('Hello', $loc(12, 17)), + 'interfaces' => [ + $this->typeNode('Greeting', $loc(29, 37)), + ], + 'directives' => [], + 'fields' => [], + 'loc' => $loc(0, 37) + ] + ], + 'loc' => $loc(0, 37) + ]; + $this->assertEquals($expected, TestUtils::nodeToArray($doc)); + } + /** * @it Extension do not include descriptions * @expectedException \GraphQL\Error\SyntaxError - * @expectedExceptionMessage Syntax Error GraphQL (2:1) + * @expectedExceptionMessage Syntax Error GraphQL (3:7) */ public function testExtensionDoNotIncludeDescriptions() { $body = ' -"Description" -extend type Hello { - world: String + "Description" + extend type Hello { + world: String + }'; + Parser::parse($body); + } + + /** + * @it Extension do not include descriptions + * @expectedException \GraphQL\Error\SyntaxError + * @expectedExceptionMessage Syntax Error GraphQL (2:14) + */ + public function testExtensionDoNotIncludeDescriptions2() { + $body = ' + extend "Description" type Hello { + world: String + } }'; Parser::parse($body); } @@ -236,7 +274,7 @@ public function testSimpleNonNullType() */ public function testSimpleTypeInheritingInterface() { - $body = 'type Hello implements World { }'; + $body = 'type Hello implements World { field: String }'; $loc = function($start, $end) { return TestUtils::locArray($start, $end); }; $doc = Parser::parse($body); @@ -250,12 +288,18 @@ public function testSimpleTypeInheritingInterface() $this->typeNode('World', $loc(22, 27)) ], 'directives' => [], - 'fields' => [], - 'loc' => $loc(0,31), + 'fields' => [ + $this->fieldNode( + $this->nameNode('field', $loc(30, 35)), + $this->typeNode('String', $loc(37, 43)), + $loc(30, 43) + ) + ], + 'loc' => $loc(0, 45), 'description' => null ] ], - 'loc' => $loc(0,31) + 'loc' => $loc(0, 45) ]; $this->assertEquals($expected, TestUtils::nodeToArray($doc)); @@ -266,7 +310,7 @@ public function testSimpleTypeInheritingInterface() */ public function testSimpleTypeInheritingMultipleInterfaces() { - $body = 'type Hello implements Wo, rld { }'; + $body = 'type Hello implements Wo, rld { field: String }'; $loc = function($start, $end) {return TestUtils::locArray($start, $end);}; $doc = Parser::parse($body); @@ -281,12 +325,18 @@ public function testSimpleTypeInheritingMultipleInterfaces() $this->typeNode('rld', $loc(26,29)) ], 'directives' => [], - 'fields' => [], - 'loc' => $loc(0, 33), + 'fields' => [ + $this->fieldNode( + $this->nameNode('field', $loc(32, 37)), + $this->typeNode('String', $loc(39, 45)), + $loc(32, 45) + ) + ], + 'loc' => $loc(0, 47), 'description' => null ] ], - 'loc' => $loc(0, 33) + 'loc' => $loc(0, 47) ]; $this->assertEquals($expected, TestUtils::nodeToArray($doc)); @@ -754,6 +804,7 @@ public function testSimpleInputObject() /** * @it Simple input object with args should fail + * @expectedException \GraphQL\Error\SyntaxError */ public function testSimpleInputObjectWithArgsShouldFail() { @@ -761,7 +812,19 @@ public function testSimpleInputObjectWithArgsShouldFail() input Hello { world(foo: Int): String }'; - $this->setExpectedException('GraphQL\Error\SyntaxError'); + Parser::parse($body); + } + + /** + * @it Directive with incorrect locations + * @expectedException \GraphQL\Error\SyntaxError + * @expectedExceptionMessage Syntax Error GraphQL (2:33) Unexpected Name "INCORRECT_LOCATION" + */ + public function testDirectiveWithIncorrectLocationShouldFail() + { + $body = ' + directive @foo on FIELD | INCORRECT_LOCATION +'; Parser::parse($body); } diff --git a/tests/Language/SchemaPrinterTest.php b/tests/Language/SchemaPrinterTest.php index 3b9a09817..3141f0651 100644 --- a/tests/Language/SchemaPrinterTest.php +++ b/tests/Language/SchemaPrinterTest.php @@ -116,9 +116,7 @@ enum AnnotatedEnum @onEnum { seven(argument: [String]): Type } -extend type Foo @onType {} - -type NoFields {} +extend type Foo @onType directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT diff --git a/tests/Language/schema-kitchen-sink.graphql b/tests/Language/schema-kitchen-sink.graphql index 4b3fbaa15..016707ddc 100644 --- a/tests/Language/schema-kitchen-sink.graphql +++ b/tests/Language/schema-kitchen-sink.graphql @@ -68,9 +68,7 @@ extend type Foo { seven(argument: [String]): Type } -extend type Foo @onType {} - -type NoFields {} +extend type Foo @onType directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT diff --git a/tests/Utils/BuildSchemaTest.php b/tests/Utils/BuildSchemaTest.php index b77220aa2..6c4eaa674 100644 --- a/tests/Utils/BuildSchemaTest.php +++ b/tests/Utils/BuildSchemaTest.php @@ -1025,7 +1025,9 @@ public function testUnknownTypeInInterfaceList() query: Hello } -type Hello implements Bar { } +type Hello implements Bar { + field: String +} '; $doc = Parser::parse($body); $schema = BuildSchema::buildAST($doc); diff --git a/tests/Validator/ExecutableDefinitionsTest.php b/tests/Validator/ExecutableDefinitionsTest.php new file mode 100644 index 000000000..2b9eda3fa --- /dev/null +++ b/tests/Validator/ExecutableDefinitionsTest.php @@ -0,0 +1,79 @@ +expectPassesRule(new ExecutableDefinitions, ' + query Foo { + dog { + name + } + } + '); + } + + /** + * @it with operation and fragment + */ + public function testWithOperationAndFragment() + { + $this->expectPassesRule(new ExecutableDefinitions, ' + query Foo { + dog { + name + ...Frag + } + } + + fragment Frag on Dog { + name + } + '); + } + + /** + * @it with typeDefinition + */ + public function testWithTypeDefinition() + { + $this->expectFailsRule(new ExecutableDefinitions, ' + query Foo { + dog { + name + } + } + + type Cow { + name: String + } + + extend type Dog { + color: String + } + ', + [ + $this->nonExecutableDefinition('Cow', 8, 12), + $this->nonExecutableDefinition('Dog', 12, 19), + ]); + } + + private function nonExecutableDefinition($defName, $line, $column) + { + return FormattedError::create( + ExecutableDefinitions::nonExecutableDefinitionMessage($defName), + [ new SourceLocation($line, $column) ] + ); + } +} diff --git a/tests/Validator/KnownDirectivesTest.php b/tests/Validator/KnownDirectivesTest.php index d581d1c9e..3e7998ef0 100644 --- a/tests/Validator/KnownDirectivesTest.php +++ b/tests/Validator/KnownDirectivesTest.php @@ -136,6 +136,8 @@ public function testWSLWithWellPlacedDirectives() myField(myArg: Int @onArgumentDefinition): String @onFieldDefinition } + extend type MyObj @onObject + scalar MyScalar @onScalar interface MyInterface @onInterface { diff --git a/tests/Validator/TestCase.php b/tests/Validator/TestCase.php index 4277bfccd..028ac241c 100644 --- a/tests/Validator/TestCase.php +++ b/tests/Validator/TestCase.php @@ -3,6 +3,7 @@ use GraphQL\Language\Parser; use GraphQL\Type\Schema; +use GraphQL\Type\Definition\CustomScalarType; use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\InputObjectType; @@ -259,6 +260,24 @@ public static function getTestSchema() ] ]); + $invalidScalar = new CustomScalarType([ + 'name' => 'Invalid', + 'serialize' => function ($value) { return $value; }, + 'parseLiteral' => function ($node) { + throw new \Exception('Invalid scalar is always invalid: ' . $node->value); + }, + 'parseValue' => function ($value) { + throw new \Exception('Invalid scalar is always invalid: ' . $value); + }, + ]); + + $anyScalar = new CustomScalarType([ + 'name' => 'Any', + 'serialize' => function ($value) { return $value; }, + 'parseLiteral' => function ($node) { return $node; }, // Allows any value + 'parseValue' => function ($value) { return $value; }, // Allows any value + ]); + $queryRoot = new ObjectType([ 'name' => 'QueryRoot', 'fields' => [ @@ -274,6 +293,14 @@ public static function getTestSchema() 'dogOrHuman' => ['type' => $DogOrHuman], 'humanOrAlien' => ['type' => $HumanOrAlien], 'complicatedArgs' => ['type' => $ComplicatedArgs], + 'invalidArg' => [ + 'args' => ['arg' => ['type' => $invalidScalar]], + 'type' => Type::string(), + ], + 'anyArg' => [ + 'args' => ['arg' => ['type' => $anyScalar]], + 'type' => Type::string(), + ], ] ]); diff --git a/tools/gendocs.php b/tools/gendocs.php index 7a2d16c52..95b5b96be 100644 --- a/tools/gendocs.php +++ b/tools/gendocs.php @@ -9,7 +9,7 @@ \GraphQL\GraphQL::class, \GraphQL\Type\Definition\Type::class, \GraphQL\Type\Definition\ResolveInfo::class, - \GraphQL\Type\Definition\DirectiveLocation::class => ['constants' => true], + \GraphQL\Language\DirectiveLocation::class => ['constants' => true], \GraphQL\Type\SchemaConfig::class, \GraphQL\Type\Schema::class, \GraphQL\Language\Parser::class, From 58453c31f78dc5c6d9cee00cd70f55dea471dbfb Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Sun, 11 Feb 2018 14:11:21 +0100 Subject: [PATCH 20/50] Improve validation error message when field names conflict ref: graphql/graphql-js#363 --- .../Rules/OverlappingFieldsCanBeMerged.php | 2 +- .../Validator/OverlappingFieldsCanBeMergedTest.php | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Validator/Rules/OverlappingFieldsCanBeMerged.php b/src/Validator/Rules/OverlappingFieldsCanBeMerged.php index d0a0fb094..95b0e869a 100644 --- a/src/Validator/Rules/OverlappingFieldsCanBeMerged.php +++ b/src/Validator/Rules/OverlappingFieldsCanBeMerged.php @@ -28,7 +28,7 @@ class OverlappingFieldsCanBeMerged extends AbstractValidationRule static function fieldsConflictMessage($responseName, $reason) { $reasonMessage = self::reasonMessage($reason); - return "Fields \"$responseName\" conflict because $reasonMessage."; + return "Fields \"$responseName\" conflict because $reasonMessage. Use different aliases on the fields to fetch both if this was intentional."; } static function reasonMessage($reason) diff --git a/tests/Validator/OverlappingFieldsCanBeMergedTest.php b/tests/Validator/OverlappingFieldsCanBeMergedTest.php index 4c659603e..84bcf2931 100644 --- a/tests/Validator/OverlappingFieldsCanBeMergedTest.php +++ b/tests/Validator/OverlappingFieldsCanBeMergedTest.php @@ -785,6 +785,19 @@ public function testIgnoresUnknownTypes() '); } + /** + * @it error message contains hint for alias conflict + */ + public function testErrorMessageContainsHintForAliasConflict() + { + // The error template should end with a hint for the user to try using + // different aliases. + $error = OverlappingFieldsCanBeMerged::fieldsConflictMessage('x', 'a and b are different fields'); + $hint = 'Use different aliases on the fields to fetch both if this was intentional.'; + + $this->assertStringEndsWith($hint, $error); + } + private function getSchema() { $StringBox = null; From 7b05673d8d9864ad700a6b6bdbfaac70cbbce9ee Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Sun, 11 Feb 2018 17:45:35 +0100 Subject: [PATCH 21/50] Validation: improving overlapping fields quality This improves the overlapping fields validation performance and improves error reporting quality by separating the concepts of checking fields "within" a single collection of fields from checking fields "between" two different collections of fields. This ensures for deeply overlapping fields that nested fields are not checked against each other repeatedly. Extending this concept further, fragment spreads are no longer expanded inline before looking for conflicts, instead the fields within a fragment are compared to the fields with the selection set which contained the referencing fragment spread. e.g. ```graphql { same: a same: b ...X } fragment X on T { same: c same: d } ``` In the above example, the initial query body is checked "within" so `a` is compared to `b`. Also, the fragment `X` is checked "within" so `c` is compared to `d`. Because of the fragment spread, the query body and fragment `X` are checked "between" so that `a` and `b` are each compared to `c` and `d`. In this trivial example, no fewer checks are performed, but in the case where fragments are referenced multiple times, this reduces the overall number of checks (regardless of memoization). **BREAKING**: This can change the order of fields reported when a conflict arises when fragment spreads are involved. If you are checking the precise output of errors (e.g. for unit tests), you may find existing errors change from `"a" and "c" are different fields` to `"c" and "a" are different fields`. From a perf point of view, this is fairly minor as the memoization "PairSet" was already keeping these repeated checks from consuming time, however this will reduce the number of memoized hits because of the algorithm improvement. From an error reporting point of view, this reports nearest-common-ancestor issues when found in a fragment that comes later in the validation process. I've added a test which fails with the existing impl and now passes, as well as changed a comment. This also fixes an error where validation issues could be missed because of an over-eager memoization. I've also modified the `PairSet` to be aware of both forms of memoization, also represented by a previously failing test. ref: graphql/graphql-js#386 --- src/Utils/PairSet.php | 85 +- .../Rules/OverlappingFieldsCanBeMerged.php | 845 +++++++++++++----- src/Validator/ValidationContext.php | 2 +- .../OverlappingFieldsCanBeMergedTest.php | 187 +++- 4 files changed, 852 insertions(+), 267 deletions(-) diff --git a/src/Utils/PairSet.php b/src/Utils/PairSet.php index 367b7b06e..d00161d39 100644 --- a/src/Utils/PairSet.php +++ b/src/Utils/PairSet.php @@ -2,86 +2,65 @@ namespace GraphQL\Utils; /** - * Class PairSet - * @package GraphQL\Utils + * A way to keep track of pairs of things when the ordering of the pair does + * not matter. We do this by maintaining a sort of double adjacency sets. */ class PairSet { - /** - * @var \SplObjectStorage> - */ - private $data; - /** * @var array */ - private $wrappers = []; + private $data; /** * PairSet constructor. */ public function __construct() { - $this->data = new \SplObjectStorage(); // SplObject hash instead? + $this->data = []; } /** - * @param $a - * @param $b - * @return null|object + * @param string $a + * @param string $b + * @param bool $areMutuallyExclusive + * @return bool */ - public function has($a, $b) + public function has($a, $b, $areMutuallyExclusive) { - $a = $this->toObj($a); - $b = $this->toObj($b); - - /** @var \SplObjectStorage $first */ $first = isset($this->data[$a]) ? $this->data[$a] : null; - return isset($first, $first[$b]) ? $first[$b] : null; - } - - /** - * @param $a - * @param $b - */ - public function add($a, $b) - { - $this->pairSetAdd($a, $b); - $this->pairSetAdd($b, $a); + $result = ($first && isset($first[$b])) ? $first[$b] : null; + if ($result === null) { + return false; + } + // areMutuallyExclusive being false is a superset of being true, + // hence if we want to know if this PairSet "has" these two with no + // exclusivity, we have to ensure it was added as such. + if ($areMutuallyExclusive === false) { + return $result === false; + } + return true; } /** - * @param $var - * @return mixed + * @param string $a + * @param string $b + * @param bool $areMutuallyExclusive */ - private function toObj($var) + public function add($a, $b, $areMutuallyExclusive) { - // SplObjectStorage expects objects, so wrapping non-objects to objects - if (is_object($var)) { - return $var; - } - if (!isset($this->wrappers[$var])) { - $tmp = new \stdClass(); - $tmp->_internal = $var; - $this->wrappers[$var] = $tmp; - } - return $this->wrappers[$var]; + $this->pairSetAdd($a, $b, $areMutuallyExclusive); + $this->pairSetAdd($b, $a, $areMutuallyExclusive); } /** - * @param $a - * @param $b + * @param string $a + * @param string $b + * @param bool $areMutuallyExclusive */ - private function pairSetAdd($a, $b) + private function pairSetAdd($a, $b, $areMutuallyExclusive) { - $a = $this->toObj($a); - $b = $this->toObj($b); - $set = isset($this->data[$a]) ? $this->data[$a] : null; - - if (!isset($set)) { - $set = new \SplObjectStorage(); - $this->data[$a] = $set; - } - $set[$b] = true; + $this->data[$a] = isset($this->data[$a]) ? $this->data[$a] : []; + $this->data[$a][$b] = $areMutuallyExclusive; } } diff --git a/src/Validator/Rules/OverlappingFieldsCanBeMerged.php b/src/Validator/Rules/OverlappingFieldsCanBeMerged.php index 95b0e869a..13f5fcf1c 100644 --- a/src/Validator/Rules/OverlappingFieldsCanBeMerged.php +++ b/src/Validator/Rules/OverlappingFieldsCanBeMerged.php @@ -1,24 +1,23 @@ comparedSet = new PairSet(); + $this->comparedFragments = new PairSet(); + $this->cachedFieldsAndFragmentNames = new \SplObjectStorage(); return [ - NodeKind::SELECTION_SET => [ - // Note: we validate on the reverse traversal so deeper conflicts will be - // caught first, for clearer error messages. - 'leave' => function(SelectionSetNode $selectionSet) use ($context) { - $fieldMap = $this->collectFieldNodesAndDefs( - $context, - $context->getParentType(), - $selectionSet - ); - - $conflicts = $this->findConflicts(false, $fieldMap, $context); - - foreach ($conflicts as $conflict) { - $responseName = $conflict[0][0]; - $reason = $conflict[0][1]; - $fields1 = $conflict[1]; - $fields2 = $conflict[2]; - - $context->reportError(new Error( - self::fieldsConflictMessage($responseName, $reason), - array_merge($fields1, $fields2) - )); - } + NodeKind::SELECTION_SET => function(SelectionSetNode $selectionSet) use ($context) { + $conflicts = $this->findConflictsWithinSelectionSet( + $context, + $context->getParentType(), + $selectionSet + ); + + foreach ($conflicts as $conflict) { + $responseName = $conflict[0][0]; + $reason = $conflict[0][1]; + $fields1 = $conflict[1]; + $fields2 = $conflict[2]; + + $context->reportError(new Error( + self::fieldsConflictMessage($responseName, $reason), + array_merge($fields1, $fields2) + )); } - ] + } ]; } - private function findConflicts($parentFieldsAreMutuallyExclusive, $fieldMap, ValidationContext $context) + /** + * Algorithm: + * + * Conflicts occur when two fields exist in a query which will produce the same + * response name, but represent differing values, thus creating a conflict. + * The algorithm below finds all conflicts via making a series of comparisons + * between fields. In order to compare as few fields as possible, this makes + * a series of comparisons "within" sets of fields and "between" sets of fields. + * + * Given any selection set, a collection produces both a set of fields by + * also including all inline fragments, as well as a list of fragments + * referenced by fragment spreads. + * + * A) Each selection set represented in the document first compares "within" its + * collected set of fields, finding any conflicts between every pair of + * overlapping fields. + * Note: This is the *only time* that a the fields "within" a set are compared + * to each other. After this only fields "between" sets are compared. + * + * B) Also, if any fragment is referenced in a selection set, then a + * comparison is made "between" the original set of fields and the + * referenced fragment. + * + * C) Also, if multiple fragments are referenced, then comparisons + * are made "between" each referenced fragment. + * + * D) When comparing "between" a set of fields and a referenced fragment, first + * a comparison is made between each field in the original set of fields and + * each field in the the referenced set of fields. + * + * E) Also, if any fragment is referenced in the referenced selection set, + * then a comparison is made "between" the original set of fields and the + * referenced fragment (recursively referring to step D). + * + * F) When comparing "between" two fragments, first a comparison is made between + * each field in the first referenced set of fields and each field in the the + * second referenced set of fields. + * + * G) Also, any fragments referenced by the first must be compared to the + * second, and any fragments referenced by the second must be compared to the + * first (recursively referring to step F). + * + * H) When comparing two fields, if both have selection sets, then a comparison + * is made "between" both selection sets, first comparing the set of fields in + * the first selection set with the set of fields in the second. + * + * I) Also, if any fragment is referenced in either selection set, then a + * comparison is made "between" the other set of fields and the + * referenced fragment. + * + * J) Also, if two fragments are referenced in both selection sets, then a + * comparison is made "between" the two fragments. + * + */ + + /** + * Find all conflicts found "within" a selection set, including those found + * via spreading in fragments. Called when visiting each SelectionSet in the + * GraphQL Document. + * + * @param ValidationContext $context + * @param CompositeType $parentType + * @param SelectionSetNode $selectionSet + * @return array + */ + private function findConflictsWithinSelectionSet( + ValidationContext $context, + $parentType, + SelectionSetNode $selectionSet) { + list($fieldMap, $fragmentNames) = $this->getFieldsAndFragmentNames( + $context, + $parentType, + $selectionSet + ); + + $conflicts = []; + + // (A) Find find all conflicts "within" the fields of this selection set. + // Note: this is the *only place* `collectConflictsWithin` is called. + $this->collectConflictsWithin( + $context, + $conflicts, + $fieldMap + ); + + // (B) Then collect conflicts between these fields and those represented by + // each spread fragment name found. + $fragmentNamesLength = count($fragmentNames); + for ($i = 0; $i < $fragmentNamesLength; $i++) { + $this->collectConflictsBetweenFieldsAndFragment( + $context, + $conflicts, + false, + $fieldMap, + $fragmentNames[$i] + ); + // (C) Then compare this fragment with all other fragments found in this + // selection set to collect conflicts between fragments spread together. + // This compares each item in the list of fragment names to every other item + // in that same list (except for itself). + for ($j = $i + 1; $j < $fragmentNamesLength; $j++) { + $this->collectConflictsBetweenFragments( + $context, + $conflicts, + false, + $fragmentNames[$i], + $fragmentNames[$j] + ); + } + } + + return $conflicts; + } + + /** + * Collect all conflicts found between a set of fields and a fragment reference + * including via spreading in any nested fragments. + * + * @param ValidationContext $context + * @param array $conflicts + * @param bool $areMutuallyExclusive + * @param array $fieldMap + * @param string $fragmentName + */ + private function collectConflictsBetweenFieldsAndFragment( + ValidationContext $context, + array &$conflicts, + $areMutuallyExclusive, + array $fieldMap, + $fragmentName + ) { + $fragment = $context->getFragment($fragmentName); + if (!$fragment) { + return; + } + + list($fieldMap2, $fragmentNames2) = $this->getReferencedFieldsAndFragmentNames( + $context, + $fragment + ); + + // (D) First collect any conflicts between the provided collection of fields + // and the collection of fields represented by the given fragment. + $this->collectConflictsBetween( + $context, + $conflicts, + $areMutuallyExclusive, + $fieldMap, + $fieldMap2 + ); + + // (E) Then collect any conflicts between the provided collection of fields + // and any fragment names found in the given fragment. + $fragmentNames2Length = count($fragmentNames2); + for ($i = 0; $i < $fragmentNames2Length; $i++) { + $this->collectConflictsBetweenFieldsAndFragment( + $context, + $conflicts, + $areMutuallyExclusive, + $fieldMap, + $fragmentNames2[$i] + ); + } + } + + /** + * Collect all conflicts found between two fragments, including via spreading in + * any nested fragments. + * + * @param ValidationContext $context + * @param array $conflicts + * @param bool $areMutuallyExclusive + * @param string $fragmentName1 + * @param string $fragmentName2 + */ + private function collectConflictsBetweenFragments( + ValidationContext $context, + array &$conflicts, + $areMutuallyExclusive, + $fragmentName1, + $fragmentName2 + ) { + $fragment1 = $context->getFragment($fragmentName1); + $fragment2 = $context->getFragment($fragmentName2); + if (!$fragment1 || !$fragment2) { + return; + } + + // No need to compare a fragment to itself. + if ($fragment1 === $fragment2) { + return; + } + + // Memoize so two fragments are not compared for conflicts more than once. + if ( + $this->comparedFragments->has($fragmentName1, $fragmentName2, $areMutuallyExclusive) + ) { + return; + } + $this->comparedFragments->add($fragmentName1, $fragmentName2, $areMutuallyExclusive); + + list($fieldMap1, $fragmentNames1) = $this->getReferencedFieldsAndFragmentNames( + $context, + $fragment1 + ); + list($fieldMap2, $fragmentNames2) = $this->getReferencedFieldsAndFragmentNames( + $context, + $fragment2 + ); + + // (F) First, collect all conflicts between these two collections of fields + // (not including any nested fragments). + $this->collectConflictsBetween( + $context, + $conflicts, + $areMutuallyExclusive, + $fieldMap1, + $fieldMap2 + ); + + // (G) Then collect conflicts between the first fragment and any nested + // fragments spread in the second fragment. + $fragmentNames2Length = count($fragmentNames2); + for ($j = 0; $j < $fragmentNames2Length; $j++) { + $this->collectConflictsBetweenFragments( + $context, + $conflicts, + $areMutuallyExclusive, + $fragmentName1, + $fragmentNames2[$j] + ); + } + + // (G) Then collect conflicts between the second fragment and any nested + // fragments spread in the first fragment. + $fragmentNames1Length = count($fragmentNames1); + for ($i = 0; $i < $fragmentNames1Length; $i++) { + $this->collectConflictsBetweenFragments( + $context, + $conflicts, + $areMutuallyExclusive, + $fragmentNames1[$i], + $fragmentName2 + ); + } + } + + /** + * Find all conflicts found between two selection sets, including those found + * via spreading in fragments. Called when determining if conflicts exist + * between the sub-fields of two overlapping fields. + * + * @param ValidationContext $context + * @param bool $areMutuallyExclusive + * @param CompositeType $parentType1 + * @param $selectionSet1 + * @param CompositeType $parentType2 + * @param $selectionSet2 + * @return array + */ + private function findConflictsBetweenSubSelectionSets( + ValidationContext $context, + $areMutuallyExclusive, + $parentType1, + SelectionSetNode $selectionSet1, + $parentType2, + SelectionSetNode $selectionSet2 + ) { $conflicts = []; + + list($fieldMap1, $fragmentNames1) = $this->getFieldsAndFragmentNames( + $context, + $parentType1, + $selectionSet1 + ); + list($fieldMap2, $fragmentNames2) = $this->getFieldsAndFragmentNames( + $context, + $parentType2, + $selectionSet2 + ); + + // (H) First, collect all conflicts between these two collections of field. + $this->collectConflictsBetween( + $context, + $conflicts, + $areMutuallyExclusive, + $fieldMap1, + $fieldMap2 + ); + + // (I) Then collect conflicts between the first collection of fields and + // those referenced by each fragment name associated with the second. + $fragmentNames2Length = count($fragmentNames2); + for ($j = 0; $j < $fragmentNames2Length; $j++) { + $this->collectConflictsBetweenFieldsAndFragment( + $context, + $conflicts, + $areMutuallyExclusive, + $fieldMap1, + $fragmentNames2[$j] + ); + } + + // (I) Then collect conflicts between the second collection of fields and + // those referenced by each fragment name associated with the first. + $fragmentNames1Length = count($fragmentNames1); + for ($i = 0; $i < $fragmentNames2Length; $i++) { + $this->collectConflictsBetweenFieldsAndFragment( + $context, + $conflicts, + $areMutuallyExclusive, + $fieldMap2, + $fragmentNames1[$i] + ); + } + + // (J) Also collect conflicts between any fragment names by the first and + // fragment names by the second. This compares each item in the first set of + // names to each item in the second set of names. + for ($i = 0; $i < $fragmentNames1Length; $i++) { + for ($j = 0; $j < $fragmentNames2Length; $j++) { + $this->collectConflictsBetweenFragments( + $context, + $conflicts, + $areMutuallyExclusive, + $fragmentNames1[$i], + $fragmentNames2[$j] + ); + } + } + return $conflicts; + } + + /** + * Collect all Conflicts "within" one collection of fields. + * + * @param ValidationContext $context + * @param array $conflicts + * @param array $fieldMap + */ + private function collectConflictsWithin( + ValidationContext $context, + array &$conflicts, + array $fieldMap + ) + { + // A field map is a keyed collection, where each key represents a response + // name and the value at that key is a list of all fields which provide that + // response name. For every response name, if there are multiple fields, they + // must be compared to find a potential conflict. foreach ($fieldMap as $responseName => $fields) { - $count = count($fields); - if ($count > 1) { - for ($i = 0; $i < $count; $i++) { - for ($j = $i; $j < $count; $j++) { + // This compares every field in the list to every other field in this list + // (except to itself). If the list only has one item, nothing needs to + // be compared. + $fieldsLength = count($fields); + if ($fieldsLength > 1) { + for ($i = 0; $i < $fieldsLength; $i++) { + for ($j = $i + 1; $j < $fieldsLength; $j++) { $conflict = $this->findConflict( - $parentFieldsAreMutuallyExclusive, + $context, + false, // within one collection is never mutually exclusive $responseName, $fields[$i], - $fields[$j], - $context + $fields[$j] ); + if ($conflict) { + $conflicts[] = $conflict; + } + } + } + } + } + } + /** + * Collect all Conflicts between two collections of fields. This is similar to, + * but different from the `collectConflictsWithin` function above. This check + * assumes that `collectConflictsWithin` has already been called on each + * provided collection of fields. This is true because this validator traverses + * each individual selection set. + * + * @param ValidationContext $context + * @param array $conflicts + * @param bool $parentFieldsAreMutuallyExclusive + * @param array $fieldMap1 + * @param array $fieldMap2 + */ + private function collectConflictsBetween( + ValidationContext $context, + array &$conflicts, + $parentFieldsAreMutuallyExclusive, + array $fieldMap1, + array $fieldMap2 + ) { + // A field map is a keyed collection, where each key represents a response + // name and the value at that key is a list of all fields which provide that + // response name. For any response name which appears in both provided field + // maps, each field from the first field map must be compared to every field + // in the second field map to find potential conflicts. + foreach ($fieldMap1 as $responseName => $fields1) { + if (isset($fieldMap2[$responseName])) { + $fields2 = $fieldMap2[$responseName]; + $fields1Length = count($fields1); + $fields2Length = count($fields2); + for ($i = 0; $i < $fields1Length; $i++) { + for ($j = 0; $j < $fields2Length; $j++) { + $conflict = $this->findConflict( + $context, + $parentFieldsAreMutuallyExclusive, + $responseName, + $fields1[$i], + $fields2[$j] + ); if ($conflict) { $conflicts[] = $conflict; } @@ -105,50 +509,29 @@ private function findConflicts($parentFieldsAreMutuallyExclusive, $fieldMap, Val } } } - return $conflicts; } /** - * @param $parentFieldsAreMutuallyExclusive - * @param $responseName - * @param [FieldNode, GraphQLFieldDefinition] $pair1 - * @param [FieldNode, GraphQLFieldDefinition] $pair2 + * Determines if there is a conflict between two particular fields, including + * comparing their sub-fields. + * * @param ValidationContext $context + * @param bool $parentFieldsAreMutuallyExclusive + * @param string $responseName + * @param array $field1 + * @param array $field2 * @return array|null */ private function findConflict( + ValidationContext $context, $parentFieldsAreMutuallyExclusive, $responseName, - array $pair1, - array $pair2, - ValidationContext $context + array $field1, + array $field2 ) { - list($parentType1, $ast1, $def1) = $pair1; - list($parentType2, $ast2, $def2) = $pair2; - - // Not a pair. - if ($ast1 === $ast2) { - return null; - } - - // Memoize, do not report the same issue twice. - // Note: Two overlapping ASTs could be encountered both when - // `parentFieldsAreMutuallyExclusive` is true and is false, which could - // produce different results (when `true` being a subset of `false`). - // However we do not need to include this piece of information when - // memoizing since this rule visits leaf fields before their parent fields, - // ensuring that `parentFieldsAreMutuallyExclusive` is `false` the first - // time two overlapping fields are encountered, ensuring that the full - // set of validation rules are always checked when necessary. - if ($this->comparedSet->has($ast1, $ast2)) { - return null; - } - $this->comparedSet->add($ast1, $ast2); - - // The return type for each field. - $type1 = isset($def1) ? $def1->getType() : null; - $type2 = isset($def2) ? $def2->getType() : null; + list($parentType1, $ast1, $def1) = $field1; + list($parentType2, $ast2, $def2) = $field2; // If it is known that two fields could not possibly apply at the same // time, due to the parent types, then it is safe to permit them to diverge @@ -158,16 +541,20 @@ private function findConflict( // different Object types. Interface or Union types might overlap - if not // in the current state of the schema, then perhaps in some future version, // thus may not safely diverge. - $fieldsAreMutuallyExclusive = + $areMutuallyExclusive = $parentFieldsAreMutuallyExclusive || $parentType1 !== $parentType2 && $parentType1 instanceof ObjectType && $parentType2 instanceof ObjectType; - if (!$fieldsAreMutuallyExclusive) { + // The return type for each field. + $type1 = $def1 ? $def1->getType() : null; + $type2 = $def2 ? $def2->getType() : null; + + if (!$areMutuallyExclusive) { + // Two aliases must refer to the same field. $name1 = $ast1->name->value; $name2 = $ast2->name->value; - if ($name1 !== $name2) { return [ [$responseName, "$name1 and $name2 are different fields"], @@ -176,10 +563,7 @@ private function findConflict( ]; } - $args1 = isset($ast1->arguments) ? $ast1->arguments : []; - $args2 = isset($ast2->arguments) ? $ast2->arguments : []; - - if (!$this->sameArguments($args1, $args2)) { + if (!$this->sameArguments($ast1->arguments ?: [], $ast2->arguments ?: [])) { return [ [$responseName, 'they have differing arguments'], [$ast1], @@ -188,7 +572,6 @@ private function findConflict( } } - if ($type1 && $type2 && $this->doTypesConflict($type1, $type2)) { return [ [$responseName, "they return conflicting types $type1 and $type2"], @@ -197,71 +580,77 @@ private function findConflict( ]; } - $subfieldMap = $this->getSubfieldMap($ast1, $type1, $ast2, $type2, $context); - - if ($subfieldMap) { - $conflicts = $this->findConflicts($fieldsAreMutuallyExclusive, $subfieldMap, $context); - return $this->subfieldConflicts($conflicts, $responseName, $ast1, $ast2); - } - return null; - } - - private function getSubfieldMap( - FieldNode $ast1, - $type1, - FieldNode $ast2, - $type2, - ValidationContext $context - ) { + // Collect and compare sub-fields. Use the same "visited fragment names" list + // for both collections so fields in a fragment reference are never + // compared to themselves. $selectionSet1 = $ast1->selectionSet; $selectionSet2 = $ast2->selectionSet; if ($selectionSet1 && $selectionSet2) { - $visitedFragmentNames = new \ArrayObject(); - $subfieldMap = $this->collectFieldNodesAndDefs( + $conflicts = $this->findConflictsBetweenSubSelectionSets( $context, + $areMutuallyExclusive, Type::getNamedType($type1), $selectionSet1, - $visitedFragmentNames + Type::getNamedType($type2), + $selectionSet2 ); - $subfieldMap = $this->collectFieldNodesAndDefs( - $context, - Type::getNamedType($type2), - $selectionSet2, - $visitedFragmentNames, - $subfieldMap + return $this->subfieldConflicts( + $conflicts, + $responseName, + $ast1, + $ast2 ); - return $subfieldMap; } + + return null; } - private function subfieldConflicts( - array $conflicts, - $responseName, - FieldNode $ast1, - FieldNode $ast2 - ) + /** + * @param ArgumentNode[] $arguments1 + * @param ArgumentNode[] $arguments2 + * + * @return bool + */ + private function sameArguments($arguments1, $arguments2) { - if (!empty($conflicts)) { - return [ - [ - $responseName, - Utils::map($conflicts, function($conflict) {return $conflict[0];}) - ], - array_reduce( - $conflicts, - function($allFields, $conflict) { return array_merge($allFields, $conflict[1]);}, - [ $ast1 ] - ), - array_reduce( - $conflicts, - function($allFields, $conflict) {return array_merge($allFields, $conflict[2]);}, - [ $ast2 ] - ) - ]; + if (count($arguments1) !== count($arguments2)) { + return false; } + foreach ($arguments1 as $argument1) { + $argument2 = null; + foreach ($arguments2 as $argument) { + if ($argument->name->value === $argument1->name->value) { + $argument2 = $argument; + break; + } + } + if (!$argument2) { + return false; + } + + if (!$this->sameValue($argument1->value, $argument2->value)) { + return false; + } + } + + return true; } /** + * @param Node $value1 + * @param Node $value2 + * @return bool + */ + private function sameValue(Node $value1, Node $value2) + { + return (!$value1 && !$value2) || (Printer::doPrint($value1) === Printer::doPrint($value2)); + } + + /** + * Two types conflict if both types could not apply to a value simultaneously. + * Composite types are ignored as their individual field types will be compared + * later recursively. However List and Non-Null types must match. + * * @param OutputType $type1 * @param OutputType $type2 * @return bool @@ -295,33 +684,93 @@ private function doTypesConflict(OutputType $type1, OutputType $type2) } /** - * Given a selectionSet, adds all of the fields in that selection to - * the passed in map of fields, and returns it at the end. - * - * Note: This is not the same as execution's collectFields because at static - * time we do not know what object type will be used, so we unconditionally - * spread in all fragments. + * Given a selection set, return the collection of fields (a mapping of response + * name to field ASTs and definitions) as well as a list of fragment names + * referenced via fragment spreads. * * @param ValidationContext $context - * @param mixed $parentType + * @param CompositeType $parentType * @param SelectionSetNode $selectionSet - * @param \ArrayObject $visitedFragmentNames - * @param \ArrayObject $astAndDefs - * @return mixed + * @return array + */ + private function getFieldsAndFragmentNames( + ValidationContext $context, + $parentType, + SelectionSetNode $selectionSet + ) { + if (!isset($this->cachedFieldsAndFragmentNames[$selectionSet])) { + $astAndDefs = []; + $fragmentNames = []; + + $this->internalCollectFieldsAndFragmentNames( + $context, + $parentType, + $selectionSet, + $astAndDefs, + $fragmentNames + ); + $cached = [$astAndDefs, array_keys($fragmentNames)]; + $this->cachedFieldsAndFragmentNames[$selectionSet] = $cached; + } else { + $cached = $this->cachedFieldsAndFragmentNames[$selectionSet]; + } + return $cached; + } + + /** + * Given a reference to a fragment, return the represented collection of fields + * as well as a list of nested fragment names referenced via fragment spreads. + * + * @param ValidationContext $context + * @param FragmentDefinitionNode $fragment + * @return array|object */ - private function collectFieldNodesAndDefs(ValidationContext $context, $parentType, SelectionSetNode $selectionSet, \ArrayObject $visitedFragmentNames = null, \ArrayObject $astAndDefs = null) + private function getReferencedFieldsAndFragmentNames( + ValidationContext $context, + FragmentDefinitionNode $fragment + ) { - $_visitedFragmentNames = $visitedFragmentNames ?: new \ArrayObject(); - $_astAndDefs = $astAndDefs ?: new \ArrayObject(); + // Short-circuit building a type from the AST if possible. + if (isset($this->cachedFieldsAndFragmentNames[$fragment->selectionSet])) { + return $this->cachedFieldsAndFragmentNames[$fragment->selectionSet]; + } + + $fragmentType = TypeInfo::typeFromAST($context->getSchema(), $fragment->typeCondition); + return $this->getFieldsAndFragmentNames( + $context, + $fragmentType, + $fragment->selectionSet + ); + } - for ($i = 0; $i < count($selectionSet->selections); $i++) { + /** + * Given a reference to a fragment, return the represented collection of fields + * as well as a list of nested fragment names referenced via fragment spreads. + * + * @param ValidationContext $context + * @param CompositeType $parentType + * @param SelectionSetNode $selectionSet + * @param array $astAndDefs + * @param array $fragmentNames + */ + private function internalCollectFieldsAndFragmentNames( + ValidationContext $context, + $parentType, + SelectionSetNode $selectionSet, + array &$astAndDefs, + array &$fragmentNames + ) + { + $selectionSetLength = count($selectionSet->selections); + for ($i = 0; $i < $selectionSetLength; $i++) { $selection = $selectionSet->selections[$i]; - switch ($selection->kind) { - case NodeKind::FIELD: + switch (true) { + case $selection instanceof FieldNode: $fieldName = $selection->name->value; $fieldDef = null; - if ($parentType && method_exists($parentType, 'getFields')) { + if ($parentType instanceof ObjectType || + $parentType instanceof InterfaceType) { $tmp = $parentType->getFields(); if (isset($tmp[$fieldName])) { $fieldDef = $tmp[$fieldName]; @@ -329,86 +778,72 @@ private function collectFieldNodesAndDefs(ValidationContext $context, $parentTyp } $responseName = $selection->alias ? $selection->alias->value : $fieldName; - if (!isset($_astAndDefs[$responseName])) { - $_astAndDefs[$responseName] = new \ArrayObject(); + if (!isset($astAndDefs[$responseName])) { + $astAndDefs[$responseName] = []; } - $_astAndDefs[$responseName][] = [$parentType, $selection, $fieldDef]; + $astAndDefs[$responseName][] = [$parentType, $selection, $fieldDef]; + break; + case $selection instanceof FragmentSpreadNode: + $fragmentNames[$selection->name->value] = true; break; - case NodeKind::INLINE_FRAGMENT: + case $selection instanceof InlineFragmentNode: $typeCondition = $selection->typeCondition; $inlineFragmentType = $typeCondition ? TypeInfo::typeFromAST($context->getSchema(), $typeCondition) : $parentType; - $_astAndDefs = $this->collectFieldNodesAndDefs( + $this->internalcollectFieldsAndFragmentNames( $context, $inlineFragmentType, $selection->selectionSet, - $_visitedFragmentNames, - $_astAndDefs - ); - break; - case NodeKind::FRAGMENT_SPREAD: - /** @var FragmentSpreadNode $selection */ - $fragName = $selection->name->value; - if (!empty($_visitedFragmentNames[$fragName])) { - continue; - } - $_visitedFragmentNames[$fragName] = true; - $fragment = $context->getFragment($fragName); - if (!$fragment) { - continue; - } - $fragmentType = TypeInfo::typeFromAST($context->getSchema(), $fragment->typeCondition); - $_astAndDefs = $this->collectFieldNodesAndDefs( - $context, - $fragmentType, - $fragment->selectionSet, - $_visitedFragmentNames, - $_astAndDefs + $astAndDefs, + $fragmentNames ); break; } } - return $_astAndDefs; } /** - * @param ArgumentNode[]|DirectiveNode[] $arguments1 - * @param ArgumentNode[]|DirectiveNode[] $arguments2 + * Given a series of Conflicts which occurred between two sub-fields, generate + * a single Conflict. * - * @return bool|string + * @param array $conflicts + * @param string $responseName + * @param FieldNode $ast1 + * @param FieldNode $ast2 + * @return array|null */ - private function sameArguments($arguments1, $arguments2) + private function subfieldConflicts( + array $conflicts, + $responseName, + FieldNode $ast1, + FieldNode $ast2 + ) { - if (count($arguments1) !== count($arguments2)) { - return false; - } - foreach ($arguments1 as $arg1) { - $arg2 = null; - foreach ($arguments2 as $arg) { - if ($arg->name->value === $arg1->name->value) { - $arg2 = $arg; - break; - } - } - if (!$arg2) { - return false; - } - if (!$this->sameValue($arg1->value, $arg2->value)) { - return false; - } + if (count($conflicts) > 0) { + return [ + [ + $responseName, + array_map(function ($conflict) { + return $conflict[0]; + }, $conflicts), + ], + array_reduce( + $conflicts, + function ($allFields, $conflict) { + return array_merge($allFields, $conflict[1]); + }, + [$ast1] + ), + array_reduce( + $conflicts, + function ($allFields, $conflict) { + return array_merge($allFields, $conflict[2]); + }, + [$ast2] + ), + ]; } - return true; - } - - private function sameValue($value1, $value2) - { - return (!$value1 && !$value2) || (Printer::doPrint($value1) === Printer::doPrint($value2)); - } - - function sameType($type1, $type2) - { - return (string) $type1 === (string) $type2; } } diff --git a/src/Validator/ValidationContext.php b/src/Validator/ValidationContext.php index 07d32687f..51ea8d1d5 100644 --- a/src/Validator/ValidationContext.php +++ b/src/Validator/ValidationContext.php @@ -124,7 +124,7 @@ function getDocument() } /** - * @param $name + * @param string $name * @return FragmentDefinitionNode|null */ function getFragment($name) diff --git a/tests/Validator/OverlappingFieldsCanBeMergedTest.php b/tests/Validator/OverlappingFieldsCanBeMergedTest.php index 84bcf2931..7301473ae 100644 --- a/tests/Validator/OverlappingFieldsCanBeMergedTest.php +++ b/tests/Validator/OverlappingFieldsCanBeMergedTest.php @@ -294,12 +294,12 @@ public function testReportsEachConflictOnce() [new SourceLocation(18, 9), new SourceLocation(21, 9)] ), FormattedError::create( - OverlappingFieldsCanBeMerged::fieldsConflictMessage('x', 'a and c are different fields'), - [new SourceLocation(18, 9), new SourceLocation(14, 11)] + OverlappingFieldsCanBeMerged::fieldsConflictMessage('x', 'c and a are different fields'), + [new SourceLocation(14, 11), new SourceLocation(18, 9)] ), FormattedError::create( - OverlappingFieldsCanBeMerged::fieldsConflictMessage('x', 'b and c are different fields'), - [new SourceLocation(21, 9), new SourceLocation(14, 11)] + OverlappingFieldsCanBeMerged::fieldsConflictMessage('x', 'c and b are different fields'), + [new SourceLocation(14, 11), new SourceLocation(21, 9)] ) ]); } @@ -432,6 +432,113 @@ public function testReportsDeepConflictToNearestCommonAncestor() ]); } + /** + * @it reports deep conflict to nearest common ancestor in fragments + */ + public function testReportsDeepConflictToNearestCommonAncestorInFragments() + { + $this->expectFailsRule(new OverlappingFieldsCanBeMerged, ' + { + field { + ...F + } + field { + ...F + } + } + fragment F on T { + deepField { + deeperField { + x: a + } + deeperField { + x: b + } + }, + deepField { + deeperField { + y + } + } + } + ', [ + FormattedError::create( + OverlappingFieldsCanBeMerged::fieldsConflictMessage('deeperField', [['x', 'a and b are different fields']]), + [ + new SourceLocation(12,11), + new SourceLocation(13,13), + new SourceLocation(15,11), + new SourceLocation(16,13), + ] + ) + ]); + } + + /** + * @it reports deep conflict in nested fragments + */ + public function testReportsDeepConflictInNestedFragments() + { + $this->expectFailsRule(new OverlappingFieldsCanBeMerged, ' + { + field { + ...F + } + field { + ...I + } + } + fragment F on T { + x: a + ...G + } + fragment G on T { + y: c + } + fragment I on T { + y: d + ...J + } + fragment J on T { + x: b + } + ', [ + FormattedError::create( + OverlappingFieldsCanBeMerged::fieldsConflictMessage('field', [ + ['x', 'a and b are different fields'], + ['y', 'c and d are different fields'], + ]), + [ + new SourceLocation(3,9), + new SourceLocation(11,9), + new SourceLocation(15,9), + new SourceLocation(6,9), + new SourceLocation(22,9), + new SourceLocation(18,9), + ] + ) + ]); + } + + /** + * @it ignores unknown fragments + */ + public function testIgnoresUnknownFragments() + { + $this->expectPassesRule(new OverlappingFieldsCanBeMerged, ' + { + field { + ...Unknown + ...Known + } + } + fragment Known on T { + field + ...OtherUnknown + } + '); + } + // Describe: return types must be unambiguous /** @@ -520,6 +627,70 @@ public function testDisallowsDifferingReturnTypesDespiteNoOverlap() ]); } + /** + * @it reports correctly when a non-exclusive follows an exclusive + */ + public function testReportsCorrectlyWhenANonExclusiveFollowsAnExclusive() + { + $this->expectFailsRuleWithSchema($this->getSchema(), new OverlappingFieldsCanBeMerged, ' + { + someBox { + ... on IntBox { + deepBox { + ...X + } + } + } + someBox { + ... on StringBox { + deepBox { + ...Y + } + } + } + memoed: someBox { + ... on IntBox { + deepBox { + ...X + } + } + } + memoed: someBox { + ... on StringBox { + deepBox { + ...Y + } + } + } + other: someBox { + ...X + } + other: someBox { + ...Y + } + } + fragment X on SomeBox { + scalar + } + fragment Y on SomeBox { + scalar: unrelatedField + } + ', [ + FormattedError::create( + OverlappingFieldsCanBeMerged::fieldsConflictMessage( + 'other', + [['scalar', 'scalar and unrelatedField are different fields']] + ), + [ + new SourceLocation(31, 11), + new SourceLocation(39, 11), + new SourceLocation(34, 11), + new SourceLocation(42, 11), + ] + ) + ]); + } + /** * @it disallows differing return type nullability despite no overlap */ @@ -753,14 +924,14 @@ public function testComparesDeepTypesIncludingList() } ', [ FormattedError::create( - OverlappingFieldsCanBeMerged::fieldsConflictMessage('edges', [['node', [['id', 'id and name are different fields']]]]), + OverlappingFieldsCanBeMerged::fieldsConflictMessage('edges', [['node', [['id', 'name and id are different fields']]]]), [ - new SourceLocation(14, 11), - new SourceLocation(15, 13), - new SourceLocation(16, 15), new SourceLocation(5, 13), new SourceLocation(6, 15), new SourceLocation(7, 17), + new SourceLocation(14, 11), + new SourceLocation(15, 13), + new SourceLocation(16, 15), ] ) ]); From 6e358eb26cdfd707a17470e63ee41ebccb967e10 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Sun, 11 Feb 2018 17:58:48 +0100 Subject: [PATCH 22/50] Fix infinite loop on invalid queries in OverlappingFields `OverlappingFieldsCanBeMerged` would infinite loop when passed something like ```graphql fragment A on User { name ...A } ``` It's not `OverlappingFieldsCanBeMerged`'s responsibility to detect that validation error, but we still would ideally avoid infinite looping. This detects that case, and pretends that the infinite spread wasn't there for the purposes of this validation step. Also, by memoizing and checking for self-references this removes duplicate reports. ref: graphql/graphql-js#1111 --- .../Rules/OverlappingFieldsCanBeMerged.php | 123 +++++++++++------- .../OverlappingFieldsCanBeMergedTest.php | 60 ++++++++- 2 files changed, 137 insertions(+), 46 deletions(-) diff --git a/src/Validator/Rules/OverlappingFieldsCanBeMerged.php b/src/Validator/Rules/OverlappingFieldsCanBeMerged.php index 13f5fcf1c..0867be6dd 100644 --- a/src/Validator/Rules/OverlappingFieldsCanBeMerged.php +++ b/src/Validator/Rules/OverlappingFieldsCanBeMerged.php @@ -49,7 +49,7 @@ static function reasonMessage($reason) * dramatically improve the performance of this validator. * @var PairSet */ - private $comparedFragments; + private $comparedFragmentPairs; /** * A cache for the "field map" and list of fragment names found in any given @@ -62,7 +62,7 @@ static function reasonMessage($reason) public function getVisitor(ValidationContext $context) { - $this->comparedFragments = new PairSet(); + $this->comparedFragmentPairs = new PairSet(); $this->cachedFieldsAndFragmentNames = new \SplObjectStorage(); return [ @@ -174,29 +174,34 @@ private function findConflictsWithinSelectionSet( $fieldMap ); - // (B) Then collect conflicts between these fields and those represented by - // each spread fragment name found. + $fragmentNamesLength = count($fragmentNames); - for ($i = 0; $i < $fragmentNamesLength; $i++) { - $this->collectConflictsBetweenFieldsAndFragment( - $context, - $conflicts, - false, - $fieldMap, - $fragmentNames[$i] - ); - // (C) Then compare this fragment with all other fragments found in this - // selection set to collect conflicts between fragments spread together. - // This compares each item in the list of fragment names to every other item - // in that same list (except for itself). - for ($j = $i + 1; $j < $fragmentNamesLength; $j++) { - $this->collectConflictsBetweenFragments( + if ($fragmentNamesLength !== 0) { + // (B) Then collect conflicts between these fields and those represented by + // each spread fragment name found. + $comparedFragments = []; + for ($i = 0; $i < $fragmentNamesLength; $i++) { + $this->collectConflictsBetweenFieldsAndFragment( $context, $conflicts, + $comparedFragments, false, - $fragmentNames[$i], - $fragmentNames[$j] + $fieldMap, + $fragmentNames[$i] ); + // (C) Then compare this fragment with all other fragments found in this + // selection set to collect conflicts between fragments spread together. + // This compares each item in the list of fragment names to every other item + // in that same list (except for itself). + for ($j = $i + 1; $j < $fragmentNamesLength; $j++) { + $this->collectConflictsBetweenFragments( + $context, + $conflicts, + false, + $fragmentNames[$i], + $fragmentNames[$j] + ); + } } } @@ -209,6 +214,7 @@ private function findConflictsWithinSelectionSet( * * @param ValidationContext $context * @param array $conflicts + * @param array $comparedFragments * @param bool $areMutuallyExclusive * @param array $fieldMap * @param string $fragmentName @@ -216,10 +222,16 @@ private function findConflictsWithinSelectionSet( private function collectConflictsBetweenFieldsAndFragment( ValidationContext $context, array &$conflicts, + array &$comparedFragments, $areMutuallyExclusive, array $fieldMap, $fragmentName ) { + if (isset($comparedFragments[$fragmentName])) { + return; + } + $comparedFragments[$fragmentName] = true; + $fragment = $context->getFragment($fragmentName); if (!$fragment) { return; @@ -230,6 +242,10 @@ private function collectConflictsBetweenFieldsAndFragment( $fragment ); + if ($fieldMap === $fieldMap2) { + return; + } + // (D) First collect any conflicts between the provided collection of fields // and the collection of fields represented by the given fragment. $this->collectConflictsBetween( @@ -247,6 +263,7 @@ private function collectConflictsBetweenFieldsAndFragment( $this->collectConflictsBetweenFieldsAndFragment( $context, $conflicts, + $comparedFragments, $areMutuallyExclusive, $fieldMap, $fragmentNames2[$i] @@ -271,24 +288,32 @@ private function collectConflictsBetweenFragments( $fragmentName1, $fragmentName2 ) { - $fragment1 = $context->getFragment($fragmentName1); - $fragment2 = $context->getFragment($fragmentName2); - if (!$fragment1 || !$fragment2) { - return; - } - // No need to compare a fragment to itself. - if ($fragment1 === $fragment2) { + if ($fragmentName1 === $fragmentName2) { return; } // Memoize so two fragments are not compared for conflicts more than once. if ( - $this->comparedFragments->has($fragmentName1, $fragmentName2, $areMutuallyExclusive) + $this->comparedFragmentPairs->has( + $fragmentName1, + $fragmentName2, + $areMutuallyExclusive + ) ) { return; } - $this->comparedFragments->add($fragmentName1, $fragmentName2, $areMutuallyExclusive); + $this->comparedFragmentPairs->add( + $fragmentName1, + $fragmentName2, + $areMutuallyExclusive + ); + + $fragment1 = $context->getFragment($fragmentName1); + $fragment2 = $context->getFragment($fragmentName2); + if (!$fragment1 || !$fragment2) { + return; + } list($fieldMap1, $fragmentNames1) = $this->getReferencedFieldsAndFragmentNames( $context, @@ -382,27 +407,35 @@ private function findConflictsBetweenSubSelectionSets( // (I) Then collect conflicts between the first collection of fields and // those referenced by each fragment name associated with the second. $fragmentNames2Length = count($fragmentNames2); - for ($j = 0; $j < $fragmentNames2Length; $j++) { - $this->collectConflictsBetweenFieldsAndFragment( - $context, - $conflicts, - $areMutuallyExclusive, - $fieldMap1, - $fragmentNames2[$j] - ); + if ($fragmentNames2Length !== 0) { + $comparedFragments = []; + for ($j = 0; $j < $fragmentNames2Length; $j++) { + $this->collectConflictsBetweenFieldsAndFragment( + $context, + $conflicts, + $comparedFragments, + $areMutuallyExclusive, + $fieldMap1, + $fragmentNames2[$j] + ); + } } // (I) Then collect conflicts between the second collection of fields and // those referenced by each fragment name associated with the first. $fragmentNames1Length = count($fragmentNames1); - for ($i = 0; $i < $fragmentNames2Length; $i++) { - $this->collectConflictsBetweenFieldsAndFragment( - $context, - $conflicts, - $areMutuallyExclusive, - $fieldMap2, - $fragmentNames1[$i] - ); + if ($fragmentNames1Length !== 0) { + $comparedFragments = []; + for ($i = 0; $i < $fragmentNames2Length; $i++) { + $this->collectConflictsBetweenFieldsAndFragment( + $context, + $conflicts, + $comparedFragments, + $areMutuallyExclusive, + $fieldMap2, + $fragmentNames1[$i] + ); + } } // (J) Also collect conflicts between any fragment names by the first and diff --git a/tests/Validator/OverlappingFieldsCanBeMergedTest.php b/tests/Validator/OverlappingFieldsCanBeMergedTest.php index 7301473ae..399af6a9c 100644 --- a/tests/Validator/OverlappingFieldsCanBeMergedTest.php +++ b/tests/Validator/OverlappingFieldsCanBeMergedTest.php @@ -454,7 +454,7 @@ public function testReportsDeepConflictToNearestCommonAncestorInFragments() deeperField { x: b } - }, + } deepField { deeperField { y @@ -969,6 +969,64 @@ public function testErrorMessageContainsHintForAliasConflict() $this->assertStringEndsWith($hint, $error); } + /** + * @it does not infinite loop on recursive fragment + */ + public function testDoesNotInfiniteLoopOnRecursiveFragment() + { + $this->expectPassesRule(new OverlappingFieldsCanBeMerged, ' + fragment fragA on Human { name, relatives { name, ...fragA } } + '); + } + + /** + * @it does not infinite loop on immediately recursive fragment + */ + public function testDoesNotInfiniteLoopOnImmeditelyRecursiveFragment() + { + $this->expectPassesRule(new OverlappingFieldsCanBeMerged, ' + fragment fragA on Human { name, ...fragA } + '); + } + + /** + * @it does not infinite loop on transitively recursive fragment + */ + public function testDoesNotInfiniteLoopOnTransitivelyRecursiveFragment() + { + $this->expectPassesRule(new OverlappingFieldsCanBeMerged, ' + fragment fragA on Human { name, ...fragB } + fragment fragB on Human { name, ...fragC } + fragment fragC on Human { name, ...fragA } + '); + } + + /** + * @it find invalid case even with immediately recursive fragment + */ + public function testFindInvalidCaseEvenWithImmediatelyRecursiveFragment() + { + $this->expectFailsRule(new OverlappingFieldsCanBeMerged, ' + fragment sameAliasesWithDifferentFieldTargets on Dob { + ...sameAliasesWithDifferentFieldTargets + fido: name + fido: nickname + } + ', + [ + FormattedError::create( + OverlappingFieldsCanBeMerged::fieldsConflictMessage( + 'fido', + 'name and nickname are different fields' + ), + [ + new SourceLocation(4, 9), + new SourceLocation(5, 9), + ] + ) + ]); + } + private function getSchema() { $StringBox = null; From ff63e07b05e5973af0a0c646f8ca4da2a4809f71 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Sun, 11 Feb 2018 18:19:52 +0100 Subject: [PATCH 23/50] Improve introspection types + new getIntrospectionQuery() This adds a new function `getIntrospectionQuery()` which allows for some minor configuration over the resulting query text: to exclude descriptions if your use case does not require them. ref: graphql/graphql-js#1113 --- src/Type/Introspection.php | 113 +++--------------- tests/Type/EnumTypeTest.php | 1 - tests/Type/IntrospectionTest.php | 2 +- tests/Validator/AbstractQuerySecurityTest.php | 2 +- 4 files changed, 21 insertions(+), 97 deletions(-) diff --git a/src/Type/Introspection.php b/src/Type/Introspection.php index 7326ea433..57be00227 100644 --- a/src/Type/Introspection.php +++ b/src/Type/Introspection.php @@ -37,104 +37,25 @@ class Introspection private static $map = []; /** + * Options: + * - descriptions + * Whether to include descriptions in the introspection result. + * Default: true + * + * @param array $options * @return string */ - public static function getIntrospectionQuery($includeDescription = true) + public static function getIntrospectionQuery($options = []) { - $withDescription = <<<'EOD' - query IntrospectionQuery { - __schema { - queryType { name } - mutationType { name } - subscriptionType { name } - types { - ...FullType - } - directives { - name - description - locations - args { - ...InputValue + if (is_bool($options)) { + trigger_error('Calling Introspection::getIntrospectionQuery(boolean) is deprecated. Please use Introspection::getIntrospectionQuery(["descriptions" => boolean]).', E_USER_DEPRECATED); + $descriptions = $options; + } else { + $descriptions = !array_key_exists('descriptions', $options) || $options['descriptions'] === true; } - } - } - } + $descriptionField = $descriptions ? 'description' : ''; - fragment FullType on __Type { - kind - name - description - fields(includeDeprecated: true) { - name - description - args { - ...InputValue - } - type { - ...TypeRef - } - isDeprecated - deprecationReason - } - inputFields { - ...InputValue - } - interfaces { - ...TypeRef - } - enumValues(includeDeprecated: true) { - name - description - isDeprecated - deprecationReason - } - possibleTypes { - ...TypeRef - } - } - - fragment InputValue on __InputValue { - name - description - type { ...TypeRef } - defaultValue - } - - fragment TypeRef on __Type { - kind - name - ofType { - kind - name - ofType { - kind - name - ofType { - kind - name - ofType { - kind - name - ofType { - kind - name - ofType { - kind - name - ofType { - kind - name - } - } - } - } - } - } - } - } -EOD; - $withoutDescription = <<<'EOD' + return << false]); $expected = array ( 'data' => array ( diff --git a/tests/Validator/AbstractQuerySecurityTest.php b/tests/Validator/AbstractQuerySecurityTest.php index 885fc9989..7e5e36bb4 100644 --- a/tests/Validator/AbstractQuerySecurityTest.php +++ b/tests/Validator/AbstractQuerySecurityTest.php @@ -53,7 +53,7 @@ protected function assertDocumentValidator($queryString, $max, array $expectedEr protected function assertIntrospectionQuery($maxExpected) { - $query = Introspection::getIntrospectionQuery(true); + $query = Introspection::getIntrospectionQuery(); $this->assertMaxValue($query, $maxExpected); } From 74854d55a099f6db1629a92d744b77b0548093d0 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Sun, 11 Feb 2018 18:28:34 +0100 Subject: [PATCH 24/50] Read-only AST types ref: graphql/graphql-js#1121 --- src/Utils/ASTDefinitionBuilder.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Utils/ASTDefinitionBuilder.php b/src/Utils/ASTDefinitionBuilder.php index 764979621..d6d7f7a78 100644 --- a/src/Utils/ASTDefinitionBuilder.php +++ b/src/Utils/ASTDefinitionBuilder.php @@ -204,9 +204,9 @@ public function buildField(FieldDefinitionNode $field) return [ 'type' => $this->buildOutputType($field->type), 'description' => $this->getDescription($field), - 'args' => $this->makeInputValues($field->arguments), + 'args' => $field->arguments ? $this->makeInputValues($field->arguments) : null, 'deprecationReason' => $this->getDeprecationReason($field), - 'astNode' => $field + 'astNode' => $field, ]; } From b5106a06c9ebf5bfb24db4087ca9a5200c38f93a Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Sun, 11 Feb 2018 21:08:53 +0100 Subject: [PATCH 25/50] SDL Spec changes This adds the recent changes to the SDL proposal. ref: graphql/graphql-js#1117 --- src/Language/AST/EnumTypeDefinitionNode.php | 2 +- src/Language/AST/EnumTypeExtensionNode.php | 25 ++ .../AST/InputObjectTypeDefinitionNode.php | 4 +- .../AST/InputObjectTypeExtensionNode.php | 25 ++ .../AST/InterfaceTypeDefinitionNode.php | 6 +- .../AST/InterfaceTypeExtensionNode.php | 25 ++ src/Language/AST/NodeKind.php | 10 + src/Language/AST/ObjectTypeDefinitionNode.php | 4 +- src/Language/AST/ScalarTypeExtensionNode.php | 20 ++ src/Language/AST/TypeExtensionNode.php | 7 +- src/Language/AST/UnionTypeDefinitionNode.php | 4 +- src/Language/AST/UnionTypeExtensionNode.php | 25 ++ src/Language/Parser.php | 238 +++++++++++++++--- src/Language/Printer.php | 50 +++- src/Language/Visitor.php | 9 +- src/Utils/ASTDefinitionBuilder.php | 62 +++-- src/Validator/Rules/KnownDirectives.php | 44 +++- tests/Language/SchemaPrinterTest.php | 42 +++- tests/Language/schema-kitchen-sink.graphql | 84 +++++-- tests/Validator/KnownDirectivesTest.php | 10 + 20 files changed, 580 insertions(+), 116 deletions(-) create mode 100644 src/Language/AST/EnumTypeExtensionNode.php create mode 100644 src/Language/AST/InputObjectTypeExtensionNode.php create mode 100644 src/Language/AST/InterfaceTypeExtensionNode.php create mode 100644 src/Language/AST/ScalarTypeExtensionNode.php create mode 100644 src/Language/AST/UnionTypeExtensionNode.php diff --git a/src/Language/AST/EnumTypeDefinitionNode.php b/src/Language/AST/EnumTypeDefinitionNode.php index 71ca5087f..e9be727f6 100644 --- a/src/Language/AST/EnumTypeDefinitionNode.php +++ b/src/Language/AST/EnumTypeDefinitionNode.php @@ -19,7 +19,7 @@ class EnumTypeDefinitionNode extends Node implements TypeDefinitionNode public $directives; /** - * @var EnumValueDefinitionNode[] + * @var EnumValueDefinitionNode[]|null */ public $values; diff --git a/src/Language/AST/EnumTypeExtensionNode.php b/src/Language/AST/EnumTypeExtensionNode.php new file mode 100644 index 000000000..5e2417d69 --- /dev/null +++ b/src/Language/AST/EnumTypeExtensionNode.php @@ -0,0 +1,25 @@ +InputObjectTypeDefinitionNode::class, // Type Extensions + NodeKind::SCALAR_TYPE_EXTENSION => ScalarTypeExtensionNode::class, NodeKind::OBJECT_TYPE_EXTENSION => ObjectTypeExtensionNode::class, + NodeKind::INTERFACE_TYPE_EXTENSION => InterfaceTypeExtensionNode::class, + NodeKind::UNION_TYPE_EXTENSION => UnionTypeExtensionNode::class, + NodeKind::ENUM_TYPE_EXTENSION => EnumTypeExtensionNode::class, + NodeKind::INPUT_OBJECT_TYPE_EXTENSION => InputObjectTypeExtensionNode::class, // Directive Definitions NodeKind::DIRECTIVE_DEFINITION => DirectiveDefinitionNode::class diff --git a/src/Language/AST/ObjectTypeDefinitionNode.php b/src/Language/AST/ObjectTypeDefinitionNode.php index addf20a11..b2c6b1b4e 100644 --- a/src/Language/AST/ObjectTypeDefinitionNode.php +++ b/src/Language/AST/ObjectTypeDefinitionNode.php @@ -19,12 +19,12 @@ class ObjectTypeDefinitionNode extends Node implements TypeDefinitionNode public $interfaces = []; /** - * @var DirectiveNode[] + * @var DirectiveNode[]|null */ public $directives; /** - * @var FieldDefinitionNode[] + * @var FieldDefinitionNode[]|null */ public $fields; diff --git a/src/Language/AST/ScalarTypeExtensionNode.php b/src/Language/AST/ScalarTypeExtensionNode.php new file mode 100644 index 000000000..1fc2d8269 --- /dev/null +++ b/src/Language/AST/ScalarTypeExtensionNode.php @@ -0,0 +1,20 @@ +parseName(); $interfaces = $this->parseImplementsInterfaces(); $directives = $this->parseDirectives(true); - $fields = $this->parseFieldDefinitions(); + $fields = $this->parseFieldsDefinition(); return new ObjectTypeDefinitionNode([ 'name' => $name, @@ -1033,13 +1039,15 @@ function parseImplementsInterfaces() * @return FieldDefinitionNode[]|NodeList * @throws SyntaxError */ - function parseFieldDefinitions() + function parseFieldsDefinition() { - return $this->many( - Token::BRACE_L, - [$this, 'parseFieldDefinition'], - Token::BRACE_R - ); + return $this->peek(Token::BRACE_L) + ? $this->many( + Token::BRACE_L, + [$this, 'parseFieldDefinition'], + Token::BRACE_R + ) + : new NodeList([]); } /** @@ -1114,7 +1122,7 @@ function parseInterfaceTypeDefinition() $this->expectKeyword('interface'); $name = $this->parseName(); $directives = $this->parseDirectives(true); - $fields = $this->parseFieldDefinitions(); + $fields = $this->parseFieldsDefinition(); return new InterfaceTypeDefinitionNode([ 'name' => $name, @@ -1136,8 +1144,7 @@ function parseUnionTypeDefinition() $this->expectKeyword('union'); $name = $this->parseName(); $directives = $this->parseDirectives(true); - $this->expect(Token::EQUALS); - $types = $this->parseUnionMembers(); + $types = $this->parseMemberTypesDefinition(); return new UnionTypeDefinitionNode([ 'name' => $name, @@ -1149,22 +1156,23 @@ function parseUnionTypeDefinition() } /** - * UnionMembers : + * MemberTypes : * - `|`? NamedType - * - UnionMembers | NamedType + * - MemberTypes | NamedType * * @return NamedTypeNode[] */ - function parseUnionMembers() + function parseMemberTypesDefinition() { - // Optional leading pipe - $this->skip(Token::PIPE); - $members = []; - - do { - $members[] = $this->parseNamedType(); - } while ($this->skip(Token::PIPE)); - return $members; + $types = []; + if ($this->skip(Token::EQUALS)) { + // Optional leading pipe + $this->skip(Token::PIPE); + do { + $types[] = $this->parseNamedType(); + } while ($this->skip(Token::PIPE)); + } + return $types; } /** @@ -1178,11 +1186,7 @@ function parseEnumTypeDefinition() $this->expectKeyword('enum'); $name = $this->parseName(); $directives = $this->parseDirectives(true); - $values = $this->many( - Token::BRACE_L, - [$this, 'parseEnumValueDefinition'], - Token::BRACE_R - ); + $values = $this->parseEnumValuesDefinition(); return new EnumTypeDefinitionNode([ 'name' => $name, @@ -1193,6 +1197,21 @@ function parseEnumTypeDefinition() ]); } + /** + * @return EnumValueDefinitionNode[]|NodeList + * @throws SyntaxError + */ + function parseEnumValuesDefinition() + { + return $this->peek(Token::BRACE_L) + ? $this->many( + Token::BRACE_L, + [$this, 'parseEnumValueDefinition'], + Token::BRACE_R + ) + : new NodeList([]); + } + /** * @return EnumValueDefinitionNode * @throws SyntaxError @@ -1223,11 +1242,7 @@ function parseInputObjectTypeDefinition() $this->expectKeyword('input'); $name = $this->parseName(); $directives = $this->parseDirectives(true); - $fields = $this->many( - Token::BRACE_L, - [$this, 'parseInputValueDef'], - Token::BRACE_R - ); + $fields = $this->parseInputFieldsDefinition(); return new InputObjectTypeDefinitionNode([ 'name' => $name, @@ -1239,6 +1254,28 @@ function parseInputObjectTypeDefinition() } /** + * @return InputValueDefinitionNode[]|NodeList + * @throws SyntaxError + */ + function parseInputFieldsDefinition() { + return $this->peek(Token::BRACE_L) + ? $this->many( + Token::BRACE_L, + [$this, 'parseInputValueDef'], + Token::BRACE_R + ) + : new NodeList([]); + } + + /** + * TypeExtension : + * - ScalarTypeExtension + * - ObjectTypeExtension + * - InterfaceTypeExtension + * - UnionTypeExtension + * - EnumTypeExtension + * - InputObjectTypeDefinition + * * @return TypeExtensionNode * @throws SyntaxError */ @@ -1248,14 +1285,45 @@ function parseTypeExtension() if ($keywordToken->kind === Token::NAME) { switch ($keywordToken->value) { + case 'scalar': + return $this->parseScalarTypeExtension(); case 'type': return $this->parseObjectTypeExtension(); + case 'interface': + return $this->parseInterfaceTypeExtension(); + case 'union': + return $this->parseUnionTypeExtension(); + case 'enum': + return $this->parseEnumTypeExtension(); + case 'input': + return $this->parseInputObjectTypeExtension(); } } throw $this->unexpected($keywordToken); } + /** + * @return ScalarTypeExtensionNode + * @throws SyntaxError + */ + function parseScalarTypeExtension() { + $start = $this->lexer->token; + $this->expectKeyword('extend'); + $this->expectKeyword('scalar'); + $name = $this->parseName(); + $directives = $this->parseDirectives(true); + if (count($directives) === 0) { + throw $this->unexpected(); + } + + return new ScalarTypeExtensionNode([ + 'name' => $name, + 'directives' => $directives, + 'loc' => $this->loc($start) + ]); + } + /** * @return ObjectTypeExtensionNode * @throws SyntaxError @@ -1267,9 +1335,7 @@ function parseObjectTypeExtension() { $name = $this->parseName(); $interfaces = $this->parseImplementsInterfaces(); $directives = $this->parseDirectives(true); - $fields = $this->peek(Token::BRACE_L) - ? $this->parseFieldDefinitions() - : []; + $fields = $this->parseFieldsDefinition(); if ( count($interfaces) === 0 && @@ -1288,6 +1354,110 @@ function parseObjectTypeExtension() { ]); } + /** + * @return InterfaceTypeExtensionNode + * @throws SyntaxError + */ + function parseInterfaceTypeExtension() { + $start = $this->lexer->token; + $this->expectKeyword('extend'); + $this->expectKeyword('interface'); + $name = $this->parseName(); + $directives = $this->parseDirectives(true); + $fields = $this->parseFieldsDefinition(); + if ( + count($directives) === 0 && + count($fields) === 0 + ) { + throw $this->unexpected(); + } + + return new InterfaceTypeExtensionNode([ + 'name' => $name, + 'directives' => $directives, + 'fields' => $fields, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return UnionTypeExtensionNode + * @throws SyntaxError + */ + function parseUnionTypeExtension() { + $start = $this->lexer->token; + $this->expectKeyword('extend'); + $this->expectKeyword('union'); + $name = $this->parseName(); + $directives = $this->parseDirectives(true); + $types = $this->parseMemberTypesDefinition(); + if ( + count($directives) === 0 && + count($types) === 0 + ) { + throw $this->unexpected(); + } + + return new UnionTypeExtensionNode([ + 'name' => $name, + 'directives' => $directives, + 'types' => $types, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return EnumTypeExtensionNode + * @throws SyntaxError + */ + function parseEnumTypeExtension() { + $start = $this->lexer->token; + $this->expectKeyword('extend'); + $this->expectKeyword('enum'); + $name = $this->parseName(); + $directives = $this->parseDirectives(true); + $values = $this->parseEnumValuesDefinition(); + if ( + count($directives) === 0 && + count($values) === 0 + ) { + throw $this->unexpected(); + } + + return new EnumTypeExtensionNode([ + 'name' => $name, + 'directives' => $directives, + 'values' => $values, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return InputObjectTypeExtensionNode + * @throws SyntaxError + */ + function parseInputObjectTypeExtension() { + $start = $this->lexer->token; + $this->expectKeyword('extend'); + $this->expectKeyword('input'); + $name = $this->parseName(); + $directives = $this->parseDirectives(true); + $fields = $this->parseInputFieldsDefinition(); + if ( + count($directives) === 0 && + count($fields) === 0 + ) { + throw $this->unexpected(); + } + + return new InputObjectTypeExtensionNode([ + 'name' => $name, + 'directives' => $directives, + 'fields' => $fields, + 'loc' => $this->loc($start) + ]); + } + /** * DirectiveDefinition : * - directive @ Name ArgumentsDefinition? on DirectiveLocations diff --git a/src/Language/Printer.php b/src/Language/Printer.php index b835b4d96..7c123a335 100644 --- a/src/Language/Printer.php +++ b/src/Language/Printer.php @@ -4,11 +4,14 @@ use GraphQL\Language\AST\ArgumentNode; use GraphQL\Language\AST\DirectiveDefinitionNode; use GraphQL\Language\AST\EnumTypeDefinitionNode; +use GraphQL\Language\AST\EnumTypeExtensionNode; use GraphQL\Language\AST\EnumValueDefinitionNode; use GraphQL\Language\AST\FieldDefinitionNode; use GraphQL\Language\AST\InputObjectTypeDefinitionNode; +use GraphQL\Language\AST\InputObjectTypeExtensionNode; use GraphQL\Language\AST\InputValueDefinitionNode; use GraphQL\Language\AST\InterfaceTypeDefinitionNode; +use GraphQL\Language\AST\InterfaceTypeExtensionNode; use GraphQL\Language\AST\ListValueNode; use GraphQL\Language\AST\BooleanValueNode; use GraphQL\Language\AST\DirectiveNode; @@ -32,11 +35,13 @@ use GraphQL\Language\AST\OperationDefinitionNode; use GraphQL\Language\AST\OperationTypeDefinitionNode; use GraphQL\Language\AST\ScalarTypeDefinitionNode; +use GraphQL\Language\AST\ScalarTypeExtensionNode; use GraphQL\Language\AST\SchemaDefinitionNode; use GraphQL\Language\AST\SelectionSetNode; use GraphQL\Language\AST\StringValueNode; use GraphQL\Language\AST\ObjectTypeExtensionNode; use GraphQL\Language\AST\UnionTypeDefinitionNode; +use GraphQL\Language\AST\UnionTypeExtensionNode; use GraphQL\Language\AST\VariableDefinitionNode; use GraphQL\Utils\Utils; @@ -246,7 +251,9 @@ public function printAST($ast) 'union', $def->name, $this->join($def->directives, ' '), - '= ' . $this->join($def->types, ' | ') + $def->types + ? '= ' . $this->join($def->types, ' | ') + : '' ], ' ') ], "\n"); }, @@ -278,6 +285,13 @@ public function printAST($ast) ], ' ') ], "\n"); }, + NodeKind::SCALAR_TYPE_EXTENSION => function(ScalarTypeExtensionNode $def) { + return $this->join([ + 'extend scalar', + $def->name, + $this->join($def->directives, ' '), + ], ' '); + }, NodeKind::OBJECT_TYPE_EXTENSION => function(ObjectTypeExtensionNode $def) { return $this->join([ 'extend type', @@ -287,6 +301,40 @@ public function printAST($ast) $this->block($def->fields), ], ' '); }, + NodeKind::INTERFACE_TYPE_EXTENSION => function(InterfaceTypeExtensionNode $def) { + return $this->join([ + 'extend interface', + $def->name, + $this->join($def->directives, ' '), + $this->block($def->fields), + ], ' '); + }, + NodeKind::UNION_TYPE_EXTENSION => function(UnionTypeExtensionNode $def) { + return $this->join([ + 'extend union', + $def->name, + $this->join($def->directives, ' '), + $def->types + ? '= ' . $this->join($def->types, ' | ') + : '' + ], ' '); + }, + NodeKind::ENUM_TYPE_EXTENSION => function(EnumTypeExtensionNode $def) { + return $this->join([ + 'extend enum', + $def->name, + $this->join($def->directives, ' '), + $this->block($def->values), + ], ' '); + }, + NodeKind::INPUT_OBJECT_TYPE_EXTENSION => function(InputObjectTypeExtensionNode $def) { + return $this->join([ + 'extend input', + $def->name, + $this->join($def->directives, ' '), + $this->block($def->fields), + ], ' '); + }, NodeKind::DIRECTIVE_DEFINITION => function(DirectiveDefinitionNode $def) { return $this->join([ $def->description, diff --git a/src/Language/Visitor.php b/src/Language/Visitor.php index fb149cda6..fc0a1e72c 100644 --- a/src/Language/Visitor.php +++ b/src/Language/Visitor.php @@ -142,7 +142,14 @@ class Visitor NodeKind::ENUM_TYPE_DEFINITION => ['description', 'name', 'directives', 'values'], NodeKind::ENUM_VALUE_DEFINITION => ['description', 'name', 'directives'], NodeKind::INPUT_OBJECT_TYPE_DEFINITION => ['description', 'name', 'directives', 'fields'], - NodeKind::OBJECT_TYPE_EXTENSION => [ 'name', 'interfaces', 'directives', 'fields' ], + + NodeKind::SCALAR_TYPE_EXTENSION => ['name', 'directives'], + NodeKind::OBJECT_TYPE_EXTENSION => ['name', 'interfaces', 'directives', 'fields'], + NodeKind::INTERFACE_TYPE_EXTENSION => ['name', 'directives', 'fields'], + NodeKind::UNION_TYPE_EXTENSION => ['name', 'directives', 'types'], + NodeKind::ENUM_TYPE_EXTENSION => ['name', 'directives', 'values'], + NodeKind::INPUT_OBJECT_TYPE_EXTENSION => ['name', 'directives', 'fields'], + NodeKind::DIRECTIVE_DEFINITION => ['description', 'name', 'arguments', 'locations'] ]; diff --git a/src/Utils/ASTDefinitionBuilder.php b/src/Utils/ASTDefinitionBuilder.php index d6d7f7a78..d38310bb8 100644 --- a/src/Utils/ASTDefinitionBuilder.php +++ b/src/Utils/ASTDefinitionBuilder.php @@ -251,15 +251,17 @@ private function makeTypeDef(ObjectTypeDefinitionNode $def) private function makeFieldDefMap($def) { - return Utils::keyValMap( - $def->fields, - function ($field) { - return $field->name->value; - }, - function ($field) { - return $this->buildField($field); - } - ); + return $def->fields + ? Utils::keyValMap( + $def->fields, + function ($field) { + return $field->name->value; + }, + function ($field) { + return $this->buildField($field); + } + ) + : []; } private function makeImplementedInterfaces(ObjectTypeDefinitionNode $def) @@ -313,20 +315,22 @@ private function makeEnumDef(EnumTypeDefinitionNode $def) return new EnumType([ 'name' => $def->name->value, 'description' => $this->getDescription($def), + 'values' => $def->values + ? Utils::keyValMap( + $def->values, + function ($enumValue) { + return $enumValue->name->value; + }, + function ($enumValue) { + return [ + 'description' => $this->getDescription($enumValue), + 'deprecationReason' => $this->getDeprecationReason($enumValue), + 'astNode' => $enumValue + ]; + } + ) + : [], 'astNode' => $def, - 'values' => Utils::keyValMap( - $def->values, - function ($enumValue) { - return $enumValue->name->value; - }, - function ($enumValue) { - return [ - 'description' => $this->getDescription($enumValue), - 'deprecationReason' => $this->getDeprecationReason($enumValue), - 'astNode' => $enumValue - ]; - } - ) ]); } @@ -335,10 +339,12 @@ private function makeUnionDef(UnionTypeDefinitionNode $def) return new UnionType([ 'name' => $def->name->value, 'description' => $this->getDescription($def), - 'types' => Utils::map($def->types, function ($typeNode) { - return $this->buildObjectType($typeNode); - }), - 'astNode' => $def + 'types' => $def->types + ? Utils::map($def->types, function ($typeNode) { + return $this->buildObjectType($typeNode); + }): + [], + 'astNode' => $def, ]); } @@ -360,7 +366,9 @@ private function makeInputObjectDef(InputObjectTypeDefinitionNode $def) 'name' => $def->name->value, 'description' => $this->getDescription($def), 'fields' => function () use ($def) { - return $this->makeInputValues($def->fields); + return $def->fields + ? $this->makeInputValues($def->fields) + : []; }, 'astNode' => $def, ]); diff --git a/src/Validator/Rules/KnownDirectives.php b/src/Validator/Rules/KnownDirectives.php index 9d48aa538..4ec3a01e2 100644 --- a/src/Validator/Rules/KnownDirectives.php +++ b/src/Validator/Rules/KnownDirectives.php @@ -67,20 +67,38 @@ private function getDirectiveLocationForASTPath(array $ancestors) case 'subscription': return DirectiveLocation::SUBSCRIPTION; } break; - case NodeKind::FIELD: return DirectiveLocation::FIELD; - case NodeKind::FRAGMENT_SPREAD: return DirectiveLocation::FRAGMENT_SPREAD; - case NodeKind::INLINE_FRAGMENT: return DirectiveLocation::INLINE_FRAGMENT; - case NodeKind::FRAGMENT_DEFINITION: return DirectiveLocation::FRAGMENT_DEFINITION; - case NodeKind::SCHEMA_DEFINITION: return DirectiveLocation::SCHEMA; - case NodeKind::SCALAR_TYPE_DEFINITION: return DirectiveLocation::SCALAR; + case NodeKind::FIELD: + return DirectiveLocation::FIELD; + case NodeKind::FRAGMENT_SPREAD: + return DirectiveLocation::FRAGMENT_SPREAD; + case NodeKind::INLINE_FRAGMENT: + return DirectiveLocation::INLINE_FRAGMENT; + case NodeKind::FRAGMENT_DEFINITION: + return DirectiveLocation::FRAGMENT_DEFINITION; + case NodeKind::SCHEMA_DEFINITION: + return DirectiveLocation::SCHEMA; + case NodeKind::SCALAR_TYPE_DEFINITION: + case NodeKind::SCALAR_TYPE_EXTENSION: + return DirectiveLocation::SCALAR; case NodeKind::OBJECT_TYPE_DEFINITION: - case NodeKind::OBJECT_TYPE_EXTENSION: return DirectiveLocation::OBJECT; - case NodeKind::FIELD_DEFINITION: return DirectiveLocation::FIELD_DEFINITION; - case NodeKind::INTERFACE_TYPE_DEFINITION: return DirectiveLocation::IFACE; - case NodeKind::UNION_TYPE_DEFINITION: return DirectiveLocation::UNION; - case NodeKind::ENUM_TYPE_DEFINITION: return DirectiveLocation::ENUM; - case NodeKind::ENUM_VALUE_DEFINITION: return DirectiveLocation::ENUM_VALUE; - case NodeKind::INPUT_OBJECT_TYPE_DEFINITION: return DirectiveLocation::INPUT_OBJECT; + case NodeKind::OBJECT_TYPE_EXTENSION: + return DirectiveLocation::OBJECT; + case NodeKind::FIELD_DEFINITION: + return DirectiveLocation::FIELD_DEFINITION; + case NodeKind::INTERFACE_TYPE_DEFINITION: + case NodeKind::INTERFACE_TYPE_EXTENSION: + return DirectiveLocation::IFACE; + case NodeKind::UNION_TYPE_DEFINITION: + case NodeKind::UNION_TYPE_EXTENSION: + return DirectiveLocation::UNION; + case NodeKind::ENUM_TYPE_DEFINITION: + case NodeKind::ENUM_TYPE_EXTENSION: + return DirectiveLocation::ENUM; + case NodeKind::ENUM_VALUE_DEFINITION: + return DirectiveLocation::ENUM_VALUE; + case NodeKind::INPUT_OBJECT_TYPE_DEFINITION: + case NodeKind::INPUT_OBJECT_TYPE_EXTENSION: + return DirectiveLocation::INPUT_OBJECT; case NodeKind::INPUT_VALUE_DEFINITION: $parentNode = $ancestors[count($ancestors) - 3]; return $parentNode instanceof InputObjectTypeDefinitionNode diff --git a/tests/Language/SchemaPrinterTest.php b/tests/Language/SchemaPrinterTest.php index 3141f0651..1e1b897a6 100644 --- a/tests/Language/SchemaPrinterTest.php +++ b/tests/Language/SchemaPrinterTest.php @@ -74,6 +74,14 @@ public function testPrintsKitchenSink() annotatedField(arg: Type = "default" @onArg): Type @onField } +type UndefinedType + +extend type Foo { + seven(argument: [String]): Type +} + +extend type Foo @onType + interface Bar { one: Type four(argument: String = "string"): String @@ -83,16 +91,32 @@ interface AnnotatedInterface @onInterface { annotatedField(arg: Type @onArg): Type @onField } +interface UndefinedInterface + +extend interface Bar { + two(argument: InputType!): Type +} + +extend interface Bar @onInterface + union Feed = Story | Article | Advert union AnnotatedUnion @onUnion = A | B union AnnotatedUnionTwo @onUnion = A | B +union UndefinedUnion + +extend union Feed = Photo | Video + +extend union Feed @onUnion + scalar CustomScalar scalar AnnotatedScalar @onScalar +extend scalar CustomScalar @onScalar + enum Site { DESKTOP MOBILE @@ -103,20 +127,30 @@ enum AnnotatedEnum @onEnum { OTHER_VALUE } +enum UndefinedEnum + +extend enum Site { + VR +} + +extend enum Site @onEnum + input InputType { key: String! answer: Int = 42 } -input AnnotatedInput @onInputObjectType { +input AnnotatedInput @onInputObject { annotatedField: Type @onField } -extend type Foo { - seven(argument: [String]): Type +input UndefinedInput + +extend input InputType { + other: Float = 1.23e4 } -extend type Foo @onType +extend input InputType @onInputObject directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT diff --git a/tests/Language/schema-kitchen-sink.graphql b/tests/Language/schema-kitchen-sink.graphql index 016707ddc..ae1a3e57f 100644 --- a/tests/Language/schema-kitchen-sink.graphql +++ b/tests/Language/schema-kitchen-sink.graphql @@ -13,20 +13,28 @@ This is a description of the `Foo` type. """ type Foo implements Bar { - one: Type - two(argument: InputType!): Type - three(argument: InputType, other: String): Int - four(argument: String = "string"): String - five(argument: [String] = ["string", "string"]): String - six(argument: InputType = {key: "value"}): Type - seven(argument: Int = null): Type +one: Type +two(argument: InputType!): Type +three(argument: InputType, other: String): Int +four(argument: String = "string"): String +five(argument: [String] = ["string", "string"]): String +six(argument: InputType = {key: "value"}): Type +seven(argument: Int = null): Type } type AnnotatedObject @onObject(arg: "value") { annotatedField(arg: Type = "default" @onArg): Type @onField } -interface Bar { +type UndefinedType + + extend type Foo { + seven(argument: [String]): Type +} + +extend type Foo @onType + + interface Bar { one: Type four(argument: String = "string"): String } @@ -35,49 +43,75 @@ interface AnnotatedInterface @onInterface { annotatedField(arg: Type @onArg): Type @onField } +interface UndefinedInterface + + extend interface Bar { + two(argument: InputType!): Type +} + +extend interface Bar @onInterface + union Feed = Story | Article | Advert union AnnotatedUnion @onUnion = A | B union AnnotatedUnionTwo @onUnion = | A | B +union UndefinedUnion + +extend union Feed = Photo | Video + +extend union Feed @onUnion + scalar CustomScalar scalar AnnotatedScalar @onScalar +extend scalar CustomScalar @onScalar + enum Site { - DESKTOP - MOBILE +DESKTOP +MOBILE } enum AnnotatedEnum @onEnum { - ANNOTATED_VALUE @onEnumValue - OTHER_VALUE +ANNOTATED_VALUE @onEnumValue +OTHER_VALUE +} + +enum UndefinedEnum + +extend enum Site { +VR } +extend enum Site @onEnum + input InputType { - key: String! - answer: Int = 42 +key: String! +answer: Int = 42 } -input AnnotatedInput @onInputObjectType { - annotatedField: Type @onField +input AnnotatedInput @onInputObject { +annotatedField: Type @onField } -extend type Foo { - seven(argument: [String]): Type +input UndefinedInput + +extend input InputType { +other: Float = 1.23e4 } -extend type Foo @onType +extend input InputType @onInputObject directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT directive @include(if: Boolean!) - on FIELD - | FRAGMENT_SPREAD - | INLINE_FRAGMENT +on FIELD +| FRAGMENT_SPREAD +| INLINE_FRAGMENT directive @include2(if: Boolean!) on - | FIELD - | FRAGMENT_SPREAD - | INLINE_FRAGMENT +| FIELD +| FRAGMENT_SPREAD +| INLINE_FRAGMENT diff --git a/tests/Validator/KnownDirectivesTest.php b/tests/Validator/KnownDirectivesTest.php index 3e7998ef0..9374b7324 100644 --- a/tests/Validator/KnownDirectivesTest.php +++ b/tests/Validator/KnownDirectivesTest.php @@ -139,20 +139,30 @@ public function testWSLWithWellPlacedDirectives() extend type MyObj @onObject scalar MyScalar @onScalar + + extend scalar MyScalar @onScalar interface MyInterface @onInterface { myField(myArg: Int @onArgumentDefinition): String @onFieldDefinition } + + extend interface MyInterface @onInterface union MyUnion @onUnion = MyObj | Other + + extend union MyUnion @onUnion enum MyEnum @onEnum { MY_VALUE @onEnumValue } + + extend enum MyEnum @onEnum input MyInput @onInputObject { myField: Int @onInputFieldDefinition } + + extend input MyInput @onInputObject schema @onSchema { query: MyQuery From 0c984a83bb61584e9379f3b9142dde1dbf8cbb45 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Sun, 11 Feb 2018 21:18:54 +0100 Subject: [PATCH 26/50] Allow constructing GraphQLError with single node. A common case is encountering an error which blames to a single AST node. Ensure the GraphQLError constructor can handle this case. ref: graphql/graphql-js#1123 --- src/Error/Error.php | 6 +++++- tests/ErrorTest.php | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/Error/Error.php b/src/Error/Error.php index ccbd323e8..06a1d1230 100644 --- a/src/Error/Error.php +++ b/src/Error/Error.php @@ -1,6 +1,7 @@ nodes = $nodes; diff --git a/tests/ErrorTest.php b/tests/ErrorTest.php index 8a3997048..fc526090e 100644 --- a/tests/ErrorTest.php +++ b/tests/ErrorTest.php @@ -37,6 +37,24 @@ public function testConvertsNodesToPositionsAndLocations() $this->assertEquals([new SourceLocation(2, 7)], $e->getLocations()); } + /** + * @it converts single node to positions and locations + */ + public function testConvertSingleNodeToPositionsAndLocations() + { + $source = new Source('{ + field + }'); + $ast = Parser::parse($source); + $fieldNode = $ast->definitions[0]->selectionSet->selections[0]; + $e = new Error('msg', $fieldNode); // Non-array value. + + $this->assertEquals([$fieldNode], $e->nodes); + $this->assertEquals($source, $e->getSource()); + $this->assertEquals([8], $e->getPositions()); + $this->assertEquals([new SourceLocation(2, 7)], $e->getLocations()); + } + /** * @it converts node with loc.start === 0 to positions and locations */ From 481cdc9a82936dfcbf6c333a1f0b4b64fde6f929 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Sun, 11 Feb 2018 21:32:40 +0100 Subject: [PATCH 27/50] Include test that printSchema includes non-spec directives. ref: graphql/graphql-js@007407deb0953fc95b8a341c064c42ec83124bc2 Also fixes expected <-> actual in this testfile --- tests/Utils/SchemaPrinterTest.php | 123 +++++++++++++++++++----------- 1 file changed, 79 insertions(+), 44 deletions(-) diff --git a/tests/Utils/SchemaPrinterTest.php b/tests/Utils/SchemaPrinterTest.php index 5dac50578..3c39a50a5 100644 --- a/tests/Utils/SchemaPrinterTest.php +++ b/tests/Utils/SchemaPrinterTest.php @@ -1,9 +1,10 @@ printSingleFieldSchema([ 'type' => Type::string() ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -48,7 +49,7 @@ public function testPrintsStringField() type Root { singleField: String } -'); +', $output); } /** @@ -59,7 +60,7 @@ public function testPrintArrayStringField() $output = $this->printSingleFieldSchema([ 'type' => Type::listOf(Type::string()) ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -67,7 +68,7 @@ public function testPrintArrayStringField() type Root { singleField: [String] } -'); +', $output); } /** @@ -78,7 +79,7 @@ public function testPrintNonNullStringField() $output = $this->printSingleFieldSchema([ 'type' => Type::nonNull(Type::string()) ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -86,7 +87,7 @@ public function testPrintNonNullStringField() type Root { singleField: String! } -'); +', $output); } /** @@ -97,7 +98,7 @@ public function testPrintNonNullArrayStringField() $output = $this->printSingleFieldSchema([ 'type' => Type::nonNull(Type::listOf(Type::string())) ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -105,7 +106,7 @@ public function testPrintNonNullArrayStringField() type Root { singleField: [String]! } -'); +', $output); } /** @@ -116,7 +117,7 @@ public function testPrintArrayNonNullStringField() $output = $this->printSingleFieldSchema([ 'type' => Type::listOf(Type::nonNull(Type::string())) ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -124,7 +125,7 @@ public function testPrintArrayNonNullStringField() type Root { singleField: [String!] } -'); +', $output); } /** @@ -135,7 +136,7 @@ public function testPrintNonNullArrayNonNullStringField() $output = $this->printSingleFieldSchema([ 'type' => Type::nonNull(Type::listOf(Type::nonNull(Type::string()))) ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -143,7 +144,7 @@ public function testPrintNonNullArrayNonNullStringField() type Root { singleField: [String!]! } -'); +', $output); } /** @@ -163,7 +164,7 @@ public function testPrintObjectField() $schema = new Schema(['query' => $root]); $output = $this->printForTest($schema); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -175,7 +176,7 @@ public function testPrintObjectField() type Root { foo: Foo } -'); +', $output); } /** @@ -187,7 +188,7 @@ public function testPrintsStringFieldWithIntArg() 'type' => Type::string(), 'args' => ['argOne' => ['type' => Type::int()]] ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -195,7 +196,7 @@ public function testPrintsStringFieldWithIntArg() type Root { singleField(argOne: Int): String } -'); +', $output); } /** @@ -207,7 +208,7 @@ public function testPrintsStringFieldWithIntArgWithDefault() 'type' => Type::string(), 'args' => ['argOne' => ['type' => Type::int(), 'defaultValue' => 2]] ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -215,7 +216,7 @@ public function testPrintsStringFieldWithIntArgWithDefault() type Root { singleField(argOne: Int = 2): String } -'); +', $output); } /** @@ -227,7 +228,7 @@ public function testPrintsStringFieldWithIntArgWithDefaultNull() 'type' => Type::string(), 'args' => ['argOne' => ['type' => Type::int(), 'defaultValue' => null]] ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -235,7 +236,7 @@ public function testPrintsStringFieldWithIntArgWithDefaultNull() type Root { singleField(argOne: Int = null): String } -'); +', $output); } /** @@ -247,7 +248,7 @@ public function testPrintsStringFieldWithNonNullIntArg() 'type' => Type::string(), 'args' => ['argOne' => ['type' => Type::nonNull(Type::int())]] ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -255,7 +256,7 @@ public function testPrintsStringFieldWithNonNullIntArg() type Root { singleField(argOne: Int!): String } -'); +', $output); } /** @@ -270,7 +271,7 @@ public function testPrintsStringFieldWithMultipleArgs() 'argTwo' => ['type' => Type::string()] ] ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -278,7 +279,7 @@ public function testPrintsStringFieldWithMultipleArgs() type Root { singleField(argOne: Int, argTwo: String): String } -'); +', $output); } /** @@ -294,7 +295,7 @@ public function testPrintsStringFieldWithMultipleArgsFirstIsDefault() 'argThree' => ['type' => Type::boolean()] ] ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -302,7 +303,7 @@ public function testPrintsStringFieldWithMultipleArgsFirstIsDefault() type Root { singleField(argOne: Int = 1, argTwo: String, argThree: Boolean): String } -'); +', $output); } /** @@ -318,7 +319,7 @@ public function testPrintsStringFieldWithMultipleArgsSecondIsDefault() 'argThree' => ['type' => Type::boolean()] ] ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -326,7 +327,7 @@ public function testPrintsStringFieldWithMultipleArgsSecondIsDefault() type Root { singleField(argOne: Int, argTwo: String = "foo", argThree: Boolean): String } -'); +', $output); } /** @@ -342,7 +343,7 @@ public function testPrintsStringFieldWithMultipleArgsLastIsDefault() 'argThree' => ['type' => Type::boolean(), 'defaultValue' => false] ] ]); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -350,7 +351,7 @@ public function testPrintsStringFieldWithMultipleArgsLastIsDefault() type Root { singleField(argOne: Int, argTwo: String, argThree: Boolean = false): String } -'); +', $output); } /** @@ -379,7 +380,7 @@ public function testPrintInterface() 'types' => [$barType] ]); $output = $this->printForTest($schema); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -395,7 +396,7 @@ interface Foo { type Root { bar: Bar } -'); +', $output); } /** @@ -432,7 +433,7 @@ public function testPrintMultipleInterface() 'types' => [$barType] ]); $output = $this->printForTest($schema); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -453,7 +454,7 @@ interface Foo { type Root { bar: Bar } -'); +', $output); } /** @@ -491,7 +492,7 @@ public function testPrintUnions() $schema = new Schema(['query' => $root]); $output = $this->printForTest($schema); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -512,7 +513,7 @@ public function testPrintUnions() } union SingleUnion = Foo -'); +', $output); } /** @@ -537,7 +538,7 @@ public function testInputType() $schema = new Schema(['query' => $root]); $output = $this->printForTest($schema); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -549,7 +550,7 @@ public function testInputType() type Root { str(argOne: InputType): String } -'); +', $output); } /** @@ -573,7 +574,7 @@ public function testCustomScalar() $schema = new Schema(['query' => $root]); $output = $this->printForTest($schema); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -583,7 +584,7 @@ public function testCustomScalar() type Root { odd: Odd } -'); +', $output); } /** @@ -609,7 +610,7 @@ public function testEnum() $schema = new Schema(['query' => $root]); $output = $this->printForTest($schema); - $this->assertEquals($output, ' + $this->assertEquals(' schema { query: Root } @@ -623,7 +624,41 @@ enum RGB { type Root { rgb: RGB } -'); +', $output); + } + + /** + * @it Prints custom directives + */ + public function testPrintsCustomDirectives() + { + $query = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'field' => ['type' => Type::string()], + ] + ]); + + $customDirectives = new Directive([ + 'name' => 'customDirective', + 'locations' => [ + DirectiveLocation::FIELD + ] + ]); + + $schema = new Schema([ + 'query' => $query, + 'directives' => [$customDirectives], + ]); + + $output = $this->printForTest($schema); + $this->assertEquals(' +directive @customDirective on FIELD + +type Query { + field: String +} +', $output); } /** From f661f3821574147e7ed7ea952d5163ca2650576c Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Sun, 11 Feb 2018 22:31:04 +0100 Subject: [PATCH 28/50] Fix unhandled error when parsing custom scalar literals. This factors out the enum value validation from scalar value validation and ensures the same try/catch is used in isValidLiteralValue as isValidPHPValue and protecting from errors in valueFromAST. ref: graphql/graphql-js#1126 --- docs/reference.md | 5 ++ src/Executor/Values.php | 71 ++++++++++++++++++++--------- src/Type/Definition/EnumType.php | 19 -------- src/Type/Definition/LeafType.php | 13 ------ src/Type/Definition/ScalarType.php | 24 ---------- src/Utils/AST.php | 32 +++++++++++-- src/Validator/DocumentValidator.php | 30 ++++++++++-- tests/Validator/TestCase.php | 32 +++++++------ tests/Validator/ValidationTest.php | 31 ++++++++++--- 9 files changed, 149 insertions(+), 108 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 1f86412e8..789eb5f51 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -936,7 +936,12 @@ const UNION_TYPE_DEFINITION = "UnionTypeDefinition"; const ENUM_TYPE_DEFINITION = "EnumTypeDefinition"; const ENUM_VALUE_DEFINITION = "EnumValueDefinition"; const INPUT_OBJECT_TYPE_DEFINITION = "InputObjectTypeDefinition"; +const SCALAR_TYPE_EXTENSION = "ScalarTypeExtension"; const OBJECT_TYPE_EXTENSION = "ObjectTypeExtension"; +const INTERFACE_TYPE_EXTENSION = "InterfaceTypeExtension"; +const UNION_TYPE_EXTENSION = "UnionTypeExtension"; +const ENUM_TYPE_EXTENSION = "EnumTypeExtension"; +const INPUT_OBJECT_TYPE_EXTENSION = "InputObjectTypeExtension"; const DIRECTIVE_DEFINITION = "DirectiveDefinition"; ``` diff --git a/src/Executor/Values.php b/src/Executor/Values.php index 49bcc3a33..0644db001 100644 --- a/src/Executor/Values.php +++ b/src/Executor/Values.php @@ -273,32 +273,37 @@ public static function isValidPHPValue($value, InputType $type) return $errors; } - Utils::invariant($type instanceof EnumType || $type instanceof ScalarType, 'Must be input type'); + if ($type instanceof EnumType) { + if (!is_string($value) || !$type->getValue($value)) { + $printed = Utils::printSafeJson($value); + return ["Expected type \"{$type->name}\", found $printed."]; + } + return []; + } - try { - // Scalar/Enum input checks to ensure the type can parse the value to - // a non-null value. + Utils::invariant($type instanceof ScalarType, 'Must be a scalar type'); + /** @var ScalarType $type */ - if (!$type->isValidValue($value)) { - $v = Utils::printSafeJson($value); + // Scalars determine if a value is valid via parseValue(). + try { + $parseResult = $type->parseValue($value); + if (Utils::isInvalid($parseResult)) { + $printed = Utils::printSafeJson($value); return [ - "Expected type \"{$type->name}\", found $v." + "Expected type \"{$type->name}\", found $printed." ]; } - } catch (\Exception $e) { - return [ - "Expected type \"{$type->name}\", found " . Utils::printSafeJson($value) . ': ' . - $e->getMessage() - ]; - } catch (\Throwable $e) { - return [ - "Expected type \"{$type->name}\", found " . Utils::printSafeJson($value) . ': ' . - $e->getMessage() - ]; + } catch (\Exception $error) { + $printed = Utils::printSafeJson($value); + $message = $error->getMessage(); + return ["Expected type \"{$type->name}\", found $printed; $message"]; + } catch (\Throwable $error) { + $printed = Utils::printSafeJson($value); + $message = $error->getMessage(); + return ["Expected type \"{$type->name}\", found $printed; $message"]; } - return []; } @@ -370,12 +375,34 @@ private static function coerceValue(Type $type, $value) return $coercedObj; } - Utils::invariant($type instanceof EnumType || $type instanceof ScalarType, 'Must be input type'); + if ($type instanceof EnumType) { + if (!is_string($value) || !$type->getValue($value)) { + return $undefined; + } - if ($type->isValidValue($value)) { - return $type->parseValue($value); + $enumValue = $type->getValue($value); + if (!$enumValue) { + return $undefined; + } + + return $enumValue->value; + } + + Utils::invariant($type instanceof ScalarType, 'Must be a scalar type'); + /** @var ScalarType $type */ + + // Scalars determine if a value is valid via parseValue(). + try { + $parseResult = $type->parseValue($value); + if (Utils::isInvalid($parseResult)) { + return $undefined; + } + } catch (\Exception $error) { + return $undefined; + } catch (\Throwable $error) { + return $undefined; } - return $undefined; + return $parseResult; } } diff --git a/src/Type/Definition/EnumType.php b/src/Type/Definition/EnumType.php index 1f188753e..0019d2431 100644 --- a/src/Type/Definition/EnumType.php +++ b/src/Type/Definition/EnumType.php @@ -115,25 +115,6 @@ public function serialize($value) return Utils::undefined(); } - /** - * @param string $value - * @return bool - */ - public function isValidValue($value) - { - return is_string($value) && $this->getNameLookup()->offsetExists($value); - } - - /** - * @param $valueNode - * @param array|null $variables - * @return bool - */ - public function isValidLiteral($valueNode, array $variables = null) - { - return $valueNode instanceof EnumValueNode && $this->getNameLookup()->offsetExists($valueNode->value); - } - /** * @param $value * @return null diff --git a/src/Type/Definition/LeafType.php b/src/Type/Definition/LeafType.php index 2ec8efc5b..9569b59c5 100644 --- a/src/Type/Definition/LeafType.php +++ b/src/Type/Definition/LeafType.php @@ -38,17 +38,4 @@ public function parseValue($value); * @return mixed */ public function parseLiteral($valueNode, array $variables = null); - - /** - * @param string $value - * @return bool - */ - public function isValidValue($value); - - /** - * @param Node $valueNode - * @param array|null $variables - * @return bool - */ - public function isValidLiteral($valueNode, array $variables = null); } diff --git a/src/Type/Definition/ScalarType.php b/src/Type/Definition/ScalarType.php index bc485b5d4..6038796c7 100644 --- a/src/Type/Definition/ScalarType.php +++ b/src/Type/Definition/ScalarType.php @@ -38,28 +38,4 @@ function __construct(array $config = []) Utils::assertValidName($this->name); } - - /** - * Determines if an internal value is valid for this type. - * - * @param $value - * @return bool - */ - public function isValidValue($value) - { - return !Utils::isInvalid($this->parseValue($value)); - } - - /** - * Determines if an internal value is valid for this type. - * Equivalent to checking for if the parsedLiteral is nullish. - * - * @param $valueNode - * @param array|null $variables - * @return bool - */ - public function isValidLiteral($valueNode, array $variables = null) - { - return !Utils::isInvalid($this->parseLiteral($valueNode, $variables)); - } } diff --git a/src/Utils/AST.php b/src/Utils/AST.php index ea38aa460..d5c867e55 100644 --- a/src/Utils/AST.php +++ b/src/Utils/AST.php @@ -383,15 +383,37 @@ public static function valueFromAST($valueNode, InputType $type, $variables = nu return $coercedObj; } - if (!$type instanceof ScalarType && !$type instanceof EnumType) { - throw new InvariantViolation('Must be input type'); + if ($type instanceof EnumType) { + if (!$valueNode instanceof EnumValueNode) { + return $undefined; + } + $enumValue = $type->getValue($valueNode->value); + if (!$enumValue) { + return $undefined; + } + + return $enumValue->value; } - if ($type->isValidLiteral($valueNode, $variables)) { - return $type->parseLiteral($valueNode, $variables); + Utils::invariant($type instanceof ScalarType, 'Must be scalar type'); + /** @var ScalarType $type */ + + // Scalars fulfill parsing a literal value via parseLiteral(). + // Invalid values represent a failure to parse correctly, in which case + // no value is returned. + try { + $result = $type->parseLiteral($valueNode, $variables); + } catch (\Exception $error) { + return $undefined; + } catch (\Throwable $error) { + return $undefined; + } + + if (Utils::isInvalid($result)) { + return $undefined; } - return $undefined; + return $result; } /** diff --git a/src/Validator/DocumentValidator.php b/src/Validator/DocumentValidator.php index 2b6e2424a..cf7f30c0a 100644 --- a/src/Validator/DocumentValidator.php +++ b/src/Validator/DocumentValidator.php @@ -2,6 +2,7 @@ namespace GraphQL\Validator; use GraphQL\Error\Error; +use GraphQL\Language\AST\EnumValueNode; use GraphQL\Language\AST\ListValueNode; use GraphQL\Language\AST\DocumentNode; use GraphQL\Language\AST\NodeKind; @@ -309,12 +310,33 @@ public static function isValidLiteralValue(Type $type, $valueNode) return $errors; } - Utils::invariant($type instanceof ScalarType || $type instanceof EnumType, 'Must be input type'); + if ($type instanceof EnumType) { + if (!$valueNode instanceof EnumValueNode || !$type->getValue($valueNode->value)) { + $printed = Printer::doPrint($valueNode); + return ["Expected type \"{$type->name}\", found $printed."]; + } + + return []; + } - // Scalars determine if a literal values is valid. - if (!$type->isValidLiteral($valueNode)) { + Utils::invariant($type instanceof ScalarType, 'Must be a scalar type'); + /** @var ScalarType $type */ + + // Scalars determine if a literal values is valid via parseLiteral(). + try { + $parseResult = $type->parseLiteral($valueNode); + if (Utils::isInvalid($parseResult)) { + $printed = Printer::doPrint($valueNode); + return ["Expected type \"{$type->name}\", found $printed."]; + } + } catch (\Exception $error) { + $printed = Printer::doPrint($valueNode); + $message = $error->getMessage(); + return ["Expected type \"{$type->name}\", found $printed; $message"]; + } catch (\Throwable $error) { $printed = Printer::doPrint($valueNode); - return [ "Expected type \"{$type->name}\", found $printed." ]; + $message = $error->getMessage(); + return ["Expected type \"{$type->name}\", found $printed; $message"]; } return []; diff --git a/tests/Validator/TestCase.php b/tests/Validator/TestCase.php index 028ac241c..770e650ae 100644 --- a/tests/Validator/TestCase.php +++ b/tests/Validator/TestCase.php @@ -260,24 +260,26 @@ public static function getTestSchema() ] ]); + $anyScalar = new CustomScalarType([ + 'name' => 'Any', + 'serialize' => function ($value) { return $value; }, + 'parseLiteral' => function ($node) { return $node; }, // Allows any value + 'parseValue' => function ($value) { return $value; }, // Allows any value + ]); + $invalidScalar = new CustomScalarType([ 'name' => 'Invalid', - 'serialize' => function ($value) { return $value; }, + 'serialize' => function ($value) { + return $value; + }, 'parseLiteral' => function ($node) { throw new \Exception('Invalid scalar is always invalid: ' . $node->value); }, - 'parseValue' => function ($value) { - throw new \Exception('Invalid scalar is always invalid: ' . $value); + 'parseValue' => function ($node) { + throw new \Exception('Invalid scalar is always invalid: ' . $node); }, ]); - $anyScalar = new CustomScalarType([ - 'name' => 'Any', - 'serialize' => function ($value) { return $value; }, - 'parseLiteral' => function ($node) { return $node; }, // Allows any value - 'parseValue' => function ($value) { return $value; }, // Allows any value - ]); - $queryRoot = new ObjectType([ 'name' => 'QueryRoot', 'fields' => [ @@ -293,14 +295,16 @@ public static function getTestSchema() 'dogOrHuman' => ['type' => $DogOrHuman], 'humanOrAlien' => ['type' => $HumanOrAlien], 'complicatedArgs' => ['type' => $ComplicatedArgs], - 'invalidArg' => [ - 'args' => ['arg' => ['type' => $invalidScalar]], - 'type' => Type::string(), - ], 'anyArg' => [ 'args' => ['arg' => ['type' => $anyScalar]], 'type' => Type::string(), ], + 'invalidArg' => [ + 'args' => [ + 'arg' => ['type' => $invalidScalar] + ], + 'type' => Type::string(), + ] ] ]); diff --git a/tests/Validator/ValidationTest.php b/tests/Validator/ValidationTest.php index a105bb2cd..7c7fc092d 100644 --- a/tests/Validator/ValidationTest.php +++ b/tests/Validator/ValidationTest.php @@ -1,6 +1,7 @@ assertSame($rule, $instance); + $expectedError = [ + 'message' => "Argument \"arg\" has invalid value \"bad value\". +Expected type \"Invalid\", found \"bad value\"; Invalid scalar is always invalid: bad value", + 'locations' => [ ['line' => 3, 'column' => 25] ] + ]; + + $this->expectInvalid( + $this->getTestSchema(), + null, + $doc, + [$expectedError] + ); } -*/ + public function testPassesValidationWithEmptyRules() { $query = '{invalid}'; From 15374a31dd26a3ae5e9fe14700f761b900be3490 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Mon, 12 Feb 2018 12:23:39 +0100 Subject: [PATCH 29/50] New: printError() Lifted from / inspired by a similar change in graphql/graphql-js#722, this creates a new function `printError()` (and uses it as the implementation for `GraphQLError#toString()`) which prints location information in the context of an error. This is moved from the syntax error where it used to be hard-coded, so it may now be used to format validation errors, value coercion errors, or any other error which may be associated with a location. ref: graphql/graphql-js BREAKING CHANGE: The SyntaxError message does not contain the codeframe anymore and only the message, (string) $error will print the codeframe. --- src/Error/Error.php | 8 ++ src/Error/FormattedError.php | 92 +++++++++++++ src/Error/SyntaxError.php | 61 +-------- src/Language/Source.php | 6 +- tests/Language/LexerTest.php | 204 ++++++++++++++++------------ tests/Language/ParserTest.php | 61 +++++++-- tests/Language/SchemaParserTest.php | 104 ++++++++++---- tests/Server/QueryExecutionTest.php | 2 +- tests/ServerTest.php | 14 +- 9 files changed, 363 insertions(+), 189 deletions(-) diff --git a/src/Error/Error.php b/src/Error/Error.php index 06a1d1230..a96a4c759 100644 --- a/src/Error/Error.php +++ b/src/Error/Error.php @@ -324,4 +324,12 @@ function jsonSerialize() { return $this->toSerializableArray(); } + + /** + * @return string + */ + public function __toString() + { + return FormattedError::printError($this); + } } diff --git a/src/Error/FormattedError.php b/src/Error/FormattedError.php index 5ed4adb26..16ed5e783 100644 --- a/src/Error/FormattedError.php +++ b/src/Error/FormattedError.php @@ -1,6 +1,7 @@ getSource(); + $locations = $error->getLocations(); + + $message = $error->getMessage(); + + foreach($locations as $location) { + $message .= $source + ? self::highlightSourceAtLocation($source, $location) + : " ({$location->line}:{$location->column})"; + } + + return $message; + } + + /** + * Render a helpful description of the location of the error in the GraphQL + * Source document. + * + * @param Source $source + * @param SourceLocation $location + * @return string + */ + private static function highlightSourceAtLocation(Source $source, SourceLocation $location) + { + $line = $location->line; + $lineOffset = $source->locationOffset->line - 1; + $columnOffset = self::getColumnOffset($source, $location); + $contextLine = $line + $lineOffset; + $contextColumn = $location->column + $columnOffset; + $prevLineNum = (string) ($contextLine - 1); + $lineNum = (string) $contextLine; + $nextLineNum = (string) ($contextLine + 1); + $padLen = strlen($nextLineNum); + $lines = preg_split('/\r\n|[\n\r]/', $source->body); + + $lines[0] = self::whitespace($source->locationOffset->column - 1) . $lines[0]; + + return ( + "\n\n{$source->name} ($contextLine:$contextColumn)\n" . + ($line >= 2 + ? (self::lpad($padLen, $prevLineNum) . ': ' . $lines[$line - 2] . "\n") + : '' + ) . + self::lpad($padLen, $lineNum) . + ': ' . + $lines[$line - 1] . + "\n" . + self::whitespace(2 + $padLen + $contextColumn - 1) . + "^\n" . + ($line < count($lines) + ? (self::lpad($padLen, $nextLineNum) . ': ' . $lines[$line] . "\n") + : '' + ) + ); + } + + /** + * @param Source $source + * @param SourceLocation $location + * @return int + */ + private static function getColumnOffset(Source $source, SourceLocation $location) + { + return $location->line === 1 ? $source->locationOffset->column - 1 : 0; + } + + /** + * @param int $len + * @return string + */ + private static function whitespace($len) { + return str_repeat(' ', $len); + } + + /** + * @param int $len + * @return string + */ + private static function lpad($len, $str) { + return self::whitespace($len - mb_strlen($str)) . $str; + } + /** * Standard GraphQL error formatter. Converts any exception to array * conforming to GraphQL spec. diff --git a/src/Error/SyntaxError.php b/src/Error/SyntaxError.php index 7f9bd5e92..ee17ab5da 100644 --- a/src/Error/SyntaxError.php +++ b/src/Error/SyntaxError.php @@ -2,7 +2,6 @@ namespace GraphQL\Error; use GraphQL\Language\Source; -use GraphQL\Language\SourceLocation; class SyntaxError extends Error { @@ -13,59 +12,11 @@ class SyntaxError extends Error */ public function __construct(Source $source, $position, $description) { - $location = $source->getLocation($position); - $line = $location->line + $source->locationOffset->line - 1; - $columnOffset = self::getColumnOffset($source, $location); - $column = $location->column + $columnOffset; - - $syntaxError = - "Syntax Error {$source->name} ({$line}:{$column}) $description\n" . - "\n". - self::highlightSourceAtLocation($source, $location); - - parent::__construct($syntaxError, null, $source, [$position]); - } - - /** - * @param Source $source - * @param SourceLocation $location - * @return string - */ - public static function highlightSourceAtLocation(Source $source, SourceLocation $location) - { - $line = $location->line; - $lineOffset = $source->locationOffset->line - 1; - $columnOffset = self::getColumnOffset($source, $location); - - $contextLine = $line + $lineOffset; - $prevLineNum = (string) ($contextLine - 1); - $lineNum = (string) $contextLine; - $nextLineNum = (string) ($contextLine + 1); - $padLen = mb_strlen($nextLineNum, 'UTF-8'); - - $unicodeChars = json_decode('"\u2028\u2029"'); // Quick hack to get js-compatible representation of these chars - $lines = preg_split('/\r\n|[\n\r' . $unicodeChars . ']/su', $source->body); - - $whitespace = function ($len) { - return str_repeat(' ', $len); - }; - - $lpad = function ($len, $str) { - return str_pad($str, $len - mb_strlen($str, 'UTF-8') + 1, ' ', STR_PAD_LEFT); - }; - - $lines[0] = $whitespace($source->locationOffset->column - 1) . $lines[0]; - - return - ($line >= 2 ? $lpad($padLen, $prevLineNum) . ': ' . $lines[$line - 2] . "\n" : '') . - ($lpad($padLen, $lineNum) . ': ' . $lines[$line - 1] . "\n") . - ($whitespace(2 + $padLen + $location->column - 1 + $columnOffset) . "^\n") . - ($line < count($lines) ? $lpad($padLen, $nextLineNum) . ': ' . $lines[$line] . "\n" : ''); + parent::__construct( + "Syntax Error: $description", + null, + $source, + [$position] + ); } - - public static function getColumnOffset(Source $source, SourceLocation $location) - { - return $location->line === 1 ? $source->locationOffset->column - 1 : 0; - } - } diff --git a/src/Language/Source.php b/src/Language/Source.php index 29fd0fe34..899d45024 100644 --- a/src/Language/Source.php +++ b/src/Language/Source.php @@ -39,8 +39,8 @@ class Source * be "Foo.graphql" and location to be `{ line: 40, column: 0 }`. * line and column in locationOffset are 1-indexed * - * @param $body - * @param null $name + * @param string $body + * @param string|null $name * @param SourceLocation|null $location */ public function __construct($body, $name = null, SourceLocation $location = null) @@ -52,7 +52,7 @@ public function __construct($body, $name = null, SourceLocation $location = null $this->body = $body; $this->length = mb_strlen($body, 'UTF-8'); - $this->name = $name ?: 'GraphQL'; + $this->name = $name ?: 'GraphQL request'; $this->locationOffset = $location ?: new SourceLocation(1, 1); Utils::invariant( diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index 946c39a6f..4d62b5ea5 100644 --- a/tests/Language/LexerTest.php +++ b/tests/Language/LexerTest.php @@ -15,10 +15,11 @@ class LexerTest extends \PHPUnit_Framework_TestCase */ public function testDissallowsUncommonControlCharacters() { - $char = Utils::chr(0x0007); - - $this->setExpectedExceptionRegExp(SyntaxError::class, '/' . preg_quote('Syntax Error GraphQL (1:1) Cannot contain the invalid character "\u0007"', '/') . '/'); - $this->lexOne($char); + $this->expectSyntaxError( + Utils::chr(0x0007), + 'Cannot contain the invalid character "\u0007"', + $this->loc(1, 1) + ); } /** @@ -107,14 +108,21 @@ public function testErrorsRespectWhitespace() " ?\n" . "\n"; - $this->setExpectedException(SyntaxError::class, - 'Syntax Error GraphQL (3:5) Cannot parse the unexpected character "?".' . "\n" . - "\n" . - "2: \n" . - "3: ?\n" . - " ^\n" . - "4: \n"); - $this->lexOne($str); + try { + $this->lexOne($str); + $this->fail('Expected exception not thrown'); + } catch (SyntaxError $error) { + $this->assertEquals( + 'Syntax Error: Cannot parse the unexpected character "?".' . "\n" . + "\n" . + "GraphQL request (3:5)\n" . + "2: \n" . + "3: ?\n" . + " ^\n" . + "4: \n", + (string) $error + ); + } } /** @@ -129,34 +137,42 @@ public function testUpdatesLineNumbersInErrorForFileContext() "\n"; $source = new Source($str, 'foo.js', new SourceLocation(11, 12)); - $this->setExpectedException( - SyntaxError::class, - 'Syntax Error foo.js (13:6) ' . - 'Cannot parse the unexpected character "?".' . "\n" . - "\n" . - '12: ' . "\n" . - '13: ?' . "\n" . - ' ^' . "\n" . - '14: ' . "\n" - ); - $lexer = new Lexer($source); - $lexer->advance(); + try { + $lexer = new Lexer($source); + $lexer->advance(); + $this->fail('Expected exception not thrown'); + } catch (SyntaxError $error) { + $this->assertEquals( + 'Syntax Error: Cannot parse the unexpected character "?".' . "\n" . + "\n" . + "foo.js (13:6)\n" . + "12: \n" . + "13: ?\n" . + " ^\n" . + "14: \n", + (string) $error + ); + } } public function testUpdatesColumnNumbersInErrorForFileContext() { $source = new Source('?', 'foo.js', new SourceLocation(1, 5)); - $this->setExpectedException( - SyntaxError::class, - 'Syntax Error foo.js (1:5) ' . - 'Cannot parse the unexpected character "?".' . "\n" . - "\n" . - '1: ?' . "\n" . - ' ^' . "\n" - ); - $lexer = new Lexer($source); - $lexer->advance(); + try { + $lexer = new Lexer($source); + $lexer->advance(); + $this->fail('Expected exception not thrown'); + } catch (SyntaxError $error) { + $this->assertEquals( + 'Syntax Error: Cannot parse the unexpected character "?".' . "\n" . + "\n" . + "foo.js (1:5)\n" . + '1: ?' . "\n" . + ' ^' . "\n", + (string) $error + ); + } } /** @@ -298,52 +314,50 @@ public function testLexesBlockString() \"\"\"")); } - public function reportsUsefulBlockStringErrors() { + public function reportsUsefulStringErrors() { return [ - ['"""', "Syntax Error GraphQL (1:4) Unterminated string.\n\n1: \"\"\"\n ^\n"], - ['"""no end quote', "Syntax Error GraphQL (1:16) Unterminated string.\n\n1: \"\"\"no end quote\n ^\n"], - ['"""contains unescaped ' . json_decode('"\u0007"') . ' control char"""', "Syntax Error GraphQL (1:23) Invalid character within String: \"\\u0007\""], - ['"""null-byte is not ' . json_decode('"\u0000"') . ' end of file"""', "Syntax Error GraphQL (1:21) Invalid character within String: \"\\u0000\""], + ['"', "Unterminated string.", $this->loc(1, 2)], + ['"no end quote', "Unterminated string.", $this->loc(1, 14)], + ["'single quotes'", "Unexpected single quote character ('), did you mean to use a double quote (\")?", $this->loc(1, 1)], + ['"contains unescaped \u0007 control char"', "Invalid character within String: \"\\u0007\"", $this->loc(1, 21)], + ['"null-byte is not \u0000 end of file"', 'Invalid character within String: "\\u0000"', $this->loc(1, 19)], + ['"multi' . "\n" . 'line"', "Unterminated string.", $this->loc(1, 7)], + ['"multi' . "\r" . 'line"', "Unterminated string.", $this->loc(1, 7)], + ['"bad \\z esc"', "Invalid character escape sequence: \\z", $this->loc(1, 7)], + ['"bad \\x esc"', "Invalid character escape sequence: \\x", $this->loc(1, 7)], + ['"bad \\u1 esc"', "Invalid character escape sequence: \\u1 es", $this->loc(1, 7)], + ['"bad \\u0XX1 esc"', "Invalid character escape sequence: \\u0XX1", $this->loc(1, 7)], + ['"bad \\uXXXX esc"', "Invalid character escape sequence: \\uXXXX", $this->loc(1, 7)], + ['"bad \\uFXXX esc"', "Invalid character escape sequence: \\uFXXX", $this->loc(1, 7)], + ['"bad \\uXXXF esc"', "Invalid character escape sequence: \\uXXXF", $this->loc(1, 7)], ]; } /** - * @dataProvider reportsUsefulBlockStringErrors - * @it lex reports useful block string errors + * @dataProvider reportsUsefulStringErrors + * @it lex reports useful string errors */ - public function testReportsUsefulBlockStringErrors($str, $expectedMessage) + public function testLexReportsUsefulStringErrors($str, $expectedMessage, $location) { - $this->setExpectedException(SyntaxError::class, $expectedMessage); - $this->lexOne($str); + $this->expectSyntaxError($str, $expectedMessage, $location); } - public function reportsUsefulStringErrors() { + public function reportsUsefulBlockStringErrors() { return [ - ['"', "Syntax Error GraphQL (1:2) Unterminated string.\n\n1: \"\n ^\n"], - ['"no end quote', "Syntax Error GraphQL (1:14) Unterminated string.\n\n1: \"no end quote\n ^\n"], - ["'single quotes'", "Syntax Error GraphQL (1:1) Unexpected single quote character ('), did you mean to use a double quote (\")?\n\n1: 'single quotes'\n ^\n"], - ['"contains unescaped \u0007 control char"', "Syntax Error GraphQL (1:21) Invalid character within String: \"\\u0007\"\n\n1: \"contains unescaped \\u0007 control char\"\n ^\n"], - ['"null-byte is not \u0000 end of file"', 'Syntax Error GraphQL (1:19) Invalid character within String: "\\u0000"' . "\n\n1: \"null-byte is not \\u0000 end of file\"\n ^\n"], - ['"multi' . "\n" . 'line"', "Syntax Error GraphQL (1:7) Unterminated string.\n\n1: \"multi\n ^\n2: line\"\n"], - ['"multi' . "\r" . 'line"', "Syntax Error GraphQL (1:7) Unterminated string.\n\n1: \"multi\n ^\n2: line\"\n"], - ['"bad \\z esc"', "Syntax Error GraphQL (1:7) Invalid character escape sequence: \\z\n\n1: \"bad \\z esc\"\n ^\n"], - ['"bad \\x esc"', "Syntax Error GraphQL (1:7) Invalid character escape sequence: \\x\n\n1: \"bad \\x esc\"\n ^\n"], - ['"bad \\u1 esc"', "Syntax Error GraphQL (1:7) Invalid character escape sequence: \\u1 es\n\n1: \"bad \\u1 esc\"\n ^\n"], - ['"bad \\u0XX1 esc"', "Syntax Error GraphQL (1:7) Invalid character escape sequence: \\u0XX1\n\n1: \"bad \\u0XX1 esc\"\n ^\n"], - ['"bad \\uXXXX esc"', "Syntax Error GraphQL (1:7) Invalid character escape sequence: \\uXXXX\n\n1: \"bad \\uXXXX esc\"\n ^\n"], - ['"bad \\uFXXX esc"', "Syntax Error GraphQL (1:7) Invalid character escape sequence: \\uFXXX\n\n1: \"bad \\uFXXX esc\"\n ^\n"], - ['"bad \\uXXXF esc"', "Syntax Error GraphQL (1:7) Invalid character escape sequence: \\uXXXF\n\n1: \"bad \\uXXXF esc\"\n ^\n"], + ['"""', "Unterminated string.", $this->loc(1, 4)], + ['"""no end quote', "Unterminated string.", $this->loc(1, 16)], + ['"""contains unescaped ' . json_decode('"\u0007"') . ' control char"""', "Invalid character within String: \"\\u0007\"", $this->loc(1, 23)], + ['"""null-byte is not ' . json_decode('"\u0000"') . ' end of file"""', "Invalid character within String: \"\\u0000\"", $this->loc(1, 21)], ]; } /** - * @dataProvider reportsUsefulStringErrors - * @it lex reports useful string errors + * @dataProvider reportsUsefulBlockStringErrors + * @it lex reports useful block string errors */ - public function testLexReportsUsefulStringErrors($str, $expectedMessage) + public function testReportsUsefulBlockStringErrors($str, $expectedMessage, $location) { - $this->setExpectedException(SyntaxError::class, $expectedMessage); - $this->lexOne($str); + $this->expectSyntaxError($str, $expectedMessage, $location); } /** @@ -420,15 +434,15 @@ public function testLexesNumbers() public function reportsUsefulNumberErrors() { return [ - [ '00', "Syntax Error GraphQL (1:2) Invalid number, unexpected digit after 0: \"0\"\n\n1: 00\n ^\n"], - [ '+1', "Syntax Error GraphQL (1:1) Cannot parse the unexpected character \"+\".\n\n1: +1\n ^\n"], - [ '1.', "Syntax Error GraphQL (1:3) Invalid number, expected digit but got: \n\n1: 1.\n ^\n"], - [ '1.e1', "Syntax Error GraphQL (1:3) Invalid number, expected digit but got: \"e\"\n\n1: 1.e1\n ^\n"], - [ '.123', "Syntax Error GraphQL (1:1) Cannot parse the unexpected character \".\".\n\n1: .123\n ^\n"], - [ '1.A', "Syntax Error GraphQL (1:3) Invalid number, expected digit but got: \"A\"\n\n1: 1.A\n ^\n"], - [ '-A', "Syntax Error GraphQL (1:2) Invalid number, expected digit but got: \"A\"\n\n1: -A\n ^\n"], - [ '1.0e', "Syntax Error GraphQL (1:5) Invalid number, expected digit but got: \n\n1: 1.0e\n ^\n"], - [ '1.0eA', "Syntax Error GraphQL (1:5) Invalid number, expected digit but got: \"A\"\n\n1: 1.0eA\n ^\n"], + [ '00', "Invalid number, unexpected digit after 0: \"0\"", $this->loc(1, 2)], + [ '+1', "Cannot parse the unexpected character \"+\".", $this->loc(1, 1)], + [ '1.', "Invalid number, expected digit but got: ", $this->loc(1, 3)], + [ '1.e1', "Invalid number, expected digit but got: \"e\"", $this->loc(1, 3)], + [ '.123', "Cannot parse the unexpected character \".\".", $this->loc(1, 1)], + [ '1.A', "Invalid number, expected digit but got: \"A\"", $this->loc(1, 3)], + [ '-A', "Invalid number, expected digit but got: \"A\"", $this->loc(1, 2)], + [ '1.0e', "Invalid number, expected digit but got: ", $this->loc(1, 5)], + [ '1.0eA', "Invalid number, expected digit but got: \"A\"", $this->loc(1, 5)], ]; } @@ -436,10 +450,9 @@ public function reportsUsefulNumberErrors() * @dataProvider reportsUsefulNumberErrors * @it lex reports useful number errors */ - public function testReportsUsefulNumberErrors($str, $expectedMessage) + public function testReportsUsefulNumberErrors($str, $expectedMessage, $location) { - $this->setExpectedException(SyntaxError::class, $expectedMessage); - $this->lexOne($str); + $this->expectSyntaxError($str, $expectedMessage, $location); } /** @@ -507,10 +520,10 @@ public function reportsUsefulUnknownCharErrors() $unicode2 = json_decode('"\u200b"'); return [ - ['..', "Syntax Error GraphQL (1:1) Cannot parse the unexpected character \".\".\n\n1: ..\n ^\n"], - ['?', "Syntax Error GraphQL (1:1) Cannot parse the unexpected character \"?\".\n\n1: ?\n ^\n"], - [$unicode1, "Syntax Error GraphQL (1:1) Cannot parse the unexpected character \"\\u203b\".\n\n1: $unicode1\n ^\n"], - [$unicode2, "Syntax Error GraphQL (1:1) Cannot parse the unexpected character \"\\u200b\".\n\n1: $unicode2\n ^\n"], + ['..', "Cannot parse the unexpected character \".\".", $this->loc(1, 1)], + ['?', "Cannot parse the unexpected character \"?\".", $this->loc(1, 1)], + [$unicode1, "Cannot parse the unexpected character \"\\u203b\".", $this->loc(1, 1)], + [$unicode2, "Cannot parse the unexpected character \"\\u200b\".", $this->loc(1, 1)], ]; } @@ -518,10 +531,9 @@ public function reportsUsefulUnknownCharErrors() * @dataProvider reportsUsefulUnknownCharErrors * @it lex reports useful unknown character error */ - public function testReportsUsefulUnknownCharErrors($str, $expectedMessage) + public function testReportsUsefulUnknownCharErrors($str, $expectedMessage, $location) { - $this->setExpectedException(SyntaxError::class, $expectedMessage); - $this->lexOne($str); + $this->expectSyntaxError($str, $expectedMessage, $location); } /** @@ -533,8 +545,14 @@ public function testReportsUsefulDashesInfo() $lexer = new Lexer(new Source($q)); $this->assertArraySubset(['kind' => Token::NAME, 'start' => 0, 'end' => 1, 'value' => 'a'], (array) $lexer->advance()); - $this->setExpectedException(SyntaxError::class, 'Syntax Error GraphQL (1:3) Invalid number, expected digit but got: "b"' . "\n\n1: a-b\n ^\n"); - $lexer->advance(); + $this->setExpectedException(SyntaxError::class, 'Syntax Error: Invalid number, expected digit but got: "b"'); + try { + $lexer->advance(); + $this->fail('Expected exception not thrown'); + } catch(SyntaxError $error) { + $this->assertEquals([$this->loc(1,3)], $error->getLocations()); + throw $error; + } } /** @@ -588,4 +606,20 @@ private function lexOne($body) $lexer = new Lexer(new Source($body)); return $lexer->advance(); } + + private function loc($line, $column) + { + return new SourceLocation($line, $column); + } + + private function expectSyntaxError($text, $message, $location) + { + $this->setExpectedException(SyntaxError::class, $message); + try { + $this->lexOne($text); + } catch (SyntaxError $error) { + $this->assertEquals([$location], $error->getLocations()); + throw $error; + } + } } diff --git a/tests/Language/ParserTest.php b/tests/Language/ParserTest.php index 71fd7e419..f4032e6a8 100644 --- a/tests/Language/ParserTest.php +++ b/tests/Language/ParserTest.php @@ -39,13 +39,13 @@ public function testAssertsThatASourceToParseIsNotObject() public function parseProvidesUsefulErrors() { return [ - ['{', "Syntax Error GraphQL (1:2) Expected Name, found \n\n1: {\n ^\n", [1], [new SourceLocation(1, 2)]], + ['{', "Syntax Error: Expected Name, found ", "Syntax Error: Expected Name, found \n\nGraphQL request (1:2)\n1: {\n ^\n", [1], [new SourceLocation(1, 2)]], ['{ ...MissingOn } fragment MissingOn Type -', "Syntax Error GraphQL (2:20) Expected \"on\", found Name \"Type\"\n\n1: { ...MissingOn }\n2: fragment MissingOn Type\n ^\n3: \n",], - ['{ field: {} }', "Syntax Error GraphQL (1:10) Expected Name, found {\n\n1: { field: {} }\n ^\n"], - ['notanoperation Foo { field }', "Syntax Error GraphQL (1:1) Unexpected Name \"notanoperation\"\n\n1: notanoperation Foo { field }\n ^\n"], - ['...', "Syntax Error GraphQL (1:1) Unexpected ...\n\n1: ...\n ^\n"], +', "Syntax Error: Expected \"on\", found Name \"Type\"", "Syntax Error: Expected \"on\", found Name \"Type\"\n\nGraphQL request (2:20)\n1: { ...MissingOn }\n2: fragment MissingOn Type\n ^\n3: \n",], + ['{ field: {} }', "Syntax Error: Expected Name, found {", "Syntax Error: Expected Name, found {\n\nGraphQL request (1:10)\n1: { field: {} }\n ^\n"], + ['notanoperation Foo { field }', "Syntax Error: Unexpected Name \"notanoperation\"", "Syntax Error: Unexpected Name \"notanoperation\"\n\nGraphQL request (1:1)\n1: notanoperation Foo { field }\n ^\n"], + ['...', "Syntax Error: Unexpected ...", "Syntax Error: Unexpected ...\n\nGraphQL request (1:1)\n1: ...\n ^\n"], ]; } @@ -53,13 +53,14 @@ public function parseProvidesUsefulErrors() * @dataProvider parseProvidesUsefulErrors * @it parse provides useful errors */ - public function testParseProvidesUsefulErrors($str, $expectedMessage, $expectedPositions = null, $expectedLocations = null) + public function testParseProvidesUsefulErrors($str, $expectedMessage, $stringRepresentation, $expectedPositions = null, $expectedLocations = null) { try { Parser::parse($str); $this->fail('Expected exception not thrown'); } catch (SyntaxError $e) { $this->assertEquals($expectedMessage, $e->getMessage()); + $this->assertEquals($stringRepresentation, (string) $e); if ($expectedPositions) { $this->assertEquals($expectedPositions, $e->getPositions()); @@ -76,8 +77,15 @@ public function testParseProvidesUsefulErrors($str, $expectedMessage, $expectedP */ public function testParseProvidesUsefulErrorWhenUsingSource() { - $this->setExpectedException(SyntaxError::class, "Syntax Error MyQuery.graphql (1:6) Expected {, found \n\n1: query\n ^\n"); - Parser::parse(new Source('query', 'MyQuery.graphql')); + try { + Parser::parse(new Source('query', 'MyQuery.graphql')); + $this->fail('Expected exception not thrown'); + } catch (SyntaxError $error) { + $this->assertEquals( + "Syntax Error: Expected {, found \n\nMyQuery.graphql (1:6)\n1: query\n ^\n", + (string) $error + ); + } } /** @@ -94,8 +102,11 @@ public function testParsesVariableInlineValues() */ public function testParsesConstantDefaultValues() { - $this->setExpectedException(SyntaxError::class, "Syntax Error GraphQL (1:37) Unexpected $\n\n" . '1: query Foo($x: Complex = { a: { b: [ $var ] } }) { field }' . "\n ^\n"); - Parser::parse('query Foo($x: Complex = { a: { b: [ $var ] } }) { field }'); + $this->expectSyntaxError( + 'query Foo($x: Complex = { a: { b: [ $var ] } }) { field }', + 'Unexpected $', + $this->loc(1,37) + ); } /** @@ -103,8 +114,11 @@ public function testParsesConstantDefaultValues() */ public function testDoesNotAcceptFragmentsNamedOn() { - $this->setExpectedException('GraphQL\Error\SyntaxError', 'Syntax Error GraphQL (1:10) Unexpected Name "on"'); - Parser::parse('fragment on on on { on }'); + $this->expectSyntaxError( + 'fragment on on on { on }', + 'Unexpected Name "on"', + $this->loc(1,10) + ); } /** @@ -112,8 +126,11 @@ public function testDoesNotAcceptFragmentsNamedOn() */ public function testDoesNotAcceptFragmentSpreadOfOn() { - $this->setExpectedException('GraphQL\Error\SyntaxError', 'Syntax Error GraphQL (1:9) Expected Name, found }'); - Parser::parse('{ ...on }'); + $this->expectSyntaxError( + '{ ...on }', + 'Expected Name, found }', + $this->loc(1,9) + ); } /** @@ -610,4 +627,20 @@ public static function nodeToArray(Node $node) { return TestUtils::nodeToArray($node); } + + private function loc($line, $column) + { + return new SourceLocation($line, $column); + } + + private function expectSyntaxError($text, $message, $location) + { + $this->setExpectedException(SyntaxError::class, $message); + try { + Parser::parse($text); + } catch (SyntaxError $error) { + $this->assertEquals([$location], $error->getLocations()); + throw $error; + } + } } diff --git a/tests/Language/SchemaParserTest.php b/tests/Language/SchemaParserTest.php index 77e2e92c9..b7d018abd 100644 --- a/tests/Language/SchemaParserTest.php +++ b/tests/Language/SchemaParserTest.php @@ -4,6 +4,7 @@ use GraphQL\Error\SyntaxError; use GraphQL\Language\AST\NodeKind; use GraphQL\Language\Parser; +use GraphQL\Language\SourceLocation; class SchemaParserTest extends \PHPUnit_Framework_TestCase { @@ -198,32 +199,50 @@ public function testExtensionWithoutFields() $this->assertEquals($expected, TestUtils::nodeToArray($doc)); } + /** + * @it Extension without anything throws + */ + public function testExtensionWithoutAnythingThrows() + { + $this->expectSyntaxError( + 'extend type Hello', + 'Unexpected ', + $this->loc(1, 18) + ); + } + /** * @it Extension do not include descriptions - * @expectedException \GraphQL\Error\SyntaxError - * @expectedExceptionMessage Syntax Error GraphQL (3:7) */ - public function testExtensionDoNotIncludeDescriptions() { + public function testExtensionDoNotIncludeDescriptions() + { $body = ' "Description" extend type Hello { world: String }'; - Parser::parse($body); + $this->expectSyntaxError( + $body, + 'Unexpected Name "extend"', + $this->loc(3, 7) + ); } /** * @it Extension do not include descriptions - * @expectedException \GraphQL\Error\SyntaxError - * @expectedExceptionMessage Syntax Error GraphQL (2:14) */ - public function testExtensionDoNotIncludeDescriptions2() { + public function testExtensionDoNotIncludeDescriptions2() + { $body = ' extend "Description" type Hello { world: String } }'; - Parser::parse($body); + $this->expectSyntaxError( + $body, + 'Unexpected String "Description"', + $this->loc(2, 14) + ); } /** @@ -707,9 +726,11 @@ public function testUnionWithTwoTypesAndLeadingPipe() */ public function testUnionFailsWithNoTypes() { - $body = 'union Hello = |'; - $this->setExpectedExceptionRegExp(SyntaxError::class, '/' . preg_quote('Syntax Error GraphQL (1:16) Expected Name, found ', '/') . '/'); - Parser::parse($body); + $this->expectSyntaxError( + 'union Hello = |', + 'Expected Name, found ', + $this->loc(1, 16) + ); } /** @@ -717,9 +738,11 @@ public function testUnionFailsWithNoTypes() */ public function testUnionFailsWithLeadingDoublePipe() { - $body = 'union Hello = || Wo | Rld'; - $this->setExpectedExceptionRegExp(SyntaxError::class, '/' . preg_quote('Syntax Error GraphQL (1:16) Expected Name, found |', '/') . '/'); - Parser::parse($body); + $this->expectSyntaxError( + 'union Hello = || Wo | Rld', + 'Expected Name, found |', + $this->loc(1, 16) + ); } /** @@ -727,9 +750,11 @@ public function testUnionFailsWithLeadingDoublePipe() */ public function testUnionFailsWithDoublePipe() { - $body = 'union Hello = Wo || Rld'; - $this->setExpectedExceptionRegExp(SyntaxError::class, '/' . preg_quote('Syntax Error GraphQL (1:19) Expected Name, found |', '/') . '/'); - Parser::parse($body); + $this->expectSyntaxError( + 'union Hello = Wo || Rld', + 'Expected Name, found |', + $this->loc(1, 19) + ); } /** @@ -737,9 +762,11 @@ public function testUnionFailsWithDoublePipe() */ public function testUnionFailsWithTrailingPipe() { - $body = 'union Hello = | Wo | Rld |'; - $this->setExpectedExceptionRegExp(SyntaxError::class, '/' . preg_quote('Syntax Error GraphQL (1:27) Expected Name, found ', '/') . '/'); - Parser::parse($body); + $this->expectSyntaxError( + 'union Hello = | Wo | Rld |', + 'Expected Name, found ', + $this->loc(1, 27) + ); } /** @@ -804,28 +831,33 @@ public function testSimpleInputObject() /** * @it Simple input object with args should fail - * @expectedException \GraphQL\Error\SyntaxError */ public function testSimpleInputObjectWithArgsShouldFail() { $body = ' -input Hello { - world(foo: Int): String -}'; - Parser::parse($body); + input Hello { + world(foo: Int): String + }'; + $this->expectSyntaxError( + $body, + 'Expected :, found (', + $this->loc(3, 14) + ); } /** * @it Directive with incorrect locations - * @expectedException \GraphQL\Error\SyntaxError - * @expectedExceptionMessage Syntax Error GraphQL (2:33) Unexpected Name "INCORRECT_LOCATION" */ public function testDirectiveWithIncorrectLocationShouldFail() { $body = ' directive @foo on FIELD | INCORRECT_LOCATION '; - Parser::parse($body); + $this->expectSyntaxError( + $body, + 'Unexpected Name "INCORRECT_LOCATION"', + $this->loc(2, 33) + ); } private function typeNode($name, $loc) @@ -887,4 +919,20 @@ private function inputValueNode($name, $type, $defaultValue, $loc) 'description' => null ]; } + + private function loc($line, $column) + { + return new SourceLocation($line, $column); + } + + private function expectSyntaxError($text, $message, $location) + { + $this->setExpectedException(SyntaxError::class, $message); + try { + Parser::parse($text); + } catch (SyntaxError $error) { + $this->assertEquals([$location], $error->getLocations()); + throw $error; + } + } } diff --git a/tests/Server/QueryExecutionTest.php b/tests/Server/QueryExecutionTest.php index 75982cb79..00b26ea7e 100644 --- a/tests/Server/QueryExecutionTest.php +++ b/tests/Server/QueryExecutionTest.php @@ -52,7 +52,7 @@ public function testReturnsSyntaxErrors() $this->assertSame(null, $result->data); $this->assertCount(1, $result->errors); $this->assertContains( - 'Syntax Error GraphQL (1:4) Expected Name, found ', + 'Syntax Error: Expected Name, found ', $result->errors[0]->getMessage() ); } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index a46684269..03c689520 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -303,10 +303,18 @@ public function testParse() $server = Server::create(); $ast = $server->parse('{q}'); $this->assertInstanceOf('GraphQL\Language\AST\DocumentNode', $ast); + } - $this->setExpectedExceptionRegExp(SyntaxError::class, '/' . preg_quote('{q', '/') . '/'); - $server->parse('{q'); - $this->fail('Expected exception not thrown'); + public function testParseFailure() + { + $server = Server::create(); + try { + $server->parse('{q'); + $this->fail('Expected exception not thrown'); + } catch (SyntaxError $error) { + $this->assertContains('{q', (string) $error); + $this->assertEquals('Syntax Error: Expected Name, found ', $error->getMessage()); + } } public function testValidate() From 06c6c4bd975190cba572d341e8929ea1ed1b9825 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Mon, 12 Feb 2018 22:41:52 +0100 Subject: [PATCH 30/50] Validate schema root types and directives This moves validation out of GraphQLSchema's constructor (but not yet from other type constructors), which is responsible for root type validation and interface implementation checking. Reduces time to construct GraphQLSchema significantly, shifting the time to validation. This also allows for much looser rules within the schema builders, which implicitly validate while trying to adhere to flow types. Instead we use any casts to loosen the rules to defer that to validation where errors can be richer. This also loosens the rule that a schema can only be constructed if it has a query type, moving that to validation as well. That makes flow typing slightly less nice, but allows for incremental schema building which is valuable ref: graphql/graphql-js#1124 --- docs/reference.md | 27 +- src/Executor/Executor.php | 16 +- src/Server.php | 1 + src/Type/Definition/InterfaceType.php | 7 + src/Type/Definition/ObjectType.php | 18 +- src/Type/Definition/Type.php | 20 + src/Type/Schema.php | 189 ++--- src/Type/SchemaConfig.php | 63 +- src/Type/SchemaValidationContext.php | 384 +++++++++ src/Utils.php | 3 + src/Utils/ASTDefinitionBuilder.php | 7 +- src/Utils/BuildSchema.php | 24 +- src/Utils/TypeComparators.php | 2 +- tests/ServerTest.php | 34 +- tests/Type/ValidationTest.php | 1091 +++++++++++++------------ tests/Utils/BuildSchemaTest.php | 34 - 16 files changed, 1152 insertions(+), 768 deletions(-) create mode 100644 src/Type/SchemaValidationContext.php diff --git a/docs/reference.md b/docs/reference.md index 789eb5f51..7ee6f21fe 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -231,6 +231,15 @@ static function isCompositeType($type) static function isAbstractType($type) ``` +```php +/** + * @api + * @param Type $type + * @return bool + */ +static function isType($type) +``` + ```php /** * @api @@ -431,7 +440,7 @@ static function create(array $options = []) * @param ObjectType $query * @return SchemaConfig */ -function setQuery(GraphQL\Type\Definition\ObjectType $query) +function setQuery($query) ``` ```php @@ -440,7 +449,7 @@ function setQuery(GraphQL\Type\Definition\ObjectType $query) * @param ObjectType $mutation * @return SchemaConfig */ -function setMutation(GraphQL\Type\Definition\ObjectType $mutation) +function setMutation($mutation) ``` ```php @@ -449,7 +458,7 @@ function setMutation(GraphQL\Type\Definition\ObjectType $mutation) * @param ObjectType $subscription * @return SchemaConfig */ -function setSubscription(GraphQL\Type\Definition\ObjectType $subscription) +function setSubscription($subscription) ``` ```php @@ -670,6 +679,18 @@ function getDirectives() function getDirective($name) ``` +```php +/** + * Validates schema. + * + * This operation requires full schema scan. Do not use in production environment. + * + * @api + * @return InvariantViolation[]|Error[] + */ +function validate() +``` + ```php /** * Validates schema. diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index fab555a09..8ce9baa09 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -333,7 +333,6 @@ private function executeOperation(OperationDefinitionNode $operation, $rootValue } } - /** * Extracts the root type of the operation from the schema. * @@ -346,12 +345,19 @@ private function getOperationRootType(Schema $schema, OperationDefinitionNode $o { switch ($operation->operation) { case 'query': - return $schema->getQueryType(); + $queryType = $schema->getQueryType(); + if (!$queryType) { + throw new Error( + 'Schema does not define the required query root type.', + [$operation] + ); + } + return $queryType; case 'mutation': $mutationType = $schema->getMutationType(); if (!$mutationType) { throw new Error( - 'Schema is not configured for mutations', + 'Schema is not configured for mutations.', [$operation] ); } @@ -360,14 +366,14 @@ private function getOperationRootType(Schema $schema, OperationDefinitionNode $o $subscriptionType = $schema->getSubscriptionType(); if (!$subscriptionType) { throw new Error( - 'Schema is not configured for subscriptions', + 'Schema is not configured for subscriptions.', [ $operation ] ); } return $subscriptionType; default: throw new Error( - 'Can only execute queries, mutations and subscriptions', + 'Can only execute queries, mutations and subscriptions.', [$operation] ); } diff --git a/src/Server.php b/src/Server.php index 3350b1fb1..16d1f05ff 100644 --- a/src/Server.php +++ b/src/Server.php @@ -472,6 +472,7 @@ public function validate(DocumentNode $query) { try { $schema = $this->getSchema(); + $schema->assertValid(); } catch (InvariantViolation $e) { throw new InvariantViolation("Cannot validate, schema contains errors: {$e->getMessage()}", null, $e); } diff --git a/src/Type/Definition/InterfaceType.php b/src/Type/Definition/InterfaceType.php index 9d532eca7..a92be4898 100644 --- a/src/Type/Definition/InterfaceType.php +++ b/src/Type/Definition/InterfaceType.php @@ -3,6 +3,7 @@ use GraphQL\Error\InvariantViolation; use GraphQL\Language\AST\InterfaceTypeDefinitionNode; +use GraphQL\Language\AST\InterfaceTypeExtensionNode; use GraphQL\Utils\Utils; /** @@ -21,6 +22,11 @@ class InterfaceType extends Type implements AbstractType, OutputType, CompositeT */ public $astNode; + /** + * @var InterfaceTypeExtensionNode[] + */ + public $extensionASTNodes; + /** * InterfaceType constructor. * @param array $config @@ -46,6 +52,7 @@ public function __construct(array $config) $this->name = $config['name']; $this->description = isset($config['description']) ? $config['description'] : null; $this->astNode = isset($config['astNode']) ? $config['astNode'] : null; + $this->extensionASTNodes = isset($config['extensionASTNodes']) ? $config['extensionASTNodes'] : null; $this->config = $config; } diff --git a/src/Type/Definition/ObjectType.php b/src/Type/Definition/ObjectType.php index 717942f24..5f2d3e12d 100644 --- a/src/Type/Definition/ObjectType.php +++ b/src/Type/Definition/ObjectType.php @@ -152,13 +152,13 @@ public function getInterfaces() $interfaces = isset($this->config['interfaces']) ? $this->config['interfaces'] : []; $interfaces = is_callable($interfaces) ? call_user_func($interfaces) : $interfaces; - if (!is_array($interfaces)) { + if ($interfaces && !is_array($interfaces)) { throw new InvariantViolation( "{$this->name} interfaces must be an Array or a callable which returns an Array." ); } - $this->interfaces = $interfaces; + $this->interfaces = $interfaces ?: []; } return $this->interfaces; } @@ -227,19 +227,5 @@ public function assertValid() $arg->assertValid($field, $this); } } - - $implemented = []; - foreach ($this->getInterfaces() as $iface) { - Utils::invariant( - $iface instanceof InterfaceType, - "{$this->name} may only implement Interface types, it cannot implement %s.", - Utils::printSafe($iface) - ); - Utils::invariant( - !isset($implemented[$iface->name]), - "{$this->name} may declare it implements {$iface->name} only once." - ); - $implemented[$iface->name] = true; - } } } diff --git a/src/Type/Definition/Type.php b/src/Type/Definition/Type.php index 89bac9b4c..5afceb836 100644 --- a/src/Type/Definition/Type.php +++ b/src/Type/Definition/Type.php @@ -2,6 +2,7 @@ namespace GraphQL\Type\Definition; use GraphQL\Error\InvariantViolation; +use GraphQL\Language\AST\ListType; use GraphQL\Language\AST\NamedType; use GraphQL\Language\AST\TypeDefinitionNode; use GraphQL\Type\Introspection; @@ -203,6 +204,25 @@ public static function isAbstractType($type) return $type instanceof AbstractType; } + /** + * @api + * @param Type $type + * @return bool + */ + public static function isType($type) + { + return ( + $type instanceof ScalarType || + $type instanceof ObjectType || + $type instanceof InterfaceType || + $type instanceof UnionType || + $type instanceof EnumType || + $type instanceof InputObjectType || + $type instanceof ListType || + $type instanceof NonNull + ); + } + /** * @api * @param Type $type diff --git a/src/Type/Schema.php b/src/Type/Schema.php index b4ec795d5..b68ef1243 100644 --- a/src/Type/Schema.php +++ b/src/Type/Schema.php @@ -1,18 +1,16 @@ $subscriptionType ]; } + if (is_array($config)) { $config = SchemaConfig::create($config); } - Utils::invariant( - $config instanceof SchemaConfig, - 'Schema constructor expects instance of GraphQL\Type\SchemaConfig or an array with keys: %s; but got: %s', - implode(', ', [ - 'query', - 'mutation', - 'subscription', - 'types', - 'directives', - 'typeLoader' - ]), - Utils::getVariableType($config) - ); - - Utils::invariant( - $config->query instanceof ObjectType, - "Schema query must be Object Type but got: " . Utils::getVariableType($config->query) - ); + // If this schema was built from a source known to be valid, then it may be + // marked with assumeValid to avoid an additional type system validation. + if ($config->getAssumeValid()) { + $this->validationErrors = []; + } else { + // Otherwise check for common mistakes during construction to produce + // clear and early error messages. + Utils::invariant( + $config instanceof SchemaConfig, + 'Schema constructor expects instance of GraphQL\Type\SchemaConfig or an array with keys: %s; but got: %s', + implode(', ', [ + 'query', + 'mutation', + 'subscription', + 'types', + 'directives', + 'typeLoader' + ]), + Utils::getVariableType($config) + ); + Utils::invariant( + !$config->types || is_array($config->types) || is_callable($config->types), + "\"types\" must be array or callable if provided but got: " . Utils::getVariableType($config->types) + ); + Utils::invariant( + !$config->directives || is_array($config->directives), + "\"directives\" must be Array if provided but got: " . Utils::getVariableType($config->directives) + ); + } $this->config = $config; - $this->resolvedTypes[$config->query->name] = $config->query; - + if ($config->query) { + $this->resolvedTypes[$config->query->name] = $config->query; + } if ($config->mutation) { $this->resolvedTypes[$config->mutation->name] = $config->mutation; } if ($config->subscription) { $this->resolvedTypes[$config->subscription->name] = $config->subscription; } - if (is_array($this->config->types)) { + if ($this->config->types) { foreach ($this->resolveAdditionalTypes() as $type) { if (isset($this->resolvedTypes[$type->name])) { Utils::invariant( @@ -393,6 +409,32 @@ private function defaultTypeLoader($typeName) return isset($typeMap[$typeName]) ? $typeMap[$typeName] : null; } + /** + * Validates schema. + * + * This operation requires full schema scan. Do not use in production environment. + * + * @api + * @return InvariantViolation[]|Error[] + */ + public function validate() { + // If this Schema has already been validated, return the previous results. + if ($this->validationErrors !== null) { + return $this->validationErrors; + } + // Validate the schema, producing a list of errors. + $context = new SchemaValidationContext($this); + $context->validateRootTypes(); + $context->validateDirectives(); + $context->validateTypes(); + + // Persist the results of validation before returning to ensure validation + // does not run multiple times for this schema. + $this->validationErrors = $context->getErrors(); + + return $this->validationErrors; + } + /** * Validates schema. * @@ -403,18 +445,13 @@ private function defaultTypeLoader($typeName) */ public function assertValid() { - foreach ($this->config->getDirectives() as $index => $directive) { - Utils::invariant( - $directive instanceof Directive, - "Each entry of \"directives\" option of Schema config must be an instance of %s but entry at position %d is %s.", - Directive::class, - $index, - Utils::printSafe($directive) - ); + $errors = $this->validate(); + + if ($errors) { + throw new InvariantViolation(implode("\n\n", $this->validationErrors)); } $internalTypes = Type::getInternalTypes() + Introspection::getTypes(); - foreach ($this->getTypeMap() as $name => $type) { if (isset($internalTypes[$name])) { continue ; @@ -422,22 +459,6 @@ public function assertValid() $type->assertValid(); - if ($type instanceof AbstractType) { - $possibleTypes = $this->getPossibleTypes($type); - - Utils::invariant( - !empty($possibleTypes), - "Could not find possible implementing types for {$type->name} " . - 'in schema. Check that schema.types is defined and is an array of ' . - 'all possible types in the schema.' - ); - - } else if ($type instanceof ObjectType) { - foreach ($type->getInterfaces() as $iface) { - $this->assertImplementsIntarface($type, $iface); - } - } - // Make sure type loader returns the same instance as registered in other places of schema if ($this->config->typeLoader) { Utils::invariant( @@ -448,74 +469,4 @@ public function assertValid() } } } - - private function assertImplementsIntarface(ObjectType $object, InterfaceType $iface) - { - $objectFieldMap = $object->getFields(); - $ifaceFieldMap = $iface->getFields(); - - // Assert each interface field is implemented. - foreach ($ifaceFieldMap as $fieldName => $ifaceField) { - - // Assert interface field exists on object. - Utils::invariant( - isset($objectFieldMap[$fieldName]), - "{$iface->name} expects field \"{$fieldName}\" but {$object->name} does not provide it" - ); - - $objectField = $objectFieldMap[$fieldName]; - - // Assert interface field type is satisfied by object field type, by being - // a valid subtype. (covariant) - Utils::invariant( - TypeComparators::isTypeSubTypeOf($this, $objectField->getType(), $ifaceField->getType()), - "{$iface->name}.{$fieldName} expects type \"{$ifaceField->getType()}\" " . - "but " . - "{$object->name}.${fieldName} provides type \"{$objectField->getType()}\"" - ); - - // Assert each interface field arg is implemented. - foreach ($ifaceField->args as $ifaceArg) { - $argName = $ifaceArg->name; - - /** @var FieldArgument $objectArg */ - $objectArg = Utils::find($objectField->args, function(FieldArgument $arg) use ($argName) { - return $arg->name === $argName; - }); - - // Assert interface field arg exists on object field. - Utils::invariant( - $objectArg, - "{$iface->name}.{$fieldName} expects argument \"{$argName}\" but ". - "{$object->name}.{$fieldName} does not provide it." - ); - - // Assert interface field arg type matches object field arg type. - // (invariant) - Utils::invariant( - TypeComparators::isEqualType($ifaceArg->getType(), $objectArg->getType()), - "{$iface->name}.{$fieldName}({$argName}:) expects type " . - "\"{$ifaceArg->getType()->name}\" but " . - "{$object->name}.{$fieldName}({$argName}:) provides type " . - "\"{$objectArg->getType()->name}\"." - ); - - // Assert additional arguments must not be required. - foreach ($objectField->args as $objectArg) { - $argName = $objectArg->name; - $ifaceArg = Utils::find($ifaceField->args, function(FieldArgument $arg) use ($argName) { - return $arg->name === $argName; - }); - if (!$ifaceArg) { - Utils::invariant( - !($objectArg->getType() instanceof NonNull), - "{$object->name}.{$fieldName}({$argName}:) is of required type " . - "\"{$objectArg->getType()}\" but is not also provided by the " . - "interface {$iface->name}.{$fieldName}." - ); - } - } - } - } - } } diff --git a/src/Type/SchemaConfig.php b/src/Type/SchemaConfig.php index 2b03c373f..5905a945b 100644 --- a/src/Type/SchemaConfig.php +++ b/src/Type/SchemaConfig.php @@ -58,6 +58,11 @@ class SchemaConfig */ public $astNode; + /** + * @var bool + */ + public $assumeValid; + /** * Converts an array of options to instance of SchemaConfig * (or just returns empty config when array is not passed). @@ -72,47 +77,22 @@ public static function create(array $options = []) if (!empty($options)) { if (isset($options['query'])) { - Utils::invariant( - $options['query'] instanceof ObjectType, - 'Schema query must be Object Type if provided but got: %s', - Utils::printSafe($options['query']) - ); $config->setQuery($options['query']); } if (isset($options['mutation'])) { - Utils::invariant( - $options['mutation'] instanceof ObjectType, - 'Schema mutation must be Object Type if provided but got: %s', - Utils::printSafe($options['mutation']) - ); $config->setMutation($options['mutation']); } if (isset($options['subscription'])) { - Utils::invariant( - $options['subscription'] instanceof ObjectType, - 'Schema subscription must be Object Type if provided but got: %s', - Utils::printSafe($options['subscription']) - ); $config->setSubscription($options['subscription']); } if (isset($options['types'])) { - Utils::invariant( - is_array($options['types']) || is_callable($options['types']), - 'Schema types must be array or callable if provided but got: %s', - Utils::printSafe($options['types']) - ); $config->setTypes($options['types']); } if (isset($options['directives'])) { - Utils::invariant( - is_array($options['directives']), - 'Schema directives must be array if provided but got: %s', - Utils::printSafe($options['directives']) - ); $config->setDirectives($options['directives']); } @@ -140,13 +120,12 @@ public static function create(array $options = []) } if (isset($options['astNode'])) { - Utils::invariant( - $options['astNode'] instanceof SchemaDefinitionNode, - 'Schema astNode must be an instance of SchemaDefinitionNode but got: %s', - Utils::printSafe($options['typeLoader']) - ); $config->setAstNode($options['astNode']); } + + if (isset($options['assumeValid'])) { + $config->setAssumeValid((bool) $options['assumeValid']); + } } return $config; @@ -175,7 +154,7 @@ public function setAstNode(SchemaDefinitionNode $astNode) * @param ObjectType $query * @return SchemaConfig */ - public function setQuery(ObjectType $query) + public function setQuery($query) { $this->query = $query; return $this; @@ -186,7 +165,7 @@ public function setQuery(ObjectType $query) * @param ObjectType $mutation * @return SchemaConfig */ - public function setMutation(ObjectType $mutation) + public function setMutation($mutation) { $this->mutation = $mutation; return $this; @@ -197,7 +176,7 @@ public function setMutation(ObjectType $mutation) * @param ObjectType $subscription * @return SchemaConfig */ - public function setSubscription(ObjectType $subscription) + public function setSubscription($subscription) { $this->subscription = $subscription; return $this; @@ -236,6 +215,16 @@ public function setTypeLoader(callable $typeLoader) return $this; } + /** + * @param bool $assumeValid + * @return SchemaConfig + */ + public function setAssumeValid($assumeValid) + { + $this->assumeValid = $assumeValid; + return $this; + } + /** * @api * @return ObjectType @@ -289,4 +278,12 @@ public function getTypeLoader() { return $this->typeLoader; } + + /** + * @return bool + */ + public function getAssumeValid() + { + return $this->assumeValid; + } } diff --git a/src/Type/SchemaValidationContext.php b/src/Type/SchemaValidationContext.php new file mode 100644 index 000000000..a0a431265 --- /dev/null +++ b/src/Type/SchemaValidationContext.php @@ -0,0 +1,384 @@ +schema = $schema; + } + + /** + * @return Error[] + */ + public function getErrors() { + return $this->errors; + } + + public function validateRootTypes() { + $queryType = $this->schema->getQueryType(); + if (!$queryType) { + $this->reportError( + 'Query root type must be provided.', + $this->schema->getAstNode() + ); + } else if (!$queryType instanceof ObjectType) { + $this->reportError( + 'Query root type must be Object type but got: ' . Utils::getVariableType($queryType) . '.', + $this->getOperationTypeNode($queryType, 'query') + ); + } + + $mutationType = $this->schema->getMutationType(); + if ($mutationType && !$mutationType instanceof ObjectType) { + $this->reportError( + 'Mutation root type must be Object type if provided but got: ' . Utils::getVariableType($mutationType) . '.', + $this->getOperationTypeNode($mutationType, 'mutation') + ); + } + + $subscriptionType = $this->schema->getSubscriptionType(); + if ($subscriptionType && !$subscriptionType instanceof ObjectType) { + $this->reportError( + 'Subscription root type must be Object type if provided but got: ' . Utils::getVariableType($subscriptionType) . '.', + $this->getOperationTypeNode($subscriptionType, 'subscription') + ); + } + } + + public function validateDirectives() + { + $directives = $this->schema->getDirectives(); + foreach($directives as $directive) { + if (!$directive instanceof Directive) { + $this->reportError( + "Expected directive but got: " . $directive, + is_object($directive) ? $directive->astNode : null + ); + } + } + } + + public function validateTypes() + { + $typeMap = $this->schema->getTypeMap(); + foreach($typeMap as $typeName => $type) { + // Ensure all provided types are in fact GraphQL type. + if (!Type::isType($type)) { + $this->reportError( + "Expected GraphQL type but got: " . Utils::getVariableType($type), + is_object($type) ? $type->astNode : null + ); + } + + // Ensure objects implement the interfaces they claim to. + if ($type instanceof ObjectType) { + $implementedTypeNames = []; + + foreach($type->getInterfaces() as $iface) { + if (isset($implementedTypeNames[$iface->name])) { + $this->reportError( + "{$type->name} must declare it implements {$iface->name} only once.", + $this->getAllImplementsInterfaceNode($type, $iface) + ); + } + $implementedTypeNames[$iface->name] = true; + $this->validateObjectImplementsInterface($type, $iface); + } + } + } + } + + /** + * @param ObjectType $object + * @param InterfaceType $iface + */ + private function validateObjectImplementsInterface(ObjectType $object, $iface) + { + if (!$iface instanceof InterfaceType) { + $this->reportError( + $object . + " must only implement Interface types, it cannot implement " . + $iface . ".", + $this->getImplementsInterfaceNode($object, $iface) + ); + return; + } + + $objectFieldMap = $object->getFields(); + $ifaceFieldMap = $iface->getFields(); + + // Assert each interface field is implemented. + foreach ($ifaceFieldMap as $fieldName => $ifaceField) { + $objectField = array_key_exists($fieldName, $objectFieldMap) + ? $objectFieldMap[$fieldName] + : null; + + // Assert interface field exists on object. + if (!$objectField) { + $this->reportError( + "\"{$iface->name}\" expects field \"{$fieldName}\" but \"{$object->name}\" does not provide it.", + [$this->getFieldNode($iface, $fieldName), $object->astNode] + ); + continue; + } + + // Assert interface field type is satisfied by object field type, by being + // a valid subtype. (covariant) + if ( + !TypeComparators::isTypeSubTypeOf( + $this->schema, + $objectField->getType(), + $ifaceField->getType() + ) + ) { + $this->reportError( + "{$iface->name}.{$fieldName} expects type ". + "\"{$ifaceField->getType()}\"" . + " but {$object->name}.{$fieldName} is type " . + "\"{$objectField->getType()}\".", + [ + $this->getFieldTypeNode($iface, $fieldName), + $this->getFieldTypeNode($object, $fieldName), + ] + ); + } + + // Assert each interface field arg is implemented. + foreach($ifaceField->args as $ifaceArg) { + $argName = $ifaceArg->name; + $objectArg = null; + + foreach($objectField->args as $arg) { + if ($arg->name === $argName) { + $objectArg = $arg; + break; + } + } + + // Assert interface field arg exists on object field. + if (!$objectArg) { + $this->reportError( + "{$iface->name}.{$fieldName} expects argument \"{$argName}\" but ". + "{$object->name}.{$fieldName} does not provide it.", + [ + $this->getFieldArgNode($iface, $fieldName, $argName), + $this->getFieldNode($object, $fieldName), + ] + ); + continue; + } + + // Assert interface field arg type matches object field arg type. + // (invariant) + // TODO: change to contravariant? + if (!TypeComparators::isEqualType($ifaceArg->getType(), $objectArg->getType())) { + $this->reportError( + "{$iface->name}.{$fieldName}({$argName}:) expects type ". + "\"{$ifaceArg->getType()}\"" . + " but {$object->name}.{$fieldName}({$argName}:) is type " . + "\"{$objectArg->getType()}\".", + [ + $this->getFieldArgTypeNode($iface, $fieldName, $argName), + $this->getFieldArgTypeNode($object, $fieldName, $argName), + ] + ); + } + + // TODO: validate default values? + } + + // Assert additional arguments must not be required. + foreach($objectField->args as $objectArg) { + $argName = $objectArg->name; + $ifaceArg = null; + + foreach($ifaceField->args as $arg) { + if ($arg->name === $argName) { + $ifaceArg = $arg; + break; + } + } + + if (!$ifaceArg && $objectArg->getType() instanceof NonNull) { + $this->reportError( + "{$object->name}.{$fieldName}({$argName}:) is of required type " . + "\"{$objectArg->getType()}\"" . + " but is not also provided by the interface {$iface->name}.{$fieldName}.", + [ + $this->getFieldArgTypeNode($object, $fieldName, $argName), + $this->getFieldNode($iface, $fieldName), + ] + ); + } + } + } + } + + /** + * @param ObjectType $type + * @param InterfaceType|null $iface + * @return NamedTypeNode|null + */ + private function getImplementsInterfaceNode(ObjectType $type, $iface) + { + $nodes = $this->getAllImplementsInterfaceNode($type, $iface); + return $nodes && isset($nodes[0]) ? $nodes[0] : null; + } + + /** + * @param ObjectType $type + * @param InterfaceType|null $iface + * @return NamedTypeNode[] + */ + private function getAllImplementsInterfaceNode(ObjectType $type, $iface) + { + $implementsNodes = []; + /** @var ObjectTypeDefinitionNode|ObjectTypeExtensionNode[] $astNodes */ + $astNodes = array_merge([$type->astNode], $type->extensionASTNodes ?: []); + + foreach($astNodes as $astNode) { + if ($astNode && $astNode->interfaces) { + foreach($astNode->interfaces as $node) { + if ($node->name->value === $iface->name) { + $implementsNodes[] = $node; + } + } + } + } + + return $implementsNodes; + } + + /** + * @param ObjectType|InterfaceType $type + * @param string $fieldName + * @return FieldDefinitionNode|null + */ + private function getFieldNode($type, $fieldName) + { + /** @var ObjectTypeDefinitionNode|ObjectTypeExtensionNode[] $astNodes */ + $astNodes = array_merge([$type->astNode], $type->extensionASTNodes ?: []); + + foreach($astNodes as $astNode) { + if ($astNode && $astNode->fields) { + foreach($astNode->fields as $node) { + if ($node->name->value === $fieldName) { + return $node; + } + } + } + } + } + + /** + * @param ObjectType|InterfaceType $type + * @param string $fieldName + * @return TypeNode|null + */ + private function getFieldTypeNode($type, $fieldName) + { + $fieldNode = $this->getFieldNode($type, $fieldName); + if ($fieldNode) { + return $fieldNode->type; + } + } + + /** + * @param ObjectType|InterfaceType $type + * @param string $fieldName + * @param string $argName + * @return InputValueDefinitionNode|null + */ + private function getFieldArgNode($type, $fieldName, $argName) + { + $fieldNode = $this->getFieldNode($type, $fieldName); + if ($fieldNode && $fieldNode->arguments) { + foreach ($fieldNode->arguments as $node) { + if ($node->name->value === $argName) { + return $node; + } + } + } + } + + /** + * @param ObjectType|InterfaceType $type + * @param string $fieldName + * @param string $argName + * @return TypeNode|null + */ + private function getFieldArgTypeNode($type, $fieldName, $argName) + { + $fieldArgNode = $this->getFieldArgNode($type, $fieldName, $argName); + if ($fieldArgNode) { + return $fieldArgNode->type; + } + } + + /** + * @param Type $type + * @param string $operation + * + * @return TypeNode|TypeDefinitionNode + */ + private function getOperationTypeNode($type, $operation) + { + $astNode = $this->schema->getAstNode(); + + $operationTypeNode = null; + if ($astNode instanceof SchemaDefinitionNode) { + $operationTypeNode = null; + + foreach($astNode->operationTypes as $operationType) { + if ($operationType->operation === $operation) { + $operationTypeNode = $operationType; + break; + } + } + } + + return $operationTypeNode ? $operationTypeNode->type : ($type ? $type->astNode : null); + } + + /** + * @param string $message + * @param array|Node|TypeNode|TypeDefinitionNode $nodes + */ + private function reportError($message, $nodes = null) { + $nodes = array_filter($nodes && is_array($nodes) ? $nodes : [$nodes]); + $this->errors[] = new Error($message, $nodes); + } +} diff --git a/src/Utils.php b/src/Utils.php index b73186f12..feabcf079 100644 --- a/src/Utils.php +++ b/src/Utils.php @@ -6,6 +6,9 @@ E_USER_DEPRECATED ); +/** + * @deprecated Use GraphQL\Utils\Utils + */ class Utils extends \GraphQL\Utils\Utils { } diff --git a/src/Utils/ASTDefinitionBuilder.php b/src/Utils/ASTDefinitionBuilder.php index d38310bb8..073e77333 100644 --- a/src/Utils/ASTDefinitionBuilder.php +++ b/src/Utils/ASTDefinitionBuilder.php @@ -266,9 +266,12 @@ function ($field) { private function makeImplementedInterfaces(ObjectTypeDefinitionNode $def) { - if (isset($def->interfaces)) { + if ($def->interfaces) { + // Note: While this could make early assertions to get the correctly + // typed values, that would throw immediately while type system + // validation with validateSchema() will produce more actionable results. return Utils::map($def->interfaces, function ($iface) { - return $this->buildInterfaceType($iface); + return $this->buildType($iface); }); } return null; diff --git a/src/Utils/BuildSchema.php b/src/Utils/BuildSchema.php index 9d764cd6b..0b8ae3141 100644 --- a/src/Utils/BuildSchema.php +++ b/src/Utils/BuildSchema.php @@ -130,20 +130,20 @@ function($typeName) { throw new Error('Type "'. $typeName . '" not found in docu $directives[] = Directive::deprecatedDirective(); } - if (!isset($operationTypes['query'])) { - throw new Error( - 'Must provide schema definition with query type or a type named Query.' - ); - } + // Note: While this could make early assertions to get the correctly + // typed values below, that would throw immediately while type system + // validation with validateSchema() will produce more actionable results. $schema = new Schema([ - 'query' => $defintionBuilder->buildObjectType($operationTypes['query']), - 'mutation' => isset($operationTypes['mutation']) ? - $defintionBuilder->buildObjectType($operationTypes['mutation']) : - null, - 'subscription' => isset($operationTypes['subscription']) ? - $defintionBuilder->buildObjectType($operationTypes['subscription']) : - null, + 'query' => isset($operationTypes['query']) + ? $defintionBuilder->buildType($operationTypes['query']) + : null, + 'mutation' => isset($operationTypes['mutation']) + ? $defintionBuilder->buildType($operationTypes['mutation']) + : null, + 'subscription' => isset($operationTypes['subscription']) + ? $defintionBuilder->buildType($operationTypes['subscription']) + : null, 'typeLoader' => function ($name) use ($defintionBuilder) { return $defintionBuilder->buildType($name); }, diff --git a/src/Utils/TypeComparators.php b/src/Utils/TypeComparators.php index 64639b45f..0ddcbaf26 100644 --- a/src/Utils/TypeComparators.php +++ b/src/Utils/TypeComparators.php @@ -48,7 +48,7 @@ public static function isEqualType(Type $typeA, Type $typeB) * @param Type $superType * @return bool */ - static function isTypeSubTypeOf(Schema $schema, Type $maybeSubType, Type $superType) + static function isTypeSubTypeOf(Schema $schema, $maybeSubType, $superType) { // Equivalent type is a valid subtype if ($maybeSubType === $superType) { diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 03c689520..db7df22da 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -39,8 +39,9 @@ public function testDefaults() $this->assertEquals(500, $server->getUnexpectedErrorStatus()); $this->assertEquals(DocumentValidator::allRules(), $server->getValidationRules()); - $this->setExpectedException(InvariantViolation::class, 'Schema query must be Object Type but got: NULL'); - $server->getSchema(); + $schema = $server->getSchema(); + $this->setExpectedException(InvariantViolation::class, 'Query root type must be provided.'); + $schema->assertValid(); } public function testCannotUseSetQueryTypeAndSetSchema() @@ -328,8 +329,8 @@ public function testValidate() $this->assertInternalType('array', $errors); $this->assertNotEmpty($errors); - $this->setExpectedException(InvariantViolation::class, 'Cannot validate, schema contains errors: Schema query must be Object Type but got: NULL'); $server = Server::create(); + $this->setExpectedException(InvariantViolation::class, 'Cannot validate, schema contains errors: Query root type must be provided.'); $server->validate($ast); } @@ -538,15 +539,14 @@ public function testHandleRequest() { $mock = $this->getMockBuilder('GraphQL\Server') ->setMethods(['readInput', 'produceOutput']) - ->getMock() - ; + ->getMock(); $mock->method('readInput') ->will($this->returnValue(json_encode(['query' => '{err}']))); $output = null; $mock->method('produceOutput') - ->will($this->returnCallback(function($a1, $a2) use (&$output) { + ->will($this->returnCallback(function ($a1, $a2) use (&$output) { $output = func_get_args(); })); @@ -554,17 +554,35 @@ public function testHandleRequest() $mock->handleRequest(); $this->assertInternalType('array', $output); - $this->assertArraySubset(['errors' => [['message' => 'Unexpected Error']]], $output[0]); - $this->assertEquals(500, $output[1]); + $this->assertArraySubset(['errors' => [['message' => 'Schema does not define the required query root type.']]], $output[0]); + $this->assertEquals(200, $output[1]); $output = null; $mock->setUnexpectedErrorMessage($newErr = 'Hey! Something went wrong!'); $mock->setUnexpectedErrorStatus(501); + $mock->method('readInput') + ->will($this->throwException(new \Exception('test'))); $mock->handleRequest(); $this->assertInternalType('array', $output); $this->assertEquals(['errors' => [['message' => $newErr]]], $output[0]); $this->assertEquals(501, $output[1]); + } + + public function testHandleRequest2() + { + $mock = $this->getMockBuilder('GraphQL\Server') + ->setMethods(['readInput', 'produceOutput']) + ->getMock(); + + $mock->method('readInput') + ->will($this->returnValue(json_encode(['query' => '{err}']))); + + $output = null; + $mock->method('produceOutput') + ->will($this->returnCallback(function ($a1, $a2) use (&$output) { + $output = func_get_args(); + })); $mock->setQueryType(new ObjectType([ 'name' => 'Query', diff --git a/tests/Type/ValidationTest.php b/tests/Type/ValidationTest.php index 6d689c67b..26626ad98 100644 --- a/tests/Type/ValidationTest.php +++ b/tests/Type/ValidationTest.php @@ -1,6 +1,7 @@ getMessage() === $message) { + if ($error instanceof Error) { + $errorLocations = []; + foreach ($error->getLocations() as $location) { + $errorLocations[] = $location->toArray(); + } + $this->assertEquals($locations, $errorLocations ?: null); + } + return; + } + } + + $this->fail( + 'Failed asserting that the array of validation messages contains ' . + 'the message "' . $message . '"' . "\n" . + 'Found the following messages in the array:' . "\n" . + join("\n", array_map(function($error) { return "\"{$error->getMessage()}\""; }, $array)) + ); + } + public function testRejectsTypesWithoutNames() { $this->assertEachCallableThrows([ @@ -213,11 +242,22 @@ function() { */ public function testAcceptsASchemaWhoseQueryTypeIsAnObjectType() { - // Must not throw: - $schema = new Schema([ - 'query' => $this->SomeObjectType - ]); - $schema->assertValid(); + $schema = BuildSchema::build(' + type Query { + test: String + } + '); + $this->assertEquals([], $schema->validate()); + + $schemaWithDef = BuildSchema::build(' + schema { + query: QueryRoot + } + type QueryRoot { + test: String + } + '); + $this->assertEquals([], $schemaWithDef->validate()); } /** @@ -225,17 +265,32 @@ public function testAcceptsASchemaWhoseQueryTypeIsAnObjectType() */ public function testAcceptsASchemaWhoseQueryAndMutationTypesAreObjectTypes() { - $mutationType = new ObjectType([ - 'name' => 'Mutation', - 'fields' => [ - 'edit' => ['type' => Type::string()] - ] - ]); - $schema = new Schema([ - 'query' => $this->SomeObjectType, - 'mutation' => $mutationType - ]); - $schema->assertValid(); + $schema = BuildSchema::build(' + type Query { + test: String + } + + type Mutation { + test: String + } + '); + $this->assertEquals([], $schema->validate()); + + $schema = BuildSchema::build(' + schema { + query: QueryRoot + mutation: MutationRoot + } + + type QueryRoot { + test: String + } + + type MutationRoot { + test: String + } + '); + $this->assertEquals([], $schema->validate()); } /** @@ -243,17 +298,32 @@ public function testAcceptsASchemaWhoseQueryAndMutationTypesAreObjectTypes() */ public function testAcceptsASchemaWhoseQueryAndSubscriptionTypesAreObjectTypes() { - $subscriptionType = new ObjectType([ - 'name' => 'Subscription', - 'fields' => [ - 'subscribe' => ['type' => Type::string()] - ] - ]); - $schema = new Schema([ - 'query' => $this->SomeObjectType, - 'subscription' => $subscriptionType - ]); - $schema->assertValid(); + $schema = BuildSchema::build(' + type Query { + test: String + } + + type Subscription { + test: String + } + '); + $this->assertEquals([], $schema->validate()); + + $schema = BuildSchema::build(' + schema { + query: QueryRoot + subscription: SubscriptionRoot + } + + type QueryRoot { + test: String + } + + type SubscriptionRoot { + test: String + } + '); + $this->assertEquals([], $schema->validate()); } /** @@ -261,22 +331,68 @@ public function testAcceptsASchemaWhoseQueryAndSubscriptionTypesAreObjectTypes() */ public function testRejectsASchemaWithoutAQueryType() { - $this->setExpectedException(InvariantViolation::class, 'Schema query must be Object Type but got: NULL'); - new Schema([]); + $schema = BuildSchema::build(' + type Mutation { + test: String + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + 'Query root type must be provided.' + ); + + + $schemaWithDef = BuildSchema::build(' + schema { + mutation: MutationRoot + } + + type MutationRoot { + test: String + } + '); + + $this->assertContainsValidationMessage( + $schemaWithDef->validate(), + 'Query root type must be provided.', + [['line' => 2, 'column' => 7]] + ); } /** - * @it rejects a Schema whose query type is an input type + * @it rejects a Schema whose query root type is not an Object type */ - public function testRejectsASchemaWhoseQueryTypeIsAnInputType() + public function testRejectsASchemaWhoseQueryTypeIsNotAnObjectType() { - $this->setExpectedException( - InvariantViolation::class, - 'Schema query must be Object Type if provided but got: SomeInputObject' + $schema = BuildSchema::build(' + input Query { + test: String + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + 'Query root type must be Object type but got: Query.', + [['line' => 2, 'column' => 7]] + ); + + + $schemaWithDef = BuildSchema::build(' + schema { + query: SomeInputObject + } + + input SomeInputObject { + test: String + } + '); + + $this->assertContainsValidationMessage( + $schemaWithDef->validate(), + 'Query root type must be Object type but got: SomeInputObject.', + [['line' => 3, 'column' => 16]] ); - new Schema([ - 'query' => $this->SomeInputObjectType - ]); } /** @@ -284,14 +400,43 @@ public function testRejectsASchemaWhoseQueryTypeIsAnInputType() */ public function testRejectsASchemaWhoseMutationTypeIsAnInputType() { - $this->setExpectedException( - InvariantViolation::class, - 'Schema mutation must be Object Type if provided but got: SomeInputObject' + $schema = BuildSchema::build(' + type Query { + field: String + } + + input Mutation { + test: String + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + 'Mutation root type must be Object type if provided but got: Mutation.', + [['line' => 6, 'column' => 7]] + ); + + + $schemaWithDef = BuildSchema::build(' + schema { + query: Query + mutation: SomeInputObject + } + + type Query { + field: String + } + + input SomeInputObject { + test: String + } + '); + + $this->assertContainsValidationMessage( + $schemaWithDef->validate(), + 'Mutation root type must be Object type if provided but got: SomeInputObject.', + [['line' => 4, 'column' => 19]] ); - new Schema([ - 'query' => $this->SomeObjectType, - 'mutation' => $this->SomeInputObjectType - ]); } /** @@ -299,14 +444,45 @@ public function testRejectsASchemaWhoseMutationTypeIsAnInputType() */ public function testRejectsASchemaWhoseSubscriptionTypeIsAnInputType() { - $this->setExpectedException( - InvariantViolation::class, - 'Schema subscription must be Object Type if provided but got: SomeInputObject' + $schema = BuildSchema::build(' + type Query { + field: String + } + + input Subscription { + test: String + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + 'Subscription root type must be Object type if provided but got: Subscription.', + [['line' => 6, 'column' => 7]] ); - new Schema([ - 'query' => $this->SomeObjectType, - 'subscription' => $this->SomeInputObjectType - ]); + + + $schemaWithDef = BuildSchema::build(' + schema { + query: Query + subscription: SomeInputObject + } + + type Query { + field: String + } + + input SomeInputObject { + test: String + } + '); + + $this->assertContainsValidationMessage( + $schemaWithDef->validate(), + 'Subscription root type must be Object type if provided but got: SomeInputObject.', + [['line' => 4, 'column' => 23]] + ); + + } /** @@ -319,12 +495,10 @@ public function testRejectsASchemaWhoseDirectivesAreIncorrectlyTyped() 'directives' => ['somedirective'] ]); - $this->setExpectedException( - InvariantViolation::class, - 'Each entry of "directives" option of Schema config must be an instance of GraphQL\Type\Definition\Directive but entry at position 0 is "somedirective".' + $this->assertContainsValidationMessage( + $schema->validate(), + 'Expected directive but got: somedirective' ); - - $schema->assertValid(); } // DESCRIBE: Type System: A Schema must contain uniquely named types @@ -774,39 +948,6 @@ public function testRejectsAnObjectTypeWithIncorrectlyTypedInterfaces() $schema->assertValid(); } - /** - * @it rejects an Object that declare it implements same interface more than once - */ - public function testRejectsAnObjectThatDeclareItImplementsSameInterfaceMoreThanOnce() - { - $NonUniqInterface = new InterfaceType([ - 'name' => 'NonUniqInterface', - 'fields' => ['f' => ['type' => Type::string()]], - ]); - - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => ['f' => ['type' => Type::string()]], - ]); - - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'interfaces' => function () use ($NonUniqInterface, $AnotherInterface) { - return [$NonUniqInterface, $AnotherInterface, $NonUniqInterface]; - }, - 'fields' => ['f' => ['type' => Type::string()]] - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject may declare it implements NonUniqInterface only once.' - ); - - $schema->assertValid(); - } - - // TODO: rejects an Object type with interfaces as a function returning an incorrect type - /** * @it rejects an Object type with interfaces as a function returning an incorrect type */ @@ -1654,52 +1795,6 @@ public function testRejectsAConstantScalarValueResolver() $schema->assertValid(); } - - - // DESCRIBE: Type System: Objects can only implement interfaces - - /** - * @it accepts an Object implementing an Interface - */ - public function testAcceptsAnObjectImplementingAnInterface() - { - $AnotherInterfaceType = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => ['f' => ['type' => Type::string()]] - ]); - - $schema = $this->schemaWithObjectImplementingType($AnotherInterfaceType); - $schema->assertValid(); - } - - /** - * @it rejects an Object implementing a non-Interface type - */ - public function testRejectsAnObjectImplementingANonInterfaceType() - { - $notInterfaceTypes = $this->withModifiers([ - $this->SomeScalarType, - $this->SomeEnumType, - $this->SomeObjectType, - $this->SomeUnionType, - $this->SomeInputObjectType, - ]); - foreach ($notInterfaceTypes as $type) { - $schema = $this->schemaWithObjectImplementingType($type); - - try { - $schema->assertValid(); - $this->fail('Exepected exception not thrown for type ' . $type); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'BadObject may only implement Interface types, it cannot implement ' . $type . '.', - $e->getMessage() - ); - } - } - } - - // DESCRIBE: Type System: Unions must represent Object types /** @@ -1991,7 +2086,6 @@ public function testRejectsANonTypeAsNullableTypeOfNonNull() } } - // DESCRIBE: Objects must adhere to Interface they implement /** @@ -1999,33 +2093,24 @@ public function testRejectsANonTypeAsNullableTypeOfNonNull() */ public function testAcceptsAnObjectWhichImplementsAnInterface() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()] - ] - ] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field(input: String): String + } + + type AnotherObject implements AnotherInterface { + field(input: String): String + } + '); - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()] - ] - ] - ] - ]); - - $schema = $this->schemaWithFieldType($AnotherObject); - $schema->assertValid(); + $this->assertEquals( + [], + $schema->validate() + ); } /** @@ -2033,34 +2118,25 @@ public function testAcceptsAnObjectWhichImplementsAnInterface() */ public function testAcceptsAnObjectWhichImplementsAnInterfaceAlongWithMoreFields() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()], - ] - ] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()], - ] - ], - 'anotherfield' => ['type' => Type::string()] - ] - ]); + interface AnotherInterface { + field(input: String): String + } - $schema = $this->schemaWithFieldType($AnotherObject); - $schema->assertValid(); + type AnotherObject implements AnotherInterface { + field(input: String): String + anotherField: String + } + '); + + $this->assertEquals( + [], + $schema->validate() + ); } /** @@ -2068,75 +2144,24 @@ public function testAcceptsAnObjectWhichImplementsAnInterfaceAlongWithMoreFields */ public function testAcceptsAnObjectWhichImplementsAnInterfaceFieldAlongWithAdditionalOptionalArguments() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()], - ] - ] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()], - 'anotherInput' => ['type' => Type::string()], - ] - ] - ] - ]); + interface AnotherInterface { + field(input: String): String + } - $schema = $this->schemaWithFieldType($AnotherObject); - $schema->assertValid(); - } + type AnotherObject implements AnotherInterface { + field(input: String, anotherInput: String): String + } + '); - /** - * @it rejects an Object which implements an Interface field along with additional required arguments - */ - public function testRejectsAnObjectWhichImplementsAnInterfaceFieldAlongWithAdditionalRequiredArguments() - { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()], - ] - ] - ] - ]); - - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()], - 'anotherInput' => ['type' => Type::nonNull(Type::string())], - ] - ] - ] - ]); - - $schema = $this->schemaWithFieldType($AnotherObject); - - $this->setExpectedException( - InvariantViolation::class, - 'AnotherObject.field(anotherInput:) is of required type "String!" but is not also provided by the interface AnotherInterface.field.' + $this->assertEquals( + [], + $schema->validate() ); - - $schema->assertValid(); } /** @@ -2144,33 +2169,26 @@ public function testRejectsAnObjectWhichImplementsAnInterfaceFieldAlongWithAddit */ public function testRejectsAnObjectMissingAnInterfaceField() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()], - ] - ] - ] - ]); - - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'anotherfield' => ['type' => Type::string()] - ] - ]); - - $schema = $this->schemaWithFieldType($AnotherObject); - - $this->setExpectedException( - InvariantViolation::class, - 'AnotherInterface expects field "field" but AnotherObject does not provide it' + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field(input: String): String + } + + type AnotherObject implements AnotherInterface { + anotherField: String + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + '"AnotherInterface" expects field "field" but ' . + '"AnotherObject" does not provide it.', + [['line' => 7, 'column' => 9], ['line' => 10, 'column' => 7]] ); - $schema->assertValid(); } /** @@ -2178,27 +2196,26 @@ public function testRejectsAnObjectMissingAnInterfaceField() */ public function testRejectsAnObjectWithAnIncorrectlyTypedInterfaceField() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => [ - 'field' => ['type' => Type::string()] - ] - ]); - - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => ['type' => $this->SomeScalarType] - ] - ]); - - $schema = $this->schemaWithFieldType($AnotherObject); - $this->setExpectedException( - InvariantViolation::class, - 'AnotherInterface.field expects type "String" but AnotherObject.field provides type "SomeScalar"' + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field(input: String): String + } + + type AnotherObject implements AnotherInterface { + field(input: String): Int + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + 'AnotherInterface.field expects type "String" but ' . + 'AnotherObject.field is type "Int".', + [['line' => 7, 'column' => 31], ['line' => 11, 'column' => 31]] ); - $schema->assertValid(); } /** @@ -2206,43 +2223,29 @@ public function testRejectsAnObjectWithAnIncorrectlyTypedInterfaceField() */ public function testRejectsAnObjectWithADifferentlyTypedInterfaceField() { - $TypeA = new ObjectType([ - 'name' => 'A', - 'fields' => [ - 'foo' => ['type' => Type::string()] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $TypeB = new ObjectType([ - 'name' => 'B', - 'fields' => [ - 'foo' => ['type' => Type::string()] - ] - ]); - - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => [ - 'field' => ['type' => $TypeA] - ] - ]); + type A { foo: String } + type B { foo: String } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => ['type' => $TypeB] - ] - ]); + interface AnotherInterface { + field: A + } - $schema = $this->schemaWithFieldType($AnotherObject); + type AnotherObject implements AnotherInterface { + field: B + } + '); - $this->setExpectedException( - InvariantViolation::class, - 'AnotherInterface.field expects type "A" but AnotherObject.field provides type "B"' + $this->assertContainsValidationMessage( + $schema->validate(), + 'AnotherInterface.field expects type "A" but ' . + 'AnotherObject.field is type "B".', + [['line' => 10, 'column' => 16], ['line' => 14, 'column' => 16]] ); - - $schema->assertValid(); } /** @@ -2250,27 +2253,24 @@ public function testRejectsAnObjectWithADifferentlyTypedInterfaceField() */ public function testAcceptsAnObjectWithASubtypedInterfaceFieldForInterface() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => function () use (&$AnotherInterface) { - return [ - 'field' => ['type' => $AnotherInterface] - ]; - } - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => function () use (&$AnotherObject) { - return [ - 'field' => ['type' => $AnotherObject] - ]; - } - ]); + interface AnotherInterface { + field: AnotherInterface + } - $schema = $this->schemaWithFieldType($AnotherObject); - $schema->assertValid(); + type AnotherObject implements AnotherInterface { + field: AnotherObject + } + '); + + $this->assertEquals( + [], + $schema->validate() + ); } /** @@ -2278,23 +2278,30 @@ public function testAcceptsAnObjectWithASubtypedInterfaceFieldForInterface() */ public function testAcceptsAnObjectWithASubtypedInterfaceFieldForUnion() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => [ - 'field' => ['type' => $this->SomeUnionType] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => ['type' => $this->SomeObjectType] - ] - ]); + type SomeObject { + field: String + } - $schema = $this->schemaWithFieldType($AnotherObject); - $schema->assertValid(); + union SomeUnionType = SomeObject + + interface AnotherInterface { + field: SomeUnionType + } + + type AnotherObject implements AnotherInterface { + field: SomeObject + } + '); + + $this->assertEquals( + [], + $schema->validate() + ); } /** @@ -2302,36 +2309,26 @@ public function testAcceptsAnObjectWithASubtypedInterfaceFieldForUnion() */ public function testRejectsAnObjectMissingAnInterfaceArgument() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()], - ] - ] - ] - ]); - - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - ] - ] - ]); - - $schema = $this->schemaWithFieldType($AnotherObject); - - $this->setExpectedException( - InvariantViolation::class, - 'AnotherInterface.field expects argument "input" but AnotherObject.field does not provide it.' + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field(input: String): String + } + + type AnotherObject implements AnotherInterface { + field: String + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + 'AnotherInterface.field expects argument "input" but ' . + 'AnotherObject.field does not provide it.', + [['line' => 7, 'column' => 15], ['line' => 11, 'column' => 9]] ); - - $schema->assertValid(); } /** @@ -2339,39 +2336,87 @@ public function testRejectsAnObjectMissingAnInterfaceArgument() */ public function testRejectsAnObjectWithAnIncorrectlyTypedInterfaceArgument() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => Type::string()], - ] - ] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field(input: String): String + } + + type AnotherObject implements AnotherInterface { + field(input: Int): String + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + 'AnotherInterface.field(input:) expects type "String" but ' . + 'AnotherObject.field(input:) is type "Int".', + [['line' => 7, 'column' => 22], ['line' => 11, 'column' => 22]] + ); + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => $this->SomeScalarType], - ] - ] - ] - ]); + /** + * @it rejects an Object with both an incorrectly typed field and argument + */ + public function testRejectsAnObjectWithBothAnIncorrectlyTypedFieldAndArgument() + { + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $schema = $this->schemaWithFieldType($AnotherObject); + interface AnotherInterface { + field(input: String): String + } - $this->setExpectedException( - InvariantViolation::class, - 'AnotherInterface.field(input:) expects type "String" but AnotherObject.field(input:) provides type "SomeScalar".' + type AnotherObject implements AnotherInterface { + field(input: Int): Int + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + 'AnotherInterface.field expects type "String" but ' . + 'AnotherObject.field is type "Int".', + [['line' => 7, 'column' => 31], ['line' => 11, 'column' => 28]] + ); + $this->assertContainsValidationMessage( + $schema->validate(), + 'AnotherInterface.field(input:) expects type "String" but ' . + 'AnotherObject.field(input:) is type "Int".', + [['line' => 7, 'column' => 22], ['line' => 11, 'column' => 22]] ); + } - $schema->assertValid(); + /** + * @it rejects an Object which implements an Interface field along with additional required arguments + */ + public function testRejectsAnObjectWhichImplementsAnInterfaceFieldAlongWithAdditionalRequiredArguments() + { + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field(input: String): String + } + + type AnotherObject implements AnotherInterface { + field(input: String, anotherInput: String!): String + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + 'AnotherObject.field(anotherInput:) is of required type ' . + '"String!" but is not also provided by the interface ' . + 'AnotherInterface.field.', + [['line' => 11, 'column' => 44], ['line' => 7, 'column' => 9]] + ); } /** @@ -2379,23 +2424,24 @@ public function testRejectsAnObjectWithAnIncorrectlyTypedInterfaceArgument() */ public function testAcceptsAnObjectWithAnEquivalentlyModifiedInterfaceFieldType() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => [ - 'field' => ['type' => Type::nonNull(Type::listOf(Type::string()))] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => ['type' => Type::nonNull(Type::listOf(Type::string()))] - ] - ]); + interface AnotherInterface { + field: [String]! + } - $schema = $this->schemaWithFieldType($AnotherObject); - $schema->assertValid(); + type AnotherObject implements AnotherInterface { + field: [String]! + } + '); + + $this->assertEquals( + [], + $schema->validate() + ); } /** @@ -2403,29 +2449,26 @@ public function testAcceptsAnObjectWithAnEquivalentlyModifiedInterfaceFieldType( */ public function testRejectsAnObjectWithANonListInterfaceFieldListType() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => [ - 'field' => ['type' => Type::listOf(Type::string())] - ] - ]); - - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => ['type' => Type::string()] - ] - ]); - - $schema = $this->schemaWithFieldType($AnotherObject); - - $this->setExpectedException( - InvariantViolation::class, - 'AnotherInterface.field expects type "[String]" but AnotherObject.field provides type "String"' + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: [String] + } + + type AnotherObject implements AnotherInterface { + field: String + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + 'AnotherInterface.field expects type "[String]" but ' . + 'AnotherObject.field is type "String".', + [['line' => 7, 'column' => 16], ['line' => 11, 'column' => 16]] ); - - $schema->assertValid(); } /** @@ -2433,27 +2476,26 @@ public function testRejectsAnObjectWithANonListInterfaceFieldListType() */ public function testRejectsAnObjectWithAListInterfaceFieldNonListType() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => [ - 'field' => ['type' => Type::string()] - ] - ]); - - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => ['type' => Type::listOf(Type::string())] - ] - ]); - - $schema = $this->schemaWithFieldType($AnotherObject); - $this->setExpectedException( - InvariantViolation::class, - 'AnotherInterface.field expects type "String" but AnotherObject.field provides type "[String]"' + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: String + } + + type AnotherObject implements AnotherInterface { + field: [String] + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + 'AnotherInterface.field expects type "String" but ' . + 'AnotherObject.field is type "[String]".', + [['line' => 7, 'column' => 16], ['line' => 11, 'column' => 16]] ); - $schema->assertValid(); } /** @@ -2461,23 +2503,24 @@ public function testRejectsAnObjectWithAListInterfaceFieldNonListType() */ public function testAcceptsAnObjectWithASubsetNonNullInterfaceFieldType() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => [ - 'field' => ['type' => Type::string()] - ] - ]); + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => ['type' => Type::nonNull(Type::string())] - ] - ]); + interface AnotherInterface { + field: String + } - $schema = $this->schemaWithFieldType($AnotherObject); - $schema->assertValid(); + type AnotherObject implements AnotherInterface { + field: String! + } + '); + + $this->assertEquals( + [], + $schema->validate() + ); } /** @@ -2485,29 +2528,26 @@ public function testAcceptsAnObjectWithASubsetNonNullInterfaceFieldType() */ public function testRejectsAnObjectWithASupersetNullableInterfaceFieldType() { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => [ - 'field' => ['type' => Type::nonNull(Type::string())] - ] - ]); - - $AnotherObject = new ObjectType([ - 'name' => 'AnotherObject', - 'interfaces' => [$AnotherInterface], - 'fields' => [ - 'field' => ['type' => Type::string()] - ] - ]); - - $schema = $this->schemaWithFieldType($AnotherObject); - - $this->setExpectedException( - InvariantViolation::class, - 'AnotherInterface.field expects type "String!" but AnotherObject.field provides type "String"' + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: String! + } + + type AnotherObject implements AnotherInterface { + field: String + } + '); + + $this->assertContainsValidationMessage( + $schema->validate(), + 'AnotherInterface.field expects type "String!" but ' . + 'AnotherObject.field is type "String".', + [['line' => 7, 'column' => 16], ['line' => 11, 'column' => 16]] ); - - $schema->assertValid(); } /** @@ -2665,25 +2705,6 @@ private function schemaWithObjectWithFieldResolver($resolveValue) ]); } - private function schemaWithObjectImplementingType($implementedType) - { - $BadObjectType = new ObjectType([ - 'name' => 'BadObject', - 'interfaces' => [$implementedType], - 'fields' => ['f' => ['type' => Type::string()]] - ]); - - return new Schema([ - 'query' => new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'f' => ['type' => $BadObjectType] - ] - ]), - 'types' => [$BadObjectType] - ]); - } - private function withModifiers($types) { return array_merge( diff --git a/tests/Utils/BuildSchemaTest.php b/tests/Utils/BuildSchemaTest.php index 6c4eaa674..cfd21f7ee 100644 --- a/tests/Utils/BuildSchemaTest.php +++ b/tests/Utils/BuildSchemaTest.php @@ -863,21 +863,6 @@ interfaceField: String // Describe: Failures - /** - * @it Requires a schema definition or Query type - */ - public function testRequiresSchemaDefinitionOrQueryType() - { - $this->setExpectedException('GraphQL\Error\Error', 'Must provide schema definition with query type or a type named Query.'); - $body = ' -type Hello { - bar: Bar -} -'; - $doc = Parser::parse($body); - BuildSchema::buildAST($doc); - } - /** * @it Allows only a single schema definition */ @@ -893,25 +878,6 @@ public function testAllowsOnlySingleSchemaDefinition() query: Hello } -type Hello { - bar: Bar -} -'; - $doc = Parser::parse($body); - BuildSchema::buildAST($doc); - } - - /** - * @it Requires a query type - */ - public function testRequiresQueryType() - { - $this->setExpectedException('GraphQL\Error\Error', 'Must provide schema definition with query type or a type named Query.'); - $body = ' -schema { - mutation: Hello -} - type Hello { bar: Bar } From cf276340a4249fb426a6c4fa940fb0e5fcbd455c Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Tue, 13 Feb 2018 10:37:00 +0100 Subject: [PATCH 31/50] Fix printError/locations for multiple nodes. If a GraphQLError represents multiple nodes across files (could happen for validation across multiple parsed files) then the reported locations and printError output can be incorrect for the second node. This ensures locations are derived from nodes whenever possible to get correct location and amends comment documentation. ref: graphql/graphql-js#1131 --- src/Error/Error.php | 12 ++++++- src/Error/FormattedError.php | 56 +++++++++++++++--------------- tests/{ => Error}/ErrorTest.php | 2 +- tests/Error/PrintErrorTest.php | 61 +++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 29 deletions(-) rename tests/{ => Error}/ErrorTest.php (99%) create mode 100644 tests/Error/PrintErrorTest.php diff --git a/src/Error/Error.php b/src/Error/Error.php index a96a4c759..369459212 100644 --- a/src/Error/Error.php +++ b/src/Error/Error.php @@ -53,7 +53,10 @@ class Error extends \Exception implements \JsonSerializable, ClientAware public $nodes; /** - * The source GraphQL document corresponding to this error. + * The source GraphQL document for the first location of this error. + * + * Note that if this Error represents more than one node, the source may not + * represent nodes after the first node. * * @var Source|null */ @@ -250,11 +253,18 @@ public function getLocations() if (null === $this->locations) { $positions = $this->getPositions(); $source = $this->getSource(); + $nodes = $this->nodes; if ($positions && $source) { $this->locations = array_map(function ($pos) use ($source) { return $source->getLocation($pos); }, $positions); + } else if ($nodes) { + $this->locations = array_filter(array_map(function ($node) { + if ($node->loc) { + return $node->loc->source->getLocation($node->loc->start); + } + }, $nodes)); } else { $this->locations = []; } diff --git a/src/Error/FormattedError.php b/src/Error/FormattedError.php index 16ed5e783..82bb0cb4a 100644 --- a/src/Error/FormattedError.php +++ b/src/Error/FormattedError.php @@ -1,6 +1,7 @@ getSource(); - $locations = $error->getLocations(); - - $message = $error->getMessage(); - - foreach($locations as $location) { - $message .= $source - ? self::highlightSourceAtLocation($source, $location) - : " ({$location->line}:{$location->column})"; + $printedLocations = []; + if ($error->nodes) { + /** @var Node $node */ + foreach($error->nodes as $node) { + if ($node->loc) { + $printedLocations[] = self::highlightSourceAtLocation( + $node->loc->source, + $node->loc->source->getLocation($node->loc->start) + ); + } + } + } else if ($error->getSource() && $error->getLocations()) { + $source = $error->getSource(); + foreach($error->getLocations() as $location) { + $printedLocations[] = self::highlightSourceAtLocation($source, $location); + } } - return $message; + return !$printedLocations + ? $error->getMessage() + : join("\n\n", array_merge([$error->getMessage()], $printedLocations)) . "\n"; } /** @@ -74,23 +84,15 @@ private static function highlightSourceAtLocation(Source $source, SourceLocation $lines[0] = self::whitespace($source->locationOffset->column - 1) . $lines[0]; - return ( - "\n\n{$source->name} ($contextLine:$contextColumn)\n" . - ($line >= 2 - ? (self::lpad($padLen, $prevLineNum) . ': ' . $lines[$line - 2] . "\n") - : '' - ) . - self::lpad($padLen, $lineNum) . - ': ' . - $lines[$line - 1] . - "\n" . - self::whitespace(2 + $padLen + $contextColumn - 1) . - "^\n" . - ($line < count($lines) - ? (self::lpad($padLen, $nextLineNum) . ': ' . $lines[$line] . "\n") - : '' - ) - ); + $outputLines = [ + "{$source->name} ($contextLine:$contextColumn)", + $line >= 2 ? (self::lpad($padLen, $prevLineNum) . ': ' . $lines[$line - 2]) : null, + self::lpad($padLen, $lineNum) . ': ' . $lines[$line - 1], + self::whitespace(2 + $padLen + $contextColumn - 1) . '^', + $line < count($lines)? self::lpad($padLen, $nextLineNum) . ': ' . $lines[$line] : null + ]; + + return join("\n", array_filter($outputLines)); } /** diff --git a/tests/ErrorTest.php b/tests/Error/ErrorTest.php similarity index 99% rename from tests/ErrorTest.php rename to tests/Error/ErrorTest.php index fc526090e..384383550 100644 --- a/tests/ErrorTest.php +++ b/tests/Error/ErrorTest.php @@ -1,5 +1,5 @@ definitions[0]->fields[0]->type; + + $sourceB = Parser::parse(new Source('type Foo { + field: Int +}', + 'SourceB' + )); + + $fieldTypeB = $sourceB->definitions[0]->fields[0]->type; + + + $error = new Error( + 'Example error with two nodes', + [ + $fieldTypeA, + $fieldTypeB, + ] + ); + + $this->assertEquals( + 'Example error with two nodes + +SourceA (2:10) +1: type Foo { +2: field: String + ^ +3: } + +SourceB (2:10) +1: type Foo { +2: field: Int + ^ +3: } +', + FormattedError::printError($error) + ); + } +} From 6d45a22ba4cead02ff4cc0b914619483e0a25062 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Tue, 13 Feb 2018 10:41:04 +0100 Subject: [PATCH 32/50] Always extract extensions from the original error if possible ref: graphql/graphql-js#2d08496720088dbe65ebea312c8526bd48fb8ee8 --- src/Error/Error.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Error/Error.php b/src/Error/Error.php index 369459212..ac931bf0f 100644 --- a/src/Error/Error.php +++ b/src/Error/Error.php @@ -173,7 +173,11 @@ public function __construct( $this->source = $source; $this->positions = $positions; $this->path = $path; - $this->extensions = $extensions; + $this->extensions = $extensions ?: ( + $previous && $previous instanceof self + ? $previous->extensions + : [] + ); if ($previous instanceof ClientAware) { $this->isClientSafe = $previous->isClientSafe(); From 60df83f47e5bddc9315e725c0a779f7eeb249c4f Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Tue, 13 Feb 2018 16:51:44 +0100 Subject: [PATCH 33/50] Preserve original coercion errors, improve error quality. This is a fairly major refactoring of coerceValue which returns an Either so it can return a complete collection of errors. This allows originalError to be preserved for scalar coercion errors and ensures *all* errors are represented in the response. This had a minor change to the logic in execute / subscribe to allow for buildExecutionContext to abrupt complete with multiple errors. ref: graphql/graphql-js#1133 --- src/Error/Error.php | 8 + src/Executor/Executor.php | 93 ++++--- src/Executor/Values.php | 280 ++++--------------- src/Type/Definition/FloatType.php | 34 ++- src/Type/Definition/IDType.php | 4 +- src/Type/Definition/IntType.php | 62 ++--- src/Type/Definition/StringType.php | 21 +- src/Utils/Utils.php | 38 +-- src/Utils/Value.php | 224 +++++++++++++++ src/Validator/Rules/QueryComplexity.php | 23 +- tests/Executor/ValuesTest.php | 353 +++++++++++------------- tests/Executor/VariablesTest.php | 72 +++-- tests/Server/QueryExecutionTest.php | 2 +- tests/Server/RequestValidationTest.php | 6 +- tests/Type/EnumTypeTest.php | 6 +- tests/Type/ScalarSerializationTest.php | 33 ++- tests/Type/TypeLoaderTest.php | 2 +- tests/Type/ValidationTest.php | 4 +- tests/Utils/CoerceValueTest.php | 201 ++++++++++++++ tests/Utils/IsValidPHPValueTest.php | 132 --------- tools/gendocs.php | 4 +- 21 files changed, 864 insertions(+), 738 deletions(-) create mode 100644 src/Utils/Value.php create mode 100644 tests/Utils/CoerceValueTest.php delete mode 100644 tests/Utils/IsValidPHPValueTest.php diff --git a/src/Error/Error.php b/src/Error/Error.php index ac931bf0f..be2c9b587 100644 --- a/src/Error/Error.php +++ b/src/Error/Error.php @@ -277,6 +277,14 @@ public function getLocations() return $this->locations; } + /** + * @return array|Node[]|null + */ + public function getNodes() + { + return $this->nodes; + } + /** * Returns an array describing the path from the root value to the field which produced this error. * Only included for execution errors. diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index 8ce9baa09..fac95536a 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -100,9 +100,16 @@ public static function execute( { // TODO: deprecate (just always use SyncAdapter here) and have `promiseToExecute()` for other cases $promiseAdapter = self::getPromiseAdapter(); - - $result = self::promiseToExecute($promiseAdapter, $schema, $ast, $rootValue, $contextValue, - $variableValues, $operationName, $fieldResolver); + $result = self::promiseToExecute( + $promiseAdapter, + $schema, + $ast, + $rootValue, + $contextValue, + $variableValues, + $operationName, + $fieldResolver + ); // Wait for promised results when using sync promises if ($promiseAdapter instanceof SyncPromiseAdapter) { @@ -140,11 +147,19 @@ public static function promiseToExecute( callable $fieldResolver = null ) { - try { - $exeContext = self::buildExecutionContext($schema, $ast, $rootValue, $contextValue, $variableValues, - $operationName, $fieldResolver, $promiseAdapter); - } catch (Error $e) { - return $promiseAdapter->createFulfilled(new ExecutionResult(null, [$e])); + $exeContext = self::buildExecutionContext( + $schema, + $ast, + $rootValue, + $contextValue, + $variableValues, + $operationName, + $fieldResolver, + $promiseAdapter + ); + + if (is_array($exeContext)) { + return $promiseAdapter->createFulfilled(new ExecutionResult(null, $exeContext)); } $executor = new self($exeContext); @@ -159,13 +174,12 @@ public static function promiseToExecute( * @param DocumentNode $documentNode * @param $rootValue * @param $contextValue - * @param $rawVariableValues + * @param array|\Traversable $rawVariableValues * @param string $operationName * @param callable $fieldResolver * @param PromiseAdapter $promiseAdapter * - * @return ExecutionContext - * @throws Error + * @return ExecutionContext|Error[] */ private static function buildExecutionContext( Schema $schema, @@ -178,30 +192,17 @@ private static function buildExecutionContext( PromiseAdapter $promiseAdapter = null ) { - if (null !== $rawVariableValues) { - Utils::invariant( - is_array($rawVariableValues) || $rawVariableValues instanceof \ArrayAccess, - "Variable values are expected to be array or instance of ArrayAccess, got " . Utils::getVariableType($rawVariableValues) - ); - } - if (null !== $operationName) { - Utils::invariant( - is_string($operationName), - "Operation name is supposed to be string, got " . Utils::getVariableType($operationName) - ); - } - $errors = []; $fragments = []; + /** @var OperationDefinitionNode $operation */ $operation = null; + $hasMultipleAssumedOperations = false; foreach ($documentNode->definitions as $definition) { switch ($definition->kind) { case NodeKind::OPERATION_DEFINITION: if (!$operationName && $operation) { - throw new Error( - 'Must provide operation name if query contains multiple operations.' - ); + $hasMultipleAssumedOperations = true; } if (!$operationName || (isset($definition->name) && $definition->name->value === $operationName)) { @@ -216,19 +217,40 @@ private static function buildExecutionContext( if (!$operation) { if ($operationName) { - throw new Error("Unknown operation named \"$operationName\"."); + $errors[] = new Error("Unknown operation named \"$operationName\"."); } else { - throw new Error('Must provide an operation.'); + $errors[] = new Error('Must provide an operation.'); } + } else if ($hasMultipleAssumedOperations) { + $errors[] = new Error( + 'Must provide operation name if query contains multiple operations.' + ); + } - $variableValues = Values::getVariableValues( - $schema, - $operation->variableDefinitions ?: [], - $rawVariableValues ?: [] - ); + $variableValues = null; + if ($operation) { + $coercedVariableValues = Values::getVariableValues( + $schema, + $operation->variableDefinitions ?: [], + $rawVariableValues ?: [] + ); + + if ($coercedVariableValues['errors']) { + $errors = array_merge($errors, $coercedVariableValues['errors']); + } else { + $variableValues = $coercedVariableValues['coerced']; + } + } + + if ($errors) { + return $errors; + } + + Utils::invariant($operation, 'Has operation if no errors.'); + Utils::invariant($variableValues !== null, 'Has variables if no errors.'); - $exeContext = new ExecutionContext( + return new ExecutionContext( $schema, $fragments, $rootValue, @@ -239,7 +261,6 @@ private static function buildExecutionContext( $fieldResolver ?: self::$defaultFieldResolver, $promiseAdapter ?: self::getPromiseAdapter() ); - return $exeContext; } /** diff --git a/src/Executor/Values.php b/src/Executor/Values.php index 0644db001..ef6a8cff8 100644 --- a/src/Executor/Values.php +++ b/src/Executor/Values.php @@ -26,6 +26,7 @@ use GraphQL\Utils\AST; use GraphQL\Utils\TypeInfo; use GraphQL\Utils\Utils; +use GraphQL\Utils\Value; use GraphQL\Validator\DocumentValidator; class Values @@ -36,56 +37,62 @@ class Values * to match the variable definitions, a Error will be thrown. * * @param Schema $schema - * @param VariableDefinitionNode[] $definitionNodes + * @param VariableDefinitionNode[] $varDefNodes * @param array $inputs * @return array - * @throws Error */ - public static function getVariableValues(Schema $schema, $definitionNodes, array $inputs) + public static function getVariableValues(Schema $schema, $varDefNodes, array $inputs) { + $errors = []; $coercedValues = []; - foreach ($definitionNodes as $definitionNode) { - $varName = $definitionNode->variable->name->value; - $varType = TypeInfo::typeFromAST($schema, $definitionNode->type); + foreach ($varDefNodes as $varDefNode) { + $varName = $varDefNode->variable->name->value; + /** @var InputType|Type $varType */ + $varType = TypeInfo::typeFromAST($schema, $varDefNode->type); if (!Type::isInputType($varType)) { - throw new Error( - 'Variable "$'.$varName.'" expected value of type ' . - '"' . Printer::doPrint($definitionNode->type) . '" which cannot be used as an input type.', - [$definitionNode->type] + $errors[] = new Error( + "Variable \"\$$varName\" expected value of type " . + '"' . Printer::doPrint($varDefNode->type) . '" which cannot be used as an input type.', + [$varDefNode->type] ); - } - - if (!array_key_exists($varName, $inputs)) { - $defaultValue = $definitionNode->defaultValue; - if ($defaultValue) { - $coercedValues[$varName] = AST::valueFromAST($defaultValue, $varType); - } - if ($varType instanceof NonNull) { - throw new Error( - 'Variable "$'.$varName .'" of required type ' . - '"'. Utils::printSafe($varType) . '" was not provided.', - [$definitionNode] - ); - } } 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, - [$definitionNode] - ); + if (!array_key_exists($varName, $inputs)) { + if ($varType instanceof NonNull) { + $errors[] = new Error( + "Variable \"\$$varName\" of required type " . + "\"{$varType}\" was not provided.", + [$varDefNode] + ); + } else if ($varDefNode->defaultValue) { + $coercedValues[$varName] = AST::valueFromAST($varDefNode->defaultValue, $varType); + } + } else { + $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) . '; '; + + foreach($coercionErrors as $error) { + $errors[] = new Error( + $messagePrelude . $error->getMessage(), + $error->getNodes(), + $error->getSource(), + $error->getPositions(), + $error->getPath(), + $error, + $error->getExtensions() + ); + } + } else { + $coercedValues[$varName] = $coerced['value']; + } } - - $coercedValue = self::coerceValue($varType, $value); - Utils::invariant($coercedValue !== Utils::undefined(), 'Should have reported error.'); - $coercedValues[$varName] = $coercedValue; } } - return $coercedValues; + return ['errors' => $errors, 'coerced' => $errors ? null : $coercedValues]; } /** @@ -206,203 +213,16 @@ public static function valueFromAST($valueNode, InputType $type, $variables = nu } /** - * 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 - * runtime values of query variables. - * + * @deprecated as of 0.12 (Use coerceValue() directly for richer information) * @param $value * @param InputType $type * @return array */ public static function isValidPHPValue($value, InputType $type) { - // A value must be provided if the type is non-null. - if ($type instanceof NonNull) { - if (null === $value) { - return ['Expected "' . Utils::printSafe($type) . '", found null.']; - } - return self::isValidPHPValue($value, $type->getWrappedType()); - } - - 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)) { - $tmp = []; - foreach ($value as $index => $item) { - $errors = self::isValidPHPValue($item, $itemType); - $tmp = array_merge($tmp, Utils::map($errors, function ($error) use ($index) { - return "In element #$index: $error"; - })); - } - return $tmp; - } - return self::isValidPHPValue($value, $itemType); - } - - // Input objects check each defined field. - if ($type instanceof InputObjectType) { - if (!is_object($value) && !is_array($value)) { - return ["Expected \"{$type->name}\", found not an object."]; - } - $fields = $type->getFields(); - $errors = []; - - // Ensure every provided field is defined. - $props = is_object($value) ? get_object_vars($value) : $value; - foreach ($props as $providedField => $tmp) { - if (!isset($fields[$providedField])) { - $errors[] = "In field \"{$providedField}\": Unknown field."; - } - } - - // Ensure every defined field is valid. - foreach ($fields as $fieldName => $tmp) { - $newErrors = self::isValidPHPValue(isset($value[$fieldName]) ? $value[$fieldName] : null, $fields[$fieldName]->getType()); - $errors = array_merge( - $errors, - Utils::map($newErrors, function ($error) use ($fieldName) { - return "In field \"{$fieldName}\": {$error}"; - }) - ); - } - return $errors; - } - - if ($type instanceof EnumType) { - if (!is_string($value) || !$type->getValue($value)) { - $printed = Utils::printSafeJson($value); - return ["Expected type \"{$type->name}\", found $printed."]; - } - - return []; - } - - Utils::invariant($type instanceof ScalarType, 'Must be a scalar type'); - /** @var ScalarType $type */ - - // Scalars determine if a value is valid via parseValue(). - try { - $parseResult = $type->parseValue($value); - if (Utils::isInvalid($parseResult)) { - $printed = Utils::printSafeJson($value); - return [ - "Expected type \"{$type->name}\", found $printed." - ]; - } - } catch (\Exception $error) { - $printed = Utils::printSafeJson($value); - $message = $error->getMessage(); - return ["Expected type \"{$type->name}\", found $printed; $message"]; - } catch (\Throwable $error) { - $printed = Utils::printSafeJson($value); - $message = $error->getMessage(); - return ["Expected type \"{$type->name}\", found $printed; $message"]; - } - - return []; - } - - /** - * Given a type and any value, return a runtime value coerced to match the type. - */ - private static function coerceValue(Type $type, $value) - { - $undefined = Utils::undefined(); - if ($value === $undefined) { - return $undefined; - } - - if ($type instanceof NonNull) { - if ($value === null) { - // Intentionally return no value. - return $undefined; - } - return self::coerceValue($type->getWrappedType(), $value); - } - - if (null === $value) { - return null; - } - - if ($type instanceof ListOfType) { - $itemType = $type->getWrappedType(); - if (is_array($value) || $value instanceof \Traversable) { - $coercedValues = []; - foreach ($value as $item) { - $itemValue = self::coerceValue($itemType, $item); - if ($undefined === $itemValue) { - // Intentionally return no value. - return $undefined; - } - $coercedValues[] = $itemValue; - } - return $coercedValues; - } else { - $coercedValue = self::coerceValue($itemType, $value); - if ($coercedValue === $undefined) { - // Intentionally return no value. - return $undefined; - } - return [$coercedValue]; - } - } - - if ($type instanceof InputObjectType) { - $coercedObj = []; - $fields = $type->getFields(); - foreach ($fields as $fieldName => $field) { - 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; - } - $fieldValue = self::coerceValue($field->getType(), $value[$fieldName]); - if ($fieldValue === $undefined) { - // Intentionally return no value. - return $undefined; - } - $coercedObj[$fieldName] = $fieldValue; - } - return $coercedObj; - } - - if ($type instanceof EnumType) { - if (!is_string($value) || !$type->getValue($value)) { - return $undefined; - } - - $enumValue = $type->getValue($value); - if (!$enumValue) { - return $undefined; - } - - return $enumValue->value; - } - - Utils::invariant($type instanceof ScalarType, 'Must be a scalar type'); - /** @var ScalarType $type */ - - // Scalars determine if a value is valid via parseValue(). - try { - $parseResult = $type->parseValue($value); - if (Utils::isInvalid($parseResult)) { - return $undefined; - } - } catch (\Exception $error) { - return $undefined; - } catch (\Throwable $error) { - return $undefined; - } - - return $parseResult; + $errors = Value::coerceValue($value, $type)['errors']; + return $errors + ? array_map(function(/*\Throwable */$error) { return $error->getMessage(); }, $errors) + : []; } } diff --git a/src/Type/Definition/FloatType.php b/src/Type/Definition/FloatType.php index 826b017ec..e6391decd 100644 --- a/src/Type/Definition/FloatType.php +++ b/src/Type/Definition/FloatType.php @@ -1,7 +1,7 @@ coerceFloat($value); } /** * @param mixed $value * @return float|null + * @throws Error */ public function parseValue($value) { - return (is_numeric($value) && !is_string($value)) ? (float) $value : Utils::undefined(); + return $this->coerceFloat($value); } /** @@ -64,4 +57,21 @@ public function parseLiteral($valueNode, array $variables = null) } return Utils::undefined(); } + + private function coerceFloat($value) { + if ($value === '') { + throw new Error( + 'Float cannot represent non numeric value: (empty string)' + ); + } + + if (!is_numeric($value) && $value !== true && $value !== false) { + throw new Error( + 'Float cannot represent non numeric value: ' . + Utils::printSafe($value) + ); + } + + return (float) $value; + } } diff --git a/src/Type/Definition/IDType.php b/src/Type/Definition/IDType.php index 47ed8979c..d60a3a153 100644 --- a/src/Type/Definition/IDType.php +++ b/src/Type/Definition/IDType.php @@ -1,7 +1,7 @@ self::MAX_INT || $value < self::MIN_INT) { - throw new InvariantViolation(sprintf( - 'Int cannot represent non 32-bit signed integer value: %s', - Utils::printSafe($value) - )); - } - $num = (float) $value; - - // The GraphQL specification does not allow serializing non-integer values - // as Int to avoid accidental data loss. - // Examples: 1.0 == 1; 1.1 != 1, etc - if ($num != (int) $value) { - // Additionally account for scientific notation (i.e. 1e3), because (float)'1e3' is 1000, but (int)'1e3' is 1 - $trimmed = floor($num); - if ($trimmed !== $num) { - throw new InvariantViolation(sprintf( - 'Int cannot represent non-integer value: %s', - Utils::printSafe($value) - )); - } - } - return (int) $value; + return $this->coerceInt($value); } /** * @param mixed $value * @return int|null + * @throws Error */ public function parseValue($value) { - // Below is a fix against PHP bug where (in some combinations of OSs and versions) - // boundary values are treated as "double" vs "integer" and failing is_int() check - $isInt = is_int($value) || $value === self::MIN_INT || $value === self::MAX_INT; - return $isInt && $value <= self::MAX_INT && $value >= self::MIN_INT ? $value : Utils::undefined(); + return $this->coerceInt($value); } /** @@ -94,4 +66,28 @@ public function parseLiteral($valueNode, array $variables = null) } return Utils::undefined(); } + + private function coerceInt($value) { + if ($value === '') { + throw new Error( + 'Int cannot represent non 32-bit signed integer value: (empty string)' + ); + } + + $num = floatval($value); + if (!is_numeric($value) && !is_bool($value) || $num > self::MAX_INT || $num < self::MIN_INT) { + throw new Error( + 'Int cannot represent non 32-bit signed integer value: ' . + Utils::printSafe($value) + ); + } + $int = intval($num); + if ($int != $num) { + throw new Error( + 'Int cannot represent non-integer value: ' . + Utils::printSafe($value) + ); + } + return $int; + } } diff --git a/src/Type/Definition/StringType.php b/src/Type/Definition/StringType.php index 98dab8277..a17bdccb6 100644 --- a/src/Type/Definition/StringType.php +++ b/src/Type/Definition/StringType.php @@ -1,7 +1,7 @@ coerceString($value); } /** * @param mixed $value * @return string + * @throws Error */ public function parseValue($value) { - return is_string($value) ? $value : Utils::undefined(); + return $this->coerceString($value); } /** @@ -66,4 +68,15 @@ public function parseLiteral($valueNode, array $variables = null) } return Utils::undefined(); } + + private function coerceString($value) { + if (is_array($value)) { + throw new Error( + 'String cannot represent an array value: ' . + Utils::printSafe($value) + ); + } + + return (string) $value; + } } diff --git a/src/Utils/Utils.php b/src/Utils/Utils.php index cb019ef49..0b48c4132 100644 --- a/src/Utils/Utils.php +++ b/src/Utils/Utils.php @@ -269,22 +269,7 @@ public static function printSafeJson($var) $var = (array) $var; } if (is_array($var)) { - $count = count($var); - if (!isset($var[0]) && $count > 0) { - $keys = []; - $keyCount = 0; - foreach ($var as $key => $value) { - $keys[] = '"' . $key . '"'; - if ($keyCount++ > 4) { - break; - } - } - $keysLabel = $keyCount === 1 ? 'key' : 'keys'; - $msg = "object with first $keysLabel: " . implode(', ', $keys); - } else { - $msg = "array($count)"; - } - return $msg; + return json_encode($var); } if ('' === $var) { return '(empty string)'; @@ -296,7 +281,7 @@ public static function printSafeJson($var) return 'false'; } if (true === $var) { - return 'false'; + return 'true'; } if (is_string($var)) { return "\"$var\""; @@ -320,22 +305,7 @@ public static function printSafe($var) return 'instance of ' . get_class($var); } if (is_array($var)) { - $count = count($var); - if (!isset($var[0]) && $count > 0) { - $keys = []; - $keyCount = 0; - foreach ($var as $key => $value) { - $keys[] = '"' . $key . '"'; - if ($keyCount++ > 4) { - break; - } - } - $keysLabel = $keyCount === 1 ? 'key' : 'keys'; - $msg = "associative array($count) with first $keysLabel: " . implode(', ', $keys); - } else { - $msg = "array($count)"; - } - return $msg; + return json_encode($var); } if ('' === $var) { return '(empty string)'; @@ -350,7 +320,7 @@ public static function printSafe($var) return 'true'; } if (is_string($var)) { - return "\"$var\""; + return $var; } if (is_scalar($var)) { return (string) $var; diff --git a/src/Utils/Value.php b/src/Utils/Value.php new file mode 100644 index 000000000..6d45c49df --- /dev/null +++ b/src/Utils/Value.php @@ -0,0 +1,224 @@ +getWrappedType(), $blameNode, $path); + } + + if (null === $value) { + // Explicitly return the value null. + return self::ofValue(null); + } + + if ($type instanceof ScalarType) { + // Scalars determine if a value is valid via parseValue(), which can + // throw to indicate failure. If it throws, maintain a reference to + // the original error. + try { + $parseResult = $type->parseValue($value); + if (Utils::isInvalid($parseResult)) { + return self::ofErrors([ + self::coercionError("Expected type {$type->name}", $blameNode, $path), + ]); + } + + return self::ofValue($parseResult); + } catch (\Exception $error) { + return self::ofErrors([ + self::coercionError("Expected type {$type->name}", $blameNode, $path, $error), + ]); + } catch (\Throwable $error) { + return self::ofErrors([ + self::coercionError("Expected type {$type->name}", $blameNode, $path, $error), + ]); + } + } + + if ($type instanceof EnumType) { + if (is_string($value)) { + $enumValue = $type->getValue($value); + if ($enumValue) { + return self::ofValue($enumValue->value); + } + } + + return self::ofErrors([ + self::coercionError("Expected type {$type->name}", $blameNode, $path), + ]); + } + + if ($type instanceof ListOfType) { + $itemType = $type->getWrappedType(); + if (is_array($value) || $value instanceof \Traversable) { + $errors = []; + $coercedValue = []; + foreach ($value as $index => $itemValue) { + $coercedItem = self::coerceValue( + $itemValue, + $itemType, + $blameNode, + self::atPath($path, $index) + ); + if ($coercedItem['errors']) { + $errors = self::add($errors, $coercedItem['errors']); + } else { + $coercedValue[] = $coercedItem['value']; + } + } + return $errors ? self::ofErrors($errors) : self::ofValue($coercedValue); + } + // Lists accept a non-list value as a list of one. + $coercedItem = self::coerceValue($value, $itemType, $blameNode); + return $coercedItem['errors'] ? $coercedItem : self::ofValue([$coercedItem['value']]); + } + + if ($type instanceof InputObjectType) { + if (!is_object($value) && !is_array($value) && !$value instanceof \Traversable) { + return self::ofErrors([ + self::coercionError("Expected object type {$type->name}", $blameNode, $path), + ]); + } + + $errors = []; + $coercedValue = []; + $fields = $type->getFields(); + foreach ($fields as $fieldName => $field) { + if (!array_key_exists($fieldName, $value)) { + if ($field->defaultValueExists()) { + $coercedValue[$fieldName] = $field->defaultValue; + } else if ($field->getType() instanceof NonNull) { + $fieldPath = self::printPath(self::atPath($path, $fieldName)); + $errors = self::add( + $errors, + self::coercionError( + "Field {$fieldPath} of required " . + "type {$field->type} was not provided", + $blameNode + ) + ); + } + } else { + $fieldValue = $value[$fieldName]; + $coercedField = self::coerceValue( + $fieldValue, + $field->getType(), + $blameNode, + self::atPath($path, $fieldName) + ); + if ($coercedField['errors']) { + $errors = self::add($errors, $coercedField['errors']); + } else { + $coercedValue[$fieldName] = $coercedField['value']; + } + } + } + + // Ensure every provided field is defined. + foreach ($value as $fieldName => $field) { + if (!array_key_exists($fieldName, $fields)) { + $errors = self::add( + $errors, + self::coercionError( + "Field \"{$fieldName}\" is not defined by type {$type->name}", + $blameNode, + $path + ) + ); + } + } + + return $errors ? self::ofErrors($errors) : self::ofValue($coercedValue); + } + + throw new Error("Unexpected type {$type}"); + } + + private static function ofValue($value) { + return ['errors' => null, 'value' => $value]; + } + + private static function ofErrors($errors) { + return ['errors' => $errors, 'value' => Utils::undefined()]; + } + + private static function add($errors, $moreErrors) { + return array_merge($errors, is_array($moreErrors) ? $moreErrors : [$moreErrors]); + } + + private static function atPath($prev, $key) { + return ['prev' => $prev, 'key' => $key]; + } + + /** + * @param string $message + * @param Node $blameNode + * @param array|null $path + * @param \Exception|\Throwable|null $originalError + * @return Error + */ + private static function coercionError($message, $blameNode, array $path = null, $originalError = null) { + $pathStr = self::printPath($path); + // Return a GraphQLError instance + return new Error( + $message . + ($pathStr ? ' at ' . $pathStr : '') . + ($originalError && $originalError->getMessage() + ? '; ' . $originalError->getMessage() + : '.'), + $blameNode, + null, + null, + null, + $originalError + ); + } + + /** + * Build a string describing the path into the value where the error was found + * + * @param $path + * @return string + */ + private static function printPath(array $path = null) { + $pathStr = ''; + $currentPath = $path; + while($currentPath) { + $pathStr = + (is_string($currentPath['key']) + ? '.' . $currentPath['key'] + : '[' . $currentPath['key'] . ']') . $pathStr; + $currentPath = $currentPath['prev']; + } + return $pathStr ? 'value' . $pathStr : ''; + } +} diff --git a/src/Validator/Rules/QueryComplexity.php b/src/Validator/Rules/QueryComplexity.php index f20334e22..0b1d61160 100644 --- a/src/Validator/Rules/QueryComplexity.php +++ b/src/Validator/Rules/QueryComplexity.php @@ -203,11 +203,21 @@ private function buildFieldArguments(FieldNode $node) $args = []; if ($fieldDef instanceof FieldDefinition) { - $variableValues = Values::getVariableValues( + $variableValuesResult = Values::getVariableValues( $this->context->getSchema(), $this->variableDefs, $rawVariableValues ); + + if ($variableValuesResult['errors']) { + throw new Error(implode("\n\n", array_map( + function ($error) { + return $error->getMessage(); + } + , $variableValuesResult['errors']))); + } + $variableValues = $variableValuesResult['coerced']; + $args = Values::getArgumentValues($fieldDef, $node, $variableValues); } @@ -220,12 +230,21 @@ private function directiveExcludesField(FieldNode $node) { return false; } - $variableValues = Values::getVariableValues( + $variableValuesResult = Values::getVariableValues( $this->context->getSchema(), $this->variableDefs, $this->getRawVariableValues() ); + if ($variableValuesResult['errors']) { + throw new Error(implode("\n\n", array_map( + function ($error) { + return $error->getMessage(); + } + , $variableValuesResult['errors']))); + } + $variableValues = $variableValuesResult['coerced']; + if ($directiveNode->name->value === 'include') { $directive = Directive::includeDirective(); $directiveArgs = Values::getArgumentValues($directive, $directiveNode, $variableValues); diff --git a/tests/Executor/ValuesTest.php b/tests/Executor/ValuesTest.php index d50fb8649..1fe81cf40 100644 --- a/tests/Executor/ValuesTest.php +++ b/tests/Executor/ValuesTest.php @@ -12,197 +12,162 @@ class ValuesTest extends \PHPUnit_Framework_TestCase { - public function testGetIDVariableValues() - { - $this->expectInputVariablesMatchOutputVariables(['idInput' => '123456789']); - $this->assertEquals( - ['idInput' => '123456789'], - self::runTestCase(['idInput' => 123456789]), - 'Integer ID was not converted to string' - ); - } - - public function testGetBooleanVariableValues() - { - $this->expectInputVariablesMatchOutputVariables(['boolInput' => true]); - $this->expectInputVariablesMatchOutputVariables(['boolInput' => false]); - } - - public function testGetIntVariableValues() - { - $this->expectInputVariablesMatchOutputVariables(['intInput' => -1]); - $this->expectInputVariablesMatchOutputVariables(['intInput' => 0]); - $this->expectInputVariablesMatchOutputVariables(['intInput' => 1]); - - // Test the int size limit - $this->expectInputVariablesMatchOutputVariables(['intInput' => 2147483647]); - $this->expectInputVariablesMatchOutputVariables(['intInput' => -2147483648]); - } - - public function testGetStringVariableValues() - { - $this->expectInputVariablesMatchOutputVariables(['stringInput' => 'meow']); - $this->expectInputVariablesMatchOutputVariables(['stringInput' => '']); - $this->expectInputVariablesMatchOutputVariables(['stringInput' => '1']); - $this->expectInputVariablesMatchOutputVariables(['stringInput' => '0']); - $this->expectInputVariablesMatchOutputVariables(['stringInput' => 'false']); - $this->expectInputVariablesMatchOutputVariables(['stringInput' => '1.2']); - } - - public function testGetFloatVariableValues() - { - $this->expectInputVariablesMatchOutputVariables(['floatInput' => 1.2]); - $this->expectInputVariablesMatchOutputVariables(['floatInput' => 1.0]); - $this->expectInputVariablesMatchOutputVariables(['floatInput' => 1]); - $this->expectInputVariablesMatchOutputVariables(['floatInput' => 0]); - $this->expectInputVariablesMatchOutputVariables(['floatInput' => 1e3]); - } - - public function testBooleanForIDVariableThrowsError() - { - $this->expectGraphQLError(['idInput' => true]); - } - - public function testFloatForIDVariableThrowsError() - { - $this->expectGraphQLError(['idInput' => 1.0]); - } - - public function testStringForBooleanVariableThrowsError() - { - $this->expectGraphQLError(['boolInput' => 'true']); - } - - public function testIntForBooleanVariableThrowsError() - { - $this->expectGraphQLError(['boolInput' => 1]); - } - - public function testFloatForBooleanVariableThrowsError() - { - $this->expectGraphQLError(['boolInput' => 1.0]); - } - - public function testBooleanForIntVariableThrowsError() - { - $this->expectGraphQLError(['intInput' => true]); - } - - public function testStringForIntVariableThrowsError() - { - $this->expectGraphQLError(['intInput' => 'true']); - } - - public function testFloatForIntVariableThrowsError() - { - $this->expectGraphQLError(['intInput' => 1.0]); - } - - public function testPositiveBigIntForIntVariableThrowsError() - { - $this->expectGraphQLError(['intInput' => 2147483648]); - } - - public function testNegativeBigIntForIntVariableThrowsError() - { - $this->expectGraphQLError(['intInput' => -2147483649]); - } - - public function testBooleanForStringVariableThrowsError() - { - $this->expectGraphQLError(['stringInput' => true]); - } - - public function testIntForStringVariableThrowsError() - { - $this->expectGraphQLError(['stringInput' => 1]); - } - - public function testFloatForStringVariableThrowsError() - { - $this->expectGraphQLError(['stringInput' => 1.0]); - } - - public function testBooleanForFloatVariableThrowsError() - { - $this->expectGraphQLError(['floatInput' => true]); - } - - public function testStringForFloatVariableThrowsError() - { - $this->expectGraphQLError(['floatInput' => '1.0']); - } - - // Helpers for running test cases and making assertions - - private function expectInputVariablesMatchOutputVariables($variables) - { - $this->assertEquals( - $variables, - self::runTestCase($variables), - 'Output variables did not match input variables' . PHP_EOL . var_export($variables, true) . PHP_EOL - ); - } - - private function expectGraphQLError($variables) - { - $this->setExpectedException(\GraphQL\Error\Error::class); - self::runTestCase($variables); - } - - private static $schema; - - private static function getSchema() - { - if (!self::$schema) { - self::$schema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'test' => [ - 'type' => Type::boolean(), - 'args' => [ - 'idInput' => Type::id(), - 'boolInput' => Type::boolean(), - 'intInput' => Type::int(), - 'stringInput' => Type::string(), - 'floatInput' => Type::float() - ] - ], - ] - ]) - ]); - } - return self::$schema; - } - - private static function getVariableDefinitionNodes() - { - $idInputDefinition = new VariableDefinitionNode([ - 'variable' => new VariableNode(['name' => new NameNode(['value' => 'idInput'])]), - 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'ID'])]) - ]); - $boolInputDefinition = new VariableDefinitionNode([ - 'variable' => new VariableNode(['name' => new NameNode(['value' => 'boolInput'])]), - 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'Boolean'])]) - ]); - $intInputDefinition = new VariableDefinitionNode([ - 'variable' => new VariableNode(['name' => new NameNode(['value' => 'intInput'])]), - 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'Int'])]) - ]); - $stringInputDefintion = new VariableDefinitionNode([ - 'variable' => new VariableNode(['name' => new NameNode(['value' => 'stringInput'])]), - 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'String'])]) - ]); - $floatInputDefinition = new VariableDefinitionNode([ - 'variable' => new VariableNode(['name' => new NameNode(['value' => 'floatInput'])]), - 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'Float'])]) - ]); - return [$idInputDefinition, $boolInputDefinition, $intInputDefinition, $stringInputDefintion, $floatInputDefinition]; - } - - private function runTestCase($variables) - { - return Values::getVariableValues(self::getSchema(), self::getVariableDefinitionNodes(), $variables); - } -} \ No newline at end of file + public function testGetIDVariableValues() + { + $this->expectInputVariablesMatchOutputVariables(['idInput' => '123456789']); + $this->assertEquals( + ['errors'=> [], 'coerced' => ['idInput' => '123456789']], + self::runTestCase(['idInput' => 123456789]), + 'Integer ID was not converted to string' + ); + } + + public function testGetBooleanVariableValues() + { + $this->expectInputVariablesMatchOutputVariables(['boolInput' => true]); + $this->expectInputVariablesMatchOutputVariables(['boolInput' => false]); + } + + public function testGetIntVariableValues() + { + $this->expectInputVariablesMatchOutputVariables(['intInput' => -1]); + $this->expectInputVariablesMatchOutputVariables(['intInput' => 0]); + $this->expectInputVariablesMatchOutputVariables(['intInput' => 1]); + + // Test the int size limit + $this->expectInputVariablesMatchOutputVariables(['intInput' => 2147483647]); + $this->expectInputVariablesMatchOutputVariables(['intInput' => -2147483648]); + } + + public function testGetStringVariableValues() + { + $this->expectInputVariablesMatchOutputVariables(['stringInput' => 'meow']); + $this->expectInputVariablesMatchOutputVariables(['stringInput' => '']); + $this->expectInputVariablesMatchOutputVariables(['stringInput' => '1']); + $this->expectInputVariablesMatchOutputVariables(['stringInput' => '0']); + $this->expectInputVariablesMatchOutputVariables(['stringInput' => 'false']); + $this->expectInputVariablesMatchOutputVariables(['stringInput' => '1.2']); + } + + public function testGetFloatVariableValues() + { + $this->expectInputVariablesMatchOutputVariables(['floatInput' => 1.2]); + $this->expectInputVariablesMatchOutputVariables(['floatInput' => 1.0]); + $this->expectInputVariablesMatchOutputVariables(['floatInput' => 1]); + $this->expectInputVariablesMatchOutputVariables(['floatInput' => 0]); + $this->expectInputVariablesMatchOutputVariables(['floatInput' => 1e3]); + } + + public function testBooleanForIDVariableThrowsError() + { + $this->expectGraphQLError(['idInput' => true]); + } + + public function testFloatForIDVariableThrowsError() + { + $this->expectGraphQLError(['idInput' => 1.0]); + } + + public function testStringForBooleanVariableThrowsError() + { + $this->expectGraphQLError(['boolInput' => 'true']); + } + + public function testIntForBooleanVariableThrowsError() + { + $this->expectGraphQLError(['boolInput' => 1]); + } + + public function testFloatForBooleanVariableThrowsError() + { + $this->expectGraphQLError(['boolInput' => 1.0]); + } + + public function testStringForIntVariableThrowsError() + { + $this->expectGraphQLError(['intInput' => 'true']); + } + + public function testPositiveBigIntForIntVariableThrowsError() + { + $this->expectGraphQLError(['intInput' => 2147483648]); + } + + public function testNegativeBigIntForIntVariableThrowsError() + { + $this->expectGraphQLError(['intInput' => -2147483649]); + } + + // Helpers for running test cases and making assertions + + private function expectInputVariablesMatchOutputVariables($variables) + { + $this->assertEquals( + $variables, + self::runTestCase($variables)['coerced'], + 'Output variables did not match input variables' . PHP_EOL . var_export($variables, true) . PHP_EOL + ); + } + + private function expectGraphQLError($variables) + { + $result = self::runTestCase($variables); + $this->assertGreaterThan(0, count($result['errors'])); + } + + private static $schema; + + private static function getSchema() + { + if (!self::$schema) { + self::$schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'test' => [ + 'type' => Type::boolean(), + 'args' => [ + 'idInput' => Type::id(), + 'boolInput' => Type::boolean(), + 'intInput' => Type::int(), + 'stringInput' => Type::string(), + 'floatInput' => Type::float() + ] + ], + ] + ]) + ]); + } + return self::$schema; + } + + private static function getVariableDefinitionNodes() + { + $idInputDefinition = new VariableDefinitionNode([ + 'variable' => new VariableNode(['name' => new NameNode(['value' => 'idInput'])]), + 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'ID'])]) + ]); + $boolInputDefinition = new VariableDefinitionNode([ + 'variable' => new VariableNode(['name' => new NameNode(['value' => 'boolInput'])]), + 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'Boolean'])]) + ]); + $intInputDefinition = new VariableDefinitionNode([ + 'variable' => new VariableNode(['name' => new NameNode(['value' => 'intInput'])]), + 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'Int'])]) + ]); + $stringInputDefintion = new VariableDefinitionNode([ + 'variable' => new VariableNode(['name' => new NameNode(['value' => 'stringInput'])]), + 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'String'])]) + ]); + $floatInputDefinition = new VariableDefinitionNode([ + 'variable' => new VariableNode(['name' => new NameNode(['value' => 'floatInput'])]), + 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'Float'])]) + ]); + return [$idInputDefinition, $boolInputDefinition, $intInputDefinition, $stringInputDefintion, $floatInputDefinition]; + } + + private function runTestCase($variables) + { + return Values::getVariableValues(self::getSchema(), self::getVariableDefinitionNodes(), $variables); + } +} diff --git a/tests/Executor/VariablesTest.php b/tests/Executor/VariablesTest.php index 418ed12d4..0180bbdd8 100644 --- a/tests/Executor/VariablesTest.php +++ b/tests/Executor/VariablesTest.php @@ -3,6 +3,7 @@ require_once __DIR__ . '/TestClasses.php'; +use GraphQL\Error\Error; use GraphQL\Error\InvariantViolation; use GraphQL\Executor\Executor; use GraphQL\Language\Parser; @@ -134,7 +135,7 @@ public function testUsingVariables() ]; $this->assertEquals($expected, $result); - // properly parses single value to array: + // properly parses single value to list: $params = ['input' => ['a' => 'foo', 'b' => 'bar', 'c' => 'baz']]; $this->assertEquals( ['data' => ['fieldWithObjectInput' => '{"a":"foo","b":["bar"],"c":"baz"}']], @@ -158,13 +159,15 @@ public function testUsingVariables() 'errors' => [ [ 'message' => - 'Variable "$input" got invalid value {"a":"foo","b":"bar","c":null}.'. "\n". - 'In field "c": Expected "String!", found null.', + 'Variable "$input" got invalid value ' . + '{"a":"foo","b":"bar","c":null}; ' . + 'Expected non-nullable type String! at value.c.', 'locations' => [['line' => 2, 'column' => 17]], 'category' => 'graphql' ] ] ]; + $this->assertEquals($expected, $result->toArray()); // errors on incorrect type: @@ -174,8 +177,8 @@ public function testUsingVariables() 'errors' => [ [ 'message' => - 'Variable "$input" got invalid value "foo bar".' . "\n" . - 'Expected "TestInputObject", found not an object.', + 'Variable "$input" got invalid value "foo bar"; ' . + 'Expected object type TestInputObject.', 'locations' => [ [ 'line' => 2, 'column' => 17 ] ], 'category' => 'graphql', ] @@ -191,8 +194,8 @@ public function testUsingVariables() 'errors' => [ [ 'message' => - 'Variable "$input" got invalid value {"a":"foo","b":"bar"}.'. "\n". - 'In field "c": Expected "String!", found null.', + 'Variable "$input" got invalid value {"a":"foo","b":"bar"}; '. + 'Field value.c of required type String! was not provided.', 'locations' => [['line' => 2, 'column' => 17]], 'category' => 'graphql', ] @@ -214,9 +217,15 @@ public function testUsingVariables() 'errors' => [ [ 'message' => - 'Variable "$input" got invalid value {"na":{"a":"foo"}}.' . "\n" . - 'In field "na": In field "c": Expected "String!", found null.' . "\n" . - 'In field "nb": Expected "String!", found null.', + 'Variable "$input" got invalid value {"na":{"a":"foo"}}; ' . + 'Field value.na.c of required type String! was not provided.', + 'locations' => [['line' => 2, 'column' => 19]], + 'category' => 'graphql', + ], + [ + 'message' => + 'Variable "$input" got invalid value {"na":{"a":"foo"}}; ' . + 'Field value.nb of required type String! was not provided.', 'locations' => [['line' => 2, 'column' => 19]], 'category' => 'graphql', ] @@ -226,14 +235,15 @@ public function testUsingVariables() // errors on addition of unknown input field - $params = ['input' => [ 'a' => 'foo', 'b' => 'bar', 'c' => 'baz', 'd' => 'dog' ]]; + $params = ['input' => [ 'a' => 'foo', 'b' => 'bar', 'c' => 'baz', 'extra' => 'dog' ]]; $result = Executor::execute($schema, $ast, null, null, $params); $expected = [ 'errors' => [ [ 'message' => - 'Variable "$input" got invalid value {"a":"foo","b":"bar","c":"baz","d":"dog"}.'."\n". - 'In field "d": Expected type "ComplexScalar", found "dog".', + 'Variable "$input" got invalid value ' . + '{"a":"foo","b":"bar","c":"baz","extra":"dog"}; ' . + 'Field "extra" is not defined by type TestInputObject.', 'locations' => [['line' => 2, 'column' => 17]], 'category' => 'graphql', ] @@ -401,8 +411,8 @@ public function testDoesNotAllowNonNullableInputsToBeSetToNullInAVariable() 'errors' => [ [ 'message' => - 'Variable "$value" got invalid value null.' . "\n". - 'Expected "String!", found null.', + 'Variable "$value" got invalid value null; ' . + 'Expected non-nullable type String!.', 'locations' => [['line' => 2, 'column' => 31]], 'category' => 'graphql', ] @@ -482,8 +492,8 @@ public function testReportsErrorForArrayPassedIntoStringInput() $expected = [ 'errors' => [[ 'message' => - 'Variable "$value" got invalid value [1,2,3].' . "\n" . - 'Expected type "String", found array(3).', + 'Variable "$value" got invalid value [1,2,3]; Expected type ' . + 'String; String cannot represent an array value: [1,2,3]', 'category' => 'graphql', 'locations' => [ ['line' => 2, 'column' => 31] @@ -491,7 +501,9 @@ public function testReportsErrorForArrayPassedIntoStringInput() ]] ]; - $this->assertEquals($expected, Executor::execute($this->schema(), $ast, null, null, $variables)->toArray()); + $result = Executor::execute($this->schema(), $ast, null, null, $variables)->toArray(true); + + $this->assertEquals($expected, $result); } /** @@ -500,8 +512,8 @@ public function testReportsErrorForArrayPassedIntoStringInput() public function testSerializingAnArrayViaGraphQLStringThrowsTypeError() { $this->setExpectedException( - InvariantViolation::class, - 'String cannot represent non scalar value: array(3)' + Error::class, + 'String cannot represent non scalar value: [1,2,3]' ); Type::string()->serialize([1, 2, 3]); } @@ -601,8 +613,8 @@ public function testDoesNotAllowNonNullListsToBeNull() 'errors' => [ [ 'message' => - 'Variable "$input" got invalid value null.' . "\n" . - 'Expected "[String]!", found null.', + 'Variable "$input" got invalid value null; ' . + 'Expected non-nullable type [String]!.', 'locations' => [['line' => 2, 'column' => 17]], 'category' => 'graphql', ] @@ -623,7 +635,7 @@ public function testAllowsNonNullListsToContainValues() '; $ast = Parser::parse($doc); $expected = ['data' => ['nnList' => '["A"]']]; - $this->assertEquals($expected, Executor::execute($this->schema(), $ast, null, null, ['input' => 'A'])->toArray()); + $this->assertEquals($expected, Executor::execute($this->schema(), $ast, null, null, ['input' => ['A']])->toArray()); } /** @@ -670,7 +682,7 @@ public function testAllowsListsOfNonNullsToContainValues() $ast = Parser::parse($doc); $expected = ['data' => ['listNN' => '["A"]']]; - $this->assertEquals($expected, Executor::execute($this->schema(), $ast, null, null, ['input' => 'A'])->toArray()); + $this->assertEquals($expected, Executor::execute($this->schema(), $ast, null, null, ['input' => ['A']])->toArray()); } /** @@ -689,8 +701,8 @@ public function testDoesNotAllowListsOfNonNullsToContainNull() 'errors' => [ [ 'message' => - 'Variable "$input" got invalid value ["A",null,"B"].' . "\n" . - 'In element #1: Expected "String!", found null.', + 'Variable "$input" got invalid value ["A",null,"B"]; ' . + 'Expected non-nullable type String! at value[1].', 'locations' => [ ['line' => 2, 'column' => 17] ], 'category' => 'graphql', ] @@ -715,8 +727,8 @@ public function testDoesNotAllowNonNullListsOfNonNullsToBeNull() 'errors' => [ [ 'message' => - 'Variable "$input" got invalid value null.' . "\n" . - 'Expected "[String!]!", found null.', + 'Variable "$input" got invalid value null; ' . + 'Expected non-nullable type [String!]!.', 'locations' => [ ['line' => 2, 'column' => 17] ], 'category' => 'graphql', ] @@ -756,8 +768,8 @@ public function testDoesNotAllowNonNullListsOfNonNullsToContainNull() 'errors' => [ [ 'message' => - 'Variable "$input" got invalid value ["A",null,"B"].'."\n". - 'In element #1: Expected "String!", found null.', + 'Variable "$input" got invalid value ["A",null,"B"]; ' . + 'Expected non-nullable type String! at value[1].', 'locations' => [ ['line' => 2, 'column' => 17] ], 'category' => 'graphql', ] diff --git a/tests/Server/QueryExecutionTest.php b/tests/Server/QueryExecutionTest.php index 00b26ea7e..487d089d7 100644 --- a/tests/Server/QueryExecutionTest.php +++ b/tests/Server/QueryExecutionTest.php @@ -322,7 +322,7 @@ public function testProhibitsInvalidPersistedQueryLoader() $this->setExpectedException( InvariantViolation::class, 'Persistent query loader must return query string or instance of GraphQL\Language\AST\DocumentNode '. - 'but got: associative array(1) with first key: "err"' + 'but got: {"err":"err"}' ); $this->config->setPersistentQueryLoader(function($queryId, OperationParams $params) use (&$called) { return ['err' => 'err']; diff --git a/tests/Server/RequestValidationTest.php b/tests/Server/RequestValidationTest.php index 2a335f1f2..0d15a7797 100644 --- a/tests/Server/RequestValidationTest.php +++ b/tests/Server/RequestValidationTest.php @@ -70,7 +70,7 @@ public function testFailsWhenQueryParameterIsNotString() $this->assertInputError( $parsedBody, - 'GraphQL Request parameter "query" must be string, but got object with first key: "t"' + 'GraphQL Request parameter "query" must be string, but got {"t":"{my query}"}' ); } @@ -82,7 +82,7 @@ public function testFailsWhenQueryIdParameterIsNotString() $this->assertInputError( $parsedBody, - 'GraphQL Request parameter "queryId" must be string, but got object with first key: "t"' + 'GraphQL Request parameter "queryId" must be string, but got {"t":"{my query}"}' ); } @@ -95,7 +95,7 @@ public function testFailsWhenOperationParameterIsNotString() $this->assertInputError( $parsedBody, - 'GraphQL Request parameter "operation" must be string, but got array(0)' + 'GraphQL Request parameter "operation" must be string, but got []' ); } diff --git a/tests/Type/EnumTypeTest.php b/tests/Type/EnumTypeTest.php index d278d7b70..0761cb1b9 100644 --- a/tests/Type/EnumTypeTest.php +++ b/tests/Type/EnumTypeTest.php @@ -235,7 +235,7 @@ public function testDoesNotAcceptIncorrectInternalValue() '{ colorEnum(fromString: "GREEN") }', null, [ - 'message' => 'Expected a value of type "Color" but received: "GREEN"', + 'message' => 'Expected a value of type "Color" but received: GREEN', 'locations' => [new SourceLocation(1, 3)] ] ); @@ -325,7 +325,7 @@ public function testDoesNotAcceptInternalValueAsEnumVariable() $this->expectFailure( 'query test($color: Color!) { colorEnum(fromEnum: $color) }', ['color' => 2], - "Variable \"\$color\" got invalid value 2.\nExpected type \"Color\", found 2." + 'Variable "$color" got invalid value 2; Expected type Color.' ); } @@ -459,7 +459,7 @@ public function testAllowsSimpleArrayAsValues() [ 'data' => ['first' => 'ONE', 'second' => 'TWO', 'third' => null], 'errors' => [[ - 'debugMessage' => 'Expected a value of type "SimpleEnum" but received: "WRONG"', + 'debugMessage' => 'Expected a value of type "SimpleEnum" but received: WRONG', 'locations' => [['line' => 4, 'column' => 13]] ]] ], diff --git a/tests/Type/ScalarSerializationTest.php b/tests/Type/ScalarSerializationTest.php index 3ded023fd..8fdd0d65a 100644 --- a/tests/Type/ScalarSerializationTest.php +++ b/tests/Type/ScalarSerializationTest.php @@ -1,8 +1,7 @@ setExpectedException(InvariantViolation::class, 'Int cannot represent non-integer value: 0.1'); + $this->setExpectedException(Error::class, 'Int cannot represent non-integer value: 0.1'); $intType->serialize(0.1); } public function testSerializesOutputIntCannotRepresentFloat2() { $intType = Type::int(); - $this->setExpectedException(InvariantViolation::class, 'Int cannot represent non-integer value: 1.1'); + $this->setExpectedException(Error::class, 'Int cannot represent non-integer value: 1.1'); $intType->serialize(1.1); } @@ -46,7 +45,7 @@ public function testSerializesOutputIntCannotRepresentFloat2() public function testSerializesOutputIntCannotRepresentNegativeFloat() { $intType = Type::int(); - $this->setExpectedException(InvariantViolation::class, 'Int cannot represent non-integer value: -1.1'); + $this->setExpectedException(Error::class, 'Int cannot represent non-integer value: -1.1'); $intType->serialize(-1.1); } @@ -54,7 +53,7 @@ public function testSerializesOutputIntCannotRepresentNegativeFloat() public function testSerializesOutputIntCannotRepresentNumericString() { $intType = Type::int(); - $this->setExpectedException(InvariantViolation::class, ''); + $this->setExpectedException(Error::class, ''); $intType->serialize('Int cannot represent non-integer value: "-1.1"'); } @@ -64,7 +63,7 @@ public function testSerializesOutputIntCannotRepresentBiggerThan32Bits() // Maybe a safe PHP int, but bigger than 2^32, so not // representable as a GraphQL Int $intType = Type::int(); - $this->setExpectedException(InvariantViolation::class, 'Int cannot represent non 32-bit signed integer value: 9876504321'); + $this->setExpectedException(Error::class, 'Int cannot represent non 32-bit signed integer value: 9876504321'); $intType->serialize(9876504321); } @@ -72,28 +71,28 @@ public function testSerializesOutputIntCannotRepresentBiggerThan32Bits() public function testSerializesOutputIntCannotRepresentLowerThan32Bits() { $intType = Type::int(); - $this->setExpectedException(InvariantViolation::class, 'Int cannot represent non 32-bit signed integer value: -9876504321'); + $this->setExpectedException(Error::class, 'Int cannot represent non 32-bit signed integer value: -9876504321'); $intType->serialize(-9876504321); } public function testSerializesOutputIntCannotRepresentBiggerThanSigned32Bits() { $intType = Type::int(); - $this->setExpectedException(InvariantViolation::class, 'Int cannot represent non 32-bit signed integer value: 1.0E+100'); + $this->setExpectedException(Error::class, 'Int cannot represent non 32-bit signed integer value: 1.0E+100'); $intType->serialize(1e100); } public function testSerializesOutputIntCannotRepresentLowerThanSigned32Bits() { $intType = Type::int(); - $this->setExpectedException(InvariantViolation::class, 'Int cannot represent non 32-bit signed integer value: -1.0E+100'); + $this->setExpectedException(Error::class, 'Int cannot represent non 32-bit signed integer value: -1.0E+100'); $intType->serialize(-1e100); } public function testSerializesOutputIntCannotRepresentString() { $intType = Type::int(); - $this->setExpectedException(InvariantViolation::class, 'Int cannot represent non 32-bit signed integer value: "one"'); + $this->setExpectedException(Error::class, 'Int cannot represent non 32-bit signed integer value: one'); $intType->serialize('one'); } @@ -101,7 +100,7 @@ public function testSerializesOutputIntCannotRepresentString() public function testSerializesOutputIntCannotRepresentEmptyString() { $intType = Type::int(); - $this->setExpectedException(InvariantViolation::class, 'Int cannot represent non 32-bit signed integer value: (empty string)'); + $this->setExpectedException(Error::class, 'Int cannot represent non 32-bit signed integer value: (empty string)'); $intType->serialize(''); } @@ -127,14 +126,14 @@ public function testSerializesOutputFloat() public function testSerializesOutputFloatCannotRepresentString() { $floatType = Type::float(); - $this->setExpectedException(InvariantViolation::class, 'Float cannot represent non numeric value: "one"'); + $this->setExpectedException(Error::class, 'Float cannot represent non numeric value: one'); $floatType->serialize('one'); } public function testSerializesOutputFloatCannotRepresentEmptyString() { $floatType = Type::float(); - $this->setExpectedException(InvariantViolation::class, 'Float cannot represent non numeric value: (empty string)'); + $this->setExpectedException(Error::class, 'Float cannot represent non numeric value: (empty string)'); $floatType->serialize(''); } @@ -156,14 +155,14 @@ public function testSerializesOutputStrings() public function testSerializesOutputStringsCannotRepresentArray() { $stringType = Type::string(); - $this->setExpectedException(InvariantViolation::class, 'String cannot represent non scalar value: array(0)'); + $this->setExpectedException(Error::class, 'String cannot represent non scalar value: []'); $stringType->serialize([]); } public function testSerializesOutputStringsCannotRepresentObject() { $stringType = Type::string(); - $this->setExpectedException(InvariantViolation::class, 'String cannot represent non scalar value: instance of stdClass'); + $this->setExpectedException(Error::class, 'String cannot represent non scalar value: instance of stdClass'); $stringType->serialize(new \stdClass()); } @@ -202,7 +201,7 @@ public function testSerializesOutputID() public function testSerializesOutputIDCannotRepresentObject() { $idType = Type::id(); - $this->setExpectedException(InvariantViolation::class, 'ID type cannot represent non scalar value: instance of stdClass'); + $this->setExpectedException(Error::class, 'ID type cannot represent non scalar value: instance of stdClass'); $idType->serialize(new \stdClass()); } } diff --git a/tests/Type/TypeLoaderTest.php b/tests/Type/TypeLoaderTest.php index 1848d7efa..db9cb0625 100644 --- a/tests/Type/TypeLoaderTest.php +++ b/tests/Type/TypeLoaderTest.php @@ -165,7 +165,7 @@ public function testSchemaRejectsNonCallableTypeLoader() { $this->setExpectedException( InvariantViolation::class, - 'Schema type loader must be callable if provided but got: array(0)' + 'Schema type loader must be callable if provided but got: []' ); new Schema([ diff --git a/tests/Type/ValidationTest.php b/tests/Type/ValidationTest.php index 26626ad98..373180d72 100644 --- a/tests/Type/ValidationTest.php +++ b/tests/Type/ValidationTest.php @@ -1662,7 +1662,7 @@ public function testRejectsAnEnumTypeWithIncorrectlyTypedValues() public function invalidEnumValueName() { return [ - ['#value', 'SomeEnum has value with invalid name: "#value" (Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "#value" does not.)'], + ['#value', 'SomeEnum has value with invalid name: #value (Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "#value" does not.)'], ['true', 'SomeEnum: "true" can not be used as an Enum value.'], ['false', 'SomeEnum: "false" can not be used as an Enum value.'], ['null', 'SomeEnum: "null" can not be used as an Enum value.'], @@ -1776,7 +1776,7 @@ public function testRejectsAnEmptyObjectFieldResolver() $this->setExpectedException( InvariantViolation::class, - 'BadResolver.badField field resolver must be a function if provided, but got: array(0)' + 'BadResolver.badField field resolver must be a function if provided, but got: []' ); $schema->assertValid(); diff --git a/tests/Utils/CoerceValueTest.php b/tests/Utils/CoerceValueTest.php new file mode 100644 index 000000000..999ca89cf --- /dev/null +++ b/tests/Utils/CoerceValueTest.php @@ -0,0 +1,201 @@ +expectError( + $result, + 'Expected type String; String cannot represent an array value: [1,2,3]' + ); + + $this->assertEquals( + 'String cannot represent an array value: [1,2,3]', + $result['errors'][0]->getPrevious()->getMessage() + ); + } + + // Describe: for GraphQLInt + + /** + * @it returns no error for int input + */ + public function testIntReturnsNoErrorForIntInput() + { + $result = Value::coerceValue('1', Type::int()); + $this->expectNoErrors($result); + } + + /** + * @it returns no error for negative int input + */ + public function testIntReturnsNoErrorForNegativeIntInput() + { + $result = Value::coerceValue('-1', Type::int()); + $this->expectNoErrors($result); + } + + /** + * @it returns no error for exponent input + */ + public function testIntReturnsNoErrorForExponentInput() + { + $result = Value::coerceValue('1e3', Type::int()); + $this->expectNoErrors($result); + } + + /** + * @it returns no error for null + */ + public function testIntReturnsASingleErrorNull() + { + $result = Value::coerceValue(null, Type::int()); + $this->expectNoErrors($result); + } + + /** + * @it returns a single error for empty value + */ + public function testIntReturnsASingleErrorForEmptyValue() + { + $result = Value::coerceValue('', Type::int()); + $this->expectError( + $result, + 'Expected type Int; Int cannot represent non 32-bit signed integer value: (empty string)' + ); + } + + /** + * @it returns error for float input as int + */ + public function testIntReturnsErrorForFloatInputAsInt() + { + $result = Value::coerceValue('1.5', Type::int()); + $this->expectError( + $result, + 'Expected type Int; Int cannot represent non-integer value: 1.5' + ); + } + + /** + * @it returns a single error for char input + */ + public function testIntReturnsASingleErrorForCharInput() + { + $result = Value::coerceValue('a', Type::int()); + $this->expectError( + $result, + 'Expected type Int; Int cannot represent non 32-bit signed integer value: a' + ); + } + + /** + * @it returns a single error for multi char input + */ + public function testIntReturnsASingleErrorForMultiCharInput() + { + $result = Value::coerceValue('meow', Type::int()); + $this->expectError( + $result, + 'Expected type Int; Int cannot represent non 32-bit signed integer value: meow' + ); + } + + // Describe: for GraphQLFloat + + /** + * @it returns no error for int input + */ + public function testFloatReturnsNoErrorForIntInput() + { + $result = Value::coerceValue('1', Type::float()); + $this->expectNoErrors($result); + } + + /** + * @it returns no error for exponent input + */ + public function testFloatReturnsNoErrorForExponentInput() + { + $result = Value::coerceValue('1e3', Type::float()); + $this->expectNoErrors($result); + } + + /** + * @it returns no error for float input + */ + public function testFloatReturnsNoErrorForFloatInput() + { + $result = Value::coerceValue('1.5', Type::float()); + $this->expectNoErrors($result); + } + + /** + * @it returns no error for null + */ + public function testFloatReturnsASingleErrorNull() + { + $result = Value::coerceValue(null, Type::float()); + $this->expectNoErrors($result); + } + + /** + * @it returns a single error for empty value + */ + public function testFloatReturnsASingleErrorForEmptyValue() + { + $result = Value::coerceValue('', Type::float()); + $this->expectError( + $result, + 'Expected type Float; Float cannot represent non numeric value: (empty string)' + ); + } + + /** + * @it returns a single error for char input + */ + public function testFloatReturnsASingleErrorForCharInput() + { + $result = Value::coerceValue('a', Type::float()); + $this->expectError( + $result, + 'Expected type Float; Float cannot represent non numeric value: a' + ); + } + + /** + * @it returns a single error for multi char input + */ + public function testFloatReturnsASingleErrorForMultiCharInput() + { + $result = Value::coerceValue('meow', Type::float()); + $this->expectError( + $result, + 'Expected type Float; Float cannot represent non numeric value: meow' + ); + } + + private function expectNoErrors($result) + { + $this->assertInternalType('array', $result); + $this->assertNull($result['errors']); + } + + private function expectError($result, $expected) { + $this->assertInternalType('array', $result); + $this->assertInternalType('array', $result['errors']); + $this->assertCount(1, $result['errors']); + $this->assertEquals($expected, $result['errors'][0]->getMessage()); + } +} diff --git a/tests/Utils/IsValidPHPValueTest.php b/tests/Utils/IsValidPHPValueTest.php deleted file mode 100644 index 937fa4f81..000000000 --- a/tests/Utils/IsValidPHPValueTest.php +++ /dev/null @@ -1,132 +0,0 @@ -expectNoErrors($result); - - // returns no error for negative int value - $result = Values::isValidPHPValue(-1, Type::int()); - $this->expectNoErrors($result); - - // returns no error for null value - $result = Values::isValidPHPValue(null, Type::int()); - $this->expectNoErrors($result); - - // returns a single error for positive int string value - $result = Values::isValidPHPValue('1', Type::int()); - $this->expectErrorResult($result, 1); - - // returns a single error for negative int string value - $result = Values::isValidPHPValue('-1', Type::int()); - $this->expectErrorResult($result, 1); - - // returns errors for exponential int string value - $result = Values::isValidPHPValue('1e3', Type::int()); - $this->expectErrorResult($result, 1); - $result = Values::isValidPHPValue('0e3', Type::int()); - $this->expectErrorResult($result, 1); - - // returns a single error for empty value - $result = Values::isValidPHPValue('', Type::int()); - $this->expectErrorResult($result, 1); - - // returns error for float value - $result = Values::isValidPHPValue(1.5, Type::int()); - $this->expectErrorResult($result, 1); - $result = Values::isValidPHPValue(1e3, Type::int()); - $this->expectErrorResult($result, 1); - - // returns error for float string value - $result = Values::isValidPHPValue('1.5', Type::int()); - $this->expectErrorResult($result, 1); - - // returns a single error for char input - $result = Values::isValidPHPValue('a', Type::int()); - $this->expectErrorResult($result, 1); - - // returns a single error for char input - $result = Values::isValidPHPValue('meow', Type::int()); - $this->expectErrorResult($result, 1); - } - - public function testValidFloatValue() - { - // returns no error for positive float value - $result = Values::isValidPHPValue(1.2, Type::float()); - $this->expectNoErrors($result); - - // returns no error for exponential float value - $result = Values::isValidPHPValue(1e3, Type::float()); - $this->expectNoErrors($result); - - // returns no error for negative float value - $result = Values::isValidPHPValue(-1.2, Type::float()); - $this->expectNoErrors($result); - - // returns no error for a positive int value - $result = Values::isValidPHPValue(1, Type::float()); - $this->expectNoErrors($result); - - // returns no errors for a negative int value - $result = Values::isValidPHPValue(-1, Type::float()); - $this->expectNoErrors($result); - - // returns no error for null value: - $result = Values::isValidPHPValue(null, Type::float()); - $this->expectNoErrors($result); - - // returns error for positive float string value - $result = Values::isValidPHPValue('1.2', Type::float()); - $this->expectErrorResult($result, 1); - - // returns error for negative float string value - $result = Values::isValidPHPValue('-1.2', Type::float()); - $this->expectErrorResult($result, 1); - - // returns error for a positive int string value - $result = Values::isValidPHPValue('1', Type::float()); - $this->expectErrorResult($result, 1); - - // returns errors for a negative int string value - $result = Values::isValidPHPValue('-1', Type::float()); - $this->expectErrorResult($result, 1); - - // returns error for exponent input - $result = Values::isValidPHPValue('1e3', Type::float()); - $this->expectErrorResult($result, 1); - $result = Values::isValidPHPValue('0e3', Type::float()); - $this->expectErrorResult($result, 1); - - // returns a single error for empty value - $result = Values::isValidPHPValue('', Type::float()); - $this->expectErrorResult($result, 1); - - // returns a single error for char input - $result = Values::isValidPHPValue('a', Type::float()); - $this->expectErrorResult($result, 1); - - // returns a single error for char input - $result = Values::isValidPHPValue('meow', Type::float()); - $this->expectErrorResult($result, 1); - } - - private function expectNoErrors($result) - { - $this->assertInternalType('array', $result); - $this->assertEquals([], $result); - } - - private function expectErrorResult($result, $size) { - $this->assertInternalType('array', $result); - $this->assertEquals($size, count($result)); - } -} diff --git a/tools/gendocs.php b/tools/gendocs.php index 95b5b96be..d28b0c93d 100644 --- a/tools/gendocs.php +++ b/tools/gendocs.php @@ -41,7 +41,7 @@ function renderClassMethod(ReflectionMethod $method) { if ($p->isDefaultValueAvailable()) { $val = $p->isDefaultValueConstant() ? $p->getDefaultValueConstantName() : $p->getDefaultValue(); - $def .= " = " . (is_array($val) ? '[]' : Utils::printSafe($val)); + $def .= " = " . Utils::printSafeJson($val); } return $def; @@ -63,7 +63,7 @@ function renderClassMethod(ReflectionMethod $method) { } function renderConstant($value, $name) { - return "const $name = " . Utils::printSafe($value) . ";"; + return "const $name = " . Utils::printSafeJson($value) . ";"; } function renderProp(ReflectionProperty $prop) { From 9387548aa1f297f4dbda8acbcf07d66b323abaa8 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Tue, 13 Feb 2018 18:04:03 +0100 Subject: [PATCH 34/50] Better Predicates Introduces new assertion functions for each kind of type mirroring the existing ones for the higher order types. ref: graphql/graphql-js#1137 --- docs/reference.md | 2 +- src/Type/Definition/InterfaceType.php | 14 +++ src/Type/Definition/ListOfType.php | 7 +- src/Type/Definition/NonNull.php | 54 +++++++----- src/Type/Definition/ObjectType.php | 14 +++ src/Type/Definition/Type.php | 18 +++- src/Utils/AST.php | 117 +++++++++++++------------- src/Utils/ASTDefinitionBuilder.php | 9 +- src/Utils/FindBreakingChanges.php | 13 ++- src/Utils/SchemaPrinter.php | 7 +- src/Validator/DocumentValidator.php | 35 ++++---- tests/Type/DefinitionTest.php | 9 -- tests/Type/ValidationTest.php | 10 +-- 13 files changed, 178 insertions(+), 131 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 7ee6f21fe..70f561442 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -2115,7 +2115,7 @@ static function valueFromASTUntyped($valueNode, array $variables = null) * @param Schema $schema * @param NamedTypeNode|ListTypeNode|NonNullTypeNode $inputTypeNode * @return Type - * @throws InvariantViolation + * @throws \Exception */ static function typeFromAST(GraphQL\Type\Schema $schema, $inputTypeNode) ``` diff --git a/src/Type/Definition/InterfaceType.php b/src/Type/Definition/InterfaceType.php index a92be4898..33bcb6756 100644 --- a/src/Type/Definition/InterfaceType.php +++ b/src/Type/Definition/InterfaceType.php @@ -12,6 +12,20 @@ */ class InterfaceType extends Type implements AbstractType, OutputType, CompositeType { + /** + * @param mixed $type + * @return self + */ + public static function assertInterfaceType($type) + { + Utils::invariant( + $type instanceof self, + 'Expected ' . Utils::printSafe($type) . ' to be a GraphQL Interface type.' + ); + + return $type; + } + /** * @var FieldDefinition[] */ diff --git a/src/Type/Definition/ListOfType.php b/src/Type/Definition/ListOfType.php index 6c454669c..5d61d1ecd 100644 --- a/src/Type/Definition/ListOfType.php +++ b/src/Type/Definition/ListOfType.php @@ -20,12 +20,7 @@ class ListOfType extends Type implements WrappingType, OutputType, InputType */ public function __construct($type) { - if (!$type instanceof Type && !is_callable($type)) { - throw new InvariantViolation( - 'Can only create List of a GraphQLType but got: ' . Utils::printSafe($type) - ); - } - $this->ofType = $type; + $this->ofType = Type::assertType($type); } /** diff --git a/src/Type/Definition/NonNull.php b/src/Type/Definition/NonNull.php index 4499c7420..2f3912d55 100644 --- a/src/Type/Definition/NonNull.php +++ b/src/Type/Definition/NonNull.php @@ -11,7 +11,35 @@ class NonNull extends Type implements WrappingType, OutputType, InputType { /** - * @var ObjectType|InterfaceType|UnionType|ScalarType|InputObjectType|EnumType + * @param mixed $type + * @return self + */ + public static function assertNullType($type) + { + Utils::invariant( + $type instanceof self, + 'Expected ' . Utils::printSafe($type) . ' to be a GraphQL Non-Null type.' + ); + + return $type; + } + + /** + * @param mixed $type + * @return ObjectType|InterfaceType|UnionType|ScalarType|InputObjectType|EnumType|ListOfType + */ + public static function assertNullableType($type) + { + Utils::invariant( + Type::isType($type) && !$type instanceof self, + 'Expected ' . Utils::printSafe($type) . ' to be a GraphQL nullable type.' + ); + + return $type; + } + + /** + * @var ObjectType|InterfaceType|UnionType|ScalarType|InputObjectType|EnumType|ListOfType */ private $ofType; @@ -21,37 +49,17 @@ class NonNull extends Type implements WrappingType, OutputType, InputType */ public function __construct($type) { - if (!$type instanceof Type && !is_callable($type)) { - throw new InvariantViolation( - 'Can only create NonNull of a Nullable GraphQLType but got: ' . Utils::printSafe($type) - ); - } - if ($type instanceof NonNull) { - throw new InvariantViolation( - 'Can only create NonNull of a Nullable GraphQLType but got: ' . Utils::printSafe($type) - ); - } - Utils::invariant( - !($type instanceof NonNull), - 'Cannot nest NonNull inside NonNull' - ); - $this->ofType = $type; + $this->ofType = self::assertNullableType($type); } /** * @param bool $recurse - * @return ObjectType|InterfaceType|UnionType|ScalarType|InputObjectType|EnumType + * @return ObjectType|InterfaceType|UnionType|ScalarType|InputObjectType|EnumType|ListOfType * @throws InvariantViolation */ public function getWrappedType($recurse = false) { $type = $this->ofType; - - Utils::invariant( - !($type instanceof NonNull), - 'Cannot nest NonNull inside NonNull' - ); - return ($recurse && $type instanceof WrappingType) ? $type->getWrappedType($recurse) : $type; } diff --git a/src/Type/Definition/ObjectType.php b/src/Type/Definition/ObjectType.php index 5f2d3e12d..5c5d0ebdc 100644 --- a/src/Type/Definition/ObjectType.php +++ b/src/Type/Definition/ObjectType.php @@ -49,6 +49,20 @@ */ class ObjectType extends Type implements OutputType, CompositeType { + /** + * @param mixed $type + * @return self + */ + public static function assertObjectType($type) + { + Utils::invariant( + $type instanceof self, + 'Expected ' . Utils::printSafe($type) . ' to be a GraphQL Object type.' + ); + + return $type; + } + /** * @var FieldDefinition[] */ diff --git a/src/Type/Definition/Type.php b/src/Type/Definition/Type.php index 5afceb836..ba797bc14 100644 --- a/src/Type/Definition/Type.php +++ b/src/Type/Definition/Type.php @@ -4,8 +4,10 @@ use GraphQL\Error\InvariantViolation; use GraphQL\Language\AST\ListType; use GraphQL\Language\AST\NamedType; +use GraphQL\Language\AST\NonNullType; use GraphQL\Language\AST\TypeDefinitionNode; use GraphQL\Type\Introspection; +use GraphQL\Utils\Utils; /** * Registry of standard GraphQL types @@ -218,11 +220,25 @@ public static function isType($type) $type instanceof UnionType || $type instanceof EnumType || $type instanceof InputObjectType || - $type instanceof ListType || + $type instanceof ListOfType || $type instanceof NonNull ); } + /** + * @param mixed $type + * @return mixed + */ + public static function assertType($type) + { + Utils::invariant( + self::isType($type), + 'Expected ' . Utils::printSafe($type) . ' to be a GraphQL type.' + ); + + return $type; + } + /** * @api * @param Type $type diff --git a/src/Utils/AST.php b/src/Utils/AST.php index d5c867e55..9a18915e3 100644 --- a/src/Utils/AST.php +++ b/src/Utils/AST.php @@ -1,6 +1,7 @@ $fieldNodes]); } - Utils::invariant( - $type instanceof ScalarType || $type instanceof EnumType, - "Must provide Input Type, cannot use: " . Utils::printSafe($type) - ); - - // Since value is an internally represented value, it must be serialized - // to an externally represented value before converting into an AST. - $serialized = $type->serialize($value); - if (null === $serialized || Utils::isInvalid($serialized)) { - return null; - } + if ($type instanceof ScalarType || $type instanceof EnumType) { + // Since value is an internally represented value, it must be serialized + // to an externally represented value before converting into an AST. + $serialized = $type->serialize($value); + if (null === $serialized || Utils::isInvalid($serialized)) { + return null; + } - // Others serialize based on their corresponding PHP scalar types. - if (is_bool($serialized)) { - return new BooleanValueNode(['value' => $serialized]); - } - if (is_int($serialized)) { - return new IntValueNode(['value' => $serialized]); - } - if (is_float($serialized)) { - if ((int) $serialized == $serialized) { + // Others serialize based on their corresponding PHP scalar types. + if (is_bool($serialized)) { + return new BooleanValueNode(['value' => $serialized]); + } + if (is_int($serialized)) { return new IntValueNode(['value' => $serialized]); } - return new FloatValueNode(['value' => $serialized]); - } - if (is_string($serialized)) { - // Enum types use Enum literals. - if ($type instanceof EnumType) { - return new EnumValueNode(['value' => $serialized]); + if (is_float($serialized)) { + if ((int) $serialized == $serialized) { + return new IntValueNode(['value' => $serialized]); + } + return new FloatValueNode(['value' => $serialized]); } + if (is_string($serialized)) { + // Enum types use Enum literals. + if ($type instanceof EnumType) { + return new EnumValueNode(['value' => $serialized]); + } - // ID types can use Int literals. - $asInt = (int) $serialized; - if ($type instanceof IDType && (string) $asInt === $serialized) { - return new IntValueNode(['value' => $serialized]); + // ID types can use Int literals. + $asInt = (int) $serialized; + if ($type instanceof IDType && (string) $asInt === $serialized) { + return new IntValueNode(['value' => $serialized]); + } + + // Use json_encode, which uses the same string encoding as GraphQL, + // then remove the quotes. + return new StringValueNode([ + 'value' => substr(json_encode($serialized), 1, -1) + ]); } - // Use json_encode, which uses the same string encoding as GraphQL, - // then remove the quotes. - return new StringValueNode([ - 'value' => substr(json_encode($serialized), 1, -1) - ]); + throw new InvariantViolation('Cannot convert value to AST: ' . Utils::printSafe($serialized)); } - throw new InvariantViolation('Cannot convert value to AST: ' . Utils::printSafe($serialized)); + throw new Error('Unknown type: ' . Utils::printSafe($type) . '.'); } /** @@ -395,25 +395,26 @@ public static function valueFromAST($valueNode, InputType $type, $variables = nu return $enumValue->value; } - Utils::invariant($type instanceof ScalarType, 'Must be scalar type'); - /** @var ScalarType $type */ + if ($type instanceof ScalarType) { + // Scalars fulfill parsing a literal value via parseLiteral(). + // Invalid values represent a failure to parse correctly, in which case + // no value is returned. + try { + $result = $type->parseLiteral($valueNode, $variables); + } catch (\Exception $error) { + return $undefined; + } catch (\Throwable $error) { + return $undefined; + } - // Scalars fulfill parsing a literal value via parseLiteral(). - // Invalid values represent a failure to parse correctly, in which case - // no value is returned. - try { - $result = $type->parseLiteral($valueNode, $variables); - } catch (\Exception $error) { - return $undefined; - } catch (\Throwable $error) { - return $undefined; - } + if (Utils::isInvalid($result)) { + return $undefined; + } - if (Utils::isInvalid($result)) { - return $undefined; + return $result; } - return $result; + throw new Error('Unknown type: ' . Utils::printSafe($type) . '.'); } /** @@ -473,9 +474,9 @@ function($field) use ($variables) { return self::valueFromASTUntyped($field->val return ($variables && isset($variables[$variableName]) && !Utils::isInvalid($variables[$variableName])) ? $variables[$variableName] : null; - default: - throw new InvariantViolation('Unexpected value kind: ' . $valueNode->kind); - } + } + + throw new Error('Unexpected value kind: ' . $valueNode->kind . '.'); } /** @@ -485,7 +486,7 @@ function($field) use ($variables) { return self::valueFromASTUntyped($field->val * @param Schema $schema * @param NamedTypeNode|ListTypeNode|NonNullTypeNode $inputTypeNode * @return Type - * @throws InvariantViolation + * @throws \Exception */ public static function typeFromAST(Schema $schema, $inputTypeNode) { @@ -497,9 +498,11 @@ public static function typeFromAST(Schema $schema, $inputTypeNode) $innerType = self::typeFromAST($schema, $inputTypeNode->type); return $innerType ? new NonNull($innerType) : null; } + if ($inputTypeNode instanceof NamedTypeNode) { + return $schema->getType($inputTypeNode->name->value); + } - Utils::invariant($inputTypeNode && $inputTypeNode instanceof NamedTypeNode, 'Must be a named type'); - return $schema->getType($inputTypeNode->name->value); + throw new Error('Unexpected type kind: ' . $inputTypeNode->kind . '.'); } /** diff --git a/src/Utils/ASTDefinitionBuilder.php b/src/Utils/ASTDefinitionBuilder.php index 073e77333..c5516ddab 100644 --- a/src/Utils/ASTDefinitionBuilder.php +++ b/src/Utils/ASTDefinitionBuilder.php @@ -75,8 +75,7 @@ private function buildWrappedType(Type $innerType, TypeNode $inputTypeNode) } if ($inputTypeNode->kind == NodeKind::NON_NULL_TYPE) { $wrappedType = $this->buildWrappedType($innerType, $inputTypeNode->type); - Utils::invariant(!($wrappedType instanceof NonNull), 'No nesting nonnull.'); - return Type::nonNull($wrappedType); + return Type::nonNull(NonNull::assertNullableType($wrappedType)); } return $innerType; } @@ -159,8 +158,7 @@ public function buildOutputType(TypeNode $typeNode) public function buildObjectType($typeNode) { $type = $this->buildType($typeNode); - Utils::invariant($type instanceof ObjectType, 'Expected Object type.' . get_class($type)); - return $type; + return ObjectType::assertObjectType($type); } /** @@ -171,8 +169,7 @@ public function buildObjectType($typeNode) public function buildInterfaceType($typeNode) { $type = $this->buildType($typeNode); - Utils::invariant($type instanceof InterfaceType, 'Expected Interface type.'); - return $type; + return InterfaceType::assertInterfaceType($type); } /** diff --git a/src/Utils/FindBreakingChanges.php b/src/Utils/FindBreakingChanges.php index 63acbef15..6c67aa187 100644 --- a/src/Utils/FindBreakingChanges.php +++ b/src/Utils/FindBreakingChanges.php @@ -148,8 +148,11 @@ public static function findArgChanges( $dangerousChanges = []; foreach ($oldTypeMap as $oldTypeName => $oldTypeDefinition) { $newTypeDefinition = isset($newTypeMap[$oldTypeName]) ? $newTypeMap[$oldTypeName] : null; - if (!($oldTypeDefinition instanceof ObjectType || $oldTypeDefinition instanceof InterfaceType) || - !($newTypeDefinition instanceof $oldTypeDefinition)) { + if ( + !($oldTypeDefinition instanceof ObjectType || $oldTypeDefinition instanceof InterfaceType) || + !($newTypeDefinition instanceof ObjectType || $newTypeDefinition instanceof InterfaceType) || + !($newTypeDefinition instanceof $oldTypeDefinition) + ) { continue; } @@ -262,7 +265,11 @@ public static function findFieldsThatChangedTypeOnObjectOrInterfaceTypes(Schema $breakingChanges = []; foreach ($oldTypeMap as $typeName => $oldType) { $newType = isset($newTypeMap[$typeName]) ? $newTypeMap[$typeName] : null; - if (!($oldType instanceof ObjectType || $oldType instanceof InterfaceType) || !($newType instanceof $oldType)) { + if ( + !($oldType instanceof ObjectType || $oldType instanceof InterfaceType) || + !($newType instanceof ObjectType || $newType instanceof InterfaceType) || + !($newType instanceof $oldType) + ) { continue; } $oldTypeFieldsDef = $oldType->getFields(); diff --git a/src/Utils/SchemaPrinter.php b/src/Utils/SchemaPrinter.php index d80e104dc..e0a74b71e 100644 --- a/src/Utils/SchemaPrinter.php +++ b/src/Utils/SchemaPrinter.php @@ -1,6 +1,7 @@ parseLiteral($valueNode); - if (Utils::isInvalid($parseResult)) { + if ($type instanceof ScalarType) { + // Scalars determine if a literal values is valid via parseLiteral(). + try { + $parseResult = $type->parseLiteral($valueNode); + if (Utils::isInvalid($parseResult)) { + $printed = Printer::doPrint($valueNode); + return ["Expected type \"{$type->name}\", found $printed."]; + } + } catch (\Exception $error) { $printed = Printer::doPrint($valueNode); - return ["Expected type \"{$type->name}\", found $printed."]; + $message = $error->getMessage(); + return ["Expected type \"{$type->name}\", found $printed; $message"]; + } catch (\Throwable $error) { + $printed = Printer::doPrint($valueNode); + $message = $error->getMessage(); + return ["Expected type \"{$type->name}\", found $printed; $message"]; } - } catch (\Exception $error) { - $printed = Printer::doPrint($valueNode); - $message = $error->getMessage(); - return ["Expected type \"{$type->name}\", found $printed; $message"]; - } catch (\Throwable $error) { - $printed = Printer::doPrint($valueNode); - $message = $error->getMessage(); - return ["Expected type \"{$type->name}\", found $printed; $message"]; + + return []; } - return []; + throw new Error('Unknown type: ' . Utils::printSafe($type) . '.'); } /** diff --git a/tests/Type/DefinitionTest.php b/tests/Type/DefinitionTest.php index 1622220b3..9f1b8ce66 100644 --- a/tests/Type/DefinitionTest.php +++ b/tests/Type/DefinitionTest.php @@ -468,15 +468,6 @@ public function testIdentifiesOutputTypes() } } - /** - * @it prohibits nesting NonNull inside NonNull - */ - public function testProhibitsNonNullNesting() - { - $this->setExpectedException('\Exception'); - new NonNull(new NonNull(Type::int())); - } - /** * @it prohibits putting non-Object types in unions */ diff --git a/tests/Type/ValidationTest.php b/tests/Type/ValidationTest.php index 373180d72..1409da4e9 100644 --- a/tests/Type/ValidationTest.php +++ b/tests/Type/ValidationTest.php @@ -1975,7 +1975,7 @@ public function testRejectsANonInputTypeAsAnInputFieldType() } - // DESCRIBE: Type System: List must accept GraphQL types + // DESCRIBE: Type System: List must accept only types /** * @it accepts an type as item type of list @@ -2022,7 +2022,7 @@ public function testRejectsANonTypeAsItemTypeOfList() $this->fail("Expected exception not thrown for: " . Utils::printSafe($type)); } catch (InvariantViolation $e) { $this->assertEquals( - 'Can only create List of a GraphQLType but got: ' . Utils::printSafe($type), + 'Expected '. Utils::printSafe($type) . ' to be a GraphQL type.', $e->getMessage() ); } @@ -2030,7 +2030,7 @@ public function testRejectsANonTypeAsItemTypeOfList() } - // DESCRIBE: Type System: NonNull must accept GraphQL types + // DESCRIBE: Type System: NonNull must only accept non-nullable types /** * @it accepts an type as nullable type of non-null @@ -2057,8 +2057,6 @@ public function testAcceptsAnTypeAsNullableTypeOfNonNull() } } - // TODO: rejects a non-type as nullable type of non-null: ${type} - /** * @it rejects a non-type as nullable type of non-null */ @@ -2079,7 +2077,7 @@ public function testRejectsANonTypeAsNullableTypeOfNonNull() $this->fail("Expected exception not thrown for: " . Utils::printSafe($type)); } catch (InvariantViolation $e) { $this->assertEquals( - 'Can only create NonNull of a Nullable GraphQLType but got: ' . Utils::printSafe($type), + 'Expected ' . Utils::printSafe($type) . ' to be a GraphQL nullable type.', $e->getMessage() ); } From 50cbfb4a44615e6f2bad427b5f5e87409ebb8307 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Tue, 13 Feb 2018 18:08:05 +0100 Subject: [PATCH 35/50] Fix Bug in PossibleFragmentSpreads validator ref: graphql/graphql-js@7e147a8dd60496505cd5d491fb7126b2319095c9 --- src/Validator/Rules/PossibleFragmentSpreads.php | 8 +++++++- tests/Validator/PossibleFragmentSpreadsTest.php | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Validator/Rules/PossibleFragmentSpreads.php b/src/Validator/Rules/PossibleFragmentSpreads.php index 3e7332e5d..37a24dc2e 100644 --- a/src/Validator/Rules/PossibleFragmentSpreads.php +++ b/src/Validator/Rules/PossibleFragmentSpreads.php @@ -61,7 +61,13 @@ public function getVisitor(ValidationContext $context) private function getFragmentType(ValidationContext $context, $name) { $frag = $context->getFragment($name); - return $frag ? TypeInfo::typeFromAST($context->getSchema(), $frag->typeCondition) : null; + if ($frag) { + $type = TypeInfo::typeFromAST($context->getSchema(), $frag->typeCondition); + if ($type instanceof CompositeType) { + return $type; + } + } + return null; } private function doTypesOverlap(Schema $schema, CompositeType $fragType, CompositeType $parentType) diff --git a/tests/Validator/PossibleFragmentSpreadsTest.php b/tests/Validator/PossibleFragmentSpreadsTest.php index d18615726..a3139de95 100644 --- a/tests/Validator/PossibleFragmentSpreadsTest.php +++ b/tests/Validator/PossibleFragmentSpreadsTest.php @@ -128,6 +128,17 @@ public function testInterfaceIntoOverlappingUnion() '); } + /** + * @it ignores incorrect type (caught by FragmentsOnCompositeTypes) + */ + public function testIgnoresIncorrectTypeCaughtByFragmentsOnCompositeTypes() + { + $this->expectPassesRule(new PossibleFragmentSpreads, ' + fragment petFragment on Pet { ...badInADifferentWay } + fragment badInADifferentWay on String { name } + '); + } + /** * @it different object into object */ From 6d08c342c9ec0959d68e78da28f66ac17736424c Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Tue, 13 Feb 2018 18:18:50 +0100 Subject: [PATCH 36/50] Address recent SDL spec changes This should be the last set of spec changes for a standardized SDL ref: graphql/graphql-js#1139 --- src/Language/AST/DefinitionNode.php | 6 +-- src/Language/AST/ExecutableDefinitionNode.php | 11 +++++ src/Language/AST/FragmentDefinitionNode.php | 2 +- src/Language/AST/OperationDefinitionNode.php | 2 +- src/Language/Parser.php | 41 ++++++++++++++----- 5 files changed, 46 insertions(+), 16 deletions(-) create mode 100644 src/Language/AST/ExecutableDefinitionNode.php diff --git a/src/Language/AST/DefinitionNode.php b/src/Language/AST/DefinitionNode.php index f5a6c2887..4c099cc00 100644 --- a/src/Language/AST/DefinitionNode.php +++ b/src/Language/AST/DefinitionNode.php @@ -4,8 +4,8 @@ interface DefinitionNode { /** - * export type DefinitionNode = OperationDefinitionNode - * | FragmentDefinitionNode - * | TypeSystemDefinitionNode // experimental non-spec addition. + * export type DefinitionNode = + * | ExecutableDefinitionNode + * | TypeSystemDefinitionNode; // experimental non-spec addition. */ } diff --git a/src/Language/AST/ExecutableDefinitionNode.php b/src/Language/AST/ExecutableDefinitionNode.php new file mode 100644 index 000000000..6b3b572b8 --- /dev/null +++ b/src/Language/AST/ExecutableDefinitionNode.php @@ -0,0 +1,11 @@ +peek(Token::BRACE_L)) { - return $this->parseOperationDefinition(); - } - if ($this->peek(Token::NAME)) { switch ($this->lexer->token->value) { case 'query': case 'mutation': case 'subscription': - return $this->parseOperationDefinition(); - case 'fragment': - return $this->parseFragmentDefinition(); + return $this->parseExecutableDefinition(); // Note: The schema definition language is an experimental addition. case 'schema': @@ -357,13 +352,37 @@ function parseDefinition() case 'input': case 'extend': case 'directive': + // Note: The schema definition language is an experimental addition. return $this->parseTypeSystemDefinition(); } + } else if ($this->peek(Token::BRACE_L)) { + return $this->parseExecutableDefinition(); + } else if ($this->peekDescription()) { + // Note: The schema definition language is an experimental addition. + return $this->parseTypeSystemDefinition(); } - // Note: The schema definition language is an experimental addition. - if ($this->peekDescription()) { - return $this->parseTypeSystemDefinition(); + throw $this->unexpected(); + } + + /** + * @return ExecutableDefinitionNode + * @throws SyntaxError + */ + function parseExecutableDefinition() + { + if ($this->peek(Token::NAME)) { + switch ($this->lexer->token->value) { + case 'query': + case 'mutation': + case 'subscription': + return $this->parseOperationDefinition(); + + case 'fragment': + return $this->parseFragmentDefinition(); + } + } else if ($this->peek(Token::BRACE_L)) { + return $this->parseOperationDefinition(); } throw $this->unexpected(); From 97e8a9e2007754a2d0ee7f27ae78c82ee4c7dafe Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Thu, 15 Feb 2018 12:14:08 +0100 Subject: [PATCH 37/50] Move schema validation into separate step (type constructors) This is the second step of moving work from type constructors to the schema validation function. ref: graphql/graphql-js#1132 --- docs/reference.md | 3 +- src/Error/Warning.php | 1 - src/Language/AST/EnumTypeDefinitionNode.php | 2 +- src/Language/AST/FieldDefinitionNode.php | 4 +- src/Language/Parser.php | 8 +- src/Type/Definition/Directive.php | 4 + src/Type/Definition/EnumType.php | 21 +- src/Type/Definition/InputObjectType.php | 41 +- src/Type/Definition/InputType.php | 15 +- src/Type/Definition/InterfaceType.php | 18 +- src/Type/Definition/NamedType.php | 15 + src/Type/Definition/ObjectType.php | 17 +- src/Type/Definition/ScalarType.php | 4 +- src/Type/Definition/Type.php | 17 +- src/Type/Definition/UnionType.php | 27 +- src/Type/Introspection.php | 2 +- src/Type/SchemaValidationContext.php | 488 ++++- src/Utils/ASTDefinitionBuilder.php | 66 +- src/Utils/SchemaPrinter.php | 2 +- src/Utils/Utils.php | 59 +- tests/Type/DefinitionTest.php | 31 - tests/Type/ValidationTest.php | 2137 ++++++------------- tests/Utils/AssertValidNameTest.php | 47 + 23 files changed, 1189 insertions(+), 1840 deletions(-) create mode 100644 src/Type/Definition/NamedType.php create mode 100644 tests/Utils/AssertValidNameTest.php diff --git a/docs/reference.md b/docs/reference.md index 70f561442..76ac3ef32 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -171,7 +171,7 @@ static function float() ```php /** * @api - * @param ObjectType|InterfaceType|UnionType|ScalarType|InputObjectType|EnumType|ListOfType|NonNull $wrappedType + * @param Type|ObjectType|InterfaceType|UnionType|ScalarType|InputObjectType|EnumType|ListOfType|NonNull $wrappedType * @return ListOfType */ static function listOf($wrappedType) @@ -1345,7 +1345,6 @@ Also it is possible to override warning handler (which is **trigger_error()** by **Class Constants:** ```php -const WARNING_NAME = 1; const WARNING_ASSIGN = 2; const WARNING_CONFIG = 4; const WARNING_FULL_SCHEMA_SCAN = 8; diff --git a/src/Error/Warning.php b/src/Error/Warning.php index fa6a666f6..4bdbf66be 100644 --- a/src/Error/Warning.php +++ b/src/Error/Warning.php @@ -9,7 +9,6 @@ */ final class Warning { - const WARNING_NAME = 1; const WARNING_ASSIGN = 2; const WARNING_CONFIG = 4; const WARNING_FULL_SCHEMA_SCAN = 8; diff --git a/src/Language/AST/EnumTypeDefinitionNode.php b/src/Language/AST/EnumTypeDefinitionNode.php index e9be727f6..fc8eb6645 100644 --- a/src/Language/AST/EnumTypeDefinitionNode.php +++ b/src/Language/AST/EnumTypeDefinitionNode.php @@ -19,7 +19,7 @@ class EnumTypeDefinitionNode extends Node implements TypeDefinitionNode public $directives; /** - * @var EnumValueDefinitionNode[]|null + * @var EnumValueDefinitionNode[]|null|NodeList */ public $values; diff --git a/src/Language/AST/FieldDefinitionNode.php b/src/Language/AST/FieldDefinitionNode.php index d081d7f32..6baf498f4 100644 --- a/src/Language/AST/FieldDefinitionNode.php +++ b/src/Language/AST/FieldDefinitionNode.php @@ -14,7 +14,7 @@ class FieldDefinitionNode extends Node public $name; /** - * @var InputValueDefinitionNode[] + * @var InputValueDefinitionNode[]|NodeList */ public $arguments; @@ -24,7 +24,7 @@ class FieldDefinitionNode extends Node public $type; /** - * @var DirectiveNode[] + * @var DirectiveNode[]|NodeList */ public $directives; diff --git a/src/Language/Parser.php b/src/Language/Parser.php index 3cdc21147..d40414a7e 100644 --- a/src/Language/Parser.php +++ b/src/Language/Parser.php @@ -980,6 +980,7 @@ function parseSchemaDefinition() /** * @return OperationTypeDefinitionNode + * @throws SyntaxError */ function parseOperationTypeDefinition() { @@ -1095,11 +1096,12 @@ function parseFieldDefinition() /** * @return InputValueDefinitionNode[]|NodeList + * @throws SyntaxError */ function parseArgumentDefs() { if (!$this->peek(Token::PAREN_L)) { - return []; + return new NodeList([]); } return $this->many(Token::PAREN_L, [$this, 'parseInputValueDef'], Token::PAREN_R); } @@ -1357,7 +1359,7 @@ function parseObjectTypeExtension() { $fields = $this->parseFieldsDefinition(); if ( - count($interfaces) === 0 && + !$interfaces && count($directives) === 0 && count($fields) === 0 ) { @@ -1412,7 +1414,7 @@ function parseUnionTypeExtension() { $types = $this->parseMemberTypesDefinition(); if ( count($directives) === 0 && - count($types) === 0 + !$types ) { throw $this->unexpected(); } diff --git a/src/Type/Definition/Directive.php b/src/Type/Definition/Directive.php index fcf6c2a09..b3b9a1a56 100644 --- a/src/Type/Definition/Directive.php +++ b/src/Type/Definition/Directive.php @@ -3,6 +3,7 @@ use GraphQL\Language\AST\DirectiveDefinitionNode; use GraphQL\Language\DirectiveLocation; +use GraphQL\Utils\Utils; /** * Class Directive @@ -159,6 +160,9 @@ public function __construct(array $config) foreach ($config as $key => $value) { $this->{$key} = $value; } + + Utils::invariant($this->name, 'Directive must be named.'); + Utils::invariant(is_array($this->locations), 'Must provide locations for directive.'); $this->config = $config; } } diff --git a/src/Type/Definition/EnumType.php b/src/Type/Definition/EnumType.php index 0019d2431..dbb7e333e 100644 --- a/src/Type/Definition/EnumType.php +++ b/src/Type/Definition/EnumType.php @@ -11,7 +11,7 @@ * Class EnumType * @package GraphQL\Type\Definition */ -class EnumType extends Type implements InputType, OutputType, LeafType +class EnumType extends Type implements InputType, OutputType, LeafType, NamedType { /** * @var EnumTypeDefinitionNode|null @@ -39,7 +39,7 @@ public function __construct($config) $config['name'] = $this->tryInferName(); } - Utils::assertValidName($config['name'], !empty($config['isIntrospection'])); + Utils::invariant(is_string($config['name']), 'Must provide name.'); Config::validate($config, [ 'name' => Config::NAME | Config::REQUIRED, @@ -188,24 +188,7 @@ public function assertValid() ); $values = $this->getValues(); - - Utils::invariant( - !empty($values), - "{$this->name} values must be not empty." - ); foreach ($values as $value) { - try { - Utils::assertValidName($value->name); - } catch (InvariantViolation $e) { - throw new InvariantViolation( - "{$this->name} has value with invalid name: " . - Utils::printSafe($value->name) . " ({$e->getMessage()})" - ); - } - Utils::invariant( - !in_array($value->name, ['true', 'false', 'null']), - "{$this->name}: \"{$value->name}\" can not be used as an Enum value." - ); Utils::invariant( !isset($value->config['isDeprecated']), "{$this->name}.{$value->name} should provide \"deprecationReason\" instead of \"isDeprecated\"." diff --git a/src/Type/Definition/InputObjectType.php b/src/Type/Definition/InputObjectType.php index d795630ab..a3661e60c 100644 --- a/src/Type/Definition/InputObjectType.php +++ b/src/Type/Definition/InputObjectType.php @@ -9,7 +9,7 @@ * Class InputObjectType * @package GraphQL\Type\Definition */ -class InputObjectType extends Type implements InputType +class InputObjectType extends Type implements InputType, NamedType { /** * @var InputObjectField[] @@ -31,7 +31,7 @@ public function __construct(array $config) $config['name'] = $this->tryInferName(); } - Utils::assertValidName($config['name']); + Utils::invariant(is_string($config['name']), 'Must provide name.'); Config::validate($config, [ 'name' => Config::NAME | Config::REQUIRED, @@ -91,41 +91,4 @@ public function getField($name) Utils::invariant(isset($this->fields[$name]), "Field '%s' is not defined for type '%s'", $name, $this->name); return $this->fields[$name]; } - - /** - * @throws InvariantViolation - */ - public function assertValid() - { - parent::assertValid(); - - $fields = $this->getFields(); - - Utils::invariant( - !empty($fields), - "{$this->name} fields must not be empty" - ); - - foreach ($fields as $field) { - try { - Utils::assertValidName($field->name); - } catch (InvariantViolation $e) { - throw new InvariantViolation("{$this->name}.{$field->name}: {$e->getMessage()}"); - } - - $fieldType = $field->type; - if ($fieldType instanceof WrappingType) { - $fieldType = $fieldType->getWrappedType(true); - } - Utils::invariant( - $fieldType instanceof InputType, - "{$this->name}.{$field->name} field type must be Input Type but got: %s.", - Utils::printSafe($field->type) - ); - Utils::invariant( - !isset($field->config['resolve']), - "{$this->name}.{$field->name} field type has a resolve property, but Input Types cannot define resolvers." - ); - } - } } diff --git a/src/Type/Definition/InputType.php b/src/Type/Definition/InputType.php index 7f9003963..b2c3830c1 100644 --- a/src/Type/Definition/InputType.php +++ b/src/Type/Definition/InputType.php @@ -3,11 +3,16 @@ /* export type GraphQLInputType = - GraphQLScalarType | - GraphQLEnumType | - GraphQLInputObjectType | - GraphQLList | - GraphQLNonNull; + | GraphQLScalarType + | GraphQLEnumType + | GraphQLInputObjectType + | GraphQLList + | GraphQLNonNull< + | GraphQLScalarType + | GraphQLEnumType + | GraphQLInputObjectType + | GraphQLList, + >; */ interface InputType { diff --git a/src/Type/Definition/InterfaceType.php b/src/Type/Definition/InterfaceType.php index 33bcb6756..3d57f889e 100644 --- a/src/Type/Definition/InterfaceType.php +++ b/src/Type/Definition/InterfaceType.php @@ -10,7 +10,7 @@ * Class InterfaceType * @package GraphQL\Type\Definition */ -class InterfaceType extends Type implements AbstractType, OutputType, CompositeType +class InterfaceType extends Type implements AbstractType, OutputType, CompositeType, NamedType { /** * @param mixed $type @@ -51,7 +51,7 @@ public function __construct(array $config) $config['name'] = $this->tryInferName(); } - Utils::assertValidName($config['name']); + Utils::invariant(is_string($config['name']), 'Must provide name.'); Config::validate($config, [ 'name' => Config::NAME, @@ -120,23 +120,9 @@ public function assertValid() { parent::assertValid(); - $fields = $this->getFields(); - Utils::invariant( !isset($this->config['resolveType']) || is_callable($this->config['resolveType']), "{$this->name} must provide \"resolveType\" as a function." ); - - Utils::invariant( - !empty($fields), - "{$this->name} fields must not be empty" - ); - - foreach ($fields as $field) { - $field->assertValid($this); - foreach ($field->args as $arg) { - $arg->assertValid($field, $this); - } - } } } diff --git a/src/Type/Definition/NamedType.php b/src/Type/Definition/NamedType.php new file mode 100644 index 000000000..be9681a05 --- /dev/null +++ b/src/Type/Definition/NamedType.php @@ -0,0 +1,15 @@ +tryInferName(); } - Utils::assertValidName($config['name'], !empty($config['isIntrospection'])); + Utils::invariant(is_string($config['name']), 'Must provide name.'); // Note: this validation is disabled by default, because it is resource-consuming // TODO: add bin/validate script to check if schema is valid during development @@ -228,18 +228,5 @@ public function assertValid() !isset($this->config['isTypeOf']) || is_callable($this->config['isTypeOf']), "{$this->name} must provide 'isTypeOf' as a function" ); - - // getFields() and getInterfaceMap() will do structural validation - $fields = $this->getFields(); - Utils::invariant( - !empty($fields), - "{$this->name} fields must not be empty" - ); - foreach ($fields as $field) { - $field->assertValid($this); - foreach ($field->args as $arg) { - $arg->assertValid($field, $this); - } - } } } diff --git a/src/Type/Definition/ScalarType.php b/src/Type/Definition/ScalarType.php index 6038796c7..eee6bb072 100644 --- a/src/Type/Definition/ScalarType.php +++ b/src/Type/Definition/ScalarType.php @@ -22,7 +22,7 @@ * } * } */ -abstract class ScalarType extends Type implements OutputType, InputType, LeafType +abstract class ScalarType extends Type implements OutputType, InputType, LeafType, NamedType { /** * @var ScalarTypeDefinitionNode|null @@ -36,6 +36,6 @@ function __construct(array $config = []) $this->astNode = isset($config['astNode']) ? $config['astNode'] : null; $this->config = $config; - Utils::assertValidName($this->name); + Utils::invariant(is_string($this->name), 'Must provide name.'); } } diff --git a/src/Type/Definition/Type.php b/src/Type/Definition/Type.php index ba797bc14..5dd6b939b 100644 --- a/src/Type/Definition/Type.php +++ b/src/Type/Definition/Type.php @@ -80,7 +80,7 @@ public static function float() /** * @api - * @param ObjectType|InterfaceType|UnionType|ScalarType|InputObjectType|EnumType|ListOfType|NonNull $wrappedType + * @param Type|ObjectType|InterfaceType|UnionType|ScalarType|InputObjectType|EnumType|ListOfType|NonNull $wrappedType * @return ListOfType */ public static function listOf($wrappedType) @@ -161,8 +161,11 @@ public static function isBuiltInType(Type $type) */ public static function isInputType($type) { - $nakedType = self::getNamedType($type); - return $nakedType instanceof InputType; + return $type instanceof InputType && + ( + !$type instanceof WrappingType || + self::getNamedType($type) instanceof InputType + ); } /** @@ -172,8 +175,11 @@ public static function isInputType($type) */ public static function isOutputType($type) { - $nakedType = self::getNamedType($type); - return $nakedType instanceof OutputType; + return $type instanceof OutputType && + ( + !$type instanceof WrappingType || + self::getNamedType($type) instanceof OutputType + ); } /** @@ -311,6 +317,7 @@ protected function tryInferName() */ public function assertValid() { + Utils::assertValidName($this->name); } /** diff --git a/src/Type/Definition/UnionType.php b/src/Type/Definition/UnionType.php index 06d57fc0a..f9f486391 100644 --- a/src/Type/Definition/UnionType.php +++ b/src/Type/Definition/UnionType.php @@ -9,7 +9,7 @@ * Class UnionType * @package GraphQL\Type\Definition */ -class UnionType extends Type implements AbstractType, OutputType, CompositeType +class UnionType extends Type implements AbstractType, OutputType, CompositeType, NamedType { /** * @var UnionTypeDefinitionNode @@ -36,7 +36,7 @@ public function __construct($config) $config['name'] = $this->tryInferName(); } - Utils::assertValidName($config['name']); + Utils::invariant(is_string($config['name']), 'Must provide name.'); Config::validate($config, [ 'name' => Config::NAME | Config::REQUIRED, @@ -81,7 +81,8 @@ public function getTypes() if (!is_array($types)) { throw new InvariantViolation( - "{$this->name} types must be an Array or a callable which returns an Array." + "Must provide Array of types or a callable which returns " . + "such an array for Union {$this->name}" ); } @@ -133,31 +134,11 @@ public function assertValid() { parent::assertValid(); - $types = $this->getTypes(); - Utils::invariant( - !empty($types), - "{$this->name} types must not be empty" - ); - if (isset($this->config['resolveType'])) { Utils::invariant( is_callable($this->config['resolveType']), "{$this->name} must provide \"resolveType\" as a function." ); } - - $includedTypeNames = []; - foreach ($types as $objType) { - Utils::invariant( - $objType instanceof ObjectType, - "{$this->name} may only contain Object types, it cannot contain: %s.", - Utils::printSafe($objType) - ); - Utils::invariant( - !isset($includedTypeNames[$objType->name]), - "{$this->name} can include {$objType->name} type only once." - ); - $includedTypeNames[$objType->name] = true; - } } } diff --git a/src/Type/Introspection.php b/src/Type/Introspection.php index 57be00227..4fd41caab 100644 --- a/src/Type/Introspection.php +++ b/src/Type/Introspection.php @@ -168,7 +168,7 @@ public static function getTypes() * @param Type $type * @return bool */ - public static function isIntrospectionType(Type $type) + public static function isIntrospectionType($type) { return in_array($type->name, array_keys(self::getTypes())); } diff --git a/src/Type/SchemaValidationContext.php b/src/Type/SchemaValidationContext.php index a0a431265..7b89871a8 100644 --- a/src/Type/SchemaValidationContext.php +++ b/src/Type/SchemaValidationContext.php @@ -2,9 +2,11 @@ namespace GraphQL\Type; use GraphQL\Error\Error; -use GraphQL\Error\InvariantViolation; +use GraphQL\Language\AST\EnumValueDefinitionNode; use GraphQL\Language\AST\FieldDefinitionNode; use GraphQL\Language\AST\InputValueDefinitionNode; +use GraphQL\Language\AST\InterfaceTypeDefinitionNode; +use GraphQL\Language\AST\InterfaceTypeExtensionNode; use GraphQL\Language\AST\NamedTypeNode; use GraphQL\Language\AST\Node; use GraphQL\Language\AST\ObjectTypeDefinitionNode; @@ -13,20 +15,24 @@ use GraphQL\Language\AST\TypeDefinitionNode; use GraphQL\Language\AST\TypeNode; use GraphQL\Type\Definition\Directive; +use GraphQL\Type\Definition\EnumType; +use GraphQL\Type\Definition\EnumValueDefinition; +use GraphQL\Type\Definition\FieldDefinition; +use GraphQL\Type\Definition\InputObjectField; +use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InterfaceType; +use GraphQL\Type\Definition\NamedType; use GraphQL\Type\Definition\NonNull; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; +use GraphQL\Type\Definition\UnionType; use GraphQL\Utils\TypeComparators; use GraphQL\Utils\Utils; -/** - * - */ class SchemaValidationContext { /** - * @var array + * @var Error[] */ private $errors = []; @@ -56,7 +62,7 @@ public function validateRootTypes() { ); } else if (!$queryType instanceof ObjectType) { $this->reportError( - 'Query root type must be Object type but got: ' . Utils::getVariableType($queryType) . '.', + 'Query root type must be Object type, it cannot be ' . Utils::printSafe($queryType) . '.', $this->getOperationTypeNode($queryType, 'query') ); } @@ -64,7 +70,7 @@ public function validateRootTypes() { $mutationType = $this->schema->getMutationType(); if ($mutationType && !$mutationType instanceof ObjectType) { $this->reportError( - 'Mutation root type must be Object type if provided but got: ' . Utils::getVariableType($mutationType) . '.', + 'Mutation root type must be Object type if provided, it cannot be ' . Utils::printSafe($mutationType) . '.', $this->getOperationTypeNode($mutationType, 'mutation') ); } @@ -72,55 +78,214 @@ public function validateRootTypes() { $subscriptionType = $this->schema->getSubscriptionType(); if ($subscriptionType && !$subscriptionType instanceof ObjectType) { $this->reportError( - 'Subscription root type must be Object type if provided but got: ' . Utils::getVariableType($subscriptionType) . '.', + 'Subscription root type must be Object type if provided, it cannot be ' . Utils::printSafe($subscriptionType) . '.', $this->getOperationTypeNode($subscriptionType, 'subscription') ); } } + /** + * @param Type $type + * @param string $operation + * + * @return TypeNode|TypeDefinitionNode + */ + private function getOperationTypeNode($type, $operation) + { + $astNode = $this->schema->getAstNode(); + + $operationTypeNode = null; + if ($astNode instanceof SchemaDefinitionNode) { + $operationTypeNode = null; + + foreach($astNode->operationTypes as $operationType) { + if ($operationType->operation === $operation) { + $operationTypeNode = $operationType; + break; + } + } + } + + return $operationTypeNode ? $operationTypeNode->type : ($type ? $type->astNode : null); + } + public function validateDirectives() { $directives = $this->schema->getDirectives(); foreach($directives as $directive) { + // Ensure all directives are in fact GraphQL directives. if (!$directive instanceof Directive) { $this->reportError( - "Expected directive but got: " . $directive, + "Expected directive but got: " . Utils::printSafe($directive) . '.', is_object($directive) ? $directive->astNode : null ); + continue; + } + + // Ensure they are named correctly. + $this->validateName($directive); + + // TODO: Ensure proper locations. + + $argNames = []; + foreach ($directive->args as $arg) { + $argName = $arg->name; + + // Ensure they are named correctly. + $this->validateName($directive); + + if (isset($argNames[$argName])) { + $this->reportError( + "Argument @{$directive->name}({$argName}:) can only be defined once.", + $this->getAllDirectiveArgNodes($directive, $argName) + ); + continue; + } + + $argNames[$argName] = true; + + // Ensure the type is an input type. + if (!Type::isInputType($arg->getType())) { + $this->reportError( + "The type of @{$directive->name}({$argName}:) must be Input Type " . + 'but got: ' . Utils::printSafe($arg->getType()) . '.', + $this->getDirectiveArgTypeNode($directive, $argName) + ); + } } } } + /** + * @param Type|Directive|FieldDefinition|EnumValueDefinition|InputObjectField $node + */ + private function validateName($node) + { + // Ensure names are valid, however introspection types opt out. + $error = Utils::isValidNameError($node->name, $node->astNode); + if ($error && !Introspection::isIntrospectionType($node)) { + $this->addError($error); + } + } + public function validateTypes() { $typeMap = $this->schema->getTypeMap(); foreach($typeMap as $typeName => $type) { // Ensure all provided types are in fact GraphQL type. - if (!Type::isType($type)) { + if (!$type instanceof NamedType) { $this->reportError( - "Expected GraphQL type but got: " . Utils::getVariableType($type), + "Expected GraphQL named type but got: " . Utils::printSafe($type) . '.', is_object($type) ? $type->astNode : null ); + continue; } - // Ensure objects implement the interfaces they claim to. + $this->validateName($type); + if ($type instanceof ObjectType) { - $implementedTypeNames = []; - - foreach($type->getInterfaces() as $iface) { - if (isset($implementedTypeNames[$iface->name])) { - $this->reportError( - "{$type->name} must declare it implements {$iface->name} only once.", - $this->getAllImplementsInterfaceNode($type, $iface) - ); - } - $implementedTypeNames[$iface->name] = true; - $this->validateObjectImplementsInterface($type, $iface); + // Ensure fields are valid + $this->validateFields($type); + + // Ensure objects implement the interfaces they claim to. + $this->validateObjectInterfaces($type); + } else if ($type instanceof InterfaceType) { + // Ensure fields are valid. + $this->validateFields($type); + } else if ($type instanceof UnionType) { + // Ensure Unions include valid member types. + $this->validateUnionMembers($type); + } else if ($type instanceof EnumType) { + // Ensure Enums have valid values. + $this->validateEnumValues($type); + } else if ($type instanceof InputObjectType) { + // Ensure Input Object fields are valid. + $this->validateInputFields($type); + } + } + } + + /** + * @param ObjectType|InterfaceType $type + */ + private function validateFields($type) { + $fieldMap = $type->getFields(); + + // Objects and Interfaces both must define one or more fields. + if (!$fieldMap) { + $this->reportError( + "Type {$type->name} must define one or more fields.", + $this->getAllObjectOrInterfaceNodes($type) + ); + } + + foreach ($fieldMap as $fieldName => $field) { + // Ensure they are named correctly. + $this->validateName($field); + + // Ensure they were defined at most once. + $fieldNodes = $this->getAllFieldNodes($type, $fieldName); + if ($fieldNodes && count($fieldNodes) > 1) { + $this->reportError( + "Field {$type->name}.{$fieldName} can only be defined once.", + $fieldNodes + ); + continue; + } + + // Ensure the type is an output type + if (!Type::isOutputType($field->getType())) { + $this->reportError( + "The type of {$type->name}.{$fieldName} must be Output Type " . + 'but got: ' . Utils::printSafe($field->getType()) . '.', + $this->getFieldTypeNode($type, $fieldName) + ); + } + + // Ensure the arguments are valid + $argNames = []; + foreach($field->args as $arg) { + $argName = $arg->name; + + // Ensure they are named correctly. + $this->validateName($arg); + + if (isset($argNames[$argName])) { + $this->reportError( + "Field argument {$type->name}.{$fieldName}({$argName}:) can only " . + 'be defined once.', + $this->getAllFieldArgNodes($type, $fieldName, $argName) + ); + } + $argNames[$argName] = true; + + // Ensure the type is an input type + if (!Type::isInputType($arg->getType())) { + $this->reportError( + "The type of {$type->name}.{$fieldName}({$argName}:) must be Input " . + 'Type but got: '. Utils::printSafe($arg->getType()) . '.', + $this->getFieldArgTypeNode($type, $fieldName, $argName) + ); } } } } + private function validateObjectInterfaces(ObjectType $object) { + $implementedTypeNames = []; + foreach($object->getInterfaces() as $iface) { + if (isset($implementedTypeNames[$iface->name])) { + $this->reportError( + "Type {$object->name} can only implement {$iface->name} once.", + $this->getAllImplementsInterfaceNodes($object, $iface) + ); + continue; + } + $implementedTypeNames[$iface->name] = true; + $this->validateObjectImplementsInterface($object, $iface); + } + } + /** * @param ObjectType $object * @param InterfaceType $iface @@ -129,9 +294,8 @@ private function validateObjectImplementsInterface(ObjectType $object, $iface) { if (!$iface instanceof InterfaceType) { $this->reportError( - $object . - " must only implement Interface types, it cannot implement " . - $iface . ".", + "Type {$object->name} must only implement Interface types, " . + "it cannot implement ". Utils::printSafe($iface) . ".", $this->getImplementsInterfaceNode($object, $iface) ); return; @@ -149,7 +313,8 @@ private function validateObjectImplementsInterface(ObjectType $object, $iface) // Assert interface field exists on object. if (!$objectField) { $this->reportError( - "\"{$iface->name}\" expects field \"{$fieldName}\" but \"{$object->name}\" does not provide it.", + "Interface field {$iface->name}.{$fieldName} expected but " . + "{$object->name} does not provide it.", [$this->getFieldNode($iface, $fieldName), $object->astNode] ); continue; @@ -165,10 +330,9 @@ private function validateObjectImplementsInterface(ObjectType $object, $iface) ) ) { $this->reportError( - "{$iface->name}.{$fieldName} expects type ". - "\"{$ifaceField->getType()}\"" . - " but {$object->name}.{$fieldName} is type " . - "\"{$objectField->getType()}\".", + "Interface field {$iface->name}.{$fieldName} expects type ". + "{$ifaceField->getType()} but {$object->name}.{$fieldName} " . + "is type " . Utils::printSafe($objectField->getType()) . ".", [ $this->getFieldTypeNode($iface, $fieldName), $this->getFieldTypeNode($object, $fieldName), @@ -191,8 +355,8 @@ private function validateObjectImplementsInterface(ObjectType $object, $iface) // Assert interface field arg exists on object field. if (!$objectArg) { $this->reportError( - "{$iface->name}.{$fieldName} expects argument \"{$argName}\" but ". - "{$object->name}.{$fieldName} does not provide it.", + "Interface field argument {$iface->name}.{$fieldName}({$argName}:) " . + "expected but {$object->name}.{$fieldName} does not provide it.", [ $this->getFieldArgNode($iface, $fieldName, $argName), $this->getFieldNode($object, $fieldName), @@ -206,10 +370,10 @@ private function validateObjectImplementsInterface(ObjectType $object, $iface) // TODO: change to contravariant? if (!TypeComparators::isEqualType($ifaceArg->getType(), $objectArg->getType())) { $this->reportError( - "{$iface->name}.{$fieldName}({$argName}:) expects type ". - "\"{$ifaceArg->getType()}\"" . - " but {$object->name}.{$fieldName}({$argName}:) is type " . - "\"{$objectArg->getType()}\".", + "Interface field argument {$iface->name}.{$fieldName}({$argName}:) ". + "expects type " . Utils::printSafe($ifaceArg->getType()) . " but " . + "{$object->name}.{$fieldName}({$argName}:) is type " . + Utils::printSafe($objectArg->getType()) . ".", [ $this->getFieldArgTypeNode($iface, $fieldName, $argName), $this->getFieldArgTypeNode($object, $fieldName, $argName), @@ -234,9 +398,9 @@ private function validateObjectImplementsInterface(ObjectType $object, $iface) if (!$ifaceArg && $objectArg->getType() instanceof NonNull) { $this->reportError( - "{$object->name}.{$fieldName}({$argName}:) is of required type " . - "\"{$objectArg->getType()}\"" . - " but is not also provided by the interface {$iface->name}.{$fieldName}.", + "Object field argument {$object->name}.{$fieldName}({$argName}:) " . + "is of required type " . Utils::printSafe($objectArg->getType()) . " but is not also " . + "provided by the Interface field {$iface->name}.{$fieldName}.", [ $this->getFieldArgTypeNode($object, $fieldName, $argName), $this->getFieldNode($iface, $fieldName), @@ -247,27 +411,135 @@ private function validateObjectImplementsInterface(ObjectType $object, $iface) } } + private function validateUnionMembers(UnionType $union) + { + $memberTypes = $union->getTypes(); + + if (!$memberTypes) { + $this->reportError( + "Union type {$union->name} must define one or more member types.", + $union->astNode + ); + } + + $includedTypeNames = []; + + foreach($memberTypes as $memberType) { + if (isset($includedTypeNames[$memberType->name])) { + $this->reportError( + "Union type {$union->name} can only include type ". + "{$memberType->name} once.", + $this->getUnionMemberTypeNodes($union, $memberType->name) + ); + continue; + } + $includedTypeNames[$memberType->name] = true; + if (!$memberType instanceof ObjectType) { + $this->reportError( + "Union type {$union->name} can only include Object types, ". + "it cannot include " . Utils::printSafe($memberType) . ".", + $this->getUnionMemberTypeNodes($union, Utils::printSafe($memberType)) + ); + } + } + } + + private function validateEnumValues(EnumType $enumType) + { + $enumValues = $enumType->getValues(); + + if (!$enumValues) { + $this->reportError( + "Enum type {$enumType->name} must define one or more values.", + $enumType->astNode + ); + } + + foreach($enumValues as $enumValue) { + $valueName = $enumValue->name; + + // Ensure no duplicates + $allNodes = $this->getEnumValueNodes($enumType, $valueName); + if ($allNodes && count($allNodes) > 1) { + $this->reportError( + "Enum type {$enumType->name} can include value {$valueName} only once.", + $allNodes + ); + } + + // Ensure valid name. + $this->validateName($enumValue); + if ($valueName === 'true' || $valueName === 'false' || $valueName === 'null') { + $this->reportError( + "Enum type {$enumType->name} cannot include value: {$valueName}.", + $enumValue->astNode + ); + } + } + } + + private function validateInputFields(InputObjectType $inputObj) + { + $fieldMap = $inputObj->getFields(); + + if (!$fieldMap) { + $this->reportError( + "Input Object type {$inputObj->name} must define one or more fields.", + $inputObj->astNode + ); + } + + // Ensure the arguments are valid + foreach ($fieldMap as $fieldName => $field) { + // Ensure they are named correctly. + $this->validateName($field); + + // TODO: Ensure they are unique per field. + + // Ensure the type is an input type + if (!Type::isInputType($field->getType())) { + $this->reportError( + "The type of {$inputObj->name}.{$fieldName} must be Input Type " . + "but got: " . Utils::printSafe($field->getType()) . ".", + $field->astNode ? $field->astNode->type : null + ); + } + } + } + + /** + * @param ObjectType|InterfaceType $type + * @return ObjectTypeDefinitionNode[]|ObjectTypeExtensionNode[]|InterfaceTypeDefinitionNode[]|InterfaceTypeExtensionNode[] + */ + private function getAllObjectOrInterfaceNodes($type) + { + return $type->astNode + ? ($type->extensionASTNodes + ? array_merge([$type->astNode], $type->extensionASTNodes) + : [$type->astNode]) + : ($type->extensionASTNodes ?: []); + } + /** * @param ObjectType $type - * @param InterfaceType|null $iface + * @param InterfaceType $iface * @return NamedTypeNode|null */ private function getImplementsInterfaceNode(ObjectType $type, $iface) { - $nodes = $this->getAllImplementsInterfaceNode($type, $iface); + $nodes = $this->getAllImplementsInterfaceNodes($type, $iface); return $nodes && isset($nodes[0]) ? $nodes[0] : null; } /** * @param ObjectType $type - * @param InterfaceType|null $iface + * @param InterfaceType $iface * @return NamedTypeNode[] */ - private function getAllImplementsInterfaceNode(ObjectType $type, $iface) + private function getAllImplementsInterfaceNodes(ObjectType $type, $iface) { $implementsNodes = []; - /** @var ObjectTypeDefinitionNode|ObjectTypeExtensionNode[] $astNodes */ - $astNodes = array_merge([$type->astNode], $type->extensionASTNodes ?: []); + $astNodes = $this->getAllObjectOrInterfaceNodes($type); foreach($astNodes as $astNode) { if ($astNode && $astNode->interfaces) { @@ -289,18 +561,29 @@ private function getAllImplementsInterfaceNode(ObjectType $type, $iface) */ private function getFieldNode($type, $fieldName) { - /** @var ObjectTypeDefinitionNode|ObjectTypeExtensionNode[] $astNodes */ - $astNodes = array_merge([$type->astNode], $type->extensionASTNodes ?: []); + $nodes = $this->getAllFieldNodes($type, $fieldName); + return $nodes && isset($nodes[0]) ? $nodes[0] : null; + } + /** + * @param ObjectType|InterfaceType $type + * @param string $fieldName + * @return FieldDefinitionNode[] + */ + private function getAllFieldNodes($type, $fieldName) + { + $fieldNodes = []; + $astNodes = $this->getAllObjectOrInterfaceNodes($type); foreach($astNodes as $astNode) { if ($astNode && $astNode->fields) { foreach($astNode->fields as $node) { if ($node->name->value === $fieldName) { - return $node; + $fieldNodes[] = $node; } } } } + return $fieldNodes; } /** @@ -311,9 +594,7 @@ private function getFieldNode($type, $fieldName) private function getFieldTypeNode($type, $fieldName) { $fieldNode = $this->getFieldNode($type, $fieldName); - if ($fieldNode) { - return $fieldNode->type; - } + return $fieldNode ? $fieldNode->type : null; } /** @@ -324,14 +605,28 @@ private function getFieldTypeNode($type, $fieldName) */ private function getFieldArgNode($type, $fieldName, $argName) { + $nodes = $this->getAllFieldArgNodes($type, $fieldName, $argName); + return $nodes && isset($nodes[0]) ? $nodes[0] : null; + } + + /** + * @param ObjectType|InterfaceType $type + * @param string $fieldName + * @param string $argName + * @return InputValueDefinitionNode[] + */ + private function getAllFieldArgNodes($type, $fieldName, $argName) + { + $argNodes = []; $fieldNode = $this->getFieldNode($type, $fieldName); if ($fieldNode && $fieldNode->arguments) { foreach ($fieldNode->arguments as $node) { if ($node->name->value === $argName) { - return $node; + $argNodes[] = $node; } } } + return $argNodes; } /** @@ -343,34 +638,76 @@ private function getFieldArgNode($type, $fieldName, $argName) private function getFieldArgTypeNode($type, $fieldName, $argName) { $fieldArgNode = $this->getFieldArgNode($type, $fieldName, $argName); - if ($fieldArgNode) { - return $fieldArgNode->type; - } + return $fieldArgNode ? $fieldArgNode->type : null; } /** - * @param Type $type - * @param string $operation - * - * @return TypeNode|TypeDefinitionNode + * @param Directive $directive + * @param string $argName + * @return InputValueDefinitionNode[] */ - private function getOperationTypeNode($type, $operation) + private function getAllDirectiveArgNodes(Directive $directive, $argName) { - $astNode = $this->schema->getAstNode(); + $argNodes = []; + $directiveNode = $directive->astNode; + if ($directiveNode && $directiveNode->arguments) { + foreach($directiveNode->arguments as $node) { + if ($node->name->value === $argName) { + $argNodes[] = $node; + } + } + } - $operationTypeNode = null; - if ($astNode instanceof SchemaDefinitionNode) { - $operationTypeNode = null; + return $argNodes; + } - foreach($astNode->operationTypes as $operationType) { - if ($operationType->operation === $operation) { - $operationTypeNode = $operationType; - break; + /** + * @param Directive $directive + * @param string $argName + * @return TypeNode|null + */ + private function getDirectiveArgTypeNode(Directive $directive, $argName) + { + $argNode = $this->getAllDirectiveArgNodes($directive, $argName)[0]; + return $argNode ? $argNode->type : null; + } + + /** + * @param UnionType $union + * @param string $typeName + * @return NamedTypeNode[] + */ + private function getUnionMemberTypeNodes(UnionType $union, $typeName) + { + if ($union->astNode && $union->astNode->types) { + return array_filter( + $union->astNode->types, + function (NamedTypeNode $value) use ($typeName) { + return $value->name->value === $typeName; } - } + ); } + return $union->astNode ? + $union->astNode->types : null; + } - return $operationTypeNode ? $operationTypeNode->type : ($type ? $type->astNode : null); + /** + * @param EnumType $enum + * @param string $valueName + * @return EnumValueDefinitionNode[] + */ + private function getEnumValueNodes(EnumType $enum, $valueName) + { + if ($enum->astNode && $enum->astNode->values) { + return array_filter( + iterator_to_array($enum->astNode->values), + function (EnumValueDefinitionNode $value) use ($valueName) { + return $value->name->value === $valueName; + } + ); + } + return $enum->astNode ? + $enum->astNode->values : null; } /** @@ -379,6 +716,13 @@ private function getOperationTypeNode($type, $operation) */ private function reportError($message, $nodes = null) { $nodes = array_filter($nodes && is_array($nodes) ? $nodes : [$nodes]); - $this->errors[] = new Error($message, $nodes); + $this->addError(new Error($message, $nodes)); + } + + /** + * @param Error $error + */ + private function addError($error) { + $this->errors[] = $error; } } diff --git a/src/Utils/ASTDefinitionBuilder.php b/src/Utils/ASTDefinitionBuilder.php index c5516ddab..be90907c3 100644 --- a/src/Utils/ASTDefinitionBuilder.php +++ b/src/Utils/ASTDefinitionBuilder.php @@ -22,7 +22,6 @@ use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InputType; -use GraphQL\Type\Definition\OutputType; use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\FieldArgument; @@ -128,53 +127,7 @@ public function buildType($ref) /** * @param TypeNode $typeNode - * @return InputType|Type - * @throws Error - */ - public function buildInputType(TypeNode $typeNode) - { - $type = $this->internalBuildWrappedType($typeNode); - Utils::invariant(Type::isInputType($type), 'Expected Input type.'); - return $type; - } - - /** - * @param TypeNode $typeNode - * @return OutputType|Type - * @throws Error - */ - public function buildOutputType(TypeNode $typeNode) - { - $type = $this->internalBuildWrappedType($typeNode); - Utils::invariant(Type::isOutputType($type), 'Expected Output type.'); - return $type; - } - - /** - * @param TypeNode|string $typeNode - * @return ObjectType|Type - * @throws Error - */ - public function buildObjectType($typeNode) - { - $type = $this->buildType($typeNode); - return ObjectType::assertObjectType($type); - } - - /** - * @param TypeNode|string $typeNode - * @return InterfaceType|Type - * @throws Error - */ - public function buildInterfaceType($typeNode) - { - $type = $this->buildType($typeNode); - return InterfaceType::assertInterfaceType($type); - } - - /** - * @param TypeNode $typeNode - * @return Type + * @return Type|InputType * @throws Error */ private function internalBuildWrappedType(TypeNode $typeNode) @@ -199,7 +152,10 @@ public function buildDirective(DirectiveDefinitionNode $directiveNode) public function buildField(FieldDefinitionNode $field) { return [ - 'type' => $this->buildOutputType($field->type), + // Note: While this could make assertions to get the correctly typed + // value, that would throw immediately while type system validation + // with validateSchema() will produce more actionable results. + 'type' => $this->internalBuildWrappedType($field->type), 'description' => $this->getDescription($field), 'args' => $field->arguments ? $this->makeInputValues($field->arguments) : null, 'deprecationReason' => $this->getDeprecationReason($field), @@ -282,7 +238,10 @@ function ($value) { return $value->name->value; }, function ($value) { - $type = $this->buildInputType($value->type); + // Note: While this could make assertions to get the correctly typed + // value, that would throw immediately while type system validation + // with validateSchema() will produce more actionable results. + $type = $this->internalBuildWrappedType($value->type); $config = [ 'name' => $value->name->value, 'type' => $type, @@ -339,9 +298,12 @@ private function makeUnionDef(UnionTypeDefinitionNode $def) return new UnionType([ 'name' => $def->name->value, 'description' => $this->getDescription($def), + // Note: While this could make assertions to get the correctly typed + // values below, that would throw immediately while type system + // validation with validateSchema() will produce more actionable results. 'types' => $def->types ? Utils::map($def->types, function ($typeNode) { - return $this->buildObjectType($typeNode); + return $this->buildType($typeNode); }): [], 'astNode' => $def, @@ -409,7 +371,7 @@ private function getLeadingCommentBlock($node) { $loc = $node->loc; if (!$loc || !$loc->startToken) { - return; + return null; } $comments = []; $token = $loc->startToken->prev; diff --git a/src/Utils/SchemaPrinter.php b/src/Utils/SchemaPrinter.php index e0a74b71e..968555470 100644 --- a/src/Utils/SchemaPrinter.php +++ b/src/Utils/SchemaPrinter.php @@ -221,7 +221,7 @@ private static function printFields($options, $type) private static function printArgs($options, $args, $indentation = '') { - if (count($args) === 0) { + if (!$args) { return ''; } diff --git a/src/Utils/Utils.php b/src/Utils/Utils.php index 0b48c4132..c000c80c8 100644 --- a/src/Utils/Utils.php +++ b/src/Utils/Utils.php @@ -1,8 +1,10 @@ toString(); } if (is_object($var)) { - return 'instance of ' . get_class($var); + if (method_exists($var, '__toString')) { + return (string) $var; + } else { + return 'instance of ' . get_class($var); + } } if (is_array($var)) { return json_encode($var); @@ -399,34 +406,46 @@ public static function printCharCode($code) } /** + * Upholds the spec rules about naming. + * * @param $name - * @param bool $isIntrospection - * @throws InvariantViolation + * @throws Error */ - public static function assertValidName($name, $isIntrospection = false) + public static function assertValidName($name) { - $regex = '/^[_a-zA-Z][_a-zA-Z0-9]*$/'; - - if (!$name || !is_string($name)) { - throw new InvariantViolation( - "Must be named. Unexpected name: " . self::printSafe($name) - ); + $error = self::isValidNameError($name); + if ($error) { + throw $error; } + } + + /** + * Returns an Error if a name is invalid. + * + * @param string $name + * @param Node|null $node + * @return Error|null + */ + public static function isValidNameError($name, $node = null) + { + Utils::invariant(is_string($name), 'Expected string'); - if (!$isIntrospection && isset($name[1]) && $name[0] === '_' && $name[1] === '_') { - Warning::warnOnce( - 'Name "'.$name.'" must not begin with "__", which is reserved by ' . - 'GraphQL introspection. In a future release of graphql this will ' . - 'become an exception', - Warning::WARNING_NAME + if (isset($name[1]) && $name[0] === '_' && $name[1] === '_') { + return new Error( + "Name \"{$name}\" must not begin with \"__\", which is reserved by " . + "GraphQL introspection.", + $node ); } - if (!preg_match($regex, $name)) { - throw new InvariantViolation( - 'Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "'.$name.'" does not.' + if (!preg_match('/^[_a-zA-Z][_a-zA-Z0-9]*$/', $name)) { + return new Error( + "Names must match /^[_a-zA-Z][_a-zA-Z0-9]*\$/ but \"{$name}\" does not.", + $node ); } + + return null; } /** diff --git a/tests/Type/DefinitionTest.php b/tests/Type/DefinitionTest.php index 9f1b8ce66..9457d1753 100644 --- a/tests/Type/DefinitionTest.php +++ b/tests/Type/DefinitionTest.php @@ -468,37 +468,6 @@ public function testIdentifiesOutputTypes() } } - /** - * @it prohibits putting non-Object types in unions - */ - public function testProhibitsPuttingNonObjectTypesInUnions() - { - $int = Type::int(); - - $badUnionTypes = [ - $int, - new NonNull($int), - new ListOfType($int), - $this->interfaceType, - $this->unionType, - $this->enumType, - $this->inputObjectType - ]; - - foreach ($badUnionTypes as $type) { - try { - $union = new UnionType(['name' => 'BadUnion', 'types' => [$type]]); - $union->assertValid(); - $this->fail('Expected exception not thrown'); - } catch (\Exception $e) { - $this->assertSame( - 'BadUnion may only contain Object types, it cannot contain: ' . Utils::printSafe($type) . '.', - $e->getMessage() - ); - } - } - } - /** * @it allows a thunk for Union\'s types */ diff --git a/tests/Type/ValidationTest.php b/tests/Type/ValidationTest.php index 1409da4e9..361607acd 100644 --- a/tests/Type/ValidationTest.php +++ b/tests/Type/ValidationTest.php @@ -21,8 +21,6 @@ class ValidationTest extends \PHPUnit_Framework_TestCase public $SomeObjectType; - public $ObjectWithIsTypeOf; - public $SomeUnionType; public $SomeInterfaceType; @@ -39,11 +37,11 @@ class ValidationTest extends \PHPUnit_Framework_TestCase public $notInputTypes; - public $String; + public $Number; public function setUp() { - $this->String = 'TestString'; + $this->Number = 1; $this->SomeScalarType = new CustomScalarType([ 'name' => 'SomeScalar', @@ -57,11 +55,6 @@ public function setUp() 'fields' => [ 'f' => [ 'type' => Type::string() ] ], 'interfaces' => function() {return [$this->SomeInterfaceType];} ]); - - $this->ObjectWithIsTypeOf = new ObjectType([ - 'name' => 'ObjectWithIsTypeOf', - 'fields' => [ 'f' => [ 'type' => Type::string() ]] - ]); $this->SomeUnionType = new UnionType([ 'name' => 'SomeUnion', 'types' => [ $this->SomeObjectType ] @@ -98,7 +91,7 @@ public function setUp() $this->notOutputTypes = $this->withModifiers([ $this->SomeInputObjectType, ]); - $this->notOutputTypes[] = $this->String; + $this->notOutputTypes[] = $this->Number; $this->inputTypes = $this->withModifiers([ Type::string(), @@ -113,7 +106,7 @@ public function setUp() $this->SomeInterfaceType, ]); - $this->notInputTypes[] = $this->String; + $this->notInputTypes[] = $this->Number; Warning::suppress(Warning::WARNING_NOT_A_TYPE); } @@ -126,29 +119,29 @@ public function tearDown() /** * @param InvariantViolation[]|Error[] $array - * @param string $message - * @param array|null $locations - */ - private function assertContainsValidationMessage($array, $message, array $locations = null) { - foreach ($array as $error) { - if ($error->getMessage() === $message) { - if ($error instanceof Error) { - $errorLocations = []; - foreach ($error->getLocations() as $location) { - $errorLocations[] = $location->toArray(); - } - $this->assertEquals($locations, $errorLocations ?: null); - } - return; + * @param array $messages + */ + private function assertContainsValidationMessage($array, $messages) { + $this->assertCount( + count($messages), + $array, + 'For messages: ' . $messages[0]['message'] . "\n" . + "Received: \n" . join("\n", array_map(function($error) { return $error->getMessage(); }, $array)) + ); + foreach ($array as $index => $error) { + if(!isset($messages[$index]) || !$error instanceof Error) { + $this->fail('Received unexpected error: ' . $error->getMessage()); } + $this->assertEquals($messages[$index]['message'], $error->getMessage()); + $errorLocations = []; + foreach ($error->getLocations() as $location) { + $errorLocations[] = $location->toArray(); + } + $this->assertEquals( + isset($messages[$index]['locations']) ? $messages[$index]['locations'] : [], + $errorLocations + ); } - - $this->fail( - 'Failed asserting that the array of validation messages contains ' . - 'the message "' . $message . '"' . "\n" . - 'Found the following messages in the array:' . "\n" . - join("\n", array_map(function($error) { return "\"{$error->getMessage()}\""; }, $array)) - ); } public function testRejectsTypesWithoutNames() @@ -169,70 +162,7 @@ function() { function() { return new InterfaceType([]); } - ], 'Must be named. Unexpected name: null'); - } - - public function testRejectsAnObjectTypeWithReservedName() - { - $this->assertWarnsOnce([ - function() { - return new ObjectType([ - 'name' => '__ReservedName', - ]); - }, - function() { - return new EnumType([ - 'name' => '__ReservedName', - ]); - }, - function() { - return new InputObjectType([ - 'name' => '__ReservedName', - ]); - }, - function() { - return new UnionType([ - 'name' => '__ReservedName', - 'types' => [new ObjectType(['name' => 'Test'])] - ]); - }, - function() { - return new InterfaceType([ - 'name' => '__ReservedName', - ]); - } - ], 'Name "__ReservedName" must not begin with "__", which is reserved by GraphQL introspection. In a future release of graphql this will become an exception'); - } - - public function testRejectsAnObjectTypeWithInvalidName() - { - $this->assertEachCallableThrows([ - function() { - return new ObjectType([ - 'name' => 'a-b-c', - ]); - }, - function() { - return new EnumType([ - 'name' => 'a-b-c', - ]); - }, - function() { - return new InputObjectType([ - 'name' => 'a-b-c', - ]); - }, - function() { - return new UnionType([ - 'name' => 'a-b-c', - ]); - }, - function() { - return new InterfaceType([ - 'name' => 'a-b-c', - ]); - } - ], 'Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "a-b-c" does not.'); + ], 'Must provide name.'); } // DESCRIBE: Type System: A Schema must have Object root types @@ -339,7 +269,7 @@ public function testRejectsASchemaWithoutAQueryType() $this->assertContainsValidationMessage( $schema->validate(), - 'Query root type must be provided.' + [['message' => 'Query root type must be provided.']] ); @@ -355,8 +285,10 @@ public function testRejectsASchemaWithoutAQueryType() $this->assertContainsValidationMessage( $schemaWithDef->validate(), - 'Query root type must be provided.', - [['line' => 2, 'column' => 7]] + [[ + 'message' => 'Query root type must be provided.', + 'locations' => [['line' => 2, 'column' => 7]], + ]] ); } @@ -373,8 +305,10 @@ public function testRejectsASchemaWhoseQueryTypeIsNotAnObjectType() $this->assertContainsValidationMessage( $schema->validate(), - 'Query root type must be Object type but got: Query.', - [['line' => 2, 'column' => 7]] + [[ + 'message' => 'Query root type must be Object type, it cannot be Query.', + 'locations' => [['line' => 2, 'column' => 7]], + ]] ); @@ -390,8 +324,10 @@ public function testRejectsASchemaWhoseQueryTypeIsNotAnObjectType() $this->assertContainsValidationMessage( $schemaWithDef->validate(), - 'Query root type must be Object type but got: SomeInputObject.', - [['line' => 3, 'column' => 16]] + [[ + 'message' => 'Query root type must be Object type, it cannot be SomeInputObject.', + 'locations' => [['line' => 3, 'column' => 16]], + ]] ); } @@ -412,11 +348,12 @@ public function testRejectsASchemaWhoseMutationTypeIsAnInputType() $this->assertContainsValidationMessage( $schema->validate(), - 'Mutation root type must be Object type if provided but got: Mutation.', - [['line' => 6, 'column' => 7]] + [[ + 'message' => 'Mutation root type must be Object type if provided, it cannot be Mutation.', + 'locations' => [['line' => 6, 'column' => 7]], + ]] ); - $schemaWithDef = BuildSchema::build(' schema { query: Query @@ -434,8 +371,10 @@ public function testRejectsASchemaWhoseMutationTypeIsAnInputType() $this->assertContainsValidationMessage( $schemaWithDef->validate(), - 'Mutation root type must be Object type if provided but got: SomeInputObject.', - [['line' => 4, 'column' => 19]] + [[ + 'message' => 'Mutation root type must be Object type if provided, it cannot be SomeInputObject.', + 'locations' => [['line' => 4, 'column' => 19]], + ]] ); } @@ -456,11 +395,12 @@ public function testRejectsASchemaWhoseSubscriptionTypeIsAnInputType() $this->assertContainsValidationMessage( $schema->validate(), - 'Subscription root type must be Object type if provided but got: Subscription.', - [['line' => 6, 'column' => 7]] + [[ + 'message' => 'Subscription root type must be Object type if provided, it cannot be Subscription.', + 'locations' => [['line' => 6, 'column' => 7]], + ]] ); - $schemaWithDef = BuildSchema::build(' schema { query: Query @@ -478,8 +418,10 @@ public function testRejectsASchemaWhoseSubscriptionTypeIsAnInputType() $this->assertContainsValidationMessage( $schemaWithDef->validate(), - 'Subscription root type must be Object type if provided but got: SomeInputObject.', - [['line' => 4, 'column' => 23]] + [[ + 'message' => 'Subscription root type must be Object type if provided, it cannot be SomeInputObject.', + 'locations' => [['line' => 4, 'column' => 23]], + ]] ); @@ -497,113 +439,10 @@ public function testRejectsASchemaWhoseDirectivesAreIncorrectlyTyped() $this->assertContainsValidationMessage( $schema->validate(), - 'Expected directive but got: somedirective' - ); - } - - // DESCRIBE: Type System: A Schema must contain uniquely named types - /** - * @it rejects a Schema which redefines a built-in type - */ - public function testRejectsASchemaWhichRedefinesABuiltInType() - { - $FakeString = new CustomScalarType([ - 'name' => 'String', - 'serialize' => function() { - return null; - }, - ]); - - $QueryType = new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'normal' => [ 'type' => Type::string() ], - 'fake' => [ 'type' => $FakeString ], - ] - ]); - - $this->setExpectedException( - InvariantViolation::class, - 'Schema must contain unique named types but contains multiple types named "String" '. - '(see http://webonyx.github.io/graphql-php/type-system/#type-registry).' - ); - new Schema(['query' => $QueryType]); - } - - /** - * @it rejects a Schema which defines an object type twice - */ - public function testRejectsASchemaWhichDfinesAnObjectTypeTwice() - { - $A = new ObjectType([ - 'name' => 'SameName', - 'fields' => [ 'f' => [ 'type' => Type::string() ]], - ]); - - $B = new ObjectType([ - 'name' => 'SameName', - 'fields' => [ 'f' => [ 'type' => Type::string() ] ], - ]); - - $QueryType = new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'a' => [ 'type' => $A ], - 'b' => [ 'type' => $B ] - ] - ]); - - $this->setExpectedException( - InvariantViolation::class, - 'Schema must contain unique named types but contains multiple types named "SameName" '. - '(see http://webonyx.github.io/graphql-php/type-system/#type-registry).' - ); - - new Schema([ 'query' => $QueryType ]); - } - - /** - * @it rejects a Schema which have same named objects implementing an interface - */ - public function testRejectsASchemaWhichHaveSameNamedObjectsImplementingAnInterface() - { - $AnotherInterface = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => [ 'f' => [ 'type' => Type::string() ]], - ]); - - $FirstBadObject = new ObjectType([ - 'name' => 'BadObject', - 'interfaces' => [ $AnotherInterface ], - 'fields' => [ 'f' => [ 'type' => Type::string() ]], - ]); - - $SecondBadObject = new ObjectType([ - 'name' => 'BadObject', - 'interfaces' => [ $AnotherInterface ], - 'fields' => [ 'f' => [ 'type' => Type::string() ]], - ]); - - $QueryType = new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'iface' => [ 'type' => $AnotherInterface ], - ] - ]); - - $this->setExpectedException( - InvariantViolation::class, - 'Schema must contain unique named types but contains multiple types named "BadObject" '. - '(see http://webonyx.github.io/graphql-php/type-system/#type-registry).' + [['message' => 'Expected directive but got: somedirective.']] ); - - new Schema([ - 'query' => $QueryType, - 'types' => [ $FirstBadObject, $SecondBadObject ] - ]); } - // DESCRIBE: Type System: Objects must have fields /** @@ -611,31 +450,17 @@ public function testRejectsASchemaWhichHaveSameNamedObjectsImplementingAnInterfa */ public function testAcceptsAnObjectTypeWithFieldsObject() { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => [ - 'f' => [ 'type' => Type::string() ] - ] - ])); + $schema = BuildSchema::build(' + type Query { + field: SomeObject + } - // Should not throw: - $schema->assertValid(); - } + type SomeObject { + field: String + } + '); - /** - * @it accepts an Object type with a field function - */ - public function testAcceptsAnObjectTypeWithAfieldFunction() - { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => function() { - return [ - 'f' => ['type' => Type::string()] - ]; - } - ])); - $schema->assertValid(); + $this->assertEquals([], $schema->validate()); } /** @@ -643,157 +468,79 @@ public function testAcceptsAnObjectTypeWithAfieldFunction() */ public function testRejectsAnObjectTypeWithMissingFields() { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject' - ])); + $schema = BuildSchema::build(' + type Query { + test: IncompleteObject + } - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject fields must not be empty' - ); - $schema->assertValid(); - } + type IncompleteObject + '); - /** - * @it rejects an Object type field with undefined config - */ - public function testRejectsAnObjectTypeFieldWithUndefinedConfig() - { - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject.f field config must be an array, but got' + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Type IncompleteObject must define one or more fields.', + 'locations' => [['line' => 6, 'column' => 7]], + ]] ); - $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => [ - 'f' => null - ] - ])); - } - - /** - * @it rejects an Object type with incorrectly named fields - */ - public function testRejectsAnObjectTypeWithIncorrectlyNamedFields() - { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => [ - 'bad-name-with-dashes' => ['type' => Type::string()] - ] - ])); - $this->setExpectedException( - InvariantViolation::class + $manualSchema = $this->schemaWithFieldType( + new ObjectType([ + 'name' => 'IncompleteObject', + 'fields' => [], + ]) ); - $schema->assertValid(); - } - - /** - * @it warns about an Object type with reserved named fields - */ - public function testWarnsAboutAnObjectTypeWithReservedNamedFields() - { - $lastMessage = null; - Warning::setWarningHandler(function($message) use (&$lastMessage) { - $lastMessage = $message; - }); - - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => [ - '__notPartOfIntrospection' => ['type' => Type::string()] - ] - ])); - - $schema->assertValid(); - - $this->assertEquals( - 'Name "__notPartOfIntrospection" must not begin with "__", which is reserved by GraphQL introspection. '. - 'In a future release of graphql this will become an exception', - $lastMessage + $this->assertContainsValidationMessage( + $manualSchema->validate(), + [['message' => 'Type IncompleteObject must define one or more fields.']] ); - Warning::setWarningHandler(null); - } - - public function testAcceptsShorthandNotationForFields() - { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => [ - 'field' => Type::string() - ] - ])); - $schema->assertValid(); - } - /** - * @it rejects an Object type with incorrectly typed fields - */ - public function testRejectsAnObjectTypeWithIncorrectlyTypedFields() - { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => [ - 'field' => new \stdClass(['type' => Type::string()]) - ] - ])); + $manualSchema2 = $this->schemaWithFieldType( + new ObjectType([ + 'name' => 'IncompleteObject', + 'fields' => function () { return []; }, + ]) + ); - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject.field field type must be Output Type but got: instance of stdClass' + $this->assertContainsValidationMessage( + $manualSchema2->validate(), + [['message' => 'Type IncompleteObject must define one or more fields.']] ); - $schema->assertValid(); } /** - * @it rejects an Object type with empty fields + * @it rejects an Object type with incorrectly named fields */ - public function testRejectsAnObjectTypeWithEmptyFields() + public function testRejectsAnObjectTypeWithIncorrectlyNamedFields() { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => [] - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject fields must not be empty' + $schema = $this->schemaWithFieldType( + new ObjectType([ + 'name' => 'SomeObject', + 'fields' => [ + 'bad-name-with-dashes' => ['type' => Type::string()] + ], + ]) ); - $schema->assertValid(); - } - /** - * @it rejects an Object type with a field function that returns nothing - */ - public function testRejectsAnObjectTypeWithAFieldFunctionThatReturnsNothing() - { - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject fields must be an array or a callable which returns such an array.' + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but ' . + '"bad-name-with-dashes" does not.', + ]] ); - $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => function() {} - ])); } - /** - * @it rejects an Object type with a field function that returns empty - */ - public function testRejectsAnObjectTypeWithAFieldFunctionThatReturnsEmpty() + public function testAcceptsShorthandNotationForFields() { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => function() { - return []; - } - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject fields must not be empty' + $schema = $this->schemaWithFieldType( + new ObjectType([ + 'name' => 'SomeObject', + 'fields' => [ + 'field' => Type::string() + ] + ]) ); $schema->assertValid(); } @@ -816,7 +563,7 @@ public function testAcceptsFieldArgsWithValidNames() ] ] ])); - $schema->assertValid(); + $this->assertEquals([], $schema->validate()); } /** @@ -837,836 +584,269 @@ public function testRejectsFieldArgWithInvalidNames() ]); $schema = new Schema(['query' => $QueryType]); - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject.badField(bad-name-with-dashes:) Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "bad-name-with-dashes" does not.' + $this->assertContainsValidationMessage( + $schema->validate(), + [['message' => 'Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "bad-name-with-dashes" does not.']] ); - - $schema->assertValid(); } - // DESCRIBE: Type System: Fields args must be objects + // DESCRIBE: Type System: Union types must be valid /** - * @it accepts an Object type with field args + * @it accepts a Union type with member types */ - public function testAcceptsAnObjectTypeWithFieldArgs() + public function testAcceptsAUnionTypeWithArrayTypes() { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => [ - 'goodField' => [ - 'type' => Type::string(), - 'args' => [ - 'goodArg' => ['type' => Type::string()] - ] - ] - ] - ])); - $schema->assertValid(); - } + $schema = BuildSchema::build(' + type Query { + test: GoodUnion + } - /** - * @it rejects an Object type with incorrectly typed field args - */ - public function testRejectsAnObjectTypeWithIncorrectlyTypedFieldArgs() - { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'fields' => [ - 'badField' => [ - 'type' => Type::string(), - 'args' => [ - ['badArg' => Type::string()] - ] - ] - ] - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject.badField(0:) Must be named. Unexpected name: 0' - ); - - $schema->assertValid(); - } - - // DESCRIBE: Type System: Object interfaces must be array - - /** - * @it accepts an Object type with array interfaces - */ - public function testAcceptsAnObjectTypeWithArrayInterfaces() - { - $AnotherInterfaceType = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => ['f' => ['type' => Type::string()]] - ]); - - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'interfaces' => [$AnotherInterfaceType], - 'fields' => ['f' => ['type' => Type::string()]] - ])); - $schema->assertValid(); - } - - /** - * @it accepts an Object type with interfaces as a function returning an array - */ - public function testAcceptsAnObjectTypeWithInterfacesAsAFunctionReturningAnArray() - { - $AnotherInterfaceType = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => ['f' => ['type' => Type::string()]] - ]); - - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'interfaces' => function () use ($AnotherInterfaceType) { - return [$AnotherInterfaceType]; - }, - 'fields' => ['f' => ['type' => Type::string()]] - ])); - $schema->assertValid(); - } - - /** - * @it rejects an Object type with incorrectly typed interfaces - */ - public function testRejectsAnObjectTypeWithIncorrectlyTypedInterfaces() - { - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject interfaces must be an Array or a callable which returns an Array.' - ); - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'interfaces' => new \stdClass(), - 'fields' => ['f' => ['type' => Type::string()]] - ])); - $schema->assertValid(); - } - - /** - * @it rejects an Object type with interfaces as a function returning an incorrect type - */ - public function testRejectsAnObjectTypeWithInterfacesAsAFunctionReturningAnIncorrectType() - { - $this->setExpectedException( - InvariantViolation::class, - 'SomeObject interfaces must be an Array or a callable which returns an Array.' - ); - $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'interfaces' => function () { - return new \stdClass(); - }, - 'fields' => ['f' => ['type' => Type::string()]] - ])); - } - - // DESCRIBE: Type System: Union types must be array - - /** - * @it accepts a Union type with array types - */ - public function testAcceptsAUnionTypeWithArrayTypes() - { - $schema = $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'types' => [$this->SomeObjectType], - ])); - $schema->assertValid(); - } - - /** - * @it accepts a Union type with function returning an array of types - */ - public function testAcceptsAUnionTypeWithFunctionReturningAnArrayOfTypes() - { - $schema = $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'types' => function () { - return [$this->SomeObjectType]; - }, - ])); - $schema->assertValid(); - } - - /** - * @it rejects a Union type without types - */ - public function testRejectsAUnionTypeWithoutTypes() - { - $this->setExpectedException( - InvariantViolation::class, - 'SomeUnion types must be an Array or a callable which returns an Array.' - ); - $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - ])); - } - - /** - * @it rejects a Union type with empty types - */ - public function testRejectsAUnionTypeWithemptyTypes() - { - $schema = $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'types' => [] - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeUnion types must not be empty' - ); - $schema->assertValid(); - } - - /** - * @it rejects a Union type with incorrectly typed types - */ - public function testRejectsAUnionTypeWithIncorrectlyTypedTypes() - { - $this->setExpectedException( - InvariantViolation::class, - 'SomeUnion types must be an Array or a callable which returns an Array.' - ); - $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'types' => $this->SomeObjectType - ])); - } - - /** - * @it rejects a Union type with duplicated member type - */ - public function testRejectsAUnionTypeWithDuplicatedMemberType() - { - $schema = $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'types' => [ - $this->SomeObjectType, - $this->SomeObjectType, - ], - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeUnion can include SomeObject type only once.' - ); - $schema->assertValid(); - } - - // DESCRIBE: Type System: Input Objects must have fields - - /** - * @it accepts an Input Object type with fields - */ - public function testAcceptsAnInputObjectTypeWithFields() - { - $schema = $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - 'fields' => [ - 'f' => ['type' => Type::string()] - ] - ])); - - $schema->assertValid(); - } - - /** - * @it accepts an Input Object type with a field function - */ - public function testAcceptsAnInputObjectTypeWithAFieldFunction() - { - $schema = $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - 'fields' => function () { - return [ - 'f' => ['type' => Type::string()] - ]; - } - ])); - - $schema->assertValid(); - } - - /** - * @it rejects an Input Object type with missing fields - */ - public function testRejectsAnInputObjectTypeWithMissingFields() - { - $schema = $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeInputObject fields must not be empty' - ); - $schema->assertValid(); - } - - /** - * @it rejects an Input Object type with incorrectly typed fields - */ - public function testRejectsAnInputObjectTypeWithIncorrectlyTypedFields() - { - $schema = $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - 'fields' => [ - ['field' => Type::string()] - ] - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeInputObject.0: Must be named. Unexpected name: 0' - ); - $schema->assertValid(); - } - - /** - * @it rejects an Input Object type with empty fields - */ - public function testRejectsAnInputObjectTypeWithEmptyFields() - { - $this->setExpectedException( - InvariantViolation::class, - 'SomeInputObject fields must be an array or a callable which returns such an array.' - ); - $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - 'fields' => new \stdClass() - ])); - } - - /** - * @it rejects an Input Object type with a field function that returns nothing - */ - public function testRejectsAnInputObjectTypeWithAFieldFunctionThatReturnsNothing() - { - $this->setExpectedException( - InvariantViolation::class, - 'SomeInputObject fields must be an array or a callable which returns such an array.' - ); - $this->schemaWithInputObject(new ObjectType([ - 'name' => 'SomeInputObject', - 'fields' => function () { - } - ])); - } - - /** - * @it rejects an Input Object type with a field function that returns empty - */ - public function testRejectsAnInputObjectTypeWithAFieldFunctionThatReturnsEmpty() - { - $this->setExpectedException( - InvariantViolation::class, - 'SomeInputObject fields must be an array or a callable which returns such an array.' - ); - $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - 'fields' => function () { - return new \stdClass(); - } - ])); - } - - // DESCRIBE: Type System: Input Object fields must not have resolvers - - /** - * @it accepts an Input Object type with no resolver - */ - public function testAcceptsAnInputObjectTypeWithNoResolver() - { - $schema = $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - 'fields' => [ - 'f' => [ - 'type' => Type::string(), - ] - ] - ])); - - $schema->assertValid(); - } - - /** - * @it accepts an Input Object type with null resolver - */ - public function testAcceptsAnInputObjectTypeWithNullResolver() - { - $schema = $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - 'fields' => [ - 'f' => [ - 'type' => Type::string(), - 'resolve' => null, - ] - ] - ])); - $schema->assertValid(); - } - - /** - * @it rejects an Input Object type with resolver function - */ - public function testRejectsAnInputObjectTypeWithResolverFunction() - { - $schema = $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - 'fields' => [ - 'f' => [ - 'type' => Type::string(), - 'resolve' => function () { - return 0; - }, - ] - ] - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeInputObject.f field type has a resolve property, but Input Types cannot define resolvers.' - ); - $schema->assertValid(); - } - - /** - * @it rejects an Input Object type with resolver constant - */ - public function testRejectsAnInputObjectTypeWithResolverConstant() - { - $schema = $this->schemaWithInputObject(new InputObjectType([ - 'name' => 'SomeInputObject', - 'fields' => [ - 'f' => [ - 'type' => Type::string(), - 'resolve' => new \stdClass(), - ] - ] - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeInputObject.f field type has a resolve property, but Input Types cannot define resolvers.' - ); - $schema->assertValid(); - } - - - // DESCRIBE: Type System: Object types must be assertable - - /** - * @it accepts an Object type with an isTypeOf function - */ - public function testAcceptsAnObjectTypeWithAnIsTypeOfFunction() - { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'AnotherObject', - 'isTypeOf' => function () { - return true; - }, - 'fields' => ['f' => ['type' => Type::string()]] - ])); - $schema->assertValid(); - } - - /** - * @it rejects an Object type with an incorrect type for isTypeOf - */ - public function testRejectsAnObjectTypeWithAnIncorrectTypeForIsTypeOf() - { - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'AnotherObject', - 'isTypeOf' => new \stdClass(), - 'fields' => ['f' => ['type' => Type::string()]] - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'AnotherObject must provide \'isTypeOf\' as a function' - ); - - $schema->assertValid(); - } - - // DESCRIBE: Type System: Interface types must be resolvable - - /** - * @it accepts an Interface type defining resolveType - */ - public function testAcceptsAnInterfaceTypeDefiningResolveType() - { - $AnotherInterfaceType = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => ['f' => ['type' => Type::string()]] - ]); - - $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'interfaces' => [$AnotherInterfaceType], - 'fields' => ['f' => ['type' => Type::string()]] - ])); - } - - /** - * @it accepts an Interface with implementing type defining isTypeOf - */ - public function testAcceptsAnInterfaceWithImplementingTypeDefiningIsTypeOf() - { - $InterfaceTypeWithoutResolveType = new InterfaceType([ - 'name' => 'InterfaceTypeWithoutResolveType', - 'fields' => ['f' => ['type' => Type::string()]] - ]); - - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'isTypeOf' => function () { - return true; - }, - 'interfaces' => [$InterfaceTypeWithoutResolveType], - 'fields' => ['f' => ['type' => Type::string()]] - ])); - - $schema->assertValid(); - } - - /** - * @it accepts an Interface type defining resolveType with implementing type defining isTypeOf - */ - public function testAcceptsAnInterfaceTypeDefiningResolveTypeWithImplementingTypeDefiningIsTypeOf() - { - $AnotherInterfaceType = new InterfaceType([ - 'name' => 'AnotherInterface', - 'fields' => ['f' => ['type' => Type::string()]] - ]); - - $schema = $this->schemaWithFieldType(new ObjectType([ - 'name' => 'SomeObject', - 'isTypeOf' => function () { - return true; - }, - 'interfaces' => [$AnotherInterfaceType], - 'fields' => ['f' => ['type' => Type::string()]] - ])); - - $schema->assertValid(); - } - - /** - * @it rejects an Interface type with an incorrect type for resolveType - */ - public function testRejectsAnInterfaceTypeWithAnIncorrectTypeForResolveType() - { - $type = new InterfaceType([ - 'name' => 'AnotherInterface', - 'resolveType' => new \stdClass(), - 'fields' => ['f' => ['type' => Type::string()]] - ]); - - $this->setExpectedException( - InvariantViolation::class, - 'AnotherInterface must provide "resolveType" as a function.' - ); - - $type->assertValid(); - } - - // DESCRIBE: Type System: Union types must be resolvable - - /** - * @it accepts a Union type defining resolveType - */ - public function testAcceptsAUnionTypeDefiningResolveType() - { - $schema = $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'types' => [$this->SomeObjectType], - ])); - $schema->assertValid(); - } - - /** - * @it accepts a Union of Object types defining isTypeOf - */ - public function testAcceptsAUnionOfObjectTypesDefiningIsTypeOf() - { - $schema = $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'types' => [$this->ObjectWithIsTypeOf], - ])); - - $schema->assertValid(); - } - - /** - * @it accepts a Union type defining resolveType of Object types defining isTypeOf - */ - public function testAcceptsAUnionTypeDefiningResolveTypeOfObjectTypesDefiningIsTypeOf() - { - $schema = $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'types' => [$this->ObjectWithIsTypeOf], - ])); - $schema->assertValid(); - } - - /** - * @it rejects a Union type with an incorrect type for resolveType - */ - public function testRejectsAUnionTypeWithAnIncorrectTypeForResolveType() - { - $schema = $this->schemaWithFieldType(new UnionType([ - 'name' => 'SomeUnion', - 'resolveType' => new \stdClass(), - 'types' => [$this->ObjectWithIsTypeOf], - ])); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeUnion must provide "resolveType" as a function.' - ); - - $schema->assertValid(); - } - - // DESCRIBE: Type System: Scalar types must be serializable - - /** - * @it accepts a Scalar type defining serialize - */ - public function testAcceptsAScalarTypeDefiningSerialize() - { - $schema = $this->schemaWithFieldType(new CustomScalarType([ - 'name' => 'SomeScalar', - 'serialize' => function () { - }, - ])); - $schema->assertValid(); - } + type TypeA { + field: String + } - /** - * @it rejects a Scalar type not defining serialize - */ - public function testRejectsAScalarTypeNotDefiningSerialize() - { - $schema = $this->schemaWithFieldType(new CustomScalarType([ - 'name' => 'SomeScalar', - ])); + type TypeB { + field: String + } - $this->setExpectedException( - InvariantViolation::class, - 'SomeScalar must provide "serialize" function. If this custom Scalar is also used as an input type, '. - 'ensure "parseValue" and "parseLiteral" functions are also provided.' - ); + union GoodUnion = + | TypeA + | TypeB + '); - $schema->assertValid(); + $this->assertEquals([], $schema->validate()); } /** - * @it rejects a Scalar type defining serialize with an incorrect type + * @it rejects a Union type with empty types */ - public function testRejectsAScalarTypeDefiningSerializeWithAnIncorrectType() + public function testRejectsAUnionTypeWithEmptyTypes() { - $schema = $this->schemaWithFieldType(new CustomScalarType([ - 'name' => 'SomeScalar', - 'serialize' => new \stdClass() - ])); + $schema = BuildSchema::build(' + type Query { + test: BadUnion + } - $this->setExpectedException( - InvariantViolation::class, - 'SomeScalar must provide "serialize" function. If this custom Scalar ' . - 'is also used as an input type, ensure "parseValue" and "parseLiteral" ' . - 'functions are also provided.' + union BadUnion + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Union type BadUnion must define one or more member types.', + 'locations' => [['line' => 6, 'column' => 7]], + ]] ); - - $schema->assertValid(); } /** - * @it accepts a Scalar type defining parseValue and parseLiteral + * @it rejects a Union type with duplicated member type */ - public function testAcceptsAScalarTypeDefiningParseValueAndParseLiteral() + public function testRejectsAUnionTypeWithDuplicatedMemberType() { - $schema = $this->schemaWithFieldType(new CustomScalarType([ - 'name' => 'SomeScalar', - 'serialize' => function () { - }, - 'parseValue' => function () { - }, - 'parseLiteral' => function () { - }, - ])); + $schema = BuildSchema::build(' + type Query { + test: BadUnion + } - $schema->assertValid(); - } + type TypeA { + field: String + } - /** - * @it rejects a Scalar type defining parseValue but not parseLiteral - */ - public function testRejectsAScalarTypeDefiningParseValueButNotParseLiteral() - { - $schema = $this->schemaWithFieldType(new CustomScalarType([ - 'name' => 'SomeScalar', - 'serialize' => function () { - }, - 'parseValue' => function () { - }, - ])); + type TypeB { + field: String + } - $this->setExpectedException( - InvariantViolation::class, - 'SomeScalar must provide both "parseValue" and "parseLiteral" functions.' + union BadUnion = + | TypeA + | TypeB + | TypeA + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Union type BadUnion can only include type TypeA once.', + 'locations' => [['line' => 15, 'column' => 11], ['line' => 17, 'column' => 11]], + ]] ); - $schema->assertValid(); } /** - * @it rejects a Scalar type defining parseLiteral but not parseValue + * @it rejects a Union type with non-Object members types */ - public function testRejectsAScalarTypeDefiningParseLiteralButNotParseValue() + public function testRejectsAUnionTypeWithNonObjectMembersType() { - $schema = $this->schemaWithFieldType(new CustomScalarType([ - 'name' => 'SomeScalar', - 'serialize' => function () { - }, - 'parseLiteral' => function () { - }, - ])); + $schema = BuildSchema::build(' + type Query { + test: BadUnion + } - $this->setExpectedException( - InvariantViolation::class, - 'SomeScalar must provide both "parseValue" and "parseLiteral" functions.' - ); + type TypeA { + field: String + } - $schema->assertValid(); - } + type TypeB { + field: String + } - /** - * @it rejects a Scalar type defining parseValue and parseLiteral with an incorrect type - */ - public function testRejectsAScalarTypeDefiningParseValueAndParseLiteralWithAnIncorrectType() - { - $schema = $this->schemaWithFieldType(new CustomScalarType([ - 'name' => 'SomeScalar', - 'serialize' => function () { - }, - 'parseValue' => new \stdClass(), - 'parseLiteral' => new \stdClass(), - ])); + union BadUnion = + | TypeA + | String + | TypeB + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Union type BadUnion can only include Object types, ' . + 'it cannot include String.', + 'locations' => [['line' => 16, 'column' => 11]], + ]] - $this->setExpectedException( - InvariantViolation::class, - 'SomeScalar must provide both "parseValue" and "parseLiteral" functions.' ); - $schema->assertValid(); - } + $badUnionMemberTypes = [ + Type::string(), + Type::nonNull($this->SomeObjectType), + Type::listOf($this->SomeObjectType), + $this->SomeInterfaceType, + $this->SomeUnionType, + $this->SomeEnumType, + $this->SomeInputObjectType, + ]; + foreach($badUnionMemberTypes as $memberType) { + $badSchema = $this->schemaWithFieldType( + new UnionType(['name' => 'BadUnion', 'types' => [$memberType]]) + ); + $this->assertContainsValidationMessage( + $badSchema->validate(), + [[ + 'message' => 'Union type BadUnion can only include Object types, ' . + "it cannot include ". Utils::printSafe($memberType) . ".", + ]] + ); + } + } - // DESCRIBE: Type System: Enum types must be well defined + // DESCRIBE: Type System: Input Objects must have fields /** - * @it accepts a well defined Enum type with empty value definition + * @it accepts an Input Object type with fields */ - public function testAcceptsAWellDefinedEnumTypeWithEmptyValueDefinition() + public function testAcceptsAnInputObjectTypeWithFields() { - $type = new EnumType([ - 'name' => 'SomeEnum', - 'values' => [ - 'FOO' => [], - 'BAR' => [], - ] - ]); + $schema = BuildSchema::build(' + type Query { + field(arg: SomeInputObject): String + } - $type->assertValid(); + input SomeInputObject { + field: String + } + '); + $this->assertEquals([], $schema->validate()); } - // TODO: accepts a well defined Enum type with internal value definition - /** - * @it accepts a well defined Enum type with internal value definition + * @it rejects an Input Object type with missing fields */ - public function testAcceptsAWellDefinedEnumTypeWithInternalValueDefinition() + public function testRejectsAnInputObjectTypeWithMissingFields() { - $type = new EnumType([ - 'name' => 'SomeEnum', - 'values' => [ - 'FOO' => ['value' => 10], - 'BAR' => ['value' => 20], - ] - ]); - $type->assertValid(); + $schema = BuildSchema::build(' + type Query { + field(arg: SomeInputObject): String + } + + input SomeInputObject + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Input Object type SomeInputObject must define one or more fields.', + 'locations' => [['line' => 6, 'column' => 7]], + ]] + ); } /** - * @it rejects an Enum type without values + * @it rejects an Input Object type with incorrectly typed fields */ - public function testRejectsAnEnumTypeWithoutValues() + public function testRejectsAnInputObjectTypeWithIncorrectlyTypedFields() { - $type = new EnumType([ - 'name' => 'SomeEnum', - ]); + $schema = BuildSchema::build(' + type Query { + field(arg: SomeInputObject): String + } + + type SomeObject { + field: String + } - $this->setExpectedException( - InvariantViolation::class, - 'SomeEnum values must be an array.' + union SomeUnion = SomeObject + + input SomeInputObject { + badObject: SomeObject + badUnion: SomeUnion + goodInputObject: SomeInputObject + } + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of SomeInputObject.badObject must be Input Type but got: SomeObject.', + 'locations' => [['line' => 13, 'column' => 20]], + ],[ + 'message' => 'The type of SomeInputObject.badUnion must be Input Type but got: SomeUnion.', + 'locations' => [['line' => 14, 'column' => 19]], + ]] ); - - $type->assertValid(); } + // DESCRIBE: Type System: Enum types must be well defined + /** - * @it rejects an Enum type with empty values + * @it rejects an Enum type without values */ - public function testRejectsAnEnumTypeWithEmptyValues() + public function testRejectsAnEnumTypeWithoutValues() { - $type = new EnumType([ - 'name' => 'SomeEnum', - 'values' => [] - ]); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeEnum values must be not empty.' + $schema = BuildSchema::build(' + type Query { + field: SomeEnum + } + + enum SomeEnum + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Enum type SomeEnum must define one or more values.', + 'locations' => [['line' => 6, 'column' => 7]], + ]] ); - - $type->assertValid(); } /** - * @it rejects an Enum type with incorrectly typed values + * @it rejects an Enum type with duplicate values */ - public function testRejectsAnEnumTypeWithIncorrectlyTypedValues() + public function testRejectsAnEnumTypeWithDuplicateValues() { - $type = new EnumType([ - 'name' => 'SomeEnum', - 'values' => [ - ['FOO' => 10] - ] - ]); - - $this->setExpectedException( - InvariantViolation::class, - 'SomeEnum values must be an array with value names as keys.' + $schema = BuildSchema::build(' + type Query { + field: SomeEnum + } + + enum SomeEnum { + SOME_VALUE + SOME_VALUE + } + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Enum type SomeEnum can include value SOME_VALUE only once.', + 'locations' => [['line' => 7, 'column' => 9], ['line' => 8, 'column' => 9]], + ]] ); - $type->assertValid(); - } - - public function invalidEnumValueName() - { - return [ - ['#value', 'SomeEnum has value with invalid name: #value (Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "#value" does not.)'], - ['true', 'SomeEnum: "true" can not be used as an Enum value.'], - ['false', 'SomeEnum: "false" can not be used as an Enum value.'], - ['null', 'SomeEnum: "null" can not be used as an Enum value.'], - ]; } public function testDoesNotAllowIsDeprecatedWithoutDeprecationReasonOnEnum() @@ -1684,14 +864,28 @@ public function testDoesNotAllowIsDeprecatedWithoutDeprecationReasonOnEnum() $enum->assertValid(); } - private function enumValue($name) + private function schemaWithEnum($name) { - return new EnumType([ - 'name' => 'SomeEnum', - 'values' => [ - $name => [] - ] - ]); + return $this->schemaWithFieldType( + new EnumType([ + 'name' => 'SomeEnum', + 'values' => [ + $name => [] + ] + ]) + ); + } + + public function invalidEnumValueName() + { + return [ + ['#value', 'Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "#value" does not.'], + ['1value', 'Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "1value" does not.'], + ['KEBAB-CASE', 'Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but "KEBAB-CASE" does not.'], + ['false', 'Enum type SomeEnum cannot include value: false.'], + ['true', 'Enum type SomeEnum cannot include value: true.'], + ['null', 'Enum type SomeEnum cannot include value: null.'], + ]; } /** @@ -1700,10 +894,14 @@ private function enumValue($name) */ public function testRejectsAnEnumTypeWithIncorrectlyNamedValues($name, $expectedMessage) { - $enum = $this->enumValue($name); + $schema = $this->schemaWithEnum($name); - $this->setExpectedException(InvariantViolation::class, $expectedMessage); - $enum->assertValid(); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => $expectedMessage, + ]] + ); } // DESCRIBE: Type System: Object fields must have output types @@ -1715,12 +913,10 @@ public function testAcceptsAnOutputTypeAsNnObjectFieldType() { foreach ($this->outputTypes as $type) { $schema = $this->schemaWithObjectFieldOfType($type); - $schema->assertValid(); + $this->assertEquals([], $schema->validate()); } } - // TODO: rejects an empty Object field type - /** * @it rejects an empty Object field type */ @@ -1728,12 +924,12 @@ public function testRejectsAnEmptyObjectFieldType() { $schema = $this->schemaWithObjectFieldOfType(null); - $this->setExpectedException( - InvariantViolation::class, - 'BadObject.badField field type must be Output Type but got: null' + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of BadObject.badField must be Output Type but got: null.', + ]] ); - - $schema->assertValid(); } /** @@ -1744,97 +940,124 @@ public function testRejectsANonOutputTypeAsAnObjectFieldType() foreach ($this->notOutputTypes as $type) { $schema = $this->schemaWithObjectFieldOfType($type); - try { - $schema->assertValid(); - $this->fail('Expected exception not thrown for ' . Utils::printSafe($type)); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'BadObject.badField field type must be Output Type but got: ' . Utils::printSafe($type), - $e->getMessage() - ); - } + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of BadObject.badField must be Output Type but got: ' . Utils::printSafe($type) . '.', + ]] + ); } } - // DESCRIBE: Type System: Object fields must have valid resolve values - /** - * @it accepts a lambda as an Object field resolver + * @it rejects with relevant locations for a non-output type as an Object field type */ - public function testAcceptsALambdaAsAnObjectFieldResolver() + public function testRejectsWithReleventLocationsForANonOutputTypeAsAnObjectFieldType() { - $schema = $this->schemaWithObjectWithFieldResolver(function() {return [];}); - $schema->assertValid(); + $schema = BuildSchema::build(' + type Query { + field: [SomeInputObject] + } + + input SomeInputObject { + field: String + } + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of Query.field must be Output Type but got: [SomeInputObject].', + 'locations' => [['line' => 3, 'column' => 16]], + ]] + ); } + // DESCRIBE: Type System: Objects can only implement unique interfaces + /** - * @it rejects an empty Object field resolver + * @it rejects an Object implementing a non-Interface type */ - public function testRejectsAnEmptyObjectFieldResolver() + public function testRejectsAnObjectImplementingANonInterfaceType() { - $schema = $this->schemaWithObjectWithFieldResolver([]); - - $this->setExpectedException( - InvariantViolation::class, - 'BadResolver.badField field resolver must be a function if provided, but got: []' + $schema = BuildSchema::build(' + type Query { + field: BadObject + } + + input SomeInputObject { + field: String + } + + type BadObject implements SomeInputObject { + field: String + } + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Type BadObject must only implement Interface types, it cannot implement SomeInputObject.', + 'locations' => [['line' => 10, 'column' => 33]], + ]] ); - - $schema->assertValid(); } /** - * @it rejects a constant scalar value resolver + * @it rejects an Object implementing the same interface twice */ - public function testRejectsAConstantScalarValueResolver() + public function testRejectsAnObjectImplementingTheSameInterfaceTwice() { - $schema = $this->schemaWithObjectWithFieldResolver(0); - $this->setExpectedException( - InvariantViolation::class, - 'BadResolver.badField field resolver must be a function if provided, but got: 0' + $schema = BuildSchema::build(' + type Query { + field: AnotherObject + } + + interface AnotherInterface { + field: String + } + + type AnotherObject implements AnotherInterface, AnotherInterface { + field: String + } + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Type AnotherObject can only implement AnotherInterface once.', + 'locations' => [['line' => 10, 'column' => 37], ['line' => 10, 'column' => 55]], + ]] ); - $schema->assertValid(); - } - - // DESCRIBE: Type System: Unions must represent Object types - - /** - * @it accepts a Union of an Object Type - */ - public function testAcceptsAUnionOfAnObjectType() - { - $schema = $this->schemaWithUnionOfType($this->SomeObjectType); - $schema->assertValid(); } /** - * @it rejects a Union of a non-Object type + * @it rejects an Object implementing the same interface twice due to extension */ - public function testRejectsAUnionOfANonObjectType() + public function testRejectsAnObjectImplementingTheSameInterfaceTwiceDueToExtension() { - $notObjectTypes = $this->withModifiers([ - $this->SomeScalarType, - $this->SomeEnumType, - $this->SomeInterfaceType, - $this->SomeUnionType, - $this->SomeInputObjectType, - ]); - foreach ($notObjectTypes as $type) { - $schema = $this->schemaWithUnionOfType($type); - try { - $schema->assertValid(); - $this->fail('Expected exception not thrown for type: ' . $type); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'BadUnion may only contain Object types, it cannot contain: ' . $type . '.', - $e->getMessage() - ); - } - } - - // "BadUnion may only contain Object types, it cannot contain: $type." + $this->markTestIncomplete('extend does not work this way (yet).'); + $schema = BuildSchema::build(' + type Query { + field: AnotherObject + } + + interface AnotherInterface { + field: String + } + + type AnotherObject implements AnotherInterface { + field: String + } + + extend type AnotherObject implements AnotherInterface + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'Type AnotherObject can only implement AnotherInterface once.', + 'locations' => [['line' => 10, 'column' => 37], ['line' => 14, 'column' => 38]], + ]] + ); } - // DESCRIBE: Type System: Interface fields must have output types /** @@ -1844,7 +1067,7 @@ public function testAcceptsAnOutputTypeAsAnInterfaceFieldType() { foreach ($this->outputTypes as $type) { $schema = $this->schemaWithInterfaceFieldOfType($type); - $schema->assertValid(); + $this->assertEquals([], $schema->validate()); } } @@ -1854,13 +1077,12 @@ public function testAcceptsAnOutputTypeAsAnInterfaceFieldType() public function testRejectsAnEmptyInterfaceFieldType() { $schema = $this->schemaWithInterfaceFieldOfType(null); - - $this->setExpectedException( - InvariantViolation::class, - 'BadInterface.badField field type must be Output Type but got: null' + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of BadInterface.badField must be Output Type but got: null.', + ]] ); - - $schema->assertValid(); } /** @@ -1871,18 +1093,41 @@ public function testRejectsANonOutputTypeAsAnInterfaceFieldType() foreach ($this->notOutputTypes as $type) { $schema = $this->schemaWithInterfaceFieldOfType($type); - try { - $schema->assertValid(); - $this->fail('Expected exception not thrown for type ' . $type); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'BadInterface.badField field type must be Output Type but got: ' . Utils::printSafe($type), - $e->getMessage() - ); - } + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of BadInterface.badField must be Output Type but got: ' . Utils::printSafe($type) . '.', + ]] + ); } } + /** + * @it rejects a non-output type as an Interface field type with locations + */ + public function testRejectsANonOutputTypeAsAnInterfaceFieldTypeWithLocations() + { + $schema = BuildSchema::build(' + type Query { + field: SomeInterface + } + + interface SomeInterface { + field: SomeInputObject + } + + input SomeInputObject { + foo: String + } + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of SomeInterface.field must be Output Type but got: SomeInputObject.', + 'locations' => [['line' => 7, 'column' => 16]], + ]] + ); + } // DESCRIBE: Type System: Field arguments must have input types @@ -1893,7 +1138,7 @@ public function testAcceptsAnInputTypeAsAFieldArgType() { foreach ($this->inputTypes as $type) { $schema = $this->schemaWithArgOfType($type); - $schema->assertValid(); + $this->assertEquals([], $schema->validate()); } } @@ -1903,9 +1148,12 @@ public function testAcceptsAnInputTypeAsAFieldArgType() public function testRejectsAnEmptyFieldArgType() { $schema = $this->schemaWithArgOfType(null); - - $this->setExpectedException(InvariantViolation::class, 'BadObject.badField(badArg): argument type must be Input Type but got: null'); - $schema->assertValid(); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of BadObject.badField(badArg:) must be Input Type but got: null.', + ]] + ); } /** @@ -1915,18 +1163,37 @@ public function testRejectsANonInputTypeAsAFieldArgType() { foreach ($this->notInputTypes as $type) { $schema = $this->schemaWithArgOfType($type); - try { - $schema->assertValid(); - $this->fail('Expected exception not thrown for type ' . $type); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'BadObject.badField(badArg): argument type must be Input Type but got: ' . Utils::printSafe($type), - $e->getMessage() - ); - } + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of BadObject.badField(badArg:) must be Input Type but got: ' . Utils::printSafe($type) . '.', + ]] + ); } } + /** + * @it rejects a non-input type as a field arg with locations + */ + public function testANonInputTypeAsAFieldArgWithLocations() + { + $schema = BuildSchema::build(' + type Query { + test(arg: SomeObject): String + } + + type SomeObject { + foo: String + } + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of Query.test(arg:) must be Input Type but got: SomeObject.', + 'locations' => [['line' => 3, 'column' => 19]], + ]] + ); + } // DESCRIBE: Type System: Input Object fields must have input types @@ -1937,7 +1204,7 @@ public function testAcceptsAnInputTypeAsAnInputFieldType() { foreach ($this->inputTypes as $type) { $schema = $this->schemaWithInputFieldOfType($type); - $schema->assertValid(); + $this->assertEquals([], $schema->validate()); } } @@ -1947,12 +1214,12 @@ public function testAcceptsAnInputTypeAsAnInputFieldType() public function testRejectsAnEmptyInputFieldType() { $schema = $this->schemaWithInputFieldOfType(null); - - $this->setExpectedException( - InvariantViolation::class, - 'BadInputObject.badField field type must be Input Type but got: null.' + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of BadInputObject.badField must be Input Type but got: null.', + ]] ); - $schema->assertValid(); } /** @@ -1962,126 +1229,40 @@ public function testRejectsANonInputTypeAsAnInputFieldType() { foreach ($this->notInputTypes as $type) { $schema = $this->schemaWithInputFieldOfType($type); - try { - $schema->assertValid(); - $this->fail('Expected exception not thrown for type ' . $type); - } catch (InvariantViolation $e) { - $this->assertEquals( - "BadInputObject.badField field type must be Input Type but got: " . Utils::printSafe($type) . ".", - $e->getMessage() - ); - } - } - } - - - // DESCRIBE: Type System: List must accept only types - - /** - * @it accepts an type as item type of list - */ - public function testAcceptsAnTypeAsItemTypeOfList() - { - $types = $this->withModifiers([ - Type::string(), - $this->SomeScalarType, - $this->SomeObjectType, - $this->SomeUnionType, - $this->SomeInterfaceType, - $this->SomeEnumType, - $this->SomeInputObjectType, - ]); - - foreach ($types as $type) { - try { - Type::listOf($type); - } catch (\Exception $e) { - throw new \Exception("Expection thrown for type $type: {$e->getMessage()}", null, $e); - } - } - } - - /** - * @it rejects a non-type as item type of list - */ - public function testRejectsANonTypeAsItemTypeOfList() - { - $notTypes = [ - [], - new \stdClass(), - 'String', - 10, - null, - true, - false, - // TODO: function() {} - ]; - foreach ($notTypes as $type) { - try { - Type::listOf($type); - $this->fail("Expected exception not thrown for: " . Utils::printSafe($type)); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Expected '. Utils::printSafe($type) . ' to be a GraphQL type.', - $e->getMessage() - ); - } - } - } - - - // DESCRIBE: Type System: NonNull must only accept non-nullable types - - /** - * @it accepts an type as nullable type of non-null - */ - public function testAcceptsAnTypeAsNullableTypeOfNonNull() - { - $nullableTypes = [ - Type::string(), - $this->SomeScalarType, - $this->SomeObjectType, - $this->SomeUnionType, - $this->SomeInterfaceType, - $this->SomeEnumType, - $this->SomeInputObjectType, - Type::listOf(Type::string()), - Type::listOf(Type::nonNull(Type::string())), - ]; - foreach ($nullableTypes as $type) { - try { - Type::nonNull($type); - } catch (\Exception $e) { - throw new \Exception("Exception thrown for type $type: " . $e->getMessage(), null, $e); - } + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of BadInputObject.badField must be Input Type but got: ' . Utils::printSafe($type) . '.', + ]] + ); } } /** - * @it rejects a non-type as nullable type of non-null + * @it rejects a non-input type as an input object field with locations */ - public function testRejectsANonTypeAsNullableTypeOfNonNull() + public function testRejectsANonInputTypeAsAnInputObjectFieldWithLocations() { - $notNullableTypes = [ - Type::nonNull(Type::string()), - [], - new \stdClass(), - 'String', - null, - true, - false - ]; - foreach ($notNullableTypes as $type) { - try { - Type::nonNull($type); - $this->fail("Expected exception not thrown for: " . Utils::printSafe($type)); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Expected ' . Utils::printSafe($type) . ' to be a GraphQL nullable type.', - $e->getMessage() - ); - } - } + $schema = BuildSchema::build(' + type Query { + test(arg: SomeInputObject): String + } + + input SomeInputObject { + foo: SomeObject + } + + type SomeObject { + bar: String + } + '); + $this->assertContainsValidationMessage( + $schema->validate(), + [[ + 'message' => 'The type of SomeInputObject.foo must be Input Type but got: SomeObject.', + 'locations' => [['line' => 7, 'column' => 14]], + ]] + ); } // DESCRIBE: Objects must adhere to Interface they implement @@ -2183,9 +1364,11 @@ interface AnotherInterface { $this->assertContainsValidationMessage( $schema->validate(), - '"AnotherInterface" expects field "field" but ' . - '"AnotherObject" does not provide it.', - [['line' => 7, 'column' => 9], ['line' => 10, 'column' => 7]] + [[ + 'message' => 'Interface field AnotherInterface.field expected but ' . + 'AnotherObject does not provide it.', + 'locations' => [['line' => 7, 'column' => 9], ['line' => 10, 'column' => 7]], + ]] ); } @@ -2210,9 +1393,11 @@ interface AnotherInterface { $this->assertContainsValidationMessage( $schema->validate(), - 'AnotherInterface.field expects type "String" but ' . - 'AnotherObject.field is type "Int".', - [['line' => 7, 'column' => 31], ['line' => 11, 'column' => 31]] + [[ + 'message' => 'Interface field AnotherInterface.field expects type String but ' . + 'AnotherObject.field is type Int.', + 'locations' => [['line' => 7, 'column' => 31], ['line' => 11, 'column' => 31]], + ]] ); } @@ -2240,9 +1425,11 @@ interface AnotherInterface { $this->assertContainsValidationMessage( $schema->validate(), - 'AnotherInterface.field expects type "A" but ' . - 'AnotherObject.field is type "B".', - [['line' => 10, 'column' => 16], ['line' => 14, 'column' => 16]] + [[ + 'message' => 'Interface field AnotherInterface.field expects type A but ' . + 'AnotherObject.field is type B.', + 'locations' => [['line' => 10, 'column' => 16], ['line' => 14, 'column' => 16]], + ]] ); } @@ -2265,10 +1452,7 @@ interface AnotherInterface { } '); - $this->assertEquals( - [], - $schema->validate() - ); + $this->assertEquals([], $schema->validate()); } /** @@ -2296,10 +1480,7 @@ interface AnotherInterface { } '); - $this->assertEquals( - [], - $schema->validate() - ); + $this->assertEquals([],$schema->validate()); } /** @@ -2323,9 +1504,11 @@ interface AnotherInterface { $this->assertContainsValidationMessage( $schema->validate(), - 'AnotherInterface.field expects argument "input" but ' . - 'AnotherObject.field does not provide it.', - [['line' => 7, 'column' => 15], ['line' => 11, 'column' => 9]] + [[ + 'message' => 'Interface field argument AnotherInterface.field(input:) expected ' . + 'but AnotherObject.field does not provide it.', + 'locations' => [['line' => 7, 'column' => 15], ['line' => 11, 'column' => 9]], + ]] ); } @@ -2350,9 +1533,11 @@ interface AnotherInterface { $this->assertContainsValidationMessage( $schema->validate(), - 'AnotherInterface.field(input:) expects type "String" but ' . - 'AnotherObject.field(input:) is type "Int".', - [['line' => 7, 'column' => 22], ['line' => 11, 'column' => 22]] + [[ + 'message' => 'Interface field argument AnotherInterface.field(input:) expects ' . + 'type String but AnotherObject.field(input:) is type Int.', + 'locations' => [['line' => 7, 'column' => 22], ['line' => 11, 'column' => 22]], + ]] ); } @@ -2377,15 +1562,15 @@ interface AnotherInterface { $this->assertContainsValidationMessage( $schema->validate(), - 'AnotherInterface.field expects type "String" but ' . - 'AnotherObject.field is type "Int".', - [['line' => 7, 'column' => 31], ['line' => 11, 'column' => 28]] - ); - $this->assertContainsValidationMessage( - $schema->validate(), - 'AnotherInterface.field(input:) expects type "String" but ' . - 'AnotherObject.field(input:) is type "Int".', - [['line' => 7, 'column' => 22], ['line' => 11, 'column' => 22]] + [[ + 'message' => 'Interface field AnotherInterface.field expects type String but ' . + 'AnotherObject.field is type Int.', + 'locations' => [['line' => 7, 'column' => 31], ['line' => 11, 'column' => 28]], + ], [ + 'message' => 'Interface field argument AnotherInterface.field(input:) expects ' . + 'type String but AnotherObject.field(input:) is type Int.', + 'locations' => [['line' => 7, 'column' => 22], ['line' => 11, 'column' => 22]], + ]] ); } @@ -2410,17 +1595,19 @@ interface AnotherInterface { $this->assertContainsValidationMessage( $schema->validate(), - 'AnotherObject.field(anotherInput:) is of required type ' . - '"String!" but is not also provided by the interface ' . - 'AnotherInterface.field.', - [['line' => 11, 'column' => 44], ['line' => 7, 'column' => 9]] + [[ + 'message' => 'Object field argument AnotherObject.field(anotherInput:) is of ' . + 'required type String! but is not also provided by the Interface ' . + 'field AnotherInterface.field.', + 'locations' => [['line' => 11, 'column' => 44], ['line' => 7, 'column' => 9]], + ]] ); } /** - * @it accepts an Object with an equivalently modified Interface field type + * @it accepts an Object with an equivalently wrapped Interface field type */ - public function testAcceptsAnObjectWithAnEquivalentlyModifiedInterfaceFieldType() + public function testAcceptsAnObjectWithAnEquivalentlyWrappedInterfaceFieldType() { $schema = BuildSchema::build(' type Query { @@ -2436,10 +1623,7 @@ interface AnotherInterface { } '); - $this->assertEquals( - [], - $schema->validate() - ); + $this->assertEquals([], $schema->validate()); } /** @@ -2463,9 +1647,11 @@ interface AnotherInterface { $this->assertContainsValidationMessage( $schema->validate(), - 'AnotherInterface.field expects type "[String]" but ' . - 'AnotherObject.field is type "String".', - [['line' => 7, 'column' => 16], ['line' => 11, 'column' => 16]] + [[ + 'message' => 'Interface field AnotherInterface.field expects type [String] ' . + 'but AnotherObject.field is type String.', + 'locations' => [['line' => 7, 'column' => 16], ['line' => 11, 'column' => 16]], + ]] ); } @@ -2490,9 +1676,11 @@ interface AnotherInterface { $this->assertContainsValidationMessage( $schema->validate(), - 'AnotherInterface.field expects type "String" but ' . - 'AnotherObject.field is type "[String]".', - [['line' => 7, 'column' => 16], ['line' => 11, 'column' => 16]] + [[ + 'message' => 'Interface field AnotherInterface.field expects type String but ' . + 'AnotherObject.field is type [String].', + 'locations' => [['line' => 7, 'column' => 16], ['line' => 11, 'column' => 16]], + ]] ); } @@ -2515,10 +1703,7 @@ interface AnotherInterface { } '); - $this->assertEquals( - [], - $schema->validate() - ); + $this->assertEquals([], $schema->validate()); } /** @@ -2542,33 +1727,12 @@ interface AnotherInterface { $this->assertContainsValidationMessage( $schema->validate(), - 'AnotherInterface.field expects type "String!" but ' . - 'AnotherObject.field is type "String".', - [['line' => 7, 'column' => 16], ['line' => 11, 'column' => 16]] - ); - } - - /** - * @it does not allow isDeprecated without deprecationReason on field - */ - public function testDoesNotAllowIsDeprecatedWithoutDeprecationReasonOnField() - { - $OldObject = new ObjectType([ - 'name' => 'OldObject', - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'isDeprecated' => true - ] - ] - ]); - - $schema = $this->schemaWithFieldType($OldObject); - $this->setExpectedException( - InvariantViolation::class, - 'OldObject.field should provide "deprecationReason" instead of "isDeprecated".' + [[ + 'message' => 'Interface field AnotherInterface.field expects type String! ' . + 'but AnotherObject.field is type String.', + 'locations' => [['line' => 7, 'column' => 16], ['line' => 11, 'column' => 16]], + ]] ); - $schema->assertValid(); } public function testRejectsDifferentInstancesOfTheSameType() @@ -2613,26 +1777,6 @@ private function assertEachCallableThrows($closures, $expectedError) } } - private function assertWarnsOnce($closures, $expectedError) - { - $warned = false; - - foreach ($closures as $index => $factory) { - if (!$warned) { - try { - $factory(); - $this->fail('Expected exception not thrown for entry ' . $index); - } catch (\PHPUnit_Framework_Error_Warning $e) { - $warned = true; - $this->assertEquals($expectedError, $e->getMessage(), 'Error in callable #' . $index); - } - } else { - // Should not throw - $factory(); - } - } - } - private function schemaWithFieldType($type) { return new Schema([ @@ -2644,23 +1788,6 @@ private function schemaWithFieldType($type) ]); } - private function schemaWithInputObject($inputObjectType) - { - return new Schema([ - 'query' => new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'f' => [ - 'type' => Type::string(), - 'args' => [ - 'input' => ['type' => $inputObjectType] - ] - ] - ] - ]) - ]); - } - private function schemaWithObjectFieldOfType($fieldType) { $BadObjectType = new ObjectType([ @@ -2681,28 +1808,6 @@ private function schemaWithObjectFieldOfType($fieldType) ]); } - private function schemaWithObjectWithFieldResolver($resolveValue) - { - $BadResolverType = new ObjectType([ - 'name' => 'BadResolver', - 'fields' => [ - 'badField' => [ - 'type' => Type::string(), - 'resolve' => $resolveValue - ] - ] - ]); - - return new Schema([ - 'query' => new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'f' => ['type' => $BadResolverType] - ] - ]) - ]); - } - private function withModifiers($types) { return array_merge( @@ -2719,22 +1824,6 @@ private function withModifiers($types) ); } - private function schemaWithUnionOfType($type) - { - $BadUnionType = new UnionType([ - 'name' => 'BadUnion', - 'types' => [$type], - ]); - return new Schema([ - 'query' => new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'f' => ['type' => $BadUnionType] - ] - ]) - ]); - } - private function schemaWithInterfaceFieldOfType($fieldType) { $BadInterfaceType = new InterfaceType([ @@ -2751,18 +1840,6 @@ private function schemaWithInterfaceFieldOfType($fieldType) 'f' => ['type' => $BadInterfaceType] ] ]), - // Have to add types implementing interfaces to bypass the "could not find implementers" exception - 'types' => [ - new ObjectType([ - 'name' => 'BadInterfaceImplementer', - 'fields' => [ - 'badField' => ['type' => $fieldType] - ], - 'interfaces' => [$BadInterfaceType], - 'isTypeOf' => function() {} - ]), - $this->SomeObjectType - ] ]); } diff --git a/tests/Utils/AssertValidNameTest.php b/tests/Utils/AssertValidNameTest.php new file mode 100644 index 000000000..50899f7aa --- /dev/null +++ b/tests/Utils/AssertValidNameTest.php @@ -0,0 +1,47 @@ +setExpectedException( + Error::class, + '"__bad" must not begin with "__", which is reserved by GraphQL introspection.' + ); + Utils::assertValidName('__bad'); + } + + /** + * @it throws for non-strings + */ + public function testThrowsForNonStrings() + { + $this->setExpectedException( + InvariantViolation::class, + 'Expected string' + ); + Utils::assertValidName([]); + } + + /** + * @it throws for names with invalid characters + */ + public function testThrowsForNamesWithInvalidCharacters() + { + $this->setExpectedException( + Error::class, + 'Names must match' + ); + Utils::assertValidName('>--()-->'); + } +} From fde7df534d78551d97049be8136de52a4d6c0a42 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Thu, 15 Feb 2018 12:22:29 +0100 Subject: [PATCH 38/50] Robust type info There are possibilities for errors during validation if a schema is not valid when provided to TypeInfo. Most checks for validity existed, but some did not. This asks flow to make those checks required and adds the remaining ones. Important now that we allow construction of invalid schema. ref: graphql/graphql-js#1143 --- src/Utils/TypeInfo.php | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/Utils/TypeInfo.php b/src/Utils/TypeInfo.php index cb002349a..34a48cb2c 100644 --- a/src/Utils/TypeInfo.php +++ b/src/Utils/TypeInfo.php @@ -4,6 +4,7 @@ use GraphQL\Error\InvariantViolation; use GraphQL\Error\Warning; use GraphQL\Language\AST\FieldNode; +use GraphQL\Language\AST\ListType; use GraphQL\Language\AST\ListTypeNode; use GraphQL\Language\AST\NamedTypeNode; use GraphQL\Language\AST\Node; @@ -296,6 +297,10 @@ function enter(Node $node) { $schema = $this->schema; + // Note: many of the types below are explicitly typed as "mixed" to drop + // any assumptions of a valid schema to ensure runtime types are properly + // checked before continuing since TypeInfo is used as part of validation + // which occurs before guarantees of schema and document validity. switch ($node->kind) { case NodeKind::SELECTION_SET: $namedType = Type::getNamedType($this->getType()); @@ -308,8 +313,12 @@ function enter(Node $node) if ($parentType) { $fieldDef = self::getFieldDefinition($schema, $parentType, $node); } - $this->fieldDefStack[] = $fieldDef; // push - $this->typeStack[] = $fieldDef ? $fieldDef->getType() : null; // push + $fieldType = null; + if ($fieldDef) { + $fieldType = $fieldDef->getType(); + } + $this->fieldDefStack[] = $fieldDef; + $this->typeStack[] = Type::isOutputType($fieldType) ? $fieldType : null; break; case NodeKind::DIRECTIVE: @@ -325,14 +334,14 @@ function enter(Node $node) } else if ($node->operation === 'subscription') { $type = $schema->getSubscriptionType(); } - $this->typeStack[] = $type; // push + $this->typeStack[] = Type::isOutputType($type) ? $type : null; break; case NodeKind::INLINE_FRAGMENT: case NodeKind::FRAGMENT_DEFINITION: $typeConditionNode = $node->typeCondition; $outputType = $typeConditionNode ? self::typeFromAST($schema, $typeConditionNode) : Type::getNamedType($this->getType()); - $this->typeStack[] = Type::isOutputType($outputType) ? $outputType : null; // push + $this->typeStack[] = Type::isOutputType($outputType) ? $outputType : null; break; case NodeKind::VARIABLE_DEFINITION: @@ -350,23 +359,28 @@ function enter(Node $node) } } $this->argument = $argDef; - $this->inputTypeStack[] = $argType; // push + $this->inputTypeStack[] = Type::isInputType($argType) ? $argType : null; break; case NodeKind::LST: $listType = Type::getNullableType($this->getInputType()); - $this->inputTypeStack[] = ($listType instanceof ListOfType ? $listType->getWrappedType() : null); // push + $itemType = null; + if ($itemType instanceof ListType) { + $itemType = $listType->getWrappedType(); + } + $this->inputTypeStack[] = Type::isInputType($itemType) ? $itemType : null; break; case NodeKind::OBJECT_FIELD: $objectType = Type::getNamedType($this->getInputType()); $fieldType = null; + $inputFieldType = null; if ($objectType instanceof InputObjectType) { $tmp = $objectType->getFields(); $inputField = isset($tmp[$node->name->value]) ? $tmp[$node->name->value] : null; - $fieldType = $inputField ? $inputField->getType() : null; + $inputFieldType = $inputField ? $inputField->getType() : null; } - $this->inputTypeStack[] = $fieldType; + $this->inputTypeStack[] = Type::isInputType($inputFieldType) ? $inputFieldType : null; break; case NodeKind::ENUM: From 949b85367826b30dd7c19d5578281fed5dc93fd4 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Thu, 15 Feb 2018 13:37:45 +0100 Subject: [PATCH 39/50] Add experimental support for parsing variable definitions in fragments ref: graphql/graphql-js#1141 --- src/Language/AST/FragmentDefinitionNode.php | 10 +++- src/Language/Parser.php | 26 +++++++++- src/Language/Printer.php | 6 ++- src/Language/Visitor.php | 10 +++- tests/Language/ParserTest.php | 15 +++++- tests/Language/PrinterTest.php | 22 +++++++++ tests/Language/VisitorTest.php | 54 +++++++++++++++++++++ 7 files changed, 137 insertions(+), 6 deletions(-) diff --git a/src/Language/AST/FragmentDefinitionNode.php b/src/Language/AST/FragmentDefinitionNode.php index 04193b154..4543b03f9 100644 --- a/src/Language/AST/FragmentDefinitionNode.php +++ b/src/Language/AST/FragmentDefinitionNode.php @@ -10,13 +10,21 @@ class FragmentDefinitionNode extends Node implements ExecutableDefinitionNode, H */ public $name; + /** + * Note: fragment variable definitions are experimental and may be changed + * or removed in the future. + * + * @var VariableDefinitionNode[]|NodeList + */ + public $variableDefinitions; + /** * @var NamedTypeNode */ public $typeCondition; /** - * @var DirectiveNode[] + * @var DirectiveNode[]|NodeList */ public $directives; diff --git a/src/Language/Parser.php b/src/Language/Parser.php index d40414a7e..08481b6e4 100644 --- a/src/Language/Parser.php +++ b/src/Language/Parser.php @@ -49,7 +49,6 @@ use GraphQL\Language\AST\VariableNode; use GraphQL\Language\AST\VariableDefinitionNode; use GraphQL\Error\SyntaxError; -use GraphQL\Type\TypeKind; /** * Parses string containing GraphQL query or [type definition](type-system/type-language.md) to Abstract Syntax Tree. @@ -67,10 +66,25 @@ class Parser * in the source that they correspond to. This configuration flag * disables that behavior for performance or testing.) * + * experimentalFragmentVariables: boolean, + * (If enabled, the parser will understand and parse variable definitions + * contained in a fragment definition. They'll be represented in the + * `variableDefinitions` field of the FragmentDefinitionNode. + * + * The syntax is identical to normal, query-defined variables. For example: + * + * fragment A($var: Boolean = false) on T { + * ... + * } + * + * Note: this feature is experimental and may change or be removed in the + * future.) + * * @api * @param Source|string $source * @param array $options * @return DocumentNode + * @throws SyntaxError */ public static function parse($source, array $options = []) { @@ -639,11 +653,19 @@ function parseFragmentDefinition() $this->expectKeyword('fragment'); $name = $this->parseFragmentName(); + + // Experimental support for defining variables within fragments changes + // the grammar of FragmentDefinition: + // - fragment FragmentName VariableDefinitions? on TypeCondition Directives? SelectionSet + $variableDefinitions = null; + if (isset($this->lexer->options['experimentalFragmentVariables'])) { + $variableDefinitions = $this->parseVariableDefinitions(); + } $this->expectKeyword('on'); $typeCondition = $this->parseNamedType(); - return new FragmentDefinitionNode([ 'name' => $name, + 'variableDefinitions' => $variableDefinitions, 'typeCondition' => $typeCondition, 'directives' => $this->parseDirectives(false), 'selectionSet' => $this->parseSelectionSet(), diff --git a/src/Language/Printer.php b/src/Language/Printer.php index 7c123a335..ed25c66aa 100644 --- a/src/Language/Printer.php +++ b/src/Language/Printer.php @@ -131,7 +131,11 @@ public function printAST($ast) ], ' '); }, NodeKind::FRAGMENT_DEFINITION => function(FragmentDefinitionNode $node) { - return "fragment {$node->name} on {$node->typeCondition} " + // Note: fragment variable definitions are experimental and may be changed + // or removed in the future. + return "fragment {$node->name}" + . $this->wrap('(', $this->join($node->variableDefinitions, ', '), ')') + . " on {$node->typeCondition} " . $this->wrap('', $this->join($node->directives, ' '), ' ') . $node->selectionSet; }, diff --git a/src/Language/Visitor.php b/src/Language/Visitor.php index fc0a1e72c..707a4b1dd 100644 --- a/src/Language/Visitor.php +++ b/src/Language/Visitor.php @@ -115,7 +115,15 @@ class Visitor NodeKind::ARGUMENT => ['name', 'value'], NodeKind::FRAGMENT_SPREAD => ['name', 'directives'], NodeKind::INLINE_FRAGMENT => ['typeCondition', 'directives', 'selectionSet'], - NodeKind::FRAGMENT_DEFINITION => ['name', 'typeCondition', 'directives', 'selectionSet'], + NodeKind::FRAGMENT_DEFINITION => [ + 'name', + // Note: fragment variable definitions are experimental and may be changed + // or removed in the future. + 'variableDefinitions', + 'typeCondition', + 'directives', + 'selectionSet' + ], NodeKind::INT => [], NodeKind::FLOAT => [], diff --git a/tests/Language/ParserTest.php b/tests/Language/ParserTest.php index f4032e6a8..b34cd9a18 100644 --- a/tests/Language/ParserTest.php +++ b/tests/Language/ParserTest.php @@ -453,10 +453,23 @@ public function testAllowsParsingWithoutSourceLocationInformation() $this->assertEquals(null, $result->loc); } + /** + * @it Experimental: allows parsing fragment defined variables + */ + public function testExperimentalAllowsParsingFragmentDefinedVariables() + { + $source = new Source('fragment a($v: Boolean = false) on t { f(v: $v) }'); + // not throw + Parser::parse($source, ['experimentalFragmentVariables' => true]); + + $this->setExpectedException(SyntaxError::class); + Parser::parse($source); + } + /** * @it contains location information that only stringifys start/end */ - public function testConvertToArray() + public function testContainsLocationInformationThatOnlyStringifysStartEnd() { $source = new Source('{ id }'); $result = Parser::parse($source); diff --git a/tests/Language/PrinterTest.php b/tests/Language/PrinterTest.php index 42bc0bcf1..9d4cce2fb 100644 --- a/tests/Language/PrinterTest.php +++ b/tests/Language/PrinterTest.php @@ -132,6 +132,28 @@ public function testCorrectlyPrintsBlockStringsWithAFirstLineIndentation() $this->assertEquals($expected, Printer::doPrint($mutationAstWithArtifacts)); } + /** + * @it Experimental: correctly prints fragment defined variables + */ + public function testExperimentalCorrectlyPrintsFragmentDefinedVariables() + { + $fragmentWithVariable = Parser::parse(' + fragment Foo($a: ComplexType, $b: Boolean = false) on TestType { + id + } + ', + ['experimentalFragmentVariables' => true] + ); + + $this->assertEquals( + Printer::doPrint($fragmentWithVariable), + 'fragment Foo($a: ComplexType, $b: Boolean = false) on TestType { + id +} +' + ); + } + /** * @it correctly prints single-line with leading space and quotation */ diff --git a/tests/Language/VisitorTest.php b/tests/Language/VisitorTest.php index 5cfd0b192..6ccc2d920 100644 --- a/tests/Language/VisitorTest.php +++ b/tests/Language/VisitorTest.php @@ -326,6 +326,60 @@ public function testAllowsANamedFunctionsVisitorAPI() $this->assertEquals($expected, $visited); } + /** + * @it Experimental: visits variables defined in fragments + */ + public function testExperimentalVisitsVariablesDefinedInFragments() + { + $ast = Parser::parse( + 'fragment a($v: Boolean = false) on t { f }', + ['experimentalFragmentVariables' => true] + ); + $visited = []; + + Visitor::visit($ast, [ + 'enter' => function($node) use (&$visited) { + $visited[] = ['enter', $node->kind, isset($node->value) ? $node->value : null]; + }, + 'leave' => function($node) use (&$visited) { + $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; + }, + ]); + + $expected = [ + ['enter', 'Document', null], + ['enter', 'FragmentDefinition', null], + ['enter', 'Name', 'a'], + ['leave', 'Name', 'a'], + ['enter', 'VariableDefinition', null], + ['enter', 'Variable', null], + ['enter', 'Name', 'v'], + ['leave', 'Name', 'v'], + ['leave', 'Variable', null], + ['enter', 'NamedType', null], + ['enter', 'Name', 'Boolean'], + ['leave', 'Name', 'Boolean'], + ['leave', 'NamedType', null], + ['enter', 'BooleanValue', false], + ['leave', 'BooleanValue', false], + ['leave', 'VariableDefinition', null], + ['enter', 'NamedType', null], + ['enter', 'Name', 't'], + ['leave', 'Name', 't'], + ['leave', 'NamedType', null], + ['enter', 'SelectionSet', null], + ['enter', 'Field', null], + ['enter', 'Name', 'f'], + ['leave', 'Name', 'f'], + ['leave', 'Field', null], + ['leave', 'SelectionSet', null], + ['leave', 'FragmentDefinition', null], + ['leave', 'Document', null], + ]; + + $this->assertEquals($expected, $visited); + } + /** * @it visits kitchen sink */ From 17520876d86f3ab2d83156927bf7b65f59bd7063 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Thu, 15 Feb 2018 17:19:53 +0100 Subject: [PATCH 40/50] Update some validators to latest upstream version This includes: graphql/graphql-js#1147 graphql/graphql-js#355 This also fixes two bugs in the Schema - types that were not found where still added to the typeMap - InputObject args should not be searched for types. --- src/Language/AST/FragmentDefinitionNode.php | 2 +- src/Type/Schema.php | 6 +- src/Utils/TypeInfo.php | 7 +- src/Utils/Utils.php | 61 ++++++++++ src/Validator/Rules/FieldsOnCorrectType.php | 122 +++++++++++++++++--- src/Validator/Rules/KnownArgumentNames.php | 78 +++++++------ src/Validator/Rules/KnownTypeNames.php | 36 ++++-- tests/Utils/QuotedOrListTest.php | 65 +++++++++++ tests/Utils/SuggestionListTest.php | 45 ++++++++ tests/Validator/FieldsOnCorrectTypeTest.php | 107 ++++++++++++----- tests/Validator/KnownArgumentNamesTest.php | 48 ++++++-- tests/Validator/KnownTypeNamesTest.php | 12 +- tests/Validator/ValidationTest.php | 2 +- 13 files changed, 485 insertions(+), 106 deletions(-) create mode 100644 tests/Utils/QuotedOrListTest.php create mode 100644 tests/Utils/SuggestionListTest.php diff --git a/src/Language/AST/FragmentDefinitionNode.php b/src/Language/AST/FragmentDefinitionNode.php index 4543b03f9..14cf662e2 100644 --- a/src/Language/AST/FragmentDefinitionNode.php +++ b/src/Language/AST/FragmentDefinitionNode.php @@ -13,7 +13,7 @@ class FragmentDefinitionNode extends Node implements ExecutableDefinitionNode, H /** * Note: fragment variable definitions are experimental and may be changed * or removed in the future. - * + * * @var VariableDefinitionNode[]|NodeList */ public $variableDefinitions; diff --git a/src/Type/Schema.php b/src/Type/Schema.php index b68ef1243..3e8b16ba5 100644 --- a/src/Type/Schema.php +++ b/src/Type/Schema.php @@ -224,7 +224,11 @@ public function getTypeMap() public function getType($name) { if (!isset($this->resolvedTypes[$name])) { - $this->resolvedTypes[$name] = $this->loadType($name); + $type = $this->loadType($name); + if (!$type) { + return null; + } + $this->resolvedTypes[$name] = $type; } return $this->resolvedTypes[$name]; } diff --git a/src/Utils/TypeInfo.php b/src/Utils/TypeInfo.php index 34a48cb2c..843a43387 100644 --- a/src/Utils/TypeInfo.php +++ b/src/Utils/TypeInfo.php @@ -122,7 +122,7 @@ public static function extractTypes($type, array $typeMap = null) if ($type instanceof ObjectType) { $nestedTypes = array_merge($nestedTypes, $type->getInterfaces()); } - if ($type instanceof ObjectType || $type instanceof InterfaceType || $type instanceof InputObjectType) { + if ($type instanceof ObjectType || $type instanceof InterfaceType) { foreach ((array) $type->getFields() as $fieldName => $field) { if (!empty($field->args)) { $fieldArgTypes = array_map(function(FieldArgument $arg) { return $arg->getType(); }, $field->args); @@ -131,6 +131,11 @@ public static function extractTypes($type, array $typeMap = null) $nestedTypes[] = $field->getType(); } } + if ($type instanceof InputObjectType) { + foreach ((array) $type->getFields() as $fieldName => $field) { + $nestedTypes[] = $field->getType(); + } + } foreach ($nestedTypes as $type) { $typeMap = self::extractTypes($type, $typeMap); } diff --git a/src/Utils/Utils.php b/src/Utils/Utils.php index c000c80c8..853fbf1a1 100644 --- a/src/Utils/Utils.php +++ b/src/Utils/Utils.php @@ -471,4 +471,65 @@ public static function withErrorHandling(callable $fn, array &$errors) } }; } + + + /** + * @param string[] $items + * @return string + */ + public static function quotedOrList(array $items) + { + $items = array_map(function($item) { return "\"$item\""; }, $items); + return self::orList($items); + } + + public static function orList(array $items) + { + if (!$items) { + throw new \LogicException('items must not need to be empty.'); + } + $selected = array_slice($items, 0, 5); + $selectedLength = count($selected); + $firstSelected = $selected[0]; + + if ($selectedLength === 1) { + return $firstSelected; + } + + return array_reduce( + range(1, $selectedLength - 1), + function ($list, $index) use ($selected, $selectedLength) { + return $list. + ($selectedLength > 2 && $index !== $selectedLength - 1? ', ' : ' ') . + ($index === $selectedLength - 1 ? 'or ' : '') . + $selected[$index]; + }, + $firstSelected + ); + } + + /** + * Given an invalid input string and a list of valid options, returns a filtered + * list of valid options sorted based on their similarity with the input. + * + * @param string $input + * @param array $options + * @return string[] + */ + public static function suggestionList($input, array $options) + { + $optionsByDistance = []; + $inputThreshold = mb_strlen($input) / 2; + foreach ($options as $option) { + $distance = levenshtein($input, $option); + $threshold = max($inputThreshold, mb_strlen($option) / 2, 1); + if ($distance <= $threshold) { + $optionsByDistance[$option] = $distance; + } + } + + asort($optionsByDistance); + + return array_keys($optionsByDistance); + } } diff --git a/src/Validator/Rules/FieldsOnCorrectType.php b/src/Validator/Rules/FieldsOnCorrectType.php index 26ee7483d..7d052ae6b 100644 --- a/src/Validator/Rules/FieldsOnCorrectType.php +++ b/src/Validator/Rules/FieldsOnCorrectType.php @@ -4,27 +4,27 @@ use GraphQL\Error\Error; use GraphQL\Language\AST\FieldNode; use GraphQL\Language\AST\NodeKind; +use GraphQL\Type\Definition\InputObjectType; +use GraphQL\Type\Definition\InterfaceType; +use GraphQL\Type\Definition\ObjectType; +use GraphQL\Type\Definition\Type; +use GraphQL\Type\Definition\UnionType; +use GraphQL\Type\Schema; use GraphQL\Utils\Utils; use GraphQL\Validator\ValidationContext; class FieldsOnCorrectType extends AbstractValidationRule { - static function undefinedFieldMessage($field, $type, array $suggestedTypes = []) + static function undefinedFieldMessage($fieldName, $type, array $suggestedTypeNames, array $suggestedFieldNames) { - $message = 'Cannot query field "' . $field . '" on type "' . $type.'".'; + $message = 'Cannot query field "' . $fieldName . '" on type "' . $type.'".'; - $maxLength = 5; - $count = count($suggestedTypes); - if ($count > 0) { - $suggestions = array_slice($suggestedTypes, 0, $maxLength); - $suggestions = Utils::map($suggestions, function($t) { return "\"$t\""; }); - $suggestions = implode(', ', $suggestions); - - if ($count > $maxLength) { - $suggestions .= ', and ' . ($count - $maxLength) . ' other types'; - } - $message .= " However, this field exists on $suggestions."; - $message .= ' Perhaps you meant to use an inline fragment?'; + if ($suggestedTypeNames) { + $suggestions = Utils::quotedOrList($suggestedTypeNames); + $message .= " Did you mean to use an inline fragment on $suggestions?"; + } else if ($suggestedFieldNames) { + $suggestions = Utils::quotedOrList($suggestedFieldNames); + $message .= " Did you mean {$suggestions}?"; } return $message; } @@ -37,8 +37,32 @@ public function getVisitor(ValidationContext $context) if ($type) { $fieldDef = $context->getFieldDef(); if (!$fieldDef) { + // This isn't valid. Let's find suggestions, if any. + $schema = $context->getSchema(); + $fieldName = $node->name->value; + // First determine if there are any suggested types to condition on. + $suggestedTypeNames = $this->getSuggestedTypeNames( + $schema, + $type, + $fieldName + ); + // If there are no suggested types, then perhaps this was a typo? + $suggestedFieldNames = $suggestedTypeNames + ? [] + : $this->getSuggestedFieldNames( + $schema, + $type, + $fieldName + ); + + // Report an error, including helpful suggestions. $context->reportError(new Error( - static::undefinedFieldMessage($node->name->value, $type->name), + static::undefinedFieldMessage( + $node->name->value, + $type->name, + $suggestedTypeNames, + $suggestedFieldNames + ), [$node] )); } @@ -46,4 +70,72 @@ public function getVisitor(ValidationContext $context) } ]; } + + /** + * Go through all of the implementations of type, as well as the interfaces + * that they implement. If any of those types include the provided field, + * suggest them, sorted by how often the type is referenced, starting + * with Interfaces. + * + * @param Schema $schema + * @param $type + * @param string $fieldName + * @return array + */ + private function getSuggestedTypeNames(Schema $schema, $type, $fieldName) + { + if (Type::isAbstractType($type)) { + $suggestedObjectTypes = []; + $interfaceUsageCount = []; + + foreach($schema->getPossibleTypes($type) as $possibleType) { + $fields = $possibleType->getFields(); + if (!isset($fields[$fieldName])) { + continue; + } + // This object type defines this field. + $suggestedObjectTypes[] = $possibleType->name; + foreach($possibleType->getInterfaces() as $possibleInterface) { + $fields = $possibleInterface->getFields(); + if (!isset($fields[$fieldName])) { + continue; + } + // This interface type defines this field. + $interfaceUsageCount[$possibleInterface->name] = + !isset($interfaceUsageCount[$possibleInterface->name]) + ? 0 + : $interfaceUsageCount[$possibleInterface->name] + 1; + } + } + + // Suggest interface types based on how common they are. + arsort($interfaceUsageCount); + $suggestedInterfaceTypes = array_keys($interfaceUsageCount); + + // Suggest both interface and object types. + return array_merge($suggestedInterfaceTypes, $suggestedObjectTypes); + } + + // Otherwise, must be an Object type, which does not have possible fields. + return []; + } + + /** + * For the field name provided, determine if there are any similar field names + * that may be the result of a typo. + * + * @param Schema $schema + * @param $type + * @param string $fieldName + * @return array|string[] + */ + private function getSuggestedFieldNames(Schema $schema, $type, $fieldName) + { + if ($type instanceof ObjectType || $type instanceof InterfaceType) { + $possibleFieldNames = array_keys($type->getFields()); + return Utils::suggestionList($fieldName, $possibleFieldNames); + } + // Otherwise, must be a Union type, which does not define fields. + return []; + } } diff --git a/src/Validator/Rules/KnownArgumentNames.php b/src/Validator/Rules/KnownArgumentNames.php index 78ee3f943..15a77abdf 100644 --- a/src/Validator/Rules/KnownArgumentNames.php +++ b/src/Validator/Rules/KnownArgumentNames.php @@ -7,56 +7,68 @@ use GraphQL\Utils\Utils; use GraphQL\Validator\ValidationContext; +/** + * Known argument names + * + * A GraphQL field is only valid if all supplied arguments are defined by + * that field. + */ class KnownArgumentNames extends AbstractValidationRule { - public static function unknownArgMessage($argName, $fieldName, $type) + public static function unknownArgMessage($argName, $fieldName, $typeName, array $suggestedArgs) { - return "Unknown argument \"$argName\" on field \"$fieldName\" of type \"$type\"."; + $message = "Unknown argument \"$argName\" on field \"$fieldName\" of type \"$typeName\"."; + if ($suggestedArgs) { + $message .= ' Did you mean ' . Utils::quotedOrList($suggestedArgs) . '?'; + } + return $message; } - public static function unknownDirectiveArgMessage($argName, $directiveName) + public static function unknownDirectiveArgMessage($argName, $directiveName, array $suggestedArgs) { - return "Unknown argument \"$argName\" on directive \"@$directiveName\"."; + $message = "Unknown argument \"$argName\" on directive \"@$directiveName\"."; + if ($suggestedArgs) { + $message .= ' Did you mean ' . Utils::quotedOrList($suggestedArgs) . '?'; + } + return $message; } public function getVisitor(ValidationContext $context) { return [ NodeKind::ARGUMENT => function(ArgumentNode $node, $key, $parent, $path, $ancestors) use ($context) { - $argumentOf = $ancestors[count($ancestors) - 1]; - if ($argumentOf->kind === NodeKind::FIELD) { - $fieldDef = $context->getFieldDef(); - - if ($fieldDef) { - $fieldArgDef = null; - foreach ($fieldDef->args as $arg) { - if ($arg->name === $node->name->value) { - $fieldArgDef = $arg; - break; - } - } - if (!$fieldArgDef) { - $parentType = $context->getParentType(); - Utils::invariant($parentType); + $argDef = $context->getArgument(); + if (!$argDef) { + $argumentOf = $ancestors[count($ancestors) - 1]; + if ($argumentOf->kind === NodeKind::FIELD) { + $fieldDef = $context->getFieldDef(); + $parentType = $context->getParentType(); + if ($fieldDef && $parentType) { $context->reportError(new Error( - self::unknownArgMessage($node->name->value, $fieldDef->name, $parentType->name), + self::unknownArgMessage( + $node->name->value, + $fieldDef->name, + $parentType->name, + Utils::suggestionList( + $node->name->value, + array_map(function ($arg) { return $arg->name; }, $fieldDef->args) + ) + ), [$node] )); } - } - } else if ($argumentOf->kind === NodeKind::DIRECTIVE) { - $directive = $context->getDirective(); - if ($directive) { - $directiveArgDef = null; - foreach ($directive->args as $arg) { - if ($arg->name === $node->name->value) { - $directiveArgDef = $arg; - break; - } - } - if (!$directiveArgDef) { + } else if ($argumentOf->kind === NodeKind::DIRECTIVE) { + $directive = $context->getDirective(); + if ($directive) { $context->reportError(new Error( - self::unknownDirectiveArgMessage($node->name->value, $directive->name), + self::unknownDirectiveArgMessage( + $node->name->value, + $directive->name, + Utils::suggestionList( + $node->name->value, + array_map(function ($arg) { return $arg->name; }, $directive->args) + ) + ), [$node] )); } diff --git a/src/Validator/Rules/KnownTypeNames.php b/src/Validator/Rules/KnownTypeNames.php index 71fa60afb..47065c16c 100644 --- a/src/Validator/Rules/KnownTypeNames.php +++ b/src/Validator/Rules/KnownTypeNames.php @@ -1,35 +1,55 @@ $skip, NodeKind::INTERFACE_TYPE_DEFINITION => $skip, NodeKind::UNION_TYPE_DEFINITION => $skip, NodeKind::INPUT_OBJECT_TYPE_DEFINITION => $skip, - - NodeKind::NAMED_TYPE => function(NamedTypeNode $node, $key) use ($context) { + NodeKind::NAMED_TYPE => function(NamedTypeNode $node) use ($context) { + $schema = $context->getSchema(); $typeName = $node->name->value; - $type = $context->getSchema()->getType($typeName); + $type = $schema->getType($typeName); if (!$type) { - $context->reportError(new Error(self::unknownTypeMessage($typeName), [$node])); + $context->reportError(new Error( + self::unknownTypeMessage( + $typeName, + Utils::suggestionList($typeName, array_keys($schema->getTypeMap())) + ), [$node]) + ); } } ]; diff --git a/tests/Utils/QuotedOrListTest.php b/tests/Utils/QuotedOrListTest.php new file mode 100644 index 000000000..861388b81 --- /dev/null +++ b/tests/Utils/QuotedOrListTest.php @@ -0,0 +1,65 @@ +setExpectedException(\LogicException::class); + Utils::quotedOrList([]); + } + + /** + * @it Returns single quoted item + */ + public function testReturnsSingleQuotedItem() + { + $this->assertEquals( + '"A"', + Utils::quotedOrList(['A']) + ); + } + + /** + * @it Returns two item list + */ + public function testReturnsTwoItemList() + { + $this->assertEquals( + '"A" or "B"', + Utils::quotedOrList(['A', 'B']) + ); + } + + /** + * @it Returns comma separated many item list + */ + public function testReturnsCommaSeparatedManyItemList() + { + $this->assertEquals( + '"A", "B" or "C"', + Utils::quotedOrList(['A', 'B', 'C']) + ); + } + + /** + * @it Limits to five items + */ + public function testLimitsToFiveItems() + { + $this->assertEquals( + '"A", "B", "C", "D" or "E"', + Utils::quotedOrList(['A', 'B', 'C', 'D', 'E', 'F']) + ); + } +} diff --git a/tests/Utils/SuggestionListTest.php b/tests/Utils/SuggestionListTest.php new file mode 100644 index 000000000..73797f76c --- /dev/null +++ b/tests/Utils/SuggestionListTest.php @@ -0,0 +1,45 @@ +assertEquals( + Utils::suggestionList('', ['a']), + ['a'] + ); + } + + /** + * @it Returns empty array when there are no options + */ + public function testReturnsEmptyArrayWhenThereAreNoOptions() + { + $this->assertEquals( + Utils::suggestionList('input', []), + [] + ); + } + + /** + * @it Returns options sorted based on similarity + */ + public function testReturnsOptionsSortedBasedOnSimilarity() + { + $this->assertEquals( + Utils::suggestionList('abc', ['a', 'ab', 'abc']), + ['abc', 'ab'] + ); + } +} diff --git a/tests/Validator/FieldsOnCorrectTypeTest.php b/tests/Validator/FieldsOnCorrectTypeTest.php index 7d4b78b6b..f59bf6c66 100644 --- a/tests/Validator/FieldsOnCorrectTypeTest.php +++ b/tests/Validator/FieldsOnCorrectTypeTest.php @@ -97,8 +97,10 @@ public function testReportsErrorsWhenTypeIsKnownAgain() } } }', - [ $this->undefinedField('unknown_pet_field', 'Pet', [], 3, 9), - $this->undefinedField('unknown_cat_field', 'Cat', [], 5, 13) ] + [ + $this->undefinedField('unknown_pet_field', 'Pet', [], [], 3, 9), + $this->undefinedField('unknown_cat_field', 'Cat', [], [], 5, 13) + ] ); } @@ -111,7 +113,7 @@ public function testFieldNotDefinedOnFragment() fragment fieldNotDefined on Dog { meowVolume }', - [$this->undefinedField('meowVolume', 'Dog', [], 3, 9)] + [$this->undefinedField('meowVolume', 'Dog', [], ['barkVolume'], 3, 9)] ); } @@ -126,7 +128,7 @@ public function testIgnoresDeeplyUnknownField() deeper_unknown_field } }', - [$this->undefinedField('unknown_field', 'Dog', [], 3, 9)] + [$this->undefinedField('unknown_field', 'Dog', [], [], 3, 9)] ); } @@ -141,7 +143,7 @@ public function testSubFieldNotDefined() unknown_field } }', - [$this->undefinedField('unknown_field', 'Pet', [], 4, 11)] + [$this->undefinedField('unknown_field', 'Pet', [], [], 4, 11)] ); } @@ -156,7 +158,7 @@ public function testFieldNotDefinedOnInlineFragment() meowVolume } }', - [$this->undefinedField('meowVolume', 'Dog', [], 4, 11)] + [$this->undefinedField('meowVolume', 'Dog', [], ['barkVolume'], 4, 11)] ); } @@ -169,7 +171,7 @@ public function testAliasedFieldTargetNotDefined() fragment aliasedFieldTargetNotDefined on Dog { volume : mooVolume }', - [$this->undefinedField('mooVolume', 'Dog', [], 3, 9)] + [$this->undefinedField('mooVolume', 'Dog', [], ['barkVolume'], 3, 9)] ); } @@ -182,7 +184,7 @@ public function testAliasedLyingFieldTargetNotDefined() fragment aliasedLyingFieldTargetNotDefined on Dog { barkVolume : kawVolume }', - [$this->undefinedField('kawVolume', 'Dog', [], 3, 9)] + [$this->undefinedField('kawVolume', 'Dog', [], ['barkVolume'], 3, 9)] ); } @@ -195,7 +197,7 @@ public function testNotDefinedOnInterface() fragment notDefinedOnInterface on Pet { tailLength }', - [$this->undefinedField('tailLength', 'Pet', [], 3, 9)] + [$this->undefinedField('tailLength', 'Pet', [], [], 3, 9)] ); } @@ -208,8 +210,7 @@ public function testDefinedOnImplmentorsButNotOnInterface() fragment definedOnImplementorsButNotInterface on Pet { nickname }', - //[$this->undefinedField('nickname', 'Pet', [ 'Cat', 'Dog' ], 3, 9)] - [$this->undefinedField('nickname', 'Pet', [ ], 3, 9)] + [$this->undefinedField('nickname', 'Pet', ['Dog', 'Cat'], ['name'], 3, 9)] ); } @@ -234,7 +235,7 @@ public function testDirectFieldSelectionOnUnion() fragment directFieldSelectionOnUnion on CatOrDog { directField }', - [$this->undefinedField('directField', 'CatOrDog', [], 3, 9)] + [$this->undefinedField('directField', 'CatOrDog', [], [], 3, 9)] ); } @@ -247,8 +248,14 @@ public function testDefinedOnImplementorsQueriedOnUnion() fragment definedOnImplementorsQueriedOnUnion on CatOrDog { name }', - //[$this->undefinedField('name', 'CatOrDog', [ 'Being', 'Pet', 'Canine', 'Cat', 'Dog' ], 3, 9)] - [$this->undefinedField('name', 'CatOrDog', [ ], 3, 9)] + [$this->undefinedField( + 'name', + 'CatOrDog', + ['Being', 'Pet', 'Canine', 'Dog', 'Cat'], + [], + 3, + 9 + )] ); } @@ -273,38 +280,78 @@ public function testValidFieldInInlineFragment() */ public function testWorksWithNoSuggestions() { - $this->assertEquals('Cannot query field "T" on type "f".', FieldsOnCorrectType::undefinedFieldMessage('T', 'f', [])); + $this->assertEquals('Cannot query field "f" on type "T".', FieldsOnCorrectType::undefinedFieldMessage('f', 'T', [], [])); } /** - * @it Works with no small numbers of suggestions + * @it Works with no small numbers of type suggestions */ - public function testWorksWithNoSmallNumbersOfSuggestions() + public function testWorksWithNoSmallNumbersOfTypeSuggestions() { - $expected = 'Cannot query field "T" on type "f". ' . - 'However, this field exists on "A", "B". ' . - 'Perhaps you meant to use an inline fragment?'; + $expected = 'Cannot query field "f" on type "T". ' . + 'Did you mean to use an inline fragment on "A" or "B"?'; - $this->assertEquals($expected, FieldsOnCorrectType::undefinedFieldMessage('T', 'f', [ 'A', 'B' ])); + $this->assertEquals($expected, FieldsOnCorrectType::undefinedFieldMessage('f', 'T', ['A', 'B'], [])); } /** - * @it Works with lots of suggestions + * @it Works with no small numbers of field suggestions */ - public function testWorksWithLotsOfSuggestions() + public function testWorksWithNoSmallNumbersOfFieldSuggestions() { - $expected = 'Cannot query field "T" on type "f". ' . - 'However, this field exists on "A", "B", "C", "D", "E", ' . - 'and 1 other types. ' . - 'Perhaps you meant to use an inline fragment?'; + $expected = 'Cannot query field "f" on type "T". ' . + 'Did you mean "z" or "y"?'; - $this->assertEquals($expected, FieldsOnCorrectType::undefinedFieldMessage('T', 'f', [ 'A', 'B', 'C', 'D', 'E', 'F' ])); + $this->assertEquals($expected, FieldsOnCorrectType::undefinedFieldMessage('f', 'T', [], ['z', 'y'])); } - private function undefinedField($field, $type, $suggestions, $line, $column) + /** + * @it Only shows one set of suggestions at a time, preferring types + */ + public function testOnlyShowsOneSetOfSuggestionsAtATimePreferringTypes() + { + $expected = 'Cannot query field "f" on type "T". ' . + 'Did you mean to use an inline fragment on "A" or "B"?'; + + $this->assertEquals($expected, FieldsOnCorrectType::undefinedFieldMessage('f', 'T', ['A', 'B'], ['z', 'y'])); + } + + /** + * @it Limits lots of type suggestions + */ + public function testLimitsLotsOfTypeSuggestions() + { + $expected = 'Cannot query field "f" on type "T". ' . + 'Did you mean to use an inline fragment on "A", "B", "C", "D" or "E"?'; + + $this->assertEquals($expected, FieldsOnCorrectType::undefinedFieldMessage( + 'f', + 'T', + ['A', 'B', 'C', 'D', 'E', 'F'], + [] + )); + } + + /** + * @it Limits lots of field suggestions + */ + public function testLimitsLotsOfFieldSuggestions() + { + $expected = 'Cannot query field "f" on type "T". ' . + 'Did you mean "z", "y", "x", "w" or "v"?'; + + $this->assertEquals($expected, FieldsOnCorrectType::undefinedFieldMessage( + 'f', + 'T', + [], + ['z', 'y', 'x', 'w', 'v', 'u'] + )); + } + + private function undefinedField($field, $type, $suggestedTypes, $suggestedFields, $line, $column) { return FormattedError::create( - FieldsOnCorrectType::undefinedFieldMessage($field, $type, $suggestions), + FieldsOnCorrectType::undefinedFieldMessage($field, $type, $suggestedTypes, $suggestedFields), [new SourceLocation($line, $column)] ); } diff --git a/tests/Validator/KnownArgumentNamesTest.php b/tests/Validator/KnownArgumentNamesTest.php index 80ced662a..84b7e3839 100644 --- a/tests/Validator/KnownArgumentNamesTest.php +++ b/tests/Validator/KnownArgumentNamesTest.php @@ -112,7 +112,21 @@ public function testUndirectiveArgsAreInvalid() dog @skip(unless: true) } ', [ - $this->unknownDirectiveArg('unless', 'skip', 3, 19), + $this->unknownDirectiveArg('unless', 'skip', [], 3, 19), + ]); + } + + /** + * @it misspelled directive args are reported + */ + public function testMisspelledDirectiveArgsAreReported() + { + $this->expectFailsRule(new KnownArgumentNames, ' + { + dog @skip(iff: true) + } + ', [ + $this->unknownDirectiveArg('iff', 'skip', ['if'], 3, 19), ]); } @@ -126,7 +140,21 @@ public function testInvalidArgName() doesKnowCommand(unknown: true) } ', [ - $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', 3, 25), + $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', [],3, 25), + ]); + } + + /** + * @it misspelled arg name is reported + */ + public function testMisspelledArgNameIsReported() + { + $this->expectFailsRule(new KnownArgumentNames, ' + fragment invalidArgName on Dog { + doesKnowCommand(dogcommand: true) + } + ', [ + $this->unknownArg('dogcommand', 'doesKnowCommand', 'Dog', ['dogCommand'],3, 25), ]); } @@ -140,8 +168,8 @@ public function testUnknownArgsAmongstKnownArgs() doesKnowCommand(whoknows: 1, dogCommand: SIT, unknown: true) } ', [ - $this->unknownArg('whoknows', 'doesKnowCommand', 'Dog', 3, 25), - $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', 3, 55), + $this->unknownArg('whoknows', 'doesKnowCommand', 'Dog', [], 3, 25), + $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', [], 3, 55), ]); } @@ -164,23 +192,23 @@ public function testUnknownArgsDeeply() } } ', [ - $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', 4, 27), - $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', 9, 31), + $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', [], 4, 27), + $this->unknownArg('unknown', 'doesKnowCommand', 'Dog', [], 9, 31), ]); } - private function unknownArg($argName, $fieldName, $typeName, $line, $column) + private function unknownArg($argName, $fieldName, $typeName, $suggestedArgs, $line, $column) { return FormattedError::create( - KnownArgumentNames::unknownArgMessage($argName, $fieldName, $typeName), + KnownArgumentNames::unknownArgMessage($argName, $fieldName, $typeName, $suggestedArgs), [new SourceLocation($line, $column)] ); } - private function unknownDirectiveArg($argName, $directiveName, $line, $column) + private function unknownDirectiveArg($argName, $directiveName, $suggestedArgs, $line, $column) { return FormattedError::create( - KnownArgumentNames::unknownDirectiveArgMessage($argName, $directiveName), + KnownArgumentNames::unknownDirectiveArgMessage($argName, $directiveName, $suggestedArgs), [new SourceLocation($line, $column)] ); } diff --git a/tests/Validator/KnownTypeNamesTest.php b/tests/Validator/KnownTypeNamesTest.php index 8bdab66c3..f8aba61e1 100644 --- a/tests/Validator/KnownTypeNamesTest.php +++ b/tests/Validator/KnownTypeNamesTest.php @@ -42,9 +42,9 @@ public function testUnknownTypeNamesAreInvalid() name } ', [ - $this->unknownType('JumbledUpLetters', 2, 23), - $this->unknownType('Badger', 5, 25), - $this->unknownType('Peettt', 8, 29) + $this->unknownType('JumbledUpLetters', [], 2, 23), + $this->unknownType('Badger', [], 5, 25), + $this->unknownType('Peettt', ['Pet'], 8, 29) ]); } @@ -70,14 +70,14 @@ interface FooBar { } } ', [ - $this->unknownType('NotInTheSchema', 12, 23), + $this->unknownType('NotInTheSchema', [], 12, 23), ]); } - private function unknownType($typeName, $line, $column) + private function unknownType($typeName, $suggestedTypes, $line, $column) { return FormattedError::create( - KnownTypeNames::unknownTypeMessage($typeName), + KnownTypeNames::unknownTypeMessage($typeName, $suggestedTypes), [new SourceLocation($line, $column)] ); } diff --git a/tests/Validator/ValidationTest.php b/tests/Validator/ValidationTest.php index 7c7fc092d..6cab583de 100644 --- a/tests/Validator/ValidationTest.php +++ b/tests/Validator/ValidationTest.php @@ -58,7 +58,7 @@ public function testPassesValidationWithEmptyRules() $query = '{invalid}'; $expectedError = [ - 'message' => 'Cannot query field "invalid" on type "QueryRoot".', + 'message' => 'Cannot query field "invalid" on type "QueryRoot". Did you mean "invalidArg"?', 'locations' => [ ['line' => 1, 'column' => 2] ] ]; $this->expectFailsCompleteValidation($query, [$expectedError]); From 58e0c7a178154203c7aec9108102805ae8db23c2 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Thu, 15 Feb 2018 21:29:14 +0100 Subject: [PATCH 41/50] Validate literals in a single rule with finer precision This generalizes the "arguments of correct type" and "default values of correct type" to a single rule "values of correct type" which has been re-written to rely on a traversal rather than the utility function `isValidLiteralValue`. To reduce breaking scope, this does not remove that utility even though it's no longer used directly within the library. Since the default values rule included another validation rule that rule was renamed to a more apt "variable default value allowed". This also includes the original errors from custom scalars in the validation error output, solving the remainder of graphql/graphql-js#821. ref: graphql/graphql-js#1144 --- src/Executor/Values.php | 10 +- src/Utils/TypeInfo.php | 36 +- src/Validator/DocumentValidator.php | 142 +----- .../Rules/ArgumentsOfCorrectType.php | 39 -- .../Rules/DefaultValuesOfCorrectType.php | 59 --- src/Validator/Rules/ValuesOfCorrectType.php | 226 +++++++++ .../Rules/VariablesDefaultValueAllowed.php | 60 +++ src/Validator/ValidationContext.php | 10 +- tests/Executor/VariablesTest.php | 10 +- tests/Type/EnumTypeTest.php | 24 +- tests/Utils/IsValidLiteralValueTest.php | 37 ++ .../DefaultValuesOfCorrectTypeTest.php | 188 -------- tests/Validator/TestCase.php | 24 +- tests/Validator/ValidationTest.php | 7 +- ...peTest.php => ValuesOfCorrectTypeTest.php} | 440 ++++++++++++------ .../VariablesDefaultValueAllowedTest.php | 109 +++++ 16 files changed, 831 insertions(+), 590 deletions(-) delete mode 100644 src/Validator/Rules/ArgumentsOfCorrectType.php delete mode 100644 src/Validator/Rules/DefaultValuesOfCorrectType.php create mode 100644 src/Validator/Rules/ValuesOfCorrectType.php create mode 100644 src/Validator/Rules/VariablesDefaultValueAllowed.php create mode 100644 tests/Utils/IsValidLiteralValueTest.php delete mode 100644 tests/Validator/DefaultValuesOfCorrectTypeTest.php rename tests/Validator/{ArgumentsOfCorrectTypeTest.php => ValuesOfCorrectTypeTest.php} (58%) create mode 100644 tests/Validator/VariablesDefaultValueAllowedTest.php diff --git a/src/Executor/Values.php b/src/Executor/Values.php index ef6a8cff8..c8353c4be 100644 --- a/src/Executor/Values.php +++ b/src/Executor/Values.php @@ -115,7 +115,6 @@ public static function getArgumentValues($def, $node, $variableValues = null) } $coercedValues = []; - $undefined = Utils::undefined(); /** @var ArgumentNode[] $argNodeMap */ $argNodeMap = $argNodes ? Utils::keyMap($argNodes, function (ArgumentNode $arg) { @@ -158,11 +157,12 @@ public static function getArgumentValues($def, $node, $variableValues = null) } else { $valueNode = $argumentNode->value; $coercedValue = AST::valueFromAST($valueNode, $argType, $variableValues); - if ($coercedValue === $undefined) { - $errors = DocumentValidator::isValidLiteralValue($argType, $valueNode); - $message = !empty($errors) ? ("\n" . implode("\n", $errors)) : ''; + 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 . '" got invalid value ' . Printer::doPrint($valueNode) . '.' . $message, + 'Argument "' . $name . '" has invalid value ' . Printer::doPrint($valueNode) . '.', [ $argumentNode->value ] ); } diff --git a/src/Utils/TypeInfo.php b/src/Utils/TypeInfo.php index 843a43387..a211c5abc 100644 --- a/src/Utils/TypeInfo.php +++ b/src/Utils/TypeInfo.php @@ -4,7 +4,6 @@ use GraphQL\Error\InvariantViolation; use GraphQL\Error\Warning; use GraphQL\Language\AST\FieldNode; -use GraphQL\Language\AST\ListType; use GraphQL\Language\AST\ListTypeNode; use GraphQL\Language\AST\NamedTypeNode; use GraphQL\Language\AST\Node; @@ -20,7 +19,6 @@ use GraphQL\Type\Definition\InputType; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\ListOfType; -use GraphQL\Type\Definition\NonNull; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; use GraphQL\Type\Definition\UnionType; @@ -217,14 +215,26 @@ static private function getFieldDefinition(Schema $schema, Type $parentType, Fie /** * TypeInfo constructor. * @param Schema $schema + * @param Type|null $initialType */ - public function __construct(Schema $schema) + public function __construct(Schema $schema, $initialType = null) { $this->schema = $schema; $this->typeStack = []; $this->parentTypeStack = []; $this->inputTypeStack = []; $this->fieldDefStack = []; + if ($initialType) { + if (Type::isInputType($initialType)) { + $this->inputTypeStack[] = $initialType; + } + if (Type::isCompositeType($initialType)) { + $this->parentTypeStack[] = $initialType; + } + if (Type::isOutputType($initialType)) { + $this->typeStack[] = $initialType; + } + } } /** @@ -239,7 +249,7 @@ function getType() } /** - * @return Type + * @return CompositeType */ function getParentType() { @@ -260,6 +270,17 @@ function getInputType() return null; } + /** + * @return InputType|null + */ + public function getParentInputType() + { + $inputTypeStackLength = count($this->inputTypeStack); + if ($inputTypeStackLength > 1) { + return $this->inputTypeStack[$inputTypeStackLength - 2]; + } + } + /** * @return FieldDefinition */ @@ -369,10 +390,9 @@ function enter(Node $node) case NodeKind::LST: $listType = Type::getNullableType($this->getInputType()); - $itemType = null; - if ($itemType instanceof ListType) { - $itemType = $listType->getWrappedType(); - } + $itemType = $listType instanceof ListOfType + ? $listType->getWrappedType() + : $listType; $this->inputTypeStack[] = Type::isInputType($itemType) ? $itemType : null; break; diff --git a/src/Validator/DocumentValidator.php b/src/Validator/DocumentValidator.php index 1a454f776..3efd992fa 100644 --- a/src/Validator/DocumentValidator.php +++ b/src/Validator/DocumentValidator.php @@ -2,26 +2,13 @@ namespace GraphQL\Validator; use GraphQL\Error\Error; -use GraphQL\Language\AST\EnumValueNode; -use GraphQL\Language\AST\ListValueNode; use GraphQL\Language\AST\DocumentNode; -use GraphQL\Language\AST\NodeKind; -use GraphQL\Language\AST\NullValueNode; -use GraphQL\Language\AST\VariableNode; -use GraphQL\Language\Printer; use GraphQL\Language\Visitor; use GraphQL\Type\Schema; -use GraphQL\Type\Definition\EnumType; -use GraphQL\Type\Definition\InputObjectType; -use GraphQL\Type\Definition\ListOfType; -use GraphQL\Type\Definition\NonNull; use GraphQL\Type\Definition\Type; -use GraphQL\Type\Definition\ScalarType; -use GraphQL\Utils\Utils; use GraphQL\Utils\TypeInfo; use GraphQL\Validator\Rules\AbstractValidationRule; -use GraphQL\Validator\Rules\ArgumentsOfCorrectType; -use GraphQL\Validator\Rules\DefaultValuesOfCorrectType; +use GraphQL\Validator\Rules\ValuesOfCorrectType; use GraphQL\Validator\Rules\DisableIntrospection; use GraphQL\Validator\Rules\ExecutableDefinitions; use GraphQL\Validator\Rules\FieldsOnCorrectType; @@ -48,6 +35,7 @@ use GraphQL\Validator\Rules\UniqueOperationNames; use GraphQL\Validator\Rules\UniqueVariableNames; use GraphQL\Validator\Rules\VariablesAreInputTypes; +use GraphQL\Validator\Rules\VariablesDefaultValueAllowed; use GraphQL\Validator\Rules\VariablesInAllowedPosition; /** @@ -144,9 +132,9 @@ public static function defaultRules() UniqueDirectivesPerLocation::class => new UniqueDirectivesPerLocation(), KnownArgumentNames::class => new KnownArgumentNames(), UniqueArgumentNames::class => new UniqueArgumentNames(), - ArgumentsOfCorrectType::class => new ArgumentsOfCorrectType(), + ValuesOfCorrectType::class => new ValuesOfCorrectType(), ProvidedNonNullArguments::class => new ProvidedNonNullArguments(), - DefaultValuesOfCorrectType::class => new DefaultValuesOfCorrectType(), + VariablesDefaultValueAllowed::class => new VariablesDefaultValueAllowed(), VariablesInAllowedPosition::class => new VariablesInAllowedPosition(), OverlappingFieldsCanBeMerged::class => new OverlappingFieldsCanBeMerged(), UniqueInputFieldNames::class => new UniqueInputFieldNames(), @@ -226,121 +214,23 @@ public static function append(&$arr, $items) } /** - * Utility for validators which determines if a value literal AST is valid given - * an input type. + * Utility which determines if a value literal node is valid for an input type. * - * Note that this only validates literal values, variables are assumed to - * provide values of the correct type. + * Deprecated. Rely on validation for documents containing literal values. * - * @return array + * @deprecated + * @return Error[] */ public static function isValidLiteralValue(Type $type, $valueNode) { - // A value must be provided if the type is non-null. - if ($type instanceof NonNull) { - if (!$valueNode || $valueNode instanceof NullValueNode) { - return [ 'Expected "' . Utils::printSafe($type) . '", found null.' ]; - } - return static::isValidLiteralValue($type->getWrappedType(), $valueNode); - } - - if (!$valueNode || $valueNode instanceof NullValueNode) { - return []; - } - - // This function only tests literals, and assumes variables will provide - // values of the correct type. - if ($valueNode instanceof VariableNode) { - return []; - } - - // Lists accept a non-list value as a list of one. - if ($type instanceof ListOfType) { - $itemType = $type->getWrappedType(); - if ($valueNode instanceof ListValueNode) { - $errors = []; - foreach($valueNode->values as $index => $itemNode) { - $tmp = static::isValidLiteralValue($itemType, $itemNode); - - if ($tmp) { - $errors = array_merge($errors, Utils::map($tmp, function($error) use ($index) { - return "In element #$index: $error"; - })); - } - } - return $errors; - } - - return static::isValidLiteralValue($itemType, $valueNode); - } - - // Input objects check each defined field and look for undefined fields. - if ($type instanceof InputObjectType) { - if ($valueNode->kind !== NodeKind::OBJECT) { - return [ "Expected \"{$type->name}\", found not an object." ]; - } - - $fields = $type->getFields(); - - $errors = []; - - // Ensure every provided field is defined. - $fieldNodes = $valueNode->fields; - - foreach ($fieldNodes as $providedFieldNode) { - if (empty($fields[$providedFieldNode->name->value])) { - $errors[] = "In field \"{$providedFieldNode->name->value}\": Unknown field."; - } - } - - // Ensure every defined field is valid. - $fieldNodeMap = Utils::keyMap($fieldNodes, function($fieldNode) {return $fieldNode->name->value;}); - foreach ($fields as $fieldName => $field) { - $result = static::isValidLiteralValue( - $field->getType(), - isset($fieldNodeMap[$fieldName]) ? $fieldNodeMap[$fieldName]->value : null - ); - if ($result) { - $errors = array_merge($errors, Utils::map($result, function($error) use ($fieldName) { - return "In field \"$fieldName\": $error"; - })); - } - } - - return $errors; - } - - if ($type instanceof EnumType) { - if (!$valueNode instanceof EnumValueNode || !$type->getValue($valueNode->value)) { - $printed = Printer::doPrint($valueNode); - return ["Expected type \"{$type->name}\", found $printed."]; - } - - return []; - } - - if ($type instanceof ScalarType) { - // Scalars determine if a literal values is valid via parseLiteral(). - try { - $parseResult = $type->parseLiteral($valueNode); - if (Utils::isInvalid($parseResult)) { - $printed = Printer::doPrint($valueNode); - return ["Expected type \"{$type->name}\", found $printed."]; - } - } catch (\Exception $error) { - $printed = Printer::doPrint($valueNode); - $message = $error->getMessage(); - return ["Expected type \"{$type->name}\", found $printed; $message"]; - } catch (\Throwable $error) { - $printed = Printer::doPrint($valueNode); - $message = $error->getMessage(); - return ["Expected type \"{$type->name}\", found $printed; $message"]; - } - - return []; - } - - throw new Error('Unknown type: ' . Utils::printSafe($type) . '.'); + $emptySchema = new Schema([]); + $emptyDoc = new DocumentNode(['definitions' => []]); + $typeInfo = new TypeInfo($emptySchema, $type); + $context = new ValidationContext($emptySchema, $emptyDoc, $typeInfo); + $validator = new ValuesOfCorrectType(); + $visitor = $validator->getVisitor($context); + Visitor::visit($valueNode, Visitor::visitWithTypeInfo($typeInfo, $visitor)); + return $context->getErrors(); } /** diff --git a/src/Validator/Rules/ArgumentsOfCorrectType.php b/src/Validator/Rules/ArgumentsOfCorrectType.php deleted file mode 100644 index 3e373222b..000000000 --- a/src/Validator/Rules/ArgumentsOfCorrectType.php +++ /dev/null @@ -1,39 +0,0 @@ - function(ArgumentNode $argNode) use ($context) { - $argDef = $context->getArgument(); - if ($argDef) { - $errors = DocumentValidator::isValidLiteralValue($argDef->getType(), $argNode->value); - - if (!empty($errors)) { - $context->reportError(new Error( - self::badValueMessage($argNode->name->value, $argDef->getType(), Printer::doPrint($argNode->value), $errors), - [$argNode->value] - )); - } - } - return Visitor::skipNode(); - } - ]; - } -} diff --git a/src/Validator/Rules/DefaultValuesOfCorrectType.php b/src/Validator/Rules/DefaultValuesOfCorrectType.php deleted file mode 100644 index 792acd7a2..000000000 --- a/src/Validator/Rules/DefaultValuesOfCorrectType.php +++ /dev/null @@ -1,59 +0,0 @@ - function(VariableDefinitionNode $varDefNode) use ($context) { - $name = $varDefNode->variable->name->value; - $defaultValue = $varDefNode->defaultValue; - $type = $context->getInputType(); - - if ($type instanceof NonNull && $defaultValue) { - $context->reportError(new Error( - static::defaultForNonNullArgMessage($name, $type, $type->getWrappedType()), - [$defaultValue] - )); - } - if ($type && $defaultValue) { - $errors = DocumentValidator::isValidLiteralValue($type, $defaultValue); - if (!empty($errors)) { - $context->reportError(new Error( - static::badValueForDefaultArgMessage($name, $type, Printer::doPrint($defaultValue), $errors), - [$defaultValue] - )); - } - } - return Visitor::skipNode(); - }, - NodeKind::SELECTION_SET => function() {return Visitor::skipNode();}, - NodeKind::FRAGMENT_DEFINITION => function() {return Visitor::skipNode();} - ]; - } -} diff --git a/src/Validator/Rules/ValuesOfCorrectType.php b/src/Validator/Rules/ValuesOfCorrectType.php new file mode 100644 index 000000000..a70de1fa2 --- /dev/null +++ b/src/Validator/Rules/ValuesOfCorrectType.php @@ -0,0 +1,226 @@ + function(NullValueNode $node) use ($context) { + $type = $context->getInputType(); + if ($type instanceof NonNull) { + $context->reportError( + new Error( + self::badValueMessage((string) $type, Printer::doPrint($node)), + $node + ) + ); + } + }, + NodeKind::LST => function(ListValueNode $node) use ($context) { + // Note: TypeInfo will traverse into a list's item type, so look to the + // parent input type to check if it is a list. + $type = Type::getNullableType($context->getParentInputType()); + if (!$type instanceof ListOfType) { + $this->isValidScalar($context, $node); + return Visitor::skipNode(); + } + }, + NodeKind::OBJECT => function(ObjectValueNode $node) use ($context) { + // Note: TypeInfo will traverse into a list's item type, so look to the + // parent input type to check if it is a list. + $type = Type::getNamedType($context->getInputType()); + if (!$type instanceof InputObjectType) { + $this->isValidScalar($context, $node); + return Visitor::skipNode(); + } + // Ensure every required field exists. + $inputFields = $type->getFields(); + $nodeFields = iterator_to_array($node->fields); + $fieldNodeMap = array_combine( + array_map(function ($field) { return $field->name->value; }, $nodeFields), + array_values($nodeFields) + ); + foreach ($inputFields as $fieldName => $fieldDef) { + $fieldType = $fieldDef->getType(); + if (!isset($fieldNodeMap[$fieldName]) && $fieldType instanceof NonNull) { + $context->reportError( + new Error( + self::requiredFieldMessage($type->name, $fieldName, (string) $fieldType), + $node + ) + ); + } + } + }, + NodeKind::OBJECT_FIELD => function(ObjectFieldNode $node) use ($context) { + $parentType = Type::getNamedType($context->getParentInputType()); + $fieldType = $context->getInputType(); + if (!$fieldType && $parentType) { + $context->reportError( + new Error( + self::unknownFieldMessage($parentType->name, $node->name->value), + $node + ) + ); + } + }, + NodeKind::ENUM => function(EnumValueNode $node) use ($context) { + $type = Type::getNamedType($context->getInputType()); + if (!$type instanceof EnumType) { + $this->isValidScalar($context, $node); + } else if (!$type->getValue($node->value)) { + $context->reportError( + new Error( + self::badValueMessage( + $type->name, + Printer::doPrint($node), + $this->enumTypeSuggestion($type, $node) + ), + $node + ) + ); + } + }, + NodeKind::INT => function (IntValueNode $node) use ($context) { $this->isValidScalar($context, $node); }, + NodeKind::FLOAT => function (FloatValueNode $node) use ($context) { $this->isValidScalar($context, $node); }, + NodeKind::STRING => function (StringValueNode $node) use ($context) { $this->isValidScalar($context, $node); }, + NodeKind::BOOLEAN => function (BooleanValueNode $node) use ($context) { $this->isValidScalar($context, $node); }, + ]; + } + + private function isValidScalar(ValidationContext $context, ValueNode $node) + { + // Report any error at the full type expected by the location. + $locationType = $context->getInputType(); + + if (!$locationType) { + return; + } + + $type = Type::getNamedType($locationType); + + if (!$type instanceof ScalarType) { + $suggestions = $type instanceof EnumType + ? $this->enumTypeSuggestion($type, $node) + : null; + $context->reportError( + new Error( + self::badValueMessage( + (string) $locationType, + Printer::doPrint($node), + $suggestions + ), + $node + ) + ); + return; + } + + // Scalars determine if a literal value is valid via parseLiteral() which + // may throw or return an invalid value to indicate failure. + try { + $parseResult = $type->parseLiteral($node); + if (Utils::isInvalid($parseResult)) { + $context->reportError( + new Error( + self::badValueMessage( + (string) $locationType, + Printer::doPrint($node) + ), + $node + ) + ); + } + } catch (\Exception $error) { + // Ensure a reference to the original error is maintained. + $context->reportError( + new Error( + self::badValueMessage( + (string) $locationType, + Printer::doPrint($node), + $error->getMessage() + ), + $node, + null, + null, + null, + $error + ) + ); + } catch (\Throwable $error) { + // Ensure a reference to the original error is maintained. + $context->reportError( + new Error( + self::badValueMessage( + (string) $locationType, + Printer::doPrint($node), + $error->getMessage() + ), + $node, + null, + null, + null, + $error + ) + ); + } + } + + private function enumTypeSuggestion(EnumType $type, ValueNode $node) + { + $suggestions = Utils::suggestionList( + Printer::doPrint($node), + array_map(function (EnumValueDefinition $value) { return $value->name; }, $type->getValues()) + ); + + return $suggestions ? 'Did you mean the enum value: ' . Utils::orList($suggestions) . '?' : ''; + } +} diff --git a/src/Validator/Rules/VariablesDefaultValueAllowed.php b/src/Validator/Rules/VariablesDefaultValueAllowed.php new file mode 100644 index 000000000..fcbbef458 --- /dev/null +++ b/src/Validator/Rules/VariablesDefaultValueAllowed.php @@ -0,0 +1,60 @@ + function(VariableDefinitionNode $node) use ($context) { + $name = $node->variable->name->value; + $defaultValue = $node->defaultValue; + $type = $context->getInputType(); + if ($type instanceof NonNull && $defaultValue) { + $context->reportError( + new Error( + self::defaultForRequiredVarMessage( + $name, + $type, + $type->getWrappedType() + ), + [$defaultValue] + ) + ); + } + + return Visitor::skipNode(); + }, + NodeKind::SELECTION_SET => function(SelectionSetNode $node) use ($context) { + return Visitor::skipNode(); + }, + NodeKind::FRAGMENT_DEFINITION => function(FragmentDefinitionNode $node) use ($context) { + return Visitor::skipNode(); + }, + ]; + } +} diff --git a/src/Validator/ValidationContext.php b/src/Validator/ValidationContext.php index 51ea8d1d5..4d82ce471 100644 --- a/src/Validator/ValidationContext.php +++ b/src/Validator/ValidationContext.php @@ -12,11 +12,9 @@ use GraphQL\Type\Schema; use GraphQL\Language\AST\DocumentNode; use GraphQL\Language\AST\FragmentDefinitionNode; -use GraphQL\Language\AST\Node; use GraphQL\Type\Definition\CompositeType; use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\InputType; -use GraphQL\Type\Definition\OutputType; use GraphQL\Type\Definition\Type; use GraphQL\Utils\TypeInfo; @@ -275,6 +273,14 @@ function getInputType() return $this->typeInfo->getInputType(); } + /** + * @return InputType + */ + function getParentInputType() + { + return $this->typeInfo->getParentInputType(); + } + /** * @return FieldDefinition */ diff --git a/tests/Executor/VariablesTest.php b/tests/Executor/VariablesTest.php index 0180bbdd8..ccb16bb59 100644 --- a/tests/Executor/VariablesTest.php +++ b/tests/Executor/VariablesTest.php @@ -4,7 +4,6 @@ require_once __DIR__ . '/TestClasses.php'; use GraphQL\Error\Error; -use GraphQL\Error\InvariantViolation; use GraphQL\Executor\Executor; use GraphQL\Language\Parser; use GraphQL\Type\Schema; @@ -82,9 +81,9 @@ public function testUsingInlineStructs() $expected = [ 'data' => ['fieldWithObjectInput' => null], 'errors' => [[ - 'message' => 'Argument "input" got invalid value ["foo", "bar", "baz"].' . "\n" . - 'Expected "TestInputObject", found not an object.', - 'path' => ['fieldWithObjectInput'] + 'message' => 'Argument "input" has invalid value ["foo", "bar", "baz"].', + 'path' => ['fieldWithObjectInput'], + 'locations' => [['line' => 3, 'column' => 39]] ]] ]; $this->assertArraySubset($expected, $result); @@ -877,8 +876,7 @@ public function testNotWhenArgumentCannotBeCoerced() 'data' => ['fieldWithDefaultArgumentValue' => null], 'errors' => [[ 'message' => - 'Argument "input" got invalid value WRONG_TYPE.' . "\n" . - 'Expected type "String", found WRONG_TYPE.', + 'Argument "input" has invalid value WRONG_TYPE.', 'locations' => [ [ 'line' => 2, 'column' => 50 ] ], 'path' => [ 'fieldWithDefaultArgumentValue' ], 'category' => 'graphql', diff --git a/tests/Type/EnumTypeTest.php b/tests/Type/EnumTypeTest.php index 0761cb1b9..9c6910dd6 100644 --- a/tests/Type/EnumTypeTest.php +++ b/tests/Type/EnumTypeTest.php @@ -220,7 +220,22 @@ public function testDoesNotAcceptStringLiterals() '{ colorEnum(fromEnum: "GREEN") }', null, [ - 'message' => "Argument \"fromEnum\" has invalid value \"GREEN\".\nExpected type \"Color\", found \"GREEN\".", + 'message' => "Expected type Color, found \"GREEN\"; Did you mean the enum value: GREEN?", + 'locations' => [new SourceLocation(1, 23)] + ] + ); + } + + /** + * @it does not accept valuesNotInTheEnum + */ + public function testDoesNotAcceptValuesNotInTheEnum() + { + $this->expectFailure( + '{ colorEnum(fromEnum: GREENISH) }', + null, + [ + 'message' => "Expected type Color, found GREENISH; Did you mean the enum value: GREEN?", 'locations' => [new SourceLocation(1, 23)] ] ); @@ -236,7 +251,8 @@ public function testDoesNotAcceptIncorrectInternalValue() null, [ 'message' => 'Expected a value of type "Color" but received: GREEN', - 'locations' => [new SourceLocation(1, 3)] + 'locations' => [new SourceLocation(1, 3)], + 'path' => ['colorEnum'], ] ); } @@ -249,7 +265,7 @@ public function testDoesNotAcceptInternalValueInPlaceOfEnumLiteral() $this->expectFailure( '{ colorEnum(fromEnum: 1) }', null, - "Argument \"fromEnum\" has invalid value 1.\nExpected type \"Color\", found 1." + "Expected type Color, found 1." ); } @@ -261,7 +277,7 @@ public function testDoesNotAcceptEnumLiteralInPlaceOfInt() $this->expectFailure( '{ colorEnum(fromInt: GREEN) }', null, - "Argument \"fromInt\" has invalid value GREEN.\nExpected type \"Int\", found GREEN." + "Expected type Int, found GREEN." ); } diff --git a/tests/Utils/IsValidLiteralValueTest.php b/tests/Utils/IsValidLiteralValueTest.php new file mode 100644 index 000000000..33b05922f --- /dev/null +++ b/tests/Utils/IsValidLiteralValueTest.php @@ -0,0 +1,37 @@ +assertEquals( + [], + DocumentValidator::isValidLiteralValue(Type::int(), Parser::parseValue('123')) + ); + } + + /** + * @it Returns errors for an invalid value + */ + public function testReturnsErrorsForForInvalidValue() + { + $errors = DocumentValidator::isValidLiteralValue(Type::int(), Parser::parseValue('"abc"')); + + $this->assertCount(1, $errors); + $this->assertEquals('Expected type Int, found "abc".', $errors[0]->getMessage()); + $this->assertEquals([new SourceLocation(1, 1)], $errors[0]->getLocations()); + $this->assertEquals(null, $errors[0]->getPath()); + } +} diff --git a/tests/Validator/DefaultValuesOfCorrectTypeTest.php b/tests/Validator/DefaultValuesOfCorrectTypeTest.php deleted file mode 100644 index a0ac412c4..000000000 --- a/tests/Validator/DefaultValuesOfCorrectTypeTest.php +++ /dev/null @@ -1,188 +0,0 @@ -expectPassesRule(new DefaultValuesOfCorrectType, ' - query NullableValues($a: Int, $b: String, $c: ComplexInput) { - dog { name } - } - '); - } - - /** - * @it required variables without default values - */ - public function testRequiredVariablesWithoutDefaultValues() - { - $this->expectPassesRule(new DefaultValuesOfCorrectType, ' - query RequiredValues($a: Int!, $b: String!) { - dog { name } - } - '); - } - - /** - * @it variables with valid default values - */ - public function testVariablesWithValidDefaultValues() - { - $this->expectPassesRule(new DefaultValuesOfCorrectType, ' - query WithDefaultValues( - $a: Int = 1, - $b: String = "ok", - $c: ComplexInput = { requiredField: true, intField: 3 } - ) { - dog { name } - } - '); - } - - /** - * @it variables with valid default null values - */ - public function testVariablesWithValidDefaultNullValues() - { - $this->expectPassesRule(new DefaultValuesOfCorrectType(), ' - query WithDefaultValues( - $a: Int = null, - $b: String = null, - $c: ComplexInput = { requiredField: true, intField: null } - ) { - dog { name } - } - '); - } - - /** - * @it variables with invalid default null values - */ - public function testVariablesWithInvalidDefaultNullValues() - { - $this->expectFailsRule(new DefaultValuesOfCorrectType(), ' - query WithDefaultValues( - $a: Int! = null, - $b: String! = null, - $c: ComplexInput = { requiredField: null, intField: null } - ) { - dog { name } - } - ', [ - $this->defaultForNonNullArg('a', 'Int!', 'Int', 3, 20), - $this->badValue('a', 'Int!', 'null', 3, 20, [ - 'Expected "Int!", found null.' - ]), - $this->defaultForNonNullArg('b', 'String!', 'String', 4, 23), - $this->badValue('b', 'String!', 'null', 4, 23, [ - 'Expected "String!", found null.' - ]), - $this->badValue('c', 'ComplexInput', '{requiredField: null, intField: null}', - 5, 28, [ - 'In field "requiredField": Expected "Boolean!", found null.' - ] - ), - ]); - } - - /** - * @it no required variables with default values - */ - public function testNoRequiredVariablesWithDefaultValues() - { - $this->expectFailsRule(new DefaultValuesOfCorrectType, ' - query UnreachableDefaultValues($a: Int! = 3, $b: String! = "default") { - dog { name } - } - ', [ - $this->defaultForNonNullArg('a', 'Int!', 'Int', 2, 49), - $this->defaultForNonNullArg('b', 'String!', 'String', 2, 66) - ]); - } - - /** - * @it variables with invalid default values - */ - public function testVariablesWithInvalidDefaultValues() - { - $this->expectFailsRule(new DefaultValuesOfCorrectType, ' - query InvalidDefaultValues( - $a: Int = "one", - $b: String = 4, - $c: ComplexInput = "notverycomplex" - ) { - dog { name } - } - ', [ - $this->badValue('a', 'Int', '"one"', 3, 19, [ - 'Expected type "Int", found "one".' - ]), - $this->badValue('b', 'String', '4', 4, 22, [ - 'Expected type "String", found 4.' - ]), - $this->badValue('c', 'ComplexInput', '"notverycomplex"', 5, 28, [ - 'Expected "ComplexInput", found not an object.' - ]) - ]); - } - - /** - * @it complex variables missing required field - */ - public function testComplexVariablesMissingRequiredField() - { - $this->expectFailsRule(new DefaultValuesOfCorrectType, ' - query MissingRequiredField($a: ComplexInput = {intField: 3}) { - dog { name } - } - ', [ - $this->badValue('a', 'ComplexInput', '{intField: 3}', 2, 53, [ - 'In field "requiredField": Expected "Boolean!", found null.' - ]) - ]); - } - - /** - * @it list variables with invalid item - */ - public function testListVariablesWithInvalidItem() - { - $this->expectFailsRule(new DefaultValuesOfCorrectType, ' - query InvalidItem($a: [String] = ["one", 2]) { - dog { name } - } - ', [ - $this->badValue('a', '[String]', '["one", 2]', 2, 40, [ - 'In element #1: Expected type "String", found 2.' - ]) - ]); - } - - private function defaultForNonNullArg($varName, $typeName, $guessTypeName, $line, $column) - { - return FormattedError::create( - DefaultValuesOfCorrectType::defaultForNonNullArgMessage($varName, $typeName, $guessTypeName), - [ new SourceLocation($line, $column) ] - ); - } - - private function badValue($varName, $typeName, $val, $line, $column, $errors = null) - { - $realErrors = !$errors ? ["Expected type \"$typeName\", found $val."] : $errors; - - return FormattedError::create( - DefaultValuesOfCorrectType::badValueForDefaultArgMessage($varName, $typeName, $val, $realErrors), - [ new SourceLocation($line, $column) ] - ); - } -} diff --git a/tests/Validator/TestCase.php b/tests/Validator/TestCase.php index 770e650ae..164effa0f 100644 --- a/tests/Validator/TestCase.php +++ b/tests/Validator/TestCase.php @@ -260,13 +260,6 @@ public static function getTestSchema() ] ]); - $anyScalar = new CustomScalarType([ - 'name' => 'Any', - 'serialize' => function ($value) { return $value; }, - 'parseLiteral' => function ($node) { return $node; }, // Allows any value - 'parseValue' => function ($value) { return $value; }, // Allows any value - ]); - $invalidScalar = new CustomScalarType([ 'name' => 'Invalid', 'serialize' => function ($value) { @@ -280,6 +273,13 @@ public static function getTestSchema() }, ]); + $anyScalar = new CustomScalarType([ + 'name' => 'Any', + 'serialize' => function ($value) { return $value; }, + 'parseLiteral' => function ($node) { return $node; }, // Allows any value + 'parseValue' => function ($value) { return $value; }, // Allows any value + ]); + $queryRoot = new ObjectType([ 'name' => 'QueryRoot', 'fields' => [ @@ -295,16 +295,16 @@ public static function getTestSchema() 'dogOrHuman' => ['type' => $DogOrHuman], 'humanOrAlien' => ['type' => $HumanOrAlien], 'complicatedArgs' => ['type' => $ComplicatedArgs], - 'anyArg' => [ - 'args' => ['arg' => ['type' => $anyScalar]], - 'type' => Type::string(), - ], 'invalidArg' => [ 'args' => [ 'arg' => ['type' => $invalidScalar] ], 'type' => Type::string(), - ] + ], + 'anyArg' => [ + 'args' => ['arg' => ['type' => $anyScalar]], + 'type' => Type::string(), + ], ] ]); diff --git a/tests/Validator/ValidationTest.php b/tests/Validator/ValidationTest.php index 6cab583de..51c7dbf2c 100644 --- a/tests/Validator/ValidationTest.php +++ b/tests/Validator/ValidationTest.php @@ -1,10 +1,6 @@ "Argument \"arg\" has invalid value \"bad value\". -Expected type \"Invalid\", found \"bad value\"; Invalid scalar is always invalid: bad value", + 'message' => "Expected type Invalid, found \"bad value\"; Invalid scalar is always invalid: bad value", 'locations' => [ ['line' => 3, 'column' => 25] ] ]; diff --git a/tests/Validator/ArgumentsOfCorrectTypeTest.php b/tests/Validator/ValuesOfCorrectTypeTest.php similarity index 58% rename from tests/Validator/ArgumentsOfCorrectTypeTest.php rename to tests/Validator/ValuesOfCorrectTypeTest.php index 1f1c9dfbb..dd62ebd33 100644 --- a/tests/Validator/ArgumentsOfCorrectTypeTest.php +++ b/tests/Validator/ValuesOfCorrectTypeTest.php @@ -3,21 +3,44 @@ use GraphQL\Error\FormattedError; use GraphQL\Language\SourceLocation; -use GraphQL\Validator\Rules\ArgumentsOfCorrectType; +use GraphQL\Validator\Rules\ValuesOfCorrectType; -class ArgumentsOfCorrectTypeTest extends TestCase +class ValuesOfCorrectTypeTest extends TestCase { - function badValue($argName, $typeName, $value, $line, $column, $errors = null) + private function badValue($typeName, $value, $line, $column, $message = null) { - $realErrors = !$errors ? ["Expected type \"$typeName\", found $value."] : $errors; + return FormattedError::create( + ValuesOfCorrectType::badValueMessage( + $typeName, + $value, + $message + ), + [new SourceLocation($line, $column)] + ); + } + private function requiredField($typeName, $fieldName, $fieldTypeName, $line, $column) { return FormattedError::create( - ArgumentsOfCorrectType::badValueMessage($argName, $typeName, $value, $realErrors), + ValuesOfCorrectType::requiredFieldMessage( + $typeName, + $fieldName, + $fieldTypeName + ), [new SourceLocation($line, $column)] ); } - // Validate: Argument values of correct type + private function unknownField($typeName, $fieldName, $line, $column) { + return FormattedError::create( + ValuesOfCorrectType::unknownFieldMessage( + $typeName, + $fieldName + ), + [new SourceLocation($line, $column)] + ); + } + + // Validate: Values of correct type // Valid values /** @@ -25,7 +48,7 @@ function badValue($argName, $typeName, $value, $line, $column, $errors = null) */ public function testGoodIntValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { intArgField(intArg: 2) @@ -39,7 +62,7 @@ public function testGoodIntValue() */ public function testGoodNegativeIntValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { intArgField(intArg: -2) @@ -53,7 +76,7 @@ public function testGoodNegativeIntValue() */ public function testGoodBooleanValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { booleanArgField(booleanArg: true) @@ -67,7 +90,7 @@ public function testGoodBooleanValue() */ public function testGoodStringValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { stringArgField(stringArg: "foo") @@ -81,7 +104,7 @@ public function testGoodStringValue() */ public function testGoodFloatValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { floatArgField(floatArg: 1.1) @@ -92,7 +115,7 @@ public function testGoodFloatValue() public function testGoodNegativeFloatValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { floatArgField(floatArg: -1.1) @@ -106,7 +129,7 @@ public function testGoodNegativeFloatValue() */ public function testIntIntoFloat() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { floatArgField(floatArg: 1) @@ -120,7 +143,7 @@ public function testIntIntoFloat() */ public function testIntIntoID() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { idArgField(idArg: 1) @@ -134,7 +157,7 @@ public function testIntIntoID() */ public function testStringIntoID() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { idArgField(idArg: "someIdString") @@ -148,7 +171,7 @@ public function testStringIntoID() */ public function testGoodEnumValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { dog { doesKnowCommand(dogCommand: SIT) @@ -162,7 +185,7 @@ public function testGoodEnumValue() */ public function testEnumWithNullValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { enumArgField(enumArg: NO_FUR) @@ -176,7 +199,7 @@ enumArgField(enumArg: NO_FUR) */ public function testNullIntoNullableType() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { intArgField(intArg: null) @@ -184,7 +207,7 @@ public function testNullIntoNullableType() } '); - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { dog(a: null, b: null, c:{ requiredField: true, intField: null }) { name @@ -200,14 +223,14 @@ public function testNullIntoNullableType() */ public function testIntIntoString() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { stringArgField(stringArg: 1) } } ', [ - $this->badValue('stringArg', 'String', '1', 4, 39) + $this->badValue('String', '1', 4, 39) ]); } @@ -216,14 +239,14 @@ public function testIntIntoString() */ public function testFloatIntoString() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { stringArgField(stringArg: 1.0) } } ', [ - $this->badValue('stringArg', 'String', '1.0', 4, 39) + $this->badValue('String', '1.0', 4, 39) ]); } @@ -232,14 +255,14 @@ public function testFloatIntoString() */ public function testBooleanIntoString() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { stringArgField(stringArg: true) } } ', [ - $this->badValue('stringArg', 'String', 'true', 4, 39) + $this->badValue('String', 'true', 4, 39) ]); } @@ -248,14 +271,14 @@ public function testBooleanIntoString() */ public function testUnquotedStringIntoString() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { stringArgField(stringArg: BAR) } } ', [ - $this->badValue('stringArg', 'String', 'BAR', 4, 39) + $this->badValue('String', 'BAR', 4, 39) ]); } @@ -266,14 +289,14 @@ public function testUnquotedStringIntoString() */ public function testStringIntoInt() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { intArgField(intArg: "3") } } ', [ - $this->badValue('intArg', 'Int', '"3"', 4, 33) + $this->badValue('Int', '"3"', 4, 33) ]); } @@ -282,14 +305,14 @@ public function testStringIntoInt() */ public function testBigIntIntoInt() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { intArgField(intArg: 829384293849283498239482938) } } ', [ - $this->badValue('intArg', 'Int', '829384293849283498239482938', 4, 33) + $this->badValue('Int', '829384293849283498239482938', 4, 33) ]); } @@ -298,14 +321,14 @@ public function testBigIntIntoInt() */ public function testUnquotedStringIntoInt() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { intArgField(intArg: FOO) } } ', [ - $this->badValue('intArg', 'Int', 'FOO', 4, 33) + $this->badValue('Int', 'FOO', 4, 33) ]); } @@ -314,14 +337,14 @@ public function testUnquotedStringIntoInt() */ public function testSimpleFloatIntoInt() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { intArgField(intArg: 3.0) } } ', [ - $this->badValue('intArg', 'Int', '3.0', 4, 33) + $this->badValue('Int', '3.0', 4, 33) ]); } @@ -330,14 +353,14 @@ public function testSimpleFloatIntoInt() */ public function testFloatIntoInt() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { intArgField(intArg: 3.333) } } ', [ - $this->badValue('intArg', 'Int', '3.333', 4, 33) + $this->badValue('Int', '3.333', 4, 33) ]); } @@ -348,14 +371,14 @@ public function testFloatIntoInt() */ public function testStringIntoFloat() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { floatArgField(floatArg: "3.333") } } ', [ - $this->badValue('floatArg', 'Float', '"3.333"', 4, 37) + $this->badValue('Float', '"3.333"', 4, 37) ]); } @@ -364,14 +387,14 @@ public function testStringIntoFloat() */ public function testBooleanIntoFloat() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { floatArgField(floatArg: true) } } ', [ - $this->badValue('floatArg', 'Float', 'true', 4, 37) + $this->badValue('Float', 'true', 4, 37) ]); } @@ -380,14 +403,14 @@ public function testBooleanIntoFloat() */ public function testUnquotedIntoFloat() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { floatArgField(floatArg: FOO) } } ', [ - $this->badValue('floatArg', 'Float', 'FOO', 4, 37) + $this->badValue('Float', 'FOO', 4, 37) ]); } @@ -398,14 +421,14 @@ public function testUnquotedIntoFloat() */ public function testIntIntoBoolean() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { booleanArgField(booleanArg: 2) } } ', [ - $this->badValue('booleanArg', 'Boolean', '2', 4, 41) + $this->badValue('Boolean', '2', 4, 41) ]); } @@ -414,14 +437,14 @@ public function testIntIntoBoolean() */ public function testFloatIntoBoolean() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { booleanArgField(booleanArg: 1.0) } } ', [ - $this->badValue('booleanArg', 'Boolean', '1.0', 4, 41) + $this->badValue('Boolean', '1.0', 4, 41) ]); } @@ -430,14 +453,14 @@ public function testFloatIntoBoolean() */ public function testStringIntoBoolean() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { booleanArgField(booleanArg: "true") } } ', [ - $this->badValue('booleanArg', 'Boolean', '"true"', 4, 41) + $this->badValue('Boolean', '"true"', 4, 41) ]); } @@ -446,14 +469,14 @@ public function testStringIntoBoolean() */ public function testUnquotedIntoBoolean() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { booleanArgField(booleanArg: TRUE) } } ', [ - $this->badValue('booleanArg', 'Boolean', 'TRUE', 4, 41) + $this->badValue('Boolean', 'TRUE', 4, 41) ]); } @@ -464,14 +487,14 @@ public function testUnquotedIntoBoolean() */ public function testFloatIntoID() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { idArgField(idArg: 1.0) } } ', [ - $this->badValue('idArg', 'ID', '1.0', 4, 31) + $this->badValue('ID', '1.0', 4, 31) ]); } @@ -480,14 +503,14 @@ public function testFloatIntoID() */ public function testBooleanIntoID() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { idArgField(idArg: true) } } ', [ - $this->badValue('idArg', 'ID', 'true', 4, 31) + $this->badValue('ID', 'true', 4, 31) ]); } @@ -496,14 +519,14 @@ public function testBooleanIntoID() */ public function testUnquotedIntoID() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { idArgField(idArg: SOMETHING) } } ', [ - $this->badValue('idArg', 'ID', 'SOMETHING', 4, 31) + $this->badValue('ID', 'SOMETHING', 4, 31) ]); } @@ -514,14 +537,14 @@ public function testUnquotedIntoID() */ public function testIntIntoEnum() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { dog { doesKnowCommand(dogCommand: 2) } } ', [ - $this->badValue('dogCommand', 'DogCommand', '2', 4, 41) + $this->badValue('DogCommand', '2', 4, 41) ]); } @@ -530,14 +553,14 @@ public function testIntIntoEnum() */ public function testFloatIntoEnum() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { dog { doesKnowCommand(dogCommand: 1.0) } } ', [ - $this->badValue('dogCommand', 'DogCommand', '1.0', 4, 41) + $this->badValue('DogCommand', '1.0', 4, 41) ]); } @@ -546,14 +569,20 @@ public function testFloatIntoEnum() */ public function testStringIntoEnum() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { dog { doesKnowCommand(dogCommand: "SIT") } } ', [ - $this->badValue('dogCommand', 'DogCommand', '"SIT"', 4, 41) + $this->badValue( + 'DogCommand', + '"SIT"', + 4, + 41, + 'Did you mean the enum value: SIT?' + ) ]); } @@ -562,14 +591,14 @@ public function testStringIntoEnum() */ public function testBooleanIntoEnum() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { dog { doesKnowCommand(dogCommand: true) } } ', [ - $this->badValue('dogCommand', 'DogCommand', 'true', 4, 41) + $this->badValue('DogCommand', 'true', 4, 41) ]); } @@ -578,14 +607,14 @@ public function testBooleanIntoEnum() */ public function testUnknownEnumValueIntoEnum() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { dog { doesKnowCommand(dogCommand: JUGGLE) } } ', [ - $this->badValue('dogCommand', 'DogCommand', 'JUGGLE', 4, 41) + $this->badValue('DogCommand', 'JUGGLE', 4, 41) ]); } @@ -594,14 +623,14 @@ public function testUnknownEnumValueIntoEnum() */ public function testDifferentCaseEnumValueIntoEnum() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { dog { doesKnowCommand(dogCommand: sit) } } ', [ - $this->badValue('dogCommand', 'DogCommand', 'sit', 4, 41) + $this->badValue('DogCommand', 'sit', 4, 41) ]); } @@ -612,7 +641,7 @@ public function testDifferentCaseEnumValueIntoEnum() */ public function testGoodListValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { stringListArgField(stringListArg: ["one", null, "two"]) @@ -626,7 +655,7 @@ public function testGoodListValue() */ public function testEmptyListValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { stringListArgField(stringListArg: []) @@ -640,7 +669,7 @@ public function testEmptyListValue() */ public function testNullValue() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { stringListArgField(stringListArg: null) @@ -654,7 +683,7 @@ public function testNullValue() */ public function testSingleValueIntoList() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { stringListArgField(stringListArg: "one") @@ -670,16 +699,14 @@ public function testSingleValueIntoList() */ public function testIncorrectItemtype() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { stringListArgField(stringListArg: ["one", 2]) } } ', [ - $this->badValue('stringListArg', '[String]', '["one", 2]', 4, 47, [ - 'In element #1: Expected type "String", found 2.' - ]), + $this->badValue('String', '2', 4, 55), ]); } @@ -688,14 +715,14 @@ public function testIncorrectItemtype() */ public function testSingleValueOfIncorrectType() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { stringListArgField(stringListArg: 1) } } ', [ - $this->badValue('stringListArg', 'String', '1', 4, 47), + $this->badValue('[String]', '1', 4, 47), ]); } @@ -706,7 +733,7 @@ public function testSingleValueOfIncorrectType() */ public function testArgOnOptionalArg() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { dog { isHousetrained(atOtherHomes: true) @@ -720,7 +747,7 @@ public function testArgOnOptionalArg() */ public function testNoArgOnOptionalArg() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { dog { isHousetrained @@ -734,7 +761,7 @@ public function testNoArgOnOptionalArg() */ public function testMultipleArgs() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleReqs(req1: 1, req2: 2) @@ -748,7 +775,7 @@ public function testMultipleArgs() */ public function testMultipleArgsReverseOrder() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleReqs(req2: 2, req1: 1) @@ -762,7 +789,7 @@ public function testMultipleArgsReverseOrder() */ public function testNoArgsOnMultipleOptional() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleOpts @@ -776,7 +803,7 @@ public function testNoArgsOnMultipleOptional() */ public function testOneArgOnMultipleOptional() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleOpts(opt1: 1) @@ -790,7 +817,7 @@ public function testOneArgOnMultipleOptional() */ public function testSecondArgOnMultipleOptional() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleOpts(opt2: 1) @@ -804,7 +831,7 @@ public function testSecondArgOnMultipleOptional() */ public function testMultipleReqsOnMixedList() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleOptAndReq(req1: 3, req2: 4) @@ -818,7 +845,7 @@ public function testMultipleReqsOnMixedList() */ public function testMultipleReqsAndOneOptOnMixedList() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleOptAndReq(req1: 3, req2: 4, opt1: 5) @@ -832,7 +859,7 @@ public function testMultipleReqsAndOneOptOnMixedList() */ public function testAllReqsAndOptsOnMixedList() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleOptAndReq(req1: 3, req2: 4, opt1: 5, opt2: 6) @@ -848,31 +875,31 @@ public function testAllReqsAndOptsOnMixedList() */ public function testIncorrectValueType() { - $this->expectFailsRule(new ArgumentsOfCorrectType, ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleReqs(req2: "two", req1: "one") } } ', [ - $this->badValue('req2', 'Int', '"two"', 4, 32), - $this->badValue('req1', 'Int', '"one"', 4, 45), + $this->badValue('Int!', '"two"', 4, 32), + $this->badValue('Int!', '"one"', 4, 45), ]); } /** - * @it Incorrect value and missing argument + * @it Incorrect value and missing argument (ProvidedNonNullArguments) */ - public function testIncorrectValueAndMissingArgument() + public function testIncorrectValueAndMissingArgumentProvidedNonNullArguments() { - $this->expectFailsRule(new ArgumentsOfCorrectType, ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleReqs(req1: "one") } } ', [ - $this->badValue('req1', 'Int', '"one"', 4, 32), + $this->badValue('Int!', '"one"', 4, 32), ]); } @@ -881,28 +908,26 @@ public function testIncorrectValueAndMissingArgument() */ public function testNullValue2() { - $this->expectFailsRule(new ArgumentsOfCorrectType(), ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { multipleReqs(req1: null) } } ', [ - $this->badValue('req1', 'Int!', 'null', 4, 32, [ - 'Expected "Int!", found null.' - ]), + $this->badValue('Int!', 'null', 4, 32), ]); } - // Valid input object value + // DESCRIBE: Valid input object value /** * @it Optional arg, despite required field in type */ public function testOptionalArgDespiteRequiredFieldInType() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { complexArgField @@ -916,7 +941,7 @@ public function testOptionalArgDespiteRequiredFieldInType() */ public function testPartialObjectOnlyRequired() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { complexArgField(complexArg: { requiredField: true }) @@ -930,7 +955,7 @@ public function testPartialObjectOnlyRequired() */ public function testPartialObjectRequiredFieldCanBeFalsey() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { complexArgField(complexArg: { requiredField: false }) @@ -944,7 +969,7 @@ public function testPartialObjectRequiredFieldCanBeFalsey() */ public function testPartialObjectIncludingRequired() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { complexArgField(complexArg: { requiredField: true, intField: 4 }) @@ -958,7 +983,7 @@ public function testPartialObjectIncludingRequired() */ public function testFullObject() { - $this->expectPassesRule(new ArgumentsOfCorrectType, ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { complexArgField(complexArg: { @@ -978,7 +1003,7 @@ public function testFullObject() */ public function testFullObjectWithFieldsInDifferentOrder() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { complicatedArgs { complexArgField(complexArg: { @@ -993,23 +1018,21 @@ public function testFullObjectWithFieldsInDifferentOrder() '); } - // Invalid input object value + // DESCRIBE: Invalid input object value /** * @it Partial object, missing required */ public function testPartialObjectMissingRequired() { - $this->expectFailsRule(new ArgumentsOfCorrectType, ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { complexArgField(complexArg: { intField: 4 }) } } ', [ - $this->badValue('complexArg', 'ComplexInput', '{intField: 4}', 4, 41, [ - 'In field "requiredField": Expected "Boolean!", found null.' - ]), + $this->requiredField('ComplexInput', 'requiredField', 'Boolean!', 4, 41), ]); } @@ -1018,7 +1041,7 @@ public function testPartialObjectMissingRequired() */ public function testPartialObjectInvalidFieldType() { - $this->expectFailsRule(new ArgumentsOfCorrectType, ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { complexArgField(complexArg: { @@ -1028,14 +1051,7 @@ public function testPartialObjectInvalidFieldType() } } ', [ - $this->badValue( - 'complexArg', - 'ComplexInput', - '{stringListField: ["one", 2], requiredField: true}', - 4, - 41, - [ 'In field "stringListField": In element #1: Expected type "String", found 2.' ] - ), + $this->badValue('String', '2', 5, 40), ]); } @@ -1044,7 +1060,7 @@ public function testPartialObjectInvalidFieldType() */ public function testPartialObjectUnknownFieldArg() { - $this->expectFailsRule(new ArgumentsOfCorrectType, ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { complicatedArgs { complexArgField(complexArg: { @@ -1053,26 +1069,61 @@ public function testPartialObjectUnknownFieldArg() }) } } + ', [ + $this->unknownField('ComplexInput', 'unknownField', 6, 15), + ]); + } + + + + /** + * @it reports original error for custom scalar which throws + */ + public function testReportsOriginalErrorForCustomScalarWhichThrows() + { + $errors = $this->expectFailsRule(new ValuesOfCorrectType, ' + { + invalidArg(arg: 123) + } ', [ $this->badValue( - 'complexArg', - 'ComplexInput', - '{requiredField: true, unknownField: "value"}', - 4, - 41, - [ 'In field "unknownField": Unknown field.' ] + 'Invalid', + '123', + 3, + 27, + 'Invalid scalar is always invalid: 123' ), ]); + + $this->assertEquals( + 'Invalid scalar is always invalid: 123', + $errors[0]->getPrevious()->getMessage() + ); } - // Directive arguments + /** + * @it allows custom scalar to accept complex literals + */ + public function testAllowsCustomScalarToAcceptComplexLiterals() + { + $this->expectPassesRule(new ValuesOfCorrectType, ' + { + test1: anyArg(arg: 123) + test2: anyArg(arg: "abc") + test3: anyArg(arg: [123, "abc"]) + test4: anyArg(arg: {deep: [123, "abc"]}) + } + '); + } + + // DESCRIBE: Directive arguments /** * @it with directives of valid types */ public function testWithDirectivesOfValidTypes() { - $this->expectPassesRule(new ArgumentsOfCorrectType(), ' + $this->expectPassesRule(new ValuesOfCorrectType, ' { dog @include(if: true) { name @@ -1081,7 +1132,7 @@ public function testWithDirectivesOfValidTypes() name } } - '); + '); } /** @@ -1089,15 +1140,134 @@ public function testWithDirectivesOfValidTypes() */ public function testWithDirectiveWithIncorrectTypes() { - $this->expectFailsRule(new ArgumentsOfCorrectType, ' + $this->expectFailsRule(new ValuesOfCorrectType, ' { dog @include(if: "yes") { name @skip(if: ENUM) } } - ', [ - $this->badValue('if', 'Boolean', '"yes"', 3, 28), - $this->badValue('if', 'Boolean', 'ENUM', 4, 28), + ', [ + $this->badValue('Boolean!', '"yes"', 3, 28), + $this->badValue('Boolean!', 'ENUM', 4, 28), + ]); + } + + // DESCRIBE: Variable default values + + /** + * @it variables with valid default values + */ + public function testVariablesWithValidDefaultValues() + { + $this->expectPassesRule(new ValuesOfCorrectType, ' + query WithDefaultValues( + $a: Int = 1, + $b: String = "ok", + $c: ComplexInput = { requiredField: true, intField: 3 } + ) { + dog { name } + } + '); + } + + /** + * @it variables with valid default null values + */ + public function testVariablesWithValidDefaultNullValues() + { + $this->expectPassesRule(new ValuesOfCorrectType, ' + query WithDefaultValues( + $a: Int = null, + $b: String = null, + $c: ComplexInput = { requiredField: true, intField: null } + ) { + dog { name } + } + '); + } + + /** + * @it variables with invalid default null values + */ + public function testVariablesWithInvalidDefaultNullValues() + { + $this->expectFailsRule(new ValuesOfCorrectType, ' + query WithDefaultValues( + $a: Int! = null, + $b: String! = null, + $c: ComplexInput = { requiredField: null, intField: null } + ) { + dog { name } + } + ', [ + $this->badValue('Int!', 'null', 3, 22), + $this->badValue('String!', 'null', 4, 25), + $this->badValue('Boolean!', 'null', 5, 47), + ]); + } + + /** + * @it variables with invalid default values + */ + public function testVariablesWithInvalidDefaultValues() + { + $this->expectFailsRule(new ValuesOfCorrectType, ' + query InvalidDefaultValues( + $a: Int = "one", + $b: String = 4, + $c: ComplexInput = "notverycomplex" + ) { + dog { name } + } + ', [ + $this->badValue('Int', '"one"', 3, 21), + $this->badValue('String', '4', 4, 24), + $this->badValue('ComplexInput', '"notverycomplex"', 5, 30), + ]); + } + + /** + * @it variables with complex invalid default values + */ + public function testVariablesWithComplexInvalidDefaultValues() + { + $this->expectFailsRule(new ValuesOfCorrectType, ' + query WithDefaultValues( + $a: ComplexInput = { requiredField: 123, intField: "abc" } + ) { + dog { name } + } + ', [ + $this->badValue('Boolean!', '123', 3, 47), + $this->badValue('Int', '"abc"', 3, 62), + ]); + } + + /** + * @it complex variables missing required field + */ + public function testComplexVariablesMissingRequiredField() + { + $this->expectFailsRule(new ValuesOfCorrectType, ' + query MissingRequiredField($a: ComplexInput = {intField: 3}) { + dog { name } + } + ', [ + $this->requiredField('ComplexInput', 'requiredField', 'Boolean!', 2, 55), + ]); + } + + /** + * @it list variables with invalid item + */ + public function testListVariablesWithInvalidItem() + { + $this->expectFailsRule(new ValuesOfCorrectType, ' + query InvalidItem($a: [String] = ["one", 2]) { + dog { name } + } + ', [ + $this->badValue('String', '2', 2, 50), ]); } } diff --git a/tests/Validator/VariablesDefaultValueAllowedTest.php b/tests/Validator/VariablesDefaultValueAllowedTest.php new file mode 100644 index 000000000..da4ca9b97 --- /dev/null +++ b/tests/Validator/VariablesDefaultValueAllowedTest.php @@ -0,0 +1,109 @@ +expectPassesRule(new VariablesDefaultValueAllowed(), ' + query NullableValues($a: Int, $b: String, $c: ComplexInput) { + dog { name } + } + '); + } + + /** + * @it required variables without default values + */ + public function testRequiredVariablesWithoutDefaultValues() + { + $this->expectPassesRule(new VariablesDefaultValueAllowed(), ' + query RequiredValues($a: Int!, $b: String!) { + dog { name } + } + '); + } + + /** + * @it variables with valid default values + */ + public function testVariablesWithValidDefaultValues() + { + $this->expectPassesRule(new VariablesDefaultValueAllowed(), ' + query WithDefaultValues( + $a: Int = 1, + $b: String = "ok", + $c: ComplexInput = { requiredField: true, intField: 3 } + ) { + dog { name } + } + '); + } + + /** + * @it variables with valid default null values + */ + public function testVariablesWithValidDefaultNullValues() + { + $this->expectPassesRule(new VariablesDefaultValueAllowed(), ' + query WithDefaultValues( + $a: Int = null, + $b: String = null, + $c: ComplexInput = { requiredField: true, intField: null } + ) { + dog { name } + } + '); + } + + /** + * @it no required variables with default values + */ + public function testNoRequiredVariablesWithDefaultValues() + { + $this->expectFailsRule(new VariablesDefaultValueAllowed(), ' + query UnreachableDefaultValues($a: Int! = 3, $b: String! = "default") { + dog { name } + } + ', [ + $this->defaultForRequiredVar('a', 'Int!', 'Int', 2, 49), + $this->defaultForRequiredVar('b', 'String!', 'String', 2, 66), + ]); + } + + /** + * @it variables with invalid default null values + */ + public function testNullIntoNullableType() + { + $this->expectFailsRule(new VariablesDefaultValueAllowed(), ' + query WithDefaultValues($a: Int! = null, $b: String! = null) { + dog { name } + } + ', [ + $this->defaultForRequiredVar('a', 'Int!', 'Int', 2, 42), + $this->defaultForRequiredVar('b', 'String!', 'String', 2, 62), + ]); + } +} From ddfeee314c97a1e7d3efbff35646248db230533e Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Thu, 15 Feb 2018 22:44:17 +0100 Subject: [PATCH 42/50] Fix path argument. Enchance visit test to validate all arguments ref: graphl/graphql-js#1149 --- src/Language/Visitor.php | 6 +- tests/Language/VisitorTest.php | 245 +++++++++++++++++++++++++-------- 2 files changed, 195 insertions(+), 56 deletions(-) diff --git a/src/Language/Visitor.php b/src/Language/Visitor.php index 707a4b1dd..ab20d1e62 100644 --- a/src/Language/Visitor.php +++ b/src/Language/Visitor.php @@ -195,7 +195,7 @@ public static function visit($root, $visitor, $keyMap = null) $isEdited = $isLeaving && count($edits) !== 0; if ($isLeaving) { - $key = count($ancestors) === 0 ? $UNDEFINED : array_pop($path); + $key = !$ancestors ? $UNDEFINED : $path[count($path) - 1]; $node = $parent; $parent = array_pop($ancestors); @@ -292,7 +292,9 @@ public static function visit($root, $visitor, $keyMap = null) $edits[] = [$key, $node]; } - if (!$isLeaving) { + if ($isLeaving) { + array_pop($path); + } else { $stack = [ 'inArray' => $inArray, 'index' => $index, diff --git a/tests/Language/VisitorTest.php b/tests/Language/VisitorTest.php index 6ccc2d920..df78b5510 100644 --- a/tests/Language/VisitorTest.php +++ b/tests/Language/VisitorTest.php @@ -18,6 +18,92 @@ class VisitorTest extends \PHPUnit_Framework_TestCase { + private function getNodeByPath(DocumentNode $ast, $path) + { + $result = $ast; + foreach ($path as $key) { + $resultArray = $result instanceof NodeList ? iterator_to_array($result) : $result->toArray(); + $this->assertArrayHasKey($key, $resultArray); + $result = $resultArray[$key]; + } + return $result; + } + + private function checkVisitorFnArgs($ast, $args, $isEdited = false) + { + /** @var Node $node */ + list($node, $key, $parent, $path, $ancestors) = $args; + + $parentArray = $parent && !is_array($parent) ? ($parent instanceof NodeList ? iterator_to_array($parent) : $parent->toArray()) : $parent; + + $this->assertInstanceOf(Node::class, $node); + $this->assertContains($node->kind, array_keys(NodeKind::$classMap)); + + $isRoot = $key === null; + if ($isRoot) { + if (!$isEdited) { + $this->assertEquals($ast, $node); + } + $this->assertEquals(null, $parent); + $this->assertEquals([], $path); + $this->assertEquals([], $ancestors); + return; + } + + $this->assertContains(gettype($key), ['integer', 'string']); + + $this->assertArrayHasKey($key, $parentArray); + + $this->assertInternalType('array', $path); + $this->assertEquals($key, $path[count($path) - 1]); + + $this->assertInternalType('array', $ancestors); + $this->assertCount(count($path) - 1, $ancestors); + + if (!$isEdited) { + $this->assertEquals($node, $parentArray[$key]); + $this->assertEquals($node, $this->getNodeByPath($ast, $path)); + $ancestorsLength = count($ancestors); + for ($i = 0; $i < $ancestorsLength; ++$i) { + $ancestorPath = array_slice($path, 0, $i); + $this->assertEquals($ancestors[$i], $this->getNodeByPath($ast, $ancestorPath)); + } + } + } + + public function testValidatesPathArgument() + { + $visited = []; + + $ast = Parser::parse('{ a }', ['noLocation' => true]); + + Visitor::visit($ast, [ + 'enter' => function ($node, $key, $parent, $path) use ($ast, &$visited) { + $this->checkVisitorFnArgs($ast, func_get_args()); + $visited[] = ['enter', $path]; + }, + 'leave' => function ($node, $key, $parent, $path) use ($ast, &$visited) { + $this->checkVisitorFnArgs($ast, func_get_args()); + $visited[] = ['leave', $path]; + }, + ]); + + $expected = [ + ['enter', []], + ['enter', ['definitions', 0]], + ['enter', ['definitions', 0, 'selectionSet']], + ['enter', ['definitions', 0, 'selectionSet', 'selections', 0]], + ['enter', ['definitions', 0, 'selectionSet', 'selections', 0, 'name']], + ['leave', ['definitions', 0, 'selectionSet', 'selections', 0, 'name']], + ['leave', ['definitions', 0, 'selectionSet', 'selections', 0]], + ['leave', ['definitions', 0, 'selectionSet']], + ['leave', ['definitions', 0]], + ['leave', []], + ]; + + $this->assertEquals($expected, $visited); + } + /** * @it allows editing a node both on enter and on leave */ @@ -28,7 +114,8 @@ public function testAllowsEditingNodeOnEnterAndOnLeave() $selectionSet = null; $editedAst = Visitor::visit($ast, [ NodeKind::OPERATION_DEFINITION => [ - 'enter' => function(OperationDefinitionNode $node) use (&$selectionSet) { + 'enter' => function(OperationDefinitionNode $node) use (&$selectionSet, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $selectionSet = $node->selectionSet; $newNode = clone $node; @@ -38,7 +125,8 @@ public function testAllowsEditingNodeOnEnterAndOnLeave() $newNode->didEnter = true; return $newNode; }, - 'leave' => function(OperationDefinitionNode $node) use (&$selectionSet) { + 'leave' => function(OperationDefinitionNode $node) use (&$selectionSet, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args(), true); $newNode = clone $node; $newNode->selectionSet = $selectionSet; $newNode->didLeave = true; @@ -66,13 +154,15 @@ public function testAllowsEditingRootNodeOnEnterAndLeave() $editedAst = Visitor::visit($ast, [ NodeKind::DOCUMENT => [ - 'enter' => function (DocumentNode $node) { + 'enter' => function (DocumentNode $node) use ($ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $tmp = clone $node; $tmp->definitions = []; $tmp->didEnter = true; return $tmp; }, - 'leave' => function(DocumentNode $node) use ($definitions) { + 'leave' => function(DocumentNode $node) use ($definitions, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args(), true); $tmp = clone $node; $node->definitions = $definitions; $node->didLeave = true; @@ -96,7 +186,8 @@ public function testAllowsForEditingOnEnter() { $ast = Parser::parse('{ a, b, c { a, b, c } }', ['noLocation' => true]); $editedAst = Visitor::visit($ast, [ - 'enter' => function($node) { + 'enter' => function($node) use ($ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); if ($node instanceof FieldNode && $node->name->value === 'b') { return Visitor::removeNode(); } @@ -120,7 +211,8 @@ public function testAllowsForEditingOnLeave() { $ast = Parser::parse('{ a, b, c { a, b, c } }', ['noLocation' => true]); $editedAst = Visitor::visit($ast, [ - 'leave' => function($node) { + 'leave' => function($node) use ($ast) { + $this->checkVisitorFnArgs($ast, func_get_args(), true); if ($node instanceof FieldNode && $node->name->value === 'b') { return Visitor::removeNode(); } @@ -151,10 +243,11 @@ public function testVisitsEditedNode() $didVisitAddedField = false; - $ast = Parser::parse('{ a { x } }'); + $ast = Parser::parse('{ a { x } }', ['noLocation' => true]); Visitor::visit($ast, [ - 'enter' => function($node) use ($addedField, &$didVisitAddedField) { + 'enter' => function($node) use ($addedField, &$didVisitAddedField, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args(), true); if ($node instanceof FieldNode && $node->name->value === 'a') { return new FieldNode([ 'selectionSet' => new SelectionSetNode(array( @@ -177,16 +270,18 @@ public function testVisitsEditedNode() public function testAllowsSkippingASubTree() { $visited = []; - $ast = Parser::parse('{ a, b { x }, c }'); + $ast = Parser::parse('{ a, b { x }, c }', ['noLocation' => true]); Visitor::visit($ast, [ - 'enter' => function(Node $node) use (&$visited) { + 'enter' => function(Node $node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['enter', $node->kind, isset($node->value) ? $node->value : null]; if ($node instanceof FieldNode && $node->name->value === 'b') { return Visitor::skipNode(); } }, - 'leave' => function (Node $node) use (&$visited) { + 'leave' => function (Node $node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; } ]); @@ -218,16 +313,18 @@ public function testAllowsSkippingASubTree() public function testAllowsEarlyExitWhileVisiting() { $visited = []; - $ast = Parser::parse('{ a, b { x }, c }'); + $ast = Parser::parse('{ a, b { x }, c }', ['noLocation' => true]); Visitor::visit($ast, [ - 'enter' => function(Node $node) use (&$visited) { + 'enter' => function(Node $node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['enter', $node->kind, isset($node->value) ? $node->value : null]; if ($node instanceof NameNode && $node->value === 'x') { return Visitor::stop(); } }, - 'leave' => function(Node $node) use (&$visited) { + 'leave' => function(Node $node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; } ]); @@ -258,12 +355,14 @@ public function testAllowsEarlyExitWhileLeaving() { $visited = []; - $ast = Parser::parse('{ a, b { x }, c }'); + $ast = Parser::parse('{ a, b { x }, c }', ['noLocation' => true]); Visitor::visit($ast, [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['enter', $node->kind, isset($node->value) ? $node->value : null]; }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; if ($node->kind === NodeKind::NAME && $node->value === 'x') { @@ -296,17 +395,20 @@ public function testAllowsEarlyExitWhileLeaving() public function testAllowsANamedFunctionsVisitorAPI() { $visited = []; - $ast = Parser::parse('{ a, b { x }, c }'); + $ast = Parser::parse('{ a, b { x }, c }', ['noLocation' => true]); Visitor::visit($ast, [ - NodeKind::NAME => function(NameNode $node) use (&$visited) { + NodeKind::NAME => function(NameNode $node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['enter', $node->kind, $node->value]; }, NodeKind::SELECTION_SET => [ - 'enter' => function(SelectionSetNode $node) use (&$visited) { + 'enter' => function(SelectionSetNode $node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['enter', $node->kind, null]; }, - 'leave' => function(SelectionSetNode $node) use (&$visited) { + 'leave' => function(SelectionSetNode $node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['leave', $node->kind, null]; } ] @@ -333,15 +435,20 @@ public function testExperimentalVisitsVariablesDefinedInFragments() { $ast = Parser::parse( 'fragment a($v: Boolean = false) on t { f }', - ['experimentalFragmentVariables' => true] + [ + 'noLocation' => true, + 'experimentalFragmentVariables' => true, + ] ); $visited = []; Visitor::visit($ast, [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['enter', $node->kind, isset($node->value) ? $node->value : null]; }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; }, ]); @@ -390,11 +497,13 @@ public function testVisitsKitchenSink() $visited = []; Visitor::visit($ast, [ - 'enter' => function(Node $node, $key, $parent) use (&$visited) { + 'enter' => function(Node $node, $key, $parent) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $r = ['enter', $node->kind, $key, $parent instanceof Node ? $parent->kind : null]; $visited[] = $r; }, - 'leave' => function(Node $node, $key, $parent) use (&$visited) { + 'leave' => function(Node $node, $key, $parent) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $r = ['leave', $node->kind, $key, $parent instanceof Node ? $parent->kind : null]; $visited[] = $r; } @@ -729,7 +838,8 @@ public function testAllowsSkippingSubTree() $ast = Parser::parse('{ a, b { x }, c }'); Visitor::visit($ast, Visitor::visitInParallel([ [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = [ 'enter', $node->kind, isset($node->value) ? $node->value : null]; if ($node->kind === 'Field' && isset($node->name->value) && $node->name->value === 'b') { @@ -737,7 +847,8 @@ public function testAllowsSkippingSubTree() } }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; } ] @@ -772,24 +883,28 @@ public function testAllowsSkippingDifferentSubTrees() $ast = Parser::parse('{ a { x }, b { y} }'); Visitor::visit($ast, Visitor::visitInParallel([ [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['no-a', 'enter', $node->kind, isset($node->value) ? $node->value : null]; if ($node->kind === 'Field' && isset($node->name->value) && $node->name->value === 'a') { return Visitor::skipNode(); } }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = [ 'no-a', 'leave', $node->kind, isset($node->value) ? $node->value : null ]; } ], [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['no-b', 'enter', $node->kind, isset($node->value) ? $node->value : null]; if ($node->kind === 'Field' && isset($node->name->value) && $node->name->value === 'b') { return Visitor::skipNode(); } }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['no-b', 'leave', $node->kind, isset($node->value) ? $node->value : null]; } ] @@ -842,14 +957,16 @@ public function testAllowsEarlyExitWhileVisiting2() $ast = Parser::parse('{ a, b { x }, c }'); Visitor::visit($ast, Visitor::visitInParallel([ [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $value = isset($node->value) ? $node->value : null; $visited[] = ['enter', $node->kind, $value]; if ($node->kind === 'Name' && $value === 'x') { return Visitor::stop(); } }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; } ] ])); @@ -881,26 +998,30 @@ public function testAllowsEarlyExitFromDifferentPoints() $ast = Parser::parse('{ a { y }, b { x } }'); Visitor::visit($ast, Visitor::visitInParallel([ [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $value = isset($node->value) ? $node->value : null; $visited[] = ['break-a', 'enter', $node->kind, $value]; if ($node->kind === 'Name' && $value === 'a') { return Visitor::stop(); } }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = [ 'break-a', 'leave', $node->kind, isset($node->value) ? $node->value : null ]; } ], [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $value = isset($node->value) ? $node->value : null; $visited[] = ['break-b', 'enter', $node->kind, $value]; if ($node->kind === 'Name' && $value === 'b') { return Visitor::stop(); } }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['break-b', 'leave', $node->kind, isset($node->value) ? $node->value : null]; } ], @@ -939,10 +1060,12 @@ public function testAllowsEarlyExitWhileLeaving2() $ast = Parser::parse('{ a, b { x }, c }'); Visitor::visit($ast, Visitor::visitInParallel([ [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['enter', $node->kind, isset($node->value) ? $node->value : null]; }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $value = isset($node->value) ? $node->value : null; $visited[] = ['leave', $node->kind, $value]; if ($node->kind === 'Name' && $value === 'x') { @@ -979,10 +1102,12 @@ public function testAllowsEarlyExitFromLeavingDifferentPoints() $ast = Parser::parse('{ a { y }, b { x } }'); Visitor::visit($ast, Visitor::visitInParallel([ [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['break-a', 'enter', $node->kind, isset($node->value) ? $node->value : null]; }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['break-a', 'leave', $node->kind, isset($node->value) ? $node->value : null]; if ($node->kind === 'Field' && isset($node->name->value) && $node->name->value === 'a') { return Visitor::stop(); @@ -990,10 +1115,12 @@ public function testAllowsEarlyExitFromLeavingDifferentPoints() } ], [ - 'enter' => function($node) use (&$visited) { + 'enter' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['break-b', 'enter', $node->kind, isset($node->value) ? $node->value : null]; }, - 'leave' => function($node) use (&$visited) { + 'leave' => function($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['break-b', 'leave', $node->kind, isset($node->value) ? $node->value : null]; if ($node->kind === 'Field' && isset($node->name->value) && $node->name->value === 'b') { return Visitor::stop(); @@ -1052,17 +1179,20 @@ public function testAllowsForEditingOnEnter2() $ast = Parser::parse('{ a, b, c { a, b, c } }', ['noLocation' => true]); $editedAst = Visitor::visit($ast, Visitor::visitInParallel([ [ - 'enter' => function ($node) use (&$visited) { + 'enter' => function ($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); if ($node->kind === 'Field' && isset($node->name->value) && $node->name->value === 'b') { return Visitor::removeNode(); } } ], [ - 'enter' => function ($node) use (&$visited) { + 'enter' => function ($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['enter', $node->kind, isset($node->value) ? $node->value : null]; }, - 'leave' => function ($node) use (&$visited) { + 'leave' => function ($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args(), true); $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; } ], @@ -1116,17 +1246,20 @@ public function testAllowsForEditingOnLeave2() $ast = Parser::parse('{ a, b, c { a, b, c } }', ['noLocation' => true]); $editedAst = Visitor::visit($ast, Visitor::visitInParallel([ [ - 'leave' => function ($node) use (&$visited) { + 'leave' => function ($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args(), true); if ($node->kind === 'Field' && isset($node->name->value) && $node->name->value === 'b') { return Visitor::removeNode(); } } ], [ - 'enter' => function ($node) use (&$visited) { + 'enter' => function ($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $visited[] = ['enter', $node->kind, isset($node->value) ? $node->value : null]; }, - 'leave' => function ($node) use (&$visited) { + 'leave' => function ($node) use (&$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args(), true); $visited[] = ['leave', $node->kind, isset($node->value) ? $node->value : null]; } ], @@ -1189,7 +1322,8 @@ public function testMaintainsTypeInfoDuringVisit() $ast = Parser::parse('{ human(id: 4) { name, pets { ... { name } }, unknown } }'); Visitor::visit($ast, Visitor::visitWithTypeInfo($typeInfo, [ - 'enter' => function ($node) use ($typeInfo, &$visited) { + 'enter' => function ($node) use ($typeInfo, &$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $parentType = $typeInfo->getParentType(); $type = $typeInfo->getType(); $inputType = $typeInfo->getInputType(); @@ -1202,7 +1336,8 @@ public function testMaintainsTypeInfoDuringVisit() $inputType ? (string)$inputType : null ]; }, - 'leave' => function ($node) use ($typeInfo, &$visited) { + 'leave' => function ($node) use ($typeInfo, &$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args()); $parentType = $typeInfo->getParentType(); $type = $typeInfo->getType(); $inputType = $typeInfo->getInputType(); @@ -1273,7 +1408,8 @@ public function testMaintainsTypeInfoDuringEdit() '{ human(id: 4) { name, pets }, alien }' ); $editedAst = Visitor::visit($ast, Visitor::visitWithTypeInfo($typeInfo, [ - 'enter' => function ($node) use ($typeInfo, &$visited) { + 'enter' => function ($node) use ($typeInfo, &$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args(), true); $parentType = $typeInfo->getParentType(); $type = $typeInfo->getType(); $inputType = $typeInfo->getInputType(); @@ -1308,7 +1444,8 @@ public function testMaintainsTypeInfoDuringEdit() ]); } }, - 'leave' => function ($node) use ($typeInfo, &$visited) { + 'leave' => function ($node) use ($typeInfo, &$visited, $ast) { + $this->checkVisitorFnArgs($ast, func_get_args(), true); $parentType = $typeInfo->getParentType(); $type = $typeInfo->getType(); $inputType = $typeInfo->getInputType(); From d71b45d60ef40740833b9f76e654d16c175eb844 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Fri, 16 Feb 2018 00:15:19 +0100 Subject: [PATCH 43/50] Find breaking directive changes ref: graphql/graphql-js#1152 --- src/Utils/FindBreakingChanges.php | 183 ++++++++++++--- tests/Utils/FindBreakingChangesTest.php | 286 ++++++++++++++++++++++-- 2 files changed, 419 insertions(+), 50 deletions(-) diff --git a/src/Utils/FindBreakingChanges.php b/src/Utils/FindBreakingChanges.php index 6c67aa187..631fd694b 100644 --- a/src/Utils/FindBreakingChanges.php +++ b/src/Utils/FindBreakingChanges.php @@ -5,10 +5,12 @@ namespace GraphQL\Utils; +use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\ListOfType; +use GraphQL\Type\Definition\NamedType; use GraphQL\Type\Definition\NonNull; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\ScalarType; @@ -30,6 +32,10 @@ class FindBreakingChanges const BREAKING_CHANGE_NON_NULL_ARG_ADDED = 'NON_NULL_ARG_ADDED'; const BREAKING_CHANGE_NON_NULL_INPUT_FIELD_ADDED = 'NON_NULL_INPUT_FIELD_ADDED'; const BREAKING_CHANGE_INTERFACE_REMOVED_FROM_OBJECT = 'INTERFACE_REMOVED_FROM_OBJECT'; + const BREAKING_CHANGE_DIRECTIVE_REMOVED = 'DIRECTIVE_REMOVED'; + const BREAKING_CHANGE_DIRECTIVE_ARG_REMOVED = 'DIRECTIVE_ARG_REMOVED'; + const BREAKING_CHANGE_DIRECTIVE_LOCATION_REMOVED = 'DIRECTIVE_LOCATION_REMOVED'; + const BREAKING_CHANGE_NON_NULL_DIRECTIVE_ARG_ADDED = 'NON_NULL_DIRECTIVE_ARG_ADDED'; const DANGEROUS_CHANGE_ARG_DEFAULT_VALUE = 'ARG_DEFAULT_VALUE_CHANGE'; const DANGEROUS_CHANGE_VALUE_ADDED_TO_ENUM = 'VALUE_ADDED_TO_ENUM'; @@ -53,7 +59,11 @@ public static function findBreakingChanges(Schema $oldSchema, Schema $newSchema) self::findTypesRemovedFromUnions($oldSchema, $newSchema), self::findValuesRemovedFromEnums($oldSchema, $newSchema), self::findArgChanges($oldSchema, $newSchema)['breakingChanges'], - self::findInterfacesRemovedFromObjectTypes($oldSchema, $newSchema) + self::findInterfacesRemovedFromObjectTypes($oldSchema, $newSchema), + self::findRemovedDirectives($oldSchema, $newSchema), + self::findRemovedDirectiveArgs($oldSchema, $newSchema), + self::findAddedNonNullDirectiveArgs($oldSchema, $newSchema), + self::findRemovedDirectiveLocations($oldSchema, $newSchema) ); } @@ -283,8 +293,8 @@ public static function findFieldsThatChangedTypeOnObjectOrInterfaceTypes(Schema $isSafe = self::isChangeSafeForObjectOrInterfaceField($oldFieldType, $newfieldType); if (!$isSafe) { - $oldFieldTypeString = self::isNamedType($oldFieldType) ? $oldFieldType->name : $oldFieldType; - $newFieldTypeString = self::isNamedType($newfieldType) ? $newfieldType->name : $newfieldType; + $oldFieldTypeString = $oldFieldType instanceof NamedType ? $oldFieldType->name : $oldFieldType; + $newFieldTypeString = $newfieldType instanceof NamedType ? $newfieldType->name : $newfieldType; $breakingChanges[] = ['type' => self::BREAKING_CHANGE_FIELD_CHANGED, 'description' => "${typeName}->${fieldName} changed type from ${oldFieldTypeString} to ${newFieldTypeString}."]; } } @@ -323,11 +333,11 @@ public static function findFieldsThatChangedTypeOnInputObjectTypes( ]; } else { $oldFieldType = $oldTypeFieldsDef[$fieldName]->getType(); - $newfieldType = $newTypeFieldsDef[$fieldName]->getType(); - $isSafe = self::isChangeSafeForInputObjectFieldOrFieldArg($oldFieldType, $newfieldType); + $newFieldType = $newTypeFieldsDef[$fieldName]->getType(); + $isSafe = self::isChangeSafeForInputObjectFieldOrFieldArg($oldFieldType, $newFieldType); if (!$isSafe) { - $oldFieldTypeString = self::isNamedType($oldFieldType) ? $oldFieldType->name : $oldFieldType; - $newFieldTypeString = self::isNamedType($newfieldType) ? $newfieldType->name : $newfieldType; + $oldFieldTypeString = $oldFieldType instanceof NamedType ? $oldFieldType->name : $oldFieldType; + $newFieldTypeString = $newFieldType instanceof NamedType ? $newFieldType->name : $newFieldType; $breakingChanges[] = [ 'type' => self::BREAKING_CHANGE_FIELD_CHANGED, 'description' => "${typeName}->${fieldName} changed type from ${oldFieldTypeString} to ${newFieldTypeString}."]; @@ -361,9 +371,9 @@ private static function isChangeSafeForObjectOrInterfaceField( Type $oldType, Type $newType ) { - if (self::isNamedType($oldType)) { + if ($oldType instanceof NamedType) { // if they're both named types, see if their names are equivalent - return (self::isNamedType($newType) && $oldType->name === $newType->name) + return ($newType instanceof NamedType && $oldType->name === $newType->name) // moving from nullable to non-null of the same underlying type is safe || ($newType instanceof NonNull && self::isChangeSafeForObjectOrInterfaceField( @@ -387,16 +397,16 @@ private static function isChangeSafeForObjectOrInterfaceField( /** * @param Type $oldType - * @param Schema $newSchema + * @param Type $newType * * @return bool */ private static function isChangeSafeForInputObjectFieldOrFieldArg( - Type $oldType, Type $newType - ) - { - if (self::isNamedType($oldType)) { - return self::isNamedType($newType) && $oldType->name === $newType->name; + Type $oldType, + Type $newType + ) { + if ($oldType instanceof NamedType) { + return $newType instanceof NamedType && $oldType->name === $newType->name; } elseif ($oldType instanceof ListOfType) { return $newType instanceof ListOfType && self::isChangeSafeForInputObjectFieldOrFieldArg($oldType->getWrappedType(), $newType->getWrappedType()); } elseif ($oldType instanceof NonNull) { @@ -583,20 +593,135 @@ public static function findInterfacesRemovedFromObjectTypes( return $breakingChanges; } - /** - * @param Type $type - * - * @return bool - */ - private static function isNamedType(Type $type) + public static function findRemovedDirectives(Schema $oldSchema, Schema $newSchema) { - return ( - $type instanceof ScalarType || - $type instanceof ObjectType || - $type instanceof InterfaceType || - $type instanceof UnionType || - $type instanceof EnumType || - $type instanceof InputObjectType - ); + $removedDirectives = []; + + $newSchemaDirectiveMap = self::getDirectiveMapForSchema($newSchema); + foreach($oldSchema->getDirectives() as $directive) { + if (!isset($newSchemaDirectiveMap[$directive->name])) { + $removedDirectives[] = [ + 'type' => self::BREAKING_CHANGE_DIRECTIVE_REMOVED, + 'description' => "{$directive->name} was removed", + ]; + } + } + + return $removedDirectives; + } + + public static function findRemovedArgsForDirectives(Directive $oldDirective, Directive $newDirective) + { + $removedArgs = []; + $newArgMap = self::getArgumentMapForDirective($newDirective); + foreach((array) $oldDirective->args as $arg) { + if (!isset($newArgMap[$arg->name])) { + $removedArgs[] = $arg; + } + } + + return $removedArgs; + } + + public static function findRemovedDirectiveArgs(Schema $oldSchema, Schema $newSchema) + { + $removedDirectiveArgs = []; + $oldSchemaDirectiveMap = self::getDirectiveMapForSchema($oldSchema); + + foreach($newSchema->getDirectives() as $newDirective) { + if (!isset($oldSchemaDirectiveMap[$newDirective->name])) { + continue; + } + + foreach(self::findRemovedArgsForDirectives($oldSchemaDirectiveMap[$newDirective->name], $newDirective) as $arg) { + $removedDirectiveArgs[] = [ + 'type' => self::BREAKING_CHANGE_DIRECTIVE_ARG_REMOVED, + 'description' => "{$arg->name} was removed from {$newDirective->name}", + ]; + } + } + + return $removedDirectiveArgs; + } + + public static function findAddedArgsForDirective(Directive $oldDirective, Directive $newDirective) + { + $addedArgs = []; + $oldArgMap = self::getArgumentMapForDirective($oldDirective); + foreach((array) $newDirective->args as $arg) { + if (!isset($oldArgMap[$arg->name])) { + $addedArgs[] = $arg; + } + } + + return $addedArgs; + } + + public static function findAddedNonNullDirectiveArgs(Schema $oldSchema, Schema $newSchema) + { + $addedNonNullableArgs = []; + $oldSchemaDirectiveMap = self::getDirectiveMapForSchema($oldSchema); + + foreach($newSchema->getDirectives() as $newDirective) { + if (!isset($oldSchemaDirectiveMap[$newDirective->name])) { + continue; + } + + foreach(self::findAddedArgsForDirective($oldSchemaDirectiveMap[$newDirective->name], $newDirective) as $arg) { + if (!$arg->getType() instanceof NonNull) { + continue; + } + $addedNonNullableArgs[] = [ + 'type' => self::BREAKING_CHANGE_NON_NULL_DIRECTIVE_ARG_ADDED, + 'description' => "A non-null arg {$arg->name} on directive {$newDirective->name} was added", + ]; + } + } + + return $addedNonNullableArgs; + } + + public static function findRemovedLocationsForDirective(Directive $oldDirective, Directive $newDirective) + { + $removedLocations = []; + $newLocationSet = array_flip($newDirective->locations); + foreach($oldDirective->locations as $oldLocation) { + if (!array_key_exists($oldLocation, $newLocationSet)) { + $removedLocations[] = $oldLocation; + } + } + + return $removedLocations; + } + + public static function findRemovedDirectiveLocations(Schema $oldSchema, Schema $newSchema) + { + $removedLocations = []; + $oldSchemaDirectiveMap = self::getDirectiveMapForSchema($oldSchema); + + foreach($newSchema->getDirectives() as $newDirective) { + if (!isset($oldSchemaDirectiveMap[$newDirective->name])) { + continue; + } + + foreach(self::findRemovedLocationsForDirective($oldSchemaDirectiveMap[$newDirective->name], $newDirective) as $location) { + $removedLocations[] = [ + 'type' => self::BREAKING_CHANGE_DIRECTIVE_LOCATION_REMOVED, + 'description' => "{$location} was removed from {$newDirective->name}", + ]; + } + } + + return $removedLocations; + } + + private static function getDirectiveMapForSchema(Schema $schema) + { + return Utils::keyMap($schema->getDirectives(), function ($dir) { return $dir->name; }); + } + + private static function getArgumentMapForDirective(Directive $directive) + { + return Utils::keyMap($directive->args ?: [], function ($arg) { return $arg->name; }); } } diff --git a/tests/Utils/FindBreakingChangesTest.php b/tests/Utils/FindBreakingChangesTest.php index bfc79a961..b60301df3 100644 --- a/tests/Utils/FindBreakingChangesTest.php +++ b/tests/Utils/FindBreakingChangesTest.php @@ -1,7 +1,10 @@ 'DirectiveThatRemovesArg', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + 'args' => FieldArgument::createMap([ + 'arg1' => [ + 'name' => 'arg1', + ], + ]), + ]); + $directiveThatRemovesArgNew = new Directive([ + 'name' => 'DirectiveThatRemovesArg', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + ]); + $nonNullDirectiveAddedOld = new Directive([ + 'name' => 'NonNullDirectiveAdded', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + ]); + $nonNullDirectiveAddedNew = new Directive([ + 'name' => 'NonNullDirectiveAdded', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + 'args' => FieldArgument::createMap([ + 'arg1' => [ + 'name' => 'arg1', + 'type' => Type::nonNull(Type::boolean()), + ], + ]), + ]); + $directiveRemovedLocationOld = new Directive([ + 'name' => 'Directive Name', + 'locations' => [DirectiveLocation::FIELD_DEFINITION, DirectiveLocation::QUERY], + ]); + $directiveRemovedLocationNew = new Directive([ + 'name' => 'Directive Name', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + ]); + $oldSchema = new Schema([ 'query' => $this->queryType, - 'types' => - [ - 'TypeThatGetsRemoved' => $typeThatGetsRemoved, - 'TypeThatChangesType' => $typeThatChangesTypeOld, - 'TypeThatHasBreakingFieldChanges' => $typeThatHasBreakingFieldChangesOld, - 'UnionTypeThatLosesAType' => $unionTypeThatLosesATypeOld, - 'EnumTypeThatLosesAValue' => $enumTypeThatLosesAValueOld, - 'ArgThatChanges' => $argThatChanges, - 'TypeThatLosesInterface' => $typeThatLosesInterfaceOld - ] + 'types' => [ + 'TypeThatGetsRemoved' => $typeThatGetsRemoved, + 'TypeThatChangesType' => $typeThatChangesTypeOld, + 'TypeThatHasBreakingFieldChanges' => $typeThatHasBreakingFieldChangesOld, + 'UnionTypeThatLosesAType' => $unionTypeThatLosesATypeOld, + 'EnumTypeThatLosesAValue' => $enumTypeThatLosesAValueOld, + 'ArgThatChanges' => $argThatChanges, + 'TypeThatLosesInterface' => $typeThatLosesInterfaceOld + ], + 'directives' => [ + $directiveThatIsRemoved, + $directiveThatRemovesArgOld, + $nonNullDirectiveAddedOld, + $directiveRemovedLocationOld, + ] ]); $newSchema = new Schema([ 'query' => $this->queryType, - 'types' => - [ - 'TypeThatChangesType' => $typeThatChangesTypeNew, - 'TypeThatHasBreakingFieldChanges' => $typeThatHasBreakingFieldChangesNew, - 'UnionTypeThatLosesAType' => $unionTypeThatLosesATypeNew, - 'EnumTypeThatLosesAValue' => $enumTypeThatLosesAValueNew, - 'ArgThatChanges' => $argChanged, - 'TypeThatLosesInterface' => $typeThatLosesInterfaceNew, - 'Interface1' => $interface1 - ] + 'types' => [ + 'TypeThatChangesType' => $typeThatChangesTypeNew, + 'TypeThatHasBreakingFieldChanges' => $typeThatHasBreakingFieldChangesNew, + 'UnionTypeThatLosesAType' => $unionTypeThatLosesATypeNew, + 'EnumTypeThatLosesAValue' => $enumTypeThatLosesAValueNew, + 'ArgThatChanges' => $argChanged, + 'TypeThatLosesInterface' => $typeThatLosesInterfaceNew, + 'Interface1' => $interface1 + ], + 'directives' => [ + $directiveThatRemovesArgNew, + $nonNullDirectiveAddedNew, + $directiveRemovedLocationNew, + ] ]); $expectedBreakingChanges = [ @@ -1206,13 +1255,208 @@ public function testDetectsAllBreakingChanges() [ 'type' => FindBreakingChanges::BREAKING_CHANGE_INTERFACE_REMOVED_FROM_OBJECT, 'description' => 'TypeThatLosesInterface1 no longer implements interface Interface1.', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_DIRECTIVE_REMOVED, + 'description' => 'skip was removed', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_DIRECTIVE_ARG_REMOVED, + 'description' => 'arg1 was removed from DirectiveThatRemovesArg', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_NON_NULL_DIRECTIVE_ARG_ADDED, + 'description' => 'A non-null arg arg1 on directive NonNullDirectiveAdded was added', + ], + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_DIRECTIVE_LOCATION_REMOVED, + 'description' => 'QUERY was removed from Directive Name', ] ]; $this->assertEquals($expectedBreakingChanges, FindBreakingChanges::findBreakingChanges($oldSchema, $newSchema)); } - // findDangerousChanges tests below here + /** + * @it should detect if a directive was explicitly removed + */ + public function testShouldDetectIfADirectiveWasExplicitlyRemoved() + { + $oldSchema = new Schema([ + 'directives' => [Directive::skipDirective(), Directive::includeDirective()], + ]); + + $newSchema = new Schema([ + 'directives' => [Directive::skipDirective()], + ]); + + $includeDirective = Directive::includeDirective(); + + $expectedBreakingChanges = [ + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_DIRECTIVE_REMOVED, + 'description' => "{$includeDirective->name} was removed", + ] + ]; + + $this->assertEquals($expectedBreakingChanges, FindBreakingChanges::findRemovedDirectives($oldSchema, $newSchema)); + } + + /** + * @it should detect if a directive was implicitly removed + */ + public function testShouldDetectIfADirectiveWasImplicitlyRemoved() + { + $oldSchema = new Schema([]); + + $newSchema = new Schema([ + 'directives' => [Directive::skipDirective(), Directive::includeDirective()], + ]); + + $deprecatedDirective = Directive::deprecatedDirective(); + + $expectedBreakingChanges = [ + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_DIRECTIVE_REMOVED, + 'description' => "{$deprecatedDirective->name} was removed", + ] + ]; + + $this->assertEquals($expectedBreakingChanges, FindBreakingChanges::findRemovedDirectives($oldSchema, $newSchema)); + } + + /** + * @it should detect if a directive argument was removed + */ + public function testShouldDetectIfADirectiveArgumentWasRemoved() + { + $oldSchema = new Schema([ + 'directives' => [ + new Directive([ + 'name' => 'DirectiveWithArg', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + 'args' => FieldArgument::createMap([ + 'arg1' => [ + 'name' => 'arg1', + ], + ]), + ]) + ], + ]); + + $newSchema = new Schema([ + 'directives' => [ + new Directive([ + 'name' => 'DirectiveWithArg', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + ]) + ], + ]); + + $expectedBreakingChanges = [ + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_DIRECTIVE_ARG_REMOVED, + 'description' => "arg1 was removed from DirectiveWithArg", + ] + ]; + + $this->assertEquals($expectedBreakingChanges, FindBreakingChanges::findRemovedDirectiveArgs($oldSchema, $newSchema)); + } + + /** + * @it should detect if a non-nullable directive argument was added + */ + public function testShouldDetectIfANonNullableDirectiveArgumentWasAdded() + { + $oldSchema = new Schema([ + 'directives' => [ + new Directive([ + 'name' => 'DirectiveName', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + ]) + ], + ]); + + $newSchema = new Schema([ + 'directives' => [ + new Directive([ + 'name' => 'DirectiveName', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + 'args' => FieldArgument::createMap([ + 'arg1' => [ + 'name' => 'arg1', + 'type' => Type::nonNull(Type::boolean()), + ], + ]), + ]) + ], + ]); + + $expectedBreakingChanges = [ + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_NON_NULL_DIRECTIVE_ARG_ADDED, + 'description' => "A non-null arg arg1 on directive DirectiveName was added", + ] + ]; + + $this->assertEquals($expectedBreakingChanges, FindBreakingChanges::findAddedNonNullDirectiveArgs($oldSchema, $newSchema)); + } + + /** + * @it should detect locations removed from a directive + */ + public function testShouldDetectLocationsRemovedFromADirective() + { + $d1 = new Directive([ + 'name' => 'Directive Name', + 'locations' => [DirectiveLocation::FIELD_DEFINITION, DirectiveLocation::QUERY], + ]); + + $d2 = new Directive([ + 'name' => 'Directive Name', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + ]); + + $this->assertEquals([DirectiveLocation::QUERY], FindBreakingChanges::findRemovedLocationsForDirective($d1, $d2)); + } + + /** + * @it should detect locations removed directives within a schema + */ + public function testShouldDetectLocationsRemovedDirectiveWithinASchema() + { + $oldSchema = new Schema([ + 'directives' => [ + new Directive([ + 'name' => 'Directive Name', + 'locations' => [ + DirectiveLocation::FIELD_DEFINITION, + DirectiveLocation::QUERY + ], + ]) + ], + ]); + + $newSchema = new Schema([ + 'directives' => [ + new Directive([ + 'name' => 'Directive Name', + 'locations' => [DirectiveLocation::FIELD_DEFINITION], + ]) + ], + ]); + + $expectedBreakingChanges = [ + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_DIRECTIVE_LOCATION_REMOVED, + 'description' => "QUERY was removed from Directive Name", + ] + ]; + + $this->assertEquals($expectedBreakingChanges, FindBreakingChanges::findRemovedDirectiveLocations($oldSchema, $newSchema)); + } + + // DESCRIBE: findDangerousChanges public function testFindDangerousArgChanges() { From 48c5e64a088f23f211dea3568ec0196138bda3c1 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Fri, 16 Feb 2018 15:30:27 +0100 Subject: [PATCH 44/50] Adding an interface to a type is now a dangerous change. ref: graphql/graphql-js#992 --- src/Utils/FindBreakingChanges.php | 340 ++++++---- tests/Utils/FindBreakingChangesTest.php | 794 +++++++++++++----------- 2 files changed, 628 insertions(+), 506 deletions(-) diff --git a/src/Utils/FindBreakingChanges.php b/src/Utils/FindBreakingChanges.php index 631fd694b..ecad9f86b 100644 --- a/src/Utils/FindBreakingChanges.php +++ b/src/Utils/FindBreakingChanges.php @@ -21,14 +21,14 @@ class FindBreakingChanges { - const BREAKING_CHANGE_FIELD_CHANGED = 'FIELD_CHANGED_KIND'; + const BREAKING_CHANGE_FIELD_CHANGED_KIND = 'FIELD_CHANGED_KIND'; const BREAKING_CHANGE_FIELD_REMOVED = 'FIELD_REMOVED'; - const BREAKING_CHANGE_TYPE_CHANGED = 'TYPE_CHANGED_KIND'; + const BREAKING_CHANGE_TYPE_CHANGED_KIND = 'TYPE_CHANGED_KIND'; const BREAKING_CHANGE_TYPE_REMOVED = 'TYPE_REMOVED'; const BREAKING_CHANGE_TYPE_REMOVED_FROM_UNION = 'TYPE_REMOVED_FROM_UNION'; const BREAKING_CHANGE_VALUE_REMOVED_FROM_ENUM = 'VALUE_REMOVED_FROM_ENUM'; const BREAKING_CHANGE_ARG_REMOVED = 'ARG_REMOVED'; - const BREAKING_CHANGE_ARG_CHANGED = 'ARG_CHANGED_KIND'; + const BREAKING_CHANGE_ARG_CHANGED_KIND = 'ARG_CHANGED_KIND'; const BREAKING_CHANGE_NON_NULL_ARG_ADDED = 'NON_NULL_ARG_ADDED'; const BREAKING_CHANGE_NON_NULL_INPUT_FIELD_ADDED = 'NON_NULL_INPUT_FIELD_ADDED'; const BREAKING_CHANGE_INTERFACE_REMOVED_FROM_OBJECT = 'INTERFACE_REMOVED_FROM_OBJECT'; @@ -37,8 +37,9 @@ class FindBreakingChanges const BREAKING_CHANGE_DIRECTIVE_LOCATION_REMOVED = 'DIRECTIVE_LOCATION_REMOVED'; const BREAKING_CHANGE_NON_NULL_DIRECTIVE_ARG_ADDED = 'NON_NULL_DIRECTIVE_ARG_ADDED'; - const DANGEROUS_CHANGE_ARG_DEFAULT_VALUE = 'ARG_DEFAULT_VALUE_CHANGE'; + const DANGEROUS_CHANGE_ARG_DEFAULT_VALUE_CHANGED = 'ARG_DEFAULT_VALUE_CHANGE'; const DANGEROUS_CHANGE_VALUE_ADDED_TO_ENUM = 'VALUE_ADDED_TO_ENUM'; + const DANGEROUS_CHANGE_INTERFACE_ADDED_TO_OBJECT = 'INTERFACE_ADDED_TO_OBJECT'; const DANGEROUS_CHANGE_TYPE_ADDED_TO_UNION = 'TYPE_ADDED_TO_UNION'; const DANGEROUS_CHANGE_NULLABLE_INPUT_FIELD_ADDED = 'NULLABLE_INPUT_FIELD_ADDED'; const DANGEROUS_CHANGE_NULLABLE_ARG_ADDED = 'NULLABLE_ARG_ADDED'; @@ -78,6 +79,7 @@ public static function findDangerousChanges(Schema $oldSchema, Schema $newSchema return array_merge( self::findArgChanges($oldSchema, $newSchema)['dangerousChanges'], self::findValuesAddedToEnums($oldSchema, $newSchema), + self::findInterfacesAddedToObjectTypes($oldSchema, $newSchema), self::findTypesAddedToUnions($oldSchema, $newSchema), self::findFieldsThatChangedTypeOnInputObjectTypes($oldSchema, $newSchema)['dangerousChanges'] ); @@ -90,20 +92,21 @@ public static function findDangerousChanges(Schema $oldSchema, Schema $newSchema * @return array */ public static function findRemovedTypes( - Schema $oldSchema, Schema $newSchema - ) - { + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); $breakingChanges = []; - foreach ($oldTypeMap as $typeName => $typeDefinition) { + foreach (array_keys($oldTypeMap) as $typeName) { if (!isset($newTypeMap[$typeName])) { - $breakingChanges[] = - ['type' => self::BREAKING_CHANGE_TYPE_REMOVED, 'description' => "${typeName} was removed."]; + $breakingChanges[] = [ + 'type' => self::BREAKING_CHANGE_TYPE_REMOVED, + 'description' => "${typeName} was removed." + ]; } } - return $breakingChanges; } @@ -114,28 +117,27 @@ public static function findRemovedTypes( * @return array */ public static function findTypesThatChangedKind( - Schema $oldSchema, Schema $newSchema - ) - { + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); $breakingChanges = []; - foreach ($oldTypeMap as $typeName => $typeDefinition) { + foreach ($oldTypeMap as $typeName => $oldType) { if (!isset($newTypeMap[$typeName])) { continue; } - $newTypeDefinition = $newTypeMap[$typeName]; - if (!($typeDefinition instanceof $newTypeDefinition)) { - $oldTypeKindName = self::typeKindName($typeDefinition); - $newTypeKindName = self::typeKindName($newTypeDefinition); + $newType = $newTypeMap[$typeName]; + if (!($oldType instanceof $newType)) { + $oldTypeKindName = self::typeKindName($oldType); + $newTypeKindName = self::typeKindName($newType); $breakingChanges[] = [ - 'type' => self::BREAKING_CHANGE_TYPE_CHANGED, + 'type' => self::BREAKING_CHANGE_TYPE_CHANGED_KIND, 'description' => "${typeName} changed from ${oldTypeKindName} to ${newTypeKindName}." ]; } } - return $breakingChanges; } @@ -148,59 +150,63 @@ public static function findTypesThatChangedKind( * @return array */ public static function findArgChanges( - Schema $oldSchema, Schema $newSchema - ) - { + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); $breakingChanges = []; $dangerousChanges = []; - foreach ($oldTypeMap as $oldTypeName => $oldTypeDefinition) { - $newTypeDefinition = isset($newTypeMap[$oldTypeName]) ? $newTypeMap[$oldTypeName] : null; + + foreach ($oldTypeMap as $typeName => $oldType) { + $newType = isset($newTypeMap[$typeName]) ? $newTypeMap[$typeName] : null; if ( - !($oldTypeDefinition instanceof ObjectType || $oldTypeDefinition instanceof InterfaceType) || - !($newTypeDefinition instanceof ObjectType || $newTypeDefinition instanceof InterfaceType) || - !($newTypeDefinition instanceof $oldTypeDefinition) + !($oldType instanceof ObjectType || $oldType instanceof InterfaceType) || + !($newType instanceof ObjectType || $newType instanceof InterfaceType) || + !($newType instanceof $oldType) ) { continue; } - $oldTypeFields = $oldTypeDefinition->getFields(); - $newTypeFields = $newTypeDefinition->getFields(); + $oldTypeFields = $oldType->getFields(); + $newTypeFields = $newType->getFields(); - foreach ($oldTypeFields as $fieldName => $fieldDefinition) { + foreach ($oldTypeFields as $fieldName => $oldField) { if (!isset($newTypeFields[$fieldName])) { continue; } - foreach ($fieldDefinition->args as $oldArgDef) { + foreach ($oldField->args as $oldArgDef) { $newArgs = $newTypeFields[$fieldName]->args; $newArgDef = Utils::find( - $newArgs, function ($arg) use ($oldArgDef) { - return $arg->name === $oldArgDef->name; - } + $newArgs, + function ($arg) use ($oldArgDef) { + return $arg->name === $oldArgDef->name; + } ); if (!$newArgDef) { - $argName = $oldArgDef->name; $breakingChanges[] = [ 'type' => self::BREAKING_CHANGE_ARG_REMOVED, - 'description' => "${oldTypeName}->${fieldName} arg ${argName} was removed" + 'description' => "${typeName}.${fieldName} arg {$oldArgDef->name} was removed" ]; } else { - $isSafe = self::isChangeSafeForInputObjectFieldOrFieldArg($oldArgDef->getType(), $newArgDef->getType()); + $isSafe = self::isChangeSafeForInputObjectFieldOrFieldArg( + $oldArgDef->getType(), + $newArgDef->getType() + ); $oldArgType = $oldArgDef->getType(); $oldArgName = $oldArgDef->name; if (!$isSafe) { $newArgType = $newArgDef->getType(); $breakingChanges[] = [ - 'type' => self::BREAKING_CHANGE_ARG_CHANGED, - 'description' => "${oldTypeName}->${fieldName} arg ${oldArgName} has changed type from ${oldArgType} to ${newArgType}." + 'type' => self::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => "${typeName}.${fieldName} arg ${oldArgName} has changed type from ${oldArgType} to ${newArgType}" ]; } elseif ($oldArgDef->defaultValueExists() && $oldArgDef->defaultValue !== $newArgDef->defaultValue) { $dangerousChanges[] = [ - 'type' => FindBreakingChanges::DANGEROUS_CHANGE_ARG_DEFAULT_VALUE, - 'description' => "${oldTypeName}->${fieldName} arg ${oldArgName} has changed defaultValue" + 'type' => FindBreakingChanges::DANGEROUS_CHANGE_ARG_DEFAULT_VALUE_CHANGED, + 'description' => "${typeName}.${fieldName} arg ${oldArgName} has changed defaultValue" ]; } } @@ -208,23 +214,24 @@ public static function findArgChanges( foreach ($newTypeFields[$fieldName]->args as $newArgDef) { $oldArgs = $oldTypeFields[$fieldName]->args; $oldArgDef = Utils::find( - $oldArgs, function ($arg) use ($newArgDef) { + $oldArgs, + function ($arg) use ($newArgDef) { return $arg->name === $newArgDef->name; } ); if (!$oldArgDef) { - $newTypeName = $newTypeDefinition->name; + $newTypeName = $newType->name; $newArgName = $newArgDef->name; if ($newArgDef->getType() instanceof NonNull) { $breakingChanges[] = [ 'type' => self::BREAKING_CHANGE_NON_NULL_ARG_ADDED, - 'description' => "A non-null arg ${newArgName} on ${newTypeName}->${fieldName} was added." + 'description' => "A non-null arg ${newArgName} on ${newTypeName}.${fieldName} was added" ]; } else { $dangerousChanges[] = [ 'type' => self::DANGEROUS_CHANGE_NULLABLE_ARG_ADDED, - 'description' => "A nullable arg ${newArgName} on ${newTypeName}->${fieldName} was added." + 'description' => "A nullable arg ${newArgName} on ${newTypeName}.${fieldName} was added" ]; } } @@ -233,7 +240,10 @@ public static function findArgChanges( } } - return ['breakingChanges' => $breakingChanges, 'dangerousChanges' => $dangerousChanges]; + return [ + 'breakingChanges' => $breakingChanges, + 'dangerousChanges' => $dangerousChanges, + ]; } /** @@ -261,14 +271,10 @@ private static function typeKindName(Type $type) throw new \TypeError('unknown type ' . $type->name); } - /** - * @param Schema $oldSchema - * @param Schema $newSchema - * - * @return array - */ - public static function findFieldsThatChangedTypeOnObjectOrInterfaceTypes(Schema $oldSchema, Schema $newSchema) - { + public static function findFieldsThatChangedTypeOnObjectOrInterfaceTypes( + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); @@ -282,20 +288,34 @@ public static function findFieldsThatChangedTypeOnObjectOrInterfaceTypes(Schema ) { continue; } + $oldTypeFieldsDef = $oldType->getFields(); $newTypeFieldsDef = $newType->getFields(); foreach ($oldTypeFieldsDef as $fieldName => $fieldDefinition) { + // Check if the field is missing on the type in the new schema. if (!isset($newTypeFieldsDef[$fieldName])) { - $breakingChanges[] = ['type' => self::BREAKING_CHANGE_FIELD_REMOVED, 'description' => "${typeName}->${fieldName} was removed."]; + $breakingChanges[] = [ + 'type' => self::BREAKING_CHANGE_FIELD_REMOVED, + 'description' => "${typeName}.${fieldName} was removed." + ]; } else { $oldFieldType = $oldTypeFieldsDef[$fieldName]->getType(); - $newfieldType = $newTypeFieldsDef[$fieldName]->getType(); - $isSafe = self::isChangeSafeForObjectOrInterfaceField($oldFieldType, $newfieldType); + $newFieldType = $newTypeFieldsDef[$fieldName]->getType(); + $isSafe = self::isChangeSafeForObjectOrInterfaceField( + $oldFieldType, + $newFieldType + ); if (!$isSafe) { - - $oldFieldTypeString = $oldFieldType instanceof NamedType ? $oldFieldType->name : $oldFieldType; - $newFieldTypeString = $newfieldType instanceof NamedType ? $newfieldType->name : $newfieldType; - $breakingChanges[] = ['type' => self::BREAKING_CHANGE_FIELD_CHANGED, 'description' => "${typeName}->${fieldName} changed type from ${oldFieldTypeString} to ${newFieldTypeString}."]; + $oldFieldTypeString = $oldFieldType instanceof NamedType + ? $oldFieldType->name + : $oldFieldType; + $newFieldTypeString = $newFieldType instanceof NamedType + ? $newFieldType->name + : $newFieldType; + $breakingChanges[] = [ + 'type' => self::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => "${typeName}.${fieldName} changed type from ${oldFieldTypeString} to ${newFieldTypeString}." + ]; } } } @@ -303,16 +323,10 @@ public static function findFieldsThatChangedTypeOnObjectOrInterfaceTypes(Schema return $breakingChanges; } - /** - * @param Schema $oldSchema - * @param Schema $newSchema - * - * @return array - */ public static function findFieldsThatChangedTypeOnInputObjectTypes( - Schema $oldSchema, Schema $newSchema - ) - { + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); @@ -323,24 +337,33 @@ public static function findFieldsThatChangedTypeOnInputObjectTypes( if (!($oldType instanceof InputObjectType) || !($newType instanceof InputObjectType)) { continue; } + $oldTypeFieldsDef = $oldType->getFields(); $newTypeFieldsDef = $newType->getFields(); - foreach ($oldTypeFieldsDef as $fieldName => $fieldDefinition) { + foreach (array_keys($oldTypeFieldsDef) as $fieldName) { if (!isset($newTypeFieldsDef[$fieldName])) { $breakingChanges[] = [ 'type' => self::BREAKING_CHANGE_FIELD_REMOVED, - 'description' => "${typeName}->${fieldName} was removed." + 'description' => "${typeName}.${fieldName} was removed." ]; } else { $oldFieldType = $oldTypeFieldsDef[$fieldName]->getType(); $newFieldType = $newTypeFieldsDef[$fieldName]->getType(); - $isSafe = self::isChangeSafeForInputObjectFieldOrFieldArg($oldFieldType, $newFieldType); + + $isSafe = self::isChangeSafeForInputObjectFieldOrFieldArg( + $oldFieldType, + $newFieldType + ); if (!$isSafe) { - $oldFieldTypeString = $oldFieldType instanceof NamedType ? $oldFieldType->name : $oldFieldType; - $newFieldTypeString = $newFieldType instanceof NamedType ? $newFieldType->name : $newFieldType; + $oldFieldTypeString = $oldFieldType instanceof NamedType + ? $oldFieldType->name + : $oldFieldType; + $newFieldTypeString = $newFieldType instanceof NamedType + ? $newFieldType->name + : $newFieldType; $breakingChanges[] = [ - 'type' => self::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => "${typeName}->${fieldName} changed type from ${oldFieldTypeString} to ${newFieldTypeString}."]; + 'type' => self::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => "${typeName}.${fieldName} changed type from ${oldFieldTypeString} to ${newFieldTypeString}."]; } } } @@ -363,35 +386,42 @@ public static function findFieldsThatChangedTypeOnInputObjectTypes( } } - return ['breakingChanges' => $breakingChanges, 'dangerousChanges' => $dangerousChanges]; + return [ + 'breakingChanges' => $breakingChanges, + 'dangerousChanges' => $dangerousChanges, + ]; } private static function isChangeSafeForObjectOrInterfaceField( - Type $oldType, Type $newType - ) - { + Type $oldType, + Type $newType + ) { if ($oldType instanceof NamedType) { - // if they're both named types, see if their names are equivalent - return ($newType instanceof NamedType && $oldType->name === $newType->name) + return ( + // if they're both named types, see if their names are equivalent + ($newType instanceof NamedType && $oldType->name === $newType->name) || // moving from nullable to non-null of the same underlying type is safe - || ($newType instanceof NonNull - && self::isChangeSafeForObjectOrInterfaceField( - $oldType, $newType->getWrappedType() - )); + ($newType instanceof NonNull && + self::isChangeSafeForObjectOrInterfaceField($oldType, $newType->getWrappedType()) + ) + ); } elseif ($oldType instanceof ListOfType) { - // if they're both lists, make sure the underlying types are compatible - return ($newType instanceof ListOfType && + return ( + // if they're both lists, make sure the underlying types are compatible + ($newType instanceof ListOfType && self::isChangeSafeForObjectOrInterfaceField($oldType->getWrappedType(), $newType->getWrappedType())) || // moving from nullable to non-null of the same underlying type is safe ($newType instanceof NonNull && - self::isChangeSafeForObjectOrInterfaceField($oldType, $newType->getWrappedType())); + self::isChangeSafeForObjectOrInterfaceField($oldType, $newType->getWrappedType())) + ); } elseif ($oldType instanceof NonNull) { // if they're both non-null, make sure the underlying types are compatible - return $newType instanceof NonNull && - self::isChangeSafeForObjectOrInterfaceField($oldType->getWrappedType(), $newType->getWrappedType()); + return ( + $newType instanceof NonNull && + self::isChangeSafeForObjectOrInterfaceField($oldType->getWrappedType(), $newType->getWrappedType()) + ); } - return false; } @@ -406,15 +436,24 @@ private static function isChangeSafeForInputObjectFieldOrFieldArg( Type $newType ) { if ($oldType instanceof NamedType) { + // if they're both named types, see if their names are equivalent return $newType instanceof NamedType && $oldType->name === $newType->name; } elseif ($oldType instanceof ListOfType) { + // if they're both lists, make sure the underlying types are compatible return $newType instanceof ListOfType && self::isChangeSafeForInputObjectFieldOrFieldArg($oldType->getWrappedType(), $newType->getWrappedType()); } elseif ($oldType instanceof NonNull) { return ( - $newType instanceof NonNull && self::isChangeSafeForInputObjectFieldOrFieldArg($oldType->getWrappedType(), $newType->getWrappedType()) - ) || ( - !($newType instanceof NonNull) && self::isChangeSafeForInputObjectFieldOrFieldArg($oldType->getWrappedType(), $newType) - ); + // if they're both non-null, make sure the underlying types are + // compatible + ($newType instanceof NonNull && + self::isChangeSafeForInputObjectFieldOrFieldArg( + $oldType->getWrappedType(), + $newType->getWrappedType() + )) || + // moving from non-null to nullable of the same underlying type is safe + (!($newType instanceof NonNull) && + self::isChangeSafeForInputObjectFieldOrFieldArg($oldType->getWrappedType(), $newType)) + ); } return false; } @@ -426,9 +465,9 @@ private static function isChangeSafeForInputObjectFieldOrFieldArg( * @return array */ public static function findTypesRemovedFromUnions( - Schema $oldSchema, Schema $newSchema - ) - { + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); @@ -444,8 +483,10 @@ public static function findTypesRemovedFromUnions( } foreach ($oldType->getTypes() as $type) { if (!isset($typeNamesInNewUnion[$type->name])) { - $missingTypeName = $type->name; - $typesRemovedFromUnion[] = ['type' => self::BREAKING_CHANGE_TYPE_REMOVED_FROM_UNION, 'description' => "${missingTypeName} was removed from union type ${typeName}."]; + $typesRemovedFromUnion[] = [ + 'type' => self::BREAKING_CHANGE_TYPE_REMOVED_FROM_UNION, + 'description' => "{$type->name} was removed from union type ${typeName}.", + ]; } } } @@ -459,14 +500,13 @@ public static function findTypesRemovedFromUnions( * @return array */ public static function findTypesAddedToUnions( - Schema $oldSchema, Schema $newSchema - ) - { + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); $typesAddedToUnion = []; - foreach ($newTypeMap as $typeName => $newType) { $oldType = isset($oldTypeMap[$typeName]) ? $oldTypeMap[$typeName] : null; if (!($oldType instanceof UnionType) || !($newType instanceof UnionType)) { @@ -479,12 +519,13 @@ public static function findTypesAddedToUnions( } foreach ($newType->getTypes() as $type) { if (!isset($typeNamesInOldUnion[$type->name])) { - $addedTypeName = $type->name; - $typesAddedToUnion[] = ['type' => self::DANGEROUS_CHANGE_TYPE_ADDED_TO_UNION, 'description' => "${addedTypeName} was added to union type ${typeName}"]; + $typesAddedToUnion[] = [ + 'type' => self::DANGEROUS_CHANGE_TYPE_ADDED_TO_UNION, + 'description' => "{$type->name} was added to union type ${typeName}.", + ]; } } } - return $typesAddedToUnion; } @@ -495,14 +536,13 @@ public static function findTypesAddedToUnions( * @return array */ public static function findValuesRemovedFromEnums( - Schema $oldSchema, Schema $newSchema - ) - { + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); $valuesRemovedFromEnums = []; - foreach ($oldTypeMap as $typeName => $oldType) { $newType = isset($newTypeMap[$typeName]) ? $newTypeMap[$typeName] : null; if (!($oldType instanceof EnumType) || !($newType instanceof EnumType)) { @@ -514,12 +554,13 @@ public static function findValuesRemovedFromEnums( } foreach ($oldType->getValues() as $value) { if (!isset($valuesInNewEnum[$value->name])) { - $valueName = $value->name; - $valuesRemovedFromEnums[] = ['type' => self::BREAKING_CHANGE_VALUE_REMOVED_FROM_ENUM, 'description' => "${valueName} was removed from enum type ${typeName}."]; + $valuesRemovedFromEnums[] = [ + 'type' => self::BREAKING_CHANGE_VALUE_REMOVED_FROM_ENUM, + 'description' => "{$value->name} was removed from enum type ${typeName}.", + ]; } } } - return $valuesRemovedFromEnums; } @@ -530,9 +571,9 @@ public static function findValuesRemovedFromEnums( * @return array */ public static function findValuesAddedToEnums( - Schema $oldSchema, Schema $newSchema - ) - { + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); @@ -548,12 +589,13 @@ public static function findValuesAddedToEnums( } foreach ($newType->getValues() as $value) { if (!isset($valuesInOldEnum[$value->name])) { - $valueName = $value->name; - $valuesAddedToEnums[] = ['type' => self::DANGEROUS_CHANGE_VALUE_ADDED_TO_ENUM, 'description' => "${valueName} was added to enum type ${typeName}"]; + $valuesAddedToEnums[] = [ + 'type' => self::DANGEROUS_CHANGE_VALUE_ADDED_TO_ENUM, + 'description' => "{$value->name} was added to enum type ${typeName}.", + ]; } } } - return $valuesAddedToEnums; } @@ -564,13 +606,13 @@ public static function findValuesAddedToEnums( * @return array */ public static function findInterfacesRemovedFromObjectTypes( - Schema $oldSchema, Schema $newSchema - ) - { + Schema $oldSchema, + Schema $newSchema + ) { $oldTypeMap = $oldSchema->getTypeMap(); $newTypeMap = $newSchema->getTypeMap(); - $breakingChanges = []; + foreach ($oldTypeMap as $typeName => $oldType) { $newType = isset($newTypeMap[$typeName]) ? $newTypeMap[$typeName] : null; if (!($oldType instanceof ObjectType) || !($newType instanceof ObjectType)) { @@ -583,9 +625,9 @@ public static function findInterfacesRemovedFromObjectTypes( if (!Utils::find($newInterfaces, function (InterfaceType $interface) use ($oldInterface) { return $interface->name === $oldInterface->name; })) { - $oldInterfaceName = $oldInterface->name; - $breakingChanges[] = ['type' => self::BREAKING_CHANGE_INTERFACE_REMOVED_FROM_OBJECT, - 'description' => "${typeName} no longer implements interface ${oldInterfaceName}." + $breakingChanges[] = [ + 'type' => self::BREAKING_CHANGE_INTERFACE_REMOVED_FROM_OBJECT, + 'description' => "${typeName} no longer implements interface {$oldInterface->name}." ]; } } @@ -593,6 +635,42 @@ public static function findInterfacesRemovedFromObjectTypes( return $breakingChanges; } + /** + * @param Schema $oldSchema + * @param Schema $newSchema + * + * @return array + */ + public static function findInterfacesAddedToObjectTypes( + Schema $oldSchema, + Schema $newSchema + ) { + $oldTypeMap = $oldSchema->getTypeMap(); + $newTypeMap = $newSchema->getTypeMap(); + $interfacesAddedToObjectTypes = []; + + foreach ($newTypeMap as $typeName => $newType) { + $oldType = isset($oldTypeMap[$typeName]) ? $oldTypeMap[$typeName] : null; + if (!($oldType instanceof ObjectType) || !($newType instanceof ObjectType)) { + continue; + } + + $oldInterfaces = $oldType->getInterfaces(); + $newInterfaces = $newType->getInterfaces(); + foreach ($newInterfaces as $newInterface) { + if (!Utils::find($oldInterfaces, function (InterfaceType $interface) use ($newInterface) { + return $interface->name === $newInterface->name; + })) { + $interfacesAddedToObjectTypes[] = [ + 'type' => self::DANGEROUS_CHANGE_INTERFACE_ADDED_TO_OBJECT, + 'description' => "{$newInterface->name} added to interfaces implemented by {$typeName}.", + ]; + } + } + } + return $interfacesAddedToObjectTypes; + } + public static function findRemovedDirectives(Schema $oldSchema, Schema $newSchema) { $removedDirectives = []; diff --git a/tests/Utils/FindBreakingChangesTest.php b/tests/Utils/FindBreakingChangesTest.php index b60301df3..60134a02d 100644 --- a/tests/Utils/FindBreakingChangesTest.php +++ b/tests/Utils/FindBreakingChangesTest.php @@ -29,7 +29,12 @@ public function setUp() ]); } - public function testShouldDetectIfTypeWasRemoved() + //DESCRIBE: findBreakingChanges + + /** + * @it should detect if a type was removed or not + */ + public function testShouldDetectIfTypeWasRemovedOrNot() { $type1 = new ObjectType([ 'name' => 'Type1', @@ -44,31 +49,33 @@ public function testShouldDetectIfTypeWasRemoved() ] ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $type1, - 'type2' => $type2 - ] - ]) + 'query' => $this->queryType, + 'types' => [$type1, $type2] ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type2' => $type2 - ] - ]) + 'query' => $this->queryType, + 'types' => [$type2] ]); - $this->assertEquals(['type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_REMOVED, 'description' => 'Type1 was removed.'], - FindBreakingChanges::findRemovedTypes($oldSchema, $newSchema)[0] + $expected = [ + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_REMOVED, + 'description' => 'Type1 was removed.' + ] + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findRemovedTypes($oldSchema, $newSchema) ); $this->assertEquals([], FindBreakingChanges::findRemovedTypes($oldSchema, $oldSchema)); } - public function testShouldDetectTypeChanges() + /** + * @it should detect if a type changed its type + */ + public function testShouldDetectIfATypeChangedItsType() { $objectType = new ObjectType([ 'name' => 'ObjectType', @@ -90,37 +97,41 @@ public function testShouldDetectTypeChanges() ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $interfaceType - ] - ]) + 'query' => $this->queryType, + 'types' => [$interfaceType] ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $unionType - ] - ]) + 'query' => $this->queryType, + 'types' => [$unionType] ]); + $expected = [ + [ + 'type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_CHANGED_KIND, + 'description' => 'Type1 changed from an Interface type to a Union type.' + ] + ]; + $this->assertEquals( - ['type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_CHANGED, 'description' => 'Type1 changed from an Interface type to a Union type.'], - FindBreakingChanges::findTypesThatChangedKind($oldSchema, $newSchema)[0] + $expected, + FindBreakingChanges::findTypesThatChangedKind($oldSchema, $newSchema) ); } - public function testShouldDetectFieldChangesAndDeletions() + /** + * @it should detect if a field on a type was deleted or changed type + */ + public function testShouldDetectIfAFieldOnATypeWasDeletedOrChangedType() { - $typeA1 = new ObjectType([ + $typeA = new ObjectType([ 'name' => 'TypeA', 'fields' => [ 'field1' => ['type' => Type::string()], ] ]); + // logically equivalent to TypeA; findBreakingFieldChanges shouldn't + // treat this as different than TypeA $typeA2 = new ObjectType([ 'name' => 'TypeA', 'fields' => [ @@ -136,10 +147,10 @@ public function testShouldDetectFieldChangesAndDeletions() $oldType1 = new InterfaceType([ 'name' => 'Type1', 'fields' => [ - 'field1' => ['type' => $typeA1], + 'field1' => ['type' => $typeA], 'field2' => ['type' => Type::string()], 'field3' => ['type' => Type::string()], - 'field4' => ['type' => $typeA1], + 'field4' => ['type' => $typeA], 'field6' => ['type' => Type::string()], 'field7' => ['type' => Type::listOf(Type::string())], 'field8' => ['type' => Type::int()], @@ -184,84 +195,78 @@ public function testShouldDetectFieldChangesAndDeletions() ] ]); + $oldSchema = new Schema([ + 'query' => $this->queryType, + 'types' => [$oldType1], + ]); + + $newSchema = new Schema([ + 'query' => $this->queryType, + 'types' => [$newType1], + ]); + $expectedFieldChanges = [ [ 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_REMOVED, - 'description' => 'Type1->field2 was removed.', + 'description' => 'Type1.field2 was removed.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field3 changed type from String to Boolean.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field3 changed type from String to Boolean.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field4 changed type from TypeA to TypeB.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field4 changed type from TypeA to TypeB.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field6 changed type from String to [String].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field6 changed type from String to [String].', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field7 changed type from [String] to String.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field7 changed type from [String] to String.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field9 changed type from Int! to Int.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field9 changed type from Int! to Int.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field10 changed type from [Int]! to [Int].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field10 changed type from [Int]! to [Int].', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field11 changed type from Int to [Int]!.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field11 changed type from Int to [Int]!.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field13 changed type from [Int!] to [Int].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field13 changed type from [Int!] to [Int].', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field14 changed type from [Int] to [[Int]].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field14 changed type from [Int] to [[Int]].', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field15 changed type from [[Int]] to [Int].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field15 changed type from [[Int]] to [Int].', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field16 changed type from Int! to [Int]!.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field16 changed type from Int! to [Int]!.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'Type1->field18 changed type from [[Int!]!] to [[Int!]].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'Type1.field18 changed type from [[Int!]!] to [[Int!]].', ], ]; - $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'Type1' => $oldType1 - ] - ]) - ]); - - $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'Type1' => $newType1 - ] - ]) - ]); - $this->assertEquals($expectedFieldChanges, FindBreakingChanges::findFieldsThatChangedTypeOnObjectOrInterfaceTypes($oldSchema, $newSchema)); } - - public function testShouldDetectInputFieldChanges() + /** + * @it should detect if fields on input types changed kind or were removed + */ + public function testShouldDetectIfFieldsOnInputTypesChangedKindOrWereRemoved() { $oldInputType = new InputObjectType([ 'name' => 'InputType1', @@ -363,74 +368,69 @@ public function testShouldDetectInputFieldChanges() ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldInputType - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldInputType] ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newInputType - ] - ]) + 'query' => $this->queryType, + 'types' => [$newInputType] ]); $expectedFieldChanges = [ [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field1 changed type from String to Int.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field1 changed type from String to Int.', ], [ 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_REMOVED, - 'description' => 'InputType1->field2 was removed.', + 'description' => 'InputType1.field2 was removed.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field3 changed type from [String] to String.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field3 changed type from [String] to String.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field5 changed type from String to String!.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field5 changed type from String to String!.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field6 changed type from [Int] to [Int]!.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field6 changed type from [Int] to [Int]!.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field8 changed type from Int to [Int]!.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field8 changed type from Int to [Int]!.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field9 changed type from [Int] to [Int!].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field9 changed type from [Int] to [Int!].', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field11 changed type from [Int] to [[Int]].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field11 changed type from [Int] to [[Int]].', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field12 changed type from [[Int]] to [Int].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field12 changed type from [[Int]] to [Int].', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field13 changed type from Int! to [Int]!.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field13 changed type from Int! to [Int]!.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'InputType1->field15 changed type from [[Int]!] to [[Int!]!].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'InputType1.field15 changed type from [[Int]!] to [[Int!]!].', ], ]; $this->assertEquals($expectedFieldChanges, FindBreakingChanges::findFieldsThatChangedTypeOnInputObjectTypes($oldSchema, $newSchema)['breakingChanges']); } - public function testDetectsNonNullFieldAddedToInputType() + /** + * @it should detect if a non-null field is added to an input type + */ + public function testShouldDetectIfANonNullFieldIsAddedToAnInputType() { $oldInputType = new InputObjectType([ 'name' => 'InputType1', @@ -449,33 +449,32 @@ public function testDetectsNonNullFieldAddedToInputType() ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldInputType - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldInputType], ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newInputType - ] - ]) + 'query' => $this->queryType, + 'types' => [$newInputType], ]); - $this->assertEquals( + $expected = [ [ 'type' => FindBreakingChanges::BREAKING_CHANGE_NON_NULL_INPUT_FIELD_ADDED, 'description' => 'A non-null field requiredField on input type InputType1 was added.' ], - FindBreakingChanges::findFieldsThatChangedTypeOnInputObjectTypes($oldSchema, $newSchema)['breakingChanges'][0] + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findFieldsThatChangedTypeOnInputObjectTypes($oldSchema, $newSchema)['breakingChanges'] ); } - public function testDetectsIfTypeWasRemovedFromUnion() + /** + * @it should detect if a type was removed from a union type + */ + public function testShouldRetectIfATypeWasRemovedFromAUnionType() { $type1 = new ObjectType([ 'name' => 'Type1', @@ -483,21 +482,20 @@ public function testDetectsIfTypeWasRemovedFromUnion() 'field1' => Type::string() ] ]); - + // logially equivalent to type1; findTypesRemovedFromUnions should not + // treat this as different than type1 $type1a = new ObjectType([ 'name' => 'Type1', 'fields' => [ 'field1' => Type::string() ] ]); - $type2 = new ObjectType([ 'name' => 'Type2', 'fields' => [ 'field1' => Type::string() ] ]); - $type3 = new ObjectType([ 'name' => 'Type3', 'fields' => [ @@ -509,41 +507,37 @@ public function testDetectsIfTypeWasRemovedFromUnion() 'name' => 'UnionType1', 'types' => [$type1, $type2], ]); - - $newUnionType = new UnionType([ 'name' => 'UnionType1', 'types' => [$type1a, $type3], ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldUnionType - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldUnionType], ]); - $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newUnionType - ] - ]) + 'query' => $this->queryType, + 'types' => [$newUnionType], ]); - $this->assertEquals( + $expected = [ [ 'type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_REMOVED_FROM_UNION, 'description' => 'Type2 was removed from union type UnionType1.' - ], - FindBreakingChanges::findTypesRemovedFromUnions($oldSchema, $newSchema)[0] + ] + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findTypesRemovedFromUnions($oldSchema, $newSchema) ); } - public function testDetectsValuesRemovedFromEnum() + /** + * @it should detect if a value was removed from an enum type + */ + public function testShouldDetectIfAValueWasRemovedFromAnEnumType() { $oldEnumType = new EnumType([ 'name' => 'EnumType1', @@ -563,35 +557,33 @@ public function testDetectsValuesRemovedFromEnum() ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldEnumType - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldEnumType] ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newEnumType - ] - ]) + 'query' => $this->queryType, + 'types' => [$newEnumType] ]); - $this->assertEquals( + $expected = [ [ 'type' => FindBreakingChanges::BREAKING_CHANGE_VALUE_REMOVED_FROM_ENUM, 'description' => 'VALUE1 was removed from enum type EnumType1.' - ], - FindBreakingChanges::findValuesRemovedFromEnums($oldSchema, $newSchema)[0] + ] + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findValuesRemovedFromEnums($oldSchema, $newSchema) ); } - public function testDetectsRemovalOfFieldArgument() + /** + * @it should detect if a field argument was removed + */ + public function testShouldDetectIfAFieldArgumentWasRemoved() { - $oldType = new ObjectType([ 'name' => 'Type1', 'fields' => [ @@ -604,7 +596,6 @@ public function testDetectsRemovalOfFieldArgument() ] ]); - $inputType = new InputObjectType([ 'name' => 'InputType1', 'fields' => [ @@ -643,48 +634,38 @@ public function testDetectsRemovalOfFieldArgument() ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldType, - 'type2' => $oldInterfaceType - ], - 'types' => [$oldType, $oldInterfaceType] - ]) + 'query' => $this->queryType, + 'types' => [$oldType, $oldInterfaceType], ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newType, - 'type2' => $newInterfaceType - ], - 'types' => [$newType, $newInterfaceType] - ]) + 'query' => $this->queryType, + 'types' => [$newType, $newInterfaceType], ]); $expectedChanges = [ [ 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_REMOVED, - 'description' => 'Type1->field1 arg name was removed', + 'description' => 'Type1.field1 arg name was removed', ], [ 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_REMOVED, - 'description' => 'Interface1->field1 arg arg1 was removed', + 'description' => 'Interface1.field1 arg arg1 was removed', ], [ 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_REMOVED, - 'description' => 'Interface1->field1 arg objectArg was removed', + 'description' => 'Interface1.field1 arg objectArg was removed', ] ]; $this->assertEquals($expectedChanges, FindBreakingChanges::findArgChanges($oldSchema, $newSchema)['breakingChanges']); } - public function testDetectsFieldArgumentTypeChange() + /** + * @it should detect if a field argument has changed type + */ + public function testShouldDetectIfAFieldArgumentHasChangedType() { - $oldType = new ObjectType([ 'name' => 'Type1', 'fields' => [ @@ -738,78 +719,73 @@ public function testDetectsFieldArgumentTypeChange() ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldType - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldType] ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newType - ] - ]) + 'query' => $this->queryType, + 'types' => [$newType] ]); $expectedChanges = [ [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg1 has changed type from String to Int.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg1 has changed type from String to Int', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg2 has changed type from String to [String].' + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg2 has changed type from String to [String]' ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg3 has changed type from [String] to String.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg3 has changed type from [String] to String', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg4 has changed type from String to String!.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg4 has changed type from String to String!', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg5 has changed type from String! to Int.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg5 has changed type from String! to Int', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg6 has changed type from String! to Int!.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg6 has changed type from String! to Int!', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg8 has changed type from Int to [Int]!.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg8 has changed type from Int to [Int]!', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg9 has changed type from [Int] to [Int!].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg9 has changed type from [Int] to [Int!]', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg11 has changed type from [Int] to [[Int]].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg11 has changed type from [Int] to [[Int]]', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg12 has changed type from [[Int]] to [Int].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg12 has changed type from [[Int]] to [Int]', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg13 has changed type from Int! to [Int]!.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg13 has changed type from Int! to [Int]!', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'Type1->field1 arg arg15 has changed type from [[Int]!] to [[Int!]!].', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'Type1.field1 arg arg15 has changed type from [[Int]!] to [[Int!]!]', ], ]; $this->assertEquals($expectedChanges, FindBreakingChanges::findArgChanges($oldSchema, $newSchema)['breakingChanges']); } - public function testDetectsAdditionOfFieldArg() + /** + * @it should detect if a non-null field argument was added + */ + public function testShouldDetectIfANonNullFieldArgumentWasAdded() { $oldType = new ObjectType([ 'name' => 'Type1', @@ -834,31 +810,30 @@ public function testDetectsAdditionOfFieldArg() ] ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldType, - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldType] ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newType - ] - ]) + 'query' => $this->queryType, + 'types' => [$newType] ]); - $this->assertEquals( + $expected = [ [ 'type' => FindBreakingChanges::BREAKING_CHANGE_NON_NULL_ARG_ADDED, - 'description' => 'A non-null arg newRequiredArg on Type1->field1 was added.' - ], - FindBreakingChanges::findArgChanges($oldSchema, $newSchema)['breakingChanges'][0]); + 'description' => 'A non-null arg newRequiredArg on Type1.field1 was added' + ] + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findArgChanges($oldSchema, $newSchema)['breakingChanges']); } - public function testDoesNotFlagArgsWithSameTypeSignature() + /** + * @it should not flag args with the same type signature as breaking + */ + public function testShouldNotFlagArgsWithTheSameTypeSignatureAsBreaking() { $inputType1a = new InputObjectType([ 'name' => 'InputType1', @@ -901,26 +876,21 @@ public function testDoesNotFlagArgsWithSameTypeSignature() ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldType, - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldType], ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newType - ] - ]) + 'query' => $this->queryType, + 'types' => [$newType], ]); $this->assertEquals([], FindBreakingChanges::findArgChanges($oldSchema, $newSchema)['breakingChanges']); } - public function testArgsThatMoveAwayFromNonNull() + /** + * @it should consider args that move away from NonNull as non-breaking + */ + public function testShouldConsiderArgsThatMoveAwayFromNonNullAsNonBreaking() { $oldType = new ObjectType([ 'name' => 'Type1', @@ -946,26 +916,21 @@ public function testArgsThatMoveAwayFromNonNull() ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldType, - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldType], ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newType - ] - ]) + 'query' => $this->queryType, + 'types' => [$newType], ]); $this->assertEquals([], FindBreakingChanges::findArgChanges($oldSchema, $newSchema)['breakingChanges']); } - public function testDetectsRemovalOfInterfaces() + /** + * @it should detect interfaces removed from types + */ + public function testShouldDetectInterfacesRemovedFromTypes() { $interface1 = new InterfaceType([ 'name' => 'Interface1', @@ -988,31 +953,30 @@ public function testDetectsRemovalOfInterfaces() ]); $oldSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $oldType, - ] - ]) + 'query' => $this->queryType, + 'types' => [$oldType], ]); $newSchema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'root', - 'fields' => [ - 'type1' => $newType - ] - ]) + 'query' => $this->queryType, + 'types' => [$newType], ]); - $this->assertEquals( + $expected = [ [ 'type' => FindBreakingChanges::BREAKING_CHANGE_INTERFACE_REMOVED_FROM_OBJECT, 'description' => 'Type1 no longer implements interface Interface1.' ], - FindBreakingChanges::findInterfacesRemovedFromObjectTypes($oldSchema, $newSchema)[0]); + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findInterfacesRemovedFromObjectTypes($oldSchema, $newSchema)); } - public function testDetectsAllBreakingChanges() + /** + * @it should detect all breaking changes + */ + public function testShouldDetectAllBreakingChanges() { $typeThatGetsRemoved = new ObjectType([ 'name' => 'TypeThatGetsRemoved', @@ -1177,13 +1141,13 @@ public function testDetectsAllBreakingChanges() $oldSchema = new Schema([ 'query' => $this->queryType, 'types' => [ - 'TypeThatGetsRemoved' => $typeThatGetsRemoved, - 'TypeThatChangesType' => $typeThatChangesTypeOld, - 'TypeThatHasBreakingFieldChanges' => $typeThatHasBreakingFieldChangesOld, - 'UnionTypeThatLosesAType' => $unionTypeThatLosesATypeOld, - 'EnumTypeThatLosesAValue' => $enumTypeThatLosesAValueOld, - 'ArgThatChanges' => $argThatChanges, - 'TypeThatLosesInterface' => $typeThatLosesInterfaceOld + $typeThatGetsRemoved, + $typeThatChangesTypeOld, + $typeThatHasBreakingFieldChangesOld, + $unionTypeThatLosesATypeOld, + $enumTypeThatLosesAValueOld, + $argThatChanges, + $typeThatLosesInterfaceOld ], 'directives' => [ $directiveThatIsRemoved, @@ -1196,13 +1160,13 @@ public function testDetectsAllBreakingChanges() $newSchema = new Schema([ 'query' => $this->queryType, 'types' => [ - 'TypeThatChangesType' => $typeThatChangesTypeNew, - 'TypeThatHasBreakingFieldChanges' => $typeThatHasBreakingFieldChangesNew, - 'UnionTypeThatLosesAType' => $unionTypeThatLosesATypeNew, - 'EnumTypeThatLosesAValue' => $enumTypeThatLosesAValueNew, - 'ArgThatChanges' => $argChanged, - 'TypeThatLosesInterface' => $typeThatLosesInterfaceNew, - 'Interface1' => $interface1 + $typeThatChangesTypeNew, + $typeThatHasBreakingFieldChangesNew, + $unionTypeThatLosesATypeNew, + $enumTypeThatLosesAValueNew, + $argChanged, + $typeThatLosesInterfaceNew, + $interface1 ], 'directives' => [ $directiveThatRemovesArgNew, @@ -1220,25 +1184,23 @@ public function testDetectsAllBreakingChanges() 'type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_REMOVED, 'description' => 'TypeInUnion2 was removed.', ], - /* - // NB the below assertion is included in the graphql-js tests, but it makes no sense. - // Seriously, look for what `int` type was supposed to be removed between the two schemas. There is none. - // I honestly think it's a bug in the js implementation and was put into the test just to make it pass. + /* This is reported in the js version because builtin sclar types are added on demand + and not like here always [ 'type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_REMOVED, 'description' => 'Int was removed.' ],*/ [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_CHANGED, + 'type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_CHANGED_KIND, 'description' => 'TypeThatChangesType changed from an Object type to an Interface type.', ], [ 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_REMOVED, - 'description' => 'TypeThatHasBreakingFieldChanges->field1 was removed.', + 'description' => 'TypeThatHasBreakingFieldChanges.field1 was removed.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED, - 'description' => 'TypeThatHasBreakingFieldChanges->field2 changed type from String to Boolean.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_FIELD_CHANGED_KIND, + 'description' => 'TypeThatHasBreakingFieldChanges.field2 changed type from String to Boolean.', ], [ 'type' => FindBreakingChanges::BREAKING_CHANGE_TYPE_REMOVED_FROM_UNION, @@ -1249,8 +1211,8 @@ public function testDetectsAllBreakingChanges() 'description' => 'VALUE0 was removed from enum type EnumTypeThatLosesAValue.', ], [ - 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED, - 'description' => 'ArgThatChanges->field1 arg id has changed type from Int to String.', + 'type' => FindBreakingChanges::BREAKING_CHANGE_ARG_CHANGED_KIND, + 'description' => 'ArgThatChanges.field1 arg id has changed type from Int to String', ], [ 'type' => FindBreakingChanges::BREAKING_CHANGE_INTERFACE_REMOVED_FROM_OBJECT, @@ -1457,8 +1419,12 @@ public function testShouldDetectLocationsRemovedDirectiveWithinASchema() } // DESCRIBE: findDangerousChanges + // DESCRIBE: findArgChanges - public function testFindDangerousArgChanges() + /** + * @it should detect if an argument's defaultValue has changed + */ + public function testShouldDetectIfAnArgumentsDefaultValueHasChanged() { $oldType = new ObjectType([ 'name' => 'Type1', @@ -1483,7 +1449,7 @@ public function testFindDangerousArgChanges() 'args' => [ 'name' => [ 'type' => Type::string(), - 'defaultValue' => 'Testertest' + 'defaultValue' => 'Test' ] ] ] @@ -1492,28 +1458,31 @@ public function testFindDangerousArgChanges() $oldSchema = new Schema([ 'query' => $this->queryType, - 'types' => [ - $oldType - ] + 'types' => [$oldType], ]); $newSchema = new Schema([ 'query' => $this->queryType, - 'types' => [ - $newType - ] + 'types' => [$newType], ]); - $this->assertEquals( + $expected = [ [ - 'type' => FindBreakingChanges::DANGEROUS_CHANGE_ARG_DEFAULT_VALUE, - 'description' => 'Type1->field1 arg name has changed defaultValue' - ], - FindBreakingChanges::findArgChanges($oldSchema, $newSchema)['dangerousChanges'][0] + 'type' => FindBreakingChanges::DANGEROUS_CHANGE_ARG_DEFAULT_VALUE_CHANGED, + 'description' => 'Type1.field1 arg name has changed defaultValue' + ] + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findArgChanges($oldSchema, $newSchema)['dangerousChanges'] ); } - public function testDetectsEnumValueAdditions() + /** + * @it should detect if a value was added to an enum type + */ + public function testShouldDetectIfAValueWasAddedToAnEnumType() { $oldEnumType = new EnumType([ 'name' => 'EnumType1', @@ -1533,28 +1502,80 @@ public function testDetectsEnumValueAdditions() $oldSchema = new Schema([ 'query' => $this->queryType, - 'types' => [ - $oldEnumType - ] + 'types' => [$oldEnumType], ]); $newSchema = new Schema([ 'query' => $this->queryType, - 'types' => [ - $newEnumType - ] + 'types' => [$newEnumType], ]); - $this->assertEquals( + $expected = [ [ 'type' => FindBreakingChanges::DANGEROUS_CHANGE_VALUE_ADDED_TO_ENUM, - 'description' => 'VALUE2 was added to enum type EnumType1' + 'description' => 'VALUE2 was added to enum type EnumType1.' + ] + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findValuesAddedToEnums($oldSchema, $newSchema) + ); + } + + /** + * @it should detect interfaces added to types + */ + public function testShouldDetectInterfacesAddedToTypes() + { + $interface1 = new InterfaceType([ + 'name' => 'Interface1', + 'fields' => [ + 'field1' => Type::string(), ], - FindBreakingChanges::findValuesAddedToEnums($oldSchema, $newSchema)[0] + ]); + $oldType = new ObjectType([ + 'name' => 'Type1', + 'fields' => [ + 'field1' => Type::string(), + ], + ]); + + $newType = new ObjectType([ + 'name' => 'Type1', + 'interfaces' => [$interface1], + 'fields' => [ + 'field1' => Type::string(), + ], + ]); + + $oldSchema = new Schema([ + 'query' => $this->queryType, + 'types' => [$oldType], + ]); + + $newSchema = new Schema([ + 'query' => $this->queryType, + 'types' => [$newType], + ]); + + $expected = [ + [ + 'type' => FindBreakingChanges::DANGEROUS_CHANGE_INTERFACE_ADDED_TO_OBJECT, + 'description' => 'Interface1 added to interfaces implemented by Type1.' + ] + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findInterfacesAddedToObjectTypes($oldSchema, $newSchema) ); } - public function testDetectsAdditionsToUnionType() + /** + * @it should detect if a type was added to a union type + */ + public function testShouldDetectIfATypeWasAddedToAUnionType() { $type1 = new ObjectType([ 'name' => 'Type1', @@ -1562,14 +1583,14 @@ public function testDetectsAdditionsToUnionType() 'field1' => Type::string() ] ]); - + // logially equivalent to type1; findTypesRemovedFromUnions should not + //treat this as different than type1 $type1a = new ObjectType([ 'name' => 'Type1', 'fields' => [ 'field1' => Type::string() ] ]); - $type2 = new ObjectType([ 'name' => 'Type2', 'fields' => [ @@ -1581,7 +1602,6 @@ public function testDetectsAdditionsToUnionType() 'name' => 'UnionType1', 'types' => [$type1], ]); - $newUnionType = new UnionType([ 'name' => 'UnionType1', 'types' => [$type1a, $type2], @@ -1589,24 +1609,24 @@ public function testDetectsAdditionsToUnionType() $oldSchema = new Schema([ 'query' => $this->queryType, - 'types' => [ - $oldUnionType - ] + 'types' => [$oldUnionType], ]); $newSchema = new Schema([ 'query' => $this->queryType, - 'types' => [ - $newUnionType - ] + 'types' => [$newUnionType], ]); - $this->assertEquals( + $expected = [ [ 'type' => FindBreakingChanges::DANGEROUS_CHANGE_TYPE_ADDED_TO_UNION, - 'description' => 'Type2 was added to union type UnionType1' - ], - FindBreakingChanges::findTypesAddedToUnions($oldSchema, $newSchema)[0] + 'description' => 'Type2 was added to union type UnionType1.' + ] + ]; + + $this->assertEquals( + $expected, + FindBreakingChanges::findTypesAddedToUnions($oldSchema, $newSchema) ); } @@ -1659,7 +1679,10 @@ public function testShouldDetectIfANullableFieldWasAddedToAnInput() $this->assertEquals($expectedFieldChanges, FindBreakingChanges::findFieldsThatChangedTypeOnInputObjectTypes($oldSchema, $newSchema)['dangerousChanges']); } - public function testFindsAllDangerousChanges() + /** + * @it should find all dangerous changes + */ + public function testShouldFindAllDangerousChanges() { $enumThatGainsAValueOld = new EnumType([ 'name' => 'EnumType1', @@ -1692,6 +1715,27 @@ public function testFindsAllDangerousChanges() ] ]); + $typeInUnion1 = new ObjectType([ + 'name' => 'TypeInUnion1', + 'fields' => [ + 'field1' => Type::string() + ] + ]); + $typeInUnion2 = new ObjectType([ + 'name' => 'TypeInUnion2', + 'fields' => [ + 'field1' => Type::string() + ] + ]); + $unionTypeThatGainsATypeOld = new UnionType([ + 'name' => 'UnionTypeThatGainsAType', + 'types' => [$typeInUnion1], + ]); + $unionTypeThatGainsATypeNew = new UnionType([ + 'name' => 'UnionTypeThatGainsAType', + 'types' => [$typeInUnion1, $typeInUnion2], + ]); + $newType = new ObjectType([ 'name' => 'Type1', 'fields' => [ @@ -1700,35 +1744,33 @@ public function testFindsAllDangerousChanges() 'args' => [ 'name' => [ 'type' => Type::string(), - 'defaultValue' => 'Testertest' + 'defaultValue' => 'Test' ] ] ] ] ]); - $typeInUnion1 = new ObjectType([ - 'name' => 'TypeInUnion1', + $interface1 = new InterfaceType([ + 'name' => 'Interface1', 'fields' => [ - 'field1' => Type::string() - ] + 'field1' => Type::string(), + ], ]); - $typeInUnion2 = new ObjectType([ - 'name' => 'TypeInUnion2', + $typeThatGainsInterfaceOld = new ObjectType([ + 'name' => 'TypeThatGainsInterface1', 'fields' => [ - 'field1' => Type::string() - ] - ]); - - $unionTypeThatGainsATypeOld = new UnionType([ - 'name' => 'UnionType1', - 'types' => [$typeInUnion1], + 'field1' => Type::string(), + ], ]); - $unionTypeThatGainsATypeNew = new UnionType([ - 'name' => 'UnionType1', - 'types' => [$typeInUnion1, $typeInUnion2], + $typeThatGainsInterfaceNew = new ObjectType([ + 'name' => 'TypeThatGainsInterface1', + 'interfaces' => [$interface1], + 'fields' => [ + 'field1' => Type::string(), + ], ]); $oldSchema = new Schema([ @@ -1736,6 +1778,7 @@ public function testFindsAllDangerousChanges() 'types' => [ $oldType, $enumThatGainsAValueOld, + $typeThatGainsInterfaceOld, $unionTypeThatGainsATypeOld ] ]); @@ -1745,22 +1788,27 @@ public function testFindsAllDangerousChanges() 'types' => [ $newType, $enumThatGainsAValueNew, + $typeThatGainsInterfaceNew, $unionTypeThatGainsATypeNew ] ]); $expectedDangerousChanges = [ [ - 'description' => 'Type1->field1 arg name has changed defaultValue', - 'type' => FindBreakingChanges::DANGEROUS_CHANGE_ARG_DEFAULT_VALUE + 'description' => 'Type1.field1 arg name has changed defaultValue', + 'type' => FindBreakingChanges::DANGEROUS_CHANGE_ARG_DEFAULT_VALUE_CHANGED ], [ - 'description' => 'VALUE2 was added to enum type EnumType1', + 'description' => 'VALUE2 was added to enum type EnumType1.', 'type' => FindBreakingChanges::DANGEROUS_CHANGE_VALUE_ADDED_TO_ENUM ], + [ + 'type' => FindBreakingChanges::DANGEROUS_CHANGE_INTERFACE_ADDED_TO_OBJECT, + 'description' => 'Interface1 added to interfaces implemented by TypeThatGainsInterface1.', + ], [ 'type' => FindBreakingChanges::DANGEROUS_CHANGE_TYPE_ADDED_TO_UNION, - 'description' => 'TypeInUnion2 was added to union type UnionType1', + 'description' => 'TypeInUnion2 was added to union type UnionTypeThatGainsAType.', ] ]; @@ -1805,21 +1853,17 @@ public function testShouldDetectIfANullableFieldArgumentWasAdded() $oldSchema = new Schema([ 'query' => $this->queryType, - 'types' => [ - $oldType, - ] + 'types' => [$oldType], ]); $newSchema = new Schema([ 'query' => $this->queryType, - 'types' => [ - $newType, - ] + 'types' => [$newType], ]); $expectedFieldChanges = [ [ - 'description' => 'A nullable arg arg2 on Type1->field1 was added.', + 'description' => 'A nullable arg arg2 on Type1.field1 was added', 'type' => FindBreakingChanges::DANGEROUS_CHANGE_NULLABLE_ARG_ADDED ], ]; From d92a2dab217c01512c2a0b7d56dcb6783ca10afa Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Fri, 16 Feb 2018 16:19:25 +0100 Subject: [PATCH 45/50] Add suggestions for invalid values For misspelled enums or field names, these suggestions can be helpful. This also changes the suggestions algorithm to better detect case-sensitivity mistakes, which are common ref: graphql/graphql-js#1153 --- src/Utils/Utils.php | 9 +- src/Utils/Value.php | 57 +++++++-- src/Validator/Rules/ValuesOfCorrectType.php | 40 ++++-- tests/Executor/VariablesTest.php | 14 +- tests/Type/EnumTypeTest.php | 19 ++- tests/Utils/CoerceValueTest.php | 135 ++++++++++++++++++++ tests/Validator/ValuesOfCorrectTypeTest.php | 23 +++- 7 files changed, 258 insertions(+), 39 deletions(-) diff --git a/src/Utils/Utils.php b/src/Utils/Utils.php index 853fbf1a1..c9c3452aa 100644 --- a/src/Utils/Utils.php +++ b/src/Utils/Utils.php @@ -512,6 +512,9 @@ function ($list, $index) use ($selected, $selectedLength) { * Given an invalid input string and a list of valid options, returns a filtered * list of valid options sorted based on their similarity with the input. * + * Includes a custom alteration from Damerau-Levenshtein to treat case changes + * as a single edit which helps identify mis-cased values with an edit distance + * of 1 * @param string $input * @param array $options * @return string[] @@ -521,7 +524,11 @@ public static function suggestionList($input, array $options) $optionsByDistance = []; $inputThreshold = mb_strlen($input) / 2; foreach ($options as $option) { - $distance = levenshtein($input, $option); + $distance = $input === $option + ? 0 + : (strtolower($input) === strtolower($option) + ? 1 + : levenshtein($input, $option)); $threshold = max($inputThreshold, mb_strlen($option) / 2, 1); if ($distance <= $threshold) { $optionsByDistance[$option] = $distance; diff --git a/src/Utils/Value.php b/src/Utils/Value.php index 6d45c49df..560609157 100644 --- a/src/Utils/Value.php +++ b/src/Utils/Value.php @@ -2,6 +2,7 @@ namespace GraphQL\Utils; use GraphQL\Error\Error; +use GraphQL\Language\AST\Node; use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InputType; @@ -26,7 +27,7 @@ public static function coerceValue($value, InputType $type, $blameNode = null, a if ($value === null) { return self::ofErrors([ self::coercionError( - "Expected non-nullable type $type", + "Expected non-nullable type $type not to be null", $blameNode, $path ), @@ -55,11 +56,23 @@ public static function coerceValue($value, InputType $type, $blameNode = null, a return self::ofValue($parseResult); } catch (\Exception $error) { return self::ofErrors([ - self::coercionError("Expected type {$type->name}", $blameNode, $path, $error), + self::coercionError( + "Expected type {$type->name}", + $blameNode, + $path, + $error->getMessage(), + $error + ), ]); } catch (\Throwable $error) { return self::ofErrors([ - self::coercionError("Expected type {$type->name}", $blameNode, $path, $error), + self::coercionError( + "Expected type {$type->name}", + $blameNode, + $path, + $error->getMessage(), + $error + ), ]); } } @@ -72,8 +85,21 @@ public static function coerceValue($value, InputType $type, $blameNode = null, a } } + $suggestions = Utils::suggestionList( + Utils::printSafe($value), + array_map(function($enumValue) { return $enumValue->name; }, $type->getValues()) + ); + $didYouMean = $suggestions + ? "did you mean " . Utils::orList($suggestions) . "?" + : null; + return self::ofErrors([ - self::coercionError("Expected type {$type->name}", $blameNode, $path), + self::coercionError( + "Expected type {$type->name}", + $blameNode, + $path, + $didYouMean + ), ]); } @@ -105,7 +131,11 @@ public static function coerceValue($value, InputType $type, $blameNode = null, a if ($type instanceof InputObjectType) { if (!is_object($value) && !is_array($value) && !$value instanceof \Traversable) { return self::ofErrors([ - self::coercionError("Expected object type {$type->name}", $blameNode, $path), + self::coercionError( + "Expected type {$type->name} to be an object", + $blameNode, + $path + ), ]); } @@ -146,12 +176,20 @@ public static function coerceValue($value, InputType $type, $blameNode = null, a // Ensure every provided field is defined. foreach ($value as $fieldName => $field) { if (!array_key_exists($fieldName, $fields)) { + $suggestions = Utils::suggestionList( + $fieldName, + array_keys($fields) + ); + $didYouMean = $suggestions + ? "did you mean " . Utils::orList($suggestions) . "?" + : null; $errors = self::add( $errors, self::coercionError( "Field \"{$fieldName}\" is not defined by type {$type->name}", $blameNode, - $path + $path, + $didYouMean ) ); } @@ -183,18 +221,17 @@ private static function atPath($prev, $key) { * @param string $message * @param Node $blameNode * @param array|null $path + * @param string $subMessage * @param \Exception|\Throwable|null $originalError * @return Error */ - private static function coercionError($message, $blameNode, array $path = null, $originalError = null) { + private static function coercionError($message, $blameNode, array $path = null, $subMessage = null, $originalError = null) { $pathStr = self::printPath($path); // Return a GraphQLError instance return new Error( $message . ($pathStr ? ' at ' . $pathStr : '') . - ($originalError && $originalError->getMessage() - ? '; ' . $originalError->getMessage() - : '.'), + ($subMessage ? '; ' . $subMessage : '.'), $blameNode, null, null, diff --git a/src/Validator/Rules/ValuesOfCorrectType.php b/src/Validator/Rules/ValuesOfCorrectType.php index a70de1fa2..c77c93b92 100644 --- a/src/Validator/Rules/ValuesOfCorrectType.php +++ b/src/Validator/Rules/ValuesOfCorrectType.php @@ -45,9 +45,12 @@ static function requiredFieldMessage($typeName, $fieldName, $fieldTypeName) "{$fieldTypeName} was not provided."; } - static function unknownFieldMessage($typeName, $fieldName) + static function unknownFieldMessage($typeName, $fieldName, $message = null) { - return "Field \"{$fieldName}\" is not defined by type {$typeName}."; + return ( + "Field \"{$fieldName}\" is not defined by type {$typeName}" . + ($message ? "; {$message}" : '.') + ); } public function getVisitor(ValidationContext $context) @@ -103,10 +106,18 @@ public function getVisitor(ValidationContext $context) NodeKind::OBJECT_FIELD => function(ObjectFieldNode $node) use ($context) { $parentType = Type::getNamedType($context->getParentInputType()); $fieldType = $context->getInputType(); - if (!$fieldType && $parentType) { + if (!$fieldType && $parentType instanceof InputObjectType) { + $suggestions = Utils::suggestionList( + $node->name->value, + array_keys($parentType->getFields()) + ); + $didYouMean = $suggestions + ? "Did you mean " . Utils::orList($suggestions) . "?" + : null; + $context->reportError( new Error( - self::unknownFieldMessage($parentType->name, $node->name->value), + self::unknownFieldMessage($parentType->name, $node->name->value, $didYouMean), $node ) ); @@ -148,15 +159,12 @@ private function isValidScalar(ValidationContext $context, ValueNode $node) $type = Type::getNamedType($locationType); if (!$type instanceof ScalarType) { - $suggestions = $type instanceof EnumType - ? $this->enumTypeSuggestion($type, $node) - : null; $context->reportError( new Error( self::badValueMessage( (string) $locationType, Printer::doPrint($node), - $suggestions + $this->enumTypeSuggestion($type, $node) ), $node ) @@ -214,13 +222,17 @@ private function isValidScalar(ValidationContext $context, ValueNode $node) } } - private function enumTypeSuggestion(EnumType $type, ValueNode $node) + private function enumTypeSuggestion($type, ValueNode $node) { - $suggestions = Utils::suggestionList( - Printer::doPrint($node), - array_map(function (EnumValueDefinition $value) { return $value->name; }, $type->getValues()) - ); + if ($type instanceof EnumType) { + $suggestions = Utils::suggestionList( + Printer::doPrint($node), + array_map(function (EnumValueDefinition $value) { + return $value->name; + }, $type->getValues()) + ); - return $suggestions ? 'Did you mean the enum value: ' . Utils::orList($suggestions) . '?' : ''; + return $suggestions ? 'Did you mean the enum value ' . Utils::orList($suggestions) . '?' : null; + } } } diff --git a/tests/Executor/VariablesTest.php b/tests/Executor/VariablesTest.php index ccb16bb59..89c931f86 100644 --- a/tests/Executor/VariablesTest.php +++ b/tests/Executor/VariablesTest.php @@ -160,7 +160,7 @@ public function testUsingVariables() 'message' => 'Variable "$input" got invalid value ' . '{"a":"foo","b":"bar","c":null}; ' . - 'Expected non-nullable type String! at value.c.', + 'Expected non-nullable type String! not to be null at value.c.', 'locations' => [['line' => 2, 'column' => 17]], 'category' => 'graphql' ] @@ -177,7 +177,7 @@ public function testUsingVariables() [ 'message' => 'Variable "$input" got invalid value "foo bar"; ' . - 'Expected object type TestInputObject.', + 'Expected type TestInputObject to be an object.', 'locations' => [ [ 'line' => 2, 'column' => 17 ] ], 'category' => 'graphql', ] @@ -411,7 +411,7 @@ public function testDoesNotAllowNonNullableInputsToBeSetToNullInAVariable() [ 'message' => 'Variable "$value" got invalid value null; ' . - 'Expected non-nullable type String!.', + 'Expected non-nullable type String! not to be null.', 'locations' => [['line' => 2, 'column' => 31]], 'category' => 'graphql', ] @@ -613,7 +613,7 @@ public function testDoesNotAllowNonNullListsToBeNull() [ 'message' => 'Variable "$input" got invalid value null; ' . - 'Expected non-nullable type [String]!.', + 'Expected non-nullable type [String]! not to be null.', 'locations' => [['line' => 2, 'column' => 17]], 'category' => 'graphql', ] @@ -701,7 +701,7 @@ public function testDoesNotAllowListsOfNonNullsToContainNull() [ 'message' => 'Variable "$input" got invalid value ["A",null,"B"]; ' . - 'Expected non-nullable type String! at value[1].', + 'Expected non-nullable type String! not to be null at value[1].', 'locations' => [ ['line' => 2, 'column' => 17] ], 'category' => 'graphql', ] @@ -727,7 +727,7 @@ public function testDoesNotAllowNonNullListsOfNonNullsToBeNull() [ 'message' => 'Variable "$input" got invalid value null; ' . - 'Expected non-nullable type [String!]!.', + 'Expected non-nullable type [String!]! not to be null.', 'locations' => [ ['line' => 2, 'column' => 17] ], 'category' => 'graphql', ] @@ -768,7 +768,7 @@ public function testDoesNotAllowNonNullListsOfNonNullsToContainNull() [ 'message' => 'Variable "$input" got invalid value ["A",null,"B"]; ' . - 'Expected non-nullable type String! at value[1].', + 'Expected non-nullable type String! not to be null at value[1].', 'locations' => [ ['line' => 2, 'column' => 17] ], 'category' => 'graphql', ] diff --git a/tests/Type/EnumTypeTest.php b/tests/Type/EnumTypeTest.php index 9c6910dd6..79741d38e 100644 --- a/tests/Type/EnumTypeTest.php +++ b/tests/Type/EnumTypeTest.php @@ -220,7 +220,7 @@ public function testDoesNotAcceptStringLiterals() '{ colorEnum(fromEnum: "GREEN") }', null, [ - 'message' => "Expected type Color, found \"GREEN\"; Did you mean the enum value: GREEN?", + 'message' => "Expected type Color, found \"GREEN\"; Did you mean the enum value GREEN?", 'locations' => [new SourceLocation(1, 23)] ] ); @@ -235,7 +235,22 @@ public function testDoesNotAcceptValuesNotInTheEnum() '{ colorEnum(fromEnum: GREENISH) }', null, [ - 'message' => "Expected type Color, found GREENISH; Did you mean the enum value: GREEN?", + 'message' => "Expected type Color, found GREENISH; Did you mean the enum value GREEN?", + 'locations' => [new SourceLocation(1, 23)] + ] + ); + } + + /** + * @it does not accept values with incorrect casing + */ + public function testDoesNotAcceptValuesWithIncorrectCasing() + { + $this->expectFailure( + '{ colorEnum(fromEnum: green) }', + null, + [ + 'message' => "Expected type Color, found green; Did you mean the enum value GREEN?", 'locations' => [new SourceLocation(1, 23)] ] ); diff --git a/tests/Utils/CoerceValueTest.php b/tests/Utils/CoerceValueTest.php index 999ca89cf..bb5823a42 100644 --- a/tests/Utils/CoerceValueTest.php +++ b/tests/Utils/CoerceValueTest.php @@ -1,12 +1,38 @@ testEnum = new EnumType([ + 'name' => 'TestEnum', + 'values' => [ + 'FOO' => 'InternalFoo', + 'BAR' => 123456789, + ], + ]); + + $this->testInputObject = new InputObjectType([ + 'name' => 'TestInputObject', + 'fields' => [ + 'foo' => Type::nonNull(Type::int()), + 'bar' => Type::int(), + ], + ]); + } + // Describe: coerceValue /** @@ -186,16 +212,125 @@ public function testFloatReturnsASingleErrorForMultiCharInput() ); } + // DESCRIBE: for GraphQLEnum + + /** + * @it returns no error for a known enum name + */ + public function testReturnsNoErrorForAKnownEnumName() + { + $fooResult = Value::coerceValue('FOO', $this->testEnum); + $this->expectNoErrors($fooResult); + $this->assertEquals('InternalFoo', $fooResult['value']); + + $barResult = Value::coerceValue('BAR', $this->testEnum); + $this->expectNoErrors($barResult); + $this->assertEquals(123456789, $barResult['value']); + } + + /** + * @it results error for misspelled enum value + */ + public function testReturnsErrorForMisspelledEnumValue() + { + $result = Value::coerceValue('foo', $this->testEnum); + $this->expectError($result, 'Expected type TestEnum; did you mean FOO?'); + } + + /** + * @it results error for incorrect value type + */ + public function testReturnsErrorForIncorrectValueType() + { + $result1 = Value::coerceValue(123, $this->testEnum); + $this->expectError($result1, 'Expected type TestEnum.'); + + $result2 = Value::coerceValue(['field' => 'value'], $this->testEnum); + $this->expectError($result2, 'Expected type TestEnum.'); + } + + // DESCRIBE: for GraphQLInputObject + + /** + * @it returns no error for a valid input + */ + public function testReturnsNoErrorForValidInput() + { + $result = Value::coerceValue(['foo' => 123], $this->testInputObject); + $this->expectNoErrors($result); + $this->assertEquals(['foo' => 123], $result['value']); + } + + /** + * @it returns no error for a non-object type + */ + public function testReturnsErrorForNonObjectType() + { + $result = Value::coerceValue(123, $this->testInputObject); + $this->expectError($result, 'Expected type TestInputObject to be an object.'); + } + + /** + * @it returns no error for an invalid field + */ + public function testReturnErrorForAnInvalidField() + { + $result = Value::coerceValue(['foo' => 'abc'], $this->testInputObject); + $this->expectError($result, 'Expected type Int at value.foo; Int cannot represent non 32-bit signed integer value: abc'); + } + + /** + * @it returns multiple errors for multiple invalid fields + */ + public function testReturnsMultipleErrorsForMultipleInvalidFields() + { + $result = Value::coerceValue(['foo' => 'abc', 'bar' => 'def'], $this->testInputObject); + $this->assertEquals([ + 'Expected type Int at value.foo; Int cannot represent non 32-bit signed integer value: abc', + 'Expected type Int at value.bar; Int cannot represent non 32-bit signed integer value: def', + ], $result['errors']); + } + + /** + * @it returns error for a missing required field + */ + public function testReturnsErrorForAMissingRequiredField() + { + $result = Value::coerceValue(['bar' => 123], $this->testInputObject); + $this->expectError($result, 'Field value.foo of required type Int! was not provided.'); + } + + /** + * @it returns error for an unknown field + */ + public function testReturnsErrorForAnUnknownField() + { + $result = Value::coerceValue(['foo' => 123, 'unknownField' => 123], $this->testInputObject); + $this->expectError($result, 'Field "unknownField" is not defined by type TestInputObject.'); + } + + /** + * @it returns error for a misspelled field + */ + public function testReturnsErrorForAMisspelledField() + { + $result = Value::coerceValue(['foo' => 123, 'bart' => 123], $this->testInputObject); + $this->expectError($result, 'Field "bart" is not defined by type TestInputObject; did you mean bar?'); + } + private function expectNoErrors($result) { $this->assertInternalType('array', $result); $this->assertNull($result['errors']); + $this->assertNotEquals(Utils::undefined(), $result['value']); } + private function expectError($result, $expected) { $this->assertInternalType('array', $result); $this->assertInternalType('array', $result['errors']); $this->assertCount(1, $result['errors']); $this->assertEquals($expected, $result['errors'][0]->getMessage()); + $this->assertEquals(Utils::undefined(), $result['value']); } } diff --git a/tests/Validator/ValuesOfCorrectTypeTest.php b/tests/Validator/ValuesOfCorrectTypeTest.php index dd62ebd33..322d98484 100644 --- a/tests/Validator/ValuesOfCorrectTypeTest.php +++ b/tests/Validator/ValuesOfCorrectTypeTest.php @@ -30,11 +30,12 @@ private function requiredField($typeName, $fieldName, $fieldTypeName, $line, $co ); } - private function unknownField($typeName, $fieldName, $line, $column) { + private function unknownField($typeName, $fieldName, $line, $column, $message = null) { return FormattedError::create( ValuesOfCorrectType::unknownFieldMessage( $typeName, - $fieldName + $fieldName, + $message ), [new SourceLocation($line, $column)] ); @@ -581,7 +582,7 @@ public function testStringIntoEnum() '"SIT"', 4, 41, - 'Did you mean the enum value: SIT?' + 'Did you mean the enum value SIT?' ) ]); } @@ -630,7 +631,13 @@ public function testDifferentCaseEnumValueIntoEnum() } } ', [ - $this->badValue('DogCommand', 'sit', 4, 41) + $this->badValue( + 'DogCommand', + 'sit', + 4, + 41, + 'Did you mean the enum value SIT?' + ) ]); } @@ -1070,7 +1077,13 @@ public function testPartialObjectUnknownFieldArg() } } ', [ - $this->unknownField('ComplexInput', 'unknownField', 6, 15), + $this->unknownField( + 'ComplexInput', + 'unknownField', + 6, + 15, + 'Did you mean intField or booleanField?' + ), ]); } From dc6e814de339792e79a97dda7d074724d02445e5 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Fri, 16 Feb 2018 16:39:59 +0100 Subject: [PATCH 46/50] Fix orList to be the same as in JS and follow the chicago style for commas --- src/Utils/Utils.php | 2 +- tests/Utils/QuotedOrListTest.php | 4 ++-- tests/Validator/FieldsOnCorrectTypeTest.php | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Utils/Utils.php b/src/Utils/Utils.php index c9c3452aa..ee202ae4e 100644 --- a/src/Utils/Utils.php +++ b/src/Utils/Utils.php @@ -500,7 +500,7 @@ public static function orList(array $items) range(1, $selectedLength - 1), function ($list, $index) use ($selected, $selectedLength) { return $list. - ($selectedLength > 2 && $index !== $selectedLength - 1? ', ' : ' ') . + ($selectedLength > 2 ? ', ' : ' ') . ($index === $selectedLength - 1 ? 'or ' : '') . $selected[$index]; }, diff --git a/tests/Utils/QuotedOrListTest.php b/tests/Utils/QuotedOrListTest.php index 861388b81..d739c4cf1 100644 --- a/tests/Utils/QuotedOrListTest.php +++ b/tests/Utils/QuotedOrListTest.php @@ -47,7 +47,7 @@ public function testReturnsTwoItemList() public function testReturnsCommaSeparatedManyItemList() { $this->assertEquals( - '"A", "B" or "C"', + '"A", "B", or "C"', Utils::quotedOrList(['A', 'B', 'C']) ); } @@ -58,7 +58,7 @@ public function testReturnsCommaSeparatedManyItemList() public function testLimitsToFiveItems() { $this->assertEquals( - '"A", "B", "C", "D" or "E"', + '"A", "B", "C", "D", or "E"', Utils::quotedOrList(['A', 'B', 'C', 'D', 'E', 'F']) ); } diff --git a/tests/Validator/FieldsOnCorrectTypeTest.php b/tests/Validator/FieldsOnCorrectTypeTest.php index f59bf6c66..75a891e9d 100644 --- a/tests/Validator/FieldsOnCorrectTypeTest.php +++ b/tests/Validator/FieldsOnCorrectTypeTest.php @@ -322,7 +322,7 @@ public function testOnlyShowsOneSetOfSuggestionsAtATimePreferringTypes() public function testLimitsLotsOfTypeSuggestions() { $expected = 'Cannot query field "f" on type "T". ' . - 'Did you mean to use an inline fragment on "A", "B", "C", "D" or "E"?'; + 'Did you mean to use an inline fragment on "A", "B", "C", "D", or "E"?'; $this->assertEquals($expected, FieldsOnCorrectType::undefinedFieldMessage( 'f', @@ -338,7 +338,7 @@ public function testLimitsLotsOfTypeSuggestions() public function testLimitsLotsOfFieldSuggestions() { $expected = 'Cannot query field "f" on type "T". ' . - 'Did you mean "z", "y", "x", "w" or "v"?'; + 'Did you mean "z", "y", "x", "w", or "v"?'; $this->assertEquals($expected, FieldsOnCorrectType::undefinedFieldMessage( 'f', From 5e7cf2aacb35c5d0c60ebf3ae6bb3b902e9d9716 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Fri, 16 Feb 2018 16:47:11 +0100 Subject: [PATCH 47/50] Skip test on PHP < 7 --- tests/Validator/ValuesOfCorrectTypeTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Validator/ValuesOfCorrectTypeTest.php b/tests/Validator/ValuesOfCorrectTypeTest.php index 322d98484..7240f9912 100644 --- a/tests/Validator/ValuesOfCorrectTypeTest.php +++ b/tests/Validator/ValuesOfCorrectTypeTest.php @@ -1064,6 +1064,9 @@ public function testPartialObjectInvalidFieldType() /** * @it Partial object, unknown field arg + * + * The sorting of equal elements has changed so that the test fails on php < 7 + * @requires PHP 7.0 */ public function testPartialObjectUnknownFieldArg() { From 61fe317faf20d879717ccaba1d123181fc4d6437 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Fri, 16 Feb 2018 16:50:38 +0100 Subject: [PATCH 48/50] Update docs --- docs/reference.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/reference.md b/docs/reference.md index 76ac3ef32..e7bba2900 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -718,10 +718,25 @@ Parses string containing GraphQL query or [type definition](type-system/type-lan * in the source that they correspond to. This configuration flag * disables that behavior for performance or testing.) * + * experimentalFragmentVariables: boolean, + * (If enabled, the parser will understand and parse variable definitions + * contained in a fragment definition. They'll be represented in the + * `variableDefinitions` field of the FragmentDefinitionNode. + * + * The syntax is identical to normal, query-defined variables. For example: + * + * fragment A($var: Boolean = false) on T { + * ... + * } + * + * Note: this feature is experimental and may change or be removed in the + * future.) + * * @api * @param Source|string $source * @param array $options * @return DocumentNode + * @throws SyntaxError */ static function parse($source, array $options = []) ``` From f9a366e69a78774f38f8091c9dc34debe5b4aff0 Mon Sep 17 00:00:00 2001 From: Daniel Tschinder Date: Fri, 16 Feb 2018 16:54:06 +0100 Subject: [PATCH 49/50] Add Fallback for DirectiveLocations --- src/Type/Definition/DirectiveLocation.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/Type/Definition/DirectiveLocation.php diff --git a/src/Type/Definition/DirectiveLocation.php b/src/Type/Definition/DirectiveLocation.php new file mode 100644 index 000000000..a4bc719a3 --- /dev/null +++ b/src/Type/Definition/DirectiveLocation.php @@ -0,0 +1,17 @@ + Date: Tue, 6 Mar 2018 12:53:28 +0100 Subject: [PATCH 50/50] Readd type decorator and fix lazy type loading --- src/Type/Schema.php | 2 +- src/Utils/ASTDefinitionBuilder.php | 67 ++++++++++++++- src/Utils/BuildSchema.php | 25 +++--- tests/Utils/BuildSchemaTest.php | 130 ++++++++++++++++++++++++++++- 4 files changed, 208 insertions(+), 16 deletions(-) diff --git a/src/Type/Schema.php b/src/Type/Schema.php index 3e8b16ba5..5bda9720e 100644 --- a/src/Type/Schema.php +++ b/src/Type/Schema.php @@ -134,7 +134,7 @@ public function __construct($config) if ($config->subscription) { $this->resolvedTypes[$config->subscription->name] = $config->subscription; } - if ($this->config->types) { + if (is_array($this->config->types)) { foreach ($this->resolveAdditionalTypes() as $type) { if (isset($this->resolvedTypes[$type->name])) { Utils::invariant( diff --git a/src/Utils/ASTDefinitionBuilder.php b/src/Utils/ASTDefinitionBuilder.php index be90907c3..36c8ec828 100644 --- a/src/Utils/ASTDefinitionBuilder.php +++ b/src/Utils/ASTDefinitionBuilder.php @@ -38,6 +38,11 @@ class ASTDefinitionBuilder */ private $typeDefintionsMap; + /** + * @var callable + */ + private $typeConfigDecorator; + /** * @var array */ @@ -53,9 +58,10 @@ class ASTDefinitionBuilder */ private $cache; - public function __construct(array $typeDefintionsMap, $options, callable $resolveType) + public function __construct(array $typeDefintionsMap, $options, callable $resolveType, callable $typeConfigDecorator = null) { $this->typeDefintionsMap = $typeDefintionsMap; + $this->typeConfigDecorator = $typeConfigDecorator; $this->options = $options; $this->resolveType = $resolveType; @@ -101,7 +107,41 @@ private function getNamedTypeNode(TypeNode $typeNode) private function internalBuildType($typeName, $typeNode = null) { if (!isset($this->cache[$typeName])) { if (isset($this->typeDefintionsMap[$typeName])) { - $this->cache[$typeName] = $this->makeSchemaDef($this->typeDefintionsMap[$typeName]); + $type = $this->makeSchemaDef($this->typeDefintionsMap[$typeName]); + if ($this->typeConfigDecorator) { + $fn = $this->typeConfigDecorator; + try { + $config = $fn($type->config, $this->typeDefintionsMap[$typeName], $this->typeDefintionsMap); + } catch (\Exception $e) { + throw new Error( + "Type config decorator passed to " . (static::class) . " threw an error " . + "when building $typeName type: {$e->getMessage()}", + null, + null, + null, + null, + $e + ); + } catch (\Throwable $e) { + throw new Error( + "Type config decorator passed to " . (static::class) . " threw an error " . + "when building $typeName type: {$e->getMessage()}", + null, + null, + null, + null, + $e + ); + } + if (!is_array($config) || isset($config[0])) { + throw new Error( + "Type config decorator passed to " . (static::class) . " is expected to return an array, but got " . + Utils::getVariableType($config) + ); + } + $type = $this->makeSchemaDefFromConfig($this->typeDefintionsMap[$typeName], $config); + } + $this->cache[$typeName] = $type; } else { $fn = $this->resolveType; $this->cache[$typeName] = $fn($typeName, $typeNode); @@ -186,6 +226,29 @@ private function makeSchemaDef($def) } } + private function makeSchemaDefFromConfig($def, array $config) + { + if (!$def) { + throw new Error('def must be defined.'); + } + switch ($def->kind) { + case NodeKind::OBJECT_TYPE_DEFINITION: + return new ObjectType($config); + case NodeKind::INTERFACE_TYPE_DEFINITION: + return new InterfaceType($config); + case NodeKind::ENUM_TYPE_DEFINITION: + return new EnumType($config); + case NodeKind::UNION_TYPE_DEFINITION: + return new UnionType($config); + case NodeKind::SCALAR_TYPE_DEFINITION: + return new CustomScalarType($config); + case NodeKind::INPUT_OBJECT_TYPE_DEFINITION: + return new InputObjectType($config); + default: + throw new Error("Type kind of {$def->kind} not supported."); + } + } + private function makeTypeDef(ObjectTypeDefinitionNode $def) { $typeName = $def->name->value; diff --git a/src/Utils/BuildSchema.php b/src/Utils/BuildSchema.php index 0b8ae3141..18e169d40 100644 --- a/src/Utils/BuildSchema.php +++ b/src/Utils/BuildSchema.php @@ -26,7 +26,7 @@ class BuildSchema * Given that AST it constructs a GraphQL\Type\Schema. The resulting schema * has no resolve methods, so execution will use default resolvers. * - * Accepts options as a second argument: + * Accepts options as a third argument: * * - commentDescriptions: * Provide true to use preceding comments as the description. @@ -34,25 +34,26 @@ class BuildSchema * * @api * @param DocumentNode $ast + * @param callable $typeConfigDecorator * @param array $options * @return Schema * @throws Error */ - public static function buildAST(DocumentNode $ast, array $options = []) + public static function buildAST(DocumentNode $ast, callable $typeConfigDecorator = null, array $options = []) { - $builder = new self($ast, $options); + $builder = new self($ast, $typeConfigDecorator, $options); return $builder->buildSchema(); } private $ast; private $nodeMap; - private $loadedTypeDefs; + private $typeConfigDecorator; private $options; - public function __construct(DocumentNode $ast, array $options = []) + public function __construct(DocumentNode $ast, callable $typeConfigDecorator = null, array $options = []) { $this->ast = $ast; - $this->loadedTypeDefs = []; + $this->typeConfigDecorator = $typeConfigDecorator; $this->options = $options; } @@ -101,7 +102,8 @@ public function buildSchema() $defintionBuilder = new ASTDefinitionBuilder( $this->nodeMap, $this->options, - function($typeName) { throw new Error('Type "'. $typeName . '" not found in document.'); } + function($typeName) { throw new Error('Type "'. $typeName . '" not found in document.'); }, + $this->typeConfigDecorator ); $directives = array_map(function($def) use ($defintionBuilder) { @@ -152,9 +154,7 @@ function($typeName) { throw new Error('Type "'. $typeName . '" not found in docu 'types' => function () use ($defintionBuilder) { $types = []; foreach ($this->nodeMap as $name => $def) { - if (!isset($this->loadedTypeDefs[$name])) { - $types[] = $defintionBuilder->buildType($def->name->value); - } + $types[] = $defintionBuilder->buildType($def->name->value); } return $types; } @@ -196,12 +196,13 @@ private function getOperationTypes($schemaDef) * * @api * @param DocumentNode|Source|string $source + * @param callable $typeConfigDecorator * @param array $options * @return Schema */ - public static function build($source, array $options = []) + public static function build($source, callable $typeConfigDecorator = null, array $options = []) { $doc = $source instanceof DocumentNode ? $source : Parser::parse($source); - return self::buildAST($doc, $options); + return self::buildAST($doc, $typeConfigDecorator, $options); } } diff --git a/tests/Utils/BuildSchemaTest.php b/tests/Utils/BuildSchemaTest.php index cfd21f7ee..916f37732 100644 --- a/tests/Utils/BuildSchemaTest.php +++ b/tests/Utils/BuildSchemaTest.php @@ -20,7 +20,7 @@ class BuildSchemaTest extends \PHPUnit_Framework_TestCase private function cycleOutput($body, $options = []) { $ast = Parser::parse($body); - $schema = BuildSchema::buildAST($ast, $options); + $schema = BuildSchema::buildAST($ast, null, $options); return "\n" . SchemaPrinter::doPrint($schema, $options); } @@ -1140,4 +1140,132 @@ public function testForbidsDuplicateTypeDefinitions() $this->setExpectedException('GraphQL\Error\Error', 'Type "Repeated" was defined more than once.'); BuildSchema::buildAST($doc); } + + public function testSupportsTypeConfigDecorator() + { + $body = ' +schema { + query: Query +} + +type Query { + str: String + color: Color + hello: Hello +} + +enum Color { + RED + GREEN + BLUE +} + +interface Hello { + world: String +} +'; + $doc = Parser::parse($body); + + $decorated = []; + $calls = []; + + $typeConfigDecorator = function($defaultConfig, $node, $allNodesMap) use (&$decorated, &$calls) { + $decorated[] = $defaultConfig['name']; + $calls[] = [$defaultConfig, $node, $allNodesMap]; + return ['description' => 'My description of ' . $node->name->value] + $defaultConfig; + }; + + $schema = BuildSchema::buildAST($doc, $typeConfigDecorator); + $schema->getTypeMap(); + $this->assertEquals(['Query', 'Color', 'Hello'], $decorated); + + list($defaultConfig, $node, $allNodesMap) = $calls[0]; + $this->assertInstanceOf(ObjectTypeDefinitionNode::class, $node); + $this->assertEquals('Query', $defaultConfig['name']); + $this->assertInstanceOf(\Closure::class, $defaultConfig['fields']); + $this->assertInstanceOf(\Closure::class, $defaultConfig['interfaces']); + $this->assertArrayHasKey('description', $defaultConfig); + $this->assertCount(5, $defaultConfig); + $this->assertEquals(array_keys($allNodesMap), ['Query', 'Color', 'Hello']); + $this->assertEquals('My description of Query', $schema->getType('Query')->description); + + + list($defaultConfig, $node, $allNodesMap) = $calls[1]; + $this->assertInstanceOf(EnumTypeDefinitionNode::class, $node); + $this->assertEquals('Color', $defaultConfig['name']); + $enumValue = [ + 'description' => '', + 'deprecationReason' => '' + ]; + $this->assertArraySubset([ + 'RED' => $enumValue, + 'GREEN' => $enumValue, + 'BLUE' => $enumValue, + ], $defaultConfig['values']); + $this->assertCount(4, $defaultConfig); // 3 + astNode + $this->assertEquals(array_keys($allNodesMap), ['Query', 'Color', 'Hello']); + $this->assertEquals('My description of Color', $schema->getType('Color')->description); + + list($defaultConfig, $node, $allNodesMap) = $calls[2]; + $this->assertInstanceOf(InterfaceTypeDefinitionNode::class, $node); + $this->assertEquals('Hello', $defaultConfig['name']); + $this->assertInstanceOf(\Closure::class, $defaultConfig['fields']); + $this->assertArrayHasKey('description', $defaultConfig); + $this->assertCount(4, $defaultConfig); + $this->assertEquals(array_keys($allNodesMap), ['Query', 'Color', 'Hello']); + $this->assertEquals('My description of Hello', $schema->getType('Hello')->description); + } + + public function testCreatesTypesLazily() + { + $body = ' +schema { + query: Query +} + +type Query { + str: String + color: Color + hello: Hello +} + +enum Color { + RED + GREEN + BLUE +} + +interface Hello { + world: String +} + +type World implements Hello { + world: String +} +'; + $doc = Parser::parse($body); + $created = []; + + $typeConfigDecorator = function($config, $node) use (&$created) { + $created[] = $node->name->value; + return $config; + }; + + $schema = BuildSchema::buildAST($doc, $typeConfigDecorator); + $this->assertEquals(['Query'], $created); + + $schema->getType('Color'); + $this->assertEquals(['Query', 'Color'], $created); + + $schema->getType('Hello'); + $this->assertEquals(['Query', 'Color', 'Hello'], $created); + + $types = $schema->getTypeMap(); + $this->assertEquals(['Query', 'Color', 'Hello', 'World'], $created); + $this->assertArrayHasKey('Query', $types); + $this->assertArrayHasKey('Color', $types); + $this->assertArrayHasKey('Hello', $types); + $this->assertArrayHasKey('World', $types); + } + }