Skip to content

Commit cdaabef

Browse files
committed
ParametersExtension: dynamic parameters via DynamicValue marker and dotted names WIP
setDynamicParameterNames() now accepts dotted names (e.g. 'db.password'), marking a value nested inside a parameter as dynamic; the marker literal is injected at that position and the promoted top-level key regenerates its subtree at runtime, so sibling values stay compiled in. Adds Nette\DI\DynamicValue, an inline marker usable directly in the parameters config at any depth; the extension derives its dotted name from its position. Its optional value is the default used when no runtime value is supplied. The value is excluded from serialization so a per-request value (e.g. from nette/bootstrap) cannot bust the cache.
1 parent b5c3843 commit cdaabef

4 files changed

Lines changed: 224 additions & 3 deletions

File tree

src/DI/DynamicValue.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php declare(strict_types=1);
2+
3+
/**
4+
* This file is part of the Nette Framework (https://nette.org)
5+
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6+
*/
7+
8+
namespace Nette\DI;
9+
10+
11+
/**
12+
* Marks a parameter value (at any nesting level) as dynamic, i.e. supplied to the container at
13+
* runtime instead of being compiled into it. It is addressed by the dotted path of its position
14+
* (e.g. 'db.password'). The optional value is the default used when no runtime value is supplied.
15+
*/
16+
final class DynamicValue
17+
{
18+
public function __construct(
19+
public readonly mixed $value = null,
20+
) {
21+
}
22+
23+
24+
/**
25+
* The value identifies neither the marker (its position does) nor the compiled container, so
26+
* that a per-request value cannot bust its cache. It therefore serializes as an empty object;
27+
* a caller caching by config and relying on the default must include it in its own cache key.
28+
*/
29+
public function __serialize(): array
30+
{
31+
return [];
32+
}
33+
}

src/DI/Extensions/ParametersExtension.php

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
use Nette\DI\Attributes\Hook;
1212
use Nette\DI\Compiler\DynamicParameter;
1313
use Nette\DI\ContainerBuilder;
14+
use Nette\DI\DynamicValue;
1415
use Nette\DI\Helpers;
1516
use Nette\DI\Phase;
16-
use function array_diff_key, array_fill_keys, array_keys, array_walk_recursive, implode, var_export;
17+
use Nette\Utils\Arrays;
18+
use function array_diff_key, array_fill_keys, array_filter, array_keys, array_merge, array_walk_recursive, explode, implode, in_array, is_array, str_contains, var_export;
1719

1820

1921
/**
@@ -45,10 +47,14 @@ public function __construct(
4547
public function doExpandParameters(ContainerBuilder $builder): void
4648
{
4749
$params = $this->config;
50+
$this->dynamicParams = array_merge($this->dynamicParams, self::findDynamicValues($params));
4851
foreach ($this->dynamicParams as $key) {
49-
$params[$key] = new DynamicParameter('$this->getParameter(' . var_export($key, return: true) . ')');
52+
// a dotted name marks a value nested inside a parameter as dynamic
53+
$ref = &Arrays::getRef($params, explode('.', $key));
54+
$ref = new DynamicParameter('$this->getParameter(' . var_export($key, return: true) . ')');
5055
}
5156

57+
unset($ref);
5258
$builder->parameters = Helpers::expand($params, $params, recursive: true);
5359
$this->compilerConfig = Helpers::expand($this->compilerConfig, $builder->parameters);
5460
}
@@ -81,7 +87,14 @@ public function doGenerateDynamicParameters(Nette\PhpGenerator\ClassType $class)
8187
$method = $manipulator->inheritMethod('getDynamicParameter');
8288
$method->addBody('return match($key) {');
8389
foreach ($this->collectedDynamicParams as $key => $foo) {
84-
$value = Helpers::expand($this->config[$key] ?? null, $builder->parameters);
90+
if (in_array((string) $key, $this->dynamicParams, strict: true)) {
91+
// an explicitly named parameter falls back to its (possibly nested) config default
92+
$default = Arrays::get($this->config, explode('.', (string) $key), null);
93+
$value = Helpers::expand($default instanceof DynamicValue ? $default->value : $default, $builder->parameters);
94+
} else {
95+
// a key promoted by collectDynamicParams() regenerates its already-expanded subtree
96+
$value = $builder->parameters[$key] ?? null;
97+
}
8598
try {
8699
$value = $generator->convertArguments($resolver->completeArguments(Helpers::filterArguments([$value])))[0];
87100
$method->addBody("\t? => ?,", [$key, $value]);
@@ -129,4 +142,29 @@ private function collectDynamicParams(ContainerBuilder $builder): array
129142
}
130143
return $dynamicParams;
131144
}
145+
146+
147+
/**
148+
* Finds DynamicValue markers anywhere in the tree and returns their dotted names.
149+
* @param array<string|int, mixed> $params
150+
* @param list<string> $path
151+
* @return list<string>
152+
*/
153+
private static function findDynamicValues(array $params, array $path = []): array
154+
{
155+
$names = [];
156+
foreach ($params as $key => $value) {
157+
$keyPath = [...$path, (string) $key];
158+
if ($value instanceof DynamicValue) {
159+
if (array_filter($keyPath, fn(string $k): bool => str_contains($k, '.'))) {
160+
throw new Nette\InvalidStateException("Dynamic value cannot be used under a key containing a dot ('" . implode('.', $keyPath) . "').");
161+
}
162+
$names[] = implode('.', $keyPath);
163+
} elseif (is_array($value)) {
164+
$names = array_merge($names, self::findDynamicValues($value, $keyPath));
165+
}
166+
}
167+
168+
return $names;
169+
}
132170
}

tests/Extensions/Parameters.dynamic.phpt

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,69 @@ testException('Reference as parameter', function () {
140140
}, Nette\InvalidStateException::class, 'Circular reference detected for: one, %dynamic%.');
141141

142142

143+
test('Nested dynamic parameter via dotted name', function () {
144+
$compiler = new DI\Compiler;
145+
$compiler->setDynamicParameterNames(['db.password']);
146+
$container = createContainer($compiler, '
147+
parameters:
148+
db:
149+
host: localhost
150+
password: default
151+
152+
services:
153+
one: Service(%db.password%)
154+
', ['db.password' => 'secret']);
155+
Assert::same('secret', $container->getService('one')->arg);
156+
Assert::same(['host' => 'localhost', 'password' => 'secret'], $container->getParameter('db'));
157+
});
158+
159+
160+
test('Nested dynamic parameter falls back to config value', function () {
161+
$compiler = new DI\Compiler;
162+
$compiler->setDynamicParameterNames(['db.password']);
163+
$container = createContainer($compiler, '
164+
parameters:
165+
db:
166+
host: localhost
167+
password: default
168+
');
169+
Assert::same(['host' => 'localhost', 'password' => 'default'], $container->getParameter('db'));
170+
171+
// the same class accepts a runtime value; the sibling stays baked
172+
$class = $container::class;
173+
$other = new $class(['db.password' => 'runtime']);
174+
Assert::same(['host' => 'localhost', 'password' => 'runtime'], $other->getParameter('db'));
175+
});
176+
177+
178+
test('Nested dynamic parameter without config value', function () {
179+
$compiler = new DI\Compiler;
180+
$compiler->setDynamicParameterNames(['db.password']);
181+
$container = createContainer($compiler, '
182+
parameters:
183+
db:
184+
host: localhost
185+
', ['db.password' => 'secret']);
186+
Assert::same(['host' => 'localhost', 'password' => 'secret'], $container->getParameter('db'));
187+
});
188+
189+
190+
test('Nested dynamic parameter within string expansion', function () {
191+
$compiler = new DI\Compiler;
192+
$compiler->setDynamicParameterNames(['db.password']);
193+
$container = createContainer($compiler, '
194+
parameters:
195+
db:
196+
password: default
197+
dsn: "pw=%db.password%"
198+
199+
services:
200+
one: Service(%dsn%)
201+
', ['db.password' => 'secret']);
202+
Assert::same('pw=secret', $container->getService('one')->arg);
203+
});
204+
205+
143206
testException('Circula references', function () {
144207
$compiler = new DI\Compiler;
145208
$compiler->setDynamicParameterNames(['one', 'two']);
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php declare(strict_types=1);
2+
3+
use Nette\DI;
4+
use Nette\DI\DynamicValue;
5+
use Tester\Assert;
6+
7+
8+
require __DIR__ . '/../bootstrap.php';
9+
10+
11+
class Service
12+
{
13+
public $arg;
14+
15+
16+
public function __construct($arg)
17+
{
18+
$this->arg = $arg;
19+
}
20+
}
21+
22+
test('Top-level DynamicValue with default', function () {
23+
$compiler = new DI\Compiler;
24+
$container = createContainer($compiler, [
25+
'parameters' => ['dynamic' => new DynamicValue('default')],
26+
]);
27+
Assert::same('default', $container->getParameter('dynamic'));
28+
29+
$class = $container::class;
30+
$other = new $class(['dynamic' => 'runtime']);
31+
Assert::same('runtime', $other->getParameter('dynamic'));
32+
});
33+
34+
35+
test('Top-level DynamicValue without a value', function () {
36+
$compiler = new DI\Compiler;
37+
$container = createContainer($compiler, [
38+
'parameters' => ['dynamic' => new DynamicValue],
39+
]);
40+
Assert::null($container->getParameter('dynamic'));
41+
});
42+
43+
44+
test('Nested DynamicValue is addressed by its dotted path', function () {
45+
$compiler = new DI\Compiler;
46+
$container = createContainer($compiler, [
47+
'parameters' => [
48+
'db' => [
49+
'host' => 'localhost',
50+
'password' => new DynamicValue('default'),
51+
],
52+
],
53+
], ['db.password' => 'secret']);
54+
Assert::same(['host' => 'localhost', 'password' => 'secret'], $container->getParameter('db'));
55+
56+
// the config default is used when the runtime value is missing
57+
$class = $container::class;
58+
$other = new $class;
59+
Assert::same(['host' => 'localhost', 'password' => 'default'], $other->getParameter('db'));
60+
});
61+
62+
63+
test('DynamicValue referenced from another parameter stays dynamic', function () {
64+
$compiler = new DI\Compiler;
65+
$container = createContainer($compiler, [
66+
'parameters' => [
67+
'db' => ['password' => new DynamicValue],
68+
'dsn' => 'pw=%db.password%',
69+
],
70+
'services' => ['one' => new DI\Definitions\Statement(Service::class, ['%dsn%'])],
71+
], ['db.password' => 'secret']);
72+
Assert::same('pw=secret', $container->getService('one')->arg);
73+
});
74+
75+
76+
test('DynamicValue serializes without its value', function () {
77+
Assert::same(serialize(new DynamicValue('a')), serialize(new DynamicValue('b')));
78+
Assert::same(serialize(new DynamicValue), serialize(new DynamicValue('a')));
79+
});
80+
81+
82+
testException('DynamicValue under a dotted key', function () {
83+
$compiler = new DI\Compiler;
84+
createContainer($compiler, [
85+
'parameters' => ['a.b' => ['c' => new DynamicValue]],
86+
]);
87+
}, Nette\InvalidStateException::class, "Dynamic value cannot be used under a key containing a dot ('a.b.c').");

0 commit comments

Comments
 (0)