Skip to content

Commit 4e9cf33

Browse files
committed
feat: add optional merge directives for registrars
typos
1 parent 2af3cbd commit 4e9cf33

10 files changed

Lines changed: 978 additions & 0 deletions

File tree

system/Config/BaseConfig.php

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use CodeIgniter\Autoloader\FileLocatorInterface;
1515
use CodeIgniter\Exceptions\ConfigException;
16+
use CodeIgniter\Exceptions\InvalidArgumentException;
1617
use CodeIgniter\Exceptions\RuntimeException;
1718
use Config\Encryption;
1819
use Config\Modules;
@@ -311,6 +312,14 @@ protected function registerProperties()
311312
}
312313

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

system/Config/Merge.php

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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 mixed $value Any value for REPLACE; array for the list strategies and BY_KEY.
59+
* @param mixed $anchor The element BEFORE/AFTER position against (matched strictly).
60+
*/
61+
private function __construct(
62+
public string $strategy,
63+
public mixed $value,
64+
public mixed $anchor = null,
65+
) {
66+
}
67+
68+
/**
69+
* Replace the existing value entirely (terminal: the payload is used
70+
* verbatim). Accepts any type, so it works for scalars too:
71+
* Merge::replace(false), Merge::replace('driver'), Merge::replace(null),
72+
* or arrays (e.g. ['a','b'] + ['c'] => ['c']).
73+
*/
74+
public static function replace(mixed $value): self
75+
{
76+
return new self(self::REPLACE, $value);
77+
}
78+
79+
/**
80+
* Append absent list items to the end of the existing value
81+
* (e.g. ['a','b'] + ['b','c'] => ['a','b','c']). Values already present are
82+
* left where they are. The payload is literal - for nested control, use
83+
* byKey() rather than nesting directives in an append() payload. List keys
84+
* are not preserved: the value is treated as a list.
85+
*
86+
* @param list<mixed> $value
87+
*/
88+
public static function append(array $value): self
89+
{
90+
return new self(self::APPEND, $value);
91+
}
92+
93+
/**
94+
* Prepend absent list items to the front of the existing value
95+
* (e.g. ['a','b'] + ['c'] => ['c','a','b']). Values already present are left
96+
* where they are. List keys are not preserved: the value is treated as a list.
97+
*
98+
* @param list<mixed> $value
99+
*/
100+
public static function prepend(array $value): self
101+
{
102+
return new self(self::PREPEND, $value);
103+
}
104+
105+
/**
106+
* Insert list items immediately before the first element equal (===) to
107+
* $anchor. An already-present value is moved to this position. If the anchor
108+
* is not in the list this falls back to prepend() and does not relocate
109+
* already-present values. List keys are not preserved.
110+
*
111+
* @param list<mixed> $value
112+
*
113+
* @throws InvalidArgumentException if $anchor is also one of the inserted values.
114+
*/
115+
public static function before(mixed $anchor, array $value): self
116+
{
117+
self::assertAnchorNotInPayload($anchor, $value, self::BEFORE);
118+
119+
return new self(self::BEFORE, $value, $anchor);
120+
}
121+
122+
/**
123+
* Insert list items immediately after the first element equal (===) to
124+
* $anchor. An already-present value is moved to this position. If the anchor
125+
* is not in the list this falls back to append() and does not relocate
126+
* already-present values. List keys are not preserved.
127+
*
128+
* @param list<mixed> $value
129+
*
130+
* @throws InvalidArgumentException if $anchor is also one of the inserted values.
131+
*/
132+
public static function after(mixed $anchor, array $value): self
133+
{
134+
self::assertAnchorNotInPayload($anchor, $value, self::AFTER);
135+
136+
return new self(self::AFTER, $value, $anchor);
137+
}
138+
139+
/**
140+
* Guards against anchoring a before()/after() insert on a value that is also
141+
* being inserted. That request is contradictory - the anchor would be removed
142+
* by de-duplication before it could be located - so it is rejected outright.
143+
*
144+
* @param list<mixed> $value
145+
*/
146+
private static function assertAnchorNotInPayload(mixed $anchor, array $value, string $strategy): void
147+
{
148+
if (in_array($anchor, $value, true)) {
149+
throw new InvalidArgumentException(
150+
'Merge::' . $strategy . '() cannot use a value that is also being inserted as its anchor.',
151+
);
152+
}
153+
}
154+
155+
/**
156+
* Deep-merge into the existing value by key: associative (string) keys are
157+
* merged/recursed, list (integer) keys append, scalar leaves are replaced.
158+
* Nested Merge directives ARE honored within the payload. Named byKey() to
159+
* distance it from PHP's array_merge_recursive(), which collects scalars
160+
* into arrays.
161+
*
162+
* @param array<array-key, mixed> $value
163+
*/
164+
public static function byKey(array $value): self
165+
{
166+
return new self(self::BY_KEY, $value);
167+
}
168+
}
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)