From 2b7754ccc220a247d6f95e25bed450b9a1af145b Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Wed, 21 Feb 2024 22:48:05 +0800 Subject: [PATCH] MDL-81011 core: Add attribute alternative to hooks interfaces This change replaces the requirement for: - \core\hook\deprecated_callback_replacement - \core\hook\described_hook These are replaced by appropriate Attributes. --- admin/classes/table/hook_list_table.php | 6 +- lib/apis.json | 5 + .../attribute/hook/replaces_callbacks.php | 35 +++ lib/classes/attribute/label.php | 32 +++ lib/classes/attribute/tags.php | 37 +++ lib/classes/attribute_helper.php | 245 ++++++++++++++++++ .../hook/backup/get_excluded_events.php | 25 +- .../hook/deprecated_callback_replacement.php | 2 + lib/classes/hook/di_configuration.php | 12 +- lib/classes/hook/manager.php | 40 ++- .../hook/navigation/primary_extend.php | 28 +- .../output/standard_head_html_prepend.php | 35 +-- lib/classes/hooks.php | 8 + lib/tests/attribute_helper_test.php | 170 ++++++++++++ .../fixtures/attribute_helper_example.php | 88 +++++++ lib/upgrade.txt | 10 + 16 files changed, 676 insertions(+), 102 deletions(-) create mode 100644 lib/classes/attribute/hook/replaces_callbacks.php create mode 100644 lib/classes/attribute/label.php create mode 100644 lib/classes/attribute/tags.php create mode 100644 lib/classes/attribute_helper.php create mode 100644 lib/tests/attribute_helper_test.php create mode 100644 lib/tests/fixtures/attribute_helper_example.php diff --git a/admin/classes/table/hook_list_table.php b/admin/classes/table/hook_list_table.php index 1f95002703e..1ffc092708b 100644 --- a/admin/classes/table/hook_list_table.php +++ b/admin/classes/table/hook_list_table.php @@ -154,11 +154,7 @@ class hook_list_table extends flexible_table { return ''; } - $rc = new \ReflectionClass($row->classname); - if (!$rc->implementsInterface(\core\hook\deprecated_callback_replacement::class)) { - return ''; - } - $deprecates = call_user_func([$row->classname, 'get_deprecated_plugin_callbacks']); + $deprecates = \core\hook\manager::get_replaced_callbacks($row->classname); if (count($deprecates) === 0) { return ''; } diff --git a/lib/apis.json b/lib/apis.json index 8d91b221258..4110a21891f 100644 --- a/lib/apis.json +++ b/lib/apis.json @@ -19,6 +19,11 @@ "allowedlevel2": true, "allowedspread": true }, + "attribute": { + "component": "core", + "allowedlevel2": true, + "allowedspread": true + }, "availability": { "component": "core_availability", "allowedlevel2": false, diff --git a/lib/classes/attribute/hook/replaces_callbacks.php b/lib/classes/attribute/hook/replaces_callbacks.php new file mode 100644 index 00000000000..9e9c3dfe176 --- /dev/null +++ b/lib/classes/attribute/hook/replaces_callbacks.php @@ -0,0 +1,35 @@ +. + +namespace core\attribute\hook; + +/** + * A set of callbacks that this hook replaces. + * + * @package core + * @copyright 2024 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\Attribute] +class replaces_callbacks { + /** @var string[] A list of callbacks that this hook replaces */ + public readonly array $callbacks; + public function __construct( + ...$callbacks, + ) { + $this->callbacks = $callbacks; + } +} diff --git a/lib/classes/attribute/label.php b/lib/classes/attribute/label.php new file mode 100644 index 00000000000..5c957cbce6e --- /dev/null +++ b/lib/classes/attribute/label.php @@ -0,0 +1,32 @@ +. + +namespace core\attribute; + +/** + * An unstranslated string attribute used to label an object. + * + * @package core + * @copyright 2024 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\Attribute] +class label { + public function __construct( + public readonly string $label, + ) { + } +} diff --git a/lib/classes/attribute/tags.php b/lib/classes/attribute/tags.php new file mode 100644 index 00000000000..e0be310805c --- /dev/null +++ b/lib/classes/attribute/tags.php @@ -0,0 +1,37 @@ +. + +namespace core\attribute; + +/** + * A set of string tags used to categorise an object. + * + * Note: These are not the same as the tags API. + * + * @package core + * @copyright 2024 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\Attribute] +class tags { + /** @var string[] A list of tags */ + public readonly array $tags; + public function __construct( + ...$tags, + ) { + $this->tags = $tags; + } +} diff --git a/lib/classes/attribute_helper.php b/lib/classes/attribute_helper.php new file mode 100644 index 00000000000..380f973ca16 --- /dev/null +++ b/lib/classes/attribute_helper.php @@ -0,0 +1,245 @@ +. + +namespace core; + +use ReflectionAttribute; + +/** + * Helper for loading attributes. + * + * @package core + * @copyright 2024 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class attribute_helper { + + /** + * Get an instance of an attribute from a reference. + * + * The reference can be: + * - a string, in which case it will be checked for a function, class, method, property, constant, or enum. + * - an array + * - an instantiated object, in which case the object will be checked for a class, method, property, or constant. + * + * @param array|string|object $reference A reference of where to find the attribute + * @param null|string $attributename The name of the attribute to find + * @param int $attributeflags The flags to use when finding the attribute + * @return ?object + */ + public static function instance( + array|string|object $reference, + ?string $attributename = null, + int $attributeflags = ReflectionAttribute::IS_INSTANCEOF, + ): ?object { + return self::one_from($reference, $attributename, $attributeflags)?->newInstance(); + } + + /** + * Get all instance of an attribute from a reference. + * + * The reference can be: + * - a string, in which case it will be checked for a function, class, method, property, constant, or enum. + * - an array + * - an instantiated object, in which case the object will be checked for a class, method, property, or constant. + * + * @param array|string|object $reference A reference of where to find the attribute + * @param null|string $attributename The name of the attribute to find + * @param int $attributeflags The flags to use when finding the attribute + * @return ?object[] + */ + public static function instances( + array|string|object $reference, + ?string $attributename = null, + int $attributeflags = ReflectionAttribute::IS_INSTANCEOF, + ): ?array { + if ($attributes = self::from($reference, $attributename, $attributeflags)) { + return array_map(fn ($attribute) => $attribute->newInstance(), $attributes); + } + + return null; + } + + /** + * Get one attribute from a reference. + * + * The reference can be: + * - a string, in which case it will be checked for a function, class, method, property, constant, or enum. + * - an array + * - an instantiated object, in which case the object will be checked for a class, method, property, or constant. + * + * @param array|string|object $reference A reference of where to find the attribute + * @param null|string $attributename The name of the attribute to find + * @param int $attributeflags The flags to use when finding the attribute + * @return \ReflectionAttribute|null + */ + public static function one_from( + array|string|object $reference, + ?string $attributename = null, + int $attributeflags = ReflectionAttribute::IS_INSTANCEOF, + ): ?\ReflectionAttribute { + $attributes = self::from($reference, $attributename, $attributeflags); + + if ($attributes && count($attributes) > 1) { + throw new \coding_exception('More than one attribute found'); + } + + return $attributes ? $attributes[0] : null; + } + + /** + * Get the attribute from a reference. + * + * The reference can be: + * - a string, in which case it will be checked for a function, class, method, property, constant, or enum. + * - an array + * - an instantiated object, in which case the object will be checked for a class, method, property, or constant. + * + * @param array|string|object $reference A reference of where to find the attribute + * @param null|string $attributename The name of the attribute to find + * @param int $attributeflags The flags to use when finding the attribute + * @return \ReflectionAttribute[]|null + */ + public static function from( + array|string|object $reference, + ?string $attributename = null, + int $attributeflags = ReflectionAttribute::IS_INSTANCEOF, + ): ?array { + if (is_string($reference)) { + if (str_contains($reference, '::')) { + // The reference is a string but it looks to be in the format `object::item`. + return self::from(explode('::', $reference), $attributename, $attributeflags);; + } + + if (class_exists($reference)) { + // The reference looks to be a class name. + return self::from([$reference], $attributename, $attributeflags); + } + + if (function_exists($reference)) { + // The reference looks to be a global function. + $ref = new \ReflectionFunction($reference); + return $ref->getAttributes( + name: $attributename, + flags: $attributeflags, + ); + } + + return null; + } + + if (is_object($reference)) { + // The reference is an object. Normalise and check again. + return self::from([$reference], $attributename, $attributeflags); + } + + if (is_array($reference) && count($reference)) { + if (is_object($reference[0])) { + // The first array key is an instance of a class, enum, etc. + $rc = new \ReflectionObject($reference[0]); + + if ($rc->isEnum() && $reference[0]->name) { + // Enums can be passed via ::from([enum::NAME]). + // In this case they will have a 'name', which must exist. + return self::from_reflected_object( + rc: $rc, + referenceproperty: $reference[0]->name, + attributename: $attributename, + flags: $attributeflags, + ); + } + + // The object is an instance of a class, or similar. + // That means that, if provided, the second array key is the name of the property, constant, method, etc. + return self::from_reflected_object( + rc: $rc, + referenceproperty: $reference[1] ?? null, + attributename: $attributename, + flags: $attributeflags, + ); + } + + if (is_string($reference[0]) && class_exists($reference[0])) { + // The first array key is a class name. + // That means that, if provided, the second array key is the name of the property, constant, method, etc. + $rc = new \ReflectionClass($reference[0]); + return self::from_reflected_object( + rc: $rc, + referenceproperty: $reference[1] ?? null, + attributename: $attributename, + flags: $attributeflags, + ); + } + + // The reference is an array, but it's not an object or a class that currently exists. + return null; + } + } + + /** + * Fetch an attribute from a reflected object. + * + * @param \ReflectionClass $rc The reflected object + * @param null|string $referenceproperty The name of the thing to find attributes on + * @param null|string $attributename The name of the attribute to find + * @param int $attributeflags The flags to use when finding the attribute + * @return \ReflectionAttribute[]|null + */ + protected static function from_reflected_object( + \ReflectionClass $rc, + ?string $referenceproperty, + ?string $attributename = null, + int $flags = 0, + ): ?array { + if ($referenceproperty === null) { + // No name specified - may be the whole class.. + return $rc->getAttributes( + name: $attributename, + flags: $flags, + ); + } + + if ($rc->hasConstant($referenceproperty)) { + // This class has a constant with the specified name. + // Note: This also applies to enums. + $ref = $rc->getReflectionConstant($referenceproperty); + return $ref->getAttributes( + name: $attributename, + flags: $flags, + ); + } + + if ($rc->hasMethod($referenceproperty)) { + // This class has a method with the specified name. + $ref = $rc->getMethod($referenceproperty); + return $ref->getAttributes( + name: $attributename, + flags: $flags, + ); + } + + if ($rc->hasProperty($referenceproperty)) { + // This class has a property with the specified name. + $ref = $rc->getProperty($referenceproperty); + return $ref->getAttributes( + name: $attributename, + flags: $flags, + ); + } + + return null; + } +} diff --git a/lib/classes/hook/backup/get_excluded_events.php b/lib/classes/hook/backup/get_excluded_events.php index 483f2faba29..c3b84536cd6 100644 --- a/lib/classes/hook/backup/get_excluded_events.php +++ b/lib/classes/hook/backup/get_excluded_events.php @@ -16,8 +16,6 @@ namespace core\hook\backup; -use core\hook\described_hook; - /** * Get a list of event names which are excluded to trigger from course changes in automated backup. * @@ -25,31 +23,14 @@ use core\hook\described_hook; * @copyright 2023 Tomo Tsuyuki * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -final class get_excluded_events implements described_hook { - +#[\core\attribute\label('Get a list of event names which are excluded to trigger from course changes in automated backup.')] +#[\core\attribute\tags('backup')] +final class get_excluded_events { /** * @var string[] Array of event names. */ private $events = []; - /** - * Describes the hook purpose. - * - * @return string - */ - public static function get_hook_description(): string { - return 'Get a list of event names which are excluded to trigger from course changes in automated backup.'; - } - - /** - * List of tags that describe this hook. - * - * @return string[] - */ - public static function get_hook_tags(): array { - return ['backup']; - } - /** * Add an array of event names which are excluded to trigger from course changes in automated backup. * This is set from plugin hook. diff --git a/lib/classes/hook/deprecated_callback_replacement.php b/lib/classes/hook/deprecated_callback_replacement.php index 40596673707..cacf114aea1 100644 --- a/lib/classes/hook/deprecated_callback_replacement.php +++ b/lib/classes/hook/deprecated_callback_replacement.php @@ -19,6 +19,8 @@ namespace core\hook; /** * Interface for describing of lib.php callbacks that were deprecated by the hook. * + * Please note that, from Moodle 4.4, you can instead use the \core\attribute\hook\replaces_callback attribute. + * * @package core * @author Petr Skoda * @copyright 2022 Open LMS diff --git a/lib/classes/hook/di_configuration.php b/lib/classes/hook/di_configuration.php index 843f6bf977f..aca2f5d8a46 100644 --- a/lib/classes/hook/di_configuration.php +++ b/lib/classes/hook/di_configuration.php @@ -17,6 +17,7 @@ namespace core\hook; use DI\ContainerBuilder; +use core\attribute\label; /** * Allow for init-time configuration of the Dependency Injection container. @@ -25,7 +26,8 @@ use DI\ContainerBuilder; * @copyright 2023 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class di_configuration implements described_hook { +#[label('The DI container, which allows plugins to register any service requiring configuration or initialisation.')] +class di_configuration { /** * Create the Dependency Injection configuration hook instance. * @@ -78,12 +80,4 @@ class di_configuration implements described_hook { return $this; } - - public static function get_hook_description(): string { - return 'The DI container, which allows plugins to register any service requiring configuration or initialisation.'; - } - - public static function get_hook_tags(): array { - return []; - } } diff --git a/lib/classes/hook/manager.php b/lib/classes/hook/manager.php index 1b29e4f2add..e793c5810e6 100644 --- a/lib/classes/hook/manager.php +++ b/lib/classes/hook/manager.php @@ -16,6 +16,7 @@ namespace core\hook; +use core\attribute_helper; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\ListenerProviderInterface; use Psr\EventDispatcher\StoppableEventInterface; @@ -171,6 +172,30 @@ final class manager implements } } + /** + * Get the list of callbacks that the given hook class replaces (if any). + * + * @param string $hookclassname + * @return array + */ + public static function get_replaced_callbacks(string $hookclassname): array { + if (!class_exists($hookclassname)) { + return []; + } + if (is_subclass_of($hookclassname, \core\hook\deprecated_callback_replacement::class)) { + return $hookclassname::get_deprecated_plugin_callbacks(); + } + + // Ensure that the replaces_callbacks attribute is loaded. + // TODO MDL-81134 Remove after LTS+1. + require_once(dirname(__DIR__) . '/attribute/hook/replaces_callbacks.php'); + if ($replaces = attribute_helper::instance($hookclassname, \core\attribute\hook\replaces_callbacks::class)) { + return $replaces->callbacks; + } + + return []; + } + /** * Verify that callback is valid. * @@ -450,20 +475,9 @@ final class manager implements private function fetch_deprecated_callbacks(): void { $candidates = self::discover_known_hooks(); - /** @var class-string $hookclassname */ foreach (array_keys($candidates) as $hookclassname) { - if (!class_exists($hookclassname)) { - continue; - } - if (!is_subclass_of($hookclassname, \core\hook\deprecated_callback_replacement::class)) { - continue; - } - $deprecations = $hookclassname::get_deprecated_plugin_callbacks(); - if (!$deprecations) { - continue; - } - foreach ($deprecations as $deprecation) { - $this->alldeprecations[$deprecation][] = $hookclassname; + foreach (self::get_replaced_callbacks($hookclassname) as $replacedcallback) { + $this->alldeprecations[$replacedcallback][] = $hookclassname; } } } diff --git a/lib/classes/hook/navigation/primary_extend.php b/lib/classes/hook/navigation/primary_extend.php index b4a8ae12e88..aaea88e38bc 100644 --- a/lib/classes/hook/navigation/primary_extend.php +++ b/lib/classes/hook/navigation/primary_extend.php @@ -16,7 +16,6 @@ namespace core\hook\navigation; -use core\hook\described_hook; use core\hook\stoppable_trait; use core\navigation\views\primary; @@ -27,8 +26,9 @@ use core\navigation\views\primary; * @copyright 2023 Marina Glancy * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class primary_extend implements described_hook, - \Psr\EventDispatcher\StoppableEventInterface { +#[\core\attribute\label('Allows plugins to insert nodes into site primary navigation')] +#[\core\attribute\tags('navigation')] +class primary_extend implements \Psr\EventDispatcher\StoppableEventInterface { use stoppable_trait; /** @@ -36,7 +36,9 @@ class primary_extend implements described_hook, * * @param primary $primaryview Primary navigation view */ - public function __construct(protected primary $primaryview) { + public function __construct( + public readonly primary $primaryview, + ) { } /** @@ -47,22 +49,4 @@ class primary_extend implements described_hook, public function get_primaryview(): primary { return $this->primaryview; } - - /** - * Describes the hook purpose. - * - * @return string - */ - public static function get_hook_description(): string { - return 'Allows plugins to insert nodes into site primary navigation'; - } - - /** - * List of tags that describe this hook. - * - * @return string[] - */ - public static function get_hook_tags(): array { - return ['navigation']; - } } diff --git a/lib/classes/hook/output/standard_head_html_prepend.php b/lib/classes/hook/output/standard_head_html_prepend.php index 52b7b9553d6..5a4ac7d0c56 100644 --- a/lib/classes/hook/output/standard_head_html_prepend.php +++ b/lib/classes/hook/output/standard_head_html_prepend.php @@ -16,9 +16,6 @@ namespace core\hook\output; -use core\hook\described_hook; -use core\hook\deprecated_callback_replacement; - /** * Allows plugins to add any elements to the page html tag * @@ -26,37 +23,13 @@ use core\hook\deprecated_callback_replacement; * @copyright 2023 Marina Glancy * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class standard_head_html_prepend implements described_hook, deprecated_callback_replacement { +#[\core\attribute\tags('output')] +#[\core\attribute\label('Allows plugins to add any elements to the page <head> html tag.')] +#[\core\attribute\hook\replaces_callbacks('before_standard_html_head')] +class standard_head_html_prepend { /** @var string $output Stores results from callbacks */ private $output = ''; - /** - * Describes the hook purpose. - * - * @return string - */ - public static function get_hook_description(): string { - return 'Allows plugins to add any elements to the page <head> html tag.'; - } - - /** - * List of tags that describe this hook. - * - * @return string[] - */ - public static function get_hook_tags(): array { - return ['output']; - } - - /** - * Returns list of lib.php plugin callbacks that were deprecated by the hook. - * - * @return array - */ - public static function get_deprecated_plugin_callbacks(): array { - return ['before_standard_html_head']; - } - /** * Plugins implementing callback can add any HTML to the page. * diff --git a/lib/classes/hooks.php b/lib/classes/hooks.php index 9b417c8947d..b2eccd3ddb0 100644 --- a/lib/classes/hooks.php +++ b/lib/classes/hooks.php @@ -81,6 +81,14 @@ final class hooks implements \core\hook\discovery_agent { if (is_subclass_of($classname, \core\hook\described_hook::class)) { $hooks[$classname]['description'] = $classname::get_hook_description(); $hooks[$classname]['tags'] = $classname::get_hook_tags(); + } else { + if ($description = attribute_helper::instance($classname, \core\attribute\label::class)) { + $hooks[$classname]['description'] = (string) $description->label; + } + + if ($tags = attribute_helper::instance($classname, \core\attribute\tags::class)) { + $hooks[$classname]['tags'][] = $tags->tags; + } } } diff --git a/lib/tests/attribute_helper_test.php b/lib/tests/attribute_helper_test.php new file mode 100644 index 00000000000..f8c4af8007d --- /dev/null +++ b/lib/tests/attribute_helper_test.php @@ -0,0 +1,170 @@ +. + +namespace core; + +/** + * Tests for the attribute_helper. + * + * @package core + * @category test + * @copyright 2024 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core\attribute_helper + */ +final class attribute_helper_test extends \advanced_testcase { + public static function setUpBeforeClass(): void { + require_once(__DIR__ . '/fixtures/attribute_helper_example.php'); + } + + /** + * @dataProvider get_attributes_provider + */ + public function test_get_attributes( + int $expectedcount, + array $args, + ): void { + $attributes = attribute_helper::from(...$args); + $instances = attribute_helper::instances(...$args); + if ($expectedcount) { + $this->assertNotEmpty($attributes); + $this->assertCount($expectedcount, $attributes); + $this->assertCount($expectedcount, $instances); + + } else { + $this->assertEmpty($attributes); + $this->assertEmpty($instances); + } + } + + public static function get_attributes_provider(): array { + return [ + [3, [[attribute_helper_example::class]]], + [0, [[attribute_helper_example_without::class]]], + [3, [[attribute_helper_example::class, 'WITH_ATTRIBUTES']]], + [0, [[attribute_helper_example::class, 'WITHOUT_ATTRIBUTE']]], + [3, [[attribute_helper_example::class, 'withattributes']]], + [0, [[attribute_helper_example::class, 'withoutattributes']]], + [3, [[attribute_helper_example::class, 'with_attributes']]], + [0, [[attribute_helper_example::class, 'without_attributes']]], + [3, [[attribute_helper_enum::class, 'WITH_ATTRIBUTES']]], + [0, [[attribute_helper_enum::class, 'WITHOUT_ATTRIBUTE']]], + [3, [__NAMESPACE__ . '\\attribute_helper_method_with']], + [0, [__NAMESPACE__ . '\\attribute_helper_method_without']], + [0, [__NAMESPACE__ . '\\function_not_exists']], + + [3, [attribute_helper_example::class]], + [0, [attribute_helper_example_without::class]], + [3, [attribute_helper_example::class . '::WITH_ATTRIBUTES']], + [0, [attribute_helper_example::class . '::WITHOUT_ATTRIBUTE']], + [3, [attribute_helper_example::class . '::withattributes']], + [0, [attribute_helper_example::class . '::withoutattributes']], + [3, [attribute_helper_example::class . '::with_attributes']], + [0, [attribute_helper_example::class . '::without_attributes']], + [3, [attribute_helper_enum::class . '::WITH_ATTRIBUTES']], + [0, [attribute_helper_enum::class . '::WITHOUT_ATTRIBUTE']], + + [2, [[attribute_helper_example::class], attribute_helper_attribute_a::class]], + [0, [[attribute_helper_example_without::class], attribute_helper_attribute_a::class]], + [2, [[attribute_helper_example::class, 'WITH_ATTRIBUTES'], attribute_helper_attribute_a::class]], + [0, [[attribute_helper_example::class, 'WITHOUT_ATTRIBUTE'], attribute_helper_attribute_a::class]], + [2, [[attribute_helper_example::class, 'withattributes'], attribute_helper_attribute_a::class]], + [0, [[attribute_helper_example::class, 'withoutattributes'], attribute_helper_attribute_a::class]], + [2, [[attribute_helper_example::class, 'with_attributes'], attribute_helper_attribute_a::class]], + [0, [[attribute_helper_example::class, 'without_attributes'], attribute_helper_attribute_a::class]], + [2, [[attribute_helper_enum::class, 'WITH_ATTRIBUTES'], attribute_helper_attribute_a::class]], + [0, [[attribute_helper_enum::class, 'WITHOUT_ATTRIBUTE'], attribute_helper_attribute_a::class]], + [2, [__NAMESPACE__ . '\\attribute_helper_method_with', attribute_helper_attribute_a::class]], + [0, [__NAMESPACE__ . '\\attribute_helper_method_without', attribute_helper_attribute_a::class]], + ]; + } + + public function test_get_attributes_references(): void { + $attributes = attribute_helper::from(new attribute_helper_example()); + $this->assertCount(3, $attributes); + + $attributes = attribute_helper::from( + new attribute_helper_example(), + attribute_helper_attribute_a::class, + ); + $this->assertCount(2, $attributes); + + $attributes = attribute_helper::from(attribute_helper_enum::WITH_ATTRIBUTES); + $this->assertCount(3, $attributes); + $instances = attribute_helper::instances(attribute_helper_enum::WITH_ATTRIBUTES); + $this->assertCount(3, $instances); + + $attributes = attribute_helper::from( + attribute_helper_enum::WITH_ATTRIBUTES, + attribute_helper_attribute_a::class, + ); + $this->assertCount(2, $attributes); + $instances = attribute_helper::instances( + attribute_helper_enum::WITH_ATTRIBUTES, + attribute_helper_attribute_a::class, + ); + $this->assertCount(2, $instances); + array_map( + fn($instance) => $this->assertInstanceOf(attribute_helper_attribute_a::class, $instance), + $instances, + ); + + // Singular fetches. + $attribute = attribute_helper::one_from( + attribute_helper_enum::WITH_ATTRIBUTES, + attribute_helper_attribute_b::class, + ); + $this->assertInstanceOf(\ReflectionAttribute::class, $attribute); + $instance = attribute_helper::instance( + attribute_helper_enum::WITH_ATTRIBUTES, + attribute_helper_attribute_b::class, + ); + $this->assertInstanceOf(attribute_helper_attribute_b::class, $instance); + } + + public function test_get_attributes_invalid(): void { + $this->assertNull(attribute_helper::from(non_existent_class::class)); + $this->assertNull(attribute_helper::from([non_existent_class::class])); + $this->assertNull(attribute_helper::from([attribute_helper_example::class, 'non_existent'])); + $this->assertNull(attribute_helper::from([non_existent_class::class, 'non_existent'])); + $this->assertNull(attribute_helper::instances(non_existent_class::class)); + $this->assertNull(attribute_helper::instances([non_existent_class::class])); + $this->assertNull(attribute_helper::instances([attribute_helper_example::class, 'non_existent'])); + $this->assertNull(attribute_helper::instances([non_existent_class::class, 'non_existent'])); + + // Test singular fetches. + $this->assertNull(attribute_helper::one_from(non_existent_class::class)); + $this->assertNull(attribute_helper::one_from([non_existent_class::class])); + $this->assertNull(attribute_helper::one_from([attribute_helper_example::class, 'non_existent'])); + $this->assertNull(attribute_helper::one_from([non_existent_class::class, 'non_existent'])); + $this->assertNull(attribute_helper::instance(non_existent_class::class)); + $this->assertNull(attribute_helper::instance([non_existent_class::class])); + $this->assertNull(attribute_helper::instance([attribute_helper_example::class, 'non_existent'])); + $this->assertNull(attribute_helper::instance([non_existent_class::class, 'non_existent'])); + } + + public function test_get_attribute_too_many(): void { + $this->expectException(\coding_exception::class); + $this->expectExceptionMessage('More than one attribute found'); + attribute_helper::one_from(attribute_helper_example::class); + } + + public function test_get_instance_too_many(): void { + $this->expectException(\coding_exception::class); + $this->expectExceptionMessage('More than one attribute found'); + attribute_helper::instance(attribute_helper_example::class); + } +} diff --git a/lib/tests/fixtures/attribute_helper_example.php b/lib/tests/fixtures/attribute_helper_example.php new file mode 100644 index 00000000000..d80f2e560bd --- /dev/null +++ b/lib/tests/fixtures/attribute_helper_example.php @@ -0,0 +1,88 @@ +. + +namespace core; + +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_ALL)] +class attribute_helper_attribute_a { + public function __construct( + public readonly string $value, + ) { + } +} + +#[\Attribute] +class attribute_helper_attribute_b { +} + +/** + * Helper for loading attributes. + * + * @package core + * @copyright 2024 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[attribute_helper_attribute_a('a')] +#[attribute_helper_attribute_a('b')] +#[attribute_helper_attribute_b] +class attribute_helper_example { + #[attribute_helper_attribute_a('a')] + #[attribute_helper_attribute_a('b')] + #[attribute_helper_attribute_b] + public const WITH_ATTRIBUTES = 'examplevalue'; + + public const WITHOUT_ATTRIBUTE = 'examplevalue'; + + #[attribute_helper_attribute_a('a')] + #[attribute_helper_attribute_a('b')] + #[attribute_helper_attribute_b] + public string $withattributes = 'With attributes'; + + public string $withoutattributes = 'Without attributes'; + + #[attribute_helper_attribute_a('a')] + #[attribute_helper_attribute_a('b')] + #[attribute_helper_attribute_b] + public function with_attributes(): void { + } + + public function without_attributes(): void { + } +} + +class attribute_helper_example_without { +} + +#[attribute_helper_attribute_a('a')] +#[attribute_helper_attribute_a('b')] +#[attribute_helper_attribute_b] +enum attribute_helper_enum: string { + #[attribute_helper_attribute_a('a')] + #[attribute_helper_attribute_a('b')] + #[attribute_helper_attribute_b] + case WITH_ATTRIBUTES = 'With attributes'; + + case WITHOUT_ATTRIBUTE = 'Without attributes'; +} + +#[attribute_helper_attribute_a('a')] +#[attribute_helper_attribute_a('b')] +#[attribute_helper_attribute_b] +function attribute_helper_method_with(): void { +} + +function attribute_helper_method_without(): void { +} diff --git a/lib/upgrade.txt b/lib/upgrade.txt index 2d74afb762f..10ce896f2a7 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -66,6 +66,16 @@ information provided here is intended especially for developers. * Removed \zip_writer::sanitise_filepath and \zipwriter::sanitise_filename as they are now automatically sanitised in the zipstream. * Plugins implementing callback `bulk_user_actions()` should be aware that bulk user actions can be executed from /admin/user.php as well as from the bulk actions page. The 'returnurl' parameter will be passed in the request. +* A new attribute helper has been created at \core\attribute_helper. + This helper contains methods to fetch a single \ReflectionAttribute, or an array of \ReflectionAttribute for an item, + or an instance, or a set of instances. +* New generic attributes have been added for: + - \core\attribute\label - An untranslated text label of an object + - \core\attribute\tags - A set of tags for an object +* The hook API now supports the use of \core\attribute\label and \core\attribute\tags + as an alternative to implementing the \core\hook\described_hook interface. +* The hook API now supports the use of the new \core\attribute\hook\replaces_callbacks() attribute + as an alternative to implementing the \core\hook\deprecated_callback_replacement interface. === 4.3 ===