Skip to content

Commit b365090

Browse files
authored
feat: add optional merge directives for registrars (#10329)
1 parent 7b7742f commit b365090

17 files changed

Lines changed: 1204 additions & 0 deletions

File tree

system/Config/BaseConfig.php

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,14 @@ protected function registerProperties()
311311
}
312312

313313
foreach ($properties as $property => $value) {
314+
// Directives are recognized only at the property root.
315+
if ($value instanceof Merge) {
316+
$this->{$property} = $this->applyMerge($this->{$property} ?? null, $value);
317+
318+
continue;
319+
}
320+
321+
// Legacy behavior - unchanged, and on the hot path with no extra checks.
314322
if (isset($this->{$property}) && is_array($this->{$property}) && is_array($value)) {
315323
$this->{$property} = array_merge($this->{$property}, $value);
316324
} else {
@@ -319,4 +327,122 @@ protected function registerProperties()
319327
}
320328
}
321329
}
330+
331+
/**
332+
* Applies a property-root Merge directive against the current value.
333+
*
334+
* REPLACE is terminal - its payload is taken verbatim. The list strategies
335+
* (APPEND/PREPEND/BEFORE/AFTER) resolve via mergeList(). BY_KEY recurses via
336+
* mergeByKey(), honoring nested directives.
337+
*/
338+
private function applyMerge(mixed $current, Merge $directive): mixed
339+
{
340+
return match ($directive->strategy) {
341+
Merge::REPLACE => $directive->value,
342+
Merge::BY_KEY => $this->mergeByKey(is_array($current) ? $current : [], $directive->value),
343+
Merge::APPEND, Merge::PREPEND, Merge::BEFORE, Merge::AFTER => $this->mergeList(is_array($current) ? $current : [], $directive),
344+
};
345+
}
346+
347+
/**
348+
* Resolves a list directive (APPEND, PREPEND, BEFORE, AFTER) against the
349+
* current value treated as a list.
350+
*
351+
* The directives never introduce a duplicate value: the incoming payload is
352+
* de-duplicated against itself (keeping first-seen order) and values already
353+
* in the list are not added again. Duplicates that already exist in the
354+
* current list are left untouched. Then:
355+
* - APPEND/PREPEND add only the values that are absent - already-present
356+
* values are left where they are (no relocation).
357+
* - BEFORE/AFTER move an already-present value to the anchor position, but
358+
* only when the anchor exists. If the anchor is missing they fall back to
359+
* APPEND/PREPEND respectively and do not relocate already-present values.
360+
*
361+
* The anchor is matched strictly (===) against the list elements, using the
362+
* first match. Do not use a value as both the anchor and an inserted value.
363+
*
364+
* @param array<array-key, mixed> $current
365+
*
366+
* @return list<mixed>
367+
*/
368+
private function mergeList(array $current, Merge $directive): array
369+
{
370+
$current = array_values($current);
371+
372+
// De-duplicate the payload itself (strict, first-seen order) so a value
373+
// repeated within it is not inserted twice.
374+
$incoming = [];
375+
376+
foreach ($directive->value as $value) {
377+
if (! in_array($value, $incoming, true)) {
378+
$incoming[] = $value;
379+
}
380+
}
381+
382+
$anchored = $directive->strategy === Merge::BEFORE || $directive->strategy === Merge::AFTER;
383+
$anchorFound = $anchored && in_array($directive->anchor, $current, true);
384+
385+
if ($anchorFound) {
386+
// Move-to-position: pull out any present copies, then insert the
387+
// whole incoming block at the (recomputed) anchor position.
388+
$current = array_values(array_filter(
389+
$current,
390+
static fn ($value): bool => ! in_array($value, $incoming, true),
391+
));
392+
393+
$index = (int) array_search($directive->anchor, $current, true);
394+
$offset = $directive->strategy === Merge::AFTER ? $index + 1 : $index;
395+
396+
array_splice($current, $offset, 0, $incoming);
397+
398+
return $current;
399+
}
400+
401+
// APPEND/PREPEND, or BEFORE/AFTER with a missing anchor: add only the
402+
// values not already present, without relocating anything.
403+
$incoming = array_values(array_filter(
404+
$incoming,
405+
static fn ($value): bool => ! in_array($value, $current, true),
406+
));
407+
408+
return $directive->strategy === Merge::PREPEND || $directive->strategy === Merge::BEFORE
409+
? array_merge($incoming, $current)
410+
: array_merge($current, $incoming);
411+
}
412+
413+
/**
414+
* Recursive by-key merge used by Merge::byKey(): string keys recurse, integer
415+
* keys append, scalar leaves are replaced, and nested Merge directives are
416+
* honored. A missing/non-array current child uses [] as its base, so directives
417+
* in brand-new subtrees are still resolved.
418+
*
419+
* @param array<array-key, mixed> $current
420+
* @param array<array-key, mixed> $incoming
421+
*
422+
* @return array<array-key, mixed>
423+
*/
424+
private function mergeByKey(array $current, array $incoming): array
425+
{
426+
foreach ($incoming as $key => $value) {
427+
if ($value instanceof Merge) {
428+
if (is_int($key)) {
429+
// No stable current element at an appended position; resolve against null.
430+
$current[] = $this->applyMerge(null, $value);
431+
} else {
432+
$current[$key] = $this->applyMerge($current[$key] ?? null, $value);
433+
}
434+
} elseif (is_int($key)) {
435+
$current[] = $value;
436+
} elseif (is_array($value)) {
437+
$current[$key] = $this->mergeByKey(
438+
isset($current[$key]) && is_array($current[$key]) ? $current[$key] : [],
439+
$value,
440+
);
441+
} else {
442+
$current[$key] = $value;
443+
}
444+
}
445+
446+
return $current;
447+
}
322448
}

