Skip to content

Commit

Permalink
[String] add LazyString to provide generic stringable objects
Browse files Browse the repository at this point in the history
  • Loading branch information
nicolas-grekas committed Feb 3, 2020
1 parent 80b003f commit 4bb19c6
Show file tree
Hide file tree
Showing 7 changed files with 307 additions and 3 deletions.
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
{
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

0 comments on commit 4bb19c6

Please sign in to comment.