From 4e9cf33d4b56e98f8f52da0677427af2f2d568fa Mon Sep 17 00:00:00 2001 From: michalsn Date: Sat, 20 Jun 2026 11:47:16 +0200 Subject: [PATCH 1/3] feat: add optional merge directives for registrars typos --- system/Config/BaseConfig.php | 125 ++++++ system/Config/Merge.php | 168 ++++++++ tests/_support/Config/MergeOrderRegistrar.php | 37 ++ tests/_support/Config/MergePlainRegistrar.php | 30 ++ tests/_support/Config/MergeRegistrar.php | 45 +++ tests/_support/Config/MergeRegistrarA.php | 31 ++ tests/_support/Config/MergeRegistrarB.php | 31 ++ tests/system/Config/BaseConfigTest.php | 87 ++++ tests/system/Config/MergeTest.php | 377 ++++++++++++++++++ .../Config/fixtures/MergeRegistrarConfig.php | 47 +++ 10 files changed, 978 insertions(+) create mode 100644 system/Config/Merge.php create mode 100644 tests/_support/Config/MergeOrderRegistrar.php create mode 100644 tests/_support/Config/MergePlainRegistrar.php create mode 100644 tests/_support/Config/MergeRegistrar.php create mode 100644 tests/_support/Config/MergeRegistrarA.php create mode 100644 tests/_support/Config/MergeRegistrarB.php create mode 100644 tests/system/Config/MergeTest.php create mode 100644 tests/system/Config/fixtures/MergeRegistrarConfig.php diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php index 42da45cdb52f..d123872537c3 100644 --- a/system/Config/BaseConfig.php +++ b/system/Config/BaseConfig.php @@ -13,6 +13,7 @@ use CodeIgniter\Autoloader\FileLocatorInterface; use CodeIgniter\Exceptions\ConfigException; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Exceptions\RuntimeException; use Config\Encryption; use Config\Modules; @@ -311,6 +312,14 @@ protected function registerProperties() } foreach ($properties as $property => $value) { + // Directives are recognized only at the property root. + if ($value instanceof Merge) { + $this->{$property} = $this->applyMerge($this->{$property} ?? null, $value); + + continue; + } + + // Legacy behavior - unchanged, and on the hot path with no extra checks. if (isset($this->{$property}) && is_array($this->{$property}) && is_array($value)) { $this->{$property} = array_merge($this->{$property}, $value); } else { @@ -319,4 +328,120 @@ protected function registerProperties() } } } + + /** + * Applies a property-root Merge directive against the current value. + * + * REPLACE is terminal - its payload is taken verbatim. The list strategies + * (APPEND/PREPEND/BEFORE/AFTER) resolve via mergeList(). BY_KEY recurses via + * mergeByKey(), honoring nested directives. + */ + private function applyMerge(mixed $current, Merge $directive): mixed + { + return match ($directive->strategy) { + Merge::REPLACE => $directive->value, + Merge::BY_KEY => $this->mergeByKey(is_array($current) ? $current : [], $directive->value), + Merge::APPEND, Merge::PREPEND, Merge::BEFORE, Merge::AFTER => $this->mergeList(is_array($current) ? $current : [], $directive), + default => throw new InvalidArgumentException('Unknown merge strategy: ' . $directive->strategy), + }; + } + + /** + * Resolves a list directive (APPEND, PREPEND, BEFORE, AFTER) against the + * current value treated as a list. + * + * The directives never introduce a duplicate value: the incoming payload is + * de-duplicated against itself (keeping first-seen order) and values already + * in the list are not added again. Duplicates that already exist in the + * current list are left untouched. Then: + * - APPEND/PREPEND add only the values that are absent - already-present + * values are left where they are (no relocation). + * - BEFORE/AFTER move an already-present value to the anchor position, but + * only when the anchor exists. If the anchor is missing they fall back to + * APPEND/PREPEND respectively and do not relocate already-present values. + * + * The anchor is matched strictly (===) against the list elements, using the + * first match. Do not use a value as both the anchor and an inserted value. + * + * @param array $current + * + * @return list + */ + private function mergeList(array $current, Merge $directive): array + { + $current = array_values($current); + + // De-duplicate the payload itself (strict, first-seen order) so a value + // repeated within it is not inserted twice. + $incoming = []; + + foreach ($directive->value as $value) { + if (! in_array($value, $incoming, true)) { + $incoming[] = $value; + } + } + + $anchored = $directive->strategy === Merge::BEFORE || $directive->strategy === Merge::AFTER; + $anchorFound = $anchored && in_array($directive->anchor, $current, true); + + if ($anchorFound) { + // Move-to-position: pull out any present copies, then insert the + // whole incoming block at the (recomputed) anchor position. + $current = array_values(array_filter( + $current, + static fn ($value): bool => ! in_array($value, $incoming, true), + )); + + $index = (int) array_search($directive->anchor, $current, true); + $offset = $directive->strategy === Merge::AFTER ? $index + 1 : $index; + + array_splice($current, $offset, 0, $incoming); + + return $current; + } + + // APPEND/PREPEND, or BEFORE/AFTER with a missing anchor: add only the + // values not already present, without relocating anything. + $incoming = array_values(array_filter( + $incoming, + static fn ($value): bool => ! in_array($value, $current, true), + )); + + return $directive->strategy === Merge::PREPEND || $directive->strategy === Merge::BEFORE + ? array_merge($incoming, $current) + : array_merge($current, $incoming); + } + + /** + * Recursive by-key merge used by Merge::byKey(): string keys recurse, integer + * keys append, scalar leaves are replaced, and nested Merge directives are + * honored. A missing/non-array current child uses [] as its base, so directives + * in brand-new subtrees are still resolved. + * + * @param array $current + * @param array $incoming + * + * @return array + */ + private function mergeByKey(array $current, array $incoming): array + { + foreach ($incoming as $key => $value) { + if ($value instanceof Merge) { + if (is_int($key)) { + // No stable current element at an appended position; resolve against null. + $current[] = $this->applyMerge(null, $value); + } else { + $current[$key] = $this->applyMerge($current[$key] ?? null, $value); + } + } elseif (is_int($key)) { + $current[] = $value; + } elseif (isset($current[$key]) && is_array($current[$key]) && is_array($value)) { + $current[$key] = $this->mergeByKey($current[$key], $value); + } else { + $current[$key] = $value; + } + } + + return $current; + } } diff --git a/system/Config/Merge.php b/system/Config/Merge.php new file mode 100644 index 000000000000..4875b133edf3 --- /dev/null +++ b/system/Config/Merge.php @@ -0,0 +1,168 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +use CodeIgniter\Exceptions\InvalidArgumentException; + +/** + * Describes how a Registrar value should be merged into an existing + * Config property. Interpreted when returned as the value of a config + * property; nested directives are honored inside Merge::byKey(). + * + * @see \CodeIgniter\Config\BaseConfig + */ +final readonly class Merge +{ + /** + * Discard the existing value and use the new one. + */ + public const REPLACE = 'replace'; + + /** + * Add absent list items to the end of the existing value. + */ + public const APPEND = 'append'; + + /** + * Add absent list items to the front of the existing value. + */ + public const PREPEND = 'prepend'; + + /** + * Insert list items immediately before the anchor element. + */ + public const BEFORE = 'before'; + + /** + * Insert list items immediately after the anchor element. + */ + public const AFTER = 'after'; + + /** + * Deep-merge by key: string keys recurse, integer keys append, scalars replace. + */ + public const BY_KEY = 'byKey'; + + /** + * @param mixed $value Any value for REPLACE; array for the list strategies and BY_KEY. + * @param mixed $anchor The element BEFORE/AFTER position against (matched strictly). + */ + private function __construct( + public string $strategy, + public mixed $value, + public mixed $anchor = null, + ) { + } + + /** + * Replace the existing value entirely (terminal: the payload is used + * verbatim). Accepts any type, so it works for scalars too: + * Merge::replace(false), Merge::replace('driver'), Merge::replace(null), + * or arrays (e.g. ['a','b'] + ['c'] => ['c']). + */ + public static function replace(mixed $value): self + { + return new self(self::REPLACE, $value); + } + + /** + * Append absent list items to the end of the existing value + * (e.g. ['a','b'] + ['b','c'] => ['a','b','c']). Values already present are + * left where they are. The payload is literal - for nested control, use + * byKey() rather than nesting directives in an append() payload. List keys + * are not preserved: the value is treated as a list. + * + * @param list $value + */ + public static function append(array $value): self + { + return new self(self::APPEND, $value); + } + + /** + * Prepend absent list items to the front of the existing value + * (e.g. ['a','b'] + ['c'] => ['c','a','b']). Values already present are left + * where they are. List keys are not preserved: the value is treated as a list. + * + * @param list $value + */ + public static function prepend(array $value): self + { + return new self(self::PREPEND, $value); + } + + /** + * Insert list items immediately before the first element equal (===) to + * $anchor. An already-present value is moved to this position. If the anchor + * is not in the list this falls back to prepend() and does not relocate + * already-present values. List keys are not preserved. + * + * @param list $value + * + * @throws InvalidArgumentException if $anchor is also one of the inserted values. + */ + public static function before(mixed $anchor, array $value): self + { + self::assertAnchorNotInPayload($anchor, $value, self::BEFORE); + + return new self(self::BEFORE, $value, $anchor); + } + + /** + * Insert list items immediately after the first element equal (===) to + * $anchor. An already-present value is moved to this position. If the anchor + * is not in the list this falls back to append() and does not relocate + * already-present values. List keys are not preserved. + * + * @param list $value + * + * @throws InvalidArgumentException if $anchor is also one of the inserted values. + */ + public static function after(mixed $anchor, array $value): self + { + self::assertAnchorNotInPayload($anchor, $value, self::AFTER); + + return new self(self::AFTER, $value, $anchor); + } + + /** + * Guards against anchoring a before()/after() insert on a value that is also + * being inserted. That request is contradictory - the anchor would be removed + * by de-duplication before it could be located - so it is rejected outright. + * + * @param list $value + */ + private static function assertAnchorNotInPayload(mixed $anchor, array $value, string $strategy): void + { + if (in_array($anchor, $value, true)) { + throw new InvalidArgumentException( + 'Merge::' . $strategy . '() cannot use a value that is also being inserted as its anchor.', + ); + } + } + + /** + * Deep-merge into the existing value by key: associative (string) keys are + * merged/recursed, list (integer) keys append, scalar leaves are replaced. + * Nested Merge directives ARE honored within the payload. Named byKey() to + * distance it from PHP's array_merge_recursive(), which collects scalars + * into arrays. + * + * @param array $value + */ + public static function byKey(array $value): self + { + return new self(self::BY_KEY, $value); + } +} diff --git a/tests/_support/Config/MergeOrderRegistrar.php b/tests/_support/Config/MergeOrderRegistrar.php new file mode 100644 index 000000000000..bfd6f1efc87c --- /dev/null +++ b/tests/_support/Config/MergeOrderRegistrar.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Config; + +use CodeIgniter\Config\Merge; + +/** + * Registrar exercising the ordering directives (prepend/before/after) through + * the real registrar flow, including nesting inside byKey() for a Filters-style + * globals list. + */ +class MergeOrderRegistrar +{ + public static function MergeRegistrarConfig(): array + { + return [ + // Order a filter relative to an existing one in a nested list. + 'globals' => Merge::byKey([ + 'before' => Merge::after('csrf', ['auth']), + 'after' => Merge::prepend(['honeypot']), + ]), + // Property-root list ordering. + 'list' => Merge::before('a', ['z']), + ]; + } +} diff --git a/tests/_support/Config/MergePlainRegistrar.php b/tests/_support/Config/MergePlainRegistrar.php new file mode 100644 index 000000000000..2f8826893276 --- /dev/null +++ b/tests/_support/Config/MergePlainRegistrar.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Config; + +/** + * Plain-array registrar (no directives) used to assert the legacy shallow + * merge behavior is unchanged — nested siblings are dropped. + */ +class MergePlainRegistrar +{ + public static function MergeRegistrarConfig(): array + { + return [ + 'arrayNested' => [ + 'key2' => ['val4' => 'subVal4'], + ], + ]; + } +} diff --git a/tests/_support/Config/MergeRegistrar.php b/tests/_support/Config/MergeRegistrar.php new file mode 100644 index 000000000000..04acf09b745c --- /dev/null +++ b/tests/_support/Config/MergeRegistrar.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Config; + +use CodeIgniter\Config\Merge; + +/** + * Registrar exercising the Merge directives against MergeRegistrarConfig. + */ +class MergeRegistrar +{ + public static function MergeRegistrarConfig(): array + { + return [ + // Example A — deep-merge a nested subtree, preserving siblings. + 'arrayNested' => Merge::byKey([ + 'key2' => ['val4' => 'subVal4'], + ]), + // Example B — append to a list nested under a string key. + 'matrix' => Merge::byKey([ + 'superadmin' => ['shippinglabel-logos.*'], + ]), + // Example C — nested directives inside byKey(). + 'globals' => Merge::byKey([ + 'before' => Merge::append(['blogFilter']), + 'after' => Merge::replace([]), + ]), + // Scalar replace. + 'handler' => Merge::replace('redis'), + // Property-root append. + 'list' => Merge::append(['c']), + ]; + } +} diff --git a/tests/_support/Config/MergeRegistrarA.php b/tests/_support/Config/MergeRegistrarA.php new file mode 100644 index 000000000000..4a808445a13a --- /dev/null +++ b/tests/_support/Config/MergeRegistrarA.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Config; + +use CodeIgniter\Config\Merge; + +/** + * First of two registrars touching the same properties, used to assert that + * accumulation/replacement follows registrar (discovery) order. + */ +class MergeRegistrarA +{ + public static function MergeRegistrarConfig(): array + { + return [ + 'list' => Merge::append(['x']), + 'handler' => Merge::replace('redis'), + ]; + } +} diff --git a/tests/_support/Config/MergeRegistrarB.php b/tests/_support/Config/MergeRegistrarB.php new file mode 100644 index 000000000000..364eb59b5a8d --- /dev/null +++ b/tests/_support/Config/MergeRegistrarB.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Config; + +use CodeIgniter\Config\Merge; + +/** + * Second of two registrars touching the same properties, used to assert that + * accumulation/replacement follows registrar (discovery) order. + */ +class MergeRegistrarB +{ + public static function MergeRegistrarConfig(): array + { + return [ + 'list' => Merge::append(['y']), + 'handler' => Merge::replace('memcached'), + ]; + } +} diff --git a/tests/system/Config/BaseConfigTest.php b/tests/system/Config/BaseConfigTest.php index 1d34ce30535a..e19c38ee64e0 100644 --- a/tests/system/Config/BaseConfigTest.php +++ b/tests/system/Config/BaseConfigTest.php @@ -19,6 +19,7 @@ use CodeIgniter\Test\CIUnitTestCase; use Config\Modules; use Encryption; +use MergeRegistrarConfig; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -27,6 +28,11 @@ use RegistrarConfig; use SimpleConfig; use Tests\Support\Config\BadRegistrar; +use Tests\Support\Config\MergeOrderRegistrar; +use Tests\Support\Config\MergePlainRegistrar; +use Tests\Support\Config\MergeRegistrar; +use Tests\Support\Config\MergeRegistrarA; +use Tests\Support\Config\MergeRegistrarB; use Tests\Support\Config\TestRegistrar; /** @@ -53,6 +59,10 @@ protected function setUp(): void require $this->fixturesFolder . '/RegistrarConfig.php'; } + if (! class_exists('MergeRegistrarConfig', false)) { + require $this->fixturesFolder . '/MergeRegistrarConfig.php'; + } + if (! class_exists('Encryption', false)) { require $this->fixturesFolder . '/Encryption.php'; } @@ -296,6 +306,83 @@ public function testRegistrars(): void $this->assertSame(['baz', 'first', 'second'], $config->bar); } + /** + * @param list $registrars + */ + private function registerMerge(MergeRegistrarConfig $config, array $registrars): void + { + $config::$registrars = $registrars; + $this->setPrivateProperty($config, 'didDiscovery', true); + $method = self::getPrivateMethodInvoker($config, 'registerProperties'); + $method(); + } + + public function testMergePlainRegistrarKeepsLegacyShallowMerge(): void + { + // BC regression: a plain-array registrar still drops nested siblings. + $config = new MergeRegistrarConfig(); + $this->registerMerge($config, [MergePlainRegistrar::class]); + + $this->assertSame([ + 'key1' => 'val1', + 'key2' => ['val4' => 'subVal4'], + ], $config->arrayNested); + } + + public function testMergeByKeyDeepMerges(): void + { + $config = new MergeRegistrarConfig(); + $this->registerMerge($config, [MergeRegistrar::class]); + + // Example A - siblings preserved. + $this->assertSame([ + 'key1' => 'val1', + 'key2' => ['val2' => 'subVal2', 'val3' => 'subVal3', 'val4' => 'subVal4'], + ], $config->arrayNested); + + // Example B - list grows. + $this->assertSame(['superadmin' => ['admin.access', 'shippinglabel-logos.*']], $config->matrix); + + // Example C - nested directives resolved. + $this->assertSame(['before' => ['csrf', 'blogFilter'], 'after' => []], $config->globals); + } + + public function testMergeAppendAndReplaceAtPropertyRoot(): void + { + $config = new MergeRegistrarConfig(); + $this->registerMerge($config, [MergeRegistrar::class]); + + $this->assertSame(['a', 'b', 'c'], $config->list); + $this->assertSame('redis', $config->handler); + } + + public function testMergeOrderingDirectivesThroughRegistrar(): void + { + $config = new MergeRegistrarConfig(); + $this->registerMerge($config, [MergeOrderRegistrar::class]); + + // Nested ordering inside byKey(): auth lands after csrf, honeypot at the front. + $this->assertSame([ + 'before' => ['csrf', 'auth'], + 'after' => ['honeypot', 'toolbar'], + ], $config->globals); + + // Property-root ordering. + $this->assertSame(['z', 'a', 'b'], $config->list); + } + + public function testMultipleRegistrarsOnSameProperty(): void + { + $config = new MergeRegistrarConfig(); + // Two registrars both append to list and both replace handler. + $this->registerMerge($config, [MergeRegistrarA::class, MergeRegistrarB::class]); + + // append() accumulates in registrar (discovery) order. + $this->assertSame(['a', 'b', 'x', 'y'], $config->list); + // competing replace() calls resolve in registrar order - last wins. + $this->assertSame('memcached', $config->handler); + } + public function testBadRegistrar(): void { // Shouldn't change any values. diff --git a/tests/system/Config/MergeTest.php b/tests/system/Config/MergeTest.php new file mode 100644 index 000000000000..c3a0e8cdcfce --- /dev/null +++ b/tests/system/Config/MergeTest.php @@ -0,0 +1,377 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +use Closure; +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; +use ReflectionClass; + +/** + * Exercises the merge engine (applyMerge()/mergeByKey()) directly, independent + * of registrar discovery. + * + * @internal + */ +#[Group('Others')] +final class MergeTest extends CIUnitTestCase +{ + /** + * @var Closure(mixed, Merge): mixed + */ + private Closure $applyMerge; + + protected function setUp(): void + { + parent::setUp(); + + // Build the config without running the constructor so registrar + // discovery is never triggered for these pure engine tests. + $config = (new ReflectionClass(BaseConfig::class))->newInstanceWithoutConstructor(); + + $this->applyMerge = self::getPrivateMethodInvoker($config, 'applyMerge'); + } + + private function apply(mixed $current, Merge $directive): mixed + { + return ($this->applyMerge)($current, $directive); + } + + public function testReplaceArray(): void + { + $this->assertSame(['c'], $this->apply(['a', 'b'], Merge::replace(['c']))); + } + + public function testReplaceScalar(): void + { + $this->assertSame('redis', $this->apply('file', Merge::replace('redis'))); + } + + public function testReplaceBool(): void + { + $this->assertFalse($this->apply(true, Merge::replace(false))); + } + + public function testReplaceNull(): void + { + $this->assertNull($this->apply('something', Merge::replace(null))); + } + + public function testAppend(): void + { + $this->assertSame(['a', 'b', 'c'], $this->apply(['a', 'b'], Merge::append(['c']))); + } + + public function testAppendOntoNonArrayCurrent(): void + { + $this->assertSame(['c'], $this->apply('scalar', Merge::append(['c']))); + $this->assertSame(['c'], $this->apply(null, Merge::append(['c']))); + } + + public function testAppendDeDups(): void + { + // A value already present is not duplicated; only the absent one is added. + $this->assertSame(['a', 'b', 'c'], $this->apply(['a', 'b'], Merge::append(['b', 'c']))); + } + + public function testAppendDeDupsWithinPayload(): void + { + // A value repeated inside the payload is added only once. + $this->assertSame(['a', 'x'], $this->apply(['a'], Merge::append(['x', 'x']))); + } + + public function testListOpsLeavePreExistingDuplicatesUntouched(): void + { + $this->assertSame(['a', 'a', 'b'], $this->apply(['a', 'a'], Merge::append(['b']))); + } + + public function testPrepend(): void + { + $this->assertSame(['c', 'a', 'b'], $this->apply(['a', 'b'], Merge::prepend(['c']))); + } + + public function testPrependDeDupsAndDoesNotMove(): void + { + // 'a' is already present, so it is left where it is, not moved to the front. + $this->assertSame(['x', 'a', 'b'], $this->apply(['a', 'b'], Merge::prepend(['a', 'x']))); + } + + public function testBeforeAnchorFound(): void + { + $base = ['csrf', 'invalidchars', 'toolbar']; + $this->assertSame( + ['csrf', 'invalidchars', 'auth', 'toolbar'], + $this->apply($base, Merge::before('toolbar', ['auth'])), + ); + } + + public function testAfterAnchorFound(): void + { + $base = ['csrf', 'invalidchars', 'toolbar']; + $this->assertSame( + ['csrf', 'auth', 'invalidchars', 'toolbar'], + $this->apply($base, Merge::after('csrf', ['auth'])), + ); + } + + public function testAfterMovesAnAlreadyPresentValue(): void + { + // auth exists before toolbar; after('toolbar', ['auth']) relocates it. + $base = ['csrf', 'auth', 'toolbar']; + $this->assertSame( + ['csrf', 'toolbar', 'auth'], + $this->apply($base, Merge::after('toolbar', ['auth'])), + ); + } + + public function testBeforeMovesAnAlreadyPresentValue(): void + { + $base = ['csrf', 'auth', 'toolbar']; + $this->assertSame( + ['toolbar', 'csrf', 'auth'], + $this->apply($base, Merge::before('csrf', ['toolbar'])), + ); + } + + public function testAfterDeDupsWithinPayloadPreservingOrder(): void + { + // Repeated payload values collapse to first-seen order before insertion. + $base = ['csrf', 'toolbar']; + $this->assertSame( + ['csrf', 'a', 'b', 'toolbar'], + $this->apply($base, Merge::after('csrf', ['a', 'b', 'a'])), + ); + } + + public function testAfterPerValueMix(): void + { + // auth present (moved), newFilter absent (inserted) - as one block after csrf. + $base = ['csrf', 'auth', 'toolbar']; + $this->assertSame( + ['csrf', 'auth', 'newFilter', 'toolbar'], + $this->apply($base, Merge::after('csrf', ['auth', 'newFilter'])), + ); + } + + public function testAfterUsesFirstAnchorMatch(): void + { + $base = ['csrf', 'auth', 'csrf']; + $this->assertSame( + ['csrf', 'x', 'auth', 'csrf'], + $this->apply($base, Merge::after('csrf', ['x'])), + ); + } + + public function testAfterMissingAnchorFallsBackToAppend(): void + { + $base = ['csrf', 'auth']; + $this->assertSame( + ['csrf', 'auth', 'newFilter'], + $this->apply($base, Merge::after('honeypot', ['newFilter'])), + ); + } + + public function testBeforeMissingAnchorFallsBackToPrepend(): void + { + $base = ['csrf', 'auth']; + $this->assertSame( + ['newFilter', 'csrf', 'auth'], + $this->apply($base, Merge::before('honeypot', ['newFilter'])), + ); + } + + public function testMissingAnchorDoesNotRelocateAPresentValue(): void + { + // honeypot is absent and auth is already present → leave the list as-is. + $base = ['csrf', 'auth', 'toolbar']; + $this->assertSame($base, $this->apply($base, Merge::after('honeypot', ['auth']))); + } + + public function testListOpOntoNonArrayCurrent(): void + { + $this->assertSame(['auth'], $this->apply(null, Merge::after('csrf', ['auth']))); + $this->assertSame(['auth'], $this->apply('scalar', Merge::before('csrf', ['auth']))); + } + + public function testRepeatedSameAnchorAfterLandsCloserToAnchor(): void + { + // Two registrars anchoring after('csrf', …) in turn: the later one lands + // closer to the anchor (documented contract). + $first = $this->apply(['csrf'], Merge::after('csrf', ['a'])); + $second = $this->apply($first, Merge::after('csrf', ['b'])); + + $this->assertSame(['csrf', 'b', 'a'], $second); + } + + public function testRepeatedSameAnchorBeforeLandsCloserToAnchor(): void + { + $first = $this->apply(['csrf'], Merge::before('csrf', ['a'])); + $second = $this->apply($first, Merge::before('csrf', ['b'])); + + $this->assertSame(['a', 'b', 'csrf'], $second); + } + + public function testBeforeRejectsAnchorInPayload(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Merge::before() cannot use a value that is also being inserted as its anchor.'); + + Merge::before('csrf', ['csrf']); + } + + public function testAfterRejectsAnchorInPayload(): void + { + $this->expectException(InvalidArgumentException::class); + + Merge::after('csrf', ['x', 'csrf']); + } + + public function testByKeyStringKeysRecurse(): void + { + // Example A - siblings preserved. + $current = [ + 'key1' => 'val1', + 'key2' => ['val2' => 'subVal2', 'val3' => 'subVal3'], + ]; + $result = $this->apply($current, Merge::byKey([ + 'key2' => ['val4' => 'subVal4'], + ])); + + $this->assertSame([ + 'key1' => 'val1', + 'key2' => ['val2' => 'subVal2', 'val3' => 'subVal3', 'val4' => 'subVal4'], + ], $result); + } + + public function testByKeyIntegerKeysAppend(): void + { + // Example B - Shield matrix superadmin list grows. + $current = ['superadmin' => ['admin.access']]; + $result = $this->apply($current, Merge::byKey([ + 'superadmin' => ['shippinglabel-logos.*'], + ])); + + $this->assertSame(['superadmin' => ['admin.access', 'shippinglabel-logos.*']], $result); + } + + public function testByKeyScalarLeafReplace(): void + { + $this->assertSame(['x' => 2], $this->apply(['x' => 1], Merge::byKey(['x' => 2]))); + } + + public function testByKeyNestedDirectives(): void + { + // Example C - nested append()/replace() resolved, untouched sibling kept. + $current = [ + 'before' => ['csrf'], + 'after' => ['toolbar'], + 'other' => ['keep'], + ]; + $result = $this->apply($current, Merge::byKey([ + 'before' => Merge::append(['blogFilter']), + 'after' => Merge::replace([]), + ])); + + $this->assertSame([ + 'before' => ['csrf', 'blogFilter'], + 'after' => [], + 'other' => ['keep'], + ], $result); + } + + public function testByKeyWithNestedOrderingDirectives(): void + { + // The realistic Filters case: order a filter relative to an existing one + // inside the nested 'before'/'after' lists of 'globals'. + $current = [ + 'before' => ['csrf', 'invalidchars'], + 'after' => ['toolbar'], + ]; + $result = $this->apply($current, Merge::byKey([ + 'before' => Merge::after('csrf', ['auth']), + 'after' => Merge::prepend(['honeypot']), + ])); + + $this->assertSame([ + 'before' => ['csrf', 'auth', 'invalidchars'], + 'after' => ['honeypot', 'toolbar'], + ], $result); + } + + public function testByKeyDirectiveInBrandNewSubtree(): void + { + // A directive under a key absent from the base resolves against an empty base. + $result = $this->apply(['existing' => 1], Merge::byKey([ + 'newKey' => Merge::append(['x']), + ])); + + // The directive under the brand-new key is resolved, not stored literally. + $this->assertSame(['existing' => 1, 'newKey' => ['x']], $result); + } + + public function testByKeyDirectiveAtIntegerKeyAppends(): void + { + $result = $this->apply(['a'], Merge::byKey([ + Merge::replace('b'), + ])); + + $this->assertSame(['a', 'b'], $result); + } + + public function testByKeyResolvesBrandNewNestedArraySubtree(): void + { + // A string key missing from the base recurses with [] as its base. + $result = $this->apply([], Merge::byKey([ + 'deep' => ['nested' => ['value']], + ])); + + $this->assertSame(['deep' => ['nested' => ['value']]], $result); + } + + public function testAppendPayloadIsTerminalLiteral(): void + { + // A directive embedded in an append() payload is literal data, not interpreted. + $result = $this->apply([], Merge::append([Merge::replace('x')])); + + $this->assertInstanceOf(Merge::class, $result[0]); + } + + public function testReplacePayloadIsTerminalLiteral(): void + { + $payload = ['nested' => Merge::append(['x'])]; + $result = $this->apply(['old'], Merge::replace($payload)); + + $this->assertInstanceOf(Merge::class, $result['nested']); + } + + public function testFactoriesSetStrategyAndValue(): void + { + $this->assertSame(Merge::REPLACE, Merge::replace('v')->strategy); + $this->assertSame(Merge::APPEND, Merge::append(['v'])->strategy); + $this->assertSame(Merge::PREPEND, Merge::prepend(['v'])->strategy); + $this->assertSame(Merge::BEFORE, Merge::before('a', ['v'])->strategy); + $this->assertSame(Merge::AFTER, Merge::after('a', ['v'])->strategy); + $this->assertSame(Merge::BY_KEY, Merge::byKey(['v'])->strategy); + $this->assertSame('v', Merge::replace('v')->value); + } + + public function testFactoriesSetAnchor(): void + { + $this->assertSame('csrf', Merge::before('csrf', ['v'])->anchor); + $this->assertSame('csrf', Merge::after('csrf', ['v'])->anchor); + // Non-anchored directives carry a null anchor. + $this->assertNull(Merge::append(['v'])->anchor); + } +} diff --git a/tests/system/Config/fixtures/MergeRegistrarConfig.php b/tests/system/Config/fixtures/MergeRegistrarConfig.php new file mode 100644 index 000000000000..71cb6ce20806 --- /dev/null +++ b/tests/system/Config/fixtures/MergeRegistrarConfig.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Config\BaseConfig; + +class MergeRegistrarConfig extends BaseConfig +{ + /** + * @var array + */ + public array $arrayNested = [ + 'key1' => 'val1', + 'key2' => ['val2' => 'subVal2', 'val3' => 'subVal3'], + ]; + + /** + * @var array> + */ + public array $matrix = [ + 'superadmin' => ['admin.access'], + ]; + + /** + * @var array> + */ + public array $globals = [ + 'before' => ['csrf'], + 'after' => ['toolbar'], + ]; + + public string $handler = 'file'; + + /** + * @var list + */ + public array $list = ['a', 'b']; +} From 3a4ccdb654f64d8572316183ef9a5d628eec16de Mon Sep 17 00:00:00 2001 From: michalsn Date: Sat, 20 Jun 2026 18:38:16 +0200 Subject: [PATCH 2/3] add docs --- user_guide_src/source/changelogs/v4.8.0.rst | 1 + .../source/general/configuration.rst | 93 +++++++++++++++++++ .../source/general/configuration/012.php | 35 +++++++ .../source/general/configuration/013.php | 26 ++++++ .../source/general/configuration/014.php | 26 ++++++ .../source/general/configuration/015.php | 29 ++++++ user_guide_src/source/general/modules.rst | 3 + 7 files changed, 213 insertions(+) create mode 100644 user_guide_src/source/general/configuration/012.php create mode 100644 user_guide_src/source/general/configuration/013.php create mode 100644 user_guide_src/source/general/configuration/014.php create mode 100644 user_guide_src/source/general/configuration/015.php diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 25b03cb154ec..4640250efb64 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -332,6 +332,7 @@ Validation Others ====== +- **Config:** Added optional ``CodeIgniter\Config\Merge`` directives for :ref:`Registrars ` to control replacing, deep merging, and ordered list additions. Existing registrar merge behavior is unchanged. See :ref:`registrar-merge-directives`. - **Float and Double Casting:** Added support for precision and rounding mode when casting to float or double in entities. - Added ``CodeIgniter\Input\InputData``, ``ValidatedInput``, and ``InputDataFactory`` for reusable typed input data objects. - Float and Double casting now throws ``CastException::forInvalidFloatRoundingMode()`` if an rounding mode other than up, down, even or odd is provided. diff --git a/user_guide_src/source/general/configuration.rst b/user_guide_src/source/general/configuration.rst index 601ab6e81177..2469966eb097 100644 --- a/user_guide_src/source/general/configuration.rst +++ b/user_guide_src/source/general/configuration.rst @@ -342,6 +342,99 @@ Registrar methods must always return an array, with keys corresponding to the pr of the target config file. Existing values are merged, and Registrar properties have overwrite priority. +By default this merge is **shallow** (top-level only). Arrays are combined with +``array_merge()``, so a nested array under a top-level key *replaces* the existing +nested array rather than merging into it. In the following example the ``key2`` +subtree from the config is replaced entirely, dropping ``val2`` and ``val3``: + +.. literalinclude:: configuration/012.php + +.. _registrar-merge-directives: + +Controlling how values are merged +--------------------------------- + +.. versionadded:: 4.8.0 + +When a registrar needs finer control than the shallow default, it can return a +``CodeIgniter\Config\Merge`` directive as the value of a property. Merge +directives are explicit instructions to either keep merging into a value, replace +it, or add items to a list. + +The two directives that control whole values are: + +- ``Merge::replace($value)`` - discard the existing value and use ``$value``. + Accepts **any type** (scalar, ``null``, or array - e.g. ``['a', 'b']`` becomes + ``['c']``, or ``Merge::replace('redis')``). +- ``Merge::byKey($value)`` - deep-merge by key: **string keys recurse, integer + keys append, and scalar leaves are replaced**. The name is deliberately not + ``recursive`` to avoid confusion with PHP's ``array_merge_recursive()``, which + collects scalar values into arrays instead of replacing them. + +Use ``Merge::byKey()`` when you want to navigate into nested configuration and +preserve sibling keys: + +.. literalinclude:: configuration/013.php + +.. important:: Inside ``Merge::byKey()``, plain arrays are still merged by key. + Use ``Merge::replace()`` when you want to stop merging at that key and + overwrite the value. For example, ``'after' => []`` leaves an existing + ``after`` list unchanged, while ``'after' => Merge::replace([])`` clears it. + +The following registrar adds a filter to ``globals['before']`` while hard-resetting +``globals['after']``, leaving any other ``globals`` keys untouched: + +.. literalinclude:: configuration/014.php + +The *list* strategies add items to a list and control where they land. They are +useful where order matters, such as the filter lists in ``Config\Filters``: + +- ``Merge::append($value)`` - add items to the **end** of the list + (e.g. ``['a', 'b']`` becomes ``['a', 'b', 'c']``). +- ``Merge::prepend($value)`` - add items to the **front** of the list + (e.g. ``['a', 'b']`` becomes ``['c', 'a', 'b']``). +- ``Merge::before($anchor, $value)`` - insert items immediately **before** the + first element equal to ``$anchor``. +- ``Merge::after($anchor, $value)`` - insert items immediately **after** the + first element equal to ``$anchor``. + +All four de-duplicate, so the directives never *introduce* a duplicate value: a +value already in the list is not added again, and duplicate payload values are +collapsed (e.g. ``Merge::append(['x', 'x'])`` adds ``x`` once). Duplicates that +already exist in the current list are left as-is. They differ only in how they +treat a value that is *already present*: + +- ``append()`` / ``prepend()`` leave an already-present value **where it is** + (they only add values that are missing). +- ``before()`` / ``after()`` **move** an already-present value to the anchor + position - but only when the anchor is in the list. If the anchor is missing + they fall back to ``append()`` / ``prepend()`` respectively, and do **not** + relocate a value that is already present. + +The anchor is matched strictly against the **direct elements** of the list; the +list strategies act on a single list level and never recurse. To reach a list +that is nested under other keys (such as ``globals['before']``), navigate to it +with ``Merge::byKey()`` and place the list directive at that key: + +.. literalinclude:: configuration/015.php + +.. important:: Merge directives are interpreted only when used as the **value of + a config property** returned by a registrar, and recursively **inside** + ``Merge::byKey()``. The payloads of the ``replace()``, ``append()``, + ``prepend()``, ``before()``, and ``after()`` strategies are taken literally and + are **not** scanned for nested directives - for nested control, wrap the + property in ``Merge::byKey()`` and place the directives at its keys. + +.. note:: Merge directives sharpen a *single* registrar's intent; they do not add + an explicit priority mechanism between registrars. Registrars are still applied + in discovery order, and an item's final position follows from that order *plus* + the strategy: with ``append()`` an earlier registrar's items sit ahead of a + later one's, while with ``prepend()``, ``before()``, and ``after()`` a later + registrar's items land **nearer the front or the anchor** than an earlier one's. + For example, ``after('csrf', ['a'])`` followed by ``after('csrf', ['b'])`` from a + second registrar yields ``['csrf', 'b', 'a']``. As always, **.env** values take + priority over registrars. + Explicit Registrars =================== diff --git a/user_guide_src/source/general/configuration/012.php b/user_guide_src/source/general/configuration/012.php new file mode 100644 index 000000000000..25170d351902 --- /dev/null +++ b/user_guide_src/source/general/configuration/012.php @@ -0,0 +1,35 @@ + 'val1', + 'key2' => ['val2' => 'subVal2', 'val3' => 'subVal3'], + ]; +} + +// Modules/MyModule/Config/Registrar.php - plain array (shallow merge) + +namespace MyModule\Config; + +class Registrar +{ + public static function Example(): array + { + return ['arrayNested' => ['key2' => ['val4' => 'subVal4']]]; + } +} + +// Result - the nested array under "key2" is replaced wholesale, so +// "val2" and "val3" are silently dropped: +// +// 'arrayNested' => [ +// 'key1' => 'val1', +// 'key2' => ['val4' => 'subVal4'], +// ] diff --git a/user_guide_src/source/general/configuration/013.php b/user_guide_src/source/general/configuration/013.php new file mode 100644 index 000000000000..2bff86e2993f --- /dev/null +++ b/user_guide_src/source/general/configuration/013.php @@ -0,0 +1,26 @@ + Merge::byKey([ + 'key2' => ['val4' => 'subVal4'], + ]), + ]; + } +} + +// Result - the sibling keys are preserved: +// +// 'arrayNested' => [ +// 'key1' => 'val1', +// 'key2' => ['val2' => 'subVal2', 'val3' => 'subVal3', 'val4' => 'subVal4'], +// ] diff --git a/user_guide_src/source/general/configuration/014.php b/user_guide_src/source/general/configuration/014.php new file mode 100644 index 000000000000..2be7eee8e516 --- /dev/null +++ b/user_guide_src/source/general/configuration/014.php @@ -0,0 +1,26 @@ + Merge::byKey([ + 'before' => Merge::append(['blogFilter']), // add to the existing list + 'after' => Merge::replace([]), // hard reset, plain [] would keep merging + ]), + ]; + } + + // Scalar replace also works at the property root: + public static function Cache(): array + { + return ['handler' => Merge::replace('redis')]; + } +} diff --git a/user_guide_src/source/general/configuration/015.php b/user_guide_src/source/general/configuration/015.php new file mode 100644 index 000000000000..75dc2f9152a4 --- /dev/null +++ b/user_guide_src/source/general/configuration/015.php @@ -0,0 +1,29 @@ + Merge::byKey([ + // Run "auth" immediately after "csrf" in the before-list. + 'before' => Merge::after('csrf', ['auth']), + // Run "honeypot" first in the after-list. + 'after' => Merge::prepend(['honeypot']), + ]), + ]; + } +} + +// Given a base of: +// 'before' => ['csrf', 'invalidchars'], +// 'after' => ['toolbar'], +// the result is: +// 'before' => ['csrf', 'auth', 'invalidchars'], +// 'after' => ['honeypot', 'toolbar'], diff --git a/user_guide_src/source/general/modules.rst b/user_guide_src/source/general/modules.rst index a077b8f5523d..ad2768ae7b6e 100644 --- a/user_guide_src/source/general/modules.rst +++ b/user_guide_src/source/general/modules.rst @@ -206,6 +206,9 @@ Config files are automatically discovered whenever using the :php:func:`config() .. note:: We don't recommend you use the same short classname in modules. Modules that need to override or add to known configurations in **app/Config/** should use :ref:`Implicit Registrars `. + To contribute to *nested* configuration (filters, permission matrices, and so on) + without clobbering existing values, use the + :ref:`merge directives `. .. note:: Prior to v4.4.0, ``config()`` finds the file in **app/Config/** when there is a class with the same shortname, From 0dcff1f63ec048df34cfa83004533b42485e728c Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 21 Jun 2026 08:14:33 +0200 Subject: [PATCH 3/3] resolve merge directives in new nested config subtrees --- system/Config/BaseConfig.php | 7 +++++-- tests/system/Config/MergeTest.php | 11 +++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php index d123872537c3..07c9c4fff5b3 100644 --- a/system/Config/BaseConfig.php +++ b/system/Config/BaseConfig.php @@ -435,8 +435,11 @@ private function mergeByKey(array $current, array $incoming): array } } elseif (is_int($key)) { $current[] = $value; - } elseif (isset($current[$key]) && is_array($current[$key]) && is_array($value)) { - $current[$key] = $this->mergeByKey($current[$key], $value); + } elseif (is_array($value)) { + $current[$key] = $this->mergeByKey( + isset($current[$key]) && is_array($current[$key]) ? $current[$key] : [], + $value, + ); } else { $current[$key] = $value; } diff --git a/tests/system/Config/MergeTest.php b/tests/system/Config/MergeTest.php index c3a0e8cdcfce..2b3ccf75685a 100644 --- a/tests/system/Config/MergeTest.php +++ b/tests/system/Config/MergeTest.php @@ -340,6 +340,17 @@ public function testByKeyResolvesBrandNewNestedArraySubtree(): void $this->assertSame(['deep' => ['nested' => ['value']]], $result); } + public function testByKeyResolvesDirectiveInsideBrandNewNestedArraySubtree(): void + { + $result = $this->apply([], Merge::byKey([ + 'globals' => [ + 'before' => Merge::append(['auth']), + ], + ])); + + $this->assertSame(['globals' => ['before' => ['auth']]], $result); + } + public function testAppendPayloadIsTerminalLiteral(): void { // A directive embedded in an append() payload is literal data, not interpreted.