system/Config/Merge.php

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Config;
15+
16+
use CodeIgniter\Exceptions\InvalidArgumentException;
17+
18+
/**
19+
* Describes how a Registrar value should be merged into an existing
20+
* Config property. Interpreted when returned as the value of a config
21+
* property; nested directives are honored inside Merge::byKey().
22+
*
23+
* @see \CodeIgniter\Config\BaseConfig
24+
*/
25+
final readonly class Merge
26+
{
27+
/**
28+
* Discard the existing value and use the new one.
29+
*/
30+
public const REPLACE = 'replace';
31+
32+
/**
33+
* Add absent list items to the end of the existing value.
34+
*/
35+
public const APPEND = 'append';
36+
37+
/**
38+
* Add absent list items to the front of the existing value.
39+
*/
40+
public const PREPEND = 'prepend';
41+
42+
/**
43+
* Insert list items immediately before the anchor element.
44+
*/
45+
public const BEFORE = 'before';
46+
47+
/**
48+
* Insert list items immediately after the anchor element.
49+
*/
50+
public const AFTER = 'after';
51+
52+
/**
53+
* Deep-merge by key: string keys recurse, integer keys append, scalars replace.
54+
*/
55+
public const BY_KEY = 'byKey';
56+
57+
/**
58+
* @param self::AFTER|self::APPEND|self::BEFORE|self::BY_KEY|self::PREPEND|self::REPLACE $strategy
59+
* @param mixed $value Any value for REPLACE; array for the list strategies and BY_KEY.
60+
* @param mixed $anchor The element BEFORE/AFTER position against (matched strictly).
61+
*/
62+
private function __construct(
63+
public string $strategy,
64+
public mixed $value,
65+
public mixed $anchor = null,
66+
) {
67+
}
68+
69+
/**
70+
* Replace the existing value entirely (terminal: the payload is used
71+
* verbatim). Accepts any type, so it works for scalars too:
72+
* Merge::replace(false), Merge::replace('driver'), Merge::replace(null),
73+
* or arrays (e.g. ['a','b'] + ['c'] => ['c']).
74+
*/
75+
public static function replace(mixed $value): self
76+
{
77+
return new self(self::REPLACE, $value);
78+
}
79+
80+
/**
81+
* Append absent list items to the end of the existing value
82+
* (e.g. ['a','b'] + ['b','c'] => ['a','b','c']). Values already present are
83+
* left where they are. The payload is literal - for nested control, use
84+
* byKey() rather than nesting directives in an append() payload. List keys
85+
* are not preserved: the value is treated as a list.
86+
*
87+
* @param list<mixed> $value
88+
*/
89+
public static function append(array $value): self
90+
{
91+
return new self(self::APPEND, $value);
92+
}
93+
94+
/**
95+
* Prepend absent list items to the front of the existing value
96+
* (e.g. ['a','b'] + ['c'] => ['c','a','b']). Values already present are left
97+
* where they are. List keys are not preserved: the value is treated as a list.
98+
*
99+
* @param list<mixed> $value
100+
*/
101+
public static function prepend(array $value): self
102+
{
103+
return new self(self::PREPEND, $value);
104+
}
105+
106+
/**
107+
* Insert list items immediately before the first element equal (===) to
108+
* $anchor. An already-present value is moved to this position. If the anchor
109+
* is not in the list this falls back to prepend() and does not relocate
110+
* already-present values. List keys are not preserved.
111+
*
112+
* @param list<mixed> $value
113+
*
114+
* @throws InvalidArgumentException if $anchor is also one of the inserted values.
115+
*/
116+
public static function before(mixed $anchor, array $value): self
117+
{
118+
self::assertAnchorNotInPayload($anchor, $value, self::BEFORE);
119+
120+
return new self(self::BEFORE, $value, $anchor);
121+
}
122+
123+
/**
124+
* Insert list items immediately after the first element equal (===) to
125+
* $anchor. An already-present value is moved to this position. If the anchor
126+
* is not in the list this falls back to append() and does not relocate
127+
* already-present values. List keys are not preserved.
128+
*
129+
* @param list<mixed> $value
130+
*
131+
* @throws InvalidArgumentException if $anchor is also one of the inserted values.
132+
*/
133+
public static function after(mixed $anchor, array $value): self
134+
{
135+
self::assertAnchorNotInPayload($anchor, $value, self::AFTER);
136+
137+
return new self(self::AFTER, $value, $anchor);
138+
}
139+
140+
/**
141+
* Guards against anchoring a before()/after() insert on a value that is also
142+
* being inserted. That request is contradictory - the anchor would be removed
143+
* by de-duplication before it could be located - so it is rejected outright.
144+
*
145+
* @param list<mixed> $value
146+
*/
147+
private static function assertAnchorNotInPayload(mixed $anchor, array $value, string $strategy): void
148+
{
149+
if (in_array($anchor, $value, true)) {
150+
throw new InvalidArgumentException(
151+
'Merge::' . $strategy . '() cannot use a value that is also being inserted as its anchor.',
152+
);
153+
}
154+
}
155+
156+
/**
157+
* Deep-merge into the existing value by key: associative (string) keys are
158+
* merged/recursed, list (integer) keys append, scalar leaves are replaced.
159+
* Nested Merge directives ARE honored within the payload. Named byKey() to
160+
* distance it from PHP's array_merge_recursive(), which collects scalars
161+
* into arrays.
162+
*
163+
* @param array<array-key, mixed> $value
164+
*/
165+
public static function byKey(array $value): self
166+
{
167+
return new self(self::BY_KEY, $value);
168+
}
169+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace Tests\Support\Config;
15+
16+
use CodeIgniter\Config\Merge;
17+
18+
/**
19+
* Registrar exercising the ordering directives (prepend/before/after) through
20+
* the real registrar flow, including nesting inside byKey() for a Filters-style
21+
* globals list.
22+
*/
23+
class MergeOrderRegistrar
24+
{
25+
public static function MergeRegistrarConfig(): array
26+
{
27+
return [
28+
// Order a filter relative to an existing one in a nested list.
29+
'globals' => Merge::byKey([
30+
'before' => Merge::after('csrf', ['auth']),
31+
'after' => Merge::prepend(['honeypot']),
32+
]),
33+
// Property-root list ordering.
34+
'list' => Merge::before('a', ['z']),
35+
];
36+
}
37+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace Tests\Support\Config;
15+
16+
/**
17+
* Plain-array registrar (no directives) used to assert the legacy shallow
18+
* merge behavior is unchanged — nested siblings are dropped.
19+
*/
20+
class MergePlainRegistrar
21+
{
22+
public static function MergeRegistrarConfig(): array
23+
{
24+
return [
25+
'arrayNested' => [
26+
'key2' => ['val4' => 'subVal4'],
27+
],
28+
];
29+
}
30+
}

0 commit comments

Comments
 (0)