From e271b17d4fc6d6db5212d6c606c2421cc9e8f4e3 Mon Sep 17 00:00:00 2001 From: Olivier Laviale Date: Sun, 14 Oct 2018 15:49:02 +0200 Subject: [PATCH] Initial commit --- .editorconfig | 21 ++ .gitignore | 3 + .travis.yml | 19 ++ Dockerfile | 24 ++ LICENSE | 30 +++ Makefile | 26 +++ README.md | 208 ++++++++++++++++++ composer.json | 28 +++ docker-compose.yml | 8 + lib/FactoryRenderer.php | 70 ++++++ lib/InterfaceResolver.php | 22 ++ .../BasicInterfaceResolver.php | 47 ++++ .../MapInterfaceResolver.php | 45 ++++ lib/MethodRenderer.php | 94 ++++++++ lib/ProxyDumper.php | 101 +++++++++ phpunit.xml | 24 ++ tests/FactoryRendererTest.php | 62 ++++++ tests/IntegrationTest.php | 200 +++++++++++++++++ .../BasicInterfaceResolverTest.php | 76 +++++++ .../MapInterfaceResolverTest.php | 63 ++++++ tests/MethodRendererTest.php | 165 ++++++++++++++ tests/ProxyDumperTest.php | 156 +++++++++++++ tests/cases/Buildable.php | 21 ++ tests/cases/BuildableFactory.php | 21 ++ tests/cases/BuildableInterface.php | 8 + tests/cases/Sample.php | 24 ++ tests/cases/Sample2.php | 35 +++ tests/cases/SampleInterface.php | 8 + tests/cases/SampleInterface2.php | 8 + .../SampleInterfaceForMethodRenderer70.php | 37 ++++ .../SampleInterfaceForMethodRenderer72.php | 17 ++ tests/sandbox/.gitignore | 2 + 32 files changed, 1673 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 composer.json create mode 100644 docker-compose.yml create mode 100644 lib/FactoryRenderer.php create mode 100644 lib/InterfaceResolver.php create mode 100644 lib/InterfaceResolver/BasicInterfaceResolver.php create mode 100644 lib/InterfaceResolver/MapInterfaceResolver.php create mode 100644 lib/MethodRenderer.php create mode 100644 lib/ProxyDumper.php create mode 100644 phpunit.xml create mode 100644 tests/FactoryRendererTest.php create mode 100644 tests/IntegrationTest.php create mode 100644 tests/InterfaceResolver/BasicInterfaceResolverTest.php create mode 100644 tests/InterfaceResolver/MapInterfaceResolverTest.php create mode 100644 tests/MethodRendererTest.php create mode 100644 tests/ProxyDumperTest.php create mode 100644 tests/cases/Buildable.php create mode 100644 tests/cases/BuildableFactory.php create mode 100644 tests/cases/BuildableInterface.php create mode 100644 tests/cases/Sample.php create mode 100644 tests/cases/Sample2.php create mode 100644 tests/cases/SampleInterface.php create mode 100644 tests/cases/SampleInterface2.php create mode 100644 tests/cases/SampleInterfaceForMethodRenderer70.php create mode 100644 tests/cases/SampleInterfaceForMethodRenderer72.php create mode 100644 tests/sandbox/.gitignore diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f6c720e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 4 + +[*.yml] +indent_style = space +indent_size = 2 + +[*.md] +indent_style = space +indent_size = 4 + +[Makefile] +indent_style = tab +indent_size = 4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..073e37a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build +composer.lock +vendor diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..6a82dd7 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +sudo: false + +cache: + directories: + - $COMPOSER_CACHE_DIR + - $HOME/.composer/cache + - $TRAVIS_BUILD_DIR/build + +language: php + +php: + - 7.1 + - 7.2 + +before_script: + - if [[ $TRAVIS_PHP_VERSION != "7.1" ]]; then phpenv config-rm xdebug.ini; fi + +script: + - if [[ $TRAVIS_PHP_VERSION == "7.1" ]]; then make test-coveralls; else make test; fi diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2b980d0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM php:7.1-alpine3.8 + +RUN apk add --no-cache make $PHPIZE_DEPS \ + && pecl install xdebug \ + && docker-php-ext-enable xdebug + +RUN echo $'\ +xdebug.coverage_enable=1\n\ +xdebug.default_enable=1\n\ +' >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini + +ENV COMPOSER_ALLOW_SUPERUSER 1 + +RUN curl -o /tmp/composer-setup.php https://getcomposer.org/installer && \ + curl -o /tmp/composer-setup.sig https://composer.github.io/installer.sig && \ + php -r "if (hash('SHA384', file_get_contents('/tmp/composer-setup.php')) !== trim(file_get_contents('/tmp/composer-setup.sig'))) { unlink('/tmp/composer-setup.php'); echo 'Invalid installer' . PHP_EOL; exit(1); }" && \ + php /tmp/composer-setup.php && \ + mv composer.phar /usr/local/bin/composer + +RUN wget -O phpunit https://phar.phpunit.de/phpunit-7.phar && \ + chmod +x phpunit && \ + mv phpunit /usr/local/bin/phpunit + +WORKDIR app diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6e00220 --- /dev/null +++ b/LICENSE @@ -0,0 +1,30 @@ +The olvlvl/symfony-dependency-injection-proxy package is free software. +It is released under the terms of the following BSD License. + +Copyright (c) 2018 by Olivier Laviale +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of Olivier Laviale nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c1271d6 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +PHPUNIT=$(shell which phpunit) + +vendor: + @composer install + +test: test-setup + @php -d xdebug.coverage_enable=0 $(PHPUNIT) + +test-coverage: test-setup + @mkdir -p build/coverage + @$(PHPUNIT) --coverage-html build/coverage + +test-coveralls: test-setup + @mkdir -p build/logs + composer require satooshi/php-coveralls '^2.0' + @$(PHPUNIT) --coverage-clover build/logs/clover.xml + php vendor/bin/php-coveralls -v + +test-container: + @docker-compose run --rm app sh + @docker-compose down + +test-setup: vendor + @rm -f tests/sandbox/* + +.PHONY: all test test-container test-coverage test-coveralls test-setup diff --git a/README.md b/README.md new file mode 100644 index 0000000..9da35b7 --- /dev/null +++ b/README.md @@ -0,0 +1,208 @@ +# Proxy generator for Symfony's DIC + +[![Release](https://img.shields.io/packagist/v/olvlvl/symfony-dependency-injection-proxy.svg)](https://packagist.org/packages/olvlvl/symfony-dependency-injection-proxy) +[![Build Status](https://img.shields.io/travis/olvlvl/symfony-dependency-injection-proxy.svg)](http://travis-ci.org/olvlvl/symfony-dependency-injection-proxy) +[![Code Quality](https://img.shields.io/scrutinizer/g/olvlvl/symfony-dependency-injection-proxy.svg)](https://scrutinizer-ci.com/g/olvlvl/symfony-dependency-injection-proxy) +[![Code Coverage](https://img.shields.io/coveralls/olvlvl/symfony-dependency-injection-proxy.svg)](https://coveralls.io/r/olvlvl/symfony-dependency-injection-proxy) +[![Packagist](https://img.shields.io/packagist/dt/olvlvl/symfony-dependency-injection-proxy.svg)](https://packagist.org/packages/olvlvl/symfony-dependency-injection-proxy) + +This package provides a proxy generator for [Symfony's dependency injection component][1] that generates super tiny, +super simple proxies, especially when [compared to Symphony's default implementation][2]. + +> If you're not familiar with proxy services, better have a look at [Symfony's documentation][3] before going any +> further. + +The generator works with the following assumptions: the service we want to proxy implements an interface and services +using that service expect that interface too. Pretty normal stuff. Consider the following code, where an +`ExceptionHandler` service requires a logger implementing `LogInterface`: + +```php +logger = $logger; + } + + // … +} +``` + +Now imagine we're using [Monolog](https://github.com/Seldaek/monolog) as a logger, and we have an expansive stream to +setup. Why waste time building that logger for every request when it's seldom used? That's when we mark our service as +_lazy_. + +The following example demonstrates how we can mark our `Psr\Log\LoggerInterface` service as lazy (we could use PHP code +or XML just the same): + +```yaml +services: + + Psr\Log\LoggerInterface: + class: Monolog\Logger + lazy: true + # … +``` + +The service can also use a factory: + +```yaml +services: + + Psr\Log\LoggerInterface: + factory: 'LoggerFactory::build' + lazy: true + # … +``` + +> We don't have to define our service with a class, we could use `logger` instead of `Psr\Log\LoggerInterface` just +> the same, except we would have to define `class` for the factory one. + +Now let's see how to build our container. + +## Building the dependency injection container + +The following code demonstrates how to build, compile, and dump a container: + +```php +compile(); + +$dumper = new PhpDumper($builder); +$dumper->setProxyDumper(new ProxyDumper( + new BasicInterfaceResolver(), + new FactoryRenderer(new MethodRenderer) +)); + +/* @var string $containerFile */ + +file_put_contents($containerFile, $dumper->dump()); +``` + +There you have it. We can use our container as usual and everything is awesome and cute. + + + + + +### What if my lazy service implements multiple interfaces? + +The basic interface resolver will have a hard time figuring out which interface to implement if a service implements +many. For instance, if a service was an instance of `ArrayObject` the following exception would be raised: + +``` +Don't know which interface to choose from for ArrayObject: IteratorAggregate, Traversable, ArrayAccess, Serializable, Countable. +``` + +We can help by decorating the basic interface resolver with a map, and specify which interface to implement for which +class: + +```php +setProxyDumper(new ProxyDumper( + new MapInterfaceResolver(new BasicInterfaceResolver(), [ + ArrayObject::class => ArrayAccess::class, + ]), + new FactoryRenderer(new MethodRenderer) +)); +``` + + + + + +---------- + + + + + +## Requirements + +The package requires PHP 7.1 or later. + + + + + +## Installation + +The recommended way to install this package is through [Composer](http://getcomposer.org/): + + $ composer require olvlvl/symfony-dependency-injection-proxy + + + + + +### Cloning the repository + +The package is [available on GitHub](https://github.com/olvlvl/symfony-dependency-injection-proxy), +its repository can be cloned with the following command line: + + $ git clone https://github.com/olvlvl/symfony-dependency-injection-proxy.git + + + + + +## Testing + +The test suite is ran with the `make test` command. [PHPUnit](https://phpunit.de/) and +[Composer](http://getcomposer.org/) need to be globally available to run the suite. The command +installs dependencies as required. The `make test-coverage` command runs test suite and also creates +an HTML coverage report in `build/coverage`. If your environment doesn't meet the requirements you can run the tests +with a container, run `make test-container` to create it. + +The package is continuously tested by [Travis CI](http://about.travis-ci.org/). + +[![Build Status](https://img.shields.io/travis/olvlvl/symfony-dependency-injection-proxy.svg)](http://travis-ci.org/olvlvl/symfony-dependency-injection-proxy) +[![Code Coverage](https://img.shields.io/coveralls/olvlvl/symfony-dependency-injection-proxy.svg)](https://coveralls.io/r/olvlvl/symfony-dependency-injection-proxy) + + + + + +## License + +**olvlvl/symfony-dependency-injection-proxy** is licensed under the New BSD License - See the [LICENSE](LICENSE) file for details. + + + + + + +[1]: https://symfony.com/doc/current/components/dependency_injection.html +[2]: https://github.com/olvlvl/symfony-dependency-injection-proxy/wiki/Comparing-olvlvl's-proxy-generator-with-Symphony's +[3]: https://symfony.com/doc/current/service_container/lazy_services.html diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..41f8c3e --- /dev/null +++ b/composer.json @@ -0,0 +1,28 @@ +{ + "name": "olvlvl/symfony-dependency-injection-proxy", + "description": "Generate super tiny proxies for Symfony's dependency injection", + "keywords": [ "symfony", "dependency", "injection", "proxy" ], + "type": "library", + "license": "BSD-3-Clause", + "authors": [ + { + "name": "Olivier Laviale", + "email": "olivier.laviale@gmail.com" + } + ], + "require": { + "php": ">=7.1", + "ext-json": "*", + "symfony/dependency-injection": "^4.1" + }, + "autoload": { + "psr-4": { + "olvlvl\\SymfonyDependencyInjectionProxy\\": "lib" + } + }, + "autoload-dev": { + "psr-4": { + "tests\\olvlvl\\SymfonyDependencyInjectionProxy\\": "tests" + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a9b8be9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +--- +version: "2.1" +services: + app: + build: . + volumes: + - .:/app:delegated + - ~/.composer:/root/.composer:delegated diff --git a/lib/FactoryRenderer.php b/lib/FactoryRenderer.php new file mode 100644 index 0000000..154c74a --- /dev/null +++ b/lib/FactoryRenderer.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace olvlvl\SymfonyDependencyInjectionProxy; + +use ReflectionClass; +use ReflectionMethod; +use function array_map; + +class FactoryRenderer +{ + /** + * @var MethodRenderer + */ + private $methodRenderer; + + public function __construct(MethodRenderer $methodRenderer) + { + $this->methodRenderer = $methodRenderer; + } + + /** + * @throws \ReflectionException + */ + public function __invoke(string $interface, string $factoryCode): string + { + $methods = $this->renderMethods( + (new ReflectionClass($interface))->getMethods(), + '($this->service ?: $this->service = ($this->factory)())' + ); + + return <<factory = \$factory; + } + +$methods + }; +PHP; + } + + /** + * @param ReflectionMethod[] $methods + */ + private function renderMethods(array $methods, string $getterCode) + { + $renderMethod = $this->methodRenderer; + + return implode("\n", array_map(function (ReflectionMethod $method) use ($renderMethod, $getterCode) { + return $renderMethod($method, $getterCode); + }, $methods)); + } +} diff --git a/lib/InterfaceResolver.php b/lib/InterfaceResolver.php new file mode 100644 index 0000000..3bbbea7 --- /dev/null +++ b/lib/InterfaceResolver.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace olvlvl\SymfonyDependencyInjectionProxy; + +interface InterfaceResolver +{ + /** + * Given a class, resolve the interface to use to create its proxy. + * + * @throws \Exception if the interface cannot be resolved. + */ + public function resolveInterface(string $class): string; +} diff --git a/lib/InterfaceResolver/BasicInterfaceResolver.php b/lib/InterfaceResolver/BasicInterfaceResolver.php new file mode 100644 index 0000000..f1734e4 --- /dev/null +++ b/lib/InterfaceResolver/BasicInterfaceResolver.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace olvlvl\SymfonyDependencyInjectionProxy\InterfaceResolver; + +use LogicException; +use olvlvl\SymfonyDependencyInjectionProxy\InterfaceResolver; +use function class_exists; +use function class_implements; +use function count; +use function implode; +use function interface_exists; +use function reset; + +final class BasicInterfaceResolver implements InterfaceResolver +{ + /** + * @inheritdoc + */ + public function resolveInterface(string $class): string + { + if (interface_exists($class)) { + return $class; + } + + if (class_exists($class)) { + $interfaces = class_implements($class); + + if (count($interfaces) > 1) { + $interfaces = implode(', ', $interfaces); + throw new LogicException("Don't know which interface to choose from for $class: $interfaces."); + } + + return reset($interfaces); + } + + throw new LogicException("Unable to determine the interface to implement for $class."); + } +} diff --git a/lib/InterfaceResolver/MapInterfaceResolver.php b/lib/InterfaceResolver/MapInterfaceResolver.php new file mode 100644 index 0000000..98a9321 --- /dev/null +++ b/lib/InterfaceResolver/MapInterfaceResolver.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace olvlvl\SymfonyDependencyInjectionProxy\InterfaceResolver; + +use olvlvl\SymfonyDependencyInjectionProxy\InterfaceResolver; + +final class MapInterfaceResolver implements InterfaceResolver +{ + /** + * @var InterfaceResolver + */ + private $next; + + /** + * @var array + */ + private $map; + + public function __construct(InterfaceResolver $next, array $map) + { + $this->next = $next; + $this->map = $map; + } + + /** + * @inheritdoc + */ + public function resolveInterface(string $class): string + { + if (isset($this->map[$class])) { + return $this->map[$class]; + } + + return $this->next->resolveInterface($class); + } +} diff --git a/lib/MethodRenderer.php b/lib/MethodRenderer.php new file mode 100644 index 0000000..5e9dc65 --- /dev/null +++ b/lib/MethodRenderer.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace olvlvl\SymfonyDependencyInjectionProxy; + +use ReflectionMethod; +use ReflectionParameter; +use function implode; +use function json_encode; +use ReflectionType; + +class MethodRenderer +{ + public function __invoke(ReflectionMethod $method, string $getterCode): string + { + $signature = $this->renderMethodSignature($method); + $call = $this->renderCall($method); + $mayReturn = ($method->hasReturnType() && $method->getReturnType()->getName() === 'void') ? '' : 'return '; + + return <<$call; + } +PHP; + } + + private function renderMethodSignature(ReflectionMethod $method): string + { + $qualifiers = []; + + if ($method->isPublic()) { + $qualifiers[] = 'public'; + } + + if ($method->isStatic()) { + $qualifiers[] = 'static'; + } + + $return = ''; + + if ($method->hasReturnType()) { + $type = $method->getReturnType(); + $return = ': ' . $this->renderType($type); + } + + $params = []; + + foreach ($method->getParameters() as $parameter) { + $params[] = $this->renderParameter($parameter); + } + + return implode(' ', $qualifiers) . " function {$method->getName()}(" . implode(', ', $params) . ")$return"; + } + + private function renderParameter(ReflectionParameter $parameter): string + { + $code = ''; + + if ($parameter->hasType()) { + $code = $this->renderType($parameter->getType()) . ' '; + } + + $code .= '$' . $parameter->getName(); + + if ($parameter->isOptional()) { + $code .= " = " . json_encode($parameter->getDefaultValue()); + } + + return $code; + } + + private function renderCall(ReflectionMethod $method): string + { + $parameters = implode(', ', array_map(function (ReflectionParameter $parameter) { + return '$' . $parameter->getName(); + }, $method->getParameters())); + + return $method->getName() . "($parameters)"; + } + + private function renderType(ReflectionType $type): string + { + return ($type->allowsNull() ? '?' : '') . ($type->isBuiltin() ? '' : '\\') . $type->getName(); + } +} diff --git a/lib/ProxyDumper.php b/lib/ProxyDumper.php new file mode 100644 index 0000000..a7cb07c --- /dev/null +++ b/lib/ProxyDumper.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace olvlvl\SymfonyDependencyInjectionProxy; + +use InvalidArgumentException; +use function method_exists; +use function sprintf; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\LazyProxy\PhpDumper\DumperInterface; +use function class_exists; +use function ltrim; + +final class ProxyDumper implements DumperInterface +{ + /** + * @var InterfaceResolver + */ + private $interfaceResolver; + + /** + * @var FactoryRenderer + */ + private $factoryRenderer; + + public function __construct(InterfaceResolver $interfaceResolver, FactoryRenderer $factoryRenderer) + { + $this->interfaceResolver = $interfaceResolver; + $this->factoryRenderer = $factoryRenderer; + } + + /** + * @inheritdoc + */ + public function isProxyCandidate(Definition $definition) + { + return $definition->isLazy() && ($definition->getFactory() || class_exists($definition->getClass())); + } + + /** + * @inheritdoc + * @throws \Exception + */ + public function getProxyFactoryCode(Definition $definition, $id, $factoryCode) + { + if (!$factoryCode) { + throw new InvalidArgumentException("Missing factory code to construct the service `$id`."); + } + + $store = ''; + + if ($definition->isShared()) { + $store = sprintf( + '$this->%s[\'%s\'] = ', + $definition->isPublic() && !$definition->isPrivate() ? 'services' : 'privates', + $id + ); + } + + $interface = $this->findInterface($definition); + $proxy = ltrim($this->renderFactory($interface, $factoryCode)); + + return <<interfaceResolver->resolveInterface($definition->getClass()); + } + + private function renderFactory(string $interface, string $factoryCode): string + { + return ($this->factoryRenderer)($interface, $factoryCode); + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..46892a9 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,24 @@ + + + + + + ./tests + + + + + + ./lib + + + diff --git a/tests/FactoryRendererTest.php b/tests/FactoryRendererTest.php new file mode 100644 index 0000000..a0019ec --- /dev/null +++ b/tests/FactoryRendererTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\SymfonyDependencyInjectionProxy; + +use olvlvl\SymfonyDependencyInjectionProxy\FactoryRenderer; +use olvlvl\SymfonyDependencyInjectionProxy\MethodRenderer; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use ReflectionMethod; +use Serializable; + +/** + * @group unit + */ +class FactoryRendererTest extends TestCase +{ + /** + * @throws \ReflectionException + */ + public function testRender() + { + $interface = Serializable::class; + $factoryCode = 'someFactoryCode'; + $methodRenderer = $this->prophesize(MethodRenderer::class); + $methodRenderer->__invoke( + Argument::type(ReflectionMethod::class), + '($this->service ?: $this->service = ($this->factory)())' + )->will(function (array $args) { + return ' codeFor:' . $args[0]->getName(); + }); + + $stu = new FactoryRenderer($methodRenderer->reveal()); + $expected = <<factory = \$factory; + } + + codeFor:serialize + codeFor:unserialize + }; +PHP; + $this->assertEquals($expected, $stu($interface, $factoryCode)); + } +} diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php new file mode 100644 index 0000000..83feb31 --- /dev/null +++ b/tests/IntegrationTest.php @@ -0,0 +1,200 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\SymfonyDependencyInjectionProxy; + +use olvlvl\SymfonyDependencyInjectionProxy\FactoryRenderer; +use olvlvl\SymfonyDependencyInjectionProxy\InterfaceResolver\BasicInterfaceResolver; +use olvlvl\SymfonyDependencyInjectionProxy\InterfaceResolver\MapInterfaceResolver; +use olvlvl\SymfonyDependencyInjectionProxy\MethodRenderer; +use olvlvl\SymfonyDependencyInjectionProxy\ProxyDumper; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Component\DependencyInjection\Alias; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Dumper\PhpDumper; +use Symfony\Component\DependencyInjection\Reference; +use tests\olvlvl\SymfonyDependencyInjectionProxy\cases\Buildable; +use tests\olvlvl\SymfonyDependencyInjectionProxy\cases\BuildableFactory; +use tests\olvlvl\SymfonyDependencyInjectionProxy\cases\BuildableInterface; +use tests\olvlvl\SymfonyDependencyInjectionProxy\cases\Sample; +use tests\olvlvl\SymfonyDependencyInjectionProxy\cases\Sample2; +use tests\olvlvl\SymfonyDependencyInjectionProxy\cases\SampleInterface; +use tests\olvlvl\SymfonyDependencyInjectionProxy\cases\SampleInterface2; +use function uniqid; + +/** + * @group integration + */ +class IntegrationTest extends TestCase +{ + /** + * @dataProvider provideDefinition + */ + public function testCompilation(array $definitions, callable $assert, callable $tweakBuilder = null) + { + $builder = new ContainerBuilder(); + $builder->addDefinitions($definitions); + + if ($tweakBuilder) { + $tweakBuilder($builder); + } + + $builder->compile(); + + $dumper = new PhpDumper($builder); + $dumper->setProxyDumper(new ProxyDumper( + new MapInterfaceResolver(new BasicInterfaceResolver(), [ + Sample2::class => SampleInterface2::class, + ]), + new FactoryRenderer(new MethodRenderer) + )); + + $containerClass = 'Container' . uniqid(); + $containerFile = __DIR__ . "/sandbox/$containerClass.php"; + + file_put_contents($containerFile, $dumper->dump([ 'class' => $containerClass ])); + + require $containerFile; + + $assert(new $containerClass); + } + + public function provideDefinition(): array + { + $alias = 'alias-' . uniqid(); + + return [ + + "service uses a class with one interface" => [ + [ + $id = uniqid() => (new Definition) + ->setClass(Sample::class) + ->setLazy(true) + ->setPublic(true) + ->addArgument($value = uniqid()) + ], + function (ContainerInterface $container) use ($id, $value) { + /* @var SampleInterface $service */ + $service = $container->get($id); + $this->assertSame($service, $container->get($id)); + + $this->assertInstanceOf(SampleInterface::class, $service); + $this->assertNotInstanceOf(Sample::class, $service); + + $this->assertSame($value, $service->getValue()); + } + ], + + "service uses a class with many interfaces" => [ + [ + $id = uniqid() => (new Definition) + ->setClass(Sample2::class) + ->setLazy(true) + ->setPublic(true) + ->addArgument(uniqid()) + ->addArgument($value2 = uniqid()) + ], + function (ContainerInterface $container) use ($id, $value2) { + /* @var SampleInterface2 $service */ + $service = $container->get($id); + $this->assertSame($service, $container->get($id)); + + $this->assertInstanceOf(SampleInterface2::class, $service); + $this->assertNotInstanceOf(Sample2::class, $service); + + $this->assertSame($value2, $service->getValue2()); + } + ], + + "service uses a factory" => [ + [ + $id = uniqid() => (new Definition) + ->setClass(BuildableInterface::class) + ->setFactory([ + new Reference('factory'), + 'build' + ]) + ->setLazy(true) + ->setPublic(true), + + 'factory' => (new Definition) + ->setClass(BuildableFactory::class) + ->addArgument($factoryName = 'factory-' . uniqid()) + ], + function (ContainerInterface $container) use ($id, $factoryName) { + /* @var BuildableInterface $service */ + $service = $container->get($id); + $this->assertSame($service, $container->get($id)); + + $this->assertInstanceOf(BuildableInterface::class, $service); + $this->assertNotInstanceOf(Buildable::class, $service); + + $this->assertSame($factoryName, $service->getFactory()); + } + ], + + "service has an alias" => [ + [ + $id = uniqid() => (new Definition) + ->setClass(Sample::class) + ->setLazy(true) + ->setPublic(true) + ->addArgument($value = uniqid()) + ], + function (ContainerInterface $container) use ($id, $alias, $value) { + /* @var SampleInterface $service */ + $service = $container->get($alias); + $this->assertSame($service, $container->get($alias)); + $this->assertSame($service, $container->get($id)); + + $this->assertInstanceOf(SampleInterface::class, $service); + $this->assertNotInstanceOf(Sample::class, $service); + + $this->assertSame($value, $service->getValue()); + $this->assertSame($service, $container->get($id)); + }, + function (ContainerBuilder $builder) use ($id, $alias) { + $builder->addAliases([ $alias => new Alias($id, true) ]); + } + ], + + "service is private but as a public alias" => [ + [ + $id = uniqid() => (new Definition) + ->setClass(Sample::class) + ->setLazy(true) + ->setPublic(false) + ->addArgument($value = uniqid()) + ], + function (ContainerInterface $container) use ($alias, $value) { + /* @var SampleInterface $service */ + $service = $container->get($alias); + $this->assertSame($service, $container->get($alias)); + + $this->assertInstanceOf(SampleInterface::class, $service); + $this->assertNotInstanceOf(Sample::class, $service); + + $this->assertSame($value, $service->getValue()); + }, + function (ContainerBuilder $builder) use ($id, $alias) { + $builder->addAliases([ + + $alias => new Alias($id, true), + + ]); + } + ], + + ]; + } +} diff --git a/tests/InterfaceResolver/BasicInterfaceResolverTest.php b/tests/InterfaceResolver/BasicInterfaceResolverTest.php new file mode 100644 index 0000000..74de933 --- /dev/null +++ b/tests/InterfaceResolver/BasicInterfaceResolverTest.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\SymfonyDependencyInjectionProxy\InterfaceResolver; + +use ArrayIterator; +use DateTimeImmutable; +use DateTimeInterface; +use olvlvl\SymfonyDependencyInjectionProxy\InterfaceResolver\BasicInterfaceResolver; +use PHPUnit\Framework\TestCase; + +/** + * @group unit + */ +class BasicInterfaceResolverTest extends TestCase +{ + /** + * @test + * @expectedException \LogicException + * @expectedExceptionMessageRegExp /Don't know which interface to choose from for ArrayIterator: Iterator,/ + * @throws \Exception + */ + public function shouldFailIfClassImplementsManyInterfaces() + { + $stu = new BasicInterfaceResolver(); + $stu->resolveInterface(ArrayIterator::class); + } + + /** + * @test + * @expectedException \LogicException + * @expectedExceptionMessage Unable to determine the interface to implement for anUndefinedClass. + * @throws \Exception + */ + public function shouldFailIfClassDoesNotExist() + { + $stu = new BasicInterfaceResolver(); + $stu->resolveInterface('anUndefinedClass'); + } + + /** + * @dataProvider provideResolveInterface + * + * @throws \Exception + */ + public function testResolveInterface(string $class, string $expected) + { + $stu = new BasicInterfaceResolver(); + $this->assertSame($expected, $stu->resolveInterface($class)); + } + + public function provideResolveInterface(): array + { + return [ + + "given an interface, should return the same interface" => [ + DateTimeInterface::class, + DateTimeInterface::class + ], + + "given an class with a single interface, should return its interface" => [ + DateTimeImmutable::class, + DateTimeInterface::class + ], + + ]; + } +} diff --git a/tests/InterfaceResolver/MapInterfaceResolverTest.php b/tests/InterfaceResolver/MapInterfaceResolverTest.php new file mode 100644 index 0000000..fe69a0a --- /dev/null +++ b/tests/InterfaceResolver/MapInterfaceResolverTest.php @@ -0,0 +1,63 @@ +assertSame($expected, $stu->resolveInterface($class)); + } + + public function provideResolveInterface(): array + { + $class = 'aClass'; + $interface = 'anInterface'; + + return [ + + "should resolve the interface using the map" => [ + $class, + $interface, + function () { + $stu = $this->prophesize(InterfaceResolver::class); + $stu->resolveInterface(Argument::any())->shouldNotBeCalled(); + return $stu->reveal(); + }, + [ $class => $interface ] + + ], + + "should resolve the interface using the next resolver" => [ + $class, + $interface, + function () use ($class, $interface) { + $stu = $this->prophesize(InterfaceResolver::class); + $stu->resolveInterface($class)->willReturn($interface); + return $stu->reveal(); + }, + [ 'anotherClass' => 'anotherInterface' ] + + ], + + ]; + } +} diff --git a/tests/MethodRendererTest.php b/tests/MethodRendererTest.php new file mode 100644 index 0000000..71cfdf5 --- /dev/null +++ b/tests/MethodRendererTest.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\SymfonyDependencyInjectionProxy; + +use olvlvl\SymfonyDependencyInjectionProxy\MethodRenderer; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; +use const PHP_VERSION_ID; +use tests\olvlvl\SymfonyDependencyInjectionProxy\cases\SampleInterfaceForMethodRenderer70; +use tests\olvlvl\SymfonyDependencyInjectionProxy\cases\SampleInterfaceForMethodRenderer72; + +/** + * @group unit + */ +class MethodRendererTest extends TestCase +{ + /** + * @dataProvider provideRender + */ + public function testRender(ReflectionMethod $method, string $getterCode, string $expected) + { + $stu = new MethodRenderer(); + + $this->assertEquals($expected, $stu($method, $getterCode)); + } + + public function provideRender(): array + { + $getterCode = "get()"; + $reflectionFor = function (string $method) { + return new ReflectionMethod(SampleInterfaceForMethodRenderer70::class, $method); + }; + $reflectionFor72 = function (string $method) { + return new ReflectionMethod(SampleInterfaceForMethodRenderer72::class, $method); + }; + + $cases = [ + + [ + $reflectionFor('aStaticMethodWithoutParametersOrReturnType'), + $getterCode, + <<aStaticMethodWithoutParametersOrReturnType(); + } +PHP + ], + + [ + $reflectionFor('aMethodWithoutParametersOrReturnType'), + $getterCode, + <<aMethodWithoutParametersOrReturnType(); + } +PHP + ], + + [ + $reflectionFor('aMethodWithoutParametersButABuiltInReturnType'), + $getterCode, + <<aMethodWithoutParametersButABuiltInReturnType(); + } +PHP + ], + + [ + $reflectionFor('aMethodWithoutParametersButABuiltInReturnTypeNullable'), + $getterCode, + <<aMethodWithoutParametersButABuiltInReturnTypeNullable(); + } +PHP + ], + + [ + $reflectionFor('aMethodWithoutParametersButANonBuiltInReturnType'), + $getterCode, + <<aMethodWithoutParametersButANonBuiltInReturnType(); + } +PHP + ], + + [ + $reflectionFor('aMethodWithoutParametersButANonBuiltInReturnTypeNullable'), + $getterCode, + <<aMethodWithoutParametersButANonBuiltInReturnTypeNullable(); + } +PHP + ], + + [ + $reflectionFor('aMethodWithParameters1'), + $getterCode, + <<aMethodWithParameters1(\$a, \$b, \$c, \$d); + } +PHP + ], + + [ + $reflectionFor('aMethodWithParameters2'), + $getterCode, + <<aMethodWithParameters2(\$a, \$b, \$c); + } +PHP + ], + + [ + $reflectionFor('aMethodWithParameters3'), + $getterCode, + <<aMethodWithParameters3(\$a, \$b, \$c); + } +PHP + ], + + ]; + + if (PHP_VERSION_ID >= 72000) { + $cases[] = [ + + $reflectionFor72('aMethodWithReturnTypeVoid'), + $getterCode, + <<aMethodWithReturnTypeVoid(\$a); + } +PHP + ]; + } + + return $cases; + } +} diff --git a/tests/ProxyDumperTest.php b/tests/ProxyDumperTest.php new file mode 100644 index 0000000..f99f74a --- /dev/null +++ b/tests/ProxyDumperTest.php @@ -0,0 +1,156 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\SymfonyDependencyInjectionProxy; + +use ArrayAccess; +use ArrayIterator; +use ArrayObject; +use olvlvl\SymfonyDependencyInjectionProxy\FactoryRenderer; +use olvlvl\SymfonyDependencyInjectionProxy\InterfaceResolver; +use olvlvl\SymfonyDependencyInjectionProxy\ProxyDumper; +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\Definition; +use function count; +use function explode; + +/** + * @group unit + */ +class ProxyDumperTest extends TestCase +{ + /** + * @dataProvider provideIsProxyCandidate + */ + public function testIsProxyCandidate(Definition $definition, bool $expected) + { + $stu = new ProxyDumper( + $this->prophesize(InterfaceResolver::class)->reveal(), + $this->prophesize(FactoryRenderer::class)->reveal() + ); + + $this->assertSame($expected, $stu->isProxyCandidate($definition)); + } + + public function provideIsProxyCandidate(): array + { + $factory = 'aFactory'; + $class = ArrayObject::class; + $interface = ArrayAccess::class; + + return [ + + [ (new Definition)->setLazy(false), false ], + [ (new Definition)->setLazy(false)->setFactory($factory), false ], + [ (new Definition)->setLazy(false)->setClass($class), false ], + [ (new Definition)->setLazy(false)->setFactory($factory)->setClass($class), false ], + [ (new Definition)->setLazy(true), false ], + [ (new Definition)->setLazy(true)->setFactory($factory), true ], + [ (new Definition)->setLazy(true)->setClass($class), true ], + [ (new Definition)->setLazy(true)->setClass($interface), false ], + [ (new Definition)->setLazy(true)->setFactory($factory)->setClass($class), true ], + [ (new Definition)->setLazy(true)->setFactory($factory)->setClass($interface), true ], + + ]; + } + + /** + * @test + * @dataProvider provideEmptyFactoryCode + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage Missing factory code to construct the service `aServiceId`. + * @throws \Exception + */ + public function shouldFailIfFactoryCodeIsEmpty($factoryCode) + { + $stu = new ProxyDumper( + $this->prophesize(InterfaceResolver::class)->reveal(), + $this->prophesize(FactoryRenderer::class)->reveal() + ); + + $stu->getProxyFactoryCode(new Definition(), 'aServiceId', $factoryCode); + } + + public function provideEmptyFactoryCode(): array + { + return [ + + [ '' ], + [ null ], + + ]; + } + + /** + * @throws \Exception + * @dataProvider provideGetProxyFactoryCode + */ + public function testGetProxyFactoryCode(string $id, bool $private, bool $shared, string $expectedStore) + { + $definition = (new Definition) + ->setClass($class = ArrayIterator::class) + ->setPrivate($private) + ->setShared($shared); + $interfaceResolver = $this->prophesize(InterfaceResolver::class); + $interfaceResolver->resolveInterface($class) + ->willReturn($interface = ArrayAccess::class); + $factoryRenderer = $this->prophesize(FactoryRenderer::class); + $factoryRenderer->__invoke($interface, $factoryCode = 'someFactoryCode') + ->willReturn($proxyFactoryCode = 'someProxyFactoryCode'); + + $stu = new ProxyDumper( + $interfaceResolver->reveal(), + $factoryRenderer->reveal() + ); + + $expected = <<assertEquals($expected, $stu->getProxyFactoryCode($definition, $id, $factoryCode)); + } + + public function provideGetProxyFactoryCode(): array + { + $id = 'aServiceId'; + $public = false; + $shared = true; + + return [ + + [ $id, $public, $shared, "\$this->services['$id'] = " ], + [ $id, !$public, $shared, "\$this->privates['$id'] = " ], + [ $id, $public, !$shared, "" ], + [ $id, !$public, !$shared, "" ], + + ]; + } + + /** + * @see https://github.com/symfony/symfony/issues/28852 + */ + public function testGetProxyCode() + { + $stu = new ProxyDumper( + $this->prophesize(InterfaceResolver::class)->reveal(), + $this->prophesize(FactoryRenderer::class)->reveal() + ); + + $proxyCode = $stu->getProxyCode(new Definition()); + + $this->assertNotEmpty($proxyCode); + $this->assertGreaterThanOrEqual(2, count(explode(' ', $proxyCode))); + } +} diff --git a/tests/cases/Buildable.php b/tests/cases/Buildable.php new file mode 100644 index 0000000..52cbab8 --- /dev/null +++ b/tests/cases/Buildable.php @@ -0,0 +1,21 @@ +factory = $factory; + } + + public function getFactory(): string + { + return $this->factory; + } +} diff --git a/tests/cases/BuildableFactory.php b/tests/cases/BuildableFactory.php new file mode 100644 index 0000000..28bb2f9 --- /dev/null +++ b/tests/cases/BuildableFactory.php @@ -0,0 +1,21 @@ +name = $name; + } + + public function build() + { + return new Buildable($this->name); + } +} diff --git a/tests/cases/BuildableInterface.php b/tests/cases/BuildableInterface.php new file mode 100644 index 0000000..b04e077 --- /dev/null +++ b/tests/cases/BuildableInterface.php @@ -0,0 +1,8 @@ +value = $value; + } + + /** + * @inheritdoc + */ + public function getValue(): string + { + return $this->value; + } +} diff --git a/tests/cases/Sample2.php b/tests/cases/Sample2.php new file mode 100644 index 0000000..8847dd5 --- /dev/null +++ b/tests/cases/Sample2.php @@ -0,0 +1,35 @@ +value = $value; + $this->value2 = $value2; + } + + /** + * @inheritdoc + */ + public function getValue(): string + { + return $this->value; + } + + public function getValue2(): string + { + return $this->value2; + } +} diff --git a/tests/cases/SampleInterface.php b/tests/cases/SampleInterface.php new file mode 100644 index 0000000..3610d0c --- /dev/null +++ b/tests/cases/SampleInterface.php @@ -0,0 +1,8 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\SymfonyDependencyInjectionProxy\cases; + +use ArrayAccess; + +interface SampleInterfaceForMethodRenderer70 +{ + const A_CONSTANT = 'aConstantValue'; + + public static function aStaticMethodWithoutParametersOrReturnType(); + + public function aMethodWithoutParametersOrReturnType(); + + public function aMethodWithoutParametersButABuiltInReturnType(): array; + + public function aMethodWithoutParametersButABuiltInReturnTypeNullable(): ?array; + + public function aMethodWithoutParametersButANonBuiltInReturnType(): ArrayAccess; + + public function aMethodWithoutParametersButANonBuiltInReturnTypeNullable(): ?ArrayAccess; + + public function aMethodWithParameters1($a, bool $b, ?int $c, $d = null); + + public function aMethodWithParameters2(ArrayAccess $a, ?ArrayAccess $b, ?ArrayAccess $c = null); + + public function aMethodWithParameters3($a = 123, $b = "abc", $c = self::A_CONSTANT); +} diff --git a/tests/cases/SampleInterfaceForMethodRenderer72.php b/tests/cases/SampleInterfaceForMethodRenderer72.php new file mode 100644 index 0000000..5e13912 --- /dev/null +++ b/tests/cases/SampleInterfaceForMethodRenderer72.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\SymfonyDependencyInjectionProxy\cases; + +interface SampleInterfaceForMethodRenderer72 +{ + public function aMethodWithReturnTypeVoid($a): void; +} diff --git a/tests/sandbox/.gitignore b/tests/sandbox/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/tests/sandbox/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore