diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 5f133c8..ac7b02d 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,10 @@ 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=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/.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/composer.json b/composer.json index cf9e78f..ace042b 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,13 @@ } ], "require": { - "php": "^8.0", - "psr/container": "^2" + "php": "^8.2", + "psr/container": "^2.0", + "doctrine/collections": "^2.3" }, "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..4e17304 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -1,16 +1,15 @@ - $definitions */ + protected ArrayCollection $definitions; + protected bool $cache_by_default = false; + + /** @var array $cache */ protected array $cache = []; - /** @var ContainerInterface[] $delegates */ - protected array $delegates = []; + /** @var ArrayCollection $delegates */ + protected ArrayCollection $delegates; + + protected bool $autowire = true; - /** - * Container constructor. - */ public function __construct() { + $this->definitions = new ArrayCollection(); + $this->delegates = new ArrayCollection(); + $this ->set(ContainerInterface::class, $this) ->cache(true); } /** - * 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. * - * @param string $id - * @return mixed - * @throws NotFoundExceptionInterface - * @throws ContainerExceptionInterface + * This will add an entry in the container with a key as the class FQDN. + * + * Example: + * + * $container = new Container(); + * $container->get(MyClass::class); + * + * @param bool $autowire + * @return self + */ + public function setAutowiring(bool $autowire): self + { + $this->autowire = $autowire; + + return $this; + } + + 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 */ public function get(string $id): mixed @@ -52,19 +104,83 @@ public function get(string $id): mixed return $this->cache[$id]; } - if (!isset($this->definitions[$id])) { - foreach ($this->delegates as $delegate) { - if ($delegate->has($id)) { - return $delegate->get($id); - } + $definition = $this->resolveDefinition($id); + + if ($definition === null) { + if ($this->delegatedHave($id)) { + return $this->getDelegatedItem($id); } + + if (!$this->autowire) { + throw NotFoundException::unableToFindEntry($id); + } + + $definition = $this->set($id); + } + + if ($definition instanceof ArrayCollection) { + return $this->resolveDefinitionCollection($definition); + } + + /** @var Definition $definition */ + if ($definition->isReference()) { + return $this->get($definition->getConcrete()->references()); + } + + 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 + { + if ($this->definitions->containsKey($id)) { + return $this->definitions->get($id); } - $definition = $this->definitions[$id] ?? $this->set($id); + if ($this->hasTag($id)) { + return $this->definitions->filter(fn(Definition $definition) => $definition->hasTag($id)); + } - $item = $definition - ->setContainer($this) - ->get(); + return null; + } + + /** + * Resolve a collection of definitions. + * + * @param ArrayCollection $definitions + * @return array + * @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; @@ -74,22 +190,54 @@ public function get(string $id): mixed } /** - * Returns true if the container can return an entry for the given identifier. - * Returns false otherwise. + * Get an item from a delegated container. * - * `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`. + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + protected function getDelegatedItem(string $id): mixed + { + return $this + ->delegates + ->findFirst(fn($k, ContainerInterface $container) => $container->has($id)) + ->get($id); + } + + /** + * @inheritdoc * - * @param string $id - * @return bool + * Implementation details: + * 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 */ public function has(string $id): bool { - return isset($this->definitions[$id]) || array_reduce( - $this->delegates, - fn($has, $container) => $has ?: $container->has($id), - false - ); + if ($this->definitions->containsKey($id)) { + return true; + } + + if ($this->hasTag($id)) { + return true; + } + + return $this->delegatedHave($id); + } + + /** + * Check if any of the definitions have the requested tag. + */ + public function hasTag(string $tag): bool + { + return $this->definitions->exists(fn($k, Definition $definition) => $definition->hasTag($tag)); + } + + /** + * 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)); } /** @@ -99,23 +247,66 @@ public function has(string $id): bool */ public function set(string $id, mixed $definition = null): Definition { - return $this->definitions[$id] ??= $definition instanceof Definition ? - $definition : - new Definition($id, $definition); + $this->definitions[$id] = $definition instanceof Definition + ? $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): Definition + { + if ($id === $from) { + throw ContainerException::extendingWithSameIdAndFromForbidden($id); + } + + if ($this->has($id)) { + throw ContainerException::extendingAnExistingEntryIsForbidden($id); + } + + if (!$this->has($from)) { + throw NotFoundException::unableToFindEntry($from); + } + + $this->definitions[$id] = (new Definition($id, $from))->setCallable($callable); + + return $this->definitions[$id]; } /** * 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; } + + /** + * @throws NotFoundException + */ + public function alias(string $alias, string $from): void + { + if (!$this->has($from)) { + throw NotFoundException::unableToFindEntry($from); + } + + $this->set($alias, new Reference($from)); + } } diff --git a/src/Container/Definition.php b/src/Container/Definition.php index 96e6edd..587a50b 100644 --- a/src/Container/Definition.php +++ b/src/Container/Definition.php @@ -1,4 +1,4 @@ -> */ protected array $methods = []; + /** @var string[] */ + protected array $tags = []; + + /** @var callable */ + protected $callable = null; + protected ContainerInterface $container; /** @@ -42,20 +54,40 @@ public function __construct( $this->concrete = $concrete === null ? $id : $concrete; } + public function getId(): string + { + return $this->id; + } + + 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; + } + /** - * @param mixed $value - * @return Definition + * @param array $values */ - public function addParameter(mixed $value): self + public function addParameters(array $values): self { - $this->parameters[] = $value; + $this->parameters = $values; return $this; } /** * @param string $name - * @param array $arguments + * @param array $arguments * @return $this */ public function addMethod(string $name, array $arguments = []): self @@ -65,6 +97,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 @@ -76,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 @@ -95,6 +156,11 @@ public function isCached(): bool return $this->cached; } + public function isReference(): bool + { + return $this->concrete instanceof Reference; + } + /** * @return mixed * @throws ContainerExceptionInterface @@ -103,11 +169,18 @@ public function isCached(): bool */ public function get(): mixed { - if (($this->id == $this->concrete && is_callable($this->concrete)) || is_callable($this->concrete)) { + 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(); } - 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(); } @@ -130,11 +203,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(); @@ -167,9 +236,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 @@ -180,12 +249,18 @@ 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->references()); + } + } + return $item->newInstanceArgs($this->parameters); } /** * @param ReflectionMethod $constructor - * @return array + * @return array * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface * @throws ReflectionException @@ -195,9 +270,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(); } @@ -227,12 +302,8 @@ protected function invokeAsCallable(): mixed { try { $function = new ReflectionFunction($this->concrete); - } catch (ReflectionException|TypeError $exception) { - throw new NotFoundException( - NotFoundException::unableToFindEntry($this->id), - $exception->getCode(), - $exception - ); + } catch (ReflectionException $exception) { + throw ContainerException::unableToGetFunctionReflection($this->concrete, $exception); } if (!$function->getNumberOfParameters()) { @@ -241,16 +312,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 52982af..245ca97 100644 --- a/src/Container/Exception/ContainerException.php +++ b/src/Container/Exception/ContainerException.php @@ -1,4 +1,4 @@ -getName() + method_exists($type, 'getName') ? $type->getName() : 'Unknown type' ), - /** @infection-ignore-all */ - $exception->getCode() ?? 0, + $exception?->getCode() ?? 0, $exception ); } + + public static function unableToGetClassReflection(string $classname, ?ReflectionException $exception = null) :self + { + return new self( + 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) :self + { + return new self( + sprintf( + 'Unable to create a reflection for function "%s", it does not exist.', + $function + ), + $exception?->getCode() ?? 0, + $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/src/Container/Exception/NotFoundException.php b/src/Container/Exception/NotFoundException.php index dcf15e7..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 @@ -14,8 +15,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 new file mode 100644 index 0000000..02fec98 --- /dev/null +++ b/src/Container/Reference.php @@ -0,0 +1,26 @@ +id; + } +} 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/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/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 dca9c06..e489691 100644 --- a/tests/Unit/ContainerTest.php +++ b/tests/Unit/ContainerTest.php @@ -2,28 +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; -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]; - } +covers(Container::class); + +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 + + expect($this->container->isCached(ContainerInterface::class))->toBeTrue() + ->and($this->container->getCachedDefinition(ContainerInterface::class))->toBe($this->container); +}); - $container->get(ContainerInterface::class); // Call 1 time to place it in cache +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 () { @@ -32,51 +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 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->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->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->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 () { @@ -111,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 () { @@ -135,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 () { @@ -144,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 () { @@ -167,38 +276,98 @@ 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 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('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('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() => $value); + $this->container->alias($alias, $id); + 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('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); diff --git a/tests/Unit/DefinitionTest.php b/tests/Unit/DefinitionTest.php index 0561714..db76d99 100644 --- a/tests/Unit/DefinitionTest.php +++ b/tests/Unit/DefinitionTest.php @@ -3,14 +3,16 @@ 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\BarDecorator; use BorschTest\Assets\Baz; use BorschTest\Assets\Biz; -use BorschTest\Assets\ExtendedDefinition; -use BorschTest\Assets\Foo; use BorschTest\Assets\Ink; use Psr\Container\ContainerInterface; +covers(Definition::class); + it('adds method', function () { $definition = new Definition(Bar::class); $definition->addMethod('setSomething', ['something']); @@ -37,13 +39,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 () { @@ -71,7 +73,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); @@ -79,14 +81,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); @@ -124,9 +126,85 @@ 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); +})->throws(TypeError::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(); +}); + +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); +}); 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 new file mode 100644 index 0000000..ee0e125 --- /dev/null +++ b/tests/Unit/ReferenceTest.php @@ -0,0 +1,10 @@ +references())->toBe('test'); +});