diff --git a/lib/behat/classes/util.php b/lib/behat/classes/util.php index 2f542cc012f..8237f6013ce 100644 --- a/lib/behat/classes/util.php +++ b/lib/behat/classes/util.php @@ -429,6 +429,9 @@ class behat_util extends testing_util { core_courseformat\base::reset_course_cache(0); get_fast_modinfo(0, 0, true); + // Reset the DI container. + \core\di::reset_container(); + // Inform data generator. self::get_data_generator()->reset(); diff --git a/lib/classes/di.php b/lib/classes/di.php new file mode 100644 index 00000000000..5142d7c26b5 --- /dev/null +++ b/lib/classes/di.php @@ -0,0 +1,128 @@ +. + +namespace core; + +use Psr\Container\ContainerInterface; + +/** + * DI Container Helper. + * + * @package core + * @copyright 2023 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class di { + /** @var ContainerInterface The stored container */ + protected static ?ContainerInterface $container; + + /** + * Get the DI Container. + * + * @return ContainerInterface + */ + public static function get_container(): ContainerInterface { + if (!isset(self::$container)) { + self::$container = self::create_container(); + } + return self::$container; + } + + /** + * Reset the DI Container. + * + * This is primarily intended for Unit Testing, and for use in Scheduled tasks. + */ + public static function reset_container(): void { + self::$container = null; + } + + /** + * Finds an entry of the container by its identifier and returns it. + * + * This is a shortcut helper for \core\di::get_container()->get($id). + * + * @param string $id Identifier of the entry to look for. + * @return mixed Entry. + */ + public static function get(string $id): mixed { + return self::get_container()->get($id); + } + + /** + * Set an entry in the container by its identifier. + * + * @param string $id Identifier of the entry to set + * @param mixed $value The value to set + */ + public static function set(string $id, mixed $value): void { + // Please note that the `set` method is not a part of the PSR-11 standard. + // We currently make use of PHP-DI which does have this method, but its use is not guaranteed. + // If Moodle switches to alternative DI resolution, this method _must_ be updated to work with it. + + /** @var \DI\Container */ + $container = self::get_container(); + $container->set($id, $value); + } + + /** + * Create a new Container Instance. + * + * @return ContainerInterface + */ + protected static function create_container(): ContainerInterface { + global $CFG, $DB; + + // PHP Does not support function autoloading. We must manually include the file. + require_once("{$CFG->libdir}/php-di/php-di/src/functions.php"); + + // Configure the Container builder. + $builder = new \DI\ContainerBuilder(); + + // At the moment we are using autowiring, but not automatic attribute injection. + // Automatic attribute injection is a php-di specific feature. + $builder->useAutowiring(true); + + if (!$CFG->debugdeveloper) { + // Enable compilation of the container and write proxies to disk in production. + // See https://php-di.org/doc/performances.html for information. + $cachedir = make_localcache_directory('di'); + $builder->enableCompilation($cachedir); + $builder->writeProxiesToFile(true, $cachedir); + } + + // Get the hook manager. + $hookmanager = \core\hook\manager::get_instance(); + + // Configure some basic definitions. + $builder->addDefinitions([ + // The hook manager should be in the container. + \core\hook\manager::class => $hookmanager, + + // The database. + \moodle_database::class => $DB, + + // The string manager. + \core_string_manager::class => fn() => get_string_manager(), + ]); + + // Add any additional definitions using hooks. + $hookmanager->dispatch(new \core\hook\di_configuration($builder)); + + // Build the container and return. + return $builder->build(); + } +} diff --git a/lib/classes/hook/di_configuration.php b/lib/classes/hook/di_configuration.php new file mode 100644 index 00000000000..843f6bf977f --- /dev/null +++ b/lib/classes/hook/di_configuration.php @@ -0,0 +1,89 @@ +. + +namespace core\hook; + +use DI\ContainerBuilder; + +/** + * Allow for init-time configuration of the Dependency Injection container. + * + * @package core + * @copyright 2023 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class di_configuration implements described_hook { + /** + * Create the Dependency Injection configuration hook instance. + * + * @param ContainerBuilder $builder + */ + public function __construct( + protected ContainerBuilder $builder, + ) { + } + + /** + * Add a definition to the Dependency Injection container. + * + * A definition is a callable that returns an instance of the service. + * + * The callable can take arguments which are resolved using the DI container, for example a definition for the + * following example service requires \moodle_database, and \core\formatting which will be resolved using the DI + * container. + * + * + * $hook->add_definition( + * id: \mod\example\service::class, + * definition: function ( + * \moodle_database $db, + * \core\formatting $formatter, + * ): \mod\example\service { + * return new \mod\example\service( + * $database, + * $formatter, + * $some, + * $other, + * $args, + * )' + * }, + * ); + * + * + * @param string $id The identifier of the container entry + * @param callable $definition The definition of the container entry + * @return self + * @example + */ + public function add_definition( + string $id, + callable $definition, + ): self { + $this->builder->addDefinitions([ + $id => $definition, + ]); + + 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/phpunit/classes/util.php b/lib/phpunit/classes/util.php index 0b0aec7eae6..2f64554b8a9 100644 --- a/lib/phpunit/classes/util.php +++ b/lib/phpunit/classes/util.php @@ -300,6 +300,9 @@ class phpunit_util extends testing_util { // Reset user agent. core_useragent::instance(true, null); + // Reset the DI container. + \core\di::reset_container(); + // verify db writes just in case something goes wrong in reset if (self::$lastdbwrites != $DB->perf_get_writes()) { error_log('Unexpected DB writes in phpunit_util::reset_all_data()'); diff --git a/lib/tests/di_test.php b/lib/tests/di_test.php new file mode 100644 index 00000000000..dfb79f0d006 --- /dev/null +++ b/lib/tests/di_test.php @@ -0,0 +1,162 @@ +. + +namespace core; + +use PHPUnit\Framework\MockObject\Stub; +use Psr\Container\ContainerInterface; + +/** + * Tests for Moodle's Container. + * + * @package core + * @copyright 2024 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core\di + */ +class di_test extends \advanced_testcase { + /** + * Test that the get_container method returns the Container Instance and stores it statically. + */ + public function test_get_container(): void { + $container = di::get_container(); + $this->assertInstanceOf(ContainerInterface::class, $container); + + $this->assertTrue($container === di::get_container()); + } + + /** + * Test that the reset_container method resets the container such that a different instance is returned. + */ + public function test_reset_container(): void { + $instance = di::get_container(); + $this->assertInstanceOf(ContainerInterface::class, $instance); + + di::reset_container(); + $this->assertFalse($instance === di::get_container()); + } + + /** + * This test just ensures that a container can return an autowired client. + * + * This is standard behaviour for a Container, but we want to actually check it. + */ + public function test_autowired_client(): void { + $container = di::get_container(); + $client = $container->get(http_client::class); + + $this->assertInstanceOf(http_client::class, $client); + + // Fetching the same again. + $this->assertEquals( + $client, + $container->get(http_client::class), + ); + } + + /** + * Test that we can mock a client and set it in the container for other consumers to get. + */ + public function test_mocked_client(): void { + $container = di::get_container(); + + // Create a mocked http_client. + $mockedclient = $this->createStub(http_client::class); + + // Set it in the container. + di::set(http_client::class, $mockedclient); + + // Fetching it out will give us the same mocked client. + $client = $container->get(http_client::class); + $this->assertEquals( + $mockedclient, + $client, + ); + + // And the returned client will of course still be an http_client and a Stub. + $this->assertInstanceOf(http_client::class, $client); + $this->assertInstanceOf(Stub::class, $client); + + // Even after getting a new container instance. + $this->assertEquals( + di::get_container()->get(http_client::class), + $client, + ); + + // Resetting the container will give us a new, unmocked, instance. + di::reset_container(); + + $client = di::get_container()->get(http_client::class); + $this->assertInstanceOf(http_client::class, $client); + $this->assertNotInstanceOf(Stub::class, $client); + $this->assertNotEquals( + $mockedclient, + $client, + ); + } + + /** + * Test that a mocked client can be set in one test, but is not preserved across tests. + * + * @return Stub The mocked client to pass to the dependant test + */ + public function test_mocked_client_test_one(): Stub { + di::set(http_client::class, $this->createStub(http_client::class)); + + $mockedclient = di::get_container()->get(http_client::class); + $this->assertInstanceOf(http_client::class, $mockedclient); + $this->assertInstanceOf(Stub::class, $mockedclient); + + return $mockedclient; + } + + /** + * Test that a client mocked in a previous test does not bleed. + * + * @depends test_mocked_client_test_one + */ + public function test_mocked_client_test_two(Stub $mockedclient): void { + $client = di::get_container()->get(http_client::class); + $this->assertInstanceOf(http_client::class, $client); + $this->assertNotInstanceOf(Stub::class, $client); + $this->assertNotEquals($mockedclient, $client); + } + + /** + * Test that the container will return the $DB global as a moodle_database instance. + */ + public function test_fetch_moodle_database(): void { + global $DB; + + $this->assertEquals($DB, di::get(\moodle_database::class)); + } + + /** + * Test that the hook manager is in the container. + */ + public function test_fetch_hook_manager(): void { + $manager = di::get(hook\manager::class); + $this->assertEquals($manager, hook\manager::get_instance()); + } + + public function test_fetch_string_manager(): void { + $stringmanager = di::get(\core_string_manager::class); + $this->assertEquals( + get_string_manager(), + $stringmanager, + ); + } +}