Skip to content
Snippets Groups Projects
Commit 366cef4e authored by dpi's avatar dpi
Browse files

Issue #3266008 by dpi, larowlan: Add a compiler pass to auto-discover services

parent 3f4cbc36
No related branches found
No related tags found
No related merge requests found
......@@ -4,35 +4,131 @@ declare(strict_types=1);
namespace Drupal\hux;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\hux\Attribute\Alter;
use Drupal\hux\Attribute\Hook;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
/**
* Hux compiler pass.
*
* Drupals' service_collector via TaggedHandlersPass requires the 'call' method
* to implement an interface. We don't require Hook implementors to implement an
* interface.
* Find files in src/Hooks directories in modules and adds them to the
* container as a service with a 'hooks' tag.
*
* Adds services tagged with 'hooks' as a method call to the Hux module handler.
* Drupals' service_collector cannot be used since TaggedHandlersPass requires
* the 'call' method to implement an interface: We don't require Hook
* implementors to implement an interface.
*/
final class HuxCompilerPass implements CompilerPassInterface {
/**
* {@inheritdoc}
*/
public function process(ContainerBuilder $container) {
$definition = $container->findDefinition('hux.module_handler');
public function process(ContainerBuilder $container): void {
/** @var class-string[] $hooksClasses */
$hooksClasses = [];
foreach ($container->findTaggedServiceIds('hooks') as $id => $tags) {
$hooksClasses[] = $container->getDefinition($id)->getClass();
}
foreach ($this->getHuxClasses($container->getParameter('container.namespaces')) as $className) {
// Don't create a service definition if this class is already a service.
if (in_array($className, $hooksClasses, TRUE)) {
continue;
}
$idSuffix = (new CamelCaseToSnakeCaseNameConverter())
->normalize(str_replace('\\', '_', $className));
$definition = new Definition($className);
$definition
->addTag('hooks')
->setPrivate(TRUE);
if ((new \ReflectionClass($className))->isSubclassOf(ContainerInjectionInterface::class)) {
$definition
->setFactory([$className, 'create'])
->setArguments([new Reference('service_container')]);
}
$container->setDefinition('hux.auto.' . $idSuffix, $definition);
}
$huxModuleHandler = $container->findDefinition('hux.module_handler');
foreach ($container->findTaggedServiceIds('hooks') as $id => $tags) {
$serviceDefinition = $container->getDefinition($id);
/** @var class-string|null $className */
$className = $serviceDefinition->getClass();
preg_match_all('/^Drupal\\\\(?<moduleName>[a-z_0-9]{1,32})\\\\.*$/m', $className, $matches, PREG_SET_ORDER);
$moduleName = $matches[0]['moduleName'] ?? throw new \Exception(sprintf('Could not determine module name from class %s', $className));
$definition->addMethodCall('addHookImplementation', [
$huxModuleHandler->addMethodCall('addHookImplementation', [
$id,
$moduleName,
]);
}
}
/**
* Get Hux classes for the provided namespaces.
*
* @param array<class-string, string> $namespaces
* An array of namespaces. Where keys are class strings and values are
* paths.
*
* @return \Generator<class-string>
* Generates class strings.
*
* @throws \ReflectionException
*/
private function getHuxClasses(array $namespaces) {
foreach ($namespaces as $namespace => $dirs) {
$dirs = (array) $dirs;
foreach ($dirs as $dir) {
$dir .= '/Hooks';
if (!file_exists($dir)) {
continue;
}
$namespace .= '\\Hooks';
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($iterator as $fileinfo) {
if ($fileinfo->getExtension() !== 'php') {
continue;
}
/** @var \RecursiveDirectoryIterator|null $subDir */
$subDir = $iterator->getSubIterator();
if (NULL === $subDir) {
continue;
}
$subDir = $subDir->getSubPath();
$subDir = $subDir ? str_replace(DIRECTORY_SEPARATOR, '\\', $subDir) . '\\' : '';
$class = $namespace . '\\' . $subDir . $fileinfo->getBasename('.php');
$reflectionClass = new \ReflectionClass($class);
$methods = $reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC);
foreach ($methods as $reflectionMethod) {
if (count($reflectionMethod->getAttributes(Hook::class)) > 0) {
yield $class;
break;
}
if (count($reflectionMethod->getAttributes(Alter::class)) > 0) {
yield $class;
break;
}
}
}
}
}
}
}
name: Hux Auto Test
type: module
description: Tests for HUX.
package: Testing
<?php
declare(strict_types=1);
namespace Drupal\hux_auto_test\Hooks;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\hux\Attribute\Hook;
use Drupal\hux_test\HuxTestCallTracker;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* A hooks class with container injection.
*/
final class HuxAutoContainerInjection implements ContainerInjectionInterface {
/**
* Creates a new HuxAutoContainerInjection.
*/
public function __construct(
protected TimeInterface $time,
) {
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container): static {
return new static(
$container->get('datetime.time'),
);
}
#[Hook('test_hook')]
public function testHook(string $something): void {
HuxTestCallTracker::record([__CLASS__, __FUNCTION__, $something, $this->time->getRequestTime()]);
}
}
<?php
declare(strict_types=1);
namespace Drupal\hux_auto_test\Hooks;
/**
* A hooks class with no hooks.
*/
final class HuxAutoEmpty {
}
<?php
declare(strict_types=1);
namespace Drupal\hux_auto_test\Hooks;
use Drupal\hux\Attribute\Hook;
use Drupal\hux_test\HuxTestCallTracker;
/**
* A hooks class with multiple hooks.
*/
final class HuxAutoMultiple {
#[Hook('test_hook')]
public function testHook1(string $something): void {
HuxTestCallTracker::record([__CLASS__, __FUNCTION__, $something]);
}
#[Hook('test_hook')]
public function testHook2(string $something): void {
HuxTestCallTracker::record([__CLASS__, __FUNCTION__, $something]);
}
}
<?php
declare(strict_types=1);
namespace Drupal\hux_auto_test\Hooks;
use Drupal\hux\Attribute\Hook;
use Drupal\hux_test\HuxTestCallTracker;
/**
* A hooks class with a single hook.
*/
final class HuxAutoSingle {
#[Hook('test_hook')]
public function testHook(string $something): void {
HuxTestCallTracker::record([__CLASS__, __FUNCTION__, $something]);
}
}
<?php
declare(strict_types=1);
namespace Drupal\hux_auto_test\Hooks\Sub;
use Drupal\hux\Attribute\Hook;
use Drupal\hux_test\HuxTestCallTracker;
final class HuxAutoSubHooks {
#[Hook('test_hook')]
public function testHook(string $something): void {
HuxTestCallTracker::record([__CLASS__, __FUNCTION__, $something]);
}
}
<?php
declare(strict_types=1);
namespace Drupal\hux_auto_test;
use Drupal\Component\Datetime\TimeInterface;
/**
* Service used to simulate time.
*/
class TimeMachine implements TimeInterface {
protected \DateTimeImmutable $time;
/**
* Constructs a new TimeMachine.
*/
public function __construct(string $time) {
$this->time = new \DateTimeImmutable($time);
}
/**
* {@inheritdoc}
*/
public function getRequestTime() {
return $this->time->getTimestamp();
}
/**
* {@inheritdoc}
*/
public function getRequestMicroTime() {
return (float) $this->time->getTimestamp();
}
/**
* {@inheritdoc}
*/
public function getCurrentTime() {
return $this->time->getTimestamp();
}
/**
* {@inheritdoc}
*/
public function getCurrentMicroTime() {
return (float) $this->time->getTimestamp();
}
}
<?php
declare(strict_types=1);
namespace Drupal\Tests\hux\Kernel;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\hux_auto_test\Hooks\HuxAutoContainerInjection;
use Drupal\hux_auto_test\Hooks\HuxAutoMultiple;
use Drupal\hux_auto_test\Hooks\HuxAutoSingle;
use Drupal\hux_auto_test\Hooks\Sub\HuxAutoSubHooks;
use Drupal\hux_auto_test\TimeMachine;
use Drupal\hux_test\HuxTestCallTracker;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests automatic discovery of classes in Hooks directories.
*
* @group hux
* @coversDefaultClass \Drupal\hux\HuxCompilerPass
*/
final class HuxAutoTest extends KernelTestBase {
private string $time = '123';
/**
* {@inheritdoc}
*/
protected static $modules = [
'hux',
'hux_auto_test',
];
/**
* Tests classes with hooks are discovered.
*
* @covers ::process
* @covers ::getHuxClasses
*/
public function testDiscovery(): void {
$this->moduleHandler()->invokeAll('test_hook', ['bar']);
$this->assertEqualsCanonicalizing([
// 'HuxAutoEmpty' must not be present.
[
HuxAutoSingle::class,
'testHook',
'bar',
],
[
HuxAutoMultiple::class,
'testHook1',
'bar',
],
[
HuxAutoMultiple::class,
'testHook2',
'bar',
],
[
HuxAutoSubHooks::class,
'testHook',
'bar',
],
[
HuxAutoContainerInjection::class,
'testHook',
'bar',
$this->time,
],
], HuxTestCallTracker::$calls);
}
/**
* The module handler.
*/
private function moduleHandler(): ModuleHandlerInterface {
return \Drupal::service('module_handler');
}
/**
* {@inheritdoc}
*
* Register a database cache backend rather than memory-based.
*/
public function register(ContainerBuilder $container) {
parent::register($container);
$container->getDefinition('datetime.time')
->setClass(TimeMachine::class)
->setArgument(0, '@' . $this->time);
}
}
<?php
declare(strict_types=1);
namespace Drupal\Tests\hux\Unit;
use Drupal\hux\HuxCompilerPass;
use Drupal\hux_auto_test\Hooks\HuxAutoContainerInjection;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\DependencyInjection\Reference;
/**
* Tests compiler pass.
*
* @group hux
* @coversDefaultClass \Drupal\hux\HuxCompilerPass
*/
final class HuxCompilerPassUnitTest extends UnitTestCase {
/**
* Tests automatic discovery of classes in Hooks directories.
*/
public function testAutoClassDiscovery(): void {
$parameterBag = $this->createMock(ParameterBagInterface::class);
$parameterBag->expects($this->any())
->method('get')
->with('container.namespaces')
->willReturn([
'Drupal\hux_auto_test' => realpath(__DIR__ . '/../../modules/hux_auto_test/src'),
]);
$containerBuilder = new ContainerBuilder($parameterBag);
$huxModuleHandlerDefinition = $this->createMock(Definition::class);
$huxModuleHandlerDefinition->expects($this->any())
->method('isPublic')
->willReturn(TRUE);
$containerBuilder->setDefinition('hux.module_handler', $huxModuleHandlerDefinition);
$huxModuleHandlerDefinition->expects($this->exactly(4))
->method('addMethodCall')
->withConsecutive(
[
'addHookImplementation',
[
'hux.auto.drupal_hux_auto_test__hooks__sub__hux_auto_sub_hooks',
'hux_auto_test',
],
],
[
'addHookImplementation',
[
'hux.auto.drupal_hux_auto_test__hooks__hux_auto_single',
'hux_auto_test',
],
],
[
'addHookImplementation',
[
'hux.auto.drupal_hux_auto_test__hooks__hux_auto_multiple',
'hux_auto_test',
],
],
[
'addHookImplementation',
[
'hux.auto.drupal_hux_auto_test__hooks__hux_auto_container_injection',
'hux_auto_test',
],
],
);
(new HuxCompilerPass())->process($containerBuilder);
$definition = $containerBuilder->getDefinition('hux.auto.drupal_hux_auto_test__hooks__hux_auto_container_injection');
$this->assertEquals([HuxAutoContainerInjection::class, 'create'], $definition->getFactory());
/** @var \Symfony\Component\DependencyInjection\Reference $arg1 */
$arg1 = $definition->getArgument(0);
$this->assertInstanceOf(Reference::class, $arg1);
$this->assertEquals('service_container', (string) $arg1);
$definition = $containerBuilder->getDefinition('hux.auto.drupal_hux_auto_test__hooks__hux_auto_single');
$this->assertNull($definition->getFactory());
$this->assertCount(0, $definition->getArguments());
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment