Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[String] add LazyString to provide memoizing stringable objects #34298

Merged
merged 1 commit into from Feb 3, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}
}

Expand Down
Expand Up @@ -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>

Expand Down
14 changes: 14 additions & 0 deletions src/Symfony/Bundle/FrameworkBundle/Resources/config/services.xml
Expand Up @@ -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>
5 changes: 3 additions & 2 deletions src/Symfony/Component/String/CHANGELOG.md
Expand Up @@ -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
-----
Expand Down
165 changes: 165 additions & 0 deletions src/Symfony/Component/String/LazyString.php
@@ -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
lyrixx marked this conversation as resolved.
Show resolved Hide resolved
{
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 changes: 112 additions & 0 deletions src/Symfony/Component/String/Tests/LazyStringTest.php
@@ -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() {}};')));
}
}
1 change: 1 addition & 0 deletions src/Symfony/Component/String/composer.json
Expand Up @@ -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"
},
Expand Down