From 4f4776726d4daaaa1129d2e865c37b02a26a749d Mon Sep 17 00:00:00 2001 From: vladar Date: Sun, 24 Apr 2016 00:24:41 +0600 Subject: [PATCH] Updated parser to 2016 spec version; schema language parsing --- src/Language/Parser.php | 603 +++++++++++++++++++++++----- tests/Language/ParserTest.php | 144 ++++++- tests/Language/SchemaParserTest.php | 598 +++++++++++++++++++++++++++ tests/Language/kitchen-sink.graphql | 19 + 4 files changed, 1243 insertions(+), 121 deletions(-) create mode 100644 tests/Language/SchemaParserTest.php diff --git a/src/Language/Parser.php b/src/Language/Parser.php index 13bcf52..2f7ffdd 100644 --- a/src/Language/Parser.php +++ b/src/Language/Parser.php @@ -4,6 +4,13 @@ namespace GraphQL\Language; // language/parser.js use GraphQL\Language\AST\Argument; +use GraphQL\Language\AST\DirectiveDefinition; +use GraphQL\Language\AST\EnumTypeDefinition; +use GraphQL\Language\AST\EnumValueDefinition; +use GraphQL\Language\AST\FieldDefinition; +use GraphQL\Language\AST\InputObjectTypeDefinition; +use GraphQL\Language\AST\InputValueDefinition; +use GraphQL\Language\AST\InterfaceTypeDefinition; use GraphQL\Language\AST\ListValue; use GraphQL\Language\AST\BooleanValue; use GraphQL\Language\AST\Directive; @@ -21,10 +28,16 @@ use GraphQL\Language\AST\Name; use GraphQL\Language\AST\NamedType; use GraphQL\Language\AST\NonNullType; use GraphQL\Language\AST\ObjectField; +use GraphQL\Language\AST\ObjectTypeDefinition; use GraphQL\Language\AST\ObjectValue; use GraphQL\Language\AST\OperationDefinition; +use GraphQL\Language\AST\ScalarTypeDefinition; +use GraphQL\Language\AST\SchemaDefinition; use GraphQL\Language\AST\SelectionSet; use GraphQL\Language\AST\StringValue; +use GraphQL\Language\AST\TypeExtensionDefinition; +use GraphQL\Language\AST\TypeSystemDefinition; +use GraphQL\Language\AST\UnionTypeDefinition; use GraphQL\Language\AST\Variable; use GraphQL\Language\AST\VariableDefinition; use GraphQL\SyntaxError; @@ -48,7 +61,7 @@ class Parser * @param array $options * @return Document */ - public static function parse($source, array $options = array()) + public static function parse($source, array $options = []) { $sourceObj = $source instanceof Source ? $source : new Source($source); $parser = new self($sourceObj, $options); @@ -80,7 +93,7 @@ class Parser */ private $token; - function __construct(Source $source, array $options = array()) + function __construct(Source $source, array $options = []) { $this->lexer = new Lexer($source); $this->source = $source; @@ -212,13 +225,13 @@ class Parser * @param callable $parseFn * @param int $closeKind * @return array - * @throws Exception + * @throws SyntaxError */ function any($openKind, $parseFn, $closeKind) { $this->expect($openKind); - $nodes = array(); + $nodes = []; while (!$this->skip($closeKind)) { $nodes[] = $parseFn($this); } @@ -235,13 +248,13 @@ class Parser * @param $parseFn * @param $closeKind * @return array - * @throws Exception + * @throws SyntaxError */ function many($openKind, $parseFn, $closeKind) { $this->expect($openKind); - $nodes = array($parseFn($this)); + $nodes = [$parseFn($this)]; while (!$this->skip($closeKind)) { $nodes[] = $parseFn($this); } @@ -252,16 +265,16 @@ class Parser * Converts a name lex token into a name parse node. * * @return Name - * @throws Exception + * @throws SyntaxError */ function parseName() { $token = $this->expect(Token::NAME); - return new Name(array( + return new Name([ 'value' => $token->value, 'loc' => $this->loc($token->start) - )); + ]); } /** @@ -280,66 +293,113 @@ class Parser * Implements the parsing rules in the Document section. * * @return Document - * @throws Exception + * @throws SyntaxError */ function parseDocument() { $start = $this->token->start; - $definitions = array(); + $definitions = []; do { - if ($this->peek(Token::BRACE_L)) { - $definitions[] = $this->parseOperationDefinition(); - } else if ($this->peek(Token::NAME)) { - if ($this->token->value === 'query' || $this->token->value === 'mutation') { - $definitions[] = $this->parseOperationDefinition(); - } else if ($this->token->value === 'fragment') { - $definitions[] = $this->parseFragmentDefinition(); - } else { - throw $this->unexpected(); - } - } else { - throw $this->unexpected(); - } + $definitions[] = $this->parseDefinition(); } while (!$this->skip(Token::EOF)); - return new Document(array( + return new Document([ 'definitions' => $definitions, 'loc' => $this->loc($start) - )); + ]); + } + + /** + * @return OperationDefinition|FragmentDefinition|TypeSystemDefinition + * @throws SyntaxError + */ + function parseDefinition() + { + if ($this->peek(Token::BRACE_L)) { + return $this->parseOperationDefinition(); + } + + if ($this->peek(Token::NAME)) { + switch ($this->token->value) { + case 'query': + case 'mutation': + // Note: subscription is an experimental non-spec addition. + case 'subscription': + return $this->parseOperationDefinition(); + + case 'fragment': + return $this->parseFragmentDefinition(); + + // Note: the Type System IDL is an experimental non-spec addition. + case 'schema': + case 'scalar': + case 'type': + case 'interface': + case 'union': + case 'enum': + case 'input': + case 'extend': + case 'directive': + return $this->parseTypeSystemDefinition(); + } + } + + throw $this->unexpected(); } // Implements the parsing rules in the Operations section. /** * @return OperationDefinition - * @throws Exception + * @throws SyntaxError */ function parseOperationDefinition() { $start = $this->token->start; if ($this->peek(Token::BRACE_L)) { - return new OperationDefinition(array( + return new OperationDefinition([ 'operation' => 'query', 'name' => null, 'variableDefinitions' => null, - 'directives' => array(), + 'directives' => [], 'selectionSet' => $this->parseSelectionSet(), 'loc' => $this->loc($start) - )); + ]); } - $operationToken = $this->expect(Token::NAME); - $operation = $operationToken->value; + $operation = $this->parseOperationType(); - return new OperationDefinition(array( + $name = null; + if ($this->peek(Token::NAME)) { + $name = $this->parseName(); + } + + return new OperationDefinition([ 'operation' => $operation, - 'name' => $this->parseName(), + 'name' => $name, 'variableDefinitions' => $this->parseVariableDefinitions(), 'directives' => $this->parseDirectives(), 'selectionSet' => $this->parseSelectionSet(), 'loc' => $this->loc($start) - )); + ]); + } + + /** + * @return string + * @throws SyntaxError + */ + function parseOperationType() + { + $operationToken = $this->expect(Token::NAME); + switch ($operationToken->value) { + case 'query': return 'query'; + case 'mutation': return 'mutation'; + // Note: subscription is an experimental non-spec addition. + case 'subscription': return 'subscription'; + } + + throw $this->unexpected($operationToken); } /** @@ -350,15 +410,15 @@ class Parser return $this->peek(Token::PAREN_L) ? $this->many( Token::PAREN_L, - array($this, 'parseVariableDefinition'), + [$this, 'parseVariableDefinition'], Token::PAREN_R ) : - array(); + []; } /** * @return VariableDefinition - * @throws Exception + * @throws SyntaxError */ function parseVariableDefinition() { @@ -368,44 +428,47 @@ class Parser $this->expect(Token::COLON); $type = $this->parseType(); - return new VariableDefinition(array( + return new VariableDefinition([ 'variable' => $var, 'type' => $type, 'defaultValue' => ($this->skip(Token::EQUALS) ? $this->parseValueLiteral(true) : null), 'loc' => $this->loc($start) - )); + ]); } /** * @return Variable - * @throws Exception + * @throws SyntaxError */ - function parseVariable() { + function parseVariable() + { $start = $this->token->start; $this->expect(Token::DOLLAR); - return new Variable(array( + return new Variable([ 'name' => $this->parseName(), 'loc' => $this->loc($start) - )); + ]); } /** * @return SelectionSet */ - function parseSelectionSet() { + function parseSelectionSet() + { $start = $this->token->start; - return new SelectionSet(array( - 'selections' => $this->many(Token::BRACE_L, array($this, 'parseSelection'), Token::BRACE_R), + return new SelectionSet([ + 'selections' => $this->many(Token::BRACE_L, [$this, 'parseSelection'], Token::BRACE_R), 'loc' => $this->loc($start) - )); + ]); } /** * @return mixed */ - function parseSelection() { + function parseSelection() + { return $this->peek(Token::SPREAD) ? $this->parseFragment() : $this->parseField(); @@ -414,7 +477,8 @@ class Parser /** * @return Field */ - function parseField() { + function parseField() + { $start = $this->token->start; $nameOrAlias = $this->parseName(); @@ -426,28 +490,29 @@ class Parser $name = $nameOrAlias; } - return new Field(array( + return new Field([ 'alias' => $alias, 'name' => $name, 'arguments' => $this->parseArguments(), 'directives' => $this->parseDirectives(), 'selectionSet' => $this->peek(Token::BRACE_L) ? $this->parseSelectionSet() : null, 'loc' => $this->loc($start) - )); + ]); } /** * @return array */ - function parseArguments() { + function parseArguments() + { return $this->peek(Token::PAREN_L) ? - $this->many(Token::PAREN_L, array($this, 'parseArgument'), Token::PAREN_R) : - array(); + $this->many(Token::PAREN_L, [$this, 'parseArgument'], Token::PAREN_R) : + []; } /** * @return Argument - * @throws Exception + * @throws SyntaxError */ function parseArgument() { @@ -457,44 +522,52 @@ class Parser $this->expect(Token::COLON); $value = $this->parseValueLiteral(false); - return new Argument(array( + return new Argument([ 'name' => $name, 'value' => $value, 'loc' => $this->loc($start) - )); + ]); } // Implements the parsing rules in the Fragments section. /** * @return FragmentSpread|InlineFragment - * @throws Exception + * @throws SyntaxError */ - function parseFragment() { + function parseFragment() + { $start = $this->token->start; $this->expect(Token::SPREAD); + if ($this->peek(Token::NAME) && $this->token->value !== 'on') { + return new FragmentSpread([ + 'name' => $this->parseFragmentName(), + 'directives' => $this->parseDirectives(), + 'loc' => $this->loc($start) + ]); + } + + $typeCondition = null; if ($this->token->value === 'on') { $this->advance(); - return new InlineFragment(array( - 'typeCondition' => $this->parseNamedType(), - 'directives' => $this->parseDirectives(), - 'selectionSet' => $this->parseSelectionSet(), - 'loc' => $this->loc($start) - )); + $typeCondition = $this->parseNamedType(); } - return new FragmentSpread(array( - 'name' => $this->parseFragmentName(), + + return new InlineFragment([ + 'typeCondition' => $typeCondition, 'directives' => $this->parseDirectives(), + 'selectionSet' => $this->parseSelectionSet(), 'loc' => $this->loc($start) - )); + ]); } /** * @return FragmentDefinition * @throws SyntaxError */ - function parseFragmentDefinition() { + function parseFragmentDefinition() + { $start = $this->token->start; $this->expectKeyword('fragment'); @@ -502,13 +575,13 @@ class Parser $this->expectKeyword('on'); $typeCondition = $this->parseNamedType(); - return new FragmentDefinition(array( + return new FragmentDefinition([ 'name' => $name, 'typeCondition' => $typeCondition, 'directives' => $this->parseDirectives(), 'selectionSet' => $this->parseSelectionSet(), 'loc' => $this->loc($start) - )); + ]); } // Implements the parsing rules in the Values section. @@ -519,7 +592,7 @@ class Parser /** * @return BooleanValue|EnumValue|FloatValue|IntValue|StringValue|Variable - * @throws Exception + * @throws SyntaxError */ function parseConstValue() { @@ -531,7 +604,8 @@ class Parser * @return BooleanValue|EnumValue|FloatValue|IntValue|StringValue|Variable * @throws SyntaxError */ - function parseValueLiteral($isConst) { + function parseValueLiteral($isConst) + { $token = $this->token; switch ($token->kind) { case Token::BRACKET_L: @@ -540,35 +614,35 @@ class Parser return $this->parseObject($isConst); case Token::INT: $this->advance(); - return new IntValue(array( + return new IntValue([ 'value' => $token->value, 'loc' => $this->loc($token->start) - )); + ]); case Token::FLOAT: $this->advance(); - return new FloatValue(array( + return new FloatValue([ 'value' => $token->value, 'loc' => $this->loc($token->start) - )); + ]); case Token::STRING: $this->advance(); - return new StringValue(array( + return new StringValue([ 'value' => $token->value, 'loc' => $this->loc($token->start) - )); + ]); case Token::NAME: if ($token->value === 'true' || $token->value === 'false') { $this->advance(); - return new BooleanValue(array( + return new BooleanValue([ 'value' => $token->value === 'true', 'loc' => $this->loc($token->start) - )); + ]); } else if ($token->value !== 'null') { $this->advance(); - return new EnumValue(array( + return new EnumValue([ 'value' => $token->value, 'loc' => $this->loc($token->start) - )); + ]); } break; @@ -589,25 +663,25 @@ class Parser { $start = $this->token->start; $item = $isConst ? 'parseConstValue' : 'parseVariableValue'; - return new ListValue(array( - 'values' => $this->any(Token::BRACKET_L, array($this, $item), Token::BRACKET_R), + return new ListValue([ + 'values' => $this->any(Token::BRACKET_L, [$this, $item], Token::BRACKET_R), 'loc' => $this->loc($start) - )); + ]); } function parseObject($isConst) { $start = $this->token->start; $this->expect(Token::BRACE_L); - $fieldNames = array(); - $fields = array(); + $fieldNames = []; + $fields = []; while (!$this->skip(Token::BRACE_R)) { $fields[] = $this->parseObjectField($isConst, $fieldNames); } - return new ObjectValue(array( + return new ObjectValue([ 'fields' => $fields, 'loc' => $this->loc($start) - )); + ]); } function parseObjectField($isConst, &$fieldNames) @@ -621,11 +695,11 @@ class Parser $fieldNames[$name->value] = true; $this->expect(Token::COLON); - return new ObjectField(array( + return new ObjectField([ 'name' => $name, 'value' => $this->parseValueLiteral($isConst), 'loc' => $this->loc($start) - )); + ]); } // Implements the parsing rules in the Directives section. @@ -635,7 +709,7 @@ class Parser */ function parseDirectives() { - $directives = array(); + $directives = []; while ($this->peek(Token::AT)) { $directives[] = $this->parseDirective(); } @@ -644,17 +718,17 @@ class Parser /** * @return Directive - * @throws Exception + * @throws SyntaxError */ function parseDirective() { $start = $this->token->start; $this->expect(Token::AT); - return new Directive(array( + return new Directive([ 'name' => $this->parseName(), 'arguments' => $this->parseArguments(), 'loc' => $this->loc($start) - )); + ]); } // Implements the parsing rules in the Types section. @@ -672,18 +746,18 @@ class Parser if ($this->skip(Token::BRACKET_L)) { $type = $this->parseType(); $this->expect(Token::BRACKET_R); - $type = new ListType(array( + $type = new ListType([ 'type' => $type, 'loc' => $this->loc($start) - )); + ]); } else { $type = $this->parseNamedType(); } if ($this->skip(Token::BANG)) { - return new NonNullType(array( + return new NonNullType([ 'type' => $type, 'loc' => $this->loc($start) - )); + ]); } return $type; @@ -697,6 +771,333 @@ class Parser 'name' => $this->parseName(), 'loc' => $this->loc($start) ]); + } + // Implements the parsing rules in the Type Definition section. + + /** + * TypeSystemDefinition : + * - TypeDefinition + * - TypeExtensionDefinition + * - DirectiveDefinition + * + * TypeDefinition : + * - ScalarTypeDefinition + * - ObjectTypeDefinition + * - InterfaceTypeDefinition + * - UnionTypeDefinition + * - EnumTypeDefinition + * - InputObjectTypeDefinition + * + * @return TypeSystemDefinition + * @throws SyntaxError + */ + function parseTypeSystemDefinition() + { + if ($this->peek(Token::NAME)) { + switch ($this->token->value) { + case 'schema': return $this->parseSchemaDefinition(); + case 'scalar': return $this->parseScalarTypeDefinition(); + case 'type': return $this->parseObjectTypeDefinition(); + case 'interface': return $this->parseInterfaceTypeDefinition(); + case 'union': return $this->parseUnionTypeDefinition(); + case 'enum': return $this->parseEnumTypeDefinition(); + case 'input': return $this->parseInputObjectTypeDefinition(); + case 'extend': return $this->parseTypeExtensionDefinition(); + case 'directive': return $this->parseDirectiveDefinition(); + } + } + + throw $this->unexpected(); + } + + /** + * @return SchemaDefinition + * @throws SyntaxError + */ + function parseSchemaDefinition() + { + $start = $this->token->start; + $this->expectKeyword('schema'); + + $operationTypes = $this->many( + Token::BRACE_L, + [$this, 'parseOperationTypeDefinition'], + Token::BRACE_R + ); + + return new SchemaDefinition([ + 'operationTypes' => $operationTypes, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return ScalarTypeDefinition + * @throws SyntaxError + */ + function parseScalarTypeDefinition() + { + $start = $this->token->start; + $this->expectKeyword('scalar'); + $name = $this->parseName(); + + return new ScalarTypeDefinition([ + 'name' => $name, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return ObjectTypeDefinition + * @throws SyntaxError + */ + function parseObjectTypeDefinition() + { + $start = $this->token->start; + $this->expectKeyword('type'); + $name = $this->parseName(); + $interfaces = $this->parseImplementsInterfaces(); + $fields = $this->any( + Token::BRACE_L, + [$this, 'parseFieldDefinition'], + Token::BRACE_R + ); + + return new ObjectTypeDefinition([ + 'name' => $name, + 'interfaces' => $interfaces, + 'fields' => $fields, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return NamedType[] + */ + function parseImplementsInterfaces() + { + $types = []; + if ($this->token->value === 'implements') { + $this->advance(); + do { + $types[] = $this->parseNamedType(); + } while (!$this->peek(Token::BRACE_L)); + } + return $types; + } + + /** + * @return FieldDefinition + * @throws SyntaxError + */ + function parseFieldDefinition() + { + $start = $this->token->start; + $name = $this->parseName(); + $args = $this->parseArgumentDefs(); + $this->expect(Token::COLON); + $type = $this->parseType(); + + return new FieldDefinition([ + 'name' => $name, + 'arguments' => $args, + 'type' => $type, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return InputValueDefinition[] + */ + function parseArgumentDefs() + { + if (!$this->peek(Token::PAREN_L)) { + return []; + } + return $this->many(Token::PAREN_L, [$this, 'parseInputValueDef'], Token::PAREN_R); + } + + /** + * @return InputValueDefinition + * @throws SyntaxError + */ + function parseInputValueDef() + { + $start = $this->token->start; + $name = $this->parseName(); + $this->expect(Token::COLON); + $type = $this->parseType(); + $defaultValue = null; + if ($this->skip(Token::EQUALS)) { + $defaultValue = $this->parseConstValue(); + } + return new InputValueDefinition([ + 'name' => $name, + 'type' => $type, + 'defaultValue' => $defaultValue, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return InterfaceTypeDefinition + * @throws SyntaxError + */ + function parseInterfaceTypeDefinition() + { + $start = $this->token->start; + $this->expectKeyword('interface'); + $name = $this->parseName(); + $fields = $this->any( + Token::BRACE_L, + [$this, 'parseFieldDefinition'], + Token::BRACE_R + ); + + return new InterfaceTypeDefinition([ + 'name' => $name, + 'fields' => $fields, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return UnionTypeDefinition + * @throws SyntaxError + */ + function parseUnionTypeDefinition() + { + $start = $this->token->start; + $this->expectKeyword('union'); + $name = $this->parseName(); + $this->expect(Token::EQUALS); + $types = $this->parseUnionMembers(); + + return new UnionTypeDefinition([ + 'name' => $name, + 'types' => $types, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return NamedType[] + */ + function parseUnionMembers() + { + $members = []; + do { + $members[] = $this->parseNamedType(); + } while ($this->skip(Token::PIPE)); + return $members; + } + + /** + * @return EnumTypeDefinition + * @throws SyntaxError + */ + function parseEnumTypeDefinition() + { + $start = $this->token->start; + $this->expectKeyword('enum'); + $name = $this->parseName(); + $values = $this->many( + Token::BRACE_L, + [$this, 'parseEnumValueDefinition'], + Token::BRACE_R + ); + + return new EnumTypeDefinition([ + 'name' => $name, + 'values' => $values, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return EnumValueDefinition + */ + function parseEnumValueDefinition() + { + $start = $this->token->start; + $name = $this->parseName(); + + return new EnumValueDefinition([ + 'name' => $name, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return InputObjectTypeDefinition + * @throws SyntaxError + */ + function parseInputObjectTypeDefinition() + { + $start = $this->token->start; + $this->expectKeyword('input'); + $name = $this->parseName(); + $fields = $this->any( + Token::BRACE_L, + [$this, 'parseInputValueDef'], + Token::BRACE_R + ); + + return new InputObjectTypeDefinition([ + 'name' => $name, + 'fields' => $fields, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return TypeExtensionDefinition + * @throws SyntaxError + */ + function parseTypeExtensionDefinition() + { + $start = $this->token->start; + $this->expectKeyword('extend'); + $definition = $this->parseObjectTypeDefinition(); + + return new TypeExtensionDefinition([ + 'definition' => $definition, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return DirectiveDefinition + * @throws SyntaxError + */ + function parseDirectiveDefinition() + { + $start = $this->token->start; + $this->expectKeyword('directive'); + $this->expect(Token::AT); + $name = $this->parseName(); + $args = $this->parseArgumentDefs(); + $this->expectKeyword('on'); + $locations = $this->parseDirectiveLocations(); + + return new DirectiveDefinition([ + 'name' => $name, + 'arguments' => $args, + 'locations' => $locations, + 'loc' => $this->loc($start) + ]); + } + + /** + * @return Name[] + */ + function parseDirectiveLocations() + { + $locations = []; + do { + $locations[] = $this->parseName(); + } while ($this->skip(Token::PIPE)); + return $locations; } } diff --git a/tests/Language/ParserTest.php b/tests/Language/ParserTest.php index d1f9802..35a8a4e 100644 --- a/tests/Language/ParserTest.php +++ b/tests/Language/ParserTest.php @@ -1,7 +1,6 @@ true]); $expected = new Document([ @@ -54,6 +57,9 @@ class ParserTest extends \PHPUnit_Framework_TestCase $this->assertEquals($expected, $actual); } + /** + * @it parse provides useful errors + */ public function testParseProvidesUsefulErrors() { $run = function($num, $str, $expectedMessage, $expectedPositions = null, $expectedLocations = null) { @@ -72,6 +78,7 @@ class ParserTest extends \PHPUnit_Framework_TestCase } }; + $run(0, '{', "Syntax Error GraphQL (1:2) Expected Name, found EOF\n\n1: {\n ^\n", [1], [new SourceLocation(1,2)]); $run(1, '{ ...MissingOn } fragment MissingOn Type @@ -82,25 +89,33 @@ fragment MissingOn Type $run(2, '{ field: {} }', "Syntax Error GraphQL (1:10) Expected Name, found {\n\n1: { field: {} }\n ^\n"); $run(3, 'notanoperation Foo { field }', "Syntax Error GraphQL (1:1) Unexpected Name \"notanoperation\"\n\n1: notanoperation Foo { field }\n ^\n"); $run(4, '...', "Syntax Error GraphQL (1:1) Unexpected ...\n\n1: ...\n ^\n"); - $run(5, '{', "Syntax Error GraphQL (1:2) Expected Name, found EOF\n\n1: {\n ^\n", [1], [new SourceLocation(1,2)]); } + /** + * @it parse provides useful error when using source + */ public function testParseProvidesUsefulErrorWhenUsingSource() { try { Parser::parse(new Source('query', 'MyQuery.graphql')); $this->fail('Expected exception not thrown'); } catch (SyntaxError $e) { - $this->assertEquals("Syntax Error MyQuery.graphql (1:6) Expected Name, found EOF\n\n1: query\n ^\n", $e->getMessage()); + $this->assertEquals("Syntax Error MyQuery.graphql (1:6) Expected {, found EOF\n\n1: query\n ^\n", $e->getMessage()); } } + /** + * @it parses variable inline values + */ public function testParsesVariableInlineValues() { // Following line should not throw: Parser::parse('{ field(complex: { a: { b: [ $var ] } }) }'); } + /** + * @it parses constant default values + */ public function testParsesConstantDefaultValues() { try { @@ -114,39 +129,71 @@ fragment MissingOn Type } } - public function testDuplicateKeysInInputObjectIsSyntaxError() - { - try { - Parser::parse('{ field(arg: { a: 1, a: 2 }) }'); - $this->fail('Expected exception not thrown'); - } catch (SyntaxError $e) { - $this->assertEquals( - "Syntax Error GraphQL (1:22) Duplicate input object field a.\n\n1: { field(arg: { a: 1, a: 2 }) }\n ^\n", - $e->getMessage() - ); - } - } - + /** + * @it does not accept fragments spread of "on" + */ public function testDoesNotAcceptFragmentsNamedOn() { - // does not accept fragments named "on" $this->setExpectedException('GraphQL\SyntaxError', 'Syntax Error GraphQL (1:10) Unexpected Name "on"'); Parser::parse('fragment on on on { on }'); } + /** + * @it does not accept fragments spread of "on" + */ public function testDoesNotAcceptFragmentSpreadOfOn() { - // does not accept fragments spread of "on" $this->setExpectedException('GraphQL\SyntaxError', 'Syntax Error GraphQL (1:9) Expected Name, found }'); Parser::parse('{ ...on }'); } + /** + * @it does not allow null as value + */ public function testDoesNotAllowNullAsValue() { $this->setExpectedException('GraphQL\SyntaxError', 'Syntax Error GraphQL (1:39) Unexpected Name "null"'); Parser::parse('{ fieldWithNullableStringInput(input: null) }'); } + /** + * @it parses multi-byte characters + */ + public function testParsesMultiByteCharacters() + { + // Note: \u0A0A could be naively interpretted as two line-feed chars. + + $char = Utils::chr(0x0A0A); + $query = << true]); + + $expected = new SelectionSet([ + 'selections' => [ + new Field([ + 'name' => new Name(['value' => 'field']), + 'arguments' => [ + new Argument([ + 'name' => new Name(['value' => 'arg']), + 'value' => new StringValue([ + 'value' => "Has a $char multi-byte character." + ]) + ]) + ], + 'directives' => [] + ]) + ] + ]); + + $this->assertEquals($expected, $result->definitions[0]->selectionSet); + } + + /** + * @it parses kitchen sink + */ public function testParsesKitchenSink() { // Following should not throw: @@ -155,14 +202,17 @@ fragment MissingOn Type $this->assertNotEmpty($result); } + /** + * allows non-keywords anywhere a Name is allowed + */ public function testAllowsNonKeywordsAnywhereANameIsAllowed() { - // allows non-keywords anywhere a Name is allowed $nonKeywords = [ 'on', 'fragment', 'query', 'mutation', + 'subscription', 'true', 'false' ]; @@ -185,6 +235,60 @@ fragment $fragmentName on Type { } } + /** + * @it parses anonymous mutation operations + */ + public function testParsessAnonymousMutationOperations() + { + // Should not throw: + Parser::parse(' + mutation { + mutationField + } + '); + } + + /** + * @it parses anonymous subscription operations + */ + public function testParsesAnonymousSubscriptionOperations() + { + // Should not throw: + Parser::parse(' + subscription { + subscriptionField + } + '); + } + + /** + * @it parses named mutation operations + */ + public function testParsesNamedMutationOperations() + { + // Should not throw: + Parser::parse(' + mutation Foo { + mutationField + } + '); + } + + /** + * @it parses named subscription operations + */ + public function testParsesNamedSubscriptionOperations() + { + Parser::parse(' + subscription Foo { + subscriptionField + } + '); + } + + /** + * @it parse creates ast + */ public function testParseCreatesAst() { $source = new Source('{ diff --git a/tests/Language/SchemaParserTest.php b/tests/Language/SchemaParserTest.php new file mode 100644 index 0000000..6489fda --- /dev/null +++ b/tests/Language/SchemaParserTest.php @@ -0,0 +1,598 @@ +createLocFn($body); + + $expected = new Document([ + 'definitions' => [ + new ObjectTypeDefinition([ + 'name' => $this->nameNode('Hello', $loc(6, 11)), + 'interfaces' => [], + 'fields' => [ + $this->fieldNode( + $this->nameNode('world', $loc(16, 21)), + $this->typeNode('String', $loc(23, 29)), + $loc(16, 29) + ) + ], + 'loc' => $loc(1, 31) + ]) + ], + 'loc' => $loc(1, 31) + ]); + $this->assertEquals($doc, $expected); + } + + /** + * @it Simple extension + */ + public function testSimpleExtension() + { + $body = ' +extend type Hello { + world: String +}'; + $doc = Parser::parse($body); + $loc = $this->createLocFn($body); + $expected = new Document([ + 'definitions' => [ + new TypeExtensionDefinition([ + 'definition' => new ObjectTypeDefinition([ + 'name' => $this->nameNode('Hello', $loc(13, 18)), + 'interfaces' => [], + 'fields' => [ + $this->fieldNode( + $this->nameNode('world', $loc(23, 28)), + $this->typeNode('String', $loc(30, 36)), + $loc(23, 36) + ) + ], + 'loc' => $loc(8, 38) + ]), + 'loc' => $loc(1, 38) + ]) + ], + 'loc' => $loc(1, 38) + ]); + $this->assertEquals($expected, $doc); + } + + /** + * @it Simple non-null type + */ + public function testSimpleNonNullType() + { + $body = ' +type Hello { + world: String! +}'; + $loc = $this->createLocFn($body); + $doc = Parser::parse($body); + + $expected = new Document([ + 'definitions' => [ + new ObjectTypeDefinition([ + 'name' => $this->nameNode('Hello', $loc(6,11)), + 'interfaces' => [], + 'fields' => [ + $this->fieldNode( + $this->nameNode('world', $loc(16, 21)), + new NonNullType([ + 'type' => $this->typeNode('String', $loc(23, 29)), + 'loc' => $loc(23, 30) + ]), + $loc(16,30) + ) + ], + 'loc' => $loc(1,32) + ]) + ], + 'loc' => $loc(1,32) + ]); + + $this->assertEquals($expected, $doc); + } + + /** + * @it Simple type inheriting interface + */ + public function testSimpleTypeInheritingInterface() + { + $body = 'type Hello implements World { }'; + $loc = $this->createLocFn($body); + $doc = Parser::parse($body); + + $expected = new Document([ + 'definitions' => [ + new ObjectTypeDefinition([ + 'name' => $this->nameNode('Hello', $loc(5, 10)), + 'interfaces' => [ + $this->typeNode('World', $loc(22, 27)) + ], + 'fields' => [], + 'loc' => $loc(0,31) + ]) + ], + 'loc' => $loc(0,31) + ]); + + $this->assertEquals($expected, $doc); + } + + /** + * @it Simple type inheriting multiple interfaces + */ + public function testSimpleTypeInheritingMultipleInterfaces() + { + $body = 'type Hello implements Wo, rld { }'; + $loc = $this->createLocFn($body); + $doc = Parser::parse($body); + + $expected = new Document([ + 'definitions' => [ + new ObjectTypeDefinition([ + 'name' => $this->nameNode('Hello', $loc(5, 10)), + 'interfaces' => [ + $this->typeNode('Wo', $loc(22,24)), + $this->typeNode('rld', $loc(26,29)) + ], + 'fields' => [], + 'loc' => $loc(0, 33) + ]) + ], + 'loc' => $loc(0, 33) + ]); + + $this->assertEquals($expected, $doc); + } + + /** + * @it Single value enum + */ + public function testSingleValueEnum() + { + $body = 'enum Hello { WORLD }'; + $loc = $this->createLocFn($body); + $doc = Parser::parse($body); + + $expected = new Document([ + 'definitions' => [ + new EnumTypeDefinition([ + 'name' => $this->nameNode('Hello', $loc(5, 10)), + 'values' => [$this->enumValueNode('WORLD', $loc(13, 18))], + 'loc' => $loc(0, 20) + ]) + ], + 'loc' => $loc(0, 20) + ]); + + $this->assertEquals($expected, $doc); + } + + /** + * @it Double value enum + */ + public function testDoubleValueEnum() + { + $body = 'enum Hello { WO, RLD }'; + $loc = $this->createLocFn($body); + $doc = Parser::parse($body); + + $expected = new Document([ + 'definitions' => [ + new EnumTypeDefinition([ + 'name' => $this->nameNode('Hello', $loc(5, 10)), + 'values' => [ + $this->enumValueNode('WO', $loc(13, 15)), + $this->enumValueNode('RLD', $loc(17, 20)) + ], + 'loc' => $loc(0, 22) + ]) + ], + 'loc' => $loc(0, 22) + ]); + + $this->assertEquals($expected, $doc); + } + + /** + * @it Simple interface + */ + public function testSimpleInterface() + { + $body = ' +interface Hello { + world: String +}'; + $doc = Parser::parse($body); + $loc = $this->createLocFn($body); + + $expected = new Document([ + 'definitions' => [ + new InterfaceTypeDefinition([ + 'name' => $this->nameNode('Hello', $loc(11, 16)), + 'fields' => [ + $this->fieldNode( + $this->nameNode('world', $loc(21, 26)), + $this->typeNode('String', $loc(28, 34)), + $loc(21, 34) + ) + ], + 'loc' => $loc(1, 36) + ]) + ], + 'loc' => $loc(1,36) + ]); + $this->assertEquals($expected, $doc); + } + + /** + * @it Simple field with arg + */ + public function testSimpleFieldWithArg() + { + $body = ' +type Hello { + world(flag: Boolean): String +}'; + $doc = Parser::parse($body); + $loc = $this->createLocFn($body); + + $expected = new Document([ + 'definitions' => [ + new ObjectTypeDefinition([ + 'name' => $this->nameNode('Hello', $loc(6, 11)), + 'interfaces' => [], + 'fields' => [ + $this->fieldNodeWithArgs( + $this->nameNode('world', $loc(16, 21)), + $this->typeNode('String', $loc(38, 44)), + [ + $this->inputValueNode( + $this->nameNode('flag', $loc(22, 26)), + $this->typeNode('Boolean', $loc(28, 35)), + null, + $loc(22, 35) + ) + ], + $loc(16, 44) + ) + ], + 'loc' => $loc(1, 46) + ]) + ], + 'loc' => $loc(1, 46) + ]); + + $this->assertEquals($expected, $doc); + } + + /** + * @it Simple field with arg with default value + */ + public function testSimpleFieldWithArgWithDefaultValue() + { + $body = ' +type Hello { + world(flag: Boolean = true): String +}'; + $doc = Parser::parse($body); + $loc = $this->createLocFn($body); + + $expected = new Document([ + 'definitions' => [ + new ObjectTypeDefinition([ + 'name' => $this->nameNode('Hello', $loc(6, 11)), + 'interfaces' => [], + 'fields' => [ + $this->fieldNodeWithArgs( + $this->nameNode('world', $loc(16, 21)), + $this->typeNode('String', $loc(45, 51)), + [ + $this->inputValueNode( + $this->nameNode('flag', $loc(22, 26)), + $this->typeNode('Boolean', $loc(28, 35)), + new BooleanValue(['value' => true, 'loc' => $loc(38, 42)]), + $loc(22, 42) + ) + ], + $loc(16, 51) + ) + ], + 'loc' => $loc(1, 53) + ]) + ], + 'loc' => $loc(1, 53) + ]); + $this->assertEquals($expected, $doc); + } + + /** + * @it Simple field with list arg + */ + public function testSimpleFieldWithListArg() + { + $body = ' +type Hello { + world(things: [String]): String +}'; + $doc = Parser::parse($body); + $loc = $this->createLocFn($body); + + $expected = new Document([ + 'definitions' => [ + new ObjectTypeDefinition([ + 'name' => $this->nameNode('Hello', $loc(6, 11)), + 'interfaces' => [], + 'fields' => [ + $this->fieldNodeWithArgs( + $this->nameNode('world', $loc(16, 21)), + $this->typeNode('String', $loc(41, 47)), + [ + $this->inputValueNode( + $this->nameNode('things', $loc(22,28)), + new ListType(['type' => $this->typeNode('String', $loc(31, 37)), 'loc' => $loc(30, 38)]), + null, + $loc(22, 38) + ) + ], + $loc(16, 47) + ) + ], + 'loc' => $loc(1, 49) + ]) + ], + 'loc' => $loc(1, 49) + ]); + + $this->assertEquals($expected, $doc); + } + + /** + * @it Simple field with two args + */ + public function testSimpleFieldWithTwoArgs() + { + $body = ' +type Hello { + world(argOne: Boolean, argTwo: Int): String +}'; + $doc = Parser::parse($body); + $loc = $this->createLocFn($body); + + $expected = new Document([ + 'definitions' => [ + new ObjectTypeDefinition([ + 'name' => $this->nameNode('Hello', $loc(6, 11)), + 'interfaces' => [], + 'fields' => [ + $this->fieldNodeWithArgs( + $this->nameNode('world', $loc(16, 21)), + $this->typeNode('String', $loc(53, 59)), + [ + $this->inputValueNode( + $this->nameNode('argOne', $loc(22, 28)), + $this->typeNode('Boolean', $loc(30, 37)), + null, + $loc(22, 37) + ), + $this->inputValueNode( + $this->nameNode('argTwo', $loc(39, 45)), + $this->typeNode('Int', $loc(47, 50)), + null, + $loc(39, 50) + ) + ], + $loc(16, 59) + ) + ], + 'loc' => $loc(1, 61) + ]) + ], + 'loc' => $loc(1, 61) + ]); + + $this->assertEquals($expected, $doc); + } + + /** + * @it Simple union + */ + public function testSimpleUnion() + { + $body = 'union Hello = World'; + $doc = Parser::parse($body); + $loc = $this->createLocFn($body); + $expected = new Document([ + 'definitions' => [ + new UnionTypeDefinition([ + 'name' => $this->nameNode('Hello', $loc(6, 11)), + 'types' => [$this->typeNode('World', $loc(14, 19))], + 'loc' => $loc(0, 19) + ]) + ], + 'loc' => $loc(0, 19) + ]); + + $this->assertEquals($expected, $doc); + } + + /** + * @it Union with two types + */ + public function testUnionWithTwoTypes() + { + $body = 'union Hello = Wo | Rld'; + $doc = Parser::parse($body); + $loc = $this->createLocFn($body); + + $expected = new Document([ + 'definitions' => [ + new UnionTypeDefinition([ + 'name' => $this->nameNode('Hello', $loc(6, 11)), + 'types' => [ + $this->typeNode('Wo', $loc(14, 16)), + $this->typeNode('Rld', $loc(19, 22)) + ], + 'loc' => $loc(0, 22) + ]) + ], + 'loc' => $loc(0, 22) + ]); + $this->assertEquals($expected, $doc); + } + + /** + * @it Scalar + */ + public function testScalar() + { + $body = 'scalar Hello'; + $doc = Parser::parse($body); + $loc = $this->createLocFn($body); + $expected = new Document([ + 'definitions' => [ + new ScalarTypeDefinition([ + 'name' => $this->nameNode('Hello', $loc(7, 12)), + 'loc' => $loc(0, 12) + ]) + ], + 'loc' => $loc(0, 12) + ]); + $this->assertEquals($expected, $doc); + } + + /** + * @it Simple input object + */ + public function testSimpleInputObject() + { + $body = ' +input Hello { + world: String +}'; + $doc = Parser::parse($body); + $loc = $this->createLocFn($body); + + $expected = new Document([ + 'definitions' => [ + new InputObjectTypeDefinition([ + 'name' => $this->nameNode('Hello', $loc(7, 12)), + 'fields' => [ + $this->inputValueNode( + $this->nameNode('world', $loc(17, 22)), + $this->typeNode('String', $loc(24, 30)), + null, + $loc(17, 30) + ) + ], + 'loc' => $loc(1, 32) + ]) + ], + 'loc' => $loc(1, 32) + ]); + $this->assertEquals($expected, $doc); + } + + /** + * @it Simple input object with args should fail + */ + public function testSimpleInputObjectWithArgsShouldFail() + { + $body = ' +input Hello { + world(foo: Int): String +}'; + $this->setExpectedException('GraphQL\SyntaxError'); + Parser::parse($body); + } + + + private function createLocFn($body) + { + return function($start, $end) use ($body) { + return new Location($start, $end, new Source($body)); + }; + } + + private function typeNode($name, $loc) + { + return new NamedType([ + 'name' => new Name(['value' => $name, 'loc' => $loc]), + 'loc' => $loc + ]); + } + + private function nameNode($name, $loc) + { + return new Name([ + 'value' => $name, + 'loc' => $loc + ]); + } + + private function fieldNode($name, $type, $loc) + { + return $this->fieldNodeWithArgs($name, $type, [], $loc); + } + + private function fieldNodeWithArgs($name, $type, $args, $loc) + { + return new FieldDefinition([ + 'name' => $name, + 'arguments' => $args, + 'type' => $type, + 'loc' => $loc + ]); + } + + private function enumValueNode($name, $loc) + { + return new EnumValueDefinition([ + 'name' => $this->nameNode($name, $loc), + 'loc' => $loc + ]); + } + + private function inputValueNode($name, $type, $defaultValue, $loc) + { + return new InputValueDefinition([ + 'name' => $name, + 'type' => $type, + 'defaultValue' => $defaultValue, + 'loc' => $loc + ]); + } +} diff --git a/tests/Language/kitchen-sink.graphql b/tests/Language/kitchen-sink.graphql index 0b05f1d..0e04e2e 100644 --- a/tests/Language/kitchen-sink.graphql +++ b/tests/Language/kitchen-sink.graphql @@ -17,6 +17,12 @@ query queryName($foo: ComplexType, $site: Site = MOBILE) { } } } + ... @skip(unless: $foo) { + id + } + ... { + id + } } } @@ -28,6 +34,19 @@ mutation likeStory { } } +subscription StoryLikeSubscription($input: StoryLikeSubscribeInput) { + storyLikeSubscribe(input: $input) { + story { + likers { + count + } + likeSentence { + text + } + } + } +} + fragment frag on Friend { foo(size: $size, bar: $b, obj: {key: "value"}) }