From 756c93fb0bcbf991de629b81879d7fa374369eca Mon Sep 17 00:00:00 2001 From: catch <6915-catch@users.noreply.drupalcode.org> Date: Tue, 3 Dec 2024 10:29:23 +0000 Subject: [PATCH] Issue #3490355 by nicxvan, godotislate, catch: Add procedural hook short circuit per module or file --- .../Hook/Attribute/StopProceduralHookScan.php | 16 +++ .../Drupal/Core/Hook/HookCollectorPass.php | 123 ++++++++++-------- .../hook_collector_skip_procedural.info.yml | 5 + .../hook_collector_skip_procedural.module | 18 +++ ...ook_collector_skip_procedural.services.yml | 2 + ...llector_skip_procedural_attribute.info.yml | 6 + ...collector_skip_procedural_attribute.module | 42 ++++++ .../Core/Hook/HookCollectorPassTest.php | 22 ++++ 8 files changed, 182 insertions(+), 52 deletions(-) create mode 100644 core/lib/Drupal/Core/Hook/Attribute/StopProceduralHookScan.php create mode 100644 core/modules/system/tests/modules/hook_collector_skip_procedural/hook_collector_skip_procedural.info.yml create mode 100644 core/modules/system/tests/modules/hook_collector_skip_procedural/hook_collector_skip_procedural.module create mode 100644 core/modules/system/tests/modules/hook_collector_skip_procedural/hook_collector_skip_procedural.services.yml create mode 100644 core/modules/system/tests/modules/hook_collector_skip_procedural_attribute/hook_collector_skip_procedural_attribute.info.yml create mode 100644 core/modules/system/tests/modules/hook_collector_skip_procedural_attribute/hook_collector_skip_procedural_attribute.module diff --git a/core/lib/Drupal/Core/Hook/Attribute/StopProceduralHookScan.php b/core/lib/Drupal/Core/Hook/Attribute/StopProceduralHookScan.php new file mode 100644 index 000000000000..73f0ce6915bd --- /dev/null +++ b/core/lib/Drupal/Core/Hook/Attribute/StopProceduralHookScan.php @@ -0,0 +1,16 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Hook\Attribute; + +/** + * Defines a StopProceduralHookScan attribute object. + * + * This allows contrib and core to mark when a file has no more + * procedural hooks. + */ +#[\Attribute(\Attribute::TARGET_FUNCTION)] +class StopProceduralHookScan { + +} diff --git a/core/lib/Drupal/Core/Hook/HookCollectorPass.php b/core/lib/Drupal/Core/Hook/HookCollectorPass.php index 006ddbec0171..4fce50e085a0 100644 --- a/core/lib/Drupal/Core/Hook/HookCollectorPass.php +++ b/core/lib/Drupal/Core/Hook/HookCollectorPass.php @@ -10,6 +10,7 @@ use Drupal\Core\Extension\ProceduralCall; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Hook\Attribute\LegacyHook; +use Drupal\Core\Hook\Attribute\StopProceduralHookScan; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -74,7 +75,7 @@ class HookCollectorPass implements CompilerPassInterface { * {@inheritdoc} */ public function process(ContainerBuilder $container): void { - $collector = static::collectAllHookImplementations($container->getParameter('container.modules')); + $collector = static::collectAllHookImplementations($container->getParameter('container.modules'), $container); $map = []; $container->register(ProceduralCall::class, ProceduralCall::class) ->addArgument($collector->includes); @@ -123,6 +124,8 @@ public function process(ContainerBuilder $container): void { * @param array $module_filenames * An associative array. Keys are the module names, values are relevant * info yml file path. + * @param Symfony\Component\DependencyInjection\ContainerBuilder|null $container + * The container. * * @return static * A HookCollectorPass instance holding all hook implementations and @@ -130,15 +133,18 @@ public function process(ContainerBuilder $container): void { * * @internal * This method is only used by ModuleHandler. + * + * * @todo Pass only $container when ModuleHandler->add is removed https://www.drupal.org/project/drupal/issues/3481778 */ - public static function collectAllHookImplementations(array $module_filenames): static { + public static function collectAllHookImplementations(array $module_filenames, ?ContainerBuilder $container = NULL): static { $modules = array_map(fn ($x) => preg_quote($x, '/'), array_keys($module_filenames)); // Longer modules first. usort($modules, fn($a, $b) => strlen($b) - strlen($a)); $module_preg = '/^(?<function>(?<module>' . implode('|', $modules) . ')_(?!preprocess_)(?!update_\d)(?<hook>[a-zA-Z0-9_\x80-\xff]+$))/'; $collector = new static(); foreach ($module_filenames as $module => $info) { - $collector->collectModuleHookImplementations(dirname($info['pathname']), $module, $module_preg); + $skip_procedural = isset($container) ? $container->hasParameter("$module.hooks_converted") : FALSE; + $collector->collectModuleHookImplementations(dirname($info['pathname']), $module, $module_preg, $skip_procedural); } return $collector; } @@ -153,70 +159,83 @@ public static function collectAllHookImplementations(array $module_filenames): s * @param $module_preg * A regular expression matching every module, longer module names are * matched first. + * @param $skip_procedural + * Skip the procedural check for the current module. * * @return void */ - protected function collectModuleHookImplementations($dir, $module, $module_preg): void { + protected function collectModuleHookImplementations($dir, $module, $module_preg, bool $skip_procedural): void { $hook_file_cache = FileCacheFactory::get('hook_implementations'); $procedural_hook_file_cache = FileCacheFactory::get('procedural_hook_implementations:' . $module_preg); - $iterator = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS | \FilesystemIterator::FOLLOW_SYMLINKS); - $iterator = new \RecursiveCallbackFilterIterator($iterator, static::filterIterator(...)); - $iterator = new \RecursiveIteratorIterator($iterator); - /** @var \RecursiveDirectoryIterator | \RecursiveIteratorIterator $iterator*/ - foreach ($iterator as $fileinfo) { - assert($fileinfo instanceof \SplFileInfo); - $extension = $fileinfo->getExtension(); - $filename = $fileinfo->getPathname(); + // Check only hook classes. + if ($skip_procedural) { + $dir = $dir . '/src/Hook'; + } + + if (is_dir($dir)) { + $iterator = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS | \FilesystemIterator::FOLLOW_SYMLINKS); + $iterator = new \RecursiveCallbackFilterIterator($iterator, static::filterIterator(...)); + $iterator = new \RecursiveIteratorIterator($iterator); + /** @var \RecursiveDirectoryIterator | \RecursiveIteratorIterator $iterator*/ + foreach ($iterator as $fileinfo) { + assert($fileinfo instanceof \SplFileInfo); + $extension = $fileinfo->getExtension(); + $filename = $fileinfo->getPathname(); - if (($extension === 'module' || $extension === 'profile') && !$iterator->getDepth()) { - // There is an expectation for all modules and profiles to be loaded. - // .module and .profile files are not supposed to be in subdirectories. - include_once $filename; - } - if ($extension === 'php') { - $cached = $hook_file_cache->get($filename); - if ($cached) { - $class = $cached['class']; - $attributes = $cached['attributes']; + if (($extension === 'module' || $extension === 'profile') && !$iterator->getDepth() && !$skip_procedural) { + // There is an expectation for all modules and profiles to be loaded. + // .module and .profile files are not supposed to be in subdirectories. + + include_once $filename; } - else { - $namespace = preg_replace('#^src/#', "Drupal/$module/", $iterator->getSubPath()); - $class = $namespace . '/' . $fileinfo->getBasename('.php'); - $class = str_replace('/', '\\', $class); - if (class_exists($class)) { - $attributes = static::getHookAttributesInClass($class); - $hook_file_cache->set($filename, ['class' => $class, 'attributes' => $attributes]); + if ($extension === 'php') { + $cached = $hook_file_cache->get($filename); + if ($cached) { + $class = $cached['class']; + $attributes = $cached['attributes']; } else { - $attributes = []; + $namespace = preg_replace('#^src/#', "Drupal/$module/", $iterator->getSubPath()); + $class = $namespace . '/' . $fileinfo->getBasename('.php'); + $class = str_replace('/', '\\', $class); + if (class_exists($class)) { + $attributes = static::getHookAttributesInClass($class); + $hook_file_cache->set($filename, ['class' => $class, 'attributes' => $attributes]); + } + else { + $attributes = []; + } + } + foreach ($attributes as $attribute) { + $this->addFromAttribute($attribute, $class, $module); } } - foreach ($attributes as $attribute) { - $this->addFromAttribute($attribute, $class, $module); - } - } - else { - $implementations = $procedural_hook_file_cache->get($filename); - if ($implementations === NULL) { - $finder = MockFileFinder::create($filename); - $parser = new StaticReflectionParser('', $finder); - $implementations = []; - foreach ($parser->getMethodAttributes() as $function => $attributes) { - if (!StaticReflectionParser::hasAttribute($attributes, LegacyHook::class) && preg_match($module_preg, $function, $matches)) { - $implementations[] = ['function' => $function, 'module' => $matches['module'], 'hook' => $matches['hook']]; + elseif (!$skip_procedural) { + $implementations = $procedural_hook_file_cache->get($filename); + if ($implementations === NULL) { + $finder = MockFileFinder::create($filename); + $parser = new StaticReflectionParser('', $finder); + $implementations = []; + foreach ($parser->getMethodAttributes() as $function => $attributes) { + if (StaticReflectionParser::hasAttribute($attributes, StopProceduralHookScan::class)) { + break; + } + if (!StaticReflectionParser::hasAttribute($attributes, LegacyHook::class) && preg_match($module_preg, $function, $matches)) { + $implementations[] = ['function' => $function, 'module' => $matches['module'], 'hook' => $matches['hook']]; + } } + $procedural_hook_file_cache->set($filename, $implementations); + } + foreach ($implementations as $implementation) { + $this->addProceduralImplementation($fileinfo, $implementation['hook'], $implementation['module'], $implementation['function']); } - $procedural_hook_file_cache->set($filename, $implementations); - } - foreach ($implementations as $implementation) { - $this->addProceduralImplementation($fileinfo, $implementation['hook'], $implementation['module'], $implementation['function']); } - } - if ($extension === 'inc') { - $parts = explode('.', $fileinfo->getFilename()); - if (count($parts) === 3 && $parts[0] === $module) { - $this->groupIncludes[$parts[1]][] = $filename; + if ($extension === 'inc') { + $parts = explode('.', $fileinfo->getFilename()); + if (count($parts) === 3 && $parts[0] === $module) { + $this->groupIncludes[$parts[1]][] = $filename; + } } } } diff --git a/core/modules/system/tests/modules/hook_collector_skip_procedural/hook_collector_skip_procedural.info.yml b/core/modules/system/tests/modules/hook_collector_skip_procedural/hook_collector_skip_procedural.info.yml new file mode 100644 index 000000000000..2ce2c7a886ee --- /dev/null +++ b/core/modules/system/tests/modules/hook_collector_skip_procedural/hook_collector_skip_procedural.info.yml @@ -0,0 +1,5 @@ +name: 'Test skipping procedural for whole module' +type: module +description: 'Test skipping procedural for whole module.' +package: Testing +version: VERSION diff --git a/core/modules/system/tests/modules/hook_collector_skip_procedural/hook_collector_skip_procedural.module b/core/modules/system/tests/modules/hook_collector_skip_procedural/hook_collector_skip_procedural.module new file mode 100644 index 000000000000..5fe9407a0b22 --- /dev/null +++ b/core/modules/system/tests/modules/hook_collector_skip_procedural/hook_collector_skip_procedural.module @@ -0,0 +1,18 @@ +<?php + +/** + * @file + * Implement hooks. + */ + +declare(strict_types=1); + +/** + * This implements a hook but should not be picked up. + * + * We have set procedural_hooks: skip. + */ +function hook_collector_skip_procedural_cache_flush(): void { + // Set a global value we can check in test code. + $GLOBALS['skip_procedural_all'] = 'skip_procedural_all'; +} diff --git a/core/modules/system/tests/modules/hook_collector_skip_procedural/hook_collector_skip_procedural.services.yml b/core/modules/system/tests/modules/hook_collector_skip_procedural/hook_collector_skip_procedural.services.yml new file mode 100644 index 000000000000..205fd7c2b670 --- /dev/null +++ b/core/modules/system/tests/modules/hook_collector_skip_procedural/hook_collector_skip_procedural.services.yml @@ -0,0 +1,2 @@ +parameters: + hook_collector_skip_procedural.hooks_converted: true diff --git a/core/modules/system/tests/modules/hook_collector_skip_procedural_attribute/hook_collector_skip_procedural_attribute.info.yml b/core/modules/system/tests/modules/hook_collector_skip_procedural_attribute/hook_collector_skip_procedural_attribute.info.yml new file mode 100644 index 000000000000..9bac70877910 --- /dev/null +++ b/core/modules/system/tests/modules/hook_collector_skip_procedural_attribute/hook_collector_skip_procedural_attribute.info.yml @@ -0,0 +1,6 @@ +name: 'Test skipping procedural for part of the module' +type: module +description: 'Test skipping procedural for part of the module.' +package: Testing +version: VERSION +procedural_hooks: scan diff --git a/core/modules/system/tests/modules/hook_collector_skip_procedural_attribute/hook_collector_skip_procedural_attribute.module b/core/modules/system/tests/modules/hook_collector_skip_procedural_attribute/hook_collector_skip_procedural_attribute.module new file mode 100644 index 000000000000..e7b4bf3f6829 --- /dev/null +++ b/core/modules/system/tests/modules/hook_collector_skip_procedural_attribute/hook_collector_skip_procedural_attribute.module @@ -0,0 +1,42 @@ +<?php + +/** + * @file + * Implement hooks. + */ + +declare(strict_types=1); + +use Drupal\Core\Hook\Attribute\StopProceduralHookScan; + +/** + * This implements a hook and should be picked up. + * + * We have set procedural_hooks: scan. + */ +function hook_collector_skip_procedural_attribute_cache_flush(): void { + // Set a global value we can check in test code. + $GLOBALS['procedural_attribute_skip_find'] = 'procedural_attribute_skip_find'; +} + +/** + * This implements a hook but should not be picked up. + * + * This attribute should stop all procedural hooks after. + * We implement on behalf of other modules so we can pick them up. + */ +#[StopProceduralHookScan] +function hook_collector_on_behalf_procedural_cache_flush(): void { + // Set a global value we can check in test code. + $GLOBALS['procedural_attribute_skip_has_attribute'] = 'procedural_attribute_skip_has_attribute'; +} + +/** + * This implements a hook but should not be picked up. + * + * The attribute above should prevent this from being found. + */ +function hook_collector_on_behalf_cache_flush(): void { + // Set a global value we can check in test code. + $GLOBALS['procedural_attribute_skip_after_attribute'] = 'procedural_attribute_skip_after_attribute'; +} diff --git a/core/tests/Drupal/KernelTests/Core/Hook/HookCollectorPassTest.php b/core/tests/Drupal/KernelTests/Core/Hook/HookCollectorPassTest.php index e05ca8ab6019..82e8734e44da 100644 --- a/core/tests/Drupal/KernelTests/Core/Hook/HookCollectorPassTest.php +++ b/core/tests/Drupal/KernelTests/Core/Hook/HookCollectorPassTest.php @@ -101,4 +101,26 @@ public function testHooksImplementedOnBehalfFileCache(): void { $this->assertTrue(isset($GLOBALS['on_behalf_procedural'])); } + /** + * Test procedural hooks for a module are skipped when skip is set.. + */ + public function testProceduralHooksSkippedWhenConfigured(): void { + $module_installer = $this->container->get('module_installer'); + $this->assertTrue($module_installer->install(['hook_collector_skip_procedural'])); + $this->assertTrue($module_installer->install(['hook_collector_on_behalf_procedural'])); + $this->assertTrue($module_installer->install(['hook_collector_skip_procedural_attribute'])); + $this->assertTrue($module_installer->install(['hook_collector_on_behalf'])); + $this->assertFalse(isset($GLOBALS['skip_procedural_all'])); + $this->assertFalse(isset($GLOBALS['procedural_attribute_skip_has_attribute'])); + $this->assertFalse(isset($GLOBALS['procedural_attribute_skip_after_attribute'])); + $this->assertFalse(isset($GLOBALS['procedural_attribute_skip_find'])); + drupal_flush_all_caches(); + $this->assertFalse(isset($GLOBALS['skip_procedural_all'])); + $this->assertFalse(isset($GLOBALS['procedural_attribute_skip_has_attribute'])); + $this->assertFalse(isset($GLOBALS['procedural_attribute_skip_after_attribute'])); + // This is the only one that should be found. + $this->assertTrue(isset($GLOBALS['procedural_attribute_skip_find'])); + + } + } -- GitLab