diff --git a/src/Symfony/Component/DependencyInjection/CHANGELOG.md b/src/Symfony/Component/DependencyInjection/CHANGELOG.md index c90cfa7471..c9477b8848 100644 --- a/src/Symfony/Component/DependencyInjection/CHANGELOG.md +++ b/src/Symfony/Component/DependencyInjection/CHANGELOG.md @@ -13,6 +13,7 @@ CHANGELOG * made singly-implemented interfaces detection be scoped by file * added ability to define a static priority method for tagged service * added support for improved syntax to define method calls in Yaml + * added `LazyString` for lazy computation of string values injected into services 4.3.0 ----- diff --git a/src/Symfony/Component/DependencyInjection/LazyString.php b/src/Symfony/Component/DependencyInjection/LazyString.php new file mode 100644 index 0000000000..6a03c376b8 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/LazyString.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection; + +/** + * A string whose value is computed lazily by a callback. + * + * @author Nicolas Grekas + */ +class LazyString +{ + private $value; + + /** + * @param callable $callback A callable or a [Closure, method] lazy-callable + * + * @return static + */ + public static function fromCallable($callback, ...$arguments): self + { + if (!\is_callable($callback) && !(\is_array($callback) && isset($callback[0]) && $callback[0] instanceof \Closure && 2 >= \count($callback))) { + throw new \TypeError(sprintf('Argument 1 passed to %s() must be a callable or a [Closure, method] lazy-callable, %s given.', __METHOD__, \gettype($callback))); + } + + $lazyString = new static(); + $lazyString->value = static function () use (&$callback, &$arguments, &$value): string { + if (null !== $arguments) { + if (!\is_callable($callback)) { + $callback[0] = $callback[0](); + $callback[1] = $callback[1] ?? '__invoke'; + } + $value = $callback(...$arguments); + $callback = self::getPrettyName($callback); + $arguments = null; + } + + return $value ?? ''; + }; + + return $lazyString; + } + + public function __toString() + { + if (\is_string($this->value)) { + return $this->value; + } + + try { + return $this->value = ($this->value)(); + } catch (\Throwable $e) { + if (\TypeError::class === \get_class($e) && __FILE__ === $e->getFile()) { + $type = explode(', ', $e->getMessage()); + $type = substr(array_pop($type), 0, -\strlen(' returned')); + $r = new \ReflectionFunction($this->value); + $callback = $r->getStaticVariables()['callback']; + + $e = new \TypeError(sprintf('Return value of %s() passed to %s::fromCallable() must be of the type string, %s returned.', $callback, static::class, $type)); + } + + if (\PHP_VERSION_ID < 70400) { + // leverage the ErrorHandler component with graceful fallback when it's not available + return trigger_error($e, E_USER_ERROR); + } + + throw $e; + } + } + + private function __construct() + { + } + + private static function getPrettyName(callable $callback): string + { + if (\is_string($callback)) { + return $callback; + } + + if (\is_array($callback)) { + $class = \is_object($callback[0]) ? \get_class($callback[0]) : $callback[0]; + $method = $callback[1]; + } elseif ($callback instanceof \Closure) { + $r = new \ReflectionFunction($callback); + + if (false !== strpos($r->name, '{closure}') || !$class = $r->getClosureScopeClass()) { + return $r->name; + } + + $class = $class->name; + $method = $r->name; + } else { + $class = \get_class($callback); + $method = '__invoke'; + } + + if (isset($class[15]) && "\0" === $class[15] && 0 === strpos($class, "class@anonymous\x00")) { + $class = get_parent_class($class).'@anonymous'; + } + + return $class.'::'.$method; + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/LazyStringTest.php b/src/Symfony/Component/DependencyInjection/Tests/LazyStringTest.php new file mode 100644 index 0000000000..38899bb369 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/LazyStringTest.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\LazyString; +use Symfony\Component\ErrorHandler\ErrorHandler; + +class LazyStringTest extends TestCase +{ + public function testLazyString() + { + $count = 0; + $s = LazyString::fromCallable(function () use (&$count) { + return ++$count; + }); + + $this->assertSame(0, $count); + $this->assertSame('1', (string) $s); + $this->assertSame(1, $count); + } + + public function testLazyCallable() + { + $count = 0; + $s = LazyString::fromCallable([function () use (&$count) { + return new class($count) { + private $count; + + public function __construct(int &$count) + { + $this->count = &$count; + } + + public function __invoke() + { + return ++$this->count; + } + }; + }]); + + $this->assertSame(0, $count); + $this->assertSame('1', (string) $s); + $this->assertSame(1, $count); + $this->assertSame('1', (string) $s); // ensure the value is memoized + $this->assertSame(1, $count); + } + + /** + * @runInSeparateProcess + */ + public function testReturnTypeError() + { + ErrorHandler::register(); + + $s = LazyString::fromCallable(function () { return []; }); + + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Return value of '.__NAMESPACE__.'\{closure}() passed to '.LazyString::class.'::fromCallable() must be of the type string, array returned.'); + + (string) $s; + } +} diff --git a/src/Symfony/Component/DependencyInjection/composer.json b/src/Symfony/Component/DependencyInjection/composer.json index 5e3f2eab8c..4924d389ee 100644 --- a/src/Symfony/Component/DependencyInjection/composer.json +++ b/src/Symfony/Component/DependencyInjection/composer.json @@ -23,6 +23,7 @@ "require-dev": { "symfony/yaml": "^3.4|^4.0|^5.0", "symfony/config": "^4.3|^5.0", + "symfony/error-handler": "^4.4|^5.0", "symfony/expression-language": "^3.4|^4.0|^5.0" }, "suggest": {