From aee90defd84a9414696ca810546b3aca70aefc0d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 10:30:47 +0000 Subject: [PATCH 01/18] Add @specifiedBy directive support, address review comments Agent-Logs-Url: https://github.com/webonyx/graphql-php/sessions/31ffabfc-b1cd-46f4-bf81-7753830e77f2 Co-authored-by: spawnia <12158000+spawnia@users.noreply.github.com> --- src/Language/Printer.php | 3 ++- src/Type/Definition/CustomScalarType.php | 2 ++ src/Type/Definition/Directive.php | 25 ++++++++++++++++++++++- src/Type/Definition/ScalarType.php | 4 ++++ src/Utils/ASTDefinitionBuilder.php | 25 +++++++++++++++++++++-- src/Utils/BuildSchema.php | 3 +++ src/Utils/SchemaExtender.php | 13 ++++++++++++ src/Utils/SchemaPrinter.php | 25 ++++++++++++++++++++++- src/Validator/Rules/QueryComplexity.php | 4 ++++ tests/Type/IntrospectionTest.php | 24 ++++++++++++++++++++++ tests/Utils/BreakingChangesFinderTest.php | 2 +- tests/Utils/BuildSchemaTest.php | 19 ++++++----------- tests/Utils/SchemaExtenderTest.php | 4 ++-- tests/Utils/SchemaPrinterTest.php | 6 ++++++ tests/Validator/ValidatorTestCase.php | 7 ++----- 15 files changed, 140 insertions(+), 26 deletions(-) diff --git a/src/Language/Printer.php b/src/Language/Printer.php index 8bf04943e..adf93f1d8 100644 --- a/src/Language/Printer.php +++ b/src/Language/Printer.php @@ -403,7 +403,8 @@ protected static function p(?Node $node): string return BlockString::print($node->value); } - return json_encode($node->value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + // Do not escape unicode or slashes in order to keep URLs valid + return json_encode($node->value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); case $node instanceof UnionTypeDefinitionNode: $typesStr = static::printList($node->types, ' | '); diff --git a/src/Type/Definition/CustomScalarType.php b/src/Type/Definition/CustomScalarType.php index 80694dca9..cd85f8d5f 100644 --- a/src/Type/Definition/CustomScalarType.php +++ b/src/Type/Definition/CustomScalarType.php @@ -18,6 +18,7 @@ * serialize?: callable(mixed): mixed, * parseValue: callable(mixed): mixed, * parseLiteral: callable(ValueNode&Node, array|null): mixed, + * specifiedByURL?: string|null, * astNode?: ScalarTypeDefinitionNode|null, * extensionASTNodes?: array|null * } @@ -27,6 +28,7 @@ * serialize: callable(mixed): mixed, * parseValue?: callable(mixed): mixed, * parseLiteral?: callable(ValueNode&Node, array|null): mixed, + * specifiedByURL?: string|null, * astNode?: ScalarTypeDefinitionNode|null, * extensionASTNodes?: array|null * } diff --git a/src/Type/Definition/Directive.php b/src/Type/Definition/Directive.php index 80d1ce956..b4e607492 100644 --- a/src/Type/Definition/Directive.php +++ b/src/Type/Definition/Directive.php @@ -22,10 +22,15 @@ class Directive public const DEFAULT_DEPRECATION_REASON = 'No longer supported'; public const INCLUDE_NAME = 'include'; - public const IF_ARGUMENT_NAME = 'if'; public const SKIP_NAME = 'skip'; + public const IF_ARGUMENT_NAME = 'if'; + public const DEPRECATED_NAME = 'deprecated'; public const REASON_ARGUMENT_NAME = 'reason'; + + public const SPECIFIED_BY_NAME = 'specifiedBy'; + public const URL_ARGUMENT_NAME = 'url'; + public const ONE_OF_NAME = 'oneOf'; /** @@ -82,6 +87,7 @@ public static function builtInDirectives(): array self::INCLUDE_NAME => self::includeDirective(), self::SKIP_NAME => self::skipDirective(), self::DEPRECATED_NAME => self::deprecatedDirective(), + self::SPECIFIED_BY_NAME => self::specifiedByDirective(), self::ONE_OF_NAME => self::oneOfDirective(), ]; } @@ -167,6 +173,23 @@ public static function oneOfDirective(): Directive ]); } + public static function specifiedByDirective(): Directive + { + return self::$internalDirectives[self::SPECIFIED_BY_NAME] ??= new self([ + 'name' => self::SPECIFIED_BY_NAME, + 'description' => 'Exposes a URL that specifies the behaviour of this scalar.', + 'locations' => [ + DirectiveLocation::SCALAR, + ], + 'args' => [ + self::URL_ARGUMENT_NAME => [ + 'type' => Type::nonNull(Type::string()), + 'description' => 'The URL that specifies the behaviour of this scalar and points to a human-readable specification of the data format, serialization, and coercion rules. It must not appear on built-in scalar types.', + ], + ], + ]); + } + public static function isBuiltInDirective(self $directive): bool { return array_key_exists($directive->name, self::builtInDirectives()); diff --git a/src/Type/Definition/ScalarType.php b/src/Type/Definition/ScalarType.php index 48a97c7db..59e05db21 100644 --- a/src/Type/Definition/ScalarType.php +++ b/src/Type/Definition/ScalarType.php @@ -28,6 +28,7 @@ * @phpstan-type ScalarConfig array{ * name?: string|null, * description?: string|null, + * specifiedByURL?: string|null, * astNode?: ScalarTypeDefinitionNode|null, * extensionASTNodes?: array|null * } @@ -38,6 +39,8 @@ abstract class ScalarType extends Type implements OutputType, InputType, LeafTyp public ?ScalarTypeDefinitionNode $astNode; + public ?string $specifiedByURL; + /** @var array */ public array $extensionASTNodes; @@ -53,6 +56,7 @@ public function __construct(array $config = []) { $this->name = $config['name'] ?? $this->inferName(); $this->description = $config['description'] ?? $this->description ?? null; + $this->specifiedByURL = $config['specifiedByURL'] ?? null; $this->astNode = $config['astNode'] ?? null; $this->extensionASTNodes = $config['extensionASTNodes'] ?? []; diff --git a/src/Utils/ASTDefinitionBuilder.php b/src/Utils/ASTDefinitionBuilder.php index 1a9d4959b..066ef80b1 100644 --- a/src/Utils/ASTDefinitionBuilder.php +++ b/src/Utils/ASTDefinitionBuilder.php @@ -403,7 +403,6 @@ public function buildField(FieldDefinitionNode $field, object $node): array * @param EnumValueDefinitionNode|FieldDefinitionNode|InputValueDefinitionNode $node * * @throws \Exception - * @throws \ReflectionException * @throws InvariantViolation */ private function getDeprecationReason(Node $node): ?string @@ -416,6 +415,24 @@ private function getDeprecationReason(Node $node): ?string return $deprecated['reason'] ?? null; } + /** + * Given a collection of directives, returns the string value for the specifiedBy URL. + * + * @param ScalarTypeDefinitionNode $node + * + * @throws \Exception + * @throws InvariantViolation + */ + private function getSpecifiedByURL(Node $node): ?string + { + $specifiedBy = Values::getDirectiveValues( + Directive::specifiedByDirective(), + $node + ); + + return $specifiedBy['url'] ?? null; + } + /** * @param array $nodes * @@ -520,7 +537,10 @@ private function makeUnionDef(UnionTypeDefinitionNode $def): UnionType ]); } - /** @throws InvariantViolation */ + /** + * @throws \Exception + * @throws InvariantViolation + */ private function makeScalarDef(ScalarTypeDefinitionNode $def): CustomScalarType { $name = $def->name->value; @@ -533,6 +553,7 @@ private function makeScalarDef(ScalarTypeDefinitionNode $def): CustomScalarType 'serialize' => static fn ($value) => $value, 'astNode' => $def, 'extensionASTNodes' => $extensionASTNodes, + 'specifiedByURL' => $this->getSpecifiedByURL($def), ]); } diff --git a/src/Utils/BuildSchema.php b/src/Utils/BuildSchema.php index 1e47cf8b4..863946f92 100644 --- a/src/Utils/BuildSchema.php +++ b/src/Utils/BuildSchema.php @@ -231,6 +231,9 @@ static function (string $typeName): Type { if (! isset($directivesByName['deprecated'])) { $directives[] = Directive::deprecatedDirective(); } + if (! isset($directivesByName['specifiedBy'])) { + $directives[] = Directive::specifiedByDirective(); + } if (! isset($directivesByName['oneOf'])) { $directives[] = Directive::oneOfDirective(); } diff --git a/src/Utils/SchemaExtender.php b/src/Utils/SchemaExtender.php index 51e7c1bc1..1643a156f 100644 --- a/src/Utils/SchemaExtender.php +++ b/src/Utils/SchemaExtender.php @@ -4,6 +4,7 @@ use GraphQL\Error\Error; use GraphQL\Error\InvariantViolation; +use GraphQL\Executor\Values; use GraphQL\Language\AST\DirectiveDefinitionNode; use GraphQL\Language\AST\DocumentNode; use GraphQL\Language\AST\EnumTypeExtensionNode; @@ -226,12 +227,24 @@ protected function extendScalarType(ScalarType $type): CustomScalarType /** @var array $extensionASTNodes */ $extensionASTNodes = $this->extensionASTNodes($type); + $specifiedByURL = $type->specifiedByURL; + if ($specifiedByURL === null) { + foreach ($extensionASTNodes as $extensionNode) { + $specifiedBy = Values::getDirectiveValues(Directive::specifiedByDirective(), $extensionNode); + if ($specifiedBy !== null) { + $specifiedByURL = $specifiedBy['url'] ?? null; + break; + } + } + } + return new CustomScalarType([ 'name' => $type->name, 'description' => $type->description, 'serialize' => [$type, 'serialize'], 'parseValue' => [$type, 'parseValue'], 'parseLiteral' => [$type, 'parseLiteral'], + 'specifiedByURL' => $specifiedByURL, 'astNode' => $type->astNode, 'extensionASTNodes' => $extensionASTNodes, ]); diff --git a/src/Utils/SchemaPrinter.php b/src/Utils/SchemaPrinter.php index f30a19f0b..baae94391 100644 --- a/src/Utils/SchemaPrinter.php +++ b/src/Utils/SchemaPrinter.php @@ -364,11 +364,14 @@ protected static function printInputValue($arg): string * @phpstan-param Options $options * * @throws \JsonException + * @throws InvariantViolation + * @throws SerializationError */ protected static function printScalar(ScalarType $type, array $options): string { return static::printDescription($options, $type) - . "scalar {$type->name}"; + . "scalar {$type->name}" + . static::printSpecifiedBy($type); } /** @@ -455,6 +458,26 @@ protected static function printDeprecated($deprecation): string return " @deprecated(reason: {$reasonASTString})"; } + /** + * @throws \JsonException + * @throws InvariantViolation + * @throws SerializationError + */ + protected static function printSpecifiedBy(ScalarType $type): string + { + $url = $type->specifiedByURL; + if ($url === null) { + return ''; + } + + $urlAST = AST::astFromValue($url, Type::string()); + assert($urlAST instanceof StringValueNode); + + $urlASTString = Printer::doPrint($urlAST); + + return " @specifiedBy(url: {$urlASTString})"; + } + protected static function printImplementedInterfaces(ImplementingType $type): string { $interfaces = $type->getInterfaces(); diff --git a/src/Validator/Rules/QueryComplexity.php b/src/Validator/Rules/QueryComplexity.php index 9d5a6bf12..3ef4ec685 100644 --- a/src/Validator/Rules/QueryComplexity.php +++ b/src/Validator/Rules/QueryComplexity.php @@ -186,6 +186,10 @@ protected function directiveExcludesField(FieldNode $node): bool return false; } + if ($directiveNode->name->value === Directive::SPECIFIED_BY_NAME) { + return false; + } + [$errors, $variableValues] = Values::getVariableValues( $this->context->getSchema(), $this->variableDefs, diff --git a/tests/Type/IntrospectionTest.php b/tests/Type/IntrospectionTest.php index ce558a7c2..6af4ddeff 100644 --- a/tests/Type/IntrospectionTest.php +++ b/tests/Type/IntrospectionTest.php @@ -1041,6 +1041,30 @@ public function testExecutesAnIntrospectionQuery(): void 0 => 'INPUT_OBJECT', ], ], + [ + 'name' => 'specifiedBy', + 'args' => [ + 0 => [ + 'name' => 'url', + 'type' => [ + 'kind' => 'NON_NULL', + 'name' => null, + 'ofType' => [ + 'kind' => 'SCALAR', + 'name' => 'String', + 'ofType' => null, + ], + ], + 'defaultValue' => null, + 'isDeprecated' => false, + 'deprecationReason' => null, + ], + ], + 'isRepeatable' => false, + 'locations' => [ + 0 => 'SCALAR', + ], + ], ], ], ], diff --git a/tests/Utils/BreakingChangesFinderTest.php b/tests/Utils/BreakingChangesFinderTest.php index 2f4a6c760..1fe895d88 100644 --- a/tests/Utils/BreakingChangesFinderTest.php +++ b/tests/Utils/BreakingChangesFinderTest.php @@ -1329,7 +1329,7 @@ public function testShouldDetectIfADirectiveWasImplicitlyRemoved(): void $oldSchema = new Schema([]); $newSchema = new Schema([ - 'directives' => [Directive::skipDirective(), Directive::includeDirective()], + 'directives' => [Directive::skipDirective(), Directive::includeDirective(), Directive::specifiedByDirective()], ]); $deprecatedDirective = Directive::deprecatedDirective(); diff --git a/tests/Utils/BuildSchemaTest.php b/tests/Utils/BuildSchemaTest.php index 877fdb3ff..7f29a8073 100644 --- a/tests/Utils/BuildSchemaTest.php +++ b/tests/Utils/BuildSchemaTest.php @@ -277,14 +277,11 @@ public function testMaintainsIncludeSkipAndSpecifiedBy(): void { $schema = BuildSchema::buildAST(Parser::parse('type Query')); - // TODO switch to 5 when adding @specifiedBy - see https://github.com/webonyx/graphql-php/issues/1140 - self::assertCount(4, $schema->getDirectives()); + self::assertCount(5, $schema->getDirectives()); self::assertSame(Directive::skipDirective(), $schema->getDirective('skip')); self::assertSame(Directive::includeDirective(), $schema->getDirective('include')); self::assertSame(Directive::deprecatedDirective(), $schema->getDirective('deprecated')); self::assertSame(Directive::oneOfDirective(), $schema->getDirective('oneOf')); - - self::markTestIncomplete('See https://github.com/webonyx/graphql-php/issues/1140'); self::assertSame(Directive::specifiedByDirective(), $schema->getDirective('specifiedBy')); } @@ -295,7 +292,7 @@ public function testOverridingDirectivesExcludesSpecified(): void directive @skip on FIELD directive @include on FIELD directive @deprecated on FIELD_DEFINITION - directive @specifiedBy on FIELD_DEFINITION + directive @specifiedBy on SCALAR ')); self::assertCount(5, $schema->getDirectives()); @@ -303,8 +300,6 @@ public function testOverridingDirectivesExcludesSpecified(): void self::assertNotEquals(Directive::includeDirective(), $schema->getDirective('include')); self::assertNotEquals(Directive::deprecatedDirective(), $schema->getDirective('deprecated')); self::assertSame(Directive::oneOfDirective(), $schema->getDirective('oneOf')); - - self::markTestIncomplete('See https://github.com/webonyx/graphql-php/issues/1140'); self::assertNotEquals(Directive::specifiedByDirective(), $schema->getDirective('specifiedBy')); } @@ -317,15 +312,12 @@ public function testAddingDirectivesMaintainsIncludeSkipAndSpecifiedBy(): void GRAPHQL; $schema = BuildSchema::buildAST(Parser::parse($sdl)); - // TODO switch to 6 when adding @specifiedBy - see https://github.com/webonyx/graphql-php/issues/1140 - self::assertCount(5, $schema->getDirectives()); + self::assertCount(6, $schema->getDirectives()); self::assertNotNull($schema->getDirective('foo')); self::assertNotNull($schema->getDirective('skip')); self::assertNotNull($schema->getDirective('include')); self::assertNotNull($schema->getDirective('deprecated')); self::assertNotNull($schema->getDirective('oneOf')); - - self::markTestIncomplete('See https://github.com/webonyx/graphql-php/issues/1140'); self::assertNotNull($schema->getDirective('specifiedBy')); } @@ -826,7 +818,6 @@ enum: MyEnum /** @see it('Supports @specifiedBy') */ public function testSupportsSpecifiedBy(): void { - self::markTestSkipped('See https://github.com/webonyx/graphql-php/issues/1140'); $sdl = <<getType('Foo'); - self::assertSame('https://example.com/foo_spec', $schema->getType('Foo')->specifiedByURL); + self::assertInstanceOf(ScalarType::class, $type); + self::assertSame('https://example.com/foo_spec', $type->specifiedByURL); } /** @see it('Correctly extend scalar type') */ diff --git a/tests/Utils/SchemaExtenderTest.php b/tests/Utils/SchemaExtenderTest.php index 16119f5af..ea3c361bc 100644 --- a/tests/Utils/SchemaExtenderTest.php +++ b/tests/Utils/SchemaExtenderTest.php @@ -456,8 +456,6 @@ public function testExtendsScalarsByAddingNewDirectives(): void /** @see it('extends scalars by adding specifiedBy directive') */ public function testExtendsScalarsByAddingSpecifiedByDirective(): void { - // @phpstan-ignore-next-line - $this->markTestSkipped('See https://github.com/webonyx/graphql-php/issues/1140'); $schema = BuildSchema::build(' type Query { foo: Foo @@ -472,8 +470,10 @@ public function testExtendsScalarsByAddingSpecifiedByDirective(): void extend scalar Foo @specifiedBy(url: "https://example.com/foo_spec") GRAPHQL; + $extendedSchema = SchemaExtender::extend($schema, Parser::parse($extensionSDL)); $foo = $extendedSchema->getType('Foo'); + assert($foo instanceof ScalarType); self::assertSame('https://example.com/foo_spec', $foo->specifiedByURL); self::assertEmpty($extendedSchema->validate()); diff --git a/tests/Utils/SchemaPrinterTest.php b/tests/Utils/SchemaPrinterTest.php index 2613adbfd..a96a4ea57 100644 --- a/tests/Utils/SchemaPrinterTest.php +++ b/tests/Utils/SchemaPrinterTest.php @@ -1076,6 +1076,12 @@ public function testPrintIntrospectionSchema(): void "Indicates that an Input Object is a OneOf Input Object (and thus requires exactly one of its fields be provided)." directive @oneOf on INPUT_OBJECT + "Exposes a URL that specifies the behaviour of this scalar." + directive @specifiedBy( + "The URL that specifies the behaviour of this scalar and points to a human-readable specification of the data format, serialization, and coercion rules. It must not appear on built-in scalar types." + url: String! + ) on SCALAR + "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 { description: String diff --git a/tests/Validator/ValidatorTestCase.php b/tests/Validator/ValidatorTestCase.php index eb006cfc0..afab588c5 100644 --- a/tests/Validator/ValidatorTestCase.php +++ b/tests/Validator/ValidatorTestCase.php @@ -367,10 +367,7 @@ public static function getTestSchema(): Schema return new Schema([ 'query' => $queryRoot, 'subscription' => $subscriptionRoot, - 'directives' => [ - Directive::includeDirective(), - Directive::skipDirective(), - Directive::deprecatedDirective(), + 'directives' => array_merge(Directive::getInternalDirectives(), [ new Directive([ 'name' => 'directive', 'locations' => [DirectiveLocation::FIELD, DirectiveLocation::FRAGMENT_DEFINITION], @@ -420,7 +417,7 @@ public static function getTestSchema(): Schema 'name' => 'onVariableDefinition', 'locations' => [DirectiveLocation::VARIABLE_DEFINITION], ]), - ], + ]), ]); } From 1f676907e12a14a6e86f265ed1e11daf3a498d8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 10:35:50 +0000 Subject: [PATCH 02/18] Enable testExtendsDifferentTypesMultipleTimes with specifiedBy directive Agent-Logs-Url: https://github.com/webonyx/graphql-php/sessions/31ffabfc-b1cd-46f4-bf81-7753830e77f2 Co-authored-by: spawnia <12158000+spawnia@users.noreply.github.com> --- tests/Utils/SchemaExtenderTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Utils/SchemaExtenderTest.php b/tests/Utils/SchemaExtenderTest.php index ea3c361bc..5b862018c 100644 --- a/tests/Utils/SchemaExtenderTest.php +++ b/tests/Utils/SchemaExtenderTest.php @@ -1065,9 +1065,9 @@ interface AnotherNewInterface { self::printSchemaChanges($schema, $schemaWithNewTypes) ); - // TODO see https://github.com/webonyx/graphql-php/issues/1140 - // extend scalar SomeScalar @specifiedBy(url: "http://example.com/foo_spec") $extendAST = Parser::parse(' + extend scalar SomeScalar @specifiedBy(url: "http://example.com/foo_spec") + extend type SomeObject implements NewInterface { newField: String } @@ -1100,9 +1100,9 @@ interface AnotherNewInterface { self::assertEmpty($extendedSchema->validate()); self::assertSame( - // TODO see https://github.com/webonyx/graphql-php/issues/1140 - // scalar SomeScalar @specifiedBy(url: \"http://example.com/foo_spec\") << Date: Wed, 13 May 2026 10:55:16 +0000 Subject: [PATCH 03/18] Fix directiveExcludesField and tighten getSpecifiedByURL parameter type Agent-Logs-Url: https://github.com/webonyx/graphql-php/sessions/ba89199d-dc5f-484d-ad1d-983d4fa1a3c8 Co-authored-by: spawnia <12158000+spawnia@users.noreply.github.com> --- src/Utils/ASTDefinitionBuilder.php | 6 ++---- src/Validator/Rules/QueryComplexity.php | 4 ++-- tests/Validator/QueryComplexityTest.php | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/Utils/ASTDefinitionBuilder.php b/src/Utils/ASTDefinitionBuilder.php index 066ef80b1..50cb56e52 100644 --- a/src/Utils/ASTDefinitionBuilder.php +++ b/src/Utils/ASTDefinitionBuilder.php @@ -416,14 +416,12 @@ private function getDeprecationReason(Node $node): ?string } /** - * Given a collection of directives, returns the string value for the specifiedBy URL. - * - * @param ScalarTypeDefinitionNode $node + * Given a scalar type definition, returns the string value for the specifiedBy URL. * * @throws \Exception * @throws InvariantViolation */ - private function getSpecifiedByURL(Node $node): ?string + private function getSpecifiedByURL(ScalarTypeDefinitionNode $node): ?string { $specifiedBy = Values::getDirectiveValues( Directive::specifiedByDirective(), diff --git a/src/Validator/Rules/QueryComplexity.php b/src/Validator/Rules/QueryComplexity.php index 3ef4ec685..6d5b048b9 100644 --- a/src/Validator/Rules/QueryComplexity.php +++ b/src/Validator/Rules/QueryComplexity.php @@ -183,11 +183,11 @@ protected function directiveExcludesField(FieldNode $node): bool { foreach ($node->directives as $directiveNode) { if ($directiveNode->name->value === Directive::DEPRECATED_NAME) { - return false; + continue; } if ($directiveNode->name->value === Directive::SPECIFIED_BY_NAME) { - return false; + continue; } [$errors, $variableValues] = Values::getVariableValues( diff --git a/tests/Validator/QueryComplexityTest.php b/tests/Validator/QueryComplexityTest.php index cbd145a5b..04c2c8b0c 100644 --- a/tests/Validator/QueryComplexityTest.php +++ b/tests/Validator/QueryComplexityTest.php @@ -176,6 +176,21 @@ public function testQueryWithCustomAndSkipDirective(): void $this->assertDocumentValidators($query, 1, 2); } + /** + * Verifies that a non-excluding directive appearing before @skip on the same field + * does not prevent the @skip from being evaluated, avoiding incorrect complexity. + */ + public function testQueryWithNonExcludingDirectiveBeforeSkip(): void + { + // @foo appears before @skip on the same field; @skip(if:true) should still exclude dogs + $query = 'query MyQuery($withoutDogs: Boolean!) { human { dogs(name: "Root") @foo(bar: true) @skip(if:$withoutDogs) { name } } }'; + + $this->getRule()->setRawVariableValues(['withoutDogs' => true]); + + // dogs is excluded by @skip, so complexity is 1 (only human) + $this->assertDocumentValidators($query, 1, 2); + } + public function testComplexityIntrospectionQuery(): void { $query = Introspection::getIntrospectionQuery(); From 507db56436a5abdde81a0fc57bc99f9b9a5b1666 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 11:16:44 +0000 Subject: [PATCH 04/18] Remove unnecessary @deprecated and @specifiedBy guards from directiveExcludesField Agent-Logs-Url: https://github.com/webonyx/graphql-php/sessions/67e190bd-9ad8-4bc7-ac58-927893b6f552 Co-authored-by: spawnia <12158000+spawnia@users.noreply.github.com> --- src/Validator/Rules/QueryComplexity.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/Validator/Rules/QueryComplexity.php b/src/Validator/Rules/QueryComplexity.php index 6d5b048b9..9843710b7 100644 --- a/src/Validator/Rules/QueryComplexity.php +++ b/src/Validator/Rules/QueryComplexity.php @@ -182,14 +182,6 @@ protected function fieldDefinition(FieldNode $field): ?FieldDefinition protected function directiveExcludesField(FieldNode $node): bool { foreach ($node->directives as $directiveNode) { - if ($directiveNode->name->value === Directive::DEPRECATED_NAME) { - continue; - } - - if ($directiveNode->name->value === Directive::SPECIFIED_BY_NAME) { - continue; - } - [$errors, $variableValues] = Values::getVariableValues( $this->context->getSchema(), $this->variableDefs, From ccb57884d766c117358bc2ba7ae655ca2735d9ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 11:35:34 +0000 Subject: [PATCH 05/18] Fix directive ordering and trailing newline in test heredoc Agent-Logs-Url: https://github.com/webonyx/graphql-php/sessions/800a98e7-d1e0-4552-bbe1-56a5a001e77b Co-authored-by: spawnia <12158000+spawnia@users.noreply.github.com> --- src/Type/Definition/Directive.php | 2 +- tests/Utils/SchemaExtenderTest.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Type/Definition/Directive.php b/src/Type/Definition/Directive.php index b4e607492..976d0cd50 100644 --- a/src/Type/Definition/Directive.php +++ b/src/Type/Definition/Directive.php @@ -87,8 +87,8 @@ public static function builtInDirectives(): array self::INCLUDE_NAME => self::includeDirective(), self::SKIP_NAME => self::skipDirective(), self::DEPRECATED_NAME => self::deprecatedDirective(), - self::SPECIFIED_BY_NAME => self::specifiedByDirective(), self::ONE_OF_NAME => self::oneOfDirective(), + self::SPECIFIED_BY_NAME => self::specifiedByDirective(), ]; } diff --git a/tests/Utils/SchemaExtenderTest.php b/tests/Utils/SchemaExtenderTest.php index 5b862018c..e0577f7d3 100644 --- a/tests/Utils/SchemaExtenderTest.php +++ b/tests/Utils/SchemaExtenderTest.php @@ -469,6 +469,7 @@ public function testExtendsScalarsByAddingSpecifiedByDirective(): void extend scalar Foo @foo extend scalar Foo @specifiedBy(url: "https://example.com/foo_spec") + GRAPHQL; $extendedSchema = SchemaExtender::extend($schema, Parser::parse($extensionSDL)); From b23ea61ae228f711c1fd7fa879a4cd9287c3cfb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 14:12:39 +0000 Subject: [PATCH 06/18] Fix directive ordering in BuildSchema.php: append oneOf before specifiedBy Agent-Logs-Url: https://github.com/webonyx/graphql-php/sessions/0c7ffff2-1fb5-408f-8217-31ac2fc73817 Co-authored-by: spawnia <12158000+spawnia@users.noreply.github.com> --- src/Utils/BuildSchema.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Utils/BuildSchema.php b/src/Utils/BuildSchema.php index 863946f92..e6f711a2b 100644 --- a/src/Utils/BuildSchema.php +++ b/src/Utils/BuildSchema.php @@ -231,12 +231,12 @@ static function (string $typeName): Type { if (! isset($directivesByName['deprecated'])) { $directives[] = Directive::deprecatedDirective(); } - if (! isset($directivesByName['specifiedBy'])) { - $directives[] = Directive::specifiedByDirective(); - } if (! isset($directivesByName['oneOf'])) { $directives[] = Directive::oneOfDirective(); } + if (! isset($directivesByName['specifiedBy'])) { + $directives[] = Directive::specifiedByDirective(); + } // Note: While this could make early assertions to get the correctly // typed values below, that would throw immediately while type system From 80b3804e36f19e8eea4a9382acc628ea2fa7fb49 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 14:52:49 +0000 Subject: [PATCH 07/18] Align @specifiedBy with graphql-js: ordering (specifiedBy before oneOf), descriptions (behavior not behaviour) Agent-Logs-Url: https://github.com/webonyx/graphql-php/sessions/5536e145-b412-4cca-9f9a-847ccaa65158 Co-authored-by: spawnia <12158000+spawnia@users.noreply.github.com> --- src/Type/Definition/Directive.php | 6 +++--- src/Utils/BuildSchema.php | 6 +++--- tests/Type/IntrospectionTest.php | 16 ++++++++-------- tests/Utils/SchemaPrinterTest.php | 10 +++++----- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/Type/Definition/Directive.php b/src/Type/Definition/Directive.php index 976d0cd50..5ca82abd5 100644 --- a/src/Type/Definition/Directive.php +++ b/src/Type/Definition/Directive.php @@ -87,8 +87,8 @@ public static function builtInDirectives(): array self::INCLUDE_NAME => self::includeDirective(), self::SKIP_NAME => self::skipDirective(), self::DEPRECATED_NAME => self::deprecatedDirective(), - self::ONE_OF_NAME => self::oneOfDirective(), self::SPECIFIED_BY_NAME => self::specifiedByDirective(), + self::ONE_OF_NAME => self::oneOfDirective(), ]; } @@ -177,14 +177,14 @@ public static function specifiedByDirective(): Directive { return self::$internalDirectives[self::SPECIFIED_BY_NAME] ??= new self([ 'name' => self::SPECIFIED_BY_NAME, - 'description' => 'Exposes a URL that specifies the behaviour of this scalar.', + 'description' => 'Exposes a URL that specifies the behavior of this scalar.', 'locations' => [ DirectiveLocation::SCALAR, ], 'args' => [ self::URL_ARGUMENT_NAME => [ 'type' => Type::nonNull(Type::string()), - 'description' => 'The URL that specifies the behaviour of this scalar and points to a human-readable specification of the data format, serialization, and coercion rules. It must not appear on built-in scalar types.', + 'description' => 'The URL that specifies the behavior of this scalar.', ], ], ]); diff --git a/src/Utils/BuildSchema.php b/src/Utils/BuildSchema.php index e6f711a2b..863946f92 100644 --- a/src/Utils/BuildSchema.php +++ b/src/Utils/BuildSchema.php @@ -231,12 +231,12 @@ static function (string $typeName): Type { if (! isset($directivesByName['deprecated'])) { $directives[] = Directive::deprecatedDirective(); } - if (! isset($directivesByName['oneOf'])) { - $directives[] = Directive::oneOfDirective(); - } if (! isset($directivesByName['specifiedBy'])) { $directives[] = Directive::specifiedByDirective(); } + if (! isset($directivesByName['oneOf'])) { + $directives[] = Directive::oneOfDirective(); + } // Note: While this could make early assertions to get the correctly // typed values below, that would throw immediately while type system diff --git a/tests/Type/IntrospectionTest.php b/tests/Type/IntrospectionTest.php index 6af4ddeff..265d73bea 100644 --- a/tests/Type/IntrospectionTest.php +++ b/tests/Type/IntrospectionTest.php @@ -1033,14 +1033,6 @@ public function testExecutesAnIntrospectionQuery(): void 3 => 'INPUT_FIELD_DEFINITION', ], ], - [ - 'name' => 'oneOf', - 'args' => [], - 'isRepeatable' => false, - 'locations' => [ - 0 => 'INPUT_OBJECT', - ], - ], [ 'name' => 'specifiedBy', 'args' => [ @@ -1065,6 +1057,14 @@ public function testExecutesAnIntrospectionQuery(): void 0 => 'SCALAR', ], ], + [ + 'name' => 'oneOf', + 'args' => [], + 'isRepeatable' => false, + 'locations' => [ + 0 => 'INPUT_OBJECT', + ], + ], ], ], ], diff --git a/tests/Utils/SchemaPrinterTest.php b/tests/Utils/SchemaPrinterTest.php index a96a4ea57..c08114616 100644 --- a/tests/Utils/SchemaPrinterTest.php +++ b/tests/Utils/SchemaPrinterTest.php @@ -1073,15 +1073,15 @@ public function testPrintIntrospectionSchema(): void reason: String = "No longer supported" ) on FIELD_DEFINITION | ENUM_VALUE | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION - "Indicates that an Input Object is a OneOf Input Object (and thus requires exactly one of its fields be provided)." - directive @oneOf on INPUT_OBJECT - - "Exposes a URL that specifies the behaviour of this scalar." + "Exposes a URL that specifies the behavior of this scalar." directive @specifiedBy( - "The URL that specifies the behaviour of this scalar and points to a human-readable specification of the data format, serialization, and coercion rules. It must not appear on built-in scalar types." + "The URL that specifies the behavior of this scalar." url: String! ) on SCALAR + "Indicates that an Input Object is a OneOf Input Object (and thus requires exactly one of its fields be provided)." + directive @oneOf on INPUT_OBJECT + "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 { description: String From e11c9a99dbd4aec19f6d2982b3e98de6b446502e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 16:04:37 +0000 Subject: [PATCH 08/18] Add changelog entry and docs for @specifiedBy directive support Agent-Logs-Url: https://github.com/webonyx/graphql-php/sessions/a454874d-745e-4ffe-9851-d04259f6106d Co-authored-by: spawnia <12158000+spawnia@users.noreply.github.com> --- CHANGELOG.md | 4 ++++ docs/type-definitions/directives.md | 5 ++++- docs/type-definitions/scalars.md | 30 +++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f55a42c31..9feab6a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +### Added + +- Add `@specifiedBy` built-in directive and `ScalarType::$specifiedByURL` property https://github.com/webonyx/graphql-php/pull/1913 + ## v15.32.3 ### Fixed diff --git a/docs/type-definitions/directives.md b/docs/type-definitions/directives.md index b0bef2536..b62208e1e 100644 --- a/docs/type-definitions/directives.md +++ b/docs/type-definitions/directives.md @@ -4,10 +4,13 @@ The directive is a way for a client to give GraphQL server additional context an the query. The directive can be attached to a field or fragment and can affect the execution of the query in any way the server desires. -GraphQL specification includes two built-in directives: +GraphQL specification includes the following built-in directives: - **@include(if: Boolean)** Only include this field or fragment in the result if the argument is **true** - **@skip(if: Boolean)** Skip this field or fragment if the argument is **true** +- **@deprecated(reason: String)** Marks a field or enum value as deprecated with an optional reason +- **@specifiedBy(url: String!)** Links a custom scalar type to a human-readable specification URL +- **@oneOf** Marks an input object type as a "oneof" input, requiring exactly one field to be non-null For example: diff --git a/docs/type-definitions/scalars.md b/docs/type-definitions/scalars.md index 09cc82a35..c7435387f 100644 --- a/docs/type-definitions/scalars.md +++ b/docs/type-definitions/scalars.md @@ -107,6 +107,36 @@ $emailType = new CustomScalarType([ Keep in mind the passed functions will be called statically, so a passed in `callable` such as `[Foo::class, 'bar']` should only reference static class methods. +## Linking a Scalar to Its Specification + +The `@specifiedBy` built-in directive lets you attach a URL to a custom scalar that points to a +human-readable specification describing the scalar's serialization format. +This URL is exposed through introspection and printed in SDL output. + +Pass `specifiedByURL` when constructing the type: + +```php +use GraphQL\Type\Definition\CustomScalarType; + +$uuidType = new CustomScalarType([ + 'name' => 'UUID', + 'specifiedByURL' => 'https://tools.ietf.org/html/rfc4122', + 'serialize' => static function ($value) { /* ... */ }, + 'parseValue' => static function ($value) { /* ... */ }, + 'parseLiteral' => static function ($valueNode, ?array $variables = null) { /* ... */ }, +]); + +$uuidType->specifiedByURL; // "https://tools.ietf.org/html/rfc4122" +``` + +When building a schema from SDL, `@specifiedBy` is parsed automatically: + +```graphql +scalar UUID @specifiedBy(url: "https://tools.ietf.org/html/rfc4122") +``` + +And `SchemaPrinter` will emit the directive when printing a schema that contains a scalar with a `specifiedByURL`. + ## Overriding Built-in Scalars You can override built-in scalars (`String`, `Int`, `Float`, `Boolean`, `ID`) on a per-schema basis by passing a custom scalar with the same name through the `types` option. From 9f2f58154c67cdf78dbf366f88e738c4bfc6f8dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 16:22:37 +0000 Subject: [PATCH 09/18] Re-fix description in scalars doc to say behavior instead of serialization format Agent-Logs-Url: https://github.com/webonyx/graphql-php/sessions/3de1d117-87f7-432f-9570-1d0e7e80a0aa Co-authored-by: spawnia <12158000+spawnia@users.noreply.github.com> --- docs/type-definitions/scalars.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/type-definitions/scalars.md b/docs/type-definitions/scalars.md index c7435387f..55b71ec7c 100644 --- a/docs/type-definitions/scalars.md +++ b/docs/type-definitions/scalars.md @@ -109,8 +109,7 @@ such as `[Foo::class, 'bar']` should only reference static class methods. ## Linking a Scalar to Its Specification -The `@specifiedBy` built-in directive lets you attach a URL to a custom scalar that points to a -human-readable specification describing the scalar's serialization format. +The `@specifiedBy` built-in directive lets you attach a URL to a custom scalar that specifies the behavior of that scalar. This URL is exposed through introspection and printed in SDL output. Pass `specifiedByURL` when constructing the type: From 22c0ab1c53ced74057c2651ca453f9f9b3418d3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 16:46:43 +0000 Subject: [PATCH 10/18] Address review comments: introspection specifiedByURL, custom directive override safety, multi-directive exclusion fix Agent-Logs-Url: https://github.com/webonyx/graphql-php/sessions/c6abb623-f0b2-4dfd-b679-95b8fba77e7d Co-authored-by: spawnia <12158000+spawnia@users.noreply.github.com> --- src/Type/Introspection.php | 6 +++ src/Utils/ASTDefinitionBuilder.php | 29 +++++++----- src/Utils/SchemaExtender.php | 18 +++++--- src/Validator/Rules/QueryComplexity.php | 26 ++++++----- tests/Type/IntrospectionTest.php | 60 ++++++++++++++++++++++--- tests/Utils/BuildSchemaTest.php | 27 +++++++++++ tests/Utils/SchemaExtenderTest.php | 25 +++++++++++ tests/Utils/SchemaPrinterTest.php | 1 + tests/Validator/QueryComplexityTest.php | 15 +++++++ 9 files changed, 174 insertions(+), 33 deletions(-) diff --git a/src/Type/Introspection.php b/src/Type/Introspection.php index 806658611..6eaf1a41c 100644 --- a/src/Type/Introspection.php +++ b/src/Type/Introspection.php @@ -361,6 +361,12 @@ public static function _type(): ObjectType ? $type->description : null, ], + 'specifiedByURL' => [ + 'type' => Type::string(), + 'resolve' => static fn (Type $type): ?string => $type instanceof ScalarType + ? $type->specifiedByURL + : null, + ], 'fields' => [ 'type' => Type::listOf(Type::nonNull(self::_field())), 'args' => [ diff --git a/src/Utils/ASTDefinitionBuilder.php b/src/Utils/ASTDefinitionBuilder.php index 50cb56e52..ec058d5c3 100644 --- a/src/Utils/ASTDefinitionBuilder.php +++ b/src/Utils/ASTDefinitionBuilder.php @@ -24,6 +24,7 @@ use GraphQL\Language\AST\ObjectTypeExtensionNode; use GraphQL\Language\AST\ScalarTypeDefinitionNode; use GraphQL\Language\AST\ScalarTypeExtensionNode; +use GraphQL\Language\AST\StringValueNode; use GraphQL\Language\AST\TypeDefinitionNode; use GraphQL\Language\AST\TypeExtensionNode; use GraphQL\Language\AST\TypeNode; @@ -418,17 +419,26 @@ private function getDeprecationReason(Node $node): ?string /** * Given a scalar type definition, returns the string value for the specifiedBy URL. * - * @throws \Exception - * @throws InvariantViolation + * Reads the URL directly from the AST to avoid coercing arguments against the + * built-in directive definition, which would throw if the schema has overridden + * @specifiedBy with a custom directive that uses different arguments. */ private function getSpecifiedByURL(ScalarTypeDefinitionNode $node): ?string { - $specifiedBy = Values::getDirectiveValues( - Directive::specifiedByDirective(), - $node - ); + foreach ($node->directives as $directive) { + if ($directive->name->value !== Directive::SPECIFIED_BY_NAME) { + continue; + } + + foreach ($directive->arguments as $argument) { + if ($argument->name->value === Directive::URL_ARGUMENT_NAME + && $argument->value instanceof StringValueNode) { + return $argument->value->value; + } + } + } - return $specifiedBy['url'] ?? null; + return null; } /** @@ -535,10 +545,7 @@ private function makeUnionDef(UnionTypeDefinitionNode $def): UnionType ]); } - /** - * @throws \Exception - * @throws InvariantViolation - */ + /** @throws InvariantViolation */ private function makeScalarDef(ScalarTypeDefinitionNode $def): CustomScalarType { $name = $def->name->value; diff --git a/src/Utils/SchemaExtender.php b/src/Utils/SchemaExtender.php index 1643a156f..5691edb9f 100644 --- a/src/Utils/SchemaExtender.php +++ b/src/Utils/SchemaExtender.php @@ -4,7 +4,6 @@ use GraphQL\Error\Error; use GraphQL\Error\InvariantViolation; -use GraphQL\Executor\Values; use GraphQL\Language\AST\DirectiveDefinitionNode; use GraphQL\Language\AST\DocumentNode; use GraphQL\Language\AST\EnumTypeExtensionNode; @@ -14,6 +13,7 @@ use GraphQL\Language\AST\ObjectTypeExtensionNode; use GraphQL\Language\AST\ScalarTypeExtensionNode; use GraphQL\Language\AST\SchemaDefinitionNode; +use GraphQL\Language\AST\StringValueNode; use GraphQL\Language\AST\SchemaExtensionNode; use GraphQL\Language\AST\TypeDefinitionNode; use GraphQL\Language\AST\TypeExtensionNode; @@ -230,10 +230,18 @@ protected function extendScalarType(ScalarType $type): CustomScalarType $specifiedByURL = $type->specifiedByURL; if ($specifiedByURL === null) { foreach ($extensionASTNodes as $extensionNode) { - $specifiedBy = Values::getDirectiveValues(Directive::specifiedByDirective(), $extensionNode); - if ($specifiedBy !== null) { - $specifiedByURL = $specifiedBy['url'] ?? null; - break; + foreach ($extensionNode->directives as $directive) { + if ($directive->name->value !== Directive::SPECIFIED_BY_NAME) { + continue; + } + + foreach ($directive->arguments as $argument) { + if ($argument->name->value === Directive::URL_ARGUMENT_NAME + && $argument->value instanceof StringValueNode) { + $specifiedByURL = $argument->value->value; + break 3; + } + } } } } diff --git a/src/Validator/Rules/QueryComplexity.php b/src/Validator/Rules/QueryComplexity.php index 9843710b7..9e7010c2a 100644 --- a/src/Validator/Rules/QueryComplexity.php +++ b/src/Validator/Rules/QueryComplexity.php @@ -181,16 +181,16 @@ protected function fieldDefinition(FieldNode $field): ?FieldDefinition */ protected function directiveExcludesField(FieldNode $node): bool { - foreach ($node->directives as $directiveNode) { - [$errors, $variableValues] = Values::getVariableValues( - $this->context->getSchema(), - $this->variableDefs, - $this->getRawVariableValues() - ); - if ($errors !== null && $errors !== []) { - throw new Error(implode("\n\n", array_map(static fn (Error $error): string => $error->getMessage(), $errors))); - } + [$errors, $variableValues] = Values::getVariableValues( + $this->context->getSchema(), + $this->variableDefs, + $this->getRawVariableValues() + ); + if ($errors !== null && $errors !== []) { + throw new Error(implode("\n\n", array_map(static fn (Error $error): string => $error->getMessage(), $errors))); + } + foreach ($node->directives as $directiveNode) { if ($directiveNode->name->value === Directive::INCLUDE_NAME) { $includeArguments = Values::getArgumentValues( Directive::includeDirective(), @@ -199,7 +199,9 @@ protected function directiveExcludesField(FieldNode $node): bool ); assert(is_bool($includeArguments['if']), 'ensured by query validation'); - return ! $includeArguments['if']; + if (! $includeArguments['if']) { + return true; + } } if ($directiveNode->name->value === Directive::SKIP_NAME) { @@ -210,7 +212,9 @@ protected function directiveExcludesField(FieldNode $node): bool ); assert(is_bool($skipArguments['if']), 'ensured by query validation'); - return $skipArguments['if']; + if ($skipArguments['if']) { + return true; + } } } diff --git a/tests/Type/IntrospectionTest.php b/tests/Type/IntrospectionTest.php index 265d73bea..33131e7ed 100644 --- a/tests/Type/IntrospectionTest.php +++ b/tests/Type/IntrospectionTest.php @@ -11,6 +11,7 @@ use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\InputObjectType; +use GraphQL\Type\Definition\CustomScalarType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; @@ -246,6 +247,17 @@ public function testExecutesAnIntrospectionQuery(): void 'deprecationReason' => null, ], 3 => [ + 'name' => 'specifiedByURL', + 'args' => [], + 'type' => [ + 'kind' => 'SCALAR', + 'name' => 'String', + 'ofType' => null, + ], + 'isDeprecated' => false, + 'deprecationReason' => null, + ], + 4 => [ 'name' => 'fields', 'args' => [ 0 => [ @@ -280,7 +292,7 @@ public function testExecutesAnIntrospectionQuery(): void 'isDeprecated' => false, 'deprecationReason' => null, ], - 4 => [ + 5 => [ 'name' => 'interfaces', 'args' => [], 'type' => [ @@ -299,7 +311,7 @@ public function testExecutesAnIntrospectionQuery(): void 'isDeprecated' => false, 'deprecationReason' => null, ], - 5 => [ + 6 => [ 'name' => 'possibleTypes', 'args' => [], 'type' => [ @@ -318,7 +330,7 @@ public function testExecutesAnIntrospectionQuery(): void 'isDeprecated' => false, 'deprecationReason' => null, ], - 6 => [ + 7 => [ 'name' => 'enumValues', 'args' => [ 0 => [ @@ -353,7 +365,7 @@ public function testExecutesAnIntrospectionQuery(): void 'isDeprecated' => false, 'deprecationReason' => null, ], - 7 => [ + 8 => [ 'name' => 'inputFields', 'args' => [ 0 => [ @@ -388,7 +400,7 @@ public function testExecutesAnIntrospectionQuery(): void 'isDeprecated' => false, 'deprecationReason' => null, ], - 8 => [ + 9 => [ 'name' => 'ofType', 'args' => [], 'type' => [ @@ -399,7 +411,7 @@ public function testExecutesAnIntrospectionQuery(): void 'isDeprecated' => false, 'deprecationReason' => null, ], - 9 => [ + 10 => [ 'name' => 'isOneOf', 'args' => [], 'type' => [ @@ -2032,6 +2044,42 @@ public function testRespectsTheIncludeDeprecatedParameterForDirectiveArgs(): voi } /** @see it('include "description" field on schema') */ + public function testSpecifiedByURL(): void + { + $scalarType = new CustomScalarType([ + 'name' => 'UUID', + 'specifiedByURL' => 'https://tools.ietf.org/html/rfc4122', + 'serialize' => static fn ($value) => $value, + ]); + + $query = new ObjectType([ + 'name' => 'QueryRoot', + 'fields' => [ + 'uuid' => ['type' => $scalarType], + ], + ]); + + $schema = new Schema(['query' => $query]); + + $result = GraphQL::executeQuery($schema, '{ __type(name: "UUID") { specifiedByURL } }')->toArray(); + + self::assertSame(['data' => ['__type' => ['specifiedByURL' => 'https://tools.ietf.org/html/rfc4122']]], $result); + } + + public function testSpecifiedByURLIsNullForNonScalars(): void + { + $query = new ObjectType([ + 'name' => 'QueryRoot', + 'fields' => ['x' => ['type' => Type::string()]], + ]); + + $schema = new Schema(['query' => $query]); + + $result = GraphQL::executeQuery($schema, '{ __type(name: "QueryRoot") { specifiedByURL } }')->toArray(); + + self::assertSame(['data' => ['__type' => ['specifiedByURL' => null]]], $result); + } + public function testIncludeDescriptionFieldOnSchema(): void { preg_match_all('/\bdescription\b/', Introspection::getIntrospectionQuery(), $matches); diff --git a/tests/Utils/BuildSchemaTest.php b/tests/Utils/BuildSchemaTest.php index 7f29a8073..5b1ea5469 100644 --- a/tests/Utils/BuildSchemaTest.php +++ b/tests/Utils/BuildSchemaTest.php @@ -836,6 +836,33 @@ public function testSupportsSpecifiedBy(): void self::assertSame('https://example.com/foo_spec', $type->specifiedByURL); } + /** + * Verifies that overriding @specifiedBy with a custom directive (no url arg) does not throw + * when building a schema with a scalar that uses the custom directive. + */ + public function testCustomSpecifiedByDirectiveOverrideShouldNotThrow(): void + { + // A schema that overrides @specifiedBy with a different (arg-less) definition + // and uses it on a scalar should not throw during schema building. + $sdl = <<getType('Foo'); + + self::assertInstanceOf(ScalarType::class, $type); + // When the built-in @specifiedBy is overridden with no url arg, specifiedByURL stays null + self::assertNull($type->specifiedByURL); + } + /** @see it('Correctly extend scalar type') */ public function testCorrectlyExtendScalarType(): void { diff --git a/tests/Utils/SchemaExtenderTest.php b/tests/Utils/SchemaExtenderTest.php index e0577f7d3..2035f2f43 100644 --- a/tests/Utils/SchemaExtenderTest.php +++ b/tests/Utils/SchemaExtenderTest.php @@ -484,6 +484,31 @@ public function testExtendsScalarsByAddingSpecifiedByDirective(): void ); } + /** + * Verifies that overriding @specifiedBy with a custom directive (no url arg) + * and using it in an extension does not throw. + */ + public function testExtendsScalarsWithCustomSpecifiedByOverrideShouldNotThrow(): void + { + $schema = BuildSchema::build(' + directive @specifiedBy on SCALAR + + type Query { + foo: Foo + } + + scalar Foo + '); + $extendedSchema = SchemaExtender::extend($schema, Parser::parse(' + extend scalar Foo @specifiedBy + ')); + $foo = $extendedSchema->getType('Foo'); + assert($foo instanceof ScalarType); + + // Custom @specifiedBy without url arg leaves specifiedByURL as null + self::assertNull($foo->specifiedByURL); + } + /** @see it('correctly assign AST nodes to new and extended types') */ public function testCorrectlyAssignASTNodesToNewAndExtendedTypes(): void { diff --git a/tests/Utils/SchemaPrinterTest.php b/tests/Utils/SchemaPrinterTest.php index c08114616..4616b4b85 100644 --- a/tests/Utils/SchemaPrinterTest.php +++ b/tests/Utils/SchemaPrinterTest.php @@ -1111,6 +1111,7 @@ public function testPrintIntrospectionSchema(): void kind: __TypeKind! name: String description: String + specifiedByURL: String fields(includeDeprecated: Boolean! = false): [__Field!] interfaces: [__Type!] possibleTypes: [__Type!] diff --git a/tests/Validator/QueryComplexityTest.php b/tests/Validator/QueryComplexityTest.php index 04c2c8b0c..22d971505 100644 --- a/tests/Validator/QueryComplexityTest.php +++ b/tests/Validator/QueryComplexityTest.php @@ -191,6 +191,21 @@ public function testQueryWithNonExcludingDirectiveBeforeSkip(): void $this->assertDocumentValidators($query, 1, 2); } + /** + * Verifies that @include(if:true) followed by @skip(if:true) on the same field correctly excludes it. + * Without evaluating all directives, returning early on @include(if:true) would yield the wrong result. + */ + public function testQueryWithIncludeAndSkipDirectives(): void + { + // @include(if:true) alone would include the field, but @skip(if:true) should still exclude it + $query = 'query MyQuery($withDogs: Boolean!, $withoutDogs: Boolean!) { human { dogs(name: "Root") @include(if:$withDogs) @skip(if:$withoutDogs) { name } } }'; + + $this->getRule()->setRawVariableValues(['withDogs' => true, 'withoutDogs' => true]); + + // dogs is excluded by @skip(if:true), so complexity is 1 (only human) + $this->assertDocumentValidators($query, 1, 2); + } + public function testComplexityIntrospectionQuery(): void { $query = Introspection::getIntrospectionQuery(); From b9f6ff786616acd5399deb8e9f11a93a5e6c9ac8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 17:10:09 +0000 Subject: [PATCH 11/18] Fix code style issues found by autofix.ci: import ordering and docblock formatting Agent-Logs-Url: https://github.com/webonyx/graphql-php/sessions/5e8edd36-9aff-44ed-b48d-b9d2f3baee0f Co-authored-by: spawnia <12158000+spawnia@users.noreply.github.com> --- src/Utils/ASTDefinitionBuilder.php | 2 +- src/Utils/SchemaExtender.php | 2 +- tests/Type/IntrospectionTest.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Utils/ASTDefinitionBuilder.php b/src/Utils/ASTDefinitionBuilder.php index ec058d5c3..e312bb9fa 100644 --- a/src/Utils/ASTDefinitionBuilder.php +++ b/src/Utils/ASTDefinitionBuilder.php @@ -421,7 +421,7 @@ private function getDeprecationReason(Node $node): ?string * * Reads the URL directly from the AST to avoid coercing arguments against the * built-in directive definition, which would throw if the schema has overridden - * @specifiedBy with a custom directive that uses different arguments. + * the built-in with a custom directive that uses different arguments. */ private function getSpecifiedByURL(ScalarTypeDefinitionNode $node): ?string { diff --git a/src/Utils/SchemaExtender.php b/src/Utils/SchemaExtender.php index 5691edb9f..8a3155c55 100644 --- a/src/Utils/SchemaExtender.php +++ b/src/Utils/SchemaExtender.php @@ -13,8 +13,8 @@ use GraphQL\Language\AST\ObjectTypeExtensionNode; use GraphQL\Language\AST\ScalarTypeExtensionNode; use GraphQL\Language\AST\SchemaDefinitionNode; -use GraphQL\Language\AST\StringValueNode; use GraphQL\Language\AST\SchemaExtensionNode; +use GraphQL\Language\AST\StringValueNode; use GraphQL\Language\AST\TypeDefinitionNode; use GraphQL\Language\AST\TypeExtensionNode; use GraphQL\Language\AST\UnionTypeExtensionNode; diff --git a/tests/Type/IntrospectionTest.php b/tests/Type/IntrospectionTest.php index 33131e7ed..ef4d66574 100644 --- a/tests/Type/IntrospectionTest.php +++ b/tests/Type/IntrospectionTest.php @@ -7,11 +7,11 @@ use GraphQL\Language\DirectiveLocation; use GraphQL\Language\SourceLocation; use GraphQL\Tests\ErrorHelper; +use GraphQL\Type\Definition\CustomScalarType; use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\InputObjectType; -use GraphQL\Type\Definition\CustomScalarType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; From d615104f6a181b890d3688f3863522cdf324f613 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 06:52:13 +0000 Subject: [PATCH 12/18] Address review comments: lazy variable coercion, extension node @specifiedBy, introspection specifiedByURL option, BuildClientSchema, docs Agent-Logs-Url: https://github.com/webonyx/graphql-php/sessions/2c5519b7-56bc-4124-b888-c095d75e8fb3 Co-authored-by: spawnia <12158000+spawnia@users.noreply.github.com> --- docs/type-definitions/directives.md | 2 +- src/Type/Introspection.php | 13 ++++++++ src/Utils/ASTDefinitionBuilder.php | 30 +++++++++-------- src/Utils/BuildClientSchema.php | 3 +- src/Validator/Rules/QueryComplexity.php | 43 ++++++++++++++++++------- 5 files changed, 64 insertions(+), 27 deletions(-) diff --git a/docs/type-definitions/directives.md b/docs/type-definitions/directives.md index b62208e1e..2fb56f2f7 100644 --- a/docs/type-definitions/directives.md +++ b/docs/type-definitions/directives.md @@ -8,7 +8,7 @@ GraphQL specification includes the following built-in directives: - **@include(if: Boolean)** Only include this field or fragment in the result if the argument is **true** - **@skip(if: Boolean)** Skip this field or fragment if the argument is **true** -- **@deprecated(reason: String)** Marks a field or enum value as deprecated with an optional reason +- **@deprecated(reason: String)** Marks a field, argument, input field, or enum value as deprecated with an optional reason - **@specifiedBy(url: String!)** Links a custom scalar type to a human-readable specification URL - **@oneOf** Marks an input object type as a "oneof" input, requiring exactly one field to be non-null diff --git a/src/Type/Introspection.php b/src/Type/Introspection.php index 6eaf1a41c..648540ea6 100644 --- a/src/Type/Introspection.php +++ b/src/Type/Introspection.php @@ -31,6 +31,7 @@ * descriptions?: bool, * directiveIsRepeatable?: bool, * schemaDescription?: bool, + * specifiedByURL?: bool, * typeIsOneOf?: bool, * } * @@ -41,6 +42,12 @@ * - directiveIsRepeatable * Include field `isRepeatable` for directives? * Default: false + * - schemaDescription + * Include `description` on the schema? + * Default: false + * - specifiedByURL + * Include field `specifiedByURL` for scalar types? + * Default: false * - typeIsOneOf * Include field `isOneOf` for types? * Default: false @@ -87,6 +94,7 @@ public static function getIntrospectionQuery(array $options = []): string 'descriptions' => true, 'directiveIsRepeatable' => false, 'schemaDescription' => false, + 'specifiedByURL' => false, 'typeIsOneOf' => false, ], $options); @@ -99,6 +107,9 @@ public static function getIntrospectionQuery(array $options = []): string $schemaDescription = $optionsWithDefaults['schemaDescription'] ? $descriptions : ''; + $specifiedByURL = $optionsWithDefaults['specifiedByURL'] + ? 'specifiedByURL' + : ''; $typeIsOneOf = $optionsWithDefaults['typeIsOneOf'] ? 'isOneOf' : ''; @@ -129,6 +140,7 @@ public static function getIntrospectionQuery(array $options = []): string kind name {$descriptions} + {$specifiedByURL} {$typeIsOneOf} fields(includeDeprecated: true) { name @@ -227,6 +239,7 @@ public static function fromSchema(Schema $schema, array $options = []): array $optionsWithDefaults = array_merge([ 'directiveIsRepeatable' => true, 'schemaDescription' => true, + 'specifiedByURL' => true, 'typeIsOneOf' => true, ], $options); diff --git a/src/Utils/ASTDefinitionBuilder.php b/src/Utils/ASTDefinitionBuilder.php index e312bb9fa..9ccb1c135 100644 --- a/src/Utils/ASTDefinitionBuilder.php +++ b/src/Utils/ASTDefinitionBuilder.php @@ -417,23 +417,25 @@ private function getDeprecationReason(Node $node): ?string } /** - * Given a scalar type definition, returns the string value for the specifiedBy URL. + * Returns the specifiedBy URL from a scalar type's definition and extension directives, + * reading directly from the AST to safely handle custom @specifiedBy directive definitions + * with different argument shapes. * - * Reads the URL directly from the AST to avoid coercing arguments against the - * built-in directive definition, which would throw if the schema has overridden - * the built-in with a custom directive that uses different arguments. + * @param array $extensionNodes */ - private function getSpecifiedByURL(ScalarTypeDefinitionNode $node): ?string + private function getSpecifiedByURL(ScalarTypeDefinitionNode $def, array $extensionNodes = []): ?string { - foreach ($node->directives as $directive) { - if ($directive->name->value !== Directive::SPECIFIED_BY_NAME) { - continue; - } + foreach ([$def, ...$extensionNodes] as $node) { + foreach ($node->directives as $directive) { + if ($directive->name->value !== Directive::SPECIFIED_BY_NAME) { + continue; + } - foreach ($directive->arguments as $argument) { - if ($argument->name->value === Directive::URL_ARGUMENT_NAME - && $argument->value instanceof StringValueNode) { - return $argument->value->value; + foreach ($directive->arguments as $argument) { + if ($argument->name->value === Directive::URL_ARGUMENT_NAME + && $argument->value instanceof StringValueNode) { + return $argument->value->value; + } } } } @@ -558,7 +560,7 @@ private function makeScalarDef(ScalarTypeDefinitionNode $def): CustomScalarType 'serialize' => static fn ($value) => $value, 'astNode' => $def, 'extensionASTNodes' => $extensionASTNodes, - 'specifiedByURL' => $this->getSpecifiedByURL($def), + 'specifiedByURL' => $this->getSpecifiedByURL($def, $extensionASTNodes), ]); } diff --git a/src/Utils/BuildClientSchema.php b/src/Utils/BuildClientSchema.php index 40845936a..33276c04b 100644 --- a/src/Utils/BuildClientSchema.php +++ b/src/Utils/BuildClientSchema.php @@ -314,7 +314,7 @@ private function buildType(array $type): NamedType } /** - * @param array $scalar + * @param array $scalar * * @throws InvariantViolation */ @@ -323,6 +323,7 @@ private function buildScalarDef(array $scalar): ScalarType return new CustomScalarType([ 'name' => $scalar['name'], 'description' => $scalar['description'], + 'specifiedByURL' => $scalar['specifiedByURL'] ?? null, 'serialize' => static fn ($value) => $value, ]); } diff --git a/src/Validator/Rules/QueryComplexity.php b/src/Validator/Rules/QueryComplexity.php index 9e7010c2a..231adc500 100644 --- a/src/Validator/Rules/QueryComplexity.php +++ b/src/Validator/Rules/QueryComplexity.php @@ -42,6 +42,9 @@ class QueryComplexity extends QuerySecurityRule protected QueryValidationContext $context; + /** @var array|null Lazily coerced variable values; reset per document. */ + private ?array $coercedVariableValues = null; + /** @throws \InvalidArgumentException */ public function __construct(int $maxQueryComplexity) { @@ -54,6 +57,7 @@ public function getVisitor(QueryValidationContext $context): array $this->context = $context; $this->variableDefs = new NodeList([]); $this->fieldNodeAndDefs = new \ArrayObject(); + $this->coercedVariableValues = null; return $this->invokeIfNeeded( $context, @@ -181,21 +185,12 @@ protected function fieldDefinition(FieldNode $field): ?FieldDefinition */ protected function directiveExcludesField(FieldNode $node): bool { - [$errors, $variableValues] = Values::getVariableValues( - $this->context->getSchema(), - $this->variableDefs, - $this->getRawVariableValues() - ); - if ($errors !== null && $errors !== []) { - throw new Error(implode("\n\n", array_map(static fn (Error $error): string => $error->getMessage(), $errors))); - } - foreach ($node->directives as $directiveNode) { if ($directiveNode->name->value === Directive::INCLUDE_NAME) { $includeArguments = Values::getArgumentValues( Directive::includeDirective(), $directiveNode, - $variableValues + $this->getCoercedVariableValues() ); assert(is_bool($includeArguments['if']), 'ensured by query validation'); @@ -208,7 +203,7 @@ protected function directiveExcludesField(FieldNode $node): bool $skipArguments = Values::getArgumentValues( Directive::skipDirective(), $directiveNode, - $variableValues + $this->getCoercedVariableValues() ); assert(is_bool($skipArguments['if']), 'ensured by query validation'); @@ -221,6 +216,32 @@ protected function directiveExcludesField(FieldNode $node): bool return false; } + /** + * Coerce variable values once per document and cache them. + * + * @throws \Exception + * @throws InvariantViolation + * + * @return array + */ + private function getCoercedVariableValues(): array + { + if ($this->coercedVariableValues !== null) { + return $this->coercedVariableValues; + } + + [$errors, $variableValues] = Values::getVariableValues( + $this->context->getSchema(), + $this->variableDefs, + $this->getRawVariableValues() + ); + if ($errors !== null && $errors !== []) { + throw new Error(implode("\n\n", array_map(static fn (Error $error): string => $error->getMessage(), $errors))); + } + + return $this->coercedVariableValues = $variableValues ?? []; + } + /** @return array */ public function getRawVariableValues(): array { From e3b7d7f98c03ad30155cdcb066d35a66d72c6941 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 07:35:20 +0000 Subject: [PATCH 13/18] Add tests for @specifiedBy extension nodes, introspection specifiedByURL option, BuildClientSchema round-trip, and lazy variable coercion Agent-Logs-Url: https://github.com/webonyx/graphql-php/sessions/056e7e07-5cd1-4d0a-8278-3c0e03334605 Co-authored-by: spawnia <12158000+spawnia@users.noreply.github.com> --- tests/Type/IntrospectionTest.php | 41 +++++++++++++++++++++++++ tests/Utils/BuildClientSchemaTest.php | 38 +++++++++++++++++++++++ tests/Utils/BuildSchemaTest.php | 24 +++++++++++++++ tests/Validator/QueryComplexityTest.php | 39 +++++++++++++++++++++++ 4 files changed, 142 insertions(+) diff --git a/tests/Type/IntrospectionTest.php b/tests/Type/IntrospectionTest.php index ef4d66574..8a739a8aa 100644 --- a/tests/Type/IntrospectionTest.php +++ b/tests/Type/IntrospectionTest.php @@ -2094,4 +2094,45 @@ public function testIncludeDescriptionFieldOnSchema(): void preg_match_all('/\bdescription\b/', Introspection::getIntrospectionQuery(['descriptions' => false, 'schemaDescription' => true]), $matches); self::assertCount(0, $matches[0]); } + + public function testSpecifiedByURLNotIncludedInIntrospectionQueryByDefault(): void + { + self::assertStringNotContainsString('specifiedByURL', Introspection::getIntrospectionQuery()); + } + + public function testSpecifiedByURLIncludedInIntrospectionQueryWhenEnabled(): void + { + self::assertStringContainsString('specifiedByURL', Introspection::getIntrospectionQuery(['specifiedByURL' => true])); + } + + public function testFromSchemaIncludesSpecifiedByURLByDefault(): void + { + $scalarType = new CustomScalarType([ + 'name' => 'UUID', + 'specifiedByURL' => 'https://tools.ietf.org/html/rfc4122', + 'serialize' => static fn ($value) => $value, + ]); + + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => ['uuid' => ['type' => $scalarType]], + ]), + ]); + + $introspection = Introspection::fromSchema($schema); + + // Find the UUID type in the introspection result + $uuidType = null; + foreach ($introspection['__schema']['types'] as $type) { + if ($type['name'] === 'UUID') { + $uuidType = $type; + break; + } + } + + self::assertNotNull($uuidType, 'UUID type not found in introspection result'); + self::assertArrayHasKey('specifiedByURL', $uuidType); + self::assertSame('https://tools.ietf.org/html/rfc4122', $uuidType['specifiedByURL']); + } } diff --git a/tests/Utils/BuildClientSchemaTest.php b/tests/Utils/BuildClientSchemaTest.php index 278be5d45..446aaf3e1 100644 --- a/tests/Utils/BuildClientSchemaTest.php +++ b/tests/Utils/BuildClientSchemaTest.php @@ -1041,4 +1041,42 @@ public function testCustomScalarValueMixedExecution(): void self::assertSame(['foo' => ['baz' => 'value']], $result->data); } + + public function testRoundTripPreservesSpecifiedByURL(): void + { + $sdl = ' + scalar UUID + + type Query { + id: UUID + } + '; + + $serverSchema = BuildSchema::build($sdl); + + // Manually set specifiedByURL because SDL-built scalars do not set it without the directive + $uuidType = $serverSchema->getType('UUID'); + self::assertInstanceOf(\GraphQL\Type\Definition\ScalarType::class, $uuidType); + self::assertNull($uuidType->specifiedByURL, 'precondition: no URL in plain SDL'); + + // Use a programmatic schema with specifiedByURL set + $scalarType = new \GraphQL\Type\Definition\CustomScalarType([ + 'name' => 'UUID', + 'specifiedByURL' => 'https://tools.ietf.org/html/rfc4122', + 'serialize' => static fn ($value) => $value, + ]); + $schema = new Schema([ + 'query' => new \GraphQL\Type\Definition\ObjectType([ + 'name' => 'Query', + 'fields' => ['id' => ['type' => $scalarType]], + ]), + ]); + + $introspection = Introspection::fromSchema($schema); + $clientSchema = BuildClientSchema::build($introspection); + + $uuidFromClient = $clientSchema->getType('UUID'); + self::assertInstanceOf(\GraphQL\Type\Definition\ScalarType::class, $uuidFromClient); + self::assertSame('https://tools.ietf.org/html/rfc4122', $uuidFromClient->specifiedByURL); + } } diff --git a/tests/Utils/BuildSchemaTest.php b/tests/Utils/BuildSchemaTest.php index 5b1ea5469..8cf75f0d4 100644 --- a/tests/Utils/BuildSchemaTest.php +++ b/tests/Utils/BuildSchemaTest.php @@ -836,6 +836,30 @@ public function testSupportsSpecifiedBy(): void self::assertSame('https://example.com/foo_spec', $type->specifiedByURL); } + /** + * Verifies that @specifiedBy on a scalar extension node is picked up by BuildSchema. + * The URL should come from the `extend scalar` directive, not just the base definition. + */ + public function testSpecifiedByURLFromExtensionNode(): void + { + $sdl = <<getType('Foo'); + + self::assertInstanceOf(ScalarType::class, $type); + self::assertSame('https://example.com/foo_spec', $type->specifiedByURL); + } + /** * Verifies that overriding @specifiedBy with a custom directive (no url arg) does not throw * when building a schema with a scalar that uses the custom directive. diff --git a/tests/Validator/QueryComplexityTest.php b/tests/Validator/QueryComplexityTest.php index 22d971505..bf616ce54 100644 --- a/tests/Validator/QueryComplexityTest.php +++ b/tests/Validator/QueryComplexityTest.php @@ -288,4 +288,43 @@ protected static function getErrorMessage(int $max, int $count): string { return QueryComplexity::maxQueryComplexityErrorMessage($max, $count); } + + /** + * Verifies that variable coercion is not triggered for fields without @include/@skip + * directives. Previously, directiveExcludesField() called getVariableValues() for + * every field unconditionally. Now it is lazy: coercion only happens when an + * @include or @skip directive is actually encountered on the field. + * + * This test passes invalid variable values for a query where no field uses + * @include or @skip. Because coercion is lazy, no error should be thrown. + */ + public function testVariableCoercionIsLazyWhenNoIncludeOrSkipDirectives(): void + { + // $dog is declared but none of the fields use @include/@skip, + // so variable coercion should never be triggered by directiveExcludesField. + $query = 'query MyQuery($dog: String!) { human { firstName } }'; + + // Provide invalid variable value (empty string for a non-null String! is technically + // valid, but passing an int is not). The old code would coerce for every field and throw. + $this->getRule()->setRawVariableValues(['dog' => 42]); + + // Should produce no errors from the complexity rule itself (complexity is within bounds) + $this->assertDocumentValidators($query, 2, 3); + } + + /** + * Verifies that variable coercion is cached: when multiple fields each have @skip, + * coercion is only performed once, not once per field. + */ + public function testVariableCoercionIsCachedAcrossMultipleDirectives(): void + { + // Two fields each with @skip using the same variable. + // Coercion should occur once and be cached. + $query = 'query MyQuery($skipIt: Boolean!) { human { firstName @skip(if:$skipIt) dogs { name @skip(if:$skipIt) } } }'; + + $this->getRule()->setRawVariableValues(['skipIt' => false]); + + // skipIt=false means nothing is skipped: complexity = human(1) + firstName(1) + dogs(10) + name(1) = 13 + $this->assertDocumentValidators($query, 13, 14); + } } From 033fc1b9e28075e6701f0d696f2f57f3da02ccca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 07:36:36 +0000 Subject: [PATCH 14/18] Fix review comments on tests: improve comment clarity and remove unnecessary precondition check Agent-Logs-Url: https://github.com/webonyx/graphql-php/sessions/056e7e07-5cd1-4d0a-8278-3c0e03334605 Co-authored-by: spawnia <12158000+spawnia@users.noreply.github.com> --- tests/Utils/BuildClientSchemaTest.php | 16 ---------------- tests/Validator/QueryComplexityTest.php | 12 ++++++++---- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/tests/Utils/BuildClientSchemaTest.php b/tests/Utils/BuildClientSchemaTest.php index 446aaf3e1..1d67a68dd 100644 --- a/tests/Utils/BuildClientSchemaTest.php +++ b/tests/Utils/BuildClientSchemaTest.php @@ -1044,22 +1044,6 @@ public function testCustomScalarValueMixedExecution(): void public function testRoundTripPreservesSpecifiedByURL(): void { - $sdl = ' - scalar UUID - - type Query { - id: UUID - } - '; - - $serverSchema = BuildSchema::build($sdl); - - // Manually set specifiedByURL because SDL-built scalars do not set it without the directive - $uuidType = $serverSchema->getType('UUID'); - self::assertInstanceOf(\GraphQL\Type\Definition\ScalarType::class, $uuidType); - self::assertNull($uuidType->specifiedByURL, 'precondition: no URL in plain SDL'); - - // Use a programmatic schema with specifiedByURL set $scalarType = new \GraphQL\Type\Definition\CustomScalarType([ 'name' => 'UUID', 'specifiedByURL' => 'https://tools.ietf.org/html/rfc4122', diff --git a/tests/Validator/QueryComplexityTest.php b/tests/Validator/QueryComplexityTest.php index bf616ce54..d438a6e07 100644 --- a/tests/Validator/QueryComplexityTest.php +++ b/tests/Validator/QueryComplexityTest.php @@ -295,8 +295,11 @@ protected static function getErrorMessage(int $max, int $count): string * every field unconditionally. Now it is lazy: coercion only happens when an * @include or @skip directive is actually encountered on the field. * - * This test passes invalid variable values for a query where no field uses - * @include or @skip. Because coercion is lazy, no error should be thrown. + * This test passes a PHP integer for a variable declared as `String!`. The `StringType` + * rejects non-string values in `parseValue`, so coercing this variable would throw. With + * the old code (unconditional coercion) this would propagate as an exception through + * `directiveExcludesField` for every field. With lazy coercion it is never triggered, + * because no field in the query uses @include or @skip. */ public function testVariableCoercionIsLazyWhenNoIncludeOrSkipDirectives(): void { @@ -304,8 +307,9 @@ public function testVariableCoercionIsLazyWhenNoIncludeOrSkipDirectives(): void // so variable coercion should never be triggered by directiveExcludesField. $query = 'query MyQuery($dog: String!) { human { firstName } }'; - // Provide invalid variable value (empty string for a non-null String! is technically - // valid, but passing an int is not). The old code would coerce for every field and throw. + // An integer is rejected by StringType::parseValue, so if coercion ran (as it did + // before the lazy-coercion fix) it would throw. With the fix, coercion is never + // triggered here because no field uses @include or @skip. $this->getRule()->setRawVariableValues(['dog' => 42]); // Should produce no errors from the complexity rule itself (complexity is within bounds) From 9d39aca58139b4675c25486d85c97c914bf1f732 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 08:02:47 +0000 Subject: [PATCH 15/18] Autofix --- tests/Validator/QueryComplexityTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Validator/QueryComplexityTest.php b/tests/Validator/QueryComplexityTest.php index d438a6e07..b63e2206e 100644 --- a/tests/Validator/QueryComplexityTest.php +++ b/tests/Validator/QueryComplexityTest.php @@ -292,7 +292,8 @@ protected static function getErrorMessage(int $max, int $count): string /** * Verifies that variable coercion is not triggered for fields without @include/@skip * directives. Previously, directiveExcludesField() called getVariableValues() for - * every field unconditionally. Now it is lazy: coercion only happens when an + * every field unconditionally. Now it is lazy: coercion only happens when an. + * * @include or @skip directive is actually encountered on the field. * * This test passes a PHP integer for a variable declared as `String!`. The `StringType` From 97268c1bca87bc5829027fe4bab89cb95285f3e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 08:06:23 +0000 Subject: [PATCH 16/18] Use proper imports in BuildClientSchemaTest instead of FQCNs Agent-Logs-Url: https://github.com/webonyx/graphql-php/sessions/40f0f931-6b5a-40b8-b4f3-6f017aaf6dc9 Co-authored-by: spawnia <12158000+spawnia@users.noreply.github.com> --- tests/Utils/BuildClientSchemaTest.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/Utils/BuildClientSchemaTest.php b/tests/Utils/BuildClientSchemaTest.php index 1d67a68dd..56094a5b9 100644 --- a/tests/Utils/BuildClientSchemaTest.php +++ b/tests/Utils/BuildClientSchemaTest.php @@ -4,9 +4,11 @@ use GraphQL\Error\InvariantViolation; use GraphQL\GraphQL; +use GraphQL\Type\Definition\CustomScalarType; use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\EnumValueDefinition; use GraphQL\Type\Definition\ObjectType; +use GraphQL\Type\Definition\ScalarType; use GraphQL\Type\Definition\Type; use GraphQL\Type\Introspection; use GraphQL\Type\Schema; @@ -1044,13 +1046,13 @@ public function testCustomScalarValueMixedExecution(): void public function testRoundTripPreservesSpecifiedByURL(): void { - $scalarType = new \GraphQL\Type\Definition\CustomScalarType([ + $scalarType = new CustomScalarType([ 'name' => 'UUID', 'specifiedByURL' => 'https://tools.ietf.org/html/rfc4122', 'serialize' => static fn ($value) => $value, ]); $schema = new Schema([ - 'query' => new \GraphQL\Type\Definition\ObjectType([ + 'query' => new ObjectType([ 'name' => 'Query', 'fields' => ['id' => ['type' => $scalarType]], ]), @@ -1060,7 +1062,7 @@ public function testRoundTripPreservesSpecifiedByURL(): void $clientSchema = BuildClientSchema::build($introspection); $uuidFromClient = $clientSchema->getType('UUID'); - self::assertInstanceOf(\GraphQL\Type\Definition\ScalarType::class, $uuidFromClient); + self::assertInstanceOf(ScalarType::class, $uuidFromClient); self::assertSame('https://tools.ietf.org/html/rfc4122', $uuidFromClient->specifiedByURL); } } From e9457cd8084dcc4e4ae2956a77f214c922261191 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 08:44:16 +0000 Subject: [PATCH 17/18] Align tests with graphql-js: @see annotations, missing tests, fix broken comment Agent-Logs-Url: https://github.com/webonyx/graphql-php/sessions/2ff7dec4-1ba2-4840-9f33-af851e349e24 Co-authored-by: spawnia <12158000+spawnia@users.noreply.github.com> --- tests/Type/IntrospectionTest.php | 6 +++++- tests/Utils/BuildClientSchemaTest.php | 26 +++++++------------------ tests/Utils/SchemaPrinterTest.php | 22 +++++++++++++++++++++ tests/Validator/QueryComplexityTest.php | 5 ++--- 4 files changed, 36 insertions(+), 23 deletions(-) diff --git a/tests/Type/IntrospectionTest.php b/tests/Type/IntrospectionTest.php index 8a739a8aa..08bc8489d 100644 --- a/tests/Type/IntrospectionTest.php +++ b/tests/Type/IntrospectionTest.php @@ -2043,7 +2043,7 @@ public function testRespectsTheIncludeDeprecatedParameterForDirectiveArgs(): voi self::assertSame($expected, $result['data']['__schema']['directives'] ?? null); } - /** @see it('include "description" field on schema') */ + /** @see it('executes an introspection query') */ public function testSpecifiedByURL(): void { $scalarType = new CustomScalarType([ @@ -2066,6 +2066,7 @@ public function testSpecifiedByURL(): void self::assertSame(['data' => ['__type' => ['specifiedByURL' => 'https://tools.ietf.org/html/rfc4122']]], $result); } + /** @see it('executes an introspection query') */ public function testSpecifiedByURLIsNullForNonScalars(): void { $query = new ObjectType([ @@ -2095,16 +2096,19 @@ public function testIncludeDescriptionFieldOnSchema(): void self::assertCount(0, $matches[0]); } + /** @see it('include "specifiedBy" field') */ public function testSpecifiedByURLNotIncludedInIntrospectionQueryByDefault(): void { self::assertStringNotContainsString('specifiedByURL', Introspection::getIntrospectionQuery()); } + /** @see it('include "specifiedBy" field') */ public function testSpecifiedByURLIncludedInIntrospectionQueryWhenEnabled(): void { self::assertStringContainsString('specifiedByURL', Introspection::getIntrospectionQuery(['specifiedByURL' => true])); } + /** @see it('include "specifiedBy" field') */ public function testFromSchemaIncludesSpecifiedByURLByDefault(): void { $scalarType = new CustomScalarType([ diff --git a/tests/Utils/BuildClientSchemaTest.php b/tests/Utils/BuildClientSchemaTest.php index 56094a5b9..8d7e17599 100644 --- a/tests/Utils/BuildClientSchemaTest.php +++ b/tests/Utils/BuildClientSchemaTest.php @@ -4,11 +4,9 @@ use GraphQL\Error\InvariantViolation; use GraphQL\GraphQL; -use GraphQL\Type\Definition\CustomScalarType; use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\EnumValueDefinition; use GraphQL\Type\Definition\ObjectType; -use GraphQL\Type\Definition\ScalarType; use GraphQL\Type\Definition\Type; use GraphQL\Type\Introspection; use GraphQL\Type\Schema; @@ -1044,25 +1042,15 @@ public function testCustomScalarValueMixedExecution(): void self::assertSame(['foo' => ['baz' => 'value']], $result->data); } + /** @see it('builds a schema with specifiedBy url') */ public function testRoundTripPreservesSpecifiedByURL(): void { - $scalarType = new CustomScalarType([ - 'name' => 'UUID', - 'specifiedByURL' => 'https://tools.ietf.org/html/rfc4122', - 'serialize' => static fn ($value) => $value, - ]); - $schema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'Query', - 'fields' => ['id' => ['type' => $scalarType]], - ]), - ]); - - $introspection = Introspection::fromSchema($schema); - $clientSchema = BuildClientSchema::build($introspection); + self::assertCycleIntrospection(' + scalar Foo @specifiedBy(url: "https://example.com/foo_spec") - $uuidFromClient = $clientSchema->getType('UUID'); - self::assertInstanceOf(ScalarType::class, $uuidFromClient); - self::assertSame('https://tools.ietf.org/html/rfc4122', $uuidFromClient->specifiedByURL); + type Query { + foo: Foo + } + '); } } diff --git a/tests/Utils/SchemaPrinterTest.php b/tests/Utils/SchemaPrinterTest.php index 4616b4b85..462f2d5ed 100644 --- a/tests/Utils/SchemaPrinterTest.php +++ b/tests/Utils/SchemaPrinterTest.php @@ -831,6 +831,28 @@ public function testCustomScalar(): void ); } + /** @see it('Custom Scalar with specifiedByURL') */ + public function testCustomScalarWithSpecifiedByURL(): void + { + $fooType = new CustomScalarType([ + 'name' => 'Foo', + 'specifiedByURL' => 'https://example.com/foo_spec', + 'serialize' => static fn () => null, + ]); + + $schema = new Schema([ + 'types' => [$fooType], + ]); + + self::assertPrintedSchemaEquals( + <<<'GRAPHQL' + scalar Foo @specifiedBy(url: "https://example.com/foo_spec") + + GRAPHQL, + $schema + ); + } + /** @see it('Enum') */ public function testEnum(): void { diff --git a/tests/Validator/QueryComplexityTest.php b/tests/Validator/QueryComplexityTest.php index b63e2206e..db6050a01 100644 --- a/tests/Validator/QueryComplexityTest.php +++ b/tests/Validator/QueryComplexityTest.php @@ -292,9 +292,8 @@ protected static function getErrorMessage(int $max, int $count): string /** * Verifies that variable coercion is not triggered for fields without @include/@skip * directives. Previously, directiveExcludesField() called getVariableValues() for - * every field unconditionally. Now it is lazy: coercion only happens when an. - * - * @include or @skip directive is actually encountered on the field. + * every field unconditionally. Now it is lazy: coercion only happens when an + * \@include or \@skip directive is actually encountered on the field. * * This test passes a PHP integer for a variable declared as `String!`. The `StringType` * rejects non-string values in `parseValue`, so coercing this variable would throw. With From b8642417956304197298e24fc2d3f6a2365842da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 09:02:54 +0000 Subject: [PATCH 18/18] Address latest 3 review comments: ScalarType subclass default, docs grammar, Printer comment Agent-Logs-Url: https://github.com/webonyx/graphql-php/sessions/ec97a2ec-237f-4065-aa53-2871b3ca7e17 Co-authored-by: spawnia <12158000+spawnia@users.noreply.github.com> --- docs/type-definitions/directives.md | 2 +- src/Language/Printer.php | 2 +- src/Type/Definition/ScalarType.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/type-definitions/directives.md b/docs/type-definitions/directives.md index 2fb56f2f7..a3829ad9d 100644 --- a/docs/type-definitions/directives.md +++ b/docs/type-definitions/directives.md @@ -4,7 +4,7 @@ The directive is a way for a client to give GraphQL server additional context an the query. The directive can be attached to a field or fragment and can affect the execution of the query in any way the server desires. -GraphQL specification includes the following built-in directives: +The GraphQL specification includes the following built-in directives: - **@include(if: Boolean)** Only include this field or fragment in the result if the argument is **true** - **@skip(if: Boolean)** Skip this field or fragment if the argument is **true** diff --git a/src/Language/Printer.php b/src/Language/Printer.php index adf93f1d8..bc061ad0f 100644 --- a/src/Language/Printer.php +++ b/src/Language/Printer.php @@ -403,7 +403,7 @@ protected static function p(?Node $node): string return BlockString::print($node->value); } - // Do not escape unicode or slashes in order to keep URLs valid + // Do not escape unicode or slashes to keep the output readable return json_encode($node->value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); case $node instanceof UnionTypeDefinitionNode: diff --git a/src/Type/Definition/ScalarType.php b/src/Type/Definition/ScalarType.php index 59e05db21..a78a4a86b 100644 --- a/src/Type/Definition/ScalarType.php +++ b/src/Type/Definition/ScalarType.php @@ -56,7 +56,7 @@ public function __construct(array $config = []) { $this->name = $config['name'] ?? $this->inferName(); $this->description = $config['description'] ?? $this->description ?? null; - $this->specifiedByURL = $config['specifiedByURL'] ?? null; + $this->specifiedByURL = $config['specifiedByURL'] ?? $this->specifiedByURL ?? null; $this->astNode = $config['astNode'] ?? null; $this->extensionASTNodes = $config['extensionASTNodes'] ?? [];