8000 [String] add LazyString to provide generic stringable objects · symfony/symfony@04a8350 · GitHub
[go: up one dir, main page]

Skip to content

Commit 04a8350

Browse files
[String] add LazyString to provide generic stringable objects
1 parent e0f6cdb commit 04a8350

File tree

6 files changed

+301
-2
lines changed

6 files changed

+301
-2
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
111111
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
112112
use Symfony\Component\Stopwatch\Stopwatch;
113+
use Symfony\Component\String\LazyString;
113114
use Symfony\Component\String\Slugger\SluggerInterface;
114115
use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand;
115116
use Symfony\Component\Translation\Translator;
@@ -1362,9 +1363,15 @@ private function registerSecretsConfiguration(array $config, ContainerBuilder $c
13621363
throw new InvalidArgumentException(sprintf('Invalid value "%s" set as "decryption_env_var": only "word" characters are allowed.', $config['decryption_env_var']));
13631364
}
13641365

1365-
$container->getDefinition('secrets.vault')->replaceArgument(1, "%env({$config['decryption_env_var']})%");
1366+
if (interface_exists(SluggerInterface::class)) {
1367+
$container->getDefinition('secrets.decryption_key')->replaceArgument(1, $config['decryption_env_var']);
1368+
} else {
1369+
$container->getDefinition('secrets.vault')->replaceArgument(1, "%env({$config['decryption_env_var']})%");
1370+
$container->removeDefinition('secrets.decryption_key');
1371+
}
13661372
} else {
13671373
$container->getDefinition('secrets.vault')->replaceArgument(1, null);
1374+
$container->removeDefinition('secrets.decryption_key');
13681375
}
13691376
}
13701377

src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,25 @@
88
<service id="secrets.vault" class="Symfony\Bundle\FrameworkBundle\Secrets\SodiumVault">
99
<tag name="container.env_var_loader" />
1010
<argument>%kernel.project_dir%/config/secrets/%kernel.environment%</argument>
11-
<argument>%env(base64:default::SYMFONY_DECRYPTION_SECRET)%</argument>
11+
<argument type="service" id="secrets.decryption_key" on-invalid="ignore" />
12+
</service>
13+
14+
<!--
15+
LazyString::fromCallable() is used as a wrapper to lazily read the SYMFONY_DECRYPTION_SECRET var from the env.
16+
By overriding this service and using the same strategy, the decryption key can be fetched lazily from any other service if needed.
17+
-->
18+
<service id="secrets.decryption_key" class="Symfony\Component\String\LazyString">
19+
<factory class="Symfony\Component\String\LazyString" method="fromCallable" />
20+
<argument type="service">
21+
<service class="Closure">
22+
<factory class="Closure" method="fromCallable" />
23+
<argument type="collection">
24+
<argument type="service" id="service_container" />
25+
<argument>getEnv</argument>
26+
</argument>
27+
</service>
28+
</argument>
29+
<argument>base64:default::SYMFONY_DECRYPTION_SECRET</argument>
1230
</service>
1331

1432
<service id="secrets.local_vault" class="Symfony\Bundle\FrameworkBundle\Secrets\DotenvVault">

src/Symfony/Component/String/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
CHANGELOG
22
=========
33

