From cd0d4669dd4e6ec57ee207b82bf76e035e6c6f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Debussch=C3=A8re?= Date: Fri, 2 May 2025 14:49:01 +0200 Subject: [PATCH 01/14] feat: tags implementation --- .github/workflows/php.yml | 7 ++- composer.json | 14 ++++-- infection.json5 | 16 ------- phpunit.xml | 27 +++++------ src/Container/Container.php | 89 +++++++++++++++++++++++++++++++---- src/Container/Definition.php | 32 +++++++++++++ tests/Unit/ContainerTest.php | 2 + tests/Unit/DefinitionTest.php | 2 + 8 files changed, 142 insertions(+), 47 deletions(-) delete mode 100644 infection.json5 diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 5f133c8..98e9a5e 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: true matrix: - php: [ '8.0', '8.1', '8.2', '8.3' ] + php: [ '8.2', '8.3', '8.4' ] name: PHP ${{ matrix.php }} @@ -40,4 +40,7 @@ jobs: run: composer install --prefer-dist --no-interaction --no-progress - name: Run test suite - run: ./vendor/bin/pest \ No newline at end of file + run: ./vendor/bin/pest --parallel + + - name: Run mutation tests + run: XDEBUG_MODE=coverage ./vendor/bin/pest --mutate --min=60 --parallel \ No newline at end of file diff --git a/composer.json b/composer.json index cf9e78f..35169a6 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,11 @@ "name": "borschphp/container", "description": "A simple PSR-11 Container implementation.", "license": "MIT", - "keywords": ["psr", "psr-11", "container"], + "keywords": [ + "psr", + "psr-11", + "container" + ], "authors": [ { "name": "Alexandre DEBUSSCHERE", @@ -10,12 +14,12 @@ } ], "require": { - "php": "^8.0", - "psr/container": "^2" + "php": "^8.2", + "psr/container": "^2.0" }, "require-dev": { - "pestphp/pest": "^1.22", - "nikic/php-parser": "^4" + "pestphp/pest": "^3.0", + "phpstan/phpstan": "^2.1" }, "autoload": { "psr-4": { diff --git a/infection.json5 b/infection.json5 deleted file mode 100644 index e1ac880..0000000 --- a/infection.json5 +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/infection/infection/0.27.9/resources/schema.json", - "source": { - "directories": [ - "src" - ] - }, - "logs": { - "text": "infection.log", - "html": "infection.html" - }, - "mutators": { - "@default": true - }, - "testFramework": "pest" -} \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml index 8f4b58c..dbb0c53 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,18 +1,13 @@ - - - - ./tests - - - - - ./app - ./src - - + + + + ./tests + + + + + ./src + + diff --git a/src/Container/Container.php b/src/Container/Container.php index 965baae..a6a3781 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -52,15 +52,67 @@ public function get(string $id): mixed return $this->cache[$id]; } - if (!isset($this->definitions[$id])) { + /*if (!$this->has($id)) { + + }*/ + + if (isset($this->definitions[$id])) { + $definition = $this->definitions[$id]; + } elseif ($this->hasTag($id)) { + $definition = array_filter( + $this->definitions, + fn(Definition $definition) => $definition->hasTag($id) + ); + } elseif (array_reduce($this->delegates, fn($has, $container) => $has ?: $container->has($id), false)) { foreach ($this->delegates as $delegate) { if ($delegate->has($id)) { return $delegate->get($id); } } + } else { + $definition = $this->set($id); } - $definition = $this->definitions[$id] ?? $this->set($id); + /*if (!isset($this->definitions[$id]) && $this->hasTag($id)) { + $definitions = array_filter( + $this->definitions, + fn(Definition $definition) => $definition->hasTag($id) + ); + + $items = []; + foreach ($definitions as $definition) { + $item = $this->cache[$definition->getId()] ?? $definition + ->setContainer($this) + ->get(); + + if ($definition->isCached()) { + $this->cache[$definition->getId()] = $item; + } + + $items[] = $item; + } + + return $items; + }*/ + + /*$definition = $this->definitions[$id] ?? $this->set($id);*/ + + if (is_array($definition)) { + $items = []; + foreach ($definition as $def) { + $item = $this->cache[$def->getId()] ?? $def + ->setContainer($this) + ->get(); + + if ($def->isCached()) { + $this->cache[$def->getId()] = $item; + } + + $items[] = $item; + } + + return $items; + } $item = $definition ->setContainer($this) @@ -85,11 +137,30 @@ public function get(string $id): mixed */ public function has(string $id): bool { - return isset($this->definitions[$id]) || array_reduce( - $this->delegates, - fn($has, $container) => $has ?: $container->has($id), - false - ); + if (isset($this->definitions[$id])) { + return true; + } + + if ($this->hasTag($id)) { + return true; + } + + return array_reduce( + $this->delegates, + fn(bool $has, ContainerInterface $container) => $has ?: $container->has($id), + false + ); + } + + public function hasTag(string $tag): bool + { + foreach ($this->definitions as $definition) { + if ($definition->hasTag($tag)) { + return true; + } + } + + return false; } /** @@ -99,9 +170,11 @@ public function has(string $id): bool */ public function set(string $id, mixed $definition = null): Definition { - return $this->definitions[$id] ??= $definition instanceof Definition ? + $this->definitions[$id] = $definition instanceof Definition ? $definition : new Definition($id, $definition); + + return $this->definitions[$id]; } /** diff --git a/src/Container/Definition.php b/src/Container/Definition.php index 96e6edd..13d141a 100644 --- a/src/Container/Definition.php +++ b/src/Container/Definition.php @@ -21,10 +21,15 @@ class Definition { + /** @var mixed[] */ protected array $parameters = []; + /** @var array> */ protected array $methods = []; + /** @var string[] */ + protected array $tags = []; + protected ContainerInterface $container; /** @@ -42,6 +47,11 @@ public function __construct( $this->concrete = $concrete === null ? $id : $concrete; } + public function getId(): string + { + return $this->id; + } + /** * @param mixed $value * @return Definition @@ -65,6 +75,28 @@ public function addMethod(string $name, array $arguments = []): self return $this; } + public function addTag(string $name): self + { + $this->tags[] = $name; + + return $this; + } + + /** @param string[] $tags */ + public function addTags(array $tags): self + { + foreach ($tags as $tag) { + $this->addTag($tag); + } + + return $this; + } + + public function hasTag(string $tag): bool + { + return in_array($tag, $this->tags); + } + /** * @param ContainerInterface $container * @return $this diff --git a/tests/Unit/ContainerTest.php b/tests/Unit/ContainerTest.php index dca9c06..67845ab 100644 --- a/tests/Unit/ContainerTest.php +++ b/tests/Unit/ContainerTest.php @@ -4,6 +4,8 @@ use BorschTest\Assets\{Bar, Baz, Foo}; use Psr\Container\ContainerInterface; +covers(Container::class); + test('constructor cache the ContainerInterface', function() { $container = new class() extends Borsch\Container\Container { public function isCached(string $id) { diff --git a/tests/Unit/DefinitionTest.php b/tests/Unit/DefinitionTest.php index 0561714..df08136 100644 --- a/tests/Unit/DefinitionTest.php +++ b/tests/Unit/DefinitionTest.php @@ -11,6 +11,8 @@ use BorschTest\Assets\Ink; use Psr\Container\ContainerInterface; +covers(Definition::class); + it('adds method', function () { $definition = new Definition(Bar::class); $definition->addMethod('setSomething', ['something']); From 56a2d0891893c25cacaea61422ec78649d8ff462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Debussch=C3=A8re?= Date: Fri, 2 May 2025 19:31:18 +0200 Subject: [PATCH 02/14] upd: doctrine/collections for definitions and delegated containers --- composer.json | 3 +- src/Container/Container.php | 127 ++++++++++++++---------------------- 2 files changed, 51 insertions(+), 79 deletions(-) diff --git a/composer.json b/composer.json index 35169a6..ace042b 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ ], "require": { "php": "^8.2", - "psr/container": "^2.0" + "psr/container": "^2.0", + "doctrine/collections": "^2.3" }, "require-dev": { "pestphp/pest": "^3.0", diff --git a/src/Container/Container.php b/src/Container/Container.php index a6a3781..07e34ff 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -5,11 +5,13 @@ namespace Borsch\Container; +use Borsch\Container\Exception\NotFoundException; use Psr\Container\{ ContainerExceptionInterface, ContainerInterface, NotFoundExceptionInterface }; +use Doctrine\Common\Collections\ArrayCollection; use ReflectionException; /** @@ -19,19 +21,22 @@ class Container implements ContainerInterface { - /** @var Definition[] $definitions */ - protected array $definitions = []; + /** @var ArrayCollection $definitions */ + protected ArrayCollection $definitions; protected array $cache = []; - /** @var ContainerInterface[] $delegates */ - protected array $delegates = []; + /** @var ArrayCollection $delegates */ + protected ArrayCollection $delegates; /** * Container constructor. */ public function __construct() { + $this->definitions = new ArrayCollection(); + $this->delegates = new ArrayCollection(); + $this ->set(ContainerInterface::class, $this) ->cache(true); @@ -52,66 +57,33 @@ public function get(string $id): mixed return $this->cache[$id]; } - /*if (!$this->has($id)) { - - }*/ - - if (isset($this->definitions[$id])) { - $definition = $this->definitions[$id]; + $definition = null; + if ($this->definitions->containsKey($id)) { + $definition = $this->definitions->get($id); } elseif ($this->hasTag($id)) { - $definition = array_filter( - $this->definitions, - fn(Definition $definition) => $definition->hasTag($id) - ); - } elseif (array_reduce($this->delegates, fn($has, $container) => $has ?: $container->has($id), false)) { - foreach ($this->delegates as $delegate) { - if ($delegate->has($id)) { - return $delegate->get($id); - } - } + $definition = $this->definitions->filter(fn(Definition $definition) => $definition->hasTag($id)); + } else if ($this->delegatedHave($id)) { + return $this->delegates + ->findFirst(fn($k, ContainerInterface $container) => $container->has($id)) + ->get($id); } else { $definition = $this->set($id); } - /*if (!isset($this->definitions[$id]) && $this->hasTag($id)) { - $definitions = array_filter( - $this->definitions, - fn(Definition $definition) => $definition->hasTag($id) - ); - - $items = []; - foreach ($definitions as $definition) { - $item = $this->cache[$definition->getId()] ?? $definition - ->setContainer($this) - ->get(); + if ($definition === null) { + // Can't be null for now, an option will come later to decide if we want to autowire unregistered classes + throw new NotFoundException(sprintf('No entry found for "%s".', $id)); + } + if ($definition instanceof ArrayCollection) { + return $definition->map(function (Definition $definition) { + $item = $definition->setContainer($this)->get(); if ($definition->isCached()) { $this->cache[$definition->getId()] = $item; } - $items[] = $item; - } - - return $items; - }*/ - - /*$definition = $this->definitions[$id] ?? $this->set($id);*/ - - if (is_array($definition)) { - $items = []; - foreach ($definition as $def) { - $item = $this->cache[$def->getId()] ?? $def - ->setContainer($this) - ->get(); - - if ($def->isCached()) { - $this->cache[$def->getId()] = $item; - } - - $items[] = $item; - } - - return $items; + return $item; + })->toArray(); } $item = $definition @@ -126,18 +98,16 @@ public function get(string $id): mixed } /** - * Returns true if the container can return an entry for the given identifier. - * Returns false otherwise. - * - * `has($id)` returning true does not mean that `get($id)` will not throw an exception. - * It does however mean that `get($id)` will not throw a `NotFoundExceptionInterface`. + * @inheritdoc * - * @param string $id - * @return bool + * Implementation details: + * 1. Checks if the ID exists in the container definitions + * 2. Checks if the ID matches a registered tag + * 3. Checks if any delegated containers have the ID */ public function has(string $id): bool { - if (isset($this->definitions[$id])) { + if ($this->definitions->containsKey($id)) { return true; } @@ -145,22 +115,23 @@ public function has(string $id): bool return true; } - return array_reduce( - $this->delegates, - fn(bool $has, ContainerInterface $container) => $has ?: $container->has($id), - false - ); + return $this->delegatedHave($id); } + /** + * Check if any of the definitions have the requested tag. + */ public function hasTag(string $tag): bool { - foreach ($this->definitions as $definition) { - if ($definition->hasTag($tag)) { - return true; - } - } + return $this->definitions->exists(fn($k, Definition $definition) => $definition->hasTag($tag)); + } - return false; + /** + * Check if any of the delegated containers have the requested ID. + */ + protected function delegatedHave(string $id): bool + { + return $this->delegates->exists(fn($k, ContainerInterface $container) => $container->has($id)); } /** @@ -170,9 +141,9 @@ public function hasTag(string $tag): bool */ public function set(string $id, mixed $definition = null): Definition { - $this->definitions[$id] = $definition instanceof Definition ? - $definition : - new Definition($id, $definition); + $this->definitions[$id] = $definition instanceof Definition + ? $definition + : new Definition($id, $definition); return $this->definitions[$id]; } @@ -181,12 +152,12 @@ public function set(string $id, mixed $definition = null): Definition * Entrust another PSR-11 container in case of missing a requested entry ID. * * @param ContainerInterface $container - * @return Container + * @return self */ public function delegate(ContainerInterface $container): Container { if (spl_object_id($container) !== spl_object_id($this)) { - $this->delegates[] = $container; + $this->delegates->add($container); } return $this; From 37ae97ca4f0445ec28f4472ae102d134efd391fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Debussch=C3=A8re?= Date: Fri, 2 May 2025 20:06:26 +0200 Subject: [PATCH 03/14] upd: reduce cognitive complexity of get() method --- src/Container/Container.php | 126 +++++++++++++++++++++++++++--------- 1 file changed, 97 insertions(+), 29 deletions(-) diff --git a/src/Container/Container.php b/src/Container/Container.php index 07e34ff..c6139f4 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -29,6 +29,8 @@ class Container implements ContainerInterface /** @var ArrayCollection $delegates */ protected ArrayCollection $delegates; + protected bool $autowire_unregistered_class = true; + /** * Container constructor. */ @@ -43,12 +45,28 @@ public function __construct() } /** - * Finds an entry of the container by its identifier and returns it. + * If set to true, the container will try to autowire unregistered classes. + * This is useful for classes that are not registered in the container but are still needed. + * + * This will add an entry in the container with key as the class FQDN. + * + * Example: + * + * $container = new Container(); + * $container->get(MyClass::class); * * @param string $id - * @return mixed - * @throws NotFoundExceptionInterface - * @throws ContainerExceptionInterface + * @return Definition + */ + public function autowireUnregisteredClass(bool $autowire): self + { + $this->autowire_unregistered_class = $autowire; + + return $this; + } + + /** + * @inheritDoc * @throws ReflectionException */ public function get(string $id): mixed @@ -57,38 +75,75 @@ public function get(string $id): mixed return $this->cache[$id]; } - $definition = null; - if ($this->definitions->containsKey($id)) { - $definition = $this->definitions->get($id); - } elseif ($this->hasTag($id)) { - $definition = $this->definitions->filter(fn(Definition $definition) => $definition->hasTag($id)); - } else if ($this->delegatedHave($id)) { - return $this->delegates - ->findFirst(fn($k, ContainerInterface $container) => $container->has($id)) - ->get($id); - } else { - $definition = $this->set($id); - } + $definition = $this->resolveDefinition($id); if ($definition === null) { - // Can't be null for now, an option will come later to decide if we want to autowire unregistered classes - throw new NotFoundException(sprintf('No entry found for "%s".', $id)); + if ($this->delegatedHave($id)) { + return $this->getDelegatedItem($id); + } + + if (!class_exists($id) || !$this->autowire_unregistered_class) { + // Can't be null for now, an option will come later to decide if we want to autowire unregistered classes + throw new NotFoundException(sprintf('No entry found for "%s".', $id)); + } + + $definition = $this->set($id); } if ($definition instanceof ArrayCollection) { - return $definition->map(function (Definition $definition) { - $item = $definition->setContainer($this)->get(); - if ($definition->isCached()) { - $this->cache[$definition->getId()] = $item; - } - - return $item; - })->toArray(); + return $this->resolveDefinitionCollection($definition); } - $item = $definition - ->setContainer($this) - ->get(); + return $this->resolveDefinitionItem($definition, $id); + } + + /** + * Resolve a definition based on lookup priority. + */ + protected function resolveDefinition(string $id): Definition|ArrayCollection|null + { + if ($this->definitions->containsKey($id)) { + return $this->definitions->get($id); + } + + if ($this->hasTag($id)) { + return $this->definitions->filter(fn(Definition $definition) => $definition->hasTag($id)); + } + + return null; + } + + /** + * Resolve a collection of definitions. + * + * @param ArrayCollection $definitions + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + * @throws ReflectionException + */ + protected function resolveDefinitionCollection(ArrayCollection $definitions): array + { + return $definitions->map(function (Definition $definition) { + $item = $definition->setContainer($this)->get(); + + if ($definition->isCached()) { + $this->cache[$definition->getId()] = $item; + } + + return $item; + })->toArray(); + } + + /** + * Resolve a single definition item. + * + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + * @throws ReflectionException + */ + protected function resolveDefinitionItem(Definition $definition, string $id): mixed + { + $item = $definition->setContainer($this)->get(); if ($definition->isCached()) { $this->cache[$id] = $item; @@ -97,6 +152,19 @@ public function get(string $id): mixed return $item; } + /** + * Get an item from a delegated container. + * + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + protected function getDelegatedItem(string $id): mixed + { + return $this->delegates + ->findFirst(fn($k, ContainerInterface $container) => $container->has($id)) + ->get($id); + } + /** * @inheritdoc * From 78f5c4514ad65d4b37b52976d4e82d29072d71db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Debussch=C3=A8re?= Date: Fri, 2 May 2025 20:09:35 +0200 Subject: [PATCH 04/14] upd: autowiring naming --- src/Container/Container.php | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/Container/Container.php b/src/Container/Container.php index c6139f4..ab3a37b 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -29,11 +29,8 @@ class Container implements ContainerInterface /** @var ArrayCollection $delegates */ protected ArrayCollection $delegates; - protected bool $autowire_unregistered_class = true; + protected bool $autowire = true; - /** - * Container constructor. - */ public function __construct() { $this->definitions = new ArrayCollection(); @@ -55,16 +52,21 @@ public function __construct() * $container = new Container(); * $container->get(MyClass::class); * - * @param string $id - * @return Definition + * @param bool $autowire + * @return self */ - public function autowireUnregisteredClass(bool $autowire): self + public function setAutowiring(bool $autowire): self { - $this->autowire_unregistered_class = $autowire; + $this->autowire = $autowire; return $this; } + public function isAutowiring(): bool + { + return $this->autowire; + } + /** * @inheritDoc * @throws ReflectionException @@ -82,7 +84,7 @@ public function get(string $id): mixed return $this->getDelegatedItem($id); } - if (!class_exists($id) || !$this->autowire_unregistered_class) { + if (!class_exists($id) || !$this->autowire) { // Can't be null for now, an option will come later to decide if we want to autowire unregistered classes throw new NotFoundException(sprintf('No entry found for "%s".', $id)); } From 4c1b87fa165f1370579ecf04f21ef4b3410df7cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Debussch=C3=A8re?= Date: Fri, 2 May 2025 20:28:40 +0200 Subject: [PATCH 05/14] upd: named parameters + references Now possible to pass an array of parameters, and parameters can be named instead of relying on index-based parameters. A new Reference class is available to reference an other Container entry inside addParameter(s) methods. --- .phpstorm.meta.php | 9 +++++++++ src/Container/Definition.php | 30 +++++++++++++++++++++++------ src/Container/Reference.php | 16 +++++++++++++++ tests/Assets/ExtendedDefinition.php | 18 ----------------- tests/Unit/DefinitionTest.php | 20 +++++++++---------- 5 files changed, 58 insertions(+), 35 deletions(-) create mode 100644 .phpstorm.meta.php create mode 100644 src/Container/Reference.php delete mode 100644 tests/Assets/ExtendedDefinition.php diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php new file mode 100644 index 0000000..0b0e620 --- /dev/null +++ b/.phpstorm.meta.php @@ -0,0 +1,9 @@ + '@', + ])); + +} \ No newline at end of file diff --git a/src/Container/Definition.php b/src/Container/Definition.php index 13d141a..02d8554 100644 --- a/src/Container/Definition.php +++ b/src/Container/Definition.php @@ -52,13 +52,25 @@ public function getId(): string return $this->id; } - /** - * @param mixed $value - * @return Definition - */ - public function addParameter(mixed $value): self + public function getConcrete(): mixed + { + return $this->concrete; + } + + public function addParameter(mixed $value, string $key = null): self + { + if ($key !== null) { + $this->parameters[$key] = $value; + } else { + $this->parameters[] = $value; + } + + return $this; + } + + public function addParameters(array $values): self { - $this->parameters[] = $value; + $this->parameters = $values; return $this; } @@ -212,6 +224,12 @@ protected function getNewInstanceWithArgs(ReflectionMethod $constructor, Reflect $this->parameters = $this->getNewInstanceParameters($constructor); } + foreach ($this->parameters as $index => $parameter) { + if ($parameter instanceof Reference) { + $this->parameters[$index] = $this->container->get($parameter->reference()); + } + } + return $item->newInstanceArgs($this->parameters); } diff --git a/src/Container/Reference.php b/src/Container/Reference.php new file mode 100644 index 0000000..6656d05 --- /dev/null +++ b/src/Container/Reference.php @@ -0,0 +1,16 @@ +id; + } +} diff --git a/tests/Assets/ExtendedDefinition.php b/tests/Assets/ExtendedDefinition.php deleted file mode 100644 index d660bc9..0000000 --- a/tests/Assets/ExtendedDefinition.php +++ /dev/null @@ -1,18 +0,0 @@ -id; - } - - public function getConcrete(): mixed - { - return $this->concrete; - } -} diff --git a/tests/Unit/DefinitionTest.php b/tests/Unit/DefinitionTest.php index df08136..7c0cc73 100644 --- a/tests/Unit/DefinitionTest.php +++ b/tests/Unit/DefinitionTest.php @@ -6,8 +6,6 @@ use BorschTest\Assets\Bar; use BorschTest\Assets\Baz; use BorschTest\Assets\Biz; -use BorschTest\Assets\ExtendedDefinition; -use BorschTest\Assets\Foo; use BorschTest\Assets\Ink; use Psr\Container\ContainerInterface; @@ -39,13 +37,13 @@ public function getContainer(): ContainerInterface }); test('constructor deals with id and concrete correctly', function () { - $definition = new ExtendedDefinition(Bar::class); - expect($definition->getId())->toBe(Bar::class); - expect($definition->getConcrete())->toBe(Bar::class); + $definition = new Definition(Bar::class); + expect($definition->getId())->toBe(Bar::class) + ->and($definition->getConcrete())->toBe(Bar::class); - $definition = new ExtendedDefinition(Bar::class, 'test'); - expect($definition->getId())->toBe(Bar::class); - expect($definition->getConcrete())->toBe('test'); + $definition = new Definition(Bar::class, 'test'); + expect($definition->getId())->toBe(Bar::class) + ->and($definition->getConcrete())->toBe('test'); }); it('adds parameter', function () { @@ -73,7 +71,7 @@ public function getContainer(): ContainerInterface expect($definition->get())->toBeInstanceOf(Baz::class); }); -test('definition with a callable concrete throw ContainerException when missing parameters', function() { +test('definition with a callable concrete throw ContainerException when missing parameters', function () { $definition = new Definition('id', fn(int $undefined) => new Baz([$undefined])); $definition->setContainer($this->container); @@ -81,14 +79,14 @@ public function getContainer(): ContainerInterface })->throws( ContainerException::class, sprintf( - 'Unable to get parameter for callable/closure defined in entry with ID "%s". '. + 'Unable to get parameter for callable/closure defined in entry with ID "%s". ' . 'Expected a parameter of type "%s" but could not be found inside the container nor its delegates.', 'id', 'int' ) ); -test('when definition throw ContainerException, a NotFoundException is thrown previously', function() { +test('when definition throw ContainerException, a NotFoundException is thrown previously', function () { try { $definition = new Definition('id', fn(int $undefined) => new Baz([$undefined])); $definition->setContainer($this->container); From 72a346fb40a44e0d439c7b17b0b9ccdfd7c5dc89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Debussch=C3=A8re?= Date: Fri, 2 May 2025 23:28:22 +0200 Subject: [PATCH 06/14] tests: missing tests New tests for Reference class and tests of new methods from Container and Definition. --- .github/workflows/php.yml | 2 +- src/Container/Container.php | 16 ++++++++ src/Container/Definition.php | 5 +++ tests/Unit/ContainerTest.php | 32 ++++++++++++++++ tests/Unit/DefinitionTest.php | 70 ++++++++++++++++++++++++++++++++++- tests/Unit/ReferenceTest.php | 10 +++++ 6 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/ReferenceTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 98e9a5e..8fa3e05 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -43,4 +43,4 @@ jobs: run: ./vendor/bin/pest --parallel - name: Run mutation tests - run: XDEBUG_MODE=coverage ./vendor/bin/pest --mutate --min=60 --parallel \ No newline at end of file + run: XDEBUG_MODE=coverage ./vendor/bin/pest --mutate --min=70 --parallel \ No newline at end of file diff --git a/src/Container/Container.php b/src/Container/Container.php index ab3a37b..ff52e73 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -92,6 +92,10 @@ public function get(string $id): mixed $definition = $this->set($id); } + if ($definition->isReference()) { + return $this->get($definition->getConcrete()->reference()); + } + if ($definition instanceof ArrayCollection) { return $this->resolveDefinitionCollection($definition); } @@ -232,4 +236,16 @@ public function delegate(ContainerInterface $container): Container return $this; } + + /** + * @throws NotFoundException + */ + public function alias(string $alias, string $from): void + { + if (!$this->has($from)) { + throw new NotFoundException(sprintf('No entry found for "%s".', $from)); + } + + $this->set($alias, new Reference($from)); + } } diff --git a/src/Container/Definition.php b/src/Container/Definition.php index 02d8554..f37ad3d 100644 --- a/src/Container/Definition.php +++ b/src/Container/Definition.php @@ -139,6 +139,11 @@ public function isCached(): bool return $this->cached; } + public function isReference(): bool + { + return $this->concrete instanceof Reference; + } + /** * @return mixed * @throws ContainerExceptionInterface diff --git a/tests/Unit/ContainerTest.php b/tests/Unit/ContainerTest.php index 67845ab..9400a6e 100644 --- a/tests/Unit/ContainerTest.php +++ b/tests/Unit/ContainerTest.php @@ -45,6 +45,26 @@ public function getCachedDefinition(string $id) { expect($this->container->has('nonExistingId'))->toBeFalse(); }); +test('has() with tags', function () { + $this->container->set(DateTime::class) + ->addParameter('now', 'datetime') + ->addTag('#date'); + $this->container->set('today', fn() => new DateTime()) + ->addTag('#date'); + expect($this->container->has('#date'))->toBeTrue() + ->and($this->container->has('#bar'))->toBeFalse(); +}); + +test('array resolution', function () { + $this->container->set('array', ['foo' => 'bar']); + expect($this->container->get('array'))->toBe(['foo' => 'bar']); +}); + +test('scalar resolution', function () { + $this->container->set('scalar', 'foo'); + expect($this->container->get('scalar'))->toBe('foo'); +}); + test('closure resolution', function () { $this->container->set('closure', fn() => 'closure'); expect($this->container->get('closure'))->toBe('closure'); @@ -204,3 +224,15 @@ public function getCachedDefinition(string $id) { expect($this->container->get(Foo::class))->toBe($foo); }); + +test('alias() returns the aliased entry', function () { + $id = substr(md5(mt_rand()), 0, 7); + $alias = substr(md5(mt_rand()), 0, 7); + $this->container->set($id, fn() => 42); + $this->container->alias($alias, $id); + expect($this->container->get($alias))->toBe(42); +}); + +test('alias() throws a NotFoundException when entry does not exist', function () { + $this->container->alias('Monolog\\Logger', 'Psr\\log\\LoggerInterface'); +})->throws(NotFoundException::class, 'No entry found for "Psr\\log\\LoggerInterface".'); diff --git a/tests/Unit/DefinitionTest.php b/tests/Unit/DefinitionTest.php index 7c0cc73..5afd429 100644 --- a/tests/Unit/DefinitionTest.php +++ b/tests/Unit/DefinitionTest.php @@ -3,6 +3,7 @@ use Borsch\Container\Definition; use Borsch\Container\Exception\ContainerException; use Borsch\Container\Exception\NotFoundException; +use Borsch\Container\Reference; use BorschTest\Assets\Bar; use BorschTest\Assets\Baz; use BorschTest\Assets\Biz; @@ -124,9 +125,76 @@ public function getContainer(): ContainerInterface ]); }); - test('invoke as callable throw exception', function () { $definition = new Definition('test', [Ink::class, 'getBar']); $definition->setContainer($this->container); $bar = $definition->get(); })->throws(NotFoundException::class); + +test('getId() return ID', function () { + $definition = new Definition('test', fn() => 'it is a test'); + expect($definition->getId())->toBe('test'); +}); + +test('getConcrete() return concrete', function () { + $definition = new Definition('test', fn() => 'it is a test'); + $concrete = $definition->getConcrete(); + expect($concrete)->toBeCallable() + ->and($concrete())->toBe('it is a test'); +}); + +test('addParameter() with key', function () { + $definition = new class('id', 'concrete') extends Definition { + public function getParameters(): array { return $this->parameters; } + }; + + $definition->addParameter('test', 'key'); + expect($definition->getParameters())->toBe(['key' => 'test']); +}); + +test('addParameters', function () { + $definition = new class('id', 'concrete') extends Definition { + public function getParameters(): array { return $this->parameters; } + }; + + $definition->addParameters(['key' => 'test']); + expect($definition->getParameters())->toBe(['key' => 'test']); +}); + +test('addTag() add tag', function () { + $definition = new Definition('id', 'concrete'); + $definition->addTag('tag1'); + expect($definition->hasTag('tag1'))->toBeTrue() + ->and($definition->hasTag('tag2'))->toBeFalse(); +}); + +test('addTags() add tags', function () { + $definition = new Definition('id', 'concrete'); + $definition->addTags(['tag1', 'tag2']); + expect($definition->hasTag('tag1'))->toBeTrue() + ->and($definition->hasTag('tag2'))->toBeTrue() + ->and($definition->hasTag('tag3'))->toBeFalse(); +}); + +test('hasTag() return true if tag exists', function () { + $definition = new Definition('id', 'concrete'); + $definition->addTag('tag1'); + expect($definition->hasTag('tag1'))->toBeTrue() + ->and($definition->hasTag('tag2'))->toBeFalse(); +}); + +test('hasTag() return false if tag does not exist', function () { + $definition = new Definition('id', 'concrete'); + $definition->addTag('tag1'); + expect($definition->hasTag('tag2'))->toBeFalse(); +}); + +test('isReference() returns true on Reference concrete', function () { + $definition = new Definition('id', new Reference('test')); + expect($definition->isReference())->toBeTrue(); +}); + +test('isReference() returns false on non Reference concrete', function () { + $definition = new Definition('id', 'test'); + expect($definition->isReference())->toBeFalse(); +}); diff --git a/tests/Unit/ReferenceTest.php b/tests/Unit/ReferenceTest.php new file mode 100644 index 0000000..87fe9d4 --- /dev/null +++ b/tests/Unit/ReferenceTest.php @@ -0,0 +1,10 @@ +reference())->toBe('test'); +}); From 42207238a36a465dcb1ca997ce58ca81643882ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Debussch=C3=A8re?= Date: Fri, 2 May 2025 23:59:52 +0200 Subject: [PATCH 07/14] tests: missing tests --- src/Container/Container.php | 7 +++-- src/Container/Definition.php | 12 ++------- .../Exception/ContainerException.php | 26 ++++++++++++++++++- tests/Unit/ContainerTest.php | 16 +++++++++++- tests/Unit/DefinitionTest.php | 2 +- 5 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/Container/Container.php b/src/Container/Container.php index ff52e73..c9018a2 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -84,9 +84,8 @@ public function get(string $id): mixed return $this->getDelegatedItem($id); } - if (!class_exists($id) || !$this->autowire) { - // Can't be null for now, an option will come later to decide if we want to autowire unregistered classes - throw new NotFoundException(sprintf('No entry found for "%s".', $id)); + if (!$this->autowire) { + throw NotFoundException::unableToFindEntry($id); } $definition = $this->set($id); @@ -243,7 +242,7 @@ public function delegate(ContainerInterface $container): Container public function alias(string $alias, string $from): void { if (!$this->has($from)) { - throw new NotFoundException(sprintf('No entry found for "%s".', $from)); + throw NotFoundException::unableToFindEntry($from); } $this->set($alias, new Reference($from)); diff --git a/src/Container/Definition.php b/src/Container/Definition.php index f37ad3d..86f7f37 100644 --- a/src/Container/Definition.php +++ b/src/Container/Definition.php @@ -179,11 +179,7 @@ protected function invokeAsClass(): object try { $item = new ReflectionClass($this->concrete); } catch (ReflectionException $exception) { - throw new NotFoundException( - NotFoundException::unableToFindEntry($this->id), - $exception->getCode(), - $exception - ); + throw ContainerException::unableToGetClassReflection($this->concrete, $exception); } $constructor = $item->getConstructor(); @@ -283,11 +279,7 @@ protected function invokeAsCallable(): mixed try { $function = new ReflectionFunction($this->concrete); } catch (ReflectionException|TypeError $exception) { - throw new NotFoundException( - NotFoundException::unableToFindEntry($this->id), - $exception->getCode(), - $exception - ); + throw ContainerException::unableToGetFunctionReflection($this->concrete, $exception); } if (!$function->getNumberOfParameters()) { diff --git a/src/Container/Exception/ContainerException.php b/src/Container/Exception/ContainerException.php index 52982af..13c6bfd 100644 --- a/src/Container/Exception/ContainerException.php +++ b/src/Container/Exception/ContainerException.php @@ -7,6 +7,7 @@ use Exception; use Psr\Container\ContainerExceptionInterface; +use ReflectionException; use ReflectionNamedType; use ReflectionUnionType; @@ -29,7 +30,30 @@ public static function unableToGetCallableParameter( $id, $type->getName() ), - /** @infection-ignore-all */ + $exception->getCode() ?? 0, + $exception + ); + } + + public static function unableToGetClassReflection(string $classname, ReflectionException $exception = null) :static + { + return new static( + sprintf( + 'Unable to create a reflection for class "%s", it does not exist.', + $classname + ), + $exception->getCode() ?? 0, + $exception + ); + } + + public static function unableToGetFunctionReflection(string $function, ReflectionException $exception = null) :static + { + return new static( + sprintf( + 'Unable to create a reflection for function "%s", it does not exist.', + $function + ), $exception->getCode() ?? 0, $exception ); diff --git a/tests/Unit/ContainerTest.php b/tests/Unit/ContainerTest.php index 9400a6e..01f9741 100644 --- a/tests/Unit/ContainerTest.php +++ b/tests/Unit/ContainerTest.php @@ -235,4 +235,18 @@ public function getCachedDefinition(string $id) { test('alias() throws a NotFoundException when entry does not exist', function () { $this->container->alias('Monolog\\Logger', 'Psr\\log\\LoggerInterface'); -})->throws(NotFoundException::class, 'No entry found for "Psr\\log\\LoggerInterface".'); +})->throws(NotFoundException::class, 'Unable to find entry with ID "Psr\\log\\LoggerInterface".'); + +test('isAutowiring() should return true by default', function () { + expect($this->container->isAutowiring())->toBeTrue(); +}); + +test('setAutowiring() to false', function () { + $this->container->setAutowiring(false); + expect($this->container->isAutowiring())->toBeFalse(); +}); + +it('should throw an exception when autowiring is off and class is missing a parameter', function () { + $this->container->setAutowiring(false); + $this->container->get(Foo::class); +})->throws(NotFoundException::class); diff --git a/tests/Unit/DefinitionTest.php b/tests/Unit/DefinitionTest.php index 5afd429..93cd5ef 100644 --- a/tests/Unit/DefinitionTest.php +++ b/tests/Unit/DefinitionTest.php @@ -129,7 +129,7 @@ public function getContainer(): ContainerInterface $definition = new Definition('test', [Ink::class, 'getBar']); $definition->setContainer($this->container); $bar = $definition->get(); -})->throws(NotFoundException::class); +})->throws(TypeError::class); test('getId() return ID', function () { $definition = new Definition('test', fn() => 'it is a test'); From 772c4c5f2edebdcd8bf10b005c28bbfc6ca314c6 Mon Sep 17 00:00:00 2001 From: debuss-a Date: Sat, 3 May 2025 20:25:09 +0200 Subject: [PATCH 08/14] tests: missing tests + phpstan - added missing tests for exception classes - added some more tests inside existing test files - added phpstan analyze in GitHub Action --- .github/workflows/php.yml | 5 +- .gitignore | 3 +- src/Container/Container.php | 23 +- src/Container/Definition.php | 36 +-- .../Exception/ContainerException.php | 30 +-- src/Container/Exception/NotFoundException.php | 4 +- src/Container/Reference.php | 12 +- tests/Unit/ContainerExceptionTest.php | 95 +++++++ tests/Unit/ContainerTest.php | 250 +++++++++++------- tests/Unit/NotFoundExceptionTest.php | 15 ++ tests/Unit/ReferenceTest.php | 2 +- 11 files changed, 337 insertions(+), 138 deletions(-) create mode 100644 tests/Unit/ContainerExceptionTest.php create mode 100644 tests/Unit/NotFoundExceptionTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 8fa3e05..ac7b02d 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -43,4 +43,7 @@ jobs: run: ./vendor/bin/pest --parallel - name: Run mutation tests - run: XDEBUG_MODE=coverage ./vendor/bin/pest --mutate --min=70 --parallel \ No newline at end of file + run: XDEBUG_MODE=coverage ./vendor/bin/pest --mutate --min=70 --parallel + + - name: PHPStan + run: ./vendor/bin/phpstan analyze src --level=6 diff --git a/.gitignore b/.gitignore index 7860755..608911a 100644 --- a/.gitignore +++ b/.gitignore @@ -121,6 +121,7 @@ fabric.properties # Generated files .phpunit.result.cache +.phpunit.cache # PHPUnit /app/phpunit.xml @@ -129,4 +130,4 @@ fabric.properties # Build data /build/ -# End of https://www.toptal.com/developers/gitignore/api/phpstorm,composer,phpunit \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/phpstorm,composer,phpunit diff --git a/src/Container/Container.php b/src/Container/Container.php index c9018a2..aac9299 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -21,12 +21,13 @@ class Container implements ContainerInterface { - /** @var ArrayCollection $definitions */ + /** @var ArrayCollection $definitions */ protected ArrayCollection $definitions; + /** @var array $cache */ protected array $cache = []; - /** @var ArrayCollection $delegates */ + /** @var ArrayCollection $delegates */ protected ArrayCollection $delegates; protected bool $autowire = true; @@ -45,7 +46,7 @@ public function __construct() * If set to true, the container will try to autowire unregistered classes. * This is useful for classes that are not registered in the container but are still needed. * - * This will add an entry in the container with key as the class FQDN. + * This will add an entry in the container with a key as the class FQDN. * * Example: * @@ -91,19 +92,22 @@ public function get(string $id): mixed $definition = $this->set($id); } - if ($definition->isReference()) { - return $this->get($definition->getConcrete()->reference()); - } - if ($definition instanceof ArrayCollection) { return $this->resolveDefinitionCollection($definition); } + if ($definition->isReference()) { + return $this->get($definition->getConcrete()->reference()); + } + return $this->resolveDefinitionItem($definition, $id); } /** * Resolve a definition based on lookup priority. + * + * @param string $id + * @return Definition|ArrayCollection|null */ protected function resolveDefinition(string $id): Definition|ArrayCollection|null { @@ -121,7 +125,8 @@ protected function resolveDefinition(string $id): Definition|ArrayCollection|nul /** * Resolve a collection of definitions. * - * @param ArrayCollection $definitions + * @param ArrayCollection $definitions + * @return array * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface * @throws ReflectionException @@ -174,7 +179,7 @@ protected function getDelegatedItem(string $id): mixed * @inheritdoc * * Implementation details: - * 1. Checks if the ID exists in the container definitions + * 1. Checks if the ID exists in container definitions * 2. Checks if the ID matches a registered tag * 3. Checks if any delegated containers have the ID */ diff --git a/src/Container/Definition.php b/src/Container/Definition.php index 86f7f37..d9843e1 100644 --- a/src/Container/Definition.php +++ b/src/Container/Definition.php @@ -10,8 +10,12 @@ use ReflectionClass; use ReflectionException; use ReflectionFunction; +use ReflectionIntersectionType; use ReflectionMethod; +use ReflectionNamedType; use ReflectionParameter; +use ReflectionType; +use ReflectionUnionType; use TypeError; /** @@ -68,6 +72,9 @@ public function addParameter(mixed $value, string $key = null): self return $this; } + /** + * @param array $values + */ public function addParameters(array $values): self { $this->parameters = $values; @@ -77,7 +84,7 @@ public function addParameters(array $values): self /** * @param string $name - * @param array $arguments + * @param array $arguments * @return $this */ public function addMethod(string $name, array $arguments = []): self @@ -212,9 +219,9 @@ protected function callObjectMethods(object $object): void } /** - * @param ReflectionMethod $constructor - * @param ReflectionClass $item - * @return object + * @template T of object + * @phpstan-param ReflectionClass $item + * @return T * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface * @throws ReflectionException @@ -227,7 +234,7 @@ protected function getNewInstanceWithArgs(ReflectionMethod $constructor, Reflect foreach ($this->parameters as $index => $parameter) { if ($parameter instanceof Reference) { - $this->parameters[$index] = $this->container->get($parameter->reference()); + $this->parameters[$index] = $this->container->get($parameter->references()); } } @@ -236,7 +243,7 @@ protected function getNewInstanceWithArgs(ReflectionMethod $constructor, Reflect /** * @param ReflectionMethod $constructor - * @return array + * @return array * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface * @throws ReflectionException @@ -246,9 +253,9 @@ protected function getNewInstanceParameters(ReflectionMethod $constructor): arra return array_reduce($constructor->getParameters(), function(array $parameters, ReflectionParameter $reflection_parameter) { $parameter = null; - $type = $reflection_parameter?->getType()?->getName(); - if ($this->containerHasOrCanRetrieve($type)) { - $parameter = $this->container->get($type); + $type = $reflection_parameter->getType(); + if (method_exists($type, 'getName') && $this->containerHasOrCanRetrieve($type->getName())) { + $parameter = $this->container->get($type->getName()); } elseif ($reflection_parameter->isOptional() && $reflection_parameter->isDefaultValueAvailable()) { $parameter = $reflection_parameter->getDefaultValue(); } @@ -278,7 +285,7 @@ protected function invokeAsCallable(): mixed { try { $function = new ReflectionFunction($this->concrete); - } catch (ReflectionException|TypeError $exception) { + } catch (ReflectionException $exception) { throw ContainerException::unableToGetFunctionReflection($this->concrete, $exception); } @@ -288,16 +295,13 @@ protected function invokeAsCallable(): mixed if (!count($this->parameters)) { foreach ($function->getParameters() as $param) { + /** @var ReflectionNamedType|ReflectionUnionType|ReflectionIntersectionType|ReflectionType|null $type */ $type = $param->getType(); - if ($type) { + if ($type && method_exists($type, 'getName')) { try { $this->parameters[] = $this->container->get($type->getName()); } catch (NotFoundException $exception) { - throw ContainerException::unableToGetCallableParameter( - $type, - $this->id, - $exception - ); + throw ContainerException::unableToGetCallableParameter($type, $this->id, $exception); } } } diff --git a/src/Container/Exception/ContainerException.php b/src/Container/Exception/ContainerException.php index 13c6bfd..8df2dc4 100644 --- a/src/Container/Exception/ContainerException.php +++ b/src/Container/Exception/ContainerException.php @@ -8,8 +8,7 @@ use Exception; use Psr\Container\ContainerExceptionInterface; use ReflectionException; -use ReflectionNamedType; -use ReflectionUnionType; +use ReflectionType; /** * Class ContainerException @@ -17,44 +16,39 @@ class ContainerException extends Exception implements ContainerExceptionInterface { - public static function unableToGetCallableParameter( - ReflectionNamedType|ReflectionUnionType $type, - string $id, - ?Exception $exception = null - ) :static + public static function unableToGetCallableParameter(ReflectionType $type, string $id, ?Exception $exception = null) :self { - return new static( + return new self( sprintf( - 'Unable to get parameter for callable/closure defined in entry with ID "%s". '. - 'Expected a parameter of type "%s" but could not be found inside the container nor its delegates.', + 'Unable to get parameter for callable/closure defined in entry with ID "%s". Expected a parameter of type "%s" but could not be found inside the container nor its delegates.', $id, - $type->getName() + method_exists($type, 'getName') ? $type->getName() : 'Unknown type' ), - $exception->getCode() ?? 0, + $exception?->getCode() ?? 0, $exception ); } - public static function unableToGetClassReflection(string $classname, ReflectionException $exception = null) :static + public static function unableToGetClassReflection(string $classname, ?ReflectionException $exception = null) :self { - return new static( + return new self( sprintf( 'Unable to create a reflection for class "%s", it does not exist.', $classname ), - $exception->getCode() ?? 0, + $exception?->getCode() ?? 0, $exception ); } - public static function unableToGetFunctionReflection(string $function, ReflectionException $exception = null) :static + public static function unableToGetFunctionReflection(string $function, ?ReflectionException $exception = null) :self { - return new static( + return new self( sprintf( 'Unable to create a reflection for function "%s", it does not exist.', $function ), - $exception->getCode() ?? 0, + $exception?->getCode() ?? 0, $exception ); } diff --git a/src/Container/Exception/NotFoundException.php b/src/Container/Exception/NotFoundException.php index dcf15e7..08d624b 100644 --- a/src/Container/Exception/NotFoundException.php +++ b/src/Container/Exception/NotFoundException.php @@ -14,8 +14,8 @@ class NotFoundException extends Exception implements NotFoundExceptionInterface { - public static function unableToFindEntry(string $id): static + public static function unableToFindEntry(string $id): self { - return new static(sprintf('Unable to find entry with ID "%s".', $id)); + return new self(sprintf('Unable to find entry with ID "%s".', $id)); } } diff --git a/src/Container/Reference.php b/src/Container/Reference.php index 6656d05..b0d0993 100644 --- a/src/Container/Reference.php +++ b/src/Container/Reference.php @@ -1,7 +1,14 @@ id; } diff --git a/tests/Unit/ContainerExceptionTest.php b/tests/Unit/ContainerExceptionTest.php new file mode 100644 index 0000000..d62bd66 --- /dev/null +++ b/tests/Unit/ContainerExceptionTest.php @@ -0,0 +1,95 @@ +toBeInstanceOf(ContainerException::class) + ->and($exception->getMessage())->toBe(sprintf( + 'Unable to get parameter for callable/closure defined in entry with ID "%s". Expected a parameter of type "foobar" but could not be found inside the container nor its delegates.', + $id + )) + ->and($exception->getCode())->toBe(0) + ->and($exception->getPrevious())->toBeNull(); +}); + +test('unableToGetCallableParameter() returns a ContainerException with ReflectionException', function () { + $id = substr(md5(mt_rand()), 0, 7); + $message = md5(mt_rand()); + $code = mt_rand(100, 999); + $reflection = new class extends ReflectionNamedType { + public function getName(): string { return 'foobar'; } + }; + $base = new ReflectionException($message, $code); + $exception = ContainerException::unableToGetCallableParameter($reflection,$id, $base); + expect($exception)->toBeInstanceOf(ContainerException::class) + ->and($exception->getMessage())->toBe(sprintf( + 'Unable to get parameter for callable/closure defined in entry with ID "%s". Expected a parameter of type "foobar" but could not be found inside the container nor its delegates.', + $id + )) + ->and($exception->getCode())->toBe($code) + ->and($exception->getPrevious())->toBeInstanceOf(ReflectionException::class) + ->and($exception->getPrevious()->getMessage())->toBe($message); +}); + +test('unableToGetClassReflection() returns a ContainerException without Exception', function () { + $classname = md5(mt_rand()); + $exception = ContainerException::unableToGetClassReflection($classname); + expect($exception)->toBeInstanceOf(ContainerException::class) + ->and($exception->getMessage())->toBe(sprintf( + 'Unable to create a reflection for class "%s", it does not exist.', + $classname + )) + ->and($exception->getCode())->toBe(0) + ->and($exception->getPrevious())->toBeNull(); +}); + +test('unableToGetClassReflection() returns a ContainerException with Exception', function () { + $classname = md5(mt_rand()); + $message = md5(mt_rand()); + $code = mt_rand(100, 999); + $base = new ReflectionException($message, $code); + $exception = ContainerException::unableToGetClassReflection($classname,$base); + expect($exception)->toBeInstanceOf(ContainerException::class) + ->and($exception->getMessage())->toBe(sprintf( + 'Unable to create a reflection for class "%s", it does not exist.', + $classname + )) + ->and($exception->getCode())->toBe($code) + ->and($exception->getPrevious())->toBeInstanceOf(ReflectionException::class) + ->and($exception->getPrevious()->getMessage())->toBe($message); +}); + +test('unableToGetFunctionReflection() returns a ContainerException without Exception', function () { + $classname = md5(mt_rand()); + $exception = ContainerException::unableToGetFunctionReflection($classname); + expect($exception)->toBeInstanceOf(ContainerException::class) + ->and($exception->getMessage())->toBe(sprintf( + 'Unable to create a reflection for function "%s", it does not exist.', + $classname + )) + ->and($exception->getCode())->toBe(0) + ->and($exception->getPrevious())->toBeNull(); +}); + +test('unableToGetFunctionReflection() returns a ContainerException with Exception', function () { + $classname = md5(mt_rand()); + $message = md5(mt_rand()); + $code = mt_rand(100, 999); + $base = new ReflectionException($message, $code); + $exception = ContainerException::unableToGetFunctionReflection($classname,$base); + expect($exception)->toBeInstanceOf(ContainerException::class) + ->and($exception->getMessage())->toBe(sprintf( + 'Unable to create a reflection for function "%s", it does not exist.', + $classname + )) + ->and($exception->getCode())->toBe($code) + ->and($exception->getPrevious())->toBeInstanceOf(ReflectionException::class) + ->and($exception->getPrevious()->getMessage())->toBe($message); +}); diff --git a/tests/Unit/ContainerTest.php b/tests/Unit/ContainerTest.php index 01f9741..a256d3f 100644 --- a/tests/Unit/ContainerTest.php +++ b/tests/Unit/ContainerTest.php @@ -2,30 +2,58 @@ use Borsch\Container\{Container, Definition, Exception\ContainerException, Exception\NotFoundException}; use BorschTest\Assets\{Bar, Baz, Foo}; +use Doctrine\Common\Collections\ArrayCollection; use Psr\Container\ContainerInterface; covers(Container::class); -test('constructor cache the ContainerInterface', function() { - $container = new class() extends Borsch\Container\Container { - public function isCached(string $id) { - return isset($this->cache[$id]); - } - public function getCachedDefinition(string $id) { - return $this->cache[$id]; - } +beforeEach(function () { + $this->container = new class extends Container { + public function getDefinitions(): ArrayCollection { return $this->definitions; } + public function getDelegates(): ArrayCollection { return $this->delegates; } + public function isCached(string $id): bool { return isset($this->cache[$id]); } + public function getCachedDefinition(string $id): mixed { return $this->cache[$id]; } + public function clear(): void { $this->definitions->clear(); } }; +}); + +test('constructor instantiate definitions', function () { + expect($this->container->getDefinitions())->toBeInstanceOf(ArrayCollection::class) + ->and($this->container->getDefinitions()->toArray())->toHaveCount(1); +}); + +test('constructor instantiate delegates', function () { + expect($this->container->getDelegates())->toBeInstanceOf(ArrayCollection::class) + ->and($this->container->getDelegates()->toArray())->toHaveCount(0); +}); + +test('constructor cache the ContainerInterface', function() { + $this->container->get(ContainerInterface::class); // Call 1 time to place it in cache - $container->get(ContainerInterface::class); // Call 1 time to place it in cache + expect($this->container->isCached(ContainerInterface::class))->toBeTrue() + ->and($this->container->getCachedDefinition(ContainerInterface::class))->toBe($this->container); +}); + +test('isAutowiring() should return true by default', function () { + expect($this->container->isAutowiring())->toBeTrue(); +}); - expect($container->isCached(ContainerInterface::class))->toBeTrue() - ->and($container->getCachedDefinition(ContainerInterface::class))->toBe($container); +test('setAutowiring() to false', function () { + $this->container->setAutowiring(false); + expect($this->container->isAutowiring())->toBeFalse(); }); +it('should throw an exception when autowiring is off and class is missing a parameter', function () { + $this->container->setAutowiring(false); + $this->container->get(Foo::class); +})->throws(NotFoundException::class); + test('has ID in container', function () { $id = substr(md5(mt_rand()), 0, 7); - $this->container->set($id, fn() => 42); - expect($this->container->has($id))->toBeTrue(); + $value = mt_rand(1, 100); + $this->container->set($id, fn() => $value); + expect($this->container->has($id))->toBeTrue() + ->and($this->container->get($id))->toBe($value); }); test('does not have ID in container', function () { @@ -34,71 +62,134 @@ public function getCachedDefinition(string $id) { test('has ID in a delegated container', function () { $id = substr(md5(mt_rand()), 0, 7); + $value = mt_rand(1, 100); $container = new Container(); - $container->set($id, fn() => 42); + $container->set($id, fn() => $value); $this->container->delegate($container); - expect($this->container->has($id))->toBeTrue(); + expect($this->container->has($id))->toBeTrue() + ->and($this->container->get($id))->toBe($value); }); test('does not have ID in delegated container', function () { + $this->container->clear(); $this->container->delegate(new Container()); expect($this->container->has('nonExistingId'))->toBeFalse(); }); -test('has() with tags', function () { - $this->container->set(DateTime::class) - ->addParameter('now', 'datetime') - ->addTag('#date'); - $this->container->set('today', fn() => new DateTime()) - ->addTag('#date'); +test('has() with and without tags', function () { + $this->container->set(DateTime::class)->addParameter('now', 'datetime')->addTag('#date'); + $this->container->set('today', fn() => new DateTime())->addTag('#date'); expect($this->container->has('#date'))->toBeTrue() - ->and($this->container->has('#bar'))->toBeFalse(); + ->and($this->container->has('#bar'))->toBeFalse() + ->and($this->container->get('#date'))->toBeArray()->toHaveCount(2); +}); + +test('hasTag() with and without tags', function () { + $this->container->set(DateTime::class)->addParameter('now', 'datetime')->addTag('#date'); + $this->container->set('today', fn() => new DateTime())->addTag('#date'); + expect($this->container->hasTag('#date'))->toBeTrue() + ->and($this->container->hasTag('#bar'))->toBeFalse(); +}); + +test('set() with Definition::class instance', function () { + $id = substr(md5(mt_rand()), 0, 7); + $message = substr(md5(mt_rand()), 0, 7); + $definition = new Definition($id, $message); + $this->container->set($id, $definition); + expect($this->container->getDefinitions())->toBeInstanceOf(ArrayCollection::class) + ->and($this->container->getDefinitions()->toArray())->toHaveCount(2) + ->and($this->container->getDefinitions()->first())->toBeInstanceOf(Definition::class) + ->and($this->container->getDefinitions()->last())->toBeInstanceOf(Definition::class) + ->and($this->container->getDefinitions()->last()->getId())->toBe($id) + ->and($this->container->getDefinitions()->last()->getConcrete())->toBe($message) + ->and($this->container->get($id))->toBe($message); }); test('array resolution', function () { $this->container->set('array', ['foo' => 'bar']); - expect($this->container->get('array'))->toBe(['foo' => 'bar']); + expect($this->container->getDefinitions())->toBeInstanceOf(ArrayCollection::class) + ->and($this->container->getDefinitions()->toArray())->toHaveCount(2) + ->and($this->container->getDefinitions()->first())->toBeInstanceOf(Definition::class) + ->and($this->container->getDefinitions()->last())->toBeInstanceOf(Definition::class) + ->and($this->container->getDefinitions()->last()->getId())->toBe('array') + ->and($this->container->getDefinitions()->last()->getConcrete())->toBe(['foo' => 'bar']) + ->and($this->container->get('array'))->toBe(['foo' => 'bar']); }); test('scalar resolution', function () { $this->container->set('scalar', 'foo'); - expect($this->container->get('scalar'))->toBe('foo'); + expect($this->container->getDefinitions())->toBeInstanceOf(ArrayCollection::class) + ->and($this->container->getDefinitions()->toArray())->toHaveCount(2) + ->and($this->container->getDefinitions()->first())->toBeInstanceOf(Definition::class) + ->and($this->container->getDefinitions()->last())->toBeInstanceOf(Definition::class) + ->and($this->container->getDefinitions()->last()->getId())->toBe('scalar') + ->and($this->container->getDefinitions()->last()->getConcrete())->toBe('foo') + ->and($this->container->get('scalar'))->toBe('foo'); }); test('closure resolution', function () { $this->container->set('closure', fn() => 'closure'); - expect($this->container->get('closure'))->toBe('closure'); + expect($this->container->getDefinitions())->toBeInstanceOf(ArrayCollection::class) + ->and($this->container->getDefinitions()->toArray())->toHaveCount(2) + ->and($this->container->getDefinitions()->first())->toBeInstanceOf(Definition::class) + ->and($this->container->getDefinitions()->last())->toBeInstanceOf(Definition::class) + ->and($this->container->getDefinitions()->last()->getId())->toBe('closure') + ->and($this->container->getDefinitions()->last()->getConcrete())->toBeCallable() + ->and($this->container->get('closure'))->toBe('closure'); }); test('closure resolution with added parameters', function () { $this->container->set(Bar::class); $this->container->set('closure', fn(Bar $bar) => $bar); - expect($this->container->get('closure'))->toBeInstanceOf(Bar::class); + expect($this->container->getDefinitions())->toBeInstanceOf(ArrayCollection::class) + ->and($this->container->getDefinitions()->toArray())->toHaveCount(3) + ->and($this->container->getDefinitions()->first())->toBeInstanceOf(Definition::class) + ->and($this->container->getDefinitions()->last())->toBeInstanceOf(Definition::class) + ->and($this->container->getDefinitions()->last()->getId())->toBe('closure') + ->and($this->container->getDefinitions()->last()->getConcrete())->toBeCallable() + ->and($this->container->get('closure'))->toBeInstanceOf(Bar::class); }); test('closure resolution with autowired parameters', function () { - $this->container->set('closure', fn($text) => $text)->addParameter('closure'); - expect($this->container->get('closure'))->toBe('closure'); + $value = (string)mt_rand(1, 100); + $this->container->set('closure', fn($text) => $text)->addParameter($value); + expect($this->container->getDefinitions())->toBeInstanceOf(ArrayCollection::class) + ->and($this->container->getDefinitions()->toArray())->toHaveCount(2) + ->and($this->container->getDefinitions()->first())->toBeInstanceOf(Definition::class) + ->and($this->container->getDefinitions()->last())->toBeInstanceOf(Definition::class) + ->and($this->container->getDefinitions()->last()->getId())->toBe('closure') + ->and($this->container->getDefinitions()->last()->getConcrete())->toBeCallable() + ->and($this->container->get('closure'))->toBe($value); }); test('class resolution', function () { $this->container->set(Bar::class); - expect($this->container->get(Bar::class))->toBeInstanceOf(Bar::class); + expect($this->container->getDefinitions())->toBeInstanceOf(ArrayCollection::class) + ->and($this->container->getDefinitions()->toArray())->toHaveCount(2) + ->and($this->container->getDefinitions()->first())->toBeInstanceOf(Definition::class) + ->and($this->container->getDefinitions()->last())->toBeInstanceOf(Definition::class) + ->and($this->container->getDefinitions()->last()->getId())->toBe(Bar::class) + ->and($this->container->getDefinitions()->last()->getConcrete())->toBe(Bar::class) + ->and($this->container->get(Bar::class))->toBeInstanceOf(Bar::class); }); test('class resolution with added parameters', function () { $this->container->set(Foo::class)->addParameter(new Bar()); - expect( - $this->container->get(Foo::class)->bar - )->toBeInstanceOf(Bar::class); + expect($this->container->getDefinitions())->toBeInstanceOf(ArrayCollection::class) + ->and($this->container->getDefinitions()->toArray())->toHaveCount(2) + ->and($this->container->getDefinitions()->first())->toBeInstanceOf(Definition::class) + ->and($this->container->getDefinitions()->last())->toBeInstanceOf(Definition::class) + ->and($this->container->getDefinitions()->last()->getId())->toBe(Foo::class) + ->and($this->container->getDefinitions()->last()->getConcrete())->toBe(Foo::class) + ->and($this->container->get(Foo::class)->bar)->toBeInstanceOf(Bar::class); }); test('class resolution with autowired parameters', function () { $this->container->set(Bar::class); $this->container->set(Foo::class); - expect( - $this->container->get(Foo::class)->bar - )->toBeInstanceOf(Bar::class); + expect($this->container->get(Foo::class))->toBeInstanceOf(Foo::class) + ->and($this->container->get(Bar::class))->toBeInstanceOf(Bar::class) + ->and($this->container->get(Foo::class)->bar)->toBeInstanceOf(Bar::class); }); it('gets scalar values', function () { @@ -133,22 +224,22 @@ public function getCachedDefinition(string $id) { fclose($resource); }); -test('auto wiring', function () { - $this->container->set(Foo::class); - expect($this->container->get(Foo::class))->toBeInstanceOf(Foo::class) - ->and($this->container->get(Foo::class)->bar)->toBeInstanceOf(Bar::class); -}); - test('class constructor with optional parameters', function () { $this->container->set(Baz::class); expect($this->container->get(Baz::class))->toBeInstanceOf(Baz::class) - ->and($this->container->get(Baz::class)->getValues()[1])->toBe('one'); + ->and($this->container->get(Baz::class)->getValues()[0])->toBe('zero') + ->and($this->container->get(Baz::class)->getValues()[1])->toBe('one') + ->and($this->container->get(Baz::class)->getValues()[2])->toBe('two') + ->and($this->container->get(Baz::class)->getValues()[3])->toBe('three'); }); test('class constructor with optional added parameters', function ($params) { $this->container->set(Baz::class)->addParameter($params); expect($this->container->get(Baz::class))->toBeInstanceOf(Baz::class) - ->and($this->container->get(Baz::class)->getValues()[1])->toBe('un'); + ->and($this->container->get(Baz::class)->getValues()[0])->toBe('zero') + ->and($this->container->get(Baz::class)->getValues()[1])->toBe('un') + ->and($this->container->get(Baz::class)->getValues()[2])->toBe('deux') + ->and($this->container->get(Baz::class)->getValues()[3])->toBe('trois'); })->with(['french numbers' => [['zero', 'un', 'deux', 'trois']]]); test('cached class', function () { @@ -157,7 +248,7 @@ public function getCachedDefinition(string $id) { $object1 = $this->container->get(Bar::class); $object2 = $this->container->get(Bar::class); - $this->assertTrue($object1 === $object2); + expect($object1)->toBe($object2); }); test('cached closure', function () { @@ -166,17 +257,13 @@ public function getCachedDefinition(string $id) { $object1 = $this->container->get('test'); $object2 = $this->container->get('test'); - $this->assertTrue($object1 === $object2); -}); - -test('class definition with args', function () { - $this->container->set(Foo::class)->addParameter(new Bar()); - expect($this->container->get(Foo::class))->toBeInstanceOf(Foo::class); + expect($object1)->toBe($object2); }); test('class definition with method call', function () { $this->container->set(Bar::class)->addMethod('setSomething', ['something in something']); - expect($this->container->get(Bar::class)->something)->toBe('something in something'); + expect($this->container->get(Bar::class))->toBeInstanceOf(Bar::class) + ->and($this->container->get(Bar::class)->something)->toBe('something in something'); }); it('gets container instance with container interface identifier', function () { @@ -189,64 +276,49 @@ public function getCachedDefinition(string $id) { test('delegated container returns what is expected', function () { $id = substr(md5(mt_rand()), 0, 7); $value = rand(1, 100); - $container1 = new Container(); - $container2 = new Container(); $container3 = new Container(); $container3->set($id, fn() => $value); - $this->container - ->delegate($container1) - ->delegate($container2) - ->delegate($container3); + $this->container->delegate(new Container())->delegate(new Container())->delegate($container3); expect($this->container->get($id))->toBe($value); }); -test('method set with Definition::class instance', function () { - $id = substr(md5(mt_rand()), 0, 7); - $message = substr(md5(mt_rand()), 0, 7); - $definition = new Definition($id, $message); - $this->container->set($id, $definition); - expect($this->container->get($id))->toBe($message); -}); +test('get() method throws NotFoundException if ID is not found', function () { + $this->container->get('anID'); +})->throws(NotFoundException::class, 'Unable to find entry with ID "anID".'); -test('get() method throws exception if ID is not found', function () { - $id = substr(md5(mt_rand()), 0, 7); - var_dump($id, $this->container->get($id)); -})->throws(NotFoundException::class); - -test('callable parameters', function () { +test('callable parameters throws ContainerException', function () { $this->container->set('callable', fn(int $bar, Baz $baz) => [$bar, $baz]); $this->container->get('callable'); -})->throws(ContainerException::class); +})->throws( + ContainerException::class, + 'Unable to get parameter for callable/closure defined in entry with ID "callable". Expected a parameter of type "int" but could not be found inside the container nor its delegates.', + 0 +); test('get() method returns real set instance', function() { - $foo = new Foo(new Bar()); + $rand = substr(md5(mt_rand()), 0, 7); + $bar = new Bar(); + $bar->setSomething($rand); + $foo = new Foo($bar); $this->container->set(Foo::class, $foo); - expect($this->container->get(Foo::class))->toBe($foo); + expect($this->container->get(Foo::class))->toBe($foo) + ->and($this->container->get(Foo::class)->bar)->toBeInstanceOf(Bar::class) + ->and($this->container->get(Foo::class)->bar)->toBe($bar) + ->and($this->container->get(Foo::class)->bar->something)->toBe($rand); }); test('alias() returns the aliased entry', function () { $id = substr(md5(mt_rand()), 0, 7); + $value = mt_rand(1, 100); $alias = substr(md5(mt_rand()), 0, 7); - $this->container->set($id, fn() => 42); + $this->container->set($id, fn() => $value); $this->container->alias($alias, $id); - expect($this->container->get($alias))->toBe(42); + expect($this->container->get($id))->toBe($value) + ->and($this->container->get($alias))->toBe($value) + ->and($this->container->get($alias))->toBe($this->container->get($id)); }); test('alias() throws a NotFoundException when entry does not exist', function () { $this->container->alias('Monolog\\Logger', 'Psr\\log\\LoggerInterface'); })->throws(NotFoundException::class, 'Unable to find entry with ID "Psr\\log\\LoggerInterface".'); - -test('isAutowiring() should return true by default', function () { - expect($this->container->isAutowiring())->toBeTrue(); -}); - -test('setAutowiring() to false', function () { - $this->container->setAutowiring(false); - expect($this->container->isAutowiring())->toBeFalse(); -}); - -it('should throw an exception when autowiring is off and class is missing a parameter', function () { - $this->container->setAutowiring(false); - $this->container->get(Foo::class); -})->throws(NotFoundException::class); diff --git a/tests/Unit/NotFoundExceptionTest.php b/tests/Unit/NotFoundExceptionTest.php new file mode 100644 index 0000000..3e41226 --- /dev/null +++ b/tests/Unit/NotFoundExceptionTest.php @@ -0,0 +1,15 @@ +toBeInstanceOf(NotFoundException::class) + ->and($exception->getMessage())->toBe(sprintf( + 'Unable to find entry with ID "%s".', + $id + )) + ->and($exception->getCode())->toBe(0) + ->and($exception->getPrevious())->toBeNull(); +}); diff --git a/tests/Unit/ReferenceTest.php b/tests/Unit/ReferenceTest.php index 87fe9d4..ee0e125 100644 --- a/tests/Unit/ReferenceTest.php +++ b/tests/Unit/ReferenceTest.php @@ -6,5 +6,5 @@ test('reference() returns the id', function () { $reference = new Reference('test'); - expect($reference->reference())->toBe('test'); + expect($reference->references())->toBe('test'); }); From a27ac5fb45e331e04f6d308ba8344923565b2bb0 Mon Sep 17 00:00:00 2001 From: debuss-a Date: Sat, 3 May 2025 21:36:15 +0200 Subject: [PATCH 09/14] feat: extend() method extend() method allows you to extend an existing definition with a callable. --- src/Container/Container.php | 63 ++++++++++++++++++- src/Container/DefinitionExtender.php | 25 ++++++++ .../Exception/ContainerException.php | 20 ++++++ tests/Unit/ContainerTest.php | 49 +++++++++++++++ 4 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 src/Container/DefinitionExtender.php diff --git a/src/Container/Container.php b/src/Container/Container.php index aac9299..9ad4891 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -5,6 +5,7 @@ namespace Borsch\Container; +use Borsch\Container\Exception\ContainerException; use Borsch\Container\Exception\NotFoundException; use Psr\Container\{ ContainerExceptionInterface, @@ -21,9 +22,11 @@ class Container implements ContainerInterface { - /** @var ArrayCollection $definitions */ + /** @var ArrayCollection $definitions */ protected ArrayCollection $definitions; + protected bool $cache_by_default = false; + /** @var array $cache */ protected array $cache = []; @@ -68,6 +71,33 @@ public function isAutowiring(): bool return $this->autowire; } + /** + * Set the default cache behavior for the container. + * + * The cache for a definition is set when you add an item to the container. + * + * If set to true, the container will cache all the definitions. + * If set to false, the container will not cache any definitions. + * The cache behavior can be overridden on a per-definition basis. + * + * @param bool $cache + * @return self + */ + public function setCacheByDefault(bool $cache): self + { + $this->cache_by_default = $cache; + + return $this; + } + + /** + * @see Container::setCacheByDefault() + */ + public function getCacheByDefault(): bool + { + return $this->cache_by_default; + } + /** * @inheritDoc * @throws ReflectionException @@ -97,7 +127,7 @@ public function get(string $id): mixed } if ($definition->isReference()) { - return $this->get($definition->getConcrete()->reference()); + return $this->get($definition->getConcrete()->references()); } return $this->resolveDefinitionItem($definition, $id); @@ -221,11 +251,38 @@ public function set(string $id, mixed $definition = null): Definition { $this->definitions[$id] = $definition instanceof Definition ? $definition - : new Definition($id, $definition); + : new Definition($id, $definition, $this->cache_by_default); return $this->definitions[$id]; } + /** + * Extend an existing definition with a callable. + * + * A new definition will be created with the ID $id in the container. + * + * @param callable $callable The callable to extend the definition with + * @phpstan-param callable(mixed, Container): mixed $callable + * @throws NotFoundException if $from is not found in the container + * @throws ContainerException if $id is the same as $from + */ + public function extend(string $id, callable $callable, string $from): DefinitionExtender + { + if ($id === $from) { + throw ContainerException::extendingWithSameIdAndFromForbidden($id); + } + + if ($this->has($id)) { + throw ContainerException::extendingAnExistingEntryIsForbidden($id); + } + + if (!$this->has($from)) { + throw NotFoundException::unableToFindEntry($from); + } + + return $this->definitions[$id] = new DefinitionExtender($id, $callable, $from); + } + /** * Entrust another PSR-11 container in case of missing a requested entry ID. * diff --git a/src/Container/DefinitionExtender.php b/src/Container/DefinitionExtender.php new file mode 100644 index 0000000..88449f2 --- /dev/null +++ b/src/Container/DefinitionExtender.php @@ -0,0 +1,25 @@ +callable, [ + $this->container->get($this->from), + $this->container + ]); + } +} diff --git a/src/Container/Exception/ContainerException.php b/src/Container/Exception/ContainerException.php index 8df2dc4..bdd0379 100644 --- a/src/Container/Exception/ContainerException.php +++ b/src/Container/Exception/ContainerException.php @@ -52,4 +52,24 @@ public static function unableToGetFunctionReflection(string $function, ?Reflecti $exception ); } + + public static function extendingWithSameIdAndFromForbidden(string $id): self + { + return new self( + sprintf( + 'It is forbidden to extend a definition with the same ID (%s), provide a new ID (e.g. $from) to fix the issue.', + $id + ) + ); + } + + public static function extendingAnExistingEntryIsForbidden(string $id): self + { + return new self( + sprintf( + 'It is forbidden to extend a definition and register it with an existing ID (%s), provide a new ID (e.g. $from) to fix the issue.', + $id + ) + ); + } } diff --git a/tests/Unit/ContainerTest.php b/tests/Unit/ContainerTest.php index a256d3f..e489691 100644 --- a/tests/Unit/ContainerTest.php +++ b/tests/Unit/ContainerTest.php @@ -322,3 +322,52 @@ public function clear(): void { $this->definitions->clear(); } test('alias() throws a NotFoundException when entry does not exist', function () { $this->container->alias('Monolog\\Logger', 'Psr\\log\\LoggerInterface'); })->throws(NotFoundException::class, 'Unable to find entry with ID "Psr\\log\\LoggerInterface".'); + +test('cache behavior is false by default on instantiation of a container', function () { + expect($this->container->getCacheByDefault())->toBeFalse(); +}); + +test('cache behavior is true when set to true', function () { + $this->container->set(DateTime::class)->addParameter('now'); + $this->container->setCacheByDefault(true); + $this->container->set(Bar::class); + $this->container->get(DateTime::class); // Call 1 time to place it in cache (if set) + $this->container->get(Bar::class); // Call 1 time to place it in cache (if set) + expect($this->container->getCacheByDefault())->toBeTrue() + ->and($this->container->isCached(Bar::class))->toBeTrue() + ->and($this->container->isCached(DateTime::class))->toBeFalse(); +}); + +test('extend() extend a scalar', function () { + $this->container->set('foo', 'Hello,'); + $this->container->set('bar', 'World!'); + $this->container->extend('test', fn(string $text, ContainerInterface $container) => $text . ' ' . $container->get('bar'), 'foo'); + + expect($this->container->get('test'))->toBe('Hello, World!'); +}); + +test('extend() extend a class', function () { + $something = substr(md5(mt_rand()), 0, 7); + $this->container->set(Bar::class)->addMethod('setSomething', [$something]); + $this->container->extend(Foo::class, fn(Bar $bar) => new Foo($bar), Bar::class); + expect($this->container->has(Foo::class))->toBeTrue() + ->and($this->container->get(Foo::class))->toBeInstanceOf(Foo::class) + ->and($this->container->get(Foo::class)->bar)->toBeInstanceOf(Bar::class) + ->and($this->container->get(Foo::class)->bar->something)->toBe($something); +}); + +test('extend() throw NotFoundException', function () { + $this->container->extend(Foo::class, fn(Bar $bar) => new Foo($bar), Bar::class); +})->throws(NotFoundException::class, 'Unable to find entry with ID "'.Bar::class.'".', 0); + +test('extend() throw ContainerException (extendingWithSameIdAndFromForbidden)', function () { + $this->container->set('foo', 'Hello,'); + $this->container->set('bar', 'World!'); + $this->container->extend('bar', fn(string $text, ContainerInterface $container) => '', 'bar'); +})->throws(ContainerException::class, 'It is forbidden to extend a definition with the same ID (bar), provide a new ID (e.g. $from) to fix the issue.', 0); + +test('extend() throw ContainerException (extendingAnExistingEntryIsForbidden)', function () { + $this->container->set('foo', 'Hello,'); + $this->container->set('bar', 'World!'); + $this->container->extend('bar', fn(string $text, ContainerInterface $container) => '', 'foo'); +})->throws(ContainerException::class, 'It is forbidden to extend a definition and register it with an existing ID (bar), provide a new ID (e.g. $from) to fix the issue.', 0); From 07f57a0eca4b15963a8072725bd044f0f4a1455d Mon Sep 17 00:00:00 2001 From: debuss-a Date: Sat, 3 May 2025 21:38:49 +0200 Subject: [PATCH 10/14] fix: phpstan for php 8.4 --- src/Container/Definition.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Container/Definition.php b/src/Container/Definition.php index d9843e1..31a26bd 100644 --- a/src/Container/Definition.php +++ b/src/Container/Definition.php @@ -61,7 +61,7 @@ public function getConcrete(): mixed return $this->concrete; } - public function addParameter(mixed $value, string $key = null): self + public function addParameter(mixed $value, ?string $key = null): self { if ($key !== null) { $this->parameters[$key] = $value; From a25e0338298d8c83b635d3a451d69c95243daaec Mon Sep 17 00:00:00 2001 From: debuss-a Date: Sun, 4 May 2025 11:48:59 +0200 Subject: [PATCH 11/14] chore: added use function clause --- src/Container/Container.php | 1 + src/Container/Definition.php | 2 +- src/Container/DefinitionExtender.php | 2 ++ src/Container/Exception/ContainerException.php | 1 + src/Container/Exception/NotFoundException.php | 1 + 5 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Container/Container.php b/src/Container/Container.php index 9ad4891..e0119e1 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -14,6 +14,7 @@ }; use Doctrine\Common\Collections\ArrayCollection; use ReflectionException; +use function spl_object_id; /** * Class Container diff --git a/src/Container/Definition.php b/src/Container/Definition.php index 31a26bd..290d001 100644 --- a/src/Container/Definition.php +++ b/src/Container/Definition.php @@ -16,7 +16,7 @@ use ReflectionParameter; use ReflectionType; use ReflectionUnionType; -use TypeError; +use function is_callable, is_string, is_null, class_exists, in_array, call_user_func_array, array_reduce, count, method_exists; /** * Class Definition diff --git a/src/Container/DefinitionExtender.php b/src/Container/DefinitionExtender.php index 88449f2..54e8029 100644 --- a/src/Container/DefinitionExtender.php +++ b/src/Container/DefinitionExtender.php @@ -2,6 +2,8 @@ namespace Borsch\Container; +use function call_user_func_array; + class DefinitionExtender extends Definition { diff --git a/src/Container/Exception/ContainerException.php b/src/Container/Exception/ContainerException.php index bdd0379..e0b5b2d 100644 --- a/src/Container/Exception/ContainerException.php +++ b/src/Container/Exception/ContainerException.php @@ -9,6 +9,7 @@ use Psr\Container\ContainerExceptionInterface; use ReflectionException; use ReflectionType; +use function sprintf, method_exists; /** * Class ContainerException diff --git a/src/Container/Exception/NotFoundException.php b/src/Container/Exception/NotFoundException.php index 08d624b..f4e2eb9 100644 --- a/src/Container/Exception/NotFoundException.php +++ b/src/Container/Exception/NotFoundException.php @@ -7,6 +7,7 @@ use Exception; use Psr\Container\NotFoundExceptionInterface; +use function sprintf; /** * Class NotFoundException From 3af349eeebfa7cd9c544ee5854cdf39766e2e5de Mon Sep 17 00:00:00 2001 From: debuss-a Date: Sun, 4 May 2025 11:50:41 +0200 Subject: [PATCH 12/14] chore: inline use clause --- src/Container/Container.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Container/Container.php b/src/Container/Container.php index e0119e1..0f19f12 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -7,11 +7,7 @@ use Borsch\Container\Exception\ContainerException; use Borsch\Container\Exception\NotFoundException; -use Psr\Container\{ - ContainerExceptionInterface, - ContainerInterface, - NotFoundExceptionInterface -}; +use Psr\Container\{ContainerExceptionInterface, ContainerInterface, NotFoundExceptionInterface}; use Doctrine\Common\Collections\ArrayCollection; use ReflectionException; use function spl_object_id; From 967a2c117858f2d6a61ca539f4e8e9f7d236fae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Debussch=C3=A8re?= Date: Mon, 5 May 2025 14:59:41 +0200 Subject: [PATCH 13/14] upd: revamp of extend() logic --- src/Container/Container.php | 8 +++++--- src/Container/Definition.php | 17 +++++++++++++++++ src/Container/DefinitionExtender.php | 27 --------------------------- tests/Assets/BarDecorator.php | 16 ++++++++++++++++ tests/Unit/DefinitionTest.php | 10 ++++++++++ 5 files changed, 48 insertions(+), 30 deletions(-) delete mode 100644 src/Container/DefinitionExtender.php create mode 100644 tests/Assets/BarDecorator.php diff --git a/src/Container/Container.php b/src/Container/Container.php index 0f19f12..58f41c7 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -19,7 +19,7 @@ class Container implements ContainerInterface { - /** @var ArrayCollection $definitions */ + /** @var ArrayCollection $definitions */ protected ArrayCollection $definitions; protected bool $cache_by_default = false; @@ -263,7 +263,7 @@ public function set(string $id, mixed $definition = null): Definition * @throws NotFoundException if $from is not found in the container * @throws ContainerException if $id is the same as $from */ - public function extend(string $id, callable $callable, string $from): DefinitionExtender + public function extend(string $id, callable $callable, string $from): Definition { if ($id === $from) { throw ContainerException::extendingWithSameIdAndFromForbidden($id); @@ -277,7 +277,9 @@ public function extend(string $id, callable $callable, string $from): Definition throw NotFoundException::unableToFindEntry($from); } - return $this->definitions[$id] = new DefinitionExtender($id, $callable, $from); + $this->definitions[$id] = (new Definition($id, $from))->setCallable($callable); + + return $this->definitions[$id]; } /** diff --git a/src/Container/Definition.php b/src/Container/Definition.php index 290d001..ee8bc5a 100644 --- a/src/Container/Definition.php +++ b/src/Container/Definition.php @@ -34,6 +34,9 @@ class Definition /** @var string[] */ protected array $tags = []; + /** @var callable */ + protected $callable = null; + protected ContainerInterface $container; /** @@ -127,6 +130,13 @@ public function setContainer(ContainerInterface &$container): self return $this; } + public function setCallable(callable $callable): self + { + $this->callable = $callable; + + return $this; + } + /** * @param bool $cached * @return $this @@ -159,6 +169,13 @@ public function isReference(): bool */ public function get(): mixed { + if ($this->callable !== null) { + return call_user_func_array($this->callable, [ + $this->container->get($this->concrete), + $this->container + ]); + } + if (($this->id == $this->concrete && is_callable($this->concrete)) || is_callable($this->concrete)) { return $this->invokeAsCallable(); } diff --git a/src/Container/DefinitionExtender.php b/src/Container/DefinitionExtender.php deleted file mode 100644 index 54e8029..0000000 --- a/src/Container/DefinitionExtender.php +++ /dev/null @@ -1,27 +0,0 @@ -callable, [ - $this->container->get($this->from), - $this->container - ]); - } -} diff --git a/tests/Assets/BarDecorator.php b/tests/Assets/BarDecorator.php new file mode 100644 index 0000000..a3cb953 --- /dev/null +++ b/tests/Assets/BarDecorator.php @@ -0,0 +1,16 @@ +bar; + } +} \ No newline at end of file diff --git a/tests/Unit/DefinitionTest.php b/tests/Unit/DefinitionTest.php index 93cd5ef..db76d99 100644 --- a/tests/Unit/DefinitionTest.php +++ b/tests/Unit/DefinitionTest.php @@ -5,6 +5,7 @@ use Borsch\Container\Exception\NotFoundException; use Borsch\Container\Reference; use BorschTest\Assets\Bar; +use BorschTest\Assets\BarDecorator; use BorschTest\Assets\Baz; use BorschTest\Assets\Biz; use BorschTest\Assets\Ink; @@ -198,3 +199,12 @@ public function getParameters(): array { return $this->parameters; } $definition = new Definition('id', 'test'); expect($definition->isReference())->toBeFalse(); }); + +test('setCallable() is called', function () { + $definition = new Definition(BarDecorator::class, Bar::class); + $definition = $definition + ->setContainer($this->container) + ->setCallable(fn(Bar $bar) => new BarDecorator($bar)); + expect($definition->get())->toBeInstanceOf(BarDecorator::class) + ->and($definition->get()->bar)->toBeInstanceOf(Bar::class); +}); From 673839ba8d37749956ab37c3ba247284451abf9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Debussch=C3=A8re?= Date: Thu, 31 Jul 2025 10:36:21 +0200 Subject: [PATCH 14/14] upd: strict_types --- src/Container/Container.php | 9 +++++---- src/Container/Definition.php | 6 +++--- src/Container/Exception/ContainerException.php | 2 +- src/Container/Reference.php | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/Container/Container.php b/src/Container/Container.php index 58f41c7..4e17304 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -1,12 +1,11 @@ -resolveDefinitionCollection($definition); } + /** @var Definition $definition */ if ($definition->isReference()) { return $this->get($definition->getConcrete()->references()); } @@ -197,7 +197,8 @@ protected function resolveDefinitionItem(Definition $definition, string $id): mi */ protected function getDelegatedItem(string $id): mixed { - return $this->delegates + return $this + ->delegates ->findFirst(fn($k, ContainerInterface $container) => $container->has($id)) ->get($id); } diff --git a/src/Container/Definition.php b/src/Container/Definition.php index ee8bc5a..587a50b 100644 --- a/src/Container/Definition.php +++ b/src/Container/Definition.php @@ -1,4 +1,4 @@ -id == $this->concrete && is_callable($this->concrete)) || is_callable($this->concrete)) { + if (($this->id === $this->concrete && is_callable($this->concrete)) || is_callable($this->concrete)) { return $this->invokeAsCallable(); } - if (($this->id == $this->concrete || is_string($this->concrete)) && class_exists($this->concrete)) { + if (($this->id === $this->concrete || is_string($this->concrete)) && class_exists($this->concrete)) { return $this->invokeAsClass(); } diff --git a/src/Container/Exception/ContainerException.php b/src/Container/Exception/ContainerException.php index e0b5b2d..245ca97 100644 --- a/src/Container/Exception/ContainerException.php +++ b/src/Container/Exception/ContainerException.php @@ -1,4 +1,4 @@ -