Revert "Simplify HookContainer"

This apparently caused some change in how hook handlers are called (it
now calls e.g.  AbuseFilterHookHandler::onAbuseFilter-generateUserVars()
instead of AbuseFilterHookHandler::onAbuseFilter_generateUserVars()),
causing both test failures and errors on Beta.

This reverts commit d139eb07fe.

Bug: T338213
Change-Id: I50c3d1c5dfd2d7eeac59992156a8a644cf0197e5
This commit is contained in:
Lucas Werkmeister 2023-06-06 11:11:29 +02:00
parent 82b378c3b0
commit b0317287bc
14 changed files with 480 additions and 770 deletions

View file

@ -72,9 +72,7 @@ class FauxHookHandlerArray implements \ArrayAccess, \IteratorAggregate {
private function getHandler( $offset ) {
if ( $this->handlers === null ) {
// NOTE: getHandlerCallbacks() only exists to support this.
// It should be deleted when we no longer need it here.
$this->handlers = $this->hookContainer->getHandlerCallbacks( $this->name );
$this->handlers = $this->hookContainer->getLegacyHandlers( $this->name );
}
return $this->handlers[$offset] ?? null;
@ -83,9 +81,7 @@ class FauxHookHandlerArray implements \ArrayAccess, \IteratorAggregate {
#[\ReturnTypeWillChange]
public function getIterator() {
if ( $this->handlers === null ) {
// NOTE: getHandlerCallbacks() only exists to support this.
// It should be deleted when we no longer need it here.
$this->handlers = $this->hookContainer->getHandlerCallbacks( $this->name );
$this->handlers = $this->hookContainer->getLegacyHandlers( $this->name );
}
return new \ArrayIterator( $this->handlers );

View file

@ -26,8 +26,6 @@
namespace MediaWiki\HookContainer;
use Closure;
use InvalidArgumentException;
use LogicException;
use MWDebug;
use MWException;
use UnexpectedValueException;
@ -36,16 +34,6 @@ use Wikimedia\NonSerializable\NonSerializableTrait;
use Wikimedia\ObjectFactory\ObjectFactory;
use Wikimedia\ScopedCallback;
use Wikimedia\Services\SalvageableService;
use function array_filter;
use function array_keys;
use function array_merge;
use function array_shift;
use function array_unique;
use function is_array;
use function is_object;
use function is_string;
use function strpos;
use function strtr;
/**
* HookContainer class.
@ -57,19 +45,19 @@ use function strtr;
class HookContainer implements SalvageableService {
use NonSerializableTrait;
public const NOOP = '*no-op*';
/** @var array Hooks and their callbacks registered through $this->register() */
private $dynamicHandlers = [];
/**
* Normalized hook handlers, as a 3D array:
* - the first level maps hook names to lists of handlers
* - the second is a list of handlers
* - each handler is an associative array with some well known keys, as returned by normalizeHandler()
* @var array<array>
* @var array Tombstone count by hook name
*/
private $handlers = [];
private $tombstones = [];
/** @var array<object> handler name and their handler objects */
private $handlerObjects = [];
/** @var string magic value for use in $dynamicHandlers */
private const TOMBSTONE = 'TOMBSTONE';
/** @var array handler name and their handler objects */
private $handlersByName = [];
/** @var HookRegistry */
private $registry;
@ -104,11 +92,12 @@ class HookContainer implements SalvageableService {
*/
public function salvage( SalvageableService $other ) {
Assert::parameterType( self::class, $other, '$other' );
if ( $this->handlers || $this->handlerObjects ) {
if ( $this->dynamicHandlers || $this->handlersByName ) {
throw new MWException( 'salvage() must be called immediately after construction' );
}
$this->handlerObjects = $other->handlerObjects;
$this->handlers = $other->handlers;
$this->handlersByName = $other->handlersByName;
$this->dynamicHandlers = $other->dynamicHandlers;
$this->tombstones = $other->tombstones;
}
/**
@ -134,44 +123,53 @@ class HookContainer implements SalvageableService {
* @throws UnexpectedValueException if handlers return an invalid value
*/
public function run( string $hook, array $args = [], array $options = [] ): bool {
$checkDeprecation = isset( $options['deprecatedVersion'] );
$abortable = $options['abortable'] ?? true;
foreach ( $this->getHandlers( $hook, $options ) as $handler ) {
// The handler is "shadowed" by a scoped hook handler.
if ( ( $handler['skip'] ?? 0 ) > 0 ) {
continue;
}
if ( $checkDeprecation ) {
$this->checkDeprecation( $hook, $handler['functionName'], $options );
}
// Compose callback arguments.
if ( !empty( $handler['args'] ) ) {
$callbackArgs = array_merge( $handler['args'], $args );
} else {
$callbackArgs = $args;
}
// Call the handler.
$callback = $handler['callback'];
$return = $callback( ...$callbackArgs );
// Handler returned false, signal abort to caller
if ( $return === false ) {
if ( !$abortable ) {
throw new UnexpectedValueException( "Handler {$handler['functionName']}" .
" return false for unabortable $hook." );
$legacyHandlers = $this->getLegacyHandlers( $hook );
$options = array_merge(
$this->registry->getDeprecatedHooks()->getDeprecationInfo( $hook ) ?? [],
$options
);
// Equivalent of legacy Hooks::runWithoutAbort()
$notAbortable = ( isset( $options['abortable'] ) && $options['abortable'] === false );
foreach ( $legacyHandlers as $handler ) {
$normalizedHandler = $this->normalizeHandler( $handler, $hook );
if ( $normalizedHandler ) {
$functionName = $normalizedHandler['functionName'];
$return = $this->callLegacyHook( $hook, $normalizedHandler, $args, $options );
if ( $notAbortable && $return !== null && $return !== true ) {
throw new UnexpectedValueException( "Invalid return from $functionName" .
" for unabortable $hook." );
}
if ( $return === false ) {
return false;
}
if ( is_string( $return ) ) {
wfDeprecatedMsg(
"Returning a string from a hook handler is deprecated since MediaWiki 1.35 ' .
'(done by $functionName for $hook)",
'1.35', false, false
);
throw new UnexpectedValueException( $return );
}
return false;
} elseif ( $return !== null && $return !== true ) {
throw new UnexpectedValueException(
"Hook handlers can only return null or a boolean. Got an unexpected value from " .
"handler {$handler['functionName']} for $hook" );
}
}
$handlers = $this->getHandlers( $hook, $options );
$funcName = 'on' . strtr( ucfirst( $hook ), ':-', '__' );
foreach ( $handlers as $handler ) {
$return = $handler->$funcName( ...$args );
if ( $notAbortable && $return !== null && $return !== true ) {
throw new UnexpectedValueException(
"Invalid return from " . $funcName . " for unabortable $hook."
);
}
if ( $return === false ) {
return false;
}
if ( $return !== null && !is_bool( $return ) ) {
throw new UnexpectedValueException( "Invalid return from " . $funcName . " for $hook." );
}
}
return true;
}
@ -191,7 +189,14 @@ class HookContainer implements SalvageableService {
throw new MWException( 'Cannot reset hooks in operation.' );
}
$this->handlers[$hook] = [];
// The tombstone logic makes it so the clear() operation can be reversed reliably,
// and does not affect global state.
// $this->tombstones[$hook]>0 suppresses any handlers from the HookRegistry,
// see getHandlers().
// The TOMBSTONE value in $this->dynamicHandlers[$hook] means that all handlers
// that precede it in the array are ignored, see getLegacyHandlers().
$this->dynamicHandlers[$hook][] = self::TOMBSTONE;
$this->tombstones[$hook] = ( $this->tombstones[$hook] ?? 0 ) + 1;
}
/**
@ -199,220 +204,140 @@ class HookContainer implements SalvageableService {
* Intended for use in temporary registration e.g. testing
*
* @param string $hook Name of hook
* @param callable|string|array $handler Handler to attach
* @param bool $replace (optional) Set true to disable existing handlers for the hook while
* the scoped handler is active.
* @param callable|string|array $callback Handler object to attach
* @param bool $replace (optional) By default adds callback to handler array.
* Set true to remove all existing callbacks for the hook.
* @return ScopedCallback
*/
public function scopedRegister( string $hook, $handler, bool $replace = false ): ScopedCallback {
$handler = $this->normalizeHandler( $hook, $handler );
if ( !$handler ) {
throw new InvalidArgumentException( 'Bad hook handler!' );
}
$this->checkDeprecation( $hook, $handler['functionName'] );
public function scopedRegister( string $hook, $callback, bool $replace = false ): ScopedCallback {
// Use a known key to register the handler, so we can later remove it
// from $this->dynamicHandlers using that key.
$id = 'TemporaryHook_' . $this->nextScopedRegisterId++;
$this->getHandlers( $hook );
$this->handlers[$hook][$id] = $handler;
if ( $replace ) {
$this->updateSkipCounter( $hook, 1, $id );
// Use a known key for the tombstone, so we can later remove it
// from $this->dynamicHandlers using that key.
$ts = "{$id}_TOMBSTONE";
// See comment in clear() for the tombstone logic.
$this->dynamicHandlers[$hook][$ts] = self::TOMBSTONE;
$this->dynamicHandlers[$hook][$id] = $callback;
$this->tombstones[$hook] = ( $this->tombstones[$hook] ?? 0 ) + 1;
return new ScopedCallback(
function () use ( $hook, $id, $ts ) {
unset( $this->dynamicHandlers[$hook][$ts] );
unset( $this->dynamicHandlers[$hook][$id] );
$this->tombstones[$hook]--;
}
);
} else {
$this->dynamicHandlers[$hook][$id] = $callback;
return new ScopedCallback( function () use ( $hook, $id ) {
unset( $this->dynamicHandlers[$hook][$id] );
} );
}
return new ScopedCallback( function () use ( $hook, $id, $replace ) {
if ( $replace ) {
$this->updateSkipCounter( $hook, -1, $id );
}
unset( $this->handlers[$hook][$id] );
} );
}
/**
* Utility for scopedRegister() for updating the counter in the 'skip'
* field of each handler of the given hook, up to the given index.
*
* The idea is that handlers with a non-zero 'skip' count will be ignored.
* This allows a call to scopedRegister to temporarily disable all previously
* registered handlers, and automatically re-enable them when the temporary
* handler goes out of scope.
*
* @param string $hook
* @param int $n The amount by which to update the count (1 to disable handlers,
* -1 to re-enable them)
* @param int|string $upTo The index at which to stop the update. This will be the
* index of the temporary handler registered with scopedRegister.
*
* @return void
*/
private function updateSkipCounter( string $hook, $n, $upTo ) {
foreach ( $this->handlers[$hook] as $i => $unused ) {
if ( $i === $upTo ) {
break;
}
$current = $this->handlers[$hook][$i]['skip'] ?? 0;
$this->handlers[$hook][$i]['skip'] = $current + $n;
}
}
/**
* Returns a callable array based on the handler specification provided.
* This will find the appropriate handler object to call a method on,
* instantiating it if it doesn't exist yet.
*
* @param string $hook The name of the hook the handler was registered for
* @param array $handler A hook handler specification as given in an extension.json file.
* @param array $options Options to apply. If the 'noServices' option is set and the
* handler requires service injection, this method will throw an
* UnexpectedValueException.
*
* @return array
*/
private function makeExtensionHandlerCallback( string $hook, array $handler, array $options = [] ): array {
$spec = $handler['handler'];
$name = $spec['name'];
if (
!empty( $options['noServices'] ) && (
!empty( $spec['services'] ) ||
!empty( $spec['optional_services'] )
)
) {
throw new UnexpectedValueException(
"The handler for the hook $hook registered in " .
"{$handler['extensionPath']} has a service dependency, " .
"but this hook does not allow it." );
}
if ( !isset( $this->handlerObjects[$name] ) ) {
// @phan-suppress-next-line PhanTypeInvalidCallableArraySize
$this->handlerObjects[$name] = $this->objectFactory->createObject( $spec );
}
$obj = $this->handlerObjects[$name];
$method = $this->getHookMethodName( $hook );
return [ $obj, $method ];
}
/**
* Normalize/clean up format of argument passed as hook handler
*
* @param array|callable $handler Executable handler function
* @param string $hook Hook name
* @param string|array|callable $handler Executable handler function. See register() for supported structures.
* @param array $options
*
* @return array|false
* - callback: (callable) Executable handler function
* - handler: (callable) Executable handler function
* - functionName: (string) Handler name for passing to wfDeprecated() or Exceptions thrown
* - args: (array) Extra handler function arguments (omitted when not needed)
* - args: (array) handler function arguments
*/
private function normalizeHandler( string $hook, $handler, array $options = [] ) {
if ( is_object( $handler ) && !$handler instanceof Closure ) {
$handler = [ $handler, $this->getHookMethodName( $hook ) ];
}
// Backwards compatibility with old-style callable that uses a qualified method name.
if ( is_array( $handler ) && is_object( $handler[0] ?? false ) && is_string( $handler[1] ?? false ) ) {
$ofs = strpos( $handler[1], '::' );
if ( $ofs !== false ) {
$handler[1] = substr( $handler[1], $ofs + 2 );
}
}
// Backwards compatibility: support objects wrapped in an array but no method name.
if ( is_array( $handler ) && is_object( $handler[0] ?? false ) && !isset( $handler[1] ) ) {
if ( !$handler[0] instanceof Closure ) {
$handler[1] = $this->getHookMethodName( $hook );
}
}
// The empty callback is used to represent a no-op handler in some test cases.
if ( $handler === [] || $handler === null || $handler === false || $handler === self::NOOP ) {
return [
'callback' => static function () {
// no-op
},
'functionName' => self::NOOP,
];
}
// Plain callback
if ( is_callable( $handler ) ) {
return [
'callback' => $handler,
'functionName' => self::callableToString( $handler ),
];
}
// Not callable and not an array. Something is wrong.
private function normalizeHandler( $handler, string $hook ) {
$normalizedHandler = $handler;
if ( !is_array( $handler ) ) {
return false;
$normalizedHandler = [ $normalizedHandler ];
}
// Empty array or array filled with null/false/empty.
if ( !array_filter( $handler ) ) {
if ( !array_filter( $normalizedHandler ) ) {
return false;
}
// ExtensionRegistry style handler
if ( isset( $handler['handler'] ) ) {
// Skip hooks that both acknowledge deprecation and are deprecated in core
if ( $handler['deprecated'] ?? false ) {
$deprecatedHooks = $this->registry->getDeprecatedHooks();
$deprecated = $deprecatedHooks->isHookDeprecated( $hook );
if ( $deprecated ) {
return false;
if ( is_array( $normalizedHandler[0] ) ) {
// First element is an array, meaning the developer intended
// the first element to be a callback. Merge it in so that
// processing can be uniform.
$normalizedHandler = array_merge( $normalizedHandler[0], array_slice( $normalizedHandler, 1 ) );
}
$firstArg = $normalizedHandler[0];
// Extract function name, handler callback, and any arguments for the callback
if ( $firstArg instanceof Closure ) {
$functionName = "hook-$hook-closure";
$callback = array_shift( $normalizedHandler );
} elseif ( is_object( $firstArg ) ) {
$object = array_shift( $normalizedHandler );
$functionName = array_shift( $normalizedHandler );
// If no method was specified, default to on$event
if ( $functionName === null ) {
$functionName = "on$hook";
} else {
$colonPos = strpos( $functionName, '::' );
if ( $colonPos !== false ) {
// Some extensions use [ $object, 'Class::func' ] which
// worked with call_user_func_array() but doesn't work now
// that we use a plain variadic call
$functionName = substr( $functionName, $colonPos + 2 );
}
}
$callback = $this->makeExtensionHandlerCallback( $hook, $handler, $options );
return [
'callback' => $callback,
'functionName' => self::callableToString( $callback ),
];
}
// Not an indexed array, something is wrong.
if ( !isset( $handler[0] ) ) {
return false;
}
// Backwards compatibility: support for arrays of the form [ $object, $method, $data... ]
if ( is_object( $handler[0] ) && is_string( $handler[1] ?? false ) && array_key_exists( 2, $handler ) ) {
$obj = $handler[0];
if ( !$obj instanceof Closure ) {
// @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
$method = $handler[1];
$handler = array_merge(
[ [ $obj, $method ] ],
array_slice( $handler, 2 )
);
$callback = [ $object, $functionName ];
} elseif ( is_string( $firstArg ) ) {
if ( is_callable( $normalizedHandler, true, $functionName )
&& class_exists( $firstArg ) // $firstArg can be a function in global scope
) {
$callback = $normalizedHandler;
$normalizedHandler = []; // Can't pass arguments here
} else {
$functionName = $callback = array_shift( $normalizedHandler );
}
}
// The only option left is an array of the form [ $callable, $data... ]
$callback = array_shift( $handler );
// Backwards-compatibility for callbacks in the form [ [ $function ], $data ]
if ( is_array( $callback ) && count( $callback ) === 1 && is_string( $callback[0] ?? null ) ) {
$callback = $callback[0];
}
if ( !is_callable( $callback ) ) {
return false;
} else {
throw new UnexpectedValueException( 'Unknown datatype in hooks for ' . $hook );
}
return [
'callback' => $callback,
'functionName' => self::callableToString( $callback ),
'args' => $handler,
'args' => $normalizedHandler,
'functionName' => $functionName,
];
}
/**
* Run legacy hooks
* Hook can be: a function, an object, an array of $function and
* $data, an array of just a function, an array of object and
* method, or an array of object, method, and data
* (See hooks.txt for more details)
*
* @param string $hook
* @param array|callable $handler The name of the hooks handler function
* @param array $args Arguments for hook handler function
* @param array $options
* @return null|string|bool
*/
private function callLegacyHook( string $hook, $handler, array $args, array $options ) {
$callback = $handler['callback'];
$hookArgs = array_merge( $handler['args'], $args );
if ( isset( $options['deprecatedVersion'] ) && empty( $options['silent'] ) ) {
wfDeprecated(
"$hook hook (used in " . $handler['functionName'] . ")",
$options['deprecatedVersion'] ?? false,
$options['component'] ?? false
);
}
// Call the hooks
return $callback( ...$hookArgs );
}
/**
* Return whether hook has any handlers registered to it.
* The function may have been registered via Hooks::register or in extension.json
@ -421,181 +346,173 @@ class HookContainer implements SalvageableService {
* @return bool Whether the hook has a handler registered to it
*/
public function isRegistered( string $hook ): bool {
return !empty( $this->getHandlers( $hook ) );
if ( $this->tombstones[$hook] ?? false ) {
// If a tombstone is set, we only care about dynamically registered hooks,
// and leave it to getLegacyHandlers() to handle the cut-off.
return !empty( $this->getLegacyHandlers( $hook ) );
}
// If no tombstone is set, we just check if any of the three arrays contains handlers.
if ( !empty( $this->registry->getGlobalHooks()[$hook] ) ||
!empty( $this->dynamicHandlers[$hook] ) ||
!empty( $this->registry->getExtensionHooks()[$hook] )
) {
return true;
}
return false;
}
/**
* Attach an event handler to a given hook.
*
* The handler should be given in one of the following forms:
*
* 1) A callable (string, array, or closure)
* 2) An extension hook handler spec in the form returned by
* HookRegistry::getExtensionHooks
*
* Several other forms are supported for backwards compatibility, but
* should not be used when calling this method directly.
*
* @param string $hook Name of hook
* @param string|array|callable $handler handler
* @param mixed $callback handler object to attach
*/
public function register( string $hook, $handler ) {
$normalized = $this->normalizeHandler( $hook, $handler );
if ( !$normalized ) {
throw new InvalidArgumentException( 'Bad hook handler!' );
public function register( string $hook, $callback ) {
$deprecatedHooks = $this->registry->getDeprecatedHooks();
$deprecated = $deprecatedHooks->isHookDeprecated( $hook );
if ( $deprecated ) {
$info = $deprecatedHooks->getDeprecationInfo( $hook );
if ( empty( $info['silent'] ) ) {
$handler = $this->normalizeHandler( $callback, $hook );
wfDeprecated(
"$hook hook (used in " . $handler['functionName'] . ")",
$info['deprecatedVersion'] ?? false,
$info['component'] ?? false
);
}
}
$this->checkDeprecation( $hook, $normalized['functionName'] );
$this->getHandlers( $hook );
$this->handlers[$hook][] = $normalized;
$this->dynamicHandlers[$hook][] = $callback;
}
/**
* Get handler callbacks.
* Get all handlers for legacy hooks system, plus any handlers added
* using register().
*
* @internal For use by FauxHookHandlerArray. Delete when no longer needed.
* @internal For use by Hooks.php
* @param string $hook Name of hook
* @return callable[]
*/
public function getHandlerCallbacks( string $hook ): array {
$handlers = $this->getHandlers( $hook );
public function getLegacyHandlers( string $hook ): array {
if ( $this->tombstones[$hook] ?? false ) {
// If there is at least one tombstone set for the hook,
// ignore all handlers from the registry, and
// only consider handlers registered after the tombstone
// was set.
$handlers = $this->dynamicHandlers[$hook] ?? [];
$keys = array_keys( $handlers );
$callbacks = [];
foreach ( $handlers as $h ) {
if ( ( $h['skip'] ?? 0 ) > 0 ) {
continue;
// Loop over the handlers backwards, to find the last tombstone.
for ( $i = count( $keys ) - 1; $i >= 0; $i-- ) {
$k = $keys[$i];
$v = $handlers[$k];
if ( $v === self::TOMBSTONE ) {
break;
}
}
$callback = $h['callback'];
if ( isset( $h['args'] ) ) {
// Needs curry in order to pass extra arguments.
// NOTE: This does not support reference parameters!
$extraArgs = $h['args'];
$callbacks[] = static function ( ...$hookArgs ) use ( $callback, $extraArgs ) {
return $callback( ...$extraArgs, ...$hookArgs );
};
} else {
$callbacks[] = $callback;
}
// Return the part of $this->dynamicHandlers[$hook] after the TOMBSTONE
// marker, preserving keys.
$keys = array_slice( $keys, $i + 1 );
$handlers = array_intersect_key( $handlers, array_fill_keys( $keys, true ) );
} else {
// If no tombstone is set, just merge the two arrays.
$handlers = array_merge(
$this->registry->getGlobalHooks()[$hook] ?? [],
$this->dynamicHandlers[$hook] ?? []
);
}
return $callbacks;
return $handlers;
}
/**
* Returns the names of all hooks that have at least one handler registered.
* @return string[]
* @return array
*/
public function getHookNames(): array {
$names = array_merge(
array_keys( array_filter( $this->handlers ) ),
array_keys( array_filter( $this->registry->getGlobalHooks() ) ),
array_keys( array_filter( $this->registry->getExtensionHooks() ) )
array_keys( $this->dynamicHandlers ),
array_keys( $this->registry->getGlobalHooks() ),
array_keys( $this->registry->getExtensionHooks() )
);
return array_unique( $names );
}
/**
* Return the array of handlers for the given hook.
* Returns the names of all hooks that have handlers registered.
* Note that this may include hook handlers that have been disabled using clear().
*
* @internal
* @return string[]
*/
public function getRegisteredHooks(): array {
$names = array_merge(
array_keys( $this->dynamicHandlers ),
array_keys( $this->registry->getExtensionHooks() ),
array_keys( $this->registry->getGlobalHooks() ),
);
return array_unique( $names );
}
/**
* Return array of handler objects registered with given hook in the new system.
* This does not include handlers registered dynamically using register(), nor does
* it include hooks registered via the old mechanism using $wgHooks.
*
* @internal For use by Hooks.php
* @param string $hook Name of the hook
* @param array $options Handler options, which may include:
* - noServices: Do not allow hook handlers with service dependencies
* @return array[] A list of handler entries
* @return array non-deprecated handler objects
*/
private function getHandlers( string $hook, array $options = [] ): array {
if ( !isset( $this->handlers[$hook] ) ) {
$handlers = [];
$registeredHooks = $this->registry->getExtensionHooks();
$configuredHooks = $this->registry->getGlobalHooks();
$rawHandlers = array_merge(
$configuredHooks[ $hook ] ?? [],
$registeredHooks[ $hook ] ?? []
);
foreach ( $rawHandlers as $raw ) {
$handler = $this->normalizeHandler( $hook, $raw, $options );
if ( !$handler ) {
// XXX: log this?!
// NOTE: also happens for deprecated hooks, which is fine!
public function getHandlers( string $hook, array $options = [] ): array {
if ( $this->tombstones[$hook] ?? false ) {
// There is at least one tombstone for the hook, so suppress all new-style hooks.
return [];
}
$handlers = [];
$deprecatedHooks = $this->registry->getDeprecatedHooks();
$registeredHooks = $this->registry->getExtensionHooks();
if ( isset( $registeredHooks[$hook] ) ) {
foreach ( $registeredHooks[$hook] as $hookReference ) {
// Non-legacy hooks have handler attributes
$handlerSpec = $hookReference['handler'];
// Skip hooks that both acknowledge deprecation and are deprecated in core
$flaggedDeprecated = !empty( $hookReference['deprecated'] );
$deprecated = $deprecatedHooks->isHookDeprecated( $hook );
if ( $deprecated && $flaggedDeprecated ) {
continue;
}
$handlers[] = $handler;
$handlerName = $handlerSpec['name'];
if (
!empty( $options['noServices'] ) && (
isset( $handlerSpec['services'] ) ||
isset( $handlerSpec['optional_services'] )
)
) {
throw new UnexpectedValueException(
"The handler for the hook $hook registered in " .
"{$hookReference['extensionPath']} has a service dependency, " .
"but this hook does not allow it." );
}
if ( !isset( $this->handlersByName[$handlerName] ) ) {
$this->handlersByName[$handlerName] =
$this->objectFactory->createObject( $handlerSpec );
}
$handlers[] = $this->handlersByName[$handlerName];
}
$this->handlers[ $hook ] = $handlers;
}
return $this->handlers[ $hook ];
return $handlers;
}
/**
* Return the array of strings that describe the handler registered with the given hook.
*
* @internal Only public for use by ApiQuerySiteInfo.php and SpecialVersion.php
* @param string $hook Name of the hook
* @return string[] A list of handler descriptions
*/
public function getHandlerDescriptions( string $hook ): array {
$descriptions = [];
$registeredHooks = $this->registry->getExtensionHooks();
$configuredHooks = $this->registry->getGlobalHooks();
$rawHandlers = array_merge(
$configuredHooks[ $hook ] ?? [],
$registeredHooks[ $hook ] ?? [],
$this->handlers[ $hook ] ?? []
);
foreach ( $rawHandlers as $raw ) {
$descr = $this->describeHandler( $hook, $raw );
if ( $descr ) {
$descriptions[] = $descr;
}
}
return $descriptions;
}
/**
* Returns a human-readable description of the given handler.
*
* @param string $hook
* @param string|array|callable $handler
*
* @return ?string
*/
private function describeHandler( string $hook, $handler ): ?string {
if ( is_array( $handler ) ) {
// already normalized
if ( isset( $handler['functionName'] ) ) {
return $handler['functionName'];
}
if ( isset( $handler['callback'] ) ) {
return self::callableToString( $handler['callback'] );
}
if ( isset( $handler['handler']['class'] ) ) {
// New style hook. Avoid instantiating the handler object
$method = $this->getHookMethodName( $hook );
return $handler['handler']['class'] . '::' . $method;
}
}
$handler = $this->normalizeHandler( $hook, $handler );
return $handler ? $handler['functionName'] : null;
}
/**
* For each hook handler of each hook, this will log a deprecation if:
* 1. the hook is marked deprecated and
* Will log a deprecation warning if:
* 1. the hook is marked deprecated
* 2. the "silent" flag is absent or false, and
* 3. an extension registers a handler in the new way but does not acknowledge deprecation
*/
@ -621,71 +538,4 @@ class HookContainer implements SalvageableService {
}
}
}
/**
* Will trigger a deprecation warning if the given hook is deprecated and the deprecation
* is not marked as silent.
*
* @param string $hook The name of the hook.
* @param string $functionName The human-readable description of the handler
* @param array|null $deprecationInfo Deprecation info if the caller already knows it.
* If not given, it will be looked up from the hook registry.
*
* @return void
*/
private function checkDeprecation( string $hook, string $functionName, array $deprecationInfo = null ): void {
if ( !$deprecationInfo ) {
$deprecatedHooks = $this->registry->getDeprecatedHooks();
$deprecationInfo = $deprecatedHooks->getDeprecationInfo( $hook );
}
if ( $deprecationInfo && empty( $deprecationInfo['silent'] ) ) {
wfDeprecated(
"$hook hook (used in " . $functionName . ")",
$deprecationInfo['deprecatedVersion'] ?? false,
$deprecationInfo['component'] ?? false
);
}
}
/**
* Returns a human-readable representation of the given callable.
*
* @param callable $callable
*
* @return string
*/
private static function callableToString( $callable ): string {
if ( is_string( $callable ) ) {
return $callable;
}
if ( $callable instanceof Closure ) {
return '*closure*';
}
if ( is_array( $callable ) ) {
[ $on, $func ] = $callable;
if ( is_object( $on ) ) {
$on = get_class( $on );
}
return "$on::$func";
}
throw new LogicException( 'Unexpected kind of callable' );
}
/**
* Returns the default handler method name for the given hook.
*
* @param string $hook
*
* @return string
*/
private function getHookMethodName( string $hook ): string {
$hook = strtr( $hook, ':\\', '__' );
return "on$hook";
}
}

View file

@ -100,7 +100,12 @@ class Hooks {
public static function getHandlers( $name ) {
wfDeprecated( __METHOD__, '1.35' );
$hookContainer = MediaWikiServices::getInstance()->getHookContainer();
return $hookContainer->getHandlerCallbacks( $name );
$handlers = $hookContainer->getLegacyHandlers( $name );
$funcName = 'on' . strtr( ucfirst( $name ), ':-', '__' );
foreach ( $hookContainer->getHandlers( $name ) as $obj ) {
$handlers[] = [ $obj, $funcName ];
}
return $handlers;
}
/**

View file

@ -1012,11 +1012,11 @@ class ApiQuerySiteinfo extends ApiQueryBase {
$data = [];
foreach ( $hookNames as $name ) {
$subscribers = $hookContainer->getHandlerDescriptions( $name );
$subscribers = $hookContainer->getLegacyHandlers( $name );
$arr = [
'name' => $name,
'subscribers' => $subscribers,
'subscribers' => array_map( [ SpecialVersion::class, 'arrayToString' ], $subscribers ),
];
ApiResult::setArrayType( $arr['subscribers'], 'array' );

View file

@ -1084,44 +1084,41 @@ class SpecialVersion extends SpecialPage {
* @return string HTML
*/
private function getHooks() {
if ( !$this->getConfig()->get( MainConfigNames::SpecialVersionShowHooks ) ) {
return '';
}
$hookContainer = MediaWikiServices::getInstance()->getHookContainer();
$hookNames = $hookContainer->getHookNames();
if ( !$hookNames ) {
return '';
}
sort( $hookNames );
$ret = [];
$this->addTocSection( 'version-hooks', 'mw-version-hooks' );
$ret[] = Html::element(
'h2',
[ 'id' => 'mw-version-hooks' ],
$this->msg( 'version-hooks' )->text()
);
$ret[] = Html::openElement( 'table', [ 'class' => 'wikitable', 'id' => 'sv-hooks' ] );
$ret[] = Html::openElement( 'tr' );
$ret[] = Html::element( 'th', [], $this->msg( 'version-hook-name' )->text() );
$ret[] = Html::element( 'th', [], $this->msg( 'version-hook-subscribedby' )->text() );
$ret[] = Html::closeElement( 'tr' );
foreach ( $hookNames as $name ) {
$handlers = $hookContainer->getHandlerDescriptions( $name );
if ( $this->getConfig()->get( MainConfigNames::SpecialVersionShowHooks ) ) {
$hookContainer = MediaWikiServices::getInstance()->getHookContainer();
$hookNames = $hookContainer->getHookNames();
sort( $hookNames );
$ret = [];
$this->addTocSection( 'version-hooks', 'mw-version-hooks' );
$ret[] = Html::element(
'h2',
[ 'id' => 'mw-version-hooks' ],
$this->msg( 'version-hooks' )->text()
);
$ret[] = Html::openElement( 'table', [ 'class' => 'wikitable', 'id' => 'sv-hooks' ] );
$ret[] = Html::openElement( 'tr' );
$ret[] = Html::element( 'td', [], $name );
$ret[] = Html::element( 'td', [], $this->listToText( $handlers ) );
$ret[] = Html::element( 'th', [], $this->msg( 'version-hook-name' )->text() );
$ret[] = Html::element( 'th', [], $this->msg( 'version-hook-subscribedby' )->text() );
$ret[] = Html::closeElement( 'tr' );
foreach ( $hookNames as $hook ) {
$hooks = $hookContainer->getLegacyHandlers( $hook );
if ( !$hooks ) {
continue;
}
$ret[] = Html::openElement( 'tr' );
$ret[] = Html::element( 'td', [], $hook );
$ret[] = Html::element( 'td', [], $this->listToText( $hooks ) );
$ret[] = Html::closeElement( 'tr' );
}
$ret[] = Html::closeElement( 'table' );
return implode( "\n", $ret );
}
$ret[] = Html::closeElement( 'table' );
return implode( "\n", $ret );
return '';
}
private function openExtType( string $text = null, string $name = null ) {

View file

@ -2542,7 +2542,7 @@ abstract class MediaWikiIntegrationTestCase extends PHPUnit\Framework\TestCase {
* @since 1.40
*/
protected function clearHooks( ?array $hookNames = null ) {
$hookNames ??= $this->localServices->getHookContainer()->getHookNames();
$hookNames ??= $this->localServices->getHookContainer()->getRegisteredHooks();
foreach ( $hookNames as $name ) {
$this->clearHook( $name );
}

View file

@ -43,14 +43,14 @@ namespace MediaWiki\HookContainer {
$hookContainer->register( 'FooHook', static function () {
return true;
} );
$handlersBeforeScopedRegister = $hookContainer->getHandlerCallbacks( 'FooHook' );
$handlersBeforeScopedRegister = $hookContainer->getLegacyHandlers( 'FooHook' );
$this->assertCount( 2, $handlersBeforeScopedRegister );
// Wipe out the 2 existing handlers and add a new scoped handler
$reset2 = $hookContainer->scopedRegister( 'FooHook', static function () {
return true;
}, true );
$handlersAfterScopedRegister = $hookContainer->getHandlerCallbacks( 'FooHook' );
$handlersAfterScopedRegister = $hookContainer->getLegacyHandlers( 'FooHook' );
$this->assertCount( 1, $handlersAfterScopedRegister );
ScopedCallback::consume( $reset2 );
@ -58,7 +58,7 @@ namespace MediaWiki\HookContainer {
// Teardown causes the original handlers to be re-applied
$this->mediaWikiTearDown();
$handlersAfterTearDown = $hookContainer->getHandlerCallbacks( 'FooHook' );
$handlersAfterTearDown = $hookContainer->getLegacyHandlers( 'FooHook' );
$this->assertCount( 2, $handlersAfterTearDown );
}

View file

@ -1,7 +1,5 @@
<?php
use Wikimedia\ScopedCallback;
class HooksTest extends MediaWikiIntegrationTestCase {
private const MOCK_HOOK_NAME = 'MediaWikiHooksTest001';
@ -11,23 +9,23 @@ class HooksTest extends MediaWikiIntegrationTestCase {
}
public static function provideHooks() {
$obj = new HookTestDummyHookHandlerClass();
$i = new HookTestDummyHookHandlerClass();
return [
[
'Object and method',
[ $obj, 'someNonStatic' ],
[ $i, 'someNonStatic' ],
'changed-nonstatic',
'changed-nonstatic'
],
[ 'Object and no method', [ $obj ], 'changed-onevent', 'original' ],
[ 'Object and no method', [ $i ], 'changed-onevent', 'original' ],
[
'Object and method with data',
[ $obj, 'someNonStaticWithData', 'data' ],
[ $i, 'someNonStaticWithData', 'data' ],
'data',
'original'
],
[ 'Object and static method', [ $obj, 'someStatic' ], 'changed-static', 'original' ],
[ 'Object and static method', [ $i, 'someStatic' ], 'changed-static', 'original' ],
[
'Class::method static call',
[ 'HookTestDummyHookHandlerClass::someStatic' ],
@ -71,50 +69,6 @@ class HooksTest extends MediaWikiIntegrationTestCase {
$this->assertSame( $expectedBar, $bar, $msg );
}
/**
* @covers Hooks::getHandlers
*/
public function testGetHandlers() {
global $wgHooks;
$hookContainer = $this->getServiceContainer()->getHookContainer();
$this->filterDeprecated( '/\$wgHooks/' );
$this->hideDeprecated( 'Hooks::getHandlers' );
$this->assertSame(
[],
Hooks::getHandlers( 'MediaWikiHooksTest001' ),
'No hooks registered'
);
$a = [ new HookTestDummyHookHandlerClass(), 'someStatic' ];
$b = [ new HookTestDummyHookHandlerClass(), 'onMediaWikiHooksTest001' ];
$wgHooks['MediaWikiHooksTest001'][] = $a;
$this->assertSame(
[ $a ],
array_values( Hooks::getHandlers( 'MediaWikiHooksTest001' ) ),
'Hook registered by $wgHooks'
);
$reset = $hookContainer->scopedRegister( 'MediaWikiHooksTest001', $b );
$this->assertSame(
[ $a, $b ],
array_values( Hooks::getHandlers( 'MediaWikiHooksTest001' ) ),
'Hooks::getHandlers() should return hooks registered via wgHooks as well as Hooks::register'
);
ScopedCallback::consume( $reset );
unset( $wgHooks['MediaWikiHooksTest001'] );
$hookContainer->register( 'MediaWikiHooksTest001', $b );
$this->assertSame(
[ $b ],
array_values( Hooks::getHandlers( 'MediaWikiHooksTest001' ) ),
'Hook registered by Hook::register'
);
}
/**
* @covers Hooks::isRegistered
* @covers Hooks::getHandlers
@ -169,9 +123,8 @@ class HooksTest extends MediaWikiIntegrationTestCase {
public function testUncallableFunction() {
$this->hideDeprecated( 'Hooks::run' );
$hookContainer = $this->getServiceContainer()->getHookContainer();
$this->expectException( InvalidArgumentException::class );
$hookContainer->register( self::MOCK_HOOK_NAME, 'ThisFunctionDoesntExist' );
$this->expectExceptionMessage( 'Call to undefined function ThisFunctionDoesntExist' );
Hooks::run( self::MOCK_HOOK_NAME, [] );
}
@ -235,8 +188,8 @@ class HooksTest extends MediaWikiIntegrationTestCase {
public function testCallHook_UnknownDatatype() {
$this->hideDeprecated( 'Hooks::run' );
$hookContainer = $this->getServiceContainer()->getHookContainer();
$this->expectException( InvalidArgumentException::class );
$hookContainer->register( self::MOCK_HOOK_NAME, 12345 );
$this->expectException( UnexpectedValueException::class );
Hooks::run( self::MOCK_HOOK_NAME );
}
@ -246,10 +199,9 @@ class HooksTest extends MediaWikiIntegrationTestCase {
public function testCallHook_Deprecated() {
$hookContainer = $this->getServiceContainer()->getHookContainer();
$hookContainer->register( self::MOCK_HOOK_NAME, 'HookTestDummyHookHandlerClass::someStatic' );
$this->expectDeprecationAndContinue( '/Use of MediaWikiHooksTest001 hook/' );
$this->expectDeprecation();
$a = $b = 0;
$this->hideDeprecated( 'Hooks::run' );
Hooks::run( self::MOCK_HOOK_NAME, [ $a, $b ], '1.31' );
}
@ -293,7 +245,9 @@ class HooksTest extends MediaWikiIntegrationTestCase {
$foo = 'original';
$this->expectException( UnexpectedValueException::class );
$this->expectExceptionMessage( 'unabortable MediaWikiHooksTest001' );
$this->expectExceptionMessage( 'Invalid return from hook-MediaWikiHooksTest001-closure for ' .
'unabortable MediaWikiHooksTest001'
);
Hooks::runWithoutAbort( self::MOCK_HOOK_NAME, [ &$foo ] );
}
@ -306,7 +260,7 @@ class HooksTest extends MediaWikiIntegrationTestCase {
$hookContainer->register( self::MOCK_HOOK_NAME, static function () {
return 'test';
} );
$this->expectException( UnexpectedValueException::class );
$this->expectDeprecation();
Hooks::run( self::MOCK_HOOK_NAME, [] );
}
}

View file

@ -55,6 +55,8 @@ class AuthManagerTest extends \MediaWikiIntegrationTestCase {
/** @var HookContainer */
private $hookContainer;
/** @var array */
private $authHooks;
/** @var UserNameUtils */
protected $userNameUtils;
@ -114,7 +116,7 @@ class AuthManagerTest extends \MediaWikiIntegrationTestCase {
$mock = $this->getMockBuilder( $hookInterface )
->onlyMethods( [ "on$hook" ] )
->getMock();
$this->hookContainer->register( $hook, $mock );
$this->authHooks[$hook][] = $mock;
return $mock->expects( $expect )->method( "on$hook" );
}
@ -123,7 +125,7 @@ class AuthManagerTest extends \MediaWikiIntegrationTestCase {
* @param string $hook
*/
protected function unhook( $hook ) {
$this->hookContainer->clear( $hook );
$this->authHooks[$hook] = [];
}
/**
@ -224,9 +226,21 @@ class AuthManagerTest extends \MediaWikiIntegrationTestCase {
$this->watchlistManager = $this->getServiceContainer()->getWatchlistManager();
}
if ( $regen || !$this->hookContainer ) {
// Set up a HookContainer we control
// Set up a HookContainer similar to the normal one except that it
// gets global hooks from $this->authHooks instead of $wgHooks
$this->hookContainer = new HookContainer(
new StaticHookRegistry( [], [], [] ),
new class( $this->authHooks ) extends StaticHookRegistry {
private $hooks;
public function __construct( &$hooks ) {
parent::__construct();
$this->hooks =& $hooks;
}
public function getGlobalHooks() {
return $this->hooks;
}
},
$this->objectFactory
);
}

View file

@ -44,7 +44,7 @@ class LanguageIntegrationTest extends LanguageClassesTestCase {
// Don't allow installed hooks to run, except if a test restores them via origHooks (needed
// for testIsKnownLanguageTag_cldr)
$this->origHandlers = $this->getServiceContainer()->getHookContainer()
->getHandlerCallbacks( 'LanguageGetTranslatedLanguageNames' );
->getLegacyHandlers( 'LanguageGetTranslatedLanguageNames' );
$this->clearHook( 'LanguageGetTranslatedLanguageNames' );
$this->overrideConfigValue( MainConfigNames::UsePigLatinVariant, true );

View file

@ -19,9 +19,6 @@ class ExtensionRegistrationTest extends MediaWikiIntegrationTestCase {
private $autoloaderState;
/** @var ?ExtensionRegistry */
private $originalExtensionRegistry = null;
protected function setUp(): void {
global $wgHooks;
@ -46,11 +43,6 @@ class ExtensionRegistrationTest extends MediaWikiIntegrationTestCase {
protected function tearDown(): void {
AutoLoader::restoreState( $this->autoloaderState );
if ( $this->originalExtensionRegistry ) {
$this->setExtensionRegistry( $this->originalExtensionRegistry );
}
parent::tearDown();
}
@ -83,47 +75,27 @@ class ExtensionRegistrationTest extends MediaWikiIntegrationTestCase {
$this->assertArrayHasKey( 1300, $GLOBALS['wgNamespaceContentModels'] );
}
private function setExtensionRegistry( ExtensionRegistry $registry ) {
$class = new \ReflectionClass( ExtensionRegistry::class );
if ( !$this->originalExtensionRegistry ) {
$this->originalExtensionRegistry = $class->getStaticPropertyValue( 'instance' );
}
$class->setStaticPropertyValue( 'instance', $registry );
}
public static function onAnEvent() {
// no-op
}
public static function onBooEvent() {
// no-op
}
public function testExportHooks() {
$manifest = [
'Hooks' => [
'AnEvent' => self::class . '::onAnEvent',
'AnEvent' => 'FooBarClass::onAnEvent',
'BooEvent' => 'main',
],
'HookHandlers' => [
'main' => [ 'class' => self::class ]
'HookHandler' => [
'main' => [ 'class' => 'Whatever' ]
],
];
$file = $this->makeManifestFile( $manifest );
$registry = new ExtensionRegistry();
$this->setExtensionRegistry( $registry );
$registry->queue( $file );
$registry->loadFromQueue();
$this->resetServices();
$hookContainer = $this->getServiceContainer()->getHookContainer();
$this->assertTrue( $hookContainer->isRegistered( 'AnEvent' ), 'AnEvent' );
$this->assertTrue( $hookContainer->isRegistered( 'BooEvent' ), 'BooEvent' );
$this->assertTrue( $hookContainer->isRegistered( 'AnEvent' ) );
$this->assertTrue( $hookContainer->isRegistered( 'BooEvent' ) );
}
public function testExportAutoload() {

View file

@ -25,6 +25,32 @@ class TestLocalisationCache extends LocalisationCache {
$this->selfAccess = TestingAccessWrapper::newFromObject( $this );
}
/**
* Recurse through the given array and replace every object by a scalar value that can be
* serialized as JSON to use as a hash key.
*
* @param array $arr
* @return array
*/
private static function hashiblifyArray( array $arr ): array {
foreach ( $arr as $key => $val ) {
if ( is_array( $val ) ) {
$arr[$key] = self::hashiblifyArray( $val );
} elseif ( is_object( $val ) ) {
// spl_object_hash() may return duplicate values if an object is destroyed and a new
// one gets its hash and happens to be registered in the same hook in the same
// location. This seems unlikely, but let's be safe and maintain a reference so it
// can't happen. (In practice, there are probably no objects in the hooks at all.)
static $objects = [];
if ( !in_array( $val, $objects, true ) ) {
$objects[] = $val;
}
$arr[$key] = spl_object_hash( $val );
}
}
return $arr;
}
public function recache( $code ) {
$hookContainer = MediaWikiServices::getInstance()->getHookContainer();
// Test run performance is killed if we have to regenerate l10n for every test
@ -32,8 +58,11 @@ class TestLocalisationCache extends LocalisationCache {
$code,
$this->selfAccess->options->get( MainConfigNames::ExtensionMessagesFiles ),
$this->selfAccess->options->get( MainConfigNames::MessagesDirs ),
$hookContainer->getHandlerDescriptions( 'LocalisationCacheRecacheFallback' ),
$hookContainer->getHandlerDescriptions( 'LocalisationCacheRecache' ),
// json_encode doesn't handle objects well
self::hashiblifyArray( $hookContainer->getLegacyHandlers( 'LocalisationCacheRecacheFallback' ) ),
self::hashiblifyArray( $hookContainer->getHandlers( 'LocalisationCacheRecacheFallback' ) ),
self::hashiblifyArray( $hookContainer->getLegacyHandlers( 'LocalisationCacheRecache' ) ),
self::hashiblifyArray( $hookContainer->getHandlers( 'LocalisationCacheRecache' ) ),
] ) );
if ( isset( self::$testingCache[$cacheKey] ) ) {
$this->data[$code] = self::$testingCache[$cacheKey];

View file

@ -33,7 +33,7 @@ class FauxGlobalHookArrayTest extends \MediaWikiUnitTestCase {
$hooks['FirstHook'][] = $handler;
$this->assertTrue( $container->isRegistered( 'FirstHook' ) );
$this->assertCount( 1, $container->getHandlerCallbacks( 'FirstHook' ) );
$this->assertCount( 1, $container->getLegacyHandlers( 'FirstHook' ) );
$this->assertTrue( isset( $hooks['FirstHook'] ) );
$this->assertCount( 1, $hooks['FirstHook'] );

View file

@ -2,7 +2,6 @@
namespace MediaWiki\HookContainer {
use InvalidArgumentException;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWikiUnitTestCase;
use stdClass;
@ -58,13 +57,11 @@ namespace MediaWiki\HookContainer {
public static function provideRegister() {
return [
'function' => [ 'strtoupper', 'strtoupper' ],
'object' => [ new \FooExtension\Hooks(), 'FooExtension\Hooks::onFooActionComplete' ],
'object and method' => [ [ new FooClass(), 'fooMethod' ], 'MediaWiki\HookContainer\FooClass::fooMethod' ],
'object in array with no method' => [ new \FooExtension\Hooks(), 'FooExtension\Hooks::onFooActionComplete' ],
// not yet supported: 'function' => [ 'strtoupper', 'strtoupper' ],
// not yet supported: 'object' => [ new \FooExtension\Hooks(), 'FooExtension\Hooks::onFooActionComplete' ],
'extension' => [
self::HANDLER_REGISTRATION,
'FooExtension\Hooks::onFooActionComplete'
'FooExtension\Hooks'
]
];
}
@ -79,21 +76,20 @@ namespace MediaWiki\HookContainer {
'FooActionComplete' => [ $handler ]
], [] );
$handlers = $hookContainer->getHandlerDescriptions( 'FooActionComplete' );
$handlers = $hookContainer->getHandlers( 'FooActionComplete' );
$this->assertSame( $expected, $handlers[0] );
$this->assertSame( $expected, get_class( $handlers[0] ) );
}
/**
* Values returned: hook, handlersToRegister, expectedReturn
*/
public static function provideGetHandlerDescriptions() {
public static function provideGetHandlers() {
return [
'NoHandlersExist' => [
'MWTestHook',
null,
0,
0
[],
],
'SuccessfulHandlerReturn' => [
'FooActionComplete',
@ -104,8 +100,9 @@ namespace MediaWiki\HookContainer {
'services' => [],
],
],
1,
1
[
new \FooExtension\Hooks(),
],
],
'SkipDeprecated' => [
'FooActionCompleteDeprecated',
@ -117,8 +114,7 @@ namespace MediaWiki\HookContainer {
],
'deprecated' => true,
],
1,
0
[],
],
];
}
@ -239,13 +235,13 @@ namespace MediaWiki\HookContainer {
} );
// handlers registered in 2 different ways
$this->assertCount( 2, $hookContainer->getHandlerCallbacks( 'MWTestHook' ) );
$this->assertCount( 2, $hookContainer->getLegacyHandlers( 'MWTestHook' ) );
$hookContainer->run( 'MWTestHook' );
$this->assertEquals( 2, $numCalls );
// Remove one of the handlers that increments $called
ScopedCallback::consume( $reset );
$this->assertCount( 1, $hookContainer->getHandlerCallbacks( 'MWTestHook' ) );
$this->assertCount( 1, $hookContainer->getLegacyHandlers( 'MWTestHook' ) );
$numCalls = 0;
$hookContainer->run( 'MWTestHook' );
@ -253,16 +249,10 @@ namespace MediaWiki\HookContainer {
}
/**
* @covers \MediaWiki\HookContainer\HookContainer::getHandlerDescriptions
* @covers \MediaWiki\HookContainer\HookContainer::getHandlerCallbacks
* @dataProvider provideGetHandlerDescriptions
* @covers \MediaWiki\HookContainer\HookContainer::getHandlers
* @dataProvider provideGetHandlers
*/
public function testGetHandlers(
string $hook,
?array $handlerToRegister,
int $expectedDescriptions,
int $expectedCallbacks
) {
public function testGetHandlers( string $hook, ?array $handlerToRegister, array $expectedReturn ) {
if ( $handlerToRegister ) {
$hooks = [ $hook => [ $handlerToRegister ] ];
} else {
@ -272,24 +262,37 @@ namespace MediaWiki\HookContainer {
'FooActionCompleteDeprecated' => [ 'deprecatedVersion' => '1.35' ]
];
$hookContainer = $this->newHookContainer( [], $hooks, $fakeDeprecatedHooks );
$descriptions = $hookContainer->getHandlerDescriptions( $hook );
$this->assertCount(
$expectedDescriptions,
$descriptions,
'getHandlerDescriptions()'
$handlers = $hookContainer->getHandlers( $hook );
$this->assertArrayEquals(
$handlers,
$expectedReturn,
'HookContainer::getHandlers() should return array of handler functions'
);
}
$callbacks = $hookContainer->getHandlerCallbacks( $hook );
$this->assertCount(
$expectedCallbacks,
/**
* @covers \MediaWiki\HookContainer\HookContainer::getLegacyHandlers
*/
public function testGetLegacyHandler() {
$hookContainer = $this->newHookContainer( [], [], [] );
$hookContainer->register(
'FooActionComplete',
[ new FooClass(),
'fooMethod'
]
);
$expectedHandlers = [ [ new FooClass(),
'fooMethod'
] ];
$callbacks = $hookContainer->getLegacyHandlers( 'FooActionComplete' );
$this->assertIsCallable( $callbacks[0] );
$this->assertArrayEquals(
$callbacks,
'getHandlerCallbacks()'
$expectedHandlers,
true
);
foreach ( $callbacks as $clbk ) {
$this->assertIsCallable( $clbk );
}
// TODO: Test support for handlers with extra args once that is fixed.
}
/**
@ -297,9 +300,6 @@ namespace MediaWiki\HookContainer {
*/
public static function provideRunConfigured() {
$fooObj = new FooClass();
$closure = static function ( &$count ) {
$count++;
};
$extra = 10;
return [
// Callables
@ -307,10 +307,11 @@ namespace MediaWiki\HookContainer {
'Object and method' => [ [ $fooObj, 'fooMethod' ] ],
'Class name and static method' => [ [ 'MediaWiki\HookContainer\FooClass', 'fooStaticMethod' ] ],
'static method' => [ 'MediaWiki\HookContainer\FooClass::fooStaticMethod' ],
'Closure' => [ $closure ],
// Shorthand
'Object' => [ $fooObj ],
'Closure' => [
static function ( &$count ) {
$count++;
}
],
// Handlers with extra data attached
'static method with extra data' => [
@ -319,6 +320,7 @@ namespace MediaWiki\HookContainer {
],
'Object and method with extra data' => [ [ [ $fooObj, 'fooMethodWithExtra' ], $extra ], 11 ],
'Function extra data' => [ [ 'fooGlobalFunctionWithExtra', $extra ], 11 ],
'Function in array with extra data' => [ [ [ 'fooGlobalFunctionWithExtra' ], $extra ], 11 ],
'Closure with extra data' => [
[
static function ( int $inc, &$count ) {
@ -329,18 +331,8 @@ namespace MediaWiki\HookContainer {
11
],
// No-ops
'empty array' => [ [], 1 ],
'null' => [ null, 1 ],
'false' => [ false, 1 ],
'NOOP' => [ HookContainer::NOOP, 1 ],
// Strange edge cases
'Object in array without method' => [ [ $fooObj ] ],
'Callable in array' => [ [ [ $fooObj, 'fooMethod' ] ] ],
'Closure in array with no extra data' => [ [ $closure ] ],
'Function in array' => [ [ 'fooGlobalFunction' ] ],
'Function in array in array' => [ [ [ 'fooGlobalFunction' ] ] ],
'static method as array in array' => [
[ [ 'MediaWiki\HookContainer\FooClass', 'fooStaticMethod' ] ]
],
@ -352,6 +344,7 @@ namespace MediaWiki\HookContainer {
/**
* @covers \MediaWiki\HookContainer\HookContainer::run
* @covers \MediaWiki\HookContainer\HookContainer::callLegacyHook
* @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler
* @dataProvider provideRunConfigured
*/
@ -366,9 +359,9 @@ namespace MediaWiki\HookContainer {
/**
* @covers \MediaWiki\HookContainer\HookContainer::run
* @covers \MediaWiki\HookContainer\HookContainer::callLegacyHook
* @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler
* @dataProvider provideRunConfigured
* @dataProvider provideRunExtensionHook
*/
public function testRegisterAndRun( $handler, $expectedCount = 2 ) {
$hookContainer = $this->newHookContainer( [], [] );
@ -380,45 +373,6 @@ namespace MediaWiki\HookContainer {
$this->assertSame( $expectedCount, $count );
}
/**
* Values returned: hook, handler, handler arguments, options
*/
public static function provideRegisterAndRunCallback() {
$fooObj = new FooClass();
return [
// Callables
'Function' => [ 'fooGlobalFunction' ],
'Object and method' => [ [ $fooObj, 'fooMethod' ] ],
'Class name and static method' => [ [ 'MediaWiki\HookContainer\FooClass', 'fooStaticMethod' ] ],
'static method' => [ 'MediaWiki\HookContainer\FooClass::fooStaticMethod' ],
'Closure' => [
static function ( &$count ) {
$count++;
}
],
// Extension-style handler
'Extension handler' => [ self::HANDLER_REGISTRATION ],
// NOTE: hook handlers with extra data are not supported for callbacks!
];
}
/**
* @covers \MediaWiki\HookContainer\HookContainer::getHandlerCallbacks
* @dataProvider provideRegisterAndRunCallback
*/
public function testRegisterAndRunCallback( $handler, $expectedCount = 2 ) {
$hookContainer = $this->newHookContainer( [], [] );
$hookContainer->register( 'Increment', $handler );
$count = 1;
foreach ( $hookContainer->getHandlerCallbacks( 'Increment' ) as $callback ) {
$callback( $count );
}
$this->assertSame( $expectedCount, $count );
}
/**
* Values returned: hook, handler, handler arguments, options
*/
@ -430,14 +384,15 @@ namespace MediaWiki\HookContainer {
/**
* @covers \MediaWiki\HookContainer\HookContainer::run
* @covers \MediaWiki\HookContainer\HookContainer::callLegacyHook
* @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler
* @dataProvider provideRunExtensionHook
*/
public function testRunExtensionHook( array $handler, $expectedCount = 1 ) {
$hookContainer = $this->newHookContainer( [], [ 'X\\Y::Increment' => [ $handler ] ] );
$hookContainer = $this->newHookContainer( [], [ 'Increment' => [ $handler ] ] );
$count = 0;
$hookValue = $hookContainer->run( 'X\\Y::Increment', [ &$count ] );
$hookValue = $hookContainer->run( 'Increment', [ &$count ] );
$this->assertTrue( $hookValue );
$this->assertSame( $expectedCount, $count );
}
@ -456,6 +411,7 @@ namespace MediaWiki\HookContainer {
/**
* @covers \MediaWiki\HookContainer\HookContainer::run
* @covers \MediaWiki\HookContainer\HookContainer::callLegacyHook
* @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler
* @dataProvider provideRunFailsWithNoService
*/
@ -495,7 +451,7 @@ namespace MediaWiki\HookContainer {
$seq = [ 'start' ];
$hookContainer->run( 'Append', [ &$seq ] );
$expected = [ 'start', 'configured1', 'configured2', 'FooExtension', 'registered' ];
$expected = [ 'start', 'configured1', 'configured2', 'registered', 'FooExtension' ];
$this->assertSame( $expected, $seq );
}
@ -571,16 +527,21 @@ namespace MediaWiki\HookContainer {
/**
* Test running deprecated hooks from $wgHooks with the deprecation declared in HookContainer.
* @covers \MediaWiki\HookContainer\HookContainer::run
* @covers \MediaWiki\HookContainer\HookContainer::callLegacyHook
* @dataProvider provideRegisterDeprecated
*/
public function testRunConfiguredDeprecated( array $deprecationInfo ) {
public function testRunConfiguredDeprecated( array $deprecationInfo, bool $expectWarning ) {
$hookContainer = $this->newHookContainer(
[ 'Increment' => [ self::HANDLER_FUNCTION ] ],
[],
[ 'Increment' => $deprecationInfo ]
);
// No warning expected when running the hook!
// Expected deprecation?
if ( $expectWarning ) {
$this->expectDeprecationAndContinue( '/Use of Increment hook/' );
}
// Deprecated hooks should still be functional!
$count = 0;
$hookContainer->run( 'Increment', [ &$count ] );
@ -590,6 +551,7 @@ namespace MediaWiki\HookContainer {
/**
* Test running deprecated hooks from $wgHooks with the deprecation passed in the options parameter.
* @covers \MediaWiki\HookContainer\HookContainer::run
* @covers \MediaWiki\HookContainer\HookContainer::callLegacyHook
* @dataProvider provideRegisterDeprecated
*/
public function testRunConfiguredDeprecatedWithOption( array $deprecationInfo, bool $expectWarning ) {
@ -626,41 +588,21 @@ namespace MediaWiki\HookContainer {
[ 'Increment' => $deprecationInfo ]
);
// NOTE: Currently expected to NOT trigger a deprecation warning. May change.
// Deprecated hooks should be functional, the handle that acknowledges deprecation should be skipped.
// We do not expect deprecation warnings here. They are covered by emitDeprecationWarnings()
$count = 0;
$hookContainer->run( 'Increment', [ &$count ] );
$this->assertSame( 1, $count );
}
/**
* Test running deprecated hooks from extensions.
*
* @covers \MediaWiki\HookContainer\HookContainer::run
*/
public function testRunHandlerObjectDeprecatedWithOption() {
$deprecationInfo = [ 'deprecatedVersion' => '1.0' ];
// If the handler acknowledges deprecation, it should be skipped
$knownDeprecated = self::HANDLER_REGISTRATION + [ 'deprecated' => true ];
$hookContainer = $this->newHookContainer(
[],
[ 'Increment' => [ self::HANDLER_REGISTRATION, $knownDeprecated ] ],
[ 'Increment' => $deprecationInfo ]
);
// We do expect deprecation warnings when the 'deprecationVersion' key is provided in the $options parameter.
$this->expectDeprecationAndContinue( '/Use of Increment hook/' );
// Deprecated hooks should be functional, the handle that acknowledges deprecation should be skipped.
$count = 0;
// Try again, with $deprecationInfo as $options
$hookContainer->run( 'Increment', [ &$count ], $deprecationInfo );
$this->assertSame( 1, $count );
$this->assertSame( 2, $count );
}
/**
* @covers \MediaWiki\HookContainer\HookContainer::getHookNames
* @covers \MediaWiki\HookContainer\HookContainer::getRegisteredHooks
*/
public function testGetHookNames() {
$fooHandler = [ 'handler' => [
@ -683,18 +625,15 @@ namespace MediaWiki\HookContainer {
$container->register( 'C', 'strtoupper' );
// Ask for a few hooks that have no handlers.
// Negative caching inside HookHandler should not cause them to be returned from getHookNames
$container->isRegistered( 'X' );
$container->getHandlerCallbacks( 'Y' );
$this->assertArrayEquals( [ 'A', 'B', 'C' ], $container->getHookNames() );
$this->assertArrayEquals( [ 'A', 'B', 'C' ], $container->getRegisteredHooks() );
// make sure we are getting each hook name only once
$container->register( 'B', 'strtoupper' );
$container->register( 'A', 'strtoupper' );
$this->assertArrayEquals( [ 'A', 'B', 'C' ], $container->getHookNames() );
$this->assertArrayEquals( [ 'A', 'B', 'C' ], $container->getRegisteredHooks() );
}
/**
@ -703,6 +642,10 @@ namespace MediaWiki\HookContainer {
public static function provideRunErrors() {
// XXX: should also fail: non-function string, empty array
return [
'a number' => [
123,
[]
],
'return a string' => [
static function () {
return 'string';
@ -731,49 +674,6 @@ namespace MediaWiki\HookContainer {
$hookContainer->run( 'MWTestHook', [], $options );
}
/**
* Values returned: hook, handlersToRegister, options
*/
public static function provideRegisterErrors() {
// XXX: should also fail: non-function string, empty array
return [
'a number' => [ 123 ],
'non-callable string' => [ 'a, b, c' ],
'array referencing an unknown method' => [ [ self::class, 'thisMethodDoesNotExist' ] ],
'empty string' => [ '' ],
'zero' => [ 0 ],
'true' => [ true ],
];
}
/**
* @dataProvider provideRegisterErrors
* @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler
* @covers \MediaWiki\HookContainer\HookContainer::register
*/
public function testRegisterErrors( $badHandler ) {
$hookContainer = $this->newHookContainer();
$this->expectException( InvalidArgumentException::class );
$hookContainer->register( 'MWTestHook', $badHandler );
}
/**
* @dataProvider provideRegisterErrors
* @covers \MediaWiki\HookContainer\HookContainer::normalizeHandler
* @covers \MediaWiki\HookContainer\HookContainer::run
*/
public function testRunWithBadHandlers( $badHandler ) {
$goodHandler = self::HANDLER_FUNCTION;
$hookContainer = $this->newHookContainer( [ 'MWTestHook' => [ $badHandler, $goodHandler ] ] );
// Bad handlers from the constructor should fail silently
$count = 0;
$hookContainer->run( 'MWTestHook', [ &$count ] );
$this->assertSame( 1, $count );
}
public static function provideEmitDeprecationWarnings() {
yield 'Deprecated extension hook' => [
'$oldHooks' => [],
@ -855,7 +755,8 @@ namespace MediaWiki\HookContainer {
/**
* @covers \MediaWiki\HookContainer\HookContainer::isRegistered
* @covers \MediaWiki\HookContainer\HookContainer::getHandlerCallbacks
* @covers \MediaWiki\HookContainer\HookContainer::getHandlers
* @covers \MediaWiki\HookContainer\HookContainer::getLegacyHandlers
* @covers \MediaWiki\HookContainer\HookContainer::register
* @covers \MediaWiki\HookContainer\HookContainer::clear
*/
@ -884,11 +785,11 @@ namespace MediaWiki\HookContainer {
$this->assertTrue( $hookContainer->isRegistered( 'XyzHook' ) );
$this->assertTrue( $hookContainer->isRegistered( 'FooActionComplete' ) );
$this->assertSame( [], $hookContainer->getHandlerCallbacks( 'Increment' ) );
$this->assertSame( [], $hookContainer->getHandlerCallbacks( 'Increment' ) );
$this->assertNotEmpty( $hookContainer->getHandlerCallbacks( 'AbcHook' ) );
$this->assertNotEmpty( $hookContainer->getHandlerCallbacks( 'FooActionComplete' ) );
$this->assertNotEmpty( $hookContainer->getHandlerCallbacks( 'XyzHook' ) );
$this->assertSame( [], $hookContainer->getLegacyHandlers( 'Increment' ) );
$this->assertSame( [], $hookContainer->getHandlers( 'Increment' ) );
$this->assertNotEmpty( $hookContainer->getLegacyHandlers( 'AbcHook' ) );
$this->assertNotEmpty( $hookContainer->getHandlers( 'FooActionComplete' ) );
$this->assertNotEmpty( $hookContainer->getLegacyHandlers( 'XyzHook' ) );
// No more increment!
$hookContainer->run( 'Increment', [ &$count ] );
@ -983,10 +884,6 @@ namespace MediaWiki\HookContainer {
return true;
}
public function onIncrement( &$count ) {
$count++;
}
public static function fooStaticMethod( &$count ) {
$count++;
return null;
@ -1044,10 +941,6 @@ namespace FooExtension {
$count++;
}
public function onX_Y__Increment( &$count ) {
$count++;
}
public function onAppend( &$list ) {
$list[] = 'FooExtension';
}