4+
5.1.0
5+
-----
6+
7+
* added `LazyString` which provides generic stringable objects
8+
9+
-----
410
5.0.0
511
-----
612

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\String;
13+
14+
/**
15+
* A string whose value is computed lazily by a callback.
16+
*
17+
* @author Nicolas Grekas <p@tchwork.com>
18+
*/
19+
class LazyString
20+
{
21+
private $value;
22+
23+
/**
24+
* @param callable $callback A callable or a [Closure, method] lazy-callable
25+
*
26+
* @return static
27+
*/
28+
public static function fromCallable($callback, ...$arguments): self
29+
{
30+
if (!\is_callable($callback) && !(\is_array($callback) && isset($callback[0]) && $callback[0] instanceof \Closure && 2 >= \count($callback))) {
31+
throw new \TypeError(sprintf('Argument 1 passed to %s() must be a callable or a [Closure, method] lazy-callable, %s given.', __METHOD__, \gettype($callback)));
32+
}
33+
34+
$lazyString = new static();
35+
$lazyString->value = static function () use (&$callback, &$arguments, &$value): string {
36+
if (null !== $arguments) {
37+
if (!\is_callable($callback)) {
38+
$callback[0] = $callback[0]();
39+
$callback[1] = $callback[1] ?? '__invoke';
40+
}
41+
$value = $callback(...$arguments);
42+
$callback = self::getPrettyName($callback< 10000 /span>);
43+
$arguments = null;
44+
}
45+
46+
return $value ?? '';
47+
};
48+
49+
return $lazyString;
50+
}
51+
52+
/**
53+
* @param object|string|int|float|bool $value A scalar or an object that implements the __toString() magic method
54+
*
55+
* @return static
56+
*/
57+
public static function fromStringable($value): self
58+
{
59+
if (!self::isStringable($value)) {
60+
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)));
61+
}
62+
63+
if (\is_object($value)) {
64+
return static::fromCallable([$value, '__toString']);
65+
}
66+
67+
$lazyString = new static();
68+
$lazyString->value = (string) $value;
69+
70+
return $lazyString;
71+
}
72+
73+
/**
74+
* Tells whether the provided value can be cast to string.
75+
*/
76+
final public static function isStringable($value): bool
77+
{
78+
return \is_string($value) || $value instanceof self || (\is_object($value) ? \is_callable([$value, '__toString']) : is_scalar($value));
79+
}
80+
81+
/**
82+
* Casts scalars and stringable objects to strings.
83+
*
84+
* @param object|string|int|float|bool $value
85+
*
86+
* @throws \TypeError When the provided value is not stringable
87+
*/
88+
final public static function resolve($value): string
89+
{
90+
return $value;
91+
}
92+
93+
public function __toString()
94+
{
95+
if (\is_string($this->value)) {
96+
return $this->value;
97+
}
98+
99+
try {
100+
return $this->value = ($this->value)();
101+
} catch (\Throwable $e) {
102+
if (\TypeError::class === \get_class($e) && __FILE__ === $e->getFile()) {
103+
$type = explode(', ', $e->getMessage());
104+
$type = substr(array_pop($type), 0, -\strlen(' returned'));
105+
$r = new \ReflectionFunction($this->value);
106+
$callback = $r->getStaticVariables()['callback'];
107+
108+
$e = new \TypeError(sprintf('Return value of %s() passed to %s::fromCallable() must be of the type string, %s returned.', $callback, static::class, $type));
109+
}
110+
111+
if (\PHP_VERSION_ID < 70400) {
112+
// leverage the ErrorHandler component with graceful fallback when it's not available
113+
return trigger_error($e, E_USER_ERROR);
114+
}
115+
116+
throw $e;
117+
}
118+
}
119+
120+
private function __construct()
121+
{
122+
}
123+
124+
private static function getPrettyName(callable $callback): string
125+
{
126+
if (\is_string($callback)) {
127+
return $callback;
128+
}
129+
130+
if (\is_array($callback)) {
131+
$class = \is_object($callback[0]) ? \get_class($callback[0]) : $callback[0];
132+
$method = $callback[1];
133+
} elseif ($callback instanceof \Closure) {
134+
$r = new \ReflectionFunction($callback);
135+
136+
if (false !== strpos($r->name, '{closure}') || !$class = $r->getClosureScopeClass()) {
137+
return $r->name;
138+
}
139+
140+
$class = $class->name;
141+
$method = $r->name;
142+
} else {
143+
$class = \get_class($callback);
144+
$method = '__invoke';
145+
}
146+
147+
if (isset($class[15]) && "\0" === $class[15] && 0 === strpos($class, "class@anonymous\x00")) {
148+
$class = get_parent_class($class).'@anonymous';
149+
}
150+
151+
return $class.'::'.$method;
152+
}
153+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\String\Tests;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\ErrorHandler\ErrorHandler;
16+
use Symfony\Component\String\LazyString;
17+
18+
class LazyStringTest extends TestCase
19+
{
20+
public function testLazyString()
21+
{
22+
$count = 0;
23+
$s = LazyString::fromCallable(function () use (&$count) {
24+
return ++$count;
25+
});
26+
27+
$this->assertSame(0, $count);
28+
$this->assertSame('1', (string) $s);
29+
$this->assertSame(1, $count);
30+
}
31+
32+
public function testLazyCallable()
33+
{
34+
$count = 0;
35+
$s = LazyString::fromCallable([function () use (&$count) {
36+
return new class($count) {
37+
private $count;
38+
39+
public function __construct(int &$count)
40+
{
41+
$this->count = &$count;
42+
}
43+
44+
public function __invoke()
45+
{
46+
return ++$this->count;
47+
}
48+
};
49+
}]);
50+
51+
$this->assertSame(0, $count);
52+
$this->assertSame('1', (string) $s);
53+
$this->assertSame(1, $count);
54+
$this->assertSame('1', (string) $s); // ensure the value is memoized
55+
$this->assertSame(1, $count);
56+
}
57+
58+
/**
59+
* @runInSeparateProcess
60+
*/
61+
public function testReturnTypeError()
62+
{
63+
ErrorHandler::register();
64+
65+
$s = LazyString::fromCallable(function () { return []; });
66+
67+
$this->expectException(\TypeError::class);
68+
$this->expectExceptionMessage('Return value of '.__NAMESPACE__.'\{closure}() passed to '.LazyString::class.'::fromCallable() must be of the type string, array returned.');
69+
70+
(string) $s;
71+
}
72+
73+
public function testFromStringable()
74+
{
75+
$this->assertInstanceOf(LazyString::class, LazyString::fromStringable('abc'));
76+
$this->assertSame('abc', (string) LazyString::fromStringable('abc'));
77+
$this->assertSame('1', (string) LazyString::fromStringable(true));
78+
$this->assertSame('', (string) LazyString::fromStringable(false));
79+
$this->assertSame('123', (string) LazyString::fromStringable(123));
80+
$this->assertSame('123.456', (string) LazyString::fromStringable(123.456));
81+
$this->assertStringContainsString('hello', (string) LazyString::fromStringable(new \Exception('hello')));
82+
}
83+
84+
public function testResolve()
85+
{
86+
$this->assertSame('abc', LazyString::resolve('abc'));
87+
$this->assertSame('1', LazyString::resolve(true));
88+
$this->assertSame('', LazyString::resolve(false));
89+
$this->assertSame('123', LazyString::resolve(123));
90+
$this->assertSame('123.456', LazyString::resolve(123.456));
91+
$this->assertStringContainsString('hello', LazyString::resolve(new \Exception('hello')));
92+
}
93+
94+
public function testIsStringable()
95+
{
96+
$this->assertTrue(LazyString::isStringable('abc'));
97+
$this->assertTrue(LazyString::isStringable(true));
98+
$this->assertTrue(LazyString::isStringable(false));
99+
$this->assertTrue(LazyString::isStringable(123));
100+
$this->assertTrue(LazyString::isStringable(123.456));
101+
$this->assertTrue(LazyString::isStringable(new \Exception('hello')));
102+
}
103+
104+
public function testIsNotStringable()
105+
{
106+
$this->assertFalse(LazyString::isStringable(null));
107+
$this->assertFalse(LazyString::isStringable([]));
108+
$this->assertFalse(LazyString::isStringable(STDIN));
109+
$this->assertFalse(LazyString::isStringable(new \StdClass()));
110+
$this->assertFalse(LazyString::isStringable(@eval('return new class() {private function __toString() {}};')));
111+
}
112+
}

src/Symfony/Component/String/composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
"symfony/polyfill-mbstring": "~1.0",
2323
"symfony/translation-contracts": "^1.1|^2"
2424
},
25+
"require-dev": {
26+
"symfony/error-handler": "^4.4|^5.0"
27+
},
2528
"autoload": {
2629
"psr-4": { "Symfony\\Component\\String\\": "" },
2730
"files": [ "Resources/functions.php" ],

0 commit comments

Comments
 (0)
0