diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 9a36d8e60353..8536868bec66 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -112,6 +112,7 @@ use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Stopwatch\Stopwatch; +use Symfony\Component\String\LazyString; use Symfony\Component\String\Slugger\SluggerInterface; use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand; use Symfony\Component\Translation\Translator; @@ -1390,9 +1391,15 @@ private function registerSecretsConfiguration(array $config, ContainerBuilder $c throw new InvalidArgumentException(sprintf('Invalid value "%s" set as "decryption_env_var": only "word" characters are allowed.', $config['decryption_env_var'])); } - $container->getDefinition('secrets.vault')->replaceArgument(1, "%env({$config['decryption_env_var']})%"); + if (class_exists(LazyString::class)) { + $container->getDefinition('secrets.decryption_key')->replaceArgument(1, $config['decryption_env_var']); + } else { + $container->getDefinition('secrets.vault')->replaceArgument(1, "%env({$config['decryption_env_var']})%"); + $container->removeDefinition('secrets.decryption_key'); + } } else { $container->getDefinition('secrets.vault')->replaceArgument(1, null); + $container->removeDefinition('secrets.decryption_key'); } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml index 65fd1073fd46..15dbabd437c0 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml @@ -8,6 +8,10 @@ + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.xml index c802f9c4fc2e..3c15f10abb8b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.xml @@ -129,5 +129,19 @@ + + + + + + + + + + getEnv + + + + diff --git a/src/Symfony/Component/String/CHANGELOG.md b/src/Symfony/Component/String/CHANGELOG.md index 819b6ef59558..e591d83c34e9 100644 --- a/src/Symfony/Component/String/CHANGELOG.md +++ b/src/Symfony/Component/String/CHANGELOG.md @@ -4,8 +4,9 @@ CHANGELOG 5.1.0 ----- - * Added the `AbstractString::reverse()` method. - * Made `AbstractString::width()` follow POSIX.1-2001. + * added the `AbstractString::reverse()` method + * made `AbstractString::width()` follow POSIX.1-2001 + * added `LazyString` which provides memoizing stringable objects 5.0.0 ----- diff --git a/src/Symfony/Component/String/LazyString.php b/src/Symfony/Component/String/LazyString.php new file mode 100644 index 000000000000..bb55baefe17a --- /dev/null +++ b/src/Symfony/Component/String/LazyString.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 Symfony\Component\String; + +/** + * A string whose value is computed lazily by a callback. + * + * @author Nicolas Grekas + */ +class LazyString implements \JsonSerializable +{ + private $value; + + /** + * @param callable|array $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; + } + + /** + * @param object|string|int|float|bool $value A scalar or an object that implements the __toString() magic method + * + * @return static + */ + public static function fromStringable($value): self + { + if (!self::isStringable($value)) { + throw new \TypeError(sprintf('Argument 1 passed to %s() must be a scalar or an object that implements the __toString() magic method, %s given.', __METHOD__, \is_object($value) ? \get_class($value) : \gettype($value))); + } + + if (\is_object($value)) { + return static::fromCallable([$value, '__toString']); + } + + $lazyString = new static(); + $lazyString->value = (string) $value; + + return $lazyString; + } + + /** + * Tells whether the provided value can be cast to string. + */ + final public static function isStringable($value): bool + { + return \is_string($value) || $value instanceof self || (\is_object($value) ? \is_callable([$value, '__toString']) : is_scalar($value)); + } + + /** + * Casts scalars and stringable objects to strings. + * + * @param object|string|int|float|bool $value + * + * @throws \TypeError When the provided value is not stringable + */ + final public static function resolve($value): string + { + return $value; + } + + 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; + } + } + + public function __sleep(): array + { + $this->__toString(); + + return ['value']; + } + + public function jsonSerialize(): string + { + return $this->__toString(); + } + + 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) ?: key(class_implements($class))).'@anonymous'; + } + + return $class.'::'.$method; + } +} diff --git a/src/Symfony/Component/String/Tests/LazyStringTest.php b/src/Symfony/Component/String/Tests/LazyStringTest.php new file mode 100644 index 000000000000..ad21bc84329e --- /dev/null +++ b/src/Symfony/Component/String/Tests/LazyStringTest.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\String\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\ErrorHandler\ErrorHandler; +use Symfony\Component\String\LazyString; + +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; + } + + public function testFromStringable() + { + $this->assertInstanceOf(LazyString::class, LazyString::fromStringable('abc')); + $this->assertSame('abc', (string) LazyString::fromStringable('abc')); + $this->assertSame('1', (string) LazyString::fromStringable(true)); + $this->assertSame('', (string) LazyString::fromStringable(false)); + $this->assertSame('123', (string) LazyString::fromStringable(123)); + $this->assertSame('123.456', (string) LazyString::fromStringable(123.456)); + $this->assertStringContainsString('hello', (string) LazyString::fromStringable(new \Exception('hello'))); + } + + public function testResolve() + { + $this->assertSame('abc', LazyString::resolve('abc')); + $this->assertSame('1', LazyString::resolve(true)); + $this->assertSame('', LazyString::resolve(false)); + $this->assertSame('123', LazyString::resolve(123)); + $this->assertSame('123.456', LazyString::resolve(123.456)); + $this->assertStringContainsString('hello', LazyString::resolve(new \Exception('hello'))); + } + + public function testIsStringable() + { + $this->assertTrue(LazyString::isStringable('abc')); + $this->assertTrue(LazyString::isStringable(true)); + $this->assertTrue(LazyString::isStringable(false)); + $this->assertTrue(LazyString::isStringable(123)); + $this->assertTrue(LazyString::isStringable(123.456)); + $this->assertTrue(LazyString::isStringable(new \Exception('hello'))); + } + + public function testIsNotStringable() + { + $this->assertFalse(LazyString::isStringable(null)); + $this->assertFalse(LazyString::isStringable([])); + $this->assertFalse(LazyString::isStringable(STDIN)); + $this->assertFalse(LazyString::isStringable(new \StdClass())); + $this->assertFalse(LazyString::isStringable(@eval('return new class() {private function __toString() {}};'))); + } +} diff --git a/src/Symfony/Component/String/composer.json b/src/Symfony/Component/String/composer.json index 470caf4e2683..94a58f6ec318 100644 --- a/src/Symfony/Component/String/composer.json +++ b/src/Symfony/Component/String/composer.json @@ -23,6 +23,7 @@ "symfony/translation-contracts": "^1.1|^2" }, "require-dev": { + "symfony/error-handler": "^4.4|^5.0", "symfony/http-client": "^4.4|^5.0", "symfony/var-exporter": "^4.4|^5.0" },