diff --git a/docs/schema-definition.md b/docs/schema-definition.md index 35b7c2b70..1d7c28f34 100644 --- a/docs/schema-definition.md +++ b/docs/schema-definition.md @@ -85,7 +85,7 @@ or an array with the following options: | subscription | `ObjectType` or `callable(): ?ObjectType` or `null` | Reserved for future subscriptions implementation. Currently presented for compatibility with introspection query of **graphql-js**, used by various clients (like Relay or GraphiQL) | | directives | `array` | A full list of [directives](type-definitions/directives.md) supported by your schema. By default, contains built-in **@skip** and **@include** directives.

If you pass your own directives and still want to use built-in directives - add them explicitly. For example:

_array_merge(GraphQL::getStandardDirectives(), [$myCustomDirective]);_ | | types | `array` | List of object types which cannot be detected by **graphql-php** during static schema analysis.

Most often this happens when the object type is never referenced in fields directly but is still a part of a schema because it implements an interface which resolves to this object type in its **resolveType** callable.

Note that you are not required to pass all of your types here - it is simply a workaround for a concrete use-case. | -| typeLoader | `callable(string $name): Type` | Expected to return a type instance given the name. Must always return the same instance if called multiple times, see [lazy loading](#lazy-loading-of-types). See section below on lazy type loading. | +| typeLoader | `callable(string $name): ?Type` | Expected to return a type instance given the name, or `null` for unknown types. Must always return the same instance if called multiple times, see [lazy loading](#lazy-loading-of-types). See section below on lazy type loading. | ### Using config class @@ -164,8 +164,8 @@ final class Types /** @var array */ private static array $types = []; - /** @return Type&NamedType */ - public static function load(string $typeName): Type + /** @return (Type&NamedType)|null */ + public static function load(string $typeName): ?Type { if (isset(self::$types[$typeName])) { return self::$types[$typeName]; @@ -178,7 +178,7 @@ final class Types default => lcfirst($typeName), }; if (! method_exists(self::class, $methodName)) { - throw new \Exception("Unknown GraphQL type: {$typeName}."); + return null; } $type = self::{$methodName}(); // @phpstan-ignore-line variable static method call diff --git a/src/Type/Definition/Type.php b/src/Type/Definition/Type.php index 8a9b05647..6eb7208db 100644 --- a/src/Type/Definition/Type.php +++ b/src/Type/Definition/Type.php @@ -203,7 +203,7 @@ public static function overrideStandardTypes(array $types): void throw new InvariantViolation("Expecting instance of {$typeClass}, got {$notType}"); } - if (! in_array($type->name, self::BUILT_IN_SCALAR_NAMES, true)) { + if (! self::isBuiltInScalarName($type->name)) { $standardTypeNames = implode(', ', self::BUILT_IN_SCALAR_NAMES); $notStandardTypeName = Utils::printSafe($type->name); throw new InvariantViolation("Expecting one of the following names for a standard type: {$standardTypeNames}; got {$notStandardTypeName}"); @@ -228,7 +228,12 @@ public static function overrideStandardTypes(array $types): void public static function isBuiltInScalar($type): bool { return $type instanceof ScalarType - && in_array($type->name, self::BUILT_IN_SCALAR_NAMES, true); + && self::isBuiltInScalarName($type->name); + } + + public static function isBuiltInScalarName(string $name): bool + { + return in_array($name, self::BUILT_IN_SCALAR_NAMES, true); } /** diff --git a/src/Type/Schema.php b/src/Type/Schema.php index 8708552b4..bb326300c 100644 --- a/src/Type/Schema.php +++ b/src/Type/Schema.php @@ -4,6 +4,7 @@ use GraphQL\Error\Error; use GraphQL\Error\InvariantViolation; +use GraphQL\Error\Warning; use GraphQL\GraphQL; use GraphQL\Language\AST\OperationDefinitionNode; use GraphQL\Language\AST\SchemaDefinitionNode; @@ -189,12 +190,19 @@ public function getTypeMap(): array } if (isset($this->config->typeLoader)) { + $typeLoader = $this->config->typeLoader; foreach (Type::BUILT_IN_SCALAR_NAMES as $scalarName) { if (isset($scalarOverrides[$scalarName])) { continue; } - $type = ($this->config->typeLoader)($scalarName); + try { + $type = $typeLoader($scalarName); + } catch (\Throwable $e) { + self::deprecateThrowingTypeLoaderForBuiltInScalar($scalarName, $e); + continue; + } + if ($type instanceof ScalarType && $type->name === $scalarName && $type !== $builtInScalars[$scalarName] @@ -225,6 +233,15 @@ public function getDirectives(): array return $this->config->directives ?? GraphQL::getStandardDirectives(); } + private static function deprecateThrowingTypeLoaderForBuiltInScalar(string $scalarName, \Throwable $e): void + { + Warning::warn( + "Type loader should return null for unknown types, but threw for built-in scalar \"{$scalarName}\": {$e->getMessage()}. In a future major version, this exception will not be caught.", + Warning::WARNING_CONFIG_DEPRECATION, + \E_USER_DEPRECATED, + ); + } + /** @param mixed $typeLoaderReturn could be anything */ public static function typeLoaderNotType($typeLoaderReturn): string { @@ -366,7 +383,18 @@ private function loadType(string $typeName): ?Type return $this->getTypeMap()[$typeName] ?? null; } - $type = ($this->config->typeLoader)($typeName); + try { + $type = ($this->config->typeLoader)($typeName); + } catch (\Throwable $e) { + if (Type::isBuiltInScalarName($typeName)) { + self::deprecateThrowingTypeLoaderForBuiltInScalar($typeName, $e); + + return null; + } + + throw $e; + } + if ($type === null) { return null; } diff --git a/tests/Type/TypeLoaderTestCaseBase.php b/tests/Type/TypeLoaderTestCaseBase.php index ae965cab3..79ee66c51 100644 --- a/tests/Type/TypeLoaderTestCaseBase.php +++ b/tests/Type/TypeLoaderTestCaseBase.php @@ -4,6 +4,7 @@ use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; use GraphQL\Error\InvariantViolation; +use GraphQL\Error\Warning; use GraphQL\Tests\TestCaseBase; use GraphQL\Type\Definition\NamedType; use GraphQL\Type\Definition\ObjectType; @@ -80,6 +81,51 @@ public function testIgnoresNonExistentType(): void self::assertNull($schema->getType('NonExistingType')); } + public function testThrowingTypeLoaderForBuiltInScalarTriggersDeprecation(): void + { + $schema = new Schema([ + 'query' => $this->query, + 'typeLoader' => function (string $name): ?Type { + if (Type::isBuiltInScalarName($name)) { + throw new \Exception("Type \"{$name}\" not found"); + } + + return ($this->typeLoader)($name); + }, + ]); + + $warnings = []; + Warning::setWarningHandler(static function (string $message, int $warningId) use (&$warnings): void { + $warnings[] = ['message' => $message, 'id' => $warningId]; + }); + + try { + self::assertSame(Type::string(), $schema->getType('String')); + self::assertSame(Type::string(), $schema->getTypeMap()['String']); + + self::assertNotEmpty($warnings); + self::assertSame(Warning::WARNING_CONFIG_DEPRECATION, $warnings[0]['id']); + self::assertStringContainsString('String', $warnings[0]['message']); + } finally { + Warning::setWarningHandler(null); + } + } + + public function testThrowingTypeLoaderForNonBuiltInTypeIsNotCaught(): void + { + $schema = new Schema([ + 'query' => $this->query, + 'typeLoader' => static function (string $name): ?Type { + throw new \Exception("Type \"{$name}\" not found"); + }, + ]); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Type "Node" not found'); + + $schema->getType('Node'); + } + public function testFailsOnNonType(): void { $notType = new \stdClass();