Skip to content

Commit 91b55bb

Browse files
spawniasimPod
andauthored
Support repeatable directives (#643)
* 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ý <[email protected]> * Fix baseline Co-authored-by: Šimon Podlipský <[email protected]>
1 parent ed8fb62 commit 91b55bb

21 files changed

+446
-184
lines changed

phpstan-baseline.neon

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -265,11 +265,6 @@ parameters:
265265
count: 2
266266
path: src/Server/OperationParams.php
267267

268-
-
269-
message: "#^Variable property access on \\$this\\(GraphQL\\\\Type\\\\Definition\\\\Directive\\)\\.$#"
270-
count: 1
271-
path: src/Type/Definition/Directive.php
272-
273268
-
274269
message: "#^Only booleans are allowed in a negated boolean, ArrayObject\\<string, GraphQL\\\\Type\\\\Definition\\\\EnumValueDefinition\\> given\\.$#"
275270
count: 1

src/Language/AST/DirectiveDefinitionNode.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ class DirectiveDefinitionNode extends Node implements TypeSystemDefinitionNode
1212
/** @var NameNode */
1313
public $name;
1414

15+
/** @var StringValueNode|null */
16+
public $description;
17+
1518
/** @var ArgumentNode[] */
1619
public $arguments;
1720

21+
/** @var bool */
22+
public $repeatable;
23+
1824
/** @var NameNode[] */
1925
public $locations;
20-
21-
/** @var StringValueNode|null */
22-
public $description;
2326
}

src/Language/Parser.php

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -327,26 +327,39 @@ private function expect(string $kind) : Token
327327
}
328328

329329
/**
330-
* If the next token is a keyword with the given value, return that token after
331-
* advancing the parser. Otherwise, do not change the parser state and return
332-
* false.
330+
* If the next token is a keyword with the given value, advance the lexer.
331+
* Otherwise, throw an error.
333332
*
334333
* @throws SyntaxError
335334
*/
336-
private function expectKeyword(string $value) : Token
335+
private function expectKeyword(string $value) : void
337336
{
338337
$token = $this->lexer->token;
338+
if ($token->kind !== Token::NAME || $token->value !== $value) {
339+
throw new SyntaxError(
340+
$this->lexer->source,
341+
$token->start,
342+
'Expected "' . $value . '", found ' . $token->getDescription()
343+
);
344+
}
345+
346+
$this->lexer->advance();
347+
}
339348

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

343-
return $token;
359+
return true;
344360
}
345-
throw new SyntaxError(
346-
$this->lexer->source,
347-
$token->start,
348-
'Expected "' . $value . '", found ' . $token->getDescription()
349-
);
361+
362+
return false;
350363
}
351364

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

719-
if ($this->peek(Token::NAME) && $this->lexer->token->value !== 'on') {
732+
$hasTypeCondition = $this->expectOptionalKeyword('on');
733+
if (! $hasTypeCondition && $this->peek(Token::NAME)) {
720734
return new FragmentSpreadNode([
721735
'name' => $this->parseFragmentName(),
722736
'directives' => $this->parseDirectives(false),
723737
'loc' => $this->loc($start),
724738
]);
725739
}
726740

727-
$typeCondition = null;
728-
if ($this->lexer->token->value === 'on') {
729-
$this->lexer->advance();
730-
$typeCondition = $this->parseNamedType();
731-
}
732-
733741
return new InlineFragmentNode([
734-
'typeCondition' => $typeCondition,
742+
'typeCondition' => $hasTypeCondition ? $this->parseNamedType() : null,
735743
'directives' => $this->parseDirectives(false),
736744
'selectionSet' => $this->parseSelectionSet(),
737745
'loc' => $this->loc($start),
@@ -1172,8 +1180,7 @@ private function parseObjectTypeDefinition() : ObjectTypeDefinitionNode
11721180
private function parseImplementsInterfaces() : array
11731181
{
11741182
$types = [];
1175-
if ($this->lexer->token->value === 'implements') {
1176-
$this->lexer->advance();
1183+
if ($this->expectOptionalKeyword('implements')) {
11771184
// Optional leading ampersand
11781185
$this->skip(Token::AMP);
11791186
do {
@@ -1668,7 +1675,7 @@ private function parseInputObjectTypeExtension() : InputObjectTypeExtensionNode
16681675

16691676
/**
16701677
* DirectiveDefinition :
1671-
* - directive @ Name ArgumentsDefinition? on DirectiveLocations
1678+
* - Description? directive @ Name ArgumentsDefinition? `repeatable`? on DirectiveLocations
16721679
*
16731680
* @throws SyntaxError
16741681
*/
@@ -1678,17 +1685,19 @@ private function parseDirectiveDefinition() : DirectiveDefinitionNode
16781685
$description = $this->parseDescription();
16791686
$this->expectKeyword('directive');
16801687
$this->expect(Token::AT);
1681-
$name = $this->parseName();
1682-
$args = $this->parseArgumentsDefinition();
1688+
$name = $this->parseName();
1689+
$args = $this->parseArgumentsDefinition();
1690+
$repeatable = $this->expectOptionalKeyword('repeatable');
16831691
$this->expectKeyword('on');
16841692
$locations = $this->parseDirectiveLocations();
16851693

16861694
return new DirectiveDefinitionNode([
16871695
'name' => $name,
1696+
'description' => $description,
16881697
'arguments' => $args,
1698+
'repeatable' => $repeatable,
16891699
'locations' => $locations,
16901700
'loc' => $this->loc($start),
1691-
'description' => $description,
16921701
]);
16931702
}
16941703

src/Language/Printer.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,7 @@ function (InterfaceTypeDefinitionNode $def) {
446446
. ($noIndent
447447
? $this->wrap('(', $this->join($def->arguments, ', '), ')')
448448
: $this->wrap("(\n", $this->indent($this->join($def->arguments, "\n")), "\n"))
449+
. ($def->repeatable ? ' repeatable' : '')
449450
. ' on ' . $this->join($def->locations, ' | ');
450451
}),
451452
],

src/Type/Definition/Directive.php

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
namespace GraphQL\Type\Definition;
66

7+
use GraphQL\Error\InvariantViolation;
78
use GraphQL\Language\AST\DirectiveDefinitionNode;
89
use GraphQL\Language\DirectiveLocation;
9-
use GraphQL\Utils\Utils;
1010
use function array_key_exists;
1111
use function is_array;
1212

@@ -31,12 +31,15 @@ class Directive
3131
/** @var string|null */
3232
public $description;
3333

34-
/** @var string[] */
35-
public $locations;
36-
3734
/** @var FieldArgument[] */
3835
public $args = [];
3936

37+
/** @var bool */
38+
public $isRepeatable;
39+
40+
/** @var string[] */
41+
public $locations;
42+
4043
/** @var DirectiveDefinitionNode|null */
4144
public $astNode;
4245

@@ -48,6 +51,13 @@ class Directive
4851
*/
4952
public function __construct(array $config)
5053
{
54+
if (! isset($config['name'])) {
55+
throw new InvariantViolation('Directive must be named.');
56+
}
57+
$this->name = $config['name'];
58+
59+
$this->description = $config['description'] ?? null;
60+
5161
if (isset($config['args'])) {
5262
$args = [];
5363
foreach ($config['args'] as $name => $arg) {
@@ -58,14 +68,16 @@ public function __construct(array $config)
5868
}
5969
}
6070
$this->args = $args;
61-
unset($config['args']);
6271
}
63-
foreach ($config as $key => $value) {
64-
$this->{$key} = $value;
72+
73+
if (! isset($config['locations']) || ! is_array($config['locations'])) {
74+
throw new InvariantViolation('Must provide locations for directive.');
6575
}
76+
$this->locations = $config['locations'];
77+
78+
$this->isRepeatable = $config['isRepeatable'] ?? false;
79+
$this->astNode = $config['astNode'] ?? null;
6680

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

src/Type/Introspection.php

Lines changed: 50 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use GraphQL\Utils\Utils;
2828
use function array_filter;
2929
use function array_key_exists;
30+
use function array_merge;
3031
use function array_values;
3132
use function is_bool;
3233
use function method_exists;
@@ -43,28 +44,28 @@ class Introspection
4344
private static $map = [];
4445

4546
/**
46-
* Options:
47-
* - descriptions
48-
* Whether to include descriptions in the introspection result.
49-
* Default: true
50-
*
51-
* @param bool[]|bool $options
47+
* @param array<string, bool> $options
48+
* Available options:
49+
* - descriptions
50+
* Whether to include descriptions in the introspection result.
51+
* Default: true
52+
* - directiveIsRepeatable
53+
* Whether to include `isRepeatable` flag on directives.
54+
* Default: false
5255
*
5356
* @return string
57+
*
58+
* @api
5459
*/
55-
public static function getIntrospectionQuery($options = [])
60+
public static function getIntrospectionQuery(array $options = [])
5661
{
57-
if (is_bool($options)) {
58-
trigger_error(
59-
'Calling Introspection::getIntrospectionQuery(boolean) is deprecated. ' .
60-
'Please use Introspection::getIntrospectionQuery(["descriptions" => boolean]).',
61-
E_USER_DEPRECATED
62-
);
63-
$descriptions = $options;
64-
} else {
65-
$descriptions = ! array_key_exists('descriptions', $options) || $options['descriptions'] === true;
66-
}
67-
$descriptionField = $descriptions ? 'description' : '';
62+
$optionsWithDefaults = array_merge([
63+
'descriptions' => true,
64+
'directiveIsRepeatable' => false,
65+
], $options);
66+
67+
$descriptions = $optionsWithDefaults['descriptions'] ? 'description' : '';
68+
$directiveIsRepeatable = $optionsWithDefaults['directiveIsRepeatable'] ? 'isRepeatable' : '';
6869

6970
return <<<EOD
7071
query IntrospectionQuery {
@@ -77,22 +78,23 @@ public static function getIntrospectionQuery($options = [])
7778
}
7879
directives {
7980
name
80-
{$descriptionField}
81-
locations
81+
{$descriptions}
8282
args {
8383
...InputValue
8484
}
85+
{$directiveIsRepeatable}
86+
locations
8587
}
8688
}
8789
}
8890
8991
fragment FullType on __Type {
9092
kind
9193
name
92-
{$descriptionField}
94+
{$descriptions}
9395
fields(includeDeprecated: true) {
9496
name
95-
{$descriptionField}
97+
{$descriptions}
9698
args {
9799
...InputValue
98100
}
@@ -110,7 +112,7 @@ interfaces {
110112
}
111113
enumValues(includeDeprecated: true) {
112114
name
113-
{$descriptionField}
115+
{$descriptions}
114116
isDeprecated
115117
deprecationReason
116118
}
@@ -121,7 +123,7 @@ enumValues(includeDeprecated: true) {
121123
122124
fragment InputValue on __InputValue {
123125
name
124-
{$descriptionField}
126+
{$descriptions}
125127
type { ...TypeRef }
126128
defaultValue
127129
}
@@ -194,20 +196,26 @@ public static function getTypes()
194196
* This is the inverse of BuildClientSchema::build(). The primary use case is outside
195197
* of the server context, for instance when doing schema comparisons.
196198
*
197-
* Options:
198-
* - descriptions
199-
* Whether to include descriptions in the introspection result.
200-
* Default: true
201-
*
202199
* @param array<string, bool> $options
200+
* Available options:
201+
* - descriptions
202+
* Whether to include `isRepeatable` flag on directives.
203+
* Default: true
204+
* - directiveIsRepeatable
205+
* Whether to include descriptions in the introspection result.
206+
* Default: true
203207
*
204208
* @return array<string, array<mixed>>|null
209+
*
210+
* @api
205211
*/
206212
public static function fromSchema(Schema $schema, array $options = []) : ?array
207213
{
214+
$optionsWithDefaults = array_merge(['directiveIsRepeatable' => true], $options);
215+
208216
$result = GraphQL::executeQuery(
209217
$schema,
210-
self::getIntrospectionQuery($options)
218+
self::getIntrospectionQuery($optionsWithDefaults)
211219
);
212220

213221
return $result->data;
@@ -651,6 +659,18 @@ public static function _directive()
651659
return $obj->description;
652660
},
653661
],
662+
'args' => [
663+
'type' => Type::nonNull(Type::listOf(Type::nonNull(self::_inputValue()))),
664+
'resolve' => static function (Directive $directive) {
665+
return $directive->args ?? [];
666+
},
667+
],
668+
'isRepeatable' => [
669+
'type' => Type::nonNull(Type::boolean()),
670+
'resolve' => static function (Directive $directive) : bool {
671+
return $directive->isRepeatable;
672+
},
673+
],
654674
'locations' => [
655675
'type' => Type::nonNull(Type::listOf(Type::nonNull(
656676
self::_directiveLocation()
@@ -659,12 +679,6 @@ public static function _directive()
659679
return $obj->locations;
660680
},
661681
],
662-
'args' => [
663-
'type' => Type::nonNull(Type::listOf(Type::nonNull(self::_inputValue()))),
664-
'resolve' => static function (Directive $directive) {
665-
return $directive->args ?? [];
666-
},
667-
],
668682
],
669683
]);
670684
}

0 commit comments

Comments
 (0)