Issue #3512835 by nicxvan: [11.1.x] Add BC stubs for Hook ordering

(cherry picked from commit 64bfef04fa)
11.0.x
Lee Rowlands 2025-05-06 08:42:16 +10:00
parent 902e7a8c57
commit 55dfb3cd96
No known key found for this signature in database
GPG Key ID: 2B829A3DF9204DC4
13 changed files with 496 additions and 6 deletions

View File

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Drupal\Core\Hook\Attribute;
use Drupal\Core\Hook\Order\OrderInterface;
/**
* This class will not have an effect until Drupal 11.1.0.
*
@ -12,13 +14,29 @@ namespace Drupal\Core\Hook\Attribute;
* #LegacyHook attributes.
*/
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class Hook {
class Hook implements HookAttributeInterface {
/**
* The hook prefix such as `form`.
*
* @var string
*/
public const string PREFIX = '';
/**
* The hook suffix such as `alter`.
*
* @var string
*/
public const string SUFFIX = '';
/**
* Constructs a Hook attribute object.
*
* @param string $hook
* The short hook name, without the 'hook_' prefix.
* $hook is only optional when Hook is extended and a PREFIX or SUFFIX is
* defined. When using the [#Hook] attribute directly $hook is required.
* See Drupal\Core\Hook\Attribute\Preprocess.
* @param string $method
* (optional) The method name. If this attribute is on a method, this
* parameter is not required. If this attribute is on a class and this
@ -30,16 +48,26 @@ class Hook {
* are executed first. If omitted, the module order is used to order the
* hook implementations.
* @param string|null $module
* (optional) The module this implementation is for. This allows one module to
* implement a hook on behalf of another module. Defaults to the module the
* implementation is in.
* (optional) The module this implementation is for. This allows one module
* to implement a hook on behalf of another module. Defaults to the module
* the implementation is in.
* @param \Drupal\Core\Hook\Order\OrderInterface|null $order
* (optional) Set the order of the implementation. This parameter is
* supported in Drupal 11.2 and greater. It will have no affect in Drupal
* 11.1.
*/
public function __construct(
public string $hook,
public string $hook = '',
public string $method = '',
public ?int $priority = NULL,
public ?string $module = NULL,
) {}
public OrderInterface|null $order = NULL,
) {
$this->hook = implode('_', array_filter([static::PREFIX, $hook, static::SUFFIX]));
if ($this->hook === '') {
throw new \LogicException('The Hook attribute or an attribute extending the Hook attribute must provide the $hook parameter, a PREFIX or a SUFFIX.');
}
}
/**
* Set the method the hook should apply to.

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Drupal\Core\Hook\Attribute;
/**
* Common interface for attributes used for hook discovery.
*
* This does not imply any shared behavior, it is only used to collect all
* hook-related attributes in the same call.
*
* @internal
*/
interface HookAttributeInterface {}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Drupal\Core\Hook\Attribute;
/**
* Prevents procedural hook_module_implements_alter from executing.
*
* This allows the use of the legacy hook_module_implements_alter alongside
* attribute-based ordering. Providing support for versions of Drupal older
* than 11.2.0.
*
* Marking hook_module_implements_alter as #LegacyModuleImplementsAlter will
* prevent hook_module_implements_alter from running when attribute-based
* ordering is available.
*
* On older versions of Drupal which are not aware of attribute-based ordering,
* only the legacy hook implementation is executed.
*/
#[\Attribute(\Attribute::TARGET_FUNCTION)]
class LegacyModuleImplementsAlter {}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Drupal\Core\Hook\Attribute;
/**
* Removes an already existing implementation.
*
* The effect of this attribute is independent from the specific class or method
* on which it is placed.
*
* This attribute is supported in Drupal 11.2 and greater.
*/
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class RemoveHook implements HookAttributeInterface {
/**
* Constructs a RemoveHook object.
*
* @param string $hook
* The hook name from which to remove the target implementation.
* @param class-string $class
* The class name of the target hook implementation.
* @param string $method
* The method name of the target hook implementation.
* If the class instance itself is the listener, this should be '__invoke'.
*/
public function __construct(
public readonly string $hook,
public readonly string $class,
public readonly string $method,
) {}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Drupal\Core\Hook\Attribute;
use Drupal\Core\Hook\Order\OrderInterface;
/**
* Sets the order of an already existing implementation.
*
* The effect of this attribute is independent from the specific class or method
* on which it is placed.
*
* This attribute is supported in Drupal 11.2 and greater.
*/
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class ReorderHook implements HookAttributeInterface {
/**
* Constructs a ReorderHook object.
*
* @param string $hook
* The hook for which to reorder an implementation.
* @param class-string $class
* The class of the targeted hook implementation.
* @param string $method
* The method name of the targeted hook implementation.
* If the #[Hook] attribute is on the class itself, this should be
* '__invoke'.
* @param \Drupal\Core\Hook\Order\OrderInterface $order
* Specifies a new position for the targeted hook implementation relative to
* other implementations.
*/
public function __construct(
public string $hook,
public string $class,
public string $method,
public OrderInterface $order,
) {}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Drupal\Core\Hook\Order;
use Drupal\Core\Hook\OrderOperation\FirstOrLast;
use Drupal\Core\Hook\OrderOperation\OrderOperation;
/**
* Set this implementation to be first or last.
*/
enum Order: int implements OrderInterface {
// This implementation should execute first.
case First = 1;
// This implementation should execute last.
case Last = 0;
/**
* {@inheritdoc}
*/
public function getOperation(string $identifier): OrderOperation {
return new FirstOrLast($identifier, $this === self::Last);
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Drupal\Core\Hook\Order;
/**
* Set this implementation to be after others.
*/
readonly class OrderAfter extends RelativeOrderBase {
/**
* {@inheritdoc}
*/
protected function isAfter(): bool {
return TRUE;
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Drupal\Core\Hook\Order;
/**
* Set this implementation to be before others.
*/
readonly class OrderBefore extends RelativeOrderBase {
/**
* {@inheritdoc}
*/
protected function isAfter(): bool {
return FALSE;
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types = 1);
namespace Drupal\Core\Hook\Order;
use Drupal\Core\Hook\OrderOperation\OrderOperation;
/**
* Interface for order specifiers used in hook attributes.
*
* Objects implementing this interface allow for relative ordering of hooks.
* These objects are passed as an order parameter to a Hook or ReorderHook
* attribute.
* Order::First and Order::Last are simple order operations that move the hook
* implementation to the first or last position of hooks at the time the order
* directive is executed.
* @code
* #[Hook('custom_hook', order: Order::First)]
* @endcode
* OrderBefore and OrderAfter take additional parameters
* for ordering. See Drupal\Core\Hook\Order\RelativeOrderBase.
* @code
* #[Hook('custom_hook', order: new OrderBefore(['other_module']))]
* @endcode
*/
interface OrderInterface {
/**
* Gets order operations specified by this object.
*
* @param string $identifier
* Identifier of the implementation to move to a new position. The format
* is the class followed by "::" then the method name. For example,
* "Drupal\my_module\Hook\MyModuleHooks::methodName".
*
* @return \Drupal\Core\Hook\OrderOperation\OrderOperation
* Order operation to apply to a hook implementation list.
*/
public function getOperation(string $identifier): OrderOperation;
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Drupal\Core\Hook\Order;
use Drupal\Core\Hook\OrderOperation\BeforeOrAfter;
use Drupal\Core\Hook\OrderOperation\OrderOperation;
/**
* Orders an implementation relative to other implementations.
*/
abstract readonly class RelativeOrderBase implements OrderInterface {
/**
* Constructor.
*
* @param list<string> $modules
* A list of modules the implementations should order against.
* @param list<array{class-string, string}> $classesAndMethods
* A list of implementations to order against, as [$class, $method].
*/
public function __construct(
public array $modules = [],
public array $classesAndMethods = [],
) {
if (!$this->modules && !$this->classesAndMethods) {
throw new \LogicException('Order must provide either modules or class-method pairs to order against.');
}
}
/**
* Specifies the ordering direction.
*
* @return bool
* TRUE, if the ordered implementation should be inserted after the
* implementations specified in the constructor.
*/
abstract protected function isAfter(): bool;
/**
* {@inheritdoc}
*/
public function getOperation(string $identifier): OrderOperation {
return new BeforeOrAfter(
$identifier,
$this->modules,
array_map(
static fn(array $class_and_method) => implode('::', $class_and_method),
$this->classesAndMethods,
),
$this->isAfter(),
);
}
}

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types = 1);
namespace Drupal\Core\Hook\OrderOperation;
/**
* Moves one listener to be called before or after other listeners.
*
* @internal
*/
class BeforeOrAfter extends OrderOperation {
/**
* Constructor.
*
* @param string $identifier
* Identifier of the implementation to move to a new position. The format
* is the class followed by "::" then the method name. For example,
* "Drupal\my_module\Hook\MyModuleHooks::methodName".
* @param list<string> $modulesToOrderAgainst
* Module names of listeners to order against.
* @param list<string> $identifiersToOrderAgainst
* Identifiers of listeners to order against.
* The format is "$class::$method".
* @param bool $isAfter
* TRUE, if the listener to move should be moved after the listener to order
* against, FALSE if it should be moved before.
*/
public function __construct(
protected readonly string $identifier,
protected readonly array $modulesToOrderAgainst,
protected readonly array $identifiersToOrderAgainst,
protected readonly bool $isAfter,
) {}
/**
* {@inheritdoc}
*/
public function apply(array &$identifiers, array $module_finder): void {
assert(array_is_list($identifiers));
$index = array_search($this->identifier, $identifiers);
if ($index === FALSE) {
// Nothing to reorder.
return;
}
$identifiers_to_order_against = $this->identifiersToOrderAgainst;
if ($this->modulesToOrderAgainst) {
$identifiers_to_order_against = [
...$identifiers_to_order_against,
...array_keys(array_intersect($module_finder, $this->modulesToOrderAgainst)),
];
}
$indices_to_order_against = array_keys(array_intersect($identifiers, $identifiers_to_order_against));
if ($indices_to_order_against === []) {
return;
}
if ($this->isAfter) {
$max_index_to_order_against = max($indices_to_order_against);
if ($index >= $max_index_to_order_against) {
// The element is already after the other elements.
return;
}
array_splice($identifiers, $max_index_to_order_against + 1, 0, $this->identifier);
// Remove the element after splicing.
unset($identifiers[$index]);
$identifiers = array_values($identifiers);
}
else {
$min_index_to_order_against = min($indices_to_order_against);
if ($index <= $min_index_to_order_against) {
// The element is already before the other elements.
return;
}
// Remove the element before splicing.
unset($identifiers[$index]);
array_splice($identifiers, $min_index_to_order_against, 0, $this->identifier);
}
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types = 1);
namespace Drupal\Core\Hook\OrderOperation;
/**
* Moves one listener to the start or end of the list.
*
* @internal
*/
class FirstOrLast extends OrderOperation {
/**
* Constructor.
*
* @param string $identifier
* Identifier of the implementation to move to a new position. The format
* is the class followed by "::" then the method name. For example,
* "Drupal\my_module\Hook\MyModuleHooks::methodName".
* @param bool $isLast
* TRUE to move to the end, FALSE to move to the start.
*/
public function __construct(
protected readonly string $identifier,
protected readonly bool $isLast,
) {}
/**
* {@inheritdoc}
*/
public function apply(array &$identifiers, array $module_finder): void {
$index = array_search($this->identifier, $identifiers);
if ($index === FALSE) {
// The element does not exist.
return;
}
unset($identifiers[$index]);
if ($this->isLast) {
$identifiers[] = $this->identifier;
}
else {
$identifiers = [$this->identifier, ...$identifiers];
}
$identifiers = array_values($identifiers);
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types = 1);
namespace Drupal\Core\Hook\OrderOperation;
/**
* Base class for order operations.
*/
abstract class OrderOperation {
/**
* Changes the order of a list of hook implementations.
*
* @param list<string> $identifiers
* Hook implementation identifiers, as "$class::$method", to be changed by
* reference.
* The order operation must make sure that the array remains a list, and
* that the values are the same as before.
* @param array<string, string> $module_finder
* Lookup map to find a module name for each implementation.
* This may contain more entries than $identifiers.
*/
abstract public function apply(array &$identifiers, array $module_finder): void;
/**
* Converts the operation to a structure that can be stored in the container.
*
* @return array
* Packed operation.
*/
final public function pack(): array {
$is_before_or_after = match(get_class($this)) {
BeforeOrAfter::class => TRUE,
FirstOrLast::class => FALSE,
};
return [$is_before_or_after, get_object_vars($this)];
}
/**
* Converts the stored operation to objects that can apply ordering rules.
*
* @param array $packed_operation
* Packed operation.
*
* @return self
* Unpacked operation.
*/
final public static function unpack(array $packed_operation): self {
[$is_before_or_after, $args] = $packed_operation;
$class = $is_before_or_after ? BeforeOrAfter::class : FirstOrLast::class;
return new $class(...$args);
}
}