diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..f4d41ae --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,6 @@ +parameters: + ignoreErrors: + - + message: "#^Method GraphQL\\\\Doctrine\\\\Types\\:\\:getOperator\\(\\) should return GraphQL\\\\Doctrine\\\\Definition\\\\Operator\\\\AbstractOperator but returns GraphQL\\\\Type\\\\Definition\\\\NamedType&GraphQL\\\\Type\\\\Definition\\\\Type\\.$#" + count: 1 + path: src/Types.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 35ca5d3..1ada6a5 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -17,3 +17,6 @@ parameters: - '~^Parameter \#1 \$type of static method GraphQL\\Type\\Definition\\Type\:\:nonNull\(\) expects~' - '~^Parameter \#1 \$config of class GraphQL\\Type\\Definition\\InputObjectType constructor expects~' - '~^Parameter \#1 \$config of method GraphQL\\Type\\Definition\\InputObjectType\:\:__construct~' + +includes: + - phpstan-baseline.neon diff --git a/src/Factory/Type/AbstractTypeFactory.php b/src/Factory/Type/AbstractTypeFactory.php index 1044467..ce23467 100644 --- a/src/Factory/Type/AbstractTypeFactory.php +++ b/src/Factory/Type/AbstractTypeFactory.php @@ -21,7 +21,7 @@ abstract class AbstractTypeFactory extends AbstractFactory * @param class-string $className class name of Doctrine entity * @param string $typeName GraphQL type name */ - abstract public function create(string $className, string $typeName): NamedType; + abstract public function create(string $className, string $typeName): Type&NamedType; /** * Get the description of a class from the doc block. diff --git a/src/Types.php b/src/Types.php index 8048367..bec3f72 100644 --- a/src/Types.php +++ b/src/Types.php @@ -39,7 +39,7 @@ final class Types implements TypesInterface { /** - * @var array mapping of type name to type instances + * @var array mapping of type name to type instances */ private array $types = []; @@ -84,10 +84,10 @@ public function has(string $key): bool return $this->customTypes && $this->customTypes->has($key) || array_key_exists($key, $this->types); } - public function get(string $key): NamedType + public function get(string $key): Type&NamedType { if ($this->customTypes && $this->customTypes->has($key)) { - /** @var NamedType $t */ + /** @var NamedType&Type $t */ $t = $this->customTypes->get($key); $this->registerInstance($t); @@ -106,7 +106,7 @@ public function get(string $key): NamedType * * @param class-string $className */ - private function getViaFactory(string $className, string $typeName, AbstractTypeFactory $factory): Type + private function getViaFactory(string $className, string $typeName, AbstractTypeFactory $factory): Type&NamedType { $this->throwIfNotEntity($className); @@ -239,7 +239,7 @@ public function getOperator(string $className, LeafType $type): AbstractOperator * * This is for internal use only. You should declare custom types via the constructor, not this method. */ - public function registerInstance(NamedType $instance): void + public function registerInstance(Type&NamedType $instance): void { $this->types[$instance->name()] = $instance; } @@ -294,4 +294,39 @@ public function createFilteredQueryBuilder(string $className, array $filter, arr { return $this->filteredQueryBuilderFactory->create($className, $filter, $sorting); } + + public function loadType(string $typeName, string $namespace): ?Type + { + if ($this->has($typeName)) { + return $this->get($typeName); + } + + if (preg_match('~^(?.*)(?PartialInput)$~', $typeName, $m) + || preg_match('~^(?.*)(?Input|PartialInput|Filter|Sorting|FilterGroupJoin|FilterGroupCondition|ID)$~', $typeName, $m) + || preg_match('~^(?JoinOn)(?.*)$~', $typeName, $m) + || preg_match('~^(?.*)$~', $typeName, $m)) { + $shortName = $m['shortName']; + $kind = $m['kind'] ?? ''; + + /** @var class-string $className */ + $className = $namespace . '\\' . $shortName; + + if ($this->isEntity($className)) { + return match ($kind) { + 'Input' => $this->getViaFactory($className, $typeName, $this->inputTypeFactory), + 'PartialInput' => $this->getViaFactory($className, $typeName, $this->partialInputTypeFactory), + 'Filter' => $this->getViaFactory($className, $typeName, $this->filterTypeFactory), + 'Sorting' => $this->getViaFactory($className, $typeName, $this->sortingTypeFactory), + 'JoinOn' => $this->getViaFactory($className, $typeName, $this->joinOnTypeFactory), + 'FilterGroupJoin' => $this->getViaFactory($className, $typeName, $this->filterGroupJoinTypeFactory), + 'FilterGroupCondition' => $this->getViaFactory($className, $typeName, $this->filterGroupConditionTypeFactory), + 'ID' => $this->getViaFactory($className, $typeName, $this->entityIDTypeFactory), + '' => $this->getViaFactory($className, $typeName, $this->objectTypeFactory), + default => throw new Exception("Unsupported kind of type `$kind` when trying to load type `$typeName`"), + }; + } + } + + return null; + } } diff --git a/src/TypesInterface.php b/src/TypesInterface.php index b1dc8cb..1cb3a25 100644 --- a/src/TypesInterface.php +++ b/src/TypesInterface.php @@ -10,6 +10,7 @@ use GraphQL\Type\Definition\ListOfType; use GraphQL\Type\Definition\NamedType; use GraphQL\Type\Definition\ObjectType; +use GraphQL\Type\Definition\Type; /** * Registry of types to manage all GraphQL types. @@ -33,7 +34,7 @@ public function has(string $key): bool; * * @param string $key the key the type was registered with (eg: "Post", "PostInput", "PostPartialInput" or "PostStatus") */ - public function get(string $key): NamedType; + public function get(string $key): Type&NamedType; /** * Returns an output type for the given entity. @@ -116,4 +117,25 @@ public function getId(string $className): EntityIDType; * @param class-string $className */ public function createFilteredQueryBuilder(string $className, array $filter, array $sorting): QueryBuilder; + + /** + * Load a type from its name. + * + * This should be used to declare typeLoader in `GraphQL\Type\Schema` with something similar to: + * + * ```php + * $types = new Types(...); + * $schema = new GraphQL\Type\Schema([ + * 'typeLoader' => fn (string $name) => $types->loadType($name, 'Application\Model') ?? $types->loadType($name, 'OtherApplication\Model') + * // ... + * ]); + * ``` + * + * While this method could technically replace of uses of dedicated `get*()` methods, we suggest to only use + * `loadType` with the `typeLoader`. Because dedicated `get*()` methods are easier to use, and provide + * stronger typing. + * + * @return null|(Type&NamedType) + */ + public function loadType(string $typeName, string $namespace): ?Type; } diff --git a/tests/TypesTest.php b/tests/TypesTest.php index 448ae51..2c9f717 100644 --- a/tests/TypesTest.php +++ b/tests/TypesTest.php @@ -156,4 +156,40 @@ public function testHas(): void $this->types->get(stdClass::class); self::assertTrue($this->types->has('customName'), 'should have custom registered type by its name, even if custom key was different, once type is created'); } + + /** + * @dataProvider provideLoadType + */ + public function testLoadType(string $typeName): void + { + $type = $this->types->loadType($typeName, 'GraphQLTests\Doctrine\Blog\Model'); + self::assertNotNull($type, 'should be able to lazy load a generated type by its name only'); + self::assertSame($typeName, $type->name(), 'loaded type must have same name'); + } + + public static function provideLoadType(): iterable + { + yield 'PostInput' => ['PostInput']; + yield 'PostPartialInput' => ['PostPartialInput']; + yield 'Post' => ['Post']; + yield 'PostID' => ['PostID']; + yield 'PostFilter' => ['PostFilter']; + yield 'PostFilterGroupJoin' => ['PostFilterGroupJoin']; + yield 'PostSorting' => ['PostSorting']; + yield 'PostStatus' => ['PostStatus']; + yield 'PostFilterGroupCondition' => ['PostFilterGroupCondition']; + yield 'JoinOnPost' => ['JoinOnPost']; + } + + public function testLoadUnknownType(): void + { + $type = $this->types->loadType('unknown-type-name', 'GraphQLTests\Doctrine\Blog\Model'); + self::assertNull($type, 'should return null if type is not found to be chainable'); + } + + public function testLoadTypeInUnknownNamespace(): void + { + $type = $this->types->loadType('Post', 'Unknown\Model'); + self::assertNull($type, 'should return null if namespace is not found to be chainable'); + } }