Skip to content

Commit

Permalink
Support repeatable directives (#643)
Browse files Browse the repository at this point in the history
* Support repeatable directives

See graphql/graphql-js#1965

* Fix introspection

See graphql/graphql-js#2416

* Fix codestyle

* Canonical ordering

* Update baseline

* Make DirectiveTest.php final

Co-Authored-By: Šimon Podlipský <simon@podlipsky.net>

* Fix baseline

Co-authored-by: Šimon Podlipský <simon@podlipsky.net>
  • Loading branch information
spawnia and simPod committed Jun 8, 2020
1 parent ed8fb62 commit 91b55bb
Show file tree
Hide file tree
Showing 21 changed files with 446 additions and 184 deletions.
5 changes: 0 additions & 5 deletions phpstan-baseline.neon
Expand Up @@ -265,11 +265,6 @@ parameters:
count: 2
path: src/Server/OperationParams.php

-
message: "#^Variable property access on \\$this\\(GraphQL\\\\Type\\\\Definition\\\\Directive\\)\\.$#"
count: 1
path: src/Type/Definition/Directive.php

-
message: "#^Only booleans are allowed in a negated boolean, ArrayObject\\<string, GraphQL\\\\Type\\\\Definition\\\\EnumValueDefinition\\> given\\.$#"
count: 1
Expand Down
9 changes: 6 additions & 3 deletions src/Language/AST/DirectiveDefinitionNode.php
Expand Up @@ -12,12 +12,15 @@ class DirectiveDefinitionNode extends Node implements TypeSystemDefinitionNode
/** @var NameNode */
public $name;

/** @var StringValueNode|null */
public $description;

/** @var ArgumentNode[] */
public $arguments;

/** @var bool */
public $repeatable;

/** @var NameNode[] */
public $locations;

/** @var StringValueNode|null */
public $description;
}
57 changes: 33 additions & 24 deletions src/Language/Parser.php
Expand Up @@ -327,26 +327,39 @@ private function expect(string $kind) : Token
}

/**
* If the next token is a keyword with the given value, return that token after
* advancing the parser. Otherwise, do not change the parser state and return
* false.
* If the next token is a keyword with the given value, advance the lexer.
* Otherwise, throw an error.
*
* @throws SyntaxError
*/
private function expectKeyword(string $value) : Token
private function expectKeyword(string $value) : void
{
$token = $this->lexer->token;
if ($token->kind !== Token::NAME || $token->value !== $value) {
throw new SyntaxError(
$this->lexer->source,
$token->start,
'Expected "' . $value . '", found ' . $token->getDescription()
);
}

$this->lexer->advance();
}

/**
* If the next token is a given keyword, return "true" after advancing
* the lexer. Otherwise, do not change the parser state and return "false".
*/
private function expectOptionalKeyword(string $value) : bool
{
$token = $this->lexer->token;
if ($token->kind === Token::NAME && $token->value === $value) {
$this->lexer->advance();

return $token;
return true;
}
throw new SyntaxError(
$this->lexer->source,
$token->start,
'Expected "' . $value . '", found ' . $token->getDescription()
);

return false;
}

private function unexpected(?Token $atToken = null) : SyntaxError
Expand Down Expand Up @@ -716,22 +729,17 @@ private function parseFragment() : SelectionNode
$start = $this->lexer->token;
$this->expect(Token::SPREAD);

if ($this->peek(Token::NAME) && $this->lexer->token->value !== 'on') {
$hasTypeCondition = $this->expectOptionalKeyword('on');
if (! $hasTypeCondition && $this->peek(Token::NAME)) {
return new FragmentSpreadNode([
'name' => $this->parseFragmentName(),
'directives' => $this->parseDirectives(false),
'loc' => $this->loc($start),
]);
}

$typeCondition = null;
if ($this->lexer->token->value === 'on') {
$this->lexer->advance();
$typeCondition = $this->parseNamedType();
}

return new InlineFragmentNode([
'typeCondition' => $typeCondition,
'typeCondition' => $hasTypeCondition ? $this->parseNamedType() : null,
'directives' => $this->parseDirectives(false),
'selectionSet' => $this->parseSelectionSet(),
'loc' => $this->loc($start),
Expand Down Expand Up @@ -1172,8 +1180,7 @@ private function parseObjectTypeDefinition() : ObjectTypeDefinitionNode
private function parseImplementsInterfaces() : array
{
$types = [];
if ($this->lexer->token->value === 'implements') {
$this->lexer->advance();
if ($this->expectOptionalKeyword('implements')) {
// Optional leading ampersand
$this->skip(Token::AMP);
do {
Expand Down Expand Up @@ -1668,7 +1675,7 @@ private function parseInputObjectTypeExtension() : InputObjectTypeExtensionNode

/**
* DirectiveDefinition :
* - directive @ Name ArgumentsDefinition? on DirectiveLocations
* - Description? directive @ Name ArgumentsDefinition? `repeatable`? on DirectiveLocations
*
* @throws SyntaxError
*/
Expand All @@ -1678,17 +1685,19 @@ private function parseDirectiveDefinition() : DirectiveDefinitionNode
$description = $this->parseDescription();
$this->expectKeyword('directive');
$this->expect(Token::AT);
$name = $this->parseName();
$args = $this->parseArgumentsDefinition();
$name = $this->parseName();
$args = $this->parseArgumentsDefinition();
$repeatable = $this->expectOptionalKeyword('repeatable');
$this->expectKeyword('on');
$locations = $this->parseDirectiveLocations();

return new DirectiveDefinitionNode([
'name' => $name,
'description' => $description,
'arguments' => $args,
'repeatable' => $repeatable,
'locations' => $locations,
'loc' => $this->loc($start),
'description' => $description,
]);
}

Expand Down
1 change: 1 addition & 0 deletions src/Language/Printer.php
Expand Up @@ -446,6 +446,7 @@ function (InterfaceTypeDefinitionNode $def) {
. ($noIndent
? $this->wrap('(', $this->join($def->arguments, ', '), ')')
: $this->wrap("(\n", $this->indent($this->join($def->arguments, "\n")), "\n"))
. ($def->repeatable ? ' repeatable' : '')
. ' on ' . $this->join($def->locations, ' | ');
}),
],
Expand Down
30 changes: 21 additions & 9 deletions src/Type/Definition/Directive.php
Expand Up @@ -4,9 +4,9 @@

namespace GraphQL\Type\Definition;

use GraphQL\Error\InvariantViolation;
use GraphQL\Language\AST\DirectiveDefinitionNode;
use GraphQL\Language\DirectiveLocation;
use GraphQL\Utils\Utils;
use function array_key_exists;
use function is_array;

Expand All @@ -31,12 +31,15 @@ class Directive
/** @var string|null */
public $description;

/** @var string[] */
public $locations;

/** @var FieldArgument[] */
public $args = [];

/** @var bool */
public $isRepeatable;

/** @var string[] */
public $locations;

/** @var DirectiveDefinitionNode|null */
public $astNode;

Expand All @@ -48,6 +51,13 @@ class Directive
*/
public function __construct(array $config)
{
if (! isset($config['name'])) {
throw new InvariantViolation('Directive must be named.');
}
$this->name = $config['name'];

$this->description = $config['description'] ?? null;

if (isset($config['args'])) {
$args = [];
foreach ($config['args'] as $name => $arg) {
Expand All @@ -58,14 +68,16 @@ public function __construct(array $config)
}
}
$this->args = $args;
unset($config['args']);
}
foreach ($config as $key => $value) {
$this->{$key} = $value;

if (! isset($config['locations']) || ! is_array($config['locations'])) {
throw new InvariantViolation('Must provide locations for directive.');
}
$this->locations = $config['locations'];

$this->isRepeatable = $config['isRepeatable'] ?? false;
$this->astNode = $config['astNode'] ?? null;

Utils::invariant($this->name, 'Directive must be named.');
Utils::invariant(is_array($this->locations), 'Must provide locations for directive.');
$this->config = $config;
}

Expand Down
86 changes: 50 additions & 36 deletions src/Type/Introspection.php
Expand Up @@ -27,6 +27,7 @@
use GraphQL\Utils\Utils;
use function array_filter;
use function array_key_exists;
use function array_merge;
use function array_values;
use function is_bool;
use function method_exists;
Expand All @@ -43,28 +44,28 @@ class Introspection
private static $map = [];

/**
* Options:
* - descriptions
* Whether to include descriptions in the introspection result.
* Default: true
*
* @param bool[]|bool $options
* @param array<string, bool> $options
* Available options:
* - descriptions
* Whether to include descriptions in the introspection result.
* Default: true
* - directiveIsRepeatable
* Whether to include `isRepeatable` flag on directives.
* Default: false
*
* @return string
*
* @api
*/
public static function getIntrospectionQuery($options = [])
public static function getIntrospectionQuery(array $options = [])
{
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' : '';
$optionsWithDefaults = array_merge([
'descriptions' => true,
'directiveIsRepeatable' => false,
], $options);

$descriptions = $optionsWithDefaults['descriptions'] ? 'description' : '';
$directiveIsRepeatable = $optionsWithDefaults['directiveIsRepeatable'] ? 'isRepeatable' : '';

return <<<EOD
query IntrospectionQuery {
Expand All @@ -77,22 +78,23 @@ public static function getIntrospectionQuery($options = [])
}
directives {
name
{$descriptionField}
locations
{$descriptions}
args {
...InputValue
}
{$directiveIsRepeatable}
locations
}
}
}
fragment FullType on __Type {
kind
name
{$descriptionField}
{$descriptions}
fields(includeDeprecated: true) {
name
{$descriptionField}
{$descriptions}
args {
...InputValue
}
Expand All @@ -110,7 +112,7 @@ interfaces {
}
enumValues(includeDeprecated: true) {
name
{$descriptionField}
{$descriptions}
isDeprecated
deprecationReason
}
Expand All @@ -121,7 +123,7 @@ enumValues(includeDeprecated: true) {
fragment InputValue on __InputValue {
name
{$descriptionField}
{$descriptions}
type { ...TypeRef }
defaultValue
}
Expand Down Expand Up @@ -194,20 +196,26 @@ public static function getTypes()
* This is the inverse of BuildClientSchema::build(). The primary use case is outside
* of the server context, for instance when doing schema comparisons.
*
* Options:
* - descriptions
* Whether to include descriptions in the introspection result.
* Default: true
*
* @param array<string, bool> $options
* Available options:
* - descriptions
* Whether to include `isRepeatable` flag on directives.
* Default: true
* - directiveIsRepeatable
* Whether to include descriptions in the introspection result.
* Default: true
*
* @return array<string, array<mixed>>|null
*
* @api
*/
public static function fromSchema(Schema $schema, array $options = []) : ?array
{
$optionsWithDefaults = array_merge(['directiveIsRepeatable' => true], $options);

$result = GraphQL::executeQuery(
$schema,
self::getIntrospectionQuery($options)
self::getIntrospectionQuery($optionsWithDefaults)
);

return $result->data;
Expand Down Expand Up @@ -651,6 +659,18 @@ public static function _directive()
return $obj->description;
},
],
'args' => [
'type' => Type::nonNull(Type::listOf(Type::nonNull(self::_inputValue()))),
'resolve' => static function (Directive $directive) {
return $directive->args ?? [];
},
],
'isRepeatable' => [
'type' => Type::nonNull(Type::boolean()),
'resolve' => static function (Directive $directive) : bool {
return $directive->isRepeatable;
},
],
'locations' => [
'type' => Type::nonNull(Type::listOf(Type::nonNull(
self::_directiveLocation()
Expand All @@ -659,12 +679,6 @@ public static function _directive()
return $obj->locations;
},
],
'args' => [
'type' => Type::nonNull(Type::listOf(Type::nonNull(self::_inputValue()))),
'resolve' => static function (Directive $directive) {
return $directive->args ?? [];
},
],
],
]);
}
Expand Down

0 comments on commit 91b55bb

Please sign in to comment.