diff --git a/composer.json b/composer.json index 24dab77d1d..d3e79e0426 100644 --- a/composer.json +++ b/composer.json @@ -25,8 +25,8 @@ "symfony/cache": "^4.3 || ^5 || ^6 || ^7", "symfony/expression-language": "^4 || ^5 || ^6 || ^7", "thecodingmachine/cache-utils": "^1", - "thecodingmachine/class-explorer": "^1.1.0", - "webonyx/graphql-php": "^v15.0" + "webonyx/graphql-php": "^v15.0", + "kcs/class-finder": "^0.4.0" }, "require-dev": { "beberlei/porpaginas": "^1.2 || ^2.0", diff --git a/src/GlobControllerQueryProvider.php b/src/GlobControllerQueryProvider.php index 85998c780b..6fc7e659d1 100644 --- a/src/GlobControllerQueryProvider.php +++ b/src/GlobControllerQueryProvider.php @@ -6,14 +6,14 @@ use GraphQL\Type\Definition\FieldDefinition; use InvalidArgumentException; -use Mouf\Composer\ClassNameMapper; +use Kcs\ClassFinder\Finder\ComposerFinder; +use Kcs\ClassFinder\Finder\FinderInterface; use Psr\Container\ContainerInterface; use Psr\SimpleCache\CacheInterface; use ReflectionClass; use ReflectionMethod; use Symfony\Component\Cache\Adapter\Psr16Adapter; use Symfony\Contracts\Cache\CacheInterface as CacheContractInterface; -use TheCodingMachine\ClassExplorer\Glob\GlobClassExplorer; use TheCodingMachine\GraphQLite\Annotations\Mutation; use TheCodingMachine\GraphQLite\Annotations\Query; use TheCodingMachine\GraphQLite\Annotations\Subscription; @@ -33,27 +33,25 @@ final class GlobControllerQueryProvider implements QueryProviderInterface { /** @var array|null */ private array|null $instancesList = null; - private ClassNameMapper $classNameMapper; + private FinderInterface $finder; private AggregateControllerQueryProvider|null $aggregateControllerQueryProvider = null; private CacheContractInterface $cacheContract; /** * @param string $namespace The namespace that contains the GraphQL types (they must have a `@Type` annotation) * @param ContainerInterface $container The container we will fetch controllers from. - * @param bool $recursive Whether subnamespaces of $namespace must be analyzed. */ public function __construct( - private string $namespace, - private FieldsBuilder $fieldsBuilder, - private ContainerInterface $container, - private AnnotationReader $annotationReader, - private CacheInterface $cache, - ClassNameMapper|null $classNameMapper = null, - private int|null $cacheTtl = null, - private bool $recursive = true, + private readonly string $namespace, + private readonly FieldsBuilder $fieldsBuilder, + private readonly ContainerInterface $container, + private readonly AnnotationReader $annotationReader, + private readonly CacheInterface $cache, + FinderInterface|null $finder = null, + int|null $cacheTtl = null, ) { - $this->classNameMapper = $classNameMapper ?? ClassNameMapper::createFromComposerFile(null, null, true); + $this->finder = $finder ?? new ComposerFinder(); $this->cacheContract = new Psr16Adapter( $this->cache, str_replace(['\\', '{', '}', '(', ')', '/', '@', ':'], '_', $namespace), @@ -96,15 +94,12 @@ private function getInstancesList(): array /** @return array */ private function buildInstancesList(): array { - $explorer = new GlobClassExplorer($this->namespace, $this->cache, $this->cacheTtl, $this->classNameMapper, $this->recursive); - $classes = $explorer->getClasses(); $instances = []; - foreach ($classes as $className) { + foreach ((clone $this->finder)->inNamespace($this->namespace) as $className => $refClass) { if (! class_exists($className) && ! interface_exists($className)) { continue; } - $refClass = new ReflectionClass($className); - if (! $refClass->isInstantiable()) { + if (! $refClass instanceof ReflectionClass || ! $refClass->isInstantiable()) { continue; } if (! $this->hasOperations($refClass)) { diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 8f23bf5df6..e49f51e8a1 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -8,7 +8,7 @@ use Doctrine\Common\Annotations\PsrCachedReader; use Doctrine\Common\Annotations\Reader; use GraphQL\Type\SchemaConfig; -use Mouf\Composer\ClassNameMapper; +use Kcs\ClassFinder\Finder\FinderInterface; use MyCLabs\Enum\Enum; use PackageVersions\Versions; use Psr\Cache\CacheItemPoolInterface; @@ -109,7 +109,7 @@ class SchemaFactory private NamingStrategyInterface|null $namingStrategy = null; - private ClassNameMapper|null $classNameMapper = null; + private FinderInterface|null $finder = null; private SchemaConfig|null $schemaConfig = null; @@ -262,9 +262,9 @@ public function setSchemaConfig(SchemaConfig $schemaConfig): self return $this; } - public function setClassNameMapper(ClassNameMapper $classNameMapper): self + public function setFinder(FinderInterface $finder): self { - $this->classNameMapper = $classNameMapper; + $this->finder = $finder; return $this; } @@ -344,7 +344,7 @@ public function createSchema(): Schema $namingStrategy = $this->namingStrategy ?: new NamingStrategy(); $typeRegistry = new TypeRegistry(); - $namespaceFactory = new NamespaceFactory($namespacedCache, $this->classNameMapper, $this->globTTL); + $namespaceFactory = new NamespaceFactory($namespacedCache, $this->finder, $this->globTTL); $nsList = array_map( static fn (string $namespace) => $namespaceFactory->createNamespace($namespace), $this->typeNamespaces, @@ -493,7 +493,7 @@ public function createSchema(): Schema $this->container, $annotationReader, $namespacedCache, - $this->classNameMapper, + $this->finder, $this->globTTL, ); } diff --git a/src/Utils/Namespaces/NS.php b/src/Utils/Namespaces/NS.php index 4e6fe98d99..262a42494b 100644 --- a/src/Utils/Namespaces/NS.php +++ b/src/Utils/Namespaces/NS.php @@ -4,13 +4,19 @@ namespace TheCodingMachine\GraphQLite\Utils\Namespaces; -use Mouf\Composer\ClassNameMapper; +use Exception; +use Kcs\ClassFinder\Finder\FinderInterface; +use Psr\SimpleCache\CacheException; use Psr\SimpleCache\CacheInterface; +use Psr\SimpleCache\InvalidArgumentException; use ReflectionClass; -use TheCodingMachine\ClassExplorer\Glob\GlobClassExplorer; +use ReflectionException; +use function array_keys; use function class_exists; use function interface_exists; +use function preg_replace; +use function trait_exists; /** * The NS class represents a PHP Namespace and provides utility methods to explore those classes. @@ -24,7 +30,7 @@ final class NS * Only instantiable classes are returned. * Key: fully qualified class name * - * @var array> + * @var array> */ private array|null $classes = null; @@ -32,10 +38,10 @@ final class NS public function __construct( private readonly string $namespace, private readonly CacheInterface $cache, - private readonly ClassNameMapper $classNameMapper, + private readonly FinderInterface $finder, private readonly int|null $globTTL, - private readonly bool $recursive, - ) { + ) + { } /** @@ -47,31 +53,47 @@ public function __construct( public function getClassList(): array { if ($this->classes === null) { - $this->classes = []; - $explorer = new GlobClassExplorer($this->namespace, $this->cache, $this->globTTL, $this->classNameMapper, $this->recursive); - /** @var array $classes Override class-explorer lib */ - $classes = $explorer->getClassMap(); - foreach ($classes as $className => $phpFile) { - if (! class_exists($className, false) && ! interface_exists($className, false)) { - // Let's try to load the file if it was not imported yet. - // We are importing the file manually to avoid triggering the autoloader. - // The autoloader might trigger errors if the file does not respect PSR-4 or if the - // Symfony DebugAutoLoader is installed. (see https://github.com/thecodingmachine/graphqlite/issues/216) - require_once $phpFile; - // Does it exists now? - // @phpstan-ignore-next-line - if (! class_exists($className, false) && ! interface_exists($className, false)) { - continue; + $cacheKey = 'GraphQLite_NS_' . preg_replace('/[\/{}()\\\\@:]/', '', $this->namespace); + try { + $classes = $this->cache->get($cacheKey); + if ($classes !== null) { + foreach ($classes as $class) { + if ( + ! class_exists($class, false) && + ! interface_exists($class, false) && + ! trait_exists($class, false) + ) { + // assume the cache is invalid + throw new class extends Exception implements CacheException { + }; + } + + $this->classes[$class] = new ReflectionClass($class); } } + } catch (CacheException | InvalidArgumentException | ReflectionException) { + $this->classes = null; + } - $refClass = new ReflectionClass($className); + if ($this->classes === null) { + $this->classes = []; + /** @var class-string $className */ + /** @var ReflectionClass $reflector */ + foreach ($this->finder->inNamespace($this->namespace) as $className => $reflector) { + if (! ($reflector instanceof ReflectionClass)) { + continue; + } - $this->classes[$className] = $refClass; + $this->classes[$className] = $reflector; + } + try { + $this->cache->set($cacheKey, array_keys($this->classes), $this->globTTL); + } catch (InvalidArgumentException) { + // @ignoreException + } } } - // @phpstan-ignore-next-line - Not sure why we cannot annotate the $classes above return $this->classes; } diff --git a/src/Utils/Namespaces/NamespaceFactory.php b/src/Utils/Namespaces/NamespaceFactory.php index 9d1c6d32cf..e4d462cc2a 100644 --- a/src/Utils/Namespaces/NamespaceFactory.php +++ b/src/Utils/Namespaces/NamespaceFactory.php @@ -4,7 +4,8 @@ namespace TheCodingMachine\GraphQLite\Utils\Namespaces; -use Mouf\Composer\ClassNameMapper; +use Kcs\ClassFinder\Finder\ComposerFinder; +use Kcs\ClassFinder\Finder\FinderInterface; use Psr\SimpleCache\CacheInterface; /** @@ -14,16 +15,16 @@ */ final class NamespaceFactory { - private ClassNameMapper $classNameMapper; + private FinderInterface $finder; - public function __construct(private readonly CacheInterface $cache, ClassNameMapper|null $classNameMapper = null, private int|null $globTTL = 2) + public function __construct(private readonly CacheInterface $cache, FinderInterface|null $finder = null, private int|null $globTTL = 2) { - $this->classNameMapper = $classNameMapper ?? ClassNameMapper::createFromComposerFile(null, null, true); + $this->finder = $finder ?? new ComposerFinder(); } /** @param string $namespace A PHP namespace */ - public function createNamespace(string $namespace, bool $recursive = true): NS + public function createNamespace(string $namespace): NS { - return new NS($namespace, $this->cache, $this->classNameMapper, $this->globTTL, $recursive); + return new NS($namespace, $this->cache, clone $this->finder, $this->globTTL); } } diff --git a/tests/Fixtures/BadNamespace/BadlyNamespacedClass.php b/tests/Fixtures/BadNamespace/BadlyNamespacedClass.php new file mode 100644 index 0000000000..258dac6fd5 --- /dev/null +++ b/tests/Fixtures/BadNamespace/BadlyNamespacedClass.php @@ -0,0 +1,8 @@ + $controller ]) implements ContainerInterface { - /** - * @var array - */ + $container = new class ([TestController::class => $controller]) implements ContainerInterface { + /** @var array */ private $controllers; public function __construct(array $controllers) @@ -24,26 +26,27 @@ public function __construct(array $controllers) $this->controllers = $controllers; } - public function get($id):mixed + public function get($id): mixed { return $this->controllers[$id]; } - public function has($id):bool + public function has($id): bool { return isset($this->controllers[$id]); } }; + $finder = new ComposerFinder(); + $finder->filter(static fn (ReflectionClass $class) => $class->getNamespaceName() === 'TheCodingMachine\\GraphQLite\\Fixtures'); // Fix for recursive:false $globControllerQueryProvider = new GlobControllerQueryProvider( 'TheCodingMachine\\GraphQLite\\Fixtures', $this->getFieldsBuilder(), $container, $this->getAnnotationReader(), - new Psr16Cache(new NullAdapter), - null, - false, - false, + new Psr16Cache(new NullAdapter()), + $finder, + 0, ); $queries = $globControllerQueryProvider->getQueries(); diff --git a/tests/SchemaFactoryTest.php b/tests/SchemaFactoryTest.php index 9ac3edceb9..d57dc78a11 100644 --- a/tests/SchemaFactoryTest.php +++ b/tests/SchemaFactoryTest.php @@ -9,7 +9,8 @@ use GraphQL\Executor\ExecutionResult; use GraphQL\GraphQL; use GraphQL\Type\SchemaConfig; -use Mouf\Composer\ClassNameMapper; +use Kcs\ClassFinder\Finder\ComposerFinder; +use Kcs\ClassFinder\Finder\RecursiveFinder; use PHPUnit\Framework\TestCase; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Adapter\Psr16Adapter; @@ -89,7 +90,7 @@ public function testSetters(): void $this->doTestSchema($schema); } - public function testClassNameMapperInjectionWithValidMapper(): void + public function testFinderInjectionWithValidMapper(): void { $factory = new SchemaFactory( new Psr16Cache(new ArrayAdapter()), @@ -99,7 +100,7 @@ public function testClassNameMapperInjectionWithValidMapper(): void ); $factory->setAuthenticationService(new VoidAuthenticationService()) ->setAuthorizationService(new VoidAuthorizationService()) - ->setClassNameMapper(ClassNameMapper::createFromComposerFile(null, null, true)) + ->setFinder(new ComposerFinder()) ->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers') ->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); @@ -136,7 +137,7 @@ public function testCreateSchemaOnlyWithFactories(): void $this->doTestSchema($schema); } - public function testClassNameMapperInjectionWithInvalidMapper(): void + public function testFinderInjectionWithInvalidMapper(): void { $factory = new SchemaFactory( new Psr16Cache(new ArrayAdapter()), @@ -146,7 +147,7 @@ public function testClassNameMapperInjectionWithInvalidMapper(): void ); $factory->setAuthenticationService(new VoidAuthenticationService()) ->setAuthorizationService(new VoidAuthorizationService()) - ->setClassNameMapper(new ClassNameMapper()) + ->setFinder(new RecursiveFinder(__DIR__ . '/Annotations')) ->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers') ->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); diff --git a/tests/Utils/NsTest.php b/tests/Utils/NsTest.php new file mode 100644 index 0000000000..84f92c1ccd --- /dev/null +++ b/tests/Utils/NsTest.php @@ -0,0 +1,144 @@ +cache = new Psr16Cache(new ArrayAdapter()); + $this->namespace = 'TheCodingMachine\GraphQLite\Fixtures\Types'; + $this->finder = new ComposerFinder(); + $this->globTTL = 10; + } + + /** @dataProvider loadsClassListProvider */ + public function testLoadsClassList(array $expectedClasses, string $namespace): void + { + $ns = new NS( + namespace: $namespace, + cache: $this->cache, + finder: $this->finder, + globTTL: null, + ); + + self::assertEqualsCanonicalizing($expectedClasses, array_keys($ns->getClassList())); + } + + public static function loadsClassListProvider(): iterable + { + yield 'autoload' => [ + [ + TestFactory::class, + GetterSetterType::class, + FooType::class, + MagicGetterSetterType::class, + FooExtendType::class, + NoTypeAnnotation::class, + AbstractFooType::class, + EnumType::class + ], + 'TheCodingMachine\GraphQLite\Fixtures\Types', + ]; + + // The class should be ignored. + yield 'incorrect namespace class without autoload' => [ + [], + 'TheCodingMachine\GraphQLite\Fixtures\BadNamespace', + ]; + } + + public function testCaching(): void + { + $ns = new NS( + namespace: $this->namespace, + cache: $this->cache, + finder: $this->finder, + globTTL: $this->globTTL, + ); + self::assertNotNull($ns->getClassList()); + + // create with mock finder to test cache + $finder = $this->createMock(FinderInterface::class); + $finder->expects(self::never())->method('inNamespace')->willReturnSelf(); + $ns = new NS( + namespace: $this->namespace, + cache: $this->cache, + finder: $finder, + globTTL: $this->globTTL, + ); + self::assertNotNull($ns->getClassList()); + } + + public function testCachingWithInvalidKey(): void + { + $exception = new class extends Exception implements InvalidArgumentException { + }; + $cache = $this->createMock(CacheInterface::class); + $cache->expects(self::once())->method('get')->willThrowException($exception); + $cache->expects(self::once())->method('set')->willThrowException($exception); + $ns = new NS( + namespace: $this->namespace, + cache: $cache, + finder: $this->finder, + globTTL: $this->globTTL, + ); + $ns->getClassList(); + } + + public function testCachingWithInvalidCache(): void + { + $cache = $this->createMock(CacheInterface::class); + $cache->expects(self::once())->method('get')->willReturn(['foo']); + $ns = new NS( + namespace: $this->namespace, + cache: $cache, + finder: $this->finder, + globTTL: $this->globTTL, + ); + $classList = $ns->getClassList(); + self::assertNotNull($classList); + self::assertNotEmpty($classList); + } + + public function testFinderWithUnexpectedOutput() { + + $finder = $this->createMock(FinderInterface::class); + $finder->expects(self::once())->method('inNamespace')->willReturnSelf(); + $finder->expects(self::once())->method('getIterator')->willReturn(new \ArrayIterator([ 'test' => new \ReflectionException()])); + $ns = new NS( + namespace: $this->namespace, + cache: $this->cache, + finder: $finder, + globTTL: $this->globTTL, + ); + $classList = $ns->getClassList(); + self::assertNotNull($classList); + self::assertEmpty($classList);} +}