feature #34298 [String] add LazyString to provide memoizing stringable objects (nicolas-grekas)
This PR was merged into the 5.1-dev branch.
Discussion
----------
[String] add LazyString to provide memoizing stringable objects
| Q | A
| ------------- | ---
| Branch? | master
| Bug fix? | no
| New feature? | yes
| Deprecations? | no
| Tickets | -
| License | MIT
| Doc PR | -
Replaces #34190
The proposed `LazyString` class is a value object that can be used in type declarations of our libraries/apps.
Right now, when a method accepts or returns "strings|stringable-objects", the most accurate type declaration one can use is `string|object` (either in docblocks or in union types in PHP 8). The goal of `LazyString` is to allow one to use `string|LazyString` instead and gain type-accuracy, thus type-safety while doing so.
Another defining property of the proposed class is also that it memoizes the computed string value so that the computation happens only once.
Two factories are provided to create a `LazyString` instance:
- `LazyString::fromStringable($value): self` -> turns any object with `__toString()` into a `LazyString`
- `LazyString::fromCallable($callback, ...$arguments): self` -> delegates the computation of the string value to a callback (optionally calling it with arguments).
Two generic helpers are also provided to help deal with stringables:
- `LazyString::isStringable($value): bool` -> checks whether a value can be safely cast to string, considering `__toString()` too. This replaces the boilerplate we all have to write currently (`is_string($value) || is_scalar($value) || is_callable([$value, '__toString'])`)
- `LazyString::resolve($value): string` -> casts a stringable value into a string. This is similar to the casting `(string)` operator or to `strval()`, but it throws a `TypeError` instead of a PHP notice when a non stringable is passed. This helps e.g. with code that enabled strict types and want to maintain compatibility with stringable objects.
An additional feature of `LazyString` instances is that they allow exceptions thrown from the wrapped `__toString()` methods or callbacks to be propagated. This requires having the `ErrorHandler` class from the `Debug` or `ErrorHandler` components registered as a PHP error handler (already the case for any Symfony apps by default). As a reminder, throwing from `__toString()` is not possible natively before PHP 7.4.
Commits
-------
4bb19c62e2
[String] add LazyString to provide generic stringable objects
This commit is contained in:
commit
fafe576b9d
@ -112,6 +112,7 @@ use Symfony\Component\Serializer\Encoder\EncoderInterface;
|
||||
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 @@ class FrameworkExtension extends Extension
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -8,6 +8,10 @@
|
||||
<service id="secrets.vault" class="Symfony\Bundle\FrameworkBundle\Secrets\SodiumVault">
|
||||
<tag name="container.env_var_loader" />
|
||||
<argument />
|
||||
<argument type="service" id="secrets.decryption_key" on-invalid="ignore" />
|
||||
</service>
|
||||
|
||||
<service id="secrets.decryption_key" parent="getenv">
|
||||
<argument />
|
||||
</service>
|
||||
|
||||
|
@ -129,5 +129,19 @@
|
||||
<tag name="kernel.locale_aware" />
|
||||
</service>
|
||||
<service id="Symfony\Component\String\Slugger\SluggerInterface" alias="slugger" />
|
||||
|
||||
<!-- inherit from this service to lazily access env vars -->
|
||||
<service id="getenv" class="Symfony\Component\String\LazyString" abstract="true">
|
||||
<factory class="Symfony\Component\String\LazyString" method="fromCallable" />
|
||||
<argument type="service">
|
||||
<service class="Closure">
|
||||
<factory class="Closure" method="fromCallable" />
|
||||
<argument type="collection">
|
||||
<argument type="service" id="service_container" />
|
||||
<argument>getEnv</argument>
|
||||
</argument>
|
||||
</service>
|
||||
</argument>
|
||||
</service>
|
||||
</services>
|
||||
</container>
|
||||
|
@ -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
|
||||
-----
|
||||
|
165
src/Symfony/Component/String/LazyString.php
Normal file
165
src/Symfony/Component/String/LazyString.php
Normal file
@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* 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 <p@tchwork.com>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
112
src/Symfony/Component/String/Tests/LazyStringTest.php
Normal file
112
src/Symfony/Component/String/Tests/LazyStringTest.php
Normal file
@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* 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() {}};')));
|
||||
}
|
||||
}
|
@ -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"
|
||||
},
|
||||
|
Reference in New Issue
Block a user