From fe66d558e4b3e94ea582263fe15ced15b27af43a Mon Sep 17 00:00:00 2001 From: Enea Date: Fri, 19 Dec 2025 19:33:34 +0100 Subject: [PATCH 01/14] feat: add node manipulation methods and corresponding tests for append, prepend, insert, and delete operations --- docs/03_node-manipulation.md | 302 +++++++++++++++ src/Config.php | 98 +++++ tests/unit/NodeManipulationTest.php | 578 ++++++++++++++++++++++++++++ tests/unit/TraverseMethodTest.php | 120 ++++++ 4 files changed, 1098 insertions(+) create mode 100644 docs/03_node-manipulation.md create mode 100644 tests/unit/NodeManipulationTest.php diff --git a/docs/03_node-manipulation.md b/docs/03_node-manipulation.md new file mode 100644 index 0000000..103ea34 --- /dev/null +++ b/docs/03_node-manipulation.md @@ -0,0 +1,302 @@ +# Manipulating Node Values with `appendTo()`, `prependTo()`, `insertAt()` and `deleteFrom()` + +The `Config` class is designed to work well with multidimensional arrays addressed via *dot notation* paths (e.g. `"key.subKey"`) and array notation (e.g. `['key', 'subKey']`). + +Besides the usual `get()`, `set()`, `has()` and `delete()` methods, `Config` also provides a small set of **node manipulation methods** that make it easy to work with values that are expected to be *lists* (arrays) at a specific path. + +These methods are useful when you want to: + +- Avoid the classic workflow of `get()` → mutate array → `set()`. +- Append/prepend/insert items to a list at a nested path. +- Remove items from a list at a nested path. + +--- + +## Table of Contents + +- [Overview](#overview) +- [Supported key formats](#supported-key-formats) +- [Method summary](#method-summary) +- [Usage Examples](#usage-examples) + - [Example 1: Appending values to the end of a list](#example-1-appending-values-to-the-end-of-a-list) + - [Example 2: Prepending values to the beginning of a list](#example-2-prepending-values-to-the-beginning-of-a-list) + - [Example 3: Inserting values at a specific position](#example-3-inserting-values-at-a-specific-position) + - [Example 4: Removing values from a list](#example-4-removing-values-from-a-list) + - [Example 5: Using `traverse()` for bulk cleanup](#example-5-using-traverse-for-bulk-cleanup) +- [Important Notes](#important-notes) +- [Conclusion](#conclusion) + +--- + +## Overview + +The following methods operate on a *single node* identified by a path: + +- `Config::appendTo($key, $value)` +- `Config::prependTo($key, $value)` +- `Config::insertAt($key, $value, int $position)` +- `Config::deleteFrom($key, $value)` + +All these methods: + +- Work on **nested paths**, not only on the root. +- Expect the value stored at `$key` (if present) to be an **array/list**. +- If the node does not exist, `appendTo()`, `prependTo()` and `insertAt()` treat it as an empty list and create it. +- Throw a `RuntimeException` if the node exists, but it is not an array. + +--- + +## Supported key formats + +All methods accept any key format already supported by `Config::get()` / `Config::set()`: + +- Dot notation string: `"settings.plugins"` +- Array notation: `['settings', 'plugins']` +- Single segment string/int: `"plugins"` or `0` + +--- + +## Method summary + +### `appendTo()` + +Appends one or more values to the end of the list stored at `$key`. + +- If `$value` is a scalar or an object, it will be appended as a single element. +- If `$value` is an array, it will be appended element-by-element (i.e. merged). +- Duplicates are allowed. + +```php +// Initial state: ['items' => ['apple']] + +$config->appendTo('items', 'orange'); // The same as $config->appendTo('items', ['orange']); +// ['items' => ['apple', 'orange']] + +$config->appendTo('items', ['banana', 'apple']); +// ['items' => ['apple', 'orange', 'banana', 'apple']] +``` + +### `prependTo()` + +Behaves like `appendTo()`, but it prepends one or more values to the beginning of the list stored at `$key`. + +```php +// Initial state: ['items' => ['apple']] + +$config->prependTo('items', 'orange'); // The same as $config->prependTo('items', ['orange']); +// ['items' => ['orange', 'apple']] + +$config->prependTo('items', ['banana', 'apple']); +// ['items' => ['banana', 'apple', 'orange', 'apple']] +``` + +### `insertAt()` + +Inserts one or more values at a given position in the list stored at `$key`. + +```php +// Initial state: ['items' => ['apple', 'orange']] + +$config->insertAt('items', 'banana', 1); +// ['items' => ['apple', 'banana', 'orange']] +``` + +### `deleteFrom()` + +Removes values from the list stored at `$key`. + +Current behavior: + +- The value is removed by **searching the first occurrence** of each requested value. +- If the last element is removed, the key is deleted and `get($key)` will return `null`. +- If the key does not exist, the method returns `true`. + +```php +// Initial state: ['items' => ['apple', 'banana', 'orange', 'banana']] + +$config->deleteFrom('items', 'banana'); +// ['items' => ['apple', 'orange', 'banana']] + +$config->deleteFrom('items', ['banana', 'orange']); +// ['items' => ['apple']] +``` + +--- + +## Usage Examples + +### Example 1: Appending values to the end of a list + +```php +use ItalyStrap\Config\Config; + +$config = new Config([ + 'key' => [ + 'subKey' => ['value1'], + ], +]); + +$config->appendTo('key.subKey', 'value2'); + +var_dump($config->get('key.subKey')); +// ['value1', 'value2'] +``` + +### Example 2: Prepending values to the beginning of a list + +```php +use ItalyStrap\Config\Config; + +$config = new Config([ + 'items' => ['apple', 'banana'], +]); + +$config->prependTo('items', 'orange'); + +var_dump($config->get('items')); +// ['orange', 'apple', 'banana'] +``` + +### Example 3: Inserting values at a specific position + +```php +use ItalyStrap\Config\Config; + +$config = new Config([ + 'items' => ['apple', 'orange'], +]); + +$config->insertAt('items', 'banana', 1); + +var_dump($config->get('items')); +// ['apple', 'banana', 'orange'] +``` + +### Example 4: Removing values from a list + +By default, `deleteFrom()` removes the first occurrence. + +```php +use ItalyStrap\Config\Config; + +$config = new Config([ + 'items' => ['apple', 'banana', 'banana', 'orange'], +]); + +$config->deleteFrom('items', 'banana'); + +var_dump($config->get('items')); +// ['apple', 'banana', 'orange'] +``` + +If you need to remove all duplicates or apply complex rules, prefer using `traverse()`. + +### Example 5: Using `traverse()` for bulk cleanup + +The node manipulation methods are intentionally lightweight. + +When you need deep or bulk changes (for example: remove duplicate entries in many places), the recommended tool is `Config::traverse()`. + +Example: remove duplicated values from all lists in the entire configuration structure: + +```php +use ItalyStrap\Config\Config; +use ItalyStrap\Config\SignalCode; + +$config = new Config([ + 'config' => [ + 'allow-plugins' => [ + 'plugin-a', + 'plugin-b', + 'plugin-a', // duplicate + 'plugin-c', + ], + ], + 'tags' => ['php', 'javascript', 'php', 'python'], // duplicates + 'settings' => [ // associative array, will NOT be modified + 'key1' => 'value1', + 'key2' => 'value2', + ], +]); + +$config->traverse(static function (&$current): ?int { + // Only process sequential arrays (lists), not associative arrays + if ( + \is_array($current) + && $current !== [] + // Check if it's a list (sequential numeric keys) + && \array_keys($current) !== range(0, count($current) - 1) + ) { + // Remove duplicates and reindex + $current = \array_values(\array_unique($current, \SORT_REGULAR)); + } + return SignalCode::NONE; +}); + +var_dump($config->toArray()); +// [ +// 'config' => [ +// 'allow-plugins' => ['plugin-a', 'plugin-b', 'plugin-c'], +// ], +// 'tags' => ['php', 'javascript', 'python'], +// 'settings' => [ +// 'key1' => 'value1', +// 'key2' => 'value2', +// ], +// ] +``` + +You can also target a specific path if you only want to clean up one list: + +```php +$config->traverse(static function (mixed &$current, string|int $key, Config $config, array $path): ?int { + // Only operate on the specific allow-plugins list + if ($path === ['config', 'allow-plugins'] && is_array($current)) { + $current = array_values(array_unique($current, SORT_REGULAR)); + } + return SignalCode::NONE; +}); +``` + +--- + +## Important Notes + +### 1) These methods work on lists (arrays) + +If the value at the given path exists, and it is not an array, a `RuntimeException` is thrown. + +This is by design: `appendTo()`, `prependTo()`, `insertAt()` and `deleteFrom()` are meant to manipulate lists at specific nodes. + +### 2) Duplicates are allowed + +`appendTo()`, `prependTo()` and `insertAt()` **do not attempt to deduplicate** values. + +If you need set-like behavior, use `traverse()` (see [Example 5](#example-5-using-traverse-for-bulk-cleanup)) or normalize the value before appending. + +### 3) `deleteFrom()` removes only the first occurrence + +This behavior is intentionally similar to list semantics in other languages. + +If you need to remove all occurrences, use `traverse()` or perform repeated `deleteFrom()` calls. + +### 4) Integers, strings, and strict comparisons + +`deleteFrom()` uses strict comparisons (`array_search(..., true)`), so: + +- `1` and `'1'` are treated as different values. +- `true` and `1` are treated as different values. + +--- + +## Conclusion + +Node manipulation methods let you treat a nested path as a list and operate on it directly: + +- Use `appendTo()` to push values to the end. +- Use `prependTo()` to push values to the start. +- Use `insertAt()` when you need positional insertion. +- Use `deleteFrom()` to remove values (first match). + +When you need advanced or cross-tree manipulations, use `Config::traverse()`. + diff --git a/src/Config.php b/src/Config.php index 61a2bd8..32b2557 100644 --- a/src/Config.php +++ b/src/Config.php @@ -135,6 +135,104 @@ public function delete($key): bool return $this->deleteValue($this->storage, $this->buildLevels($key)); } + /** + * @param TKey|string|int|array $key + * @param TValue|mixed $value + */ + public function appendTo($key, $value): bool + { + $oldValue = $this->get($key, []); + $this->assertList($key, $oldValue); + + $oldValue = \array_merge($oldValue, (array)$value); + return $this->set($key, $oldValue); + } + + /** + * @param TKey|string|int|array $key + * @param TValue|mixed $value + */ + public function prependTo($key, $value): bool + { + $oldValue = $this->get($key, []); + $this->assertList($key, $oldValue); + + $oldValue = \array_merge((array)$value, $oldValue); + return $this->set($key, $oldValue); + } + + /** + * @param TKey|string|int|array $key + * @param TValue|mixed $value + */ + public function insertAt($key, $value, int $position): bool + { + $oldValue = $this->get($key, []); + + $this->assertList($key, $oldValue); + + $oldValue = \array_merge( + \array_slice($oldValue, 0, $position), + (array)$value, + \array_slice($oldValue, $position) + ); + + return $this->set($key, $oldValue); + } + + /** + * @param TKey|string|int|array $key + * @param TValue|mixed $value + */ + public function deleteFrom($key, $value): bool + { + $oldValue = $this->get($key); + + if ($oldValue === null) { + return true; + } + + $this->assertList($key, $oldValue, 'delete'); + + $toRemove = (array) $value; + foreach ($toRemove as $needle) { + $index = \array_search($needle, $oldValue, true); + if ($index !== false) { + unset($oldValue[$index]); // rimuove una sola occorrenza + } + } + + if ($oldValue === []) { + $this->delete($key); + return true; + } + + $oldValue = \array_merge($oldValue); + return $this->set($key, $oldValue); + } + + /** + * @param TKey|string|int|array $key + * @param mixed $value + */ + private function assertList($key, $value, string $methodName = 'set'): void + { + if (\is_int($key) || \is_string($key)) { + $key = [$key]; + } + + if (!\is_array($value)) { + throw new \RuntimeException( + \sprintf( + 'The value at "%s" is not an array, if you want to set a value use the `%s::%s` method', + \implode('.', $key), + self::class, + $methodName + ) + ); + } + } + /** * @param array|\IteratorAggregate|\Iterator|\stdClass|string ...$array_to_merge */ diff --git a/tests/unit/NodeManipulationTest.php b/tests/unit/NodeManipulationTest.php new file mode 100644 index 0000000..bb51084 --- /dev/null +++ b/tests/unit/NodeManipulationTest.php @@ -0,0 +1,578 @@ + [ + ['items' => ['apple', 'banana']], + 'items', + 'orange', + ['apple', 'banana', 'orange'], + ]; + + yield 'Append single value to empty array' => [ + ['items' => []], + 'items', + 'apple', + ['apple'], + ]; + + yield 'Append array value to existing array' => [ + ['items' => ['apple']], + 'items', + ['banana', 'orange'], + ['apple', 'banana', 'orange'], + ]; + + yield 'Append to nested path' => [ + ['key' => ['subKey' => ['value1']]], + 'key.subKey', + 'value2', + ['value1', 'value2'], + ]; + + yield 'Append associative array' => [ + ['settings' => ['plugins' => ['plugin1' => true]]], + 'settings.plugins', + ['plugin2' => false], + ['plugin1' => true, 'plugin2' => false], + ]; + + yield 'Append to non-existent key creates array' => [ + [], + 'items', + 'apple', + ['apple'], + ]; + } + + /** + * @dataProvider appendToDataProvider + */ + public function testAppendToAddsValuesToEnd(array $initial, string $key, $value, array $expected): void + { + $config = $this->makeInstance($initial); + $config->appendTo($key, $value); + $this->assertSame($expected, $config->get($key)); + } + + public function testAppendToMultipleValuesAddsAllToEnd(): void + { + $config = $this->makeInstance(['items' => ['value1']]); + $config->appendTo('items', 'value2'); + $config->appendTo('items', 'value3'); + $config->appendTo('items', 'value3'); + + $this->assertSame(['value1', 'value2', 'value3', 'value3'], $config->get('items')); + } + + public function testAppendToWithMixedArrayTypes(): void + { + $config = $this->makeInstance(); + $config->appendTo('settings.plugins', ['plugin1' => true]); + $config->appendTo('settings.plugins', ['plugin2']); + $config->appendTo('settings.plugins', ['plugin3']); + $config->appendTo('settings.plugins', 'plugin4'); + $config->appendTo('settings.plugins', 5); + + $this->assertSame( + ['plugin1' => true, 'plugin2', 'plugin3', 'plugin4', 5], + $config->get('settings.plugins') + ); + } + + public function testAppendToNonArrayThrowsException(): void + { + $this->expectException(\RuntimeException::class); + + $config = $this->makeInstance(['items' => 'not-an-array']); + $config->appendTo('items', 'value'); + } + + public static function prependToDataProvider(): \Generator + { + yield 'Prepend single value to existing array' => [ + ['items' => ['banana', 'orange']], + 'items', + 'apple', + ['apple', 'banana', 'orange'], + ]; + + yield 'Prepend single value to empty array' => [ + ['items' => []], + 'items', + 'apple', + ['apple'], + ]; + + yield 'Prepend array value to existing array' => [ + ['items' => ['orange']], + 'items', + ['apple', 'banana'], + ['apple', 'banana', 'orange'], + ]; + + yield 'Prepend to nested path' => [ + ['key' => ['subKey' => ['value2']]], + 'key.subKey', + 'value1', + ['value1', 'value2'], + ]; + + yield 'Prepend to non-existent key creates array' => [ + [], + 'items', + 'apple', + ['apple'], + ]; + } + + /** + * @dataProvider prependToDataProvider + */ + public function testPrependToAddsValuesToBeginning(array $initial, string $key, $value, array $expected): void + { + $config = $this->makeInstance($initial); + $config->prependTo($key, $value); + $this->assertSame($expected, $config->get($key)); + } + + public function testPrependToMultipleValuesAddsAllToBeginning(): void + { + $config = $this->makeInstance(['items' => ['value3']]); + $config->prependTo('items', 'value2'); + $config->prependTo('items', 'value1'); + + $this->assertSame(['value1', 'value2', 'value3'], $config->get('items')); + } + + public static function insertAtDataProvider(): \Generator + { + yield 'Insert at beginning (position 0)' => [ + ['items' => ['banana', 'orange']], + 'items', + 'apple', + 0, + ['apple', 'banana', 'orange'], + ]; + + yield 'Insert in middle' => [ + ['items' => ['apple', 'orange']], + 'items', + 'banana', + 1, + ['apple', 'banana', 'orange'], + ]; + + yield 'Insert at end' => [ + ['items' => ['apple', 'banana']], + 'items', + 'orange', + 2, + ['apple', 'banana', 'orange'], + ]; + + yield 'Insert into empty array' => [ + ['items' => []], + 'items', + 'apple', + 0, + ['apple'], + ]; + + yield 'Insert array at position' => [ + ['items' => ['apple', 'orange']], + 'items', + ['banana', 'grape'], + 1, + ['apple', 'banana', 'grape', 'orange'], + ]; + + yield 'Insert to nested path' => [ + ['key' => ['subKey' => ['value1', 'value3']]], + 'key.subKey', + 'value2', + 1, + ['value1', 'value2', 'value3'], + ]; + } + + /** + * @dataProvider insertAtDataProvider + */ + public function testInsertAtAddsValueAtSpecificPosition( + array $initial, + string $key, + $value, + int $position, + array $expected + ): void { + $config = $this->makeInstance($initial); + $config->insertAt($key, $value, $position); + $this->assertSame($expected, $config->get($key)); + } + + public function testInsertAtNonArrayThrowsException(): void + { + $this->expectException(\RuntimeException::class); + + $config = $this->makeInstance(['items' => 'not-an-array']); + $config->insertAt('items', 'value', 0); + } + + public static function deleteFromDataProvider(): \Generator + { + yield 'Delete single value from array' => [ + ['items' => ['apple', 'banana', 'orange']], + 'items', + 'banana', + ['apple', 'orange'], + ]; + + yield 'Delete first value' => [ + ['items' => ['apple', 'banana', 'orange']], + 'items', + 'apple', + ['banana', 'orange'], + ]; + + yield 'Delete last value' => [ + ['items' => ['apple', 'banana', 'orange']], + 'items', + 'orange', + ['apple', 'banana'], + ]; + + yield 'Delete array of values' => [ + ['items' => ['apple', 'banana', 'orange', 'grape']], + 'items', + ['banana', 'grape'], + ['apple', 'orange'], + ]; + + yield 'Delete from nested path' => [ + ['key' => ['subKey' => ['value1', 'value2', 'value3']]], + 'key.subKey', + 'value2', + ['value1', 'value3'], + ]; + + yield 'Delete associative array value' => [ + ['settings' => ['plugins' => ['plugin1' => true, 'plugin2', 'plugin3']]], + 'settings.plugins', + 'plugin2', + ['plugin1' => true, 'plugin3'], + ]; + + yield 'Delete integer value' => [ + ['items' => ['apple', 5, 'banana']], + 'items', + 5, + ['apple', 'banana'], + ]; + } + + /** + * @dataProvider deleteFromDataProvider + */ + public function testDeleteFromRemovesValues(array $initial, string $key, $value, array $expected): void + { + $config = $this->makeInstance($initial); + $config->deleteFrom($key, $value); + $this->assertSame($expected, $config->get($key)); + } + + public function testDeleteFromLastValueRemovesKey(): void + { + $config = $this->makeInstance(['items' => ['apple']]); + $config->deleteFrom('items', 'apple'); + $this->assertNull($config->get('items')); + } + + public function testDeleteFromAllValuesRemovesKey(): void + { + $config = $this->makeInstance(['settings' => ['plugins' => ['plugin1' => true]]]); + $config->deleteFrom('settings.plugins', ['plugin1' => true]); + $this->assertNull($config->get('settings.plugins')); + } + + public function testDeleteFromRemovesTheFirstOccurrence(): void + { + $config = $this->makeInstance(['items' => ['apple', 'banana', 'banana', 'orange']]); + $config->deleteFrom('items', 'banana'); + $this->assertSame(['apple', 'banana', 'orange'], $config->get('items')); + } + + public function testDeleteFromNonExistentValue(): void + { + $config = $this->makeInstance(['items' => ['apple', 'banana']]); + $config->deleteFrom('items', 'orange'); + $this->assertSame(['apple', 'banana'], $config->get('items')); + } + + public function testDeleteFromNonExistentKeyReturnsTrue(): void + { + $config = $this->makeInstance([]); + $result = $config->deleteFrom('items', 'apple'); + $this->assertTrue($result); + } + + public function testDeleteFromNonArrayThrowsException(): void + { + $this->expectException(\RuntimeException::class); + + $config = $this->makeInstance(['items' => 'not-an-array']); + $config->deleteFrom('items', 'value'); + } + + public function testDeleteFromSequentialOperations(): void + { + $config = $this->makeInstance([ + 'settings' => ['plugins' => ['plugin1' => true, 'plugin2', 'plugin3', 'plugin4', 5]], + ]); + + $config->deleteFrom('settings.plugins', 'plugin3'); + $this->assertSame( + ['plugin1' => true, 'plugin2', 'plugin4', 5], + $config->get('settings.plugins') + ); + + $config->deleteFrom('settings.plugins', 'plugin4'); + $this->assertSame( + ['plugin1' => true, 'plugin2', 5], + $config->get('settings.plugins') + ); + + $config->deleteFrom('settings.plugins', 5); + $this->assertSame( + ['plugin1' => true, 'plugin2'], + $config->get('settings.plugins') + ); + + $config->deleteFrom('settings.plugins', 'plugin2'); + $this->assertSame( + ['plugin1' => true], + $config->get('settings.plugins') + ); + } + + public function testComplexArrayBehaviourWithMixedTypes(): void + { + $config = $this->makeInstance(); + + $config->set('key.subKey', ['value']); + $config->appendTo('key.subKey', 'value5'); + $this->assertSame(['value', 'value5'], $config->get('key.subKey')); + + $config->appendTo('key.subKey', 'value6'); + $config->appendTo('key.subKey', 'value6'); + $this->assertSame(['value', 'value5', 'value6', 'value6'], $config->get('key.subKey')); + + $config->appendTo('config.allow-plugins', ['vendor/name' => true]); + $this->assertSame( + [ + 'vendor/name' => true, + ], + $config->get('config.allow-plugins') + ); + + $config->appendTo('config.allow-plugins', ['vendor/name-ttt']); + $this->assertSame( + [ + 'vendor/name' => true, + 'vendor/name-ttt', + ], + $config->get('config.allow-plugins') + ); + + $config->appendTo('config.allow-plugins', ['vendor/name-ppp']); + $config->appendTo('config.allow-plugins', ['vendor/name-ppp']); + $config->appendTo('config.allow-plugins', 'vendor/name-ccc'); + $config->appendTo('config.allow-plugins', 5); + $this->assertSame( + [ + 'vendor/name' => true, + 'vendor/name-ttt', + 'vendor/name-ppp', + 'vendor/name-ppp', + 'vendor/name-ccc', + 5, + ], + $config->get('config.allow-plugins') + ); + + $config->deleteFrom('key.subKey', 'value5'); + $this->assertSame(['value', 'value6', 'value6'], $config->get('key.subKey')); + + $config->deleteFrom('key.subKey', 'value6'); + $this->assertSame(['value', 'value6'], $config->get('key.subKey')); + + $config->deleteFrom('config.allow-plugins', 'vendor/name-ppp'); + $this->assertSame( + [ + 'vendor/name' => true, + 'vendor/name-ttt', + 'vendor/name-ppp', + 'vendor/name-ccc', + 5, + ], + $config->get('config.allow-plugins') + ); + + $config->deleteFrom('config.allow-plugins', 'vendor/name-ccc'); + $this->assertSame( + [ + 'vendor/name' => true, + 'vendor/name-ttt', + 'vendor/name-ppp', + 5, + ], + $config->get('config.allow-plugins') + ); + + $config->deleteFrom('config.allow-plugins', 5); + $this->assertSame( + [ + 'vendor/name' => true, + 'vendor/name-ttt', + 'vendor/name-ppp', + ], + $config->get('config.allow-plugins') + ); + + $config->deleteFrom('config.allow-plugins', 'vendor/name-ttt'); + $this->assertSame( + [ + 'vendor/name' => true, + 'vendor/name-ppp', + ], + $config->get('config.allow-plugins'), + 'The value should be an array with one element' + ); + + $config->deleteFrom('config.allow-plugins', ['vendor/name' => true, 'vendor/name-ppp']); + $this->assertNull($config->get('config.allow-plugins'), 'The value should be null'); + } + + public function testAppendToWithAssociativeArrayAndDuplicates(): void + { + $config = $this->makeInstance(); + + $config->appendTo('settings.plugins', ['plugin1' => true]); + $this->assertSame( + ['plugin1' => true], + $config->get('settings.plugins') + ); + + $config->appendTo('settings.plugins', ['plugin2']); + $this->assertSame( + ['plugin1' => true, 'plugin2'], + $config->get('settings.plugins') + ); + + $config->appendTo('settings.plugins', ['plugin3']); + $config->appendTo('settings.plugins', ['plugin3']); + $config->appendTo('settings.plugins', 'plugin4'); + $config->appendTo('settings.plugins', 5); + $this->assertSame( + [ + 'plugin1' => true, + 'plugin2', + 'plugin3', + 'plugin3', + 'plugin4', + 5, + ], + $config->get('settings.plugins') + ); + } + + public function testDeleteFromCompleteWorkflow(): void + { + $config = $this->makeInstance(); + + $config->set('key.subKey', ['value1', 'value2', 'value3']); + $config->set('settings.plugins', ['plugin1' => true, 'plugin2', 'plugin3', 'plugin4', 5]); + + $config->deleteFrom('key.subKey', 'value2'); + $this->assertSame(['value1', 'value3'], $config->get('key.subKey')); + + $config->deleteFrom('key.subKey', 'value3'); + $this->assertSame(['value1'], $config->get('key.subKey')); + + $config->deleteFrom('settings.plugins', 'plugin3'); + $this->assertSame( + ['plugin1' => true, 'plugin2', 'plugin4', 5], + $config->get('settings.plugins') + ); + + $config->deleteFrom('settings.plugins', 'plugin4'); + $this->assertSame( + ['plugin1' => true, 'plugin2', 5], + $config->get('settings.plugins') + ); + + $config->deleteFrom('settings.plugins', 5); + $this->assertSame( + ['plugin1' => true, 'plugin2'], + $config->get('settings.plugins') + ); + + $config->deleteFrom('settings.plugins', 'plugin2'); + $this->assertSame( + ['plugin1' => true], + $config->get('settings.plugins') + ); + + $config->deleteFrom('settings.plugins', ['plugin1' => true]); + $this->assertNull($config->get('settings.plugins')); + } + + public function testPrependToWithInitialArrayAndMultipleValues(): void + { + $config = $this->makeInstance(); + + $config->set('key.subKey', ['value2']); + $config->prependTo('key.subKey', 'value1'); + $this->assertSame(['value1', 'value2'], $config->get('key.subKey')); + + $config->prependTo('key.subKey', 'value0'); + $this->assertSame(['value0', 'value1', 'value2'], $config->get('key.subKey')); + } + + public function testAppendToWithMultidimensionalArrayFragment(): void + { + $config = $this->makeInstance(); + $config->set('root', []); + + $fragment = [ + 'stubs' => [ + [ + 'file' => [ + [ + '@attributes' => [ + 'name' => 'vendor/inpsyde/wp-stubs-versions/latest.php', + ], + ], + ], + ], + ], + ]; + + $config->appendTo('root', $fragment); + $this->assertSame($fragment, $config->get('root')); + } +} diff --git a/tests/unit/TraverseMethodTest.php b/tests/unit/TraverseMethodTest.php index 3bfa770..f883b83 100644 --- a/tests/unit/TraverseMethodTest.php +++ b/tests/unit/TraverseMethodTest.php @@ -813,4 +813,124 @@ public function testOrderOfTraversalExecution(): void $expected = ['items', 'item1', 'subitem1', 'subitem2']; $this->assertSame($expected, $orderOfCallbacks); } + + /** + * Removes duplicate entries + */ + public function testRemoveDuplicateEntriesAtPath(): void + { + $config = new Config([ + 'values' => ['a', 'b', 'a', 'c', 'b'], + ]); + + $config->traverse(static function (&$current, $key, ConfigInterface $config, array $path): ?int { + if ($key === 'values' && is_array($current)) { + $current = array_values(array_unique($current)); + return SignalCode::CONTINUE; + } + + return SignalCode::NONE; + }); + $this->assertSame(['a', 'b', 'c'], $config->get('values')); + } + + /** + * Removes all duplicate entries in the whole structure + */ + public function testRemoveAllDuplicateEntries(): void + { + $config = new Config([ + 'group1' => [ + 'values' => ['a', 'b', 'a'], + ], + 'group2' => [ + 'values' => ['b', 'c', 'c'], + ], + ]); + + $config->traverse(static function (&$current): ?int { + // Only process arrays that are sequential/list-like (numeric keys starting from 0) + // and check if it's a list (sequential numeric keys) + if ( + \is_array($current) + && $current !== [] + && \array_keys($current) === \range(0, \count($current) - 1) + ) { + // Remove duplicates and reindex + $current = \array_values(\array_unique($current, \SORT_REGULAR)); + } + + return SignalCode::NONE; + }); + + $this->assertSame([ + 'group1' => [ + 'values' => ['a', 'b'], + ], + 'group2' => [ + 'values' => ['b', 'c'], + ], + ], $config->toArray()); + } + + /** + * Removes all duplicate entries in complex nested structure + * while preserving associative arrays + */ + public function testRemoveAllDuplicateEntriesInComplexStructure(): void + { + $config = new Config([ + 'config' => [ + 'allow-plugins' => [ + 'plugin-a', + 'plugin-b', + 'plugin-a', // duplicate + 'plugin-c', + 'plugin-b', // duplicate + ], + ], + 'nested' => [ + 'level1' => [ + 'items' => [1, 2, 3, 2, 1], // duplicates + 'settings' => [ // associative array, should NOT be modified + 'key1' => 'value1', + 'key2' => 'value2', + ], + ], + 'level2' => [ + 'tags' => ['php', 'javascript', 'php', 'python'], // duplicates + ], + ], + ]); + + $config->traverse(static function (&$current): ?int { + if ( + \is_array($current) + && $current !== [] + && \array_keys($current) === \range(0, \count($current) - 1) + ) { + $current = \array_values(\array_unique($current, \SORT_REGULAR)); + } + + return SignalCode::NONE; + }); + + $this->assertSame([ + 'config' => [ + 'allow-plugins' => ['plugin-a', 'plugin-b', 'plugin-c'], + ], + 'nested' => [ + 'level1' => [ + 'items' => [1, 2, 3], + 'settings' => [ + 'key1' => 'value1', + 'key2' => 'value2', + ], + ], + 'level2' => [ + 'tags' => ['php', 'javascript', 'python'], + ], + ], + ], $config->toArray()); + } } From 10278aabe45feb704cc45f981d10fae37017eb56 Mon Sep 17 00:00:00 2001 From: Enea Date: Fri, 19 Dec 2025 23:30:33 +0100 Subject: [PATCH 02/14] refactor: improve type hinting and variable annotations in append, prepend, insert, and delete methods --- src/Config.php | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/Config.php b/src/Config.php index 32b2557..199e827 100644 --- a/src/Config.php +++ b/src/Config.php @@ -137,11 +137,15 @@ public function delete($key): bool /** * @param TKey|string|int|array $key - * @param TValue|mixed $value + * @param TValue $value */ public function appendTo($key, $value): bool { - $oldValue = $this->get($key, []); + /** @var TValue $default */ + $default = []; + + /** @var array $oldValue */ + $oldValue = $this->get($key, $default); $this->assertList($key, $oldValue); $oldValue = \array_merge($oldValue, (array)$value); @@ -150,11 +154,15 @@ public function appendTo($key, $value): bool /** * @param TKey|string|int|array $key - * @param TValue|mixed $value + * @param TValue $value */ public function prependTo($key, $value): bool { - $oldValue = $this->get($key, []); + /** @var TValue $default */ + $default = []; + + /** @var array $oldValue */ + $oldValue = $this->get($key, $default); $this->assertList($key, $oldValue); $oldValue = \array_merge((array)$value, $oldValue); @@ -167,7 +175,11 @@ public function prependTo($key, $value): bool */ public function insertAt($key, $value, int $position): bool { - $oldValue = $this->get($key, []); + /** @var TValue $default */ + $default = []; + + /** @var array $oldValue */ + $oldValue = $this->get($key, $default); $this->assertList($key, $oldValue); @@ -186,6 +198,7 @@ public function insertAt($key, $value, int $position): bool */ public function deleteFrom($key, $value): bool { + /** @var array|null $oldValue */ $oldValue = $this->get($key); if ($oldValue === null) { @@ -194,6 +207,7 @@ public function deleteFrom($key, $value): bool $this->assertList($key, $oldValue, 'delete'); + /** @var array $toRemove */ $toRemove = (array) $value; foreach ($toRemove as $needle) { $index = \array_search($needle, $oldValue, true); @@ -217,15 +231,13 @@ public function deleteFrom($key, $value): bool */ private function assertList($key, $value, string $methodName = 'set'): void { - if (\is_int($key) || \is_string($key)) { - $key = [$key]; - } + $levels = $this->buildLevels($key); if (!\is_array($value)) { throw new \RuntimeException( \sprintf( 'The value at "%s" is not an array, if you want to set a value use the `%s::%s` method', - \implode('.', $key), + \implode('.', $levels), self::class, $methodName ) From 034dd3dfe8e3f78ed0e590681c8404e257004c70 Mon Sep 17 00:00:00 2001 From: Enea Date: Sun, 28 Dec 2025 15:21:43 +0100 Subject: [PATCH 03/14] feat: implement NodeManipulationInterface for list node operations in configuration --- src/Config.php | 3 +- src/NodeManipulationInterface.php | 79 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 src/NodeManipulationInterface.php diff --git a/src/Config.php b/src/Config.php index 199e827..edc3b4e 100644 --- a/src/Config.php +++ b/src/Config.php @@ -13,10 +13,11 @@ * @template TValue * * @template-implements \ItalyStrap\Config\ConfigInterface + * @template-implements \ItalyStrap\Config\NodeManipulationInterface * @template-extends \ArrayObject * @psalm-suppress DeprecatedInterface */ -class Config extends ArrayObject implements ConfigInterface, \JsonSerializable +class Config extends ArrayObject implements ConfigInterface, NodeManipulationInterface, \JsonSerializable { /** * @use ArrayObjectTrait diff --git a/src/NodeManipulationInterface.php b/src/NodeManipulationInterface.php new file mode 100644 index 0000000..97fb78d --- /dev/null +++ b/src/NodeManipulationInterface.php @@ -0,0 +1,79 @@ + Date: Sun, 28 Dec 2025 15:25:20 +0100 Subject: [PATCH 04/14] fix: update comment from Italian to English --- src/Config.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Config.php b/src/Config.php index edc3b4e..5cdcf3c 100644 --- a/src/Config.php +++ b/src/Config.php @@ -213,7 +213,7 @@ public function deleteFrom($key, $value): bool foreach ($toRemove as $needle) { $index = \array_search($needle, $oldValue, true); if ($index !== false) { - unset($oldValue[$index]); // rimuove una sola occorrenza + unset($oldValue[$index]); // removes only the first occurrence. } } From 36bfa49f419483b076a9833e6ee4e850e5fab291 Mon Sep 17 00:00:00 2001 From: Enea Date: Sun, 28 Dec 2025 15:39:27 +0100 Subject: [PATCH 05/14] feat: add benchmarks for node manipulation methods in Config class --- .../Benchmark/ConfigNodeManipulationBench.php | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 tests/Benchmark/ConfigNodeManipulationBench.php diff --git a/tests/Benchmark/ConfigNodeManipulationBench.php b/tests/Benchmark/ConfigNodeManipulationBench.php new file mode 100644 index 0000000..8146b98 --- /dev/null +++ b/tests/Benchmark/ConfigNodeManipulationBench.php @@ -0,0 +1,193 @@ +config = new Config([ + 'plugins' => ['plugin1', 'plugin2', 'plugin3'], + 'settings' => [ + 'features' => ['feature1', 'feature2'], + 'modules' => ['module1', 'module2', 'module3', 'module4', 'module5'], + ], + 'deeply' => [ + 'nested' => [ + 'path' => [ + 'items' => ['item1', 'item2'], + ], + ], + ], + 'empty_list' => [], + 'large_list' => \range(1, 100), + ]); + } + + // ========================================================================= + // appendTo benchmarks + // ========================================================================= + + public function benchAppendToExistingList(): void + { + $this->config->appendTo('plugins', 'plugin4'); + } + + public function benchAppendToEmptyList(): void + { + $this->config->appendTo('empty_list', 'value'); + } + + public function benchAppendToNestedPath(): void + { + $this->config->appendTo('settings.features', 'feature3'); + } + + public function benchAppendToDeeplyNestedPath(): void + { + $this->config->appendTo('deeply.nested.path.items', 'item3'); + } + + public function benchAppendMultipleValuesToList(): void + { + $this->config->appendTo('plugins', ['plugin4', 'plugin5', 'plugin6']); + } + + public function benchAppendToLargeList(): void + { + $this->config->appendTo('large_list', 101); + } + + public function benchAppendToNonExistentKeyCreatesArray(): void + { + $this->config->appendTo('new_key', 'value'); + } + + // ========================================================================= + // prependTo benchmarks + // ========================================================================= + + public function benchPrependToExistingList(): void + { + $this->config->prependTo('plugins', 'plugin0'); + } + + public function benchPrependToEmptyList(): void + { + $this->config->prependTo('empty_list', 'value'); + } + + public function benchPrependToNestedPath(): void + { + $this->config->prependTo('settings.features', 'feature0'); + } + + public function benchPrependToDeeplyNestedPath(): void + { + $this->config->prependTo('deeply.nested.path.items', 'item0'); + } + + public function benchPrependMultipleValuesToList(): void + { + $this->config->prependTo('plugins', ['plugin-1', 'plugin-2', 'plugin-3']); + } + + public function benchPrependToLargeList(): void + { + $this->config->prependTo('large_list', 0); + } + + // ========================================================================= + // insertAt benchmarks + // ========================================================================= + + public function benchInsertAtBeginning(): void + { + $this->config->insertAt('plugins', 'plugin0', 0); + } + + public function benchInsertAtMiddle(): void + { + $this->config->insertAt('plugins', 'plugin1.5', 1); + } + + public function benchInsertAtEnd(): void + { + $this->config->insertAt('plugins', 'plugin4', 3); + } + + public function benchInsertAtNestedPath(): void + { + $this->config->insertAt('settings.modules', 'module2.5', 2); + } + + public function benchInsertAtDeeplyNestedPath(): void + { + $this->config->insertAt('deeply.nested.path.items', 'item1.5', 1); + } + + public function benchInsertMultipleValuesAtPosition(): void + { + $this->config->insertAt('plugins', ['pluginA', 'pluginB'], 1); + } + + public function benchInsertAtMiddleOfLargeList(): void + { + $this->config->insertAt('large_list', 999, 50); + } + + // ========================================================================= + // deleteFrom benchmarks + // ========================================================================= + + public function benchDeleteFromExistingList(): void + { + $this->config->deleteFrom('plugins', 'plugin2'); + } + + public function benchDeleteFromNestedPath(): void + { + $this->config->deleteFrom('settings.features', 'feature1'); + } + + public function benchDeleteFromDeeplyNestedPath(): void + { + $this->config->deleteFrom('deeply.nested.path.items', 'item1'); + } + + public function benchDeleteMultipleValuesFromList(): void + { + $this->config->deleteFrom('plugins', ['plugin1', 'plugin3']); + } + + public function benchDeleteNonExistentValue(): void + { + $this->config->deleteFrom('plugins', 'non-existent'); + } + + public function benchDeleteFromNonExistentKey(): void + { + $this->config->deleteFrom('non_existent_key', 'value'); + } + + public function benchDeleteFromLargeList(): void + { + $this->config->deleteFrom('large_list', 50); + } + + public function benchDeleteFromLargeListMultipleValues(): void + { + $this->config->deleteFrom('large_list', [10, 30, 50, 70, 90]); + } +} From bd3e589b3c203d0782da980e9aeb038112dc78ce Mon Sep 17 00:00:00 2001 From: Enea Date: Sun, 28 Dec 2025 20:11:45 +0100 Subject: [PATCH 06/14] fix: improve error message in assertList method for clarity --- src/Config.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Config.php b/src/Config.php index 5cdcf3c..2605a39 100644 --- a/src/Config.php +++ b/src/Config.php @@ -237,7 +237,7 @@ private function assertList($key, $value, string $methodName = 'set'): void if (!\is_array($value)) { throw new \RuntimeException( \sprintf( - 'The value at "%s" is not an array, if you want to set a value use the `%s::%s` method', + 'The value at "%s" is not an array, use the `%s::%s()` method instead', \implode('.', $levels), self::class, $methodName From 67b0b601bfc3b835038dd538f62cb32e05459e0c Mon Sep 17 00:00:00 2001 From: Enea Date: Sun, 28 Dec 2025 21:27:14 +0100 Subject: [PATCH 07/14] refactor: streamline key handling in appendTo, prependTo, insertAt, and deleteFrom methods --- src/Config.php | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/Config.php b/src/Config.php index 2605a39..e59bf85 100644 --- a/src/Config.php +++ b/src/Config.php @@ -142,15 +142,17 @@ public function delete($key): bool */ public function appendTo($key, $value): bool { + $levels = $this->buildLevels($key); + /** @var TValue $default */ $default = []; /** @var array $oldValue */ - $oldValue = $this->get($key, $default); - $this->assertList($key, $oldValue); + $oldValue = $this->findValue($this->storage, $levels, $default); + $this->assertList($levels, $oldValue); $oldValue = \array_merge($oldValue, (array)$value); - return $this->set($key, $oldValue); + return $this->insertValue($this->storage, $levels, $oldValue); } /** @@ -159,15 +161,17 @@ public function appendTo($key, $value): bool */ public function prependTo($key, $value): bool { + $levels = $this->buildLevels($key); + /** @var TValue $default */ $default = []; /** @var array $oldValue */ - $oldValue = $this->get($key, $default); - $this->assertList($key, $oldValue); + $oldValue = $this->findValue($this->storage, $levels, $default); + $this->assertList($levels, $oldValue); $oldValue = \array_merge((array)$value, $oldValue); - return $this->set($key, $oldValue); + return $this->insertValue($this->storage, $levels, $oldValue); } /** @@ -176,13 +180,15 @@ public function prependTo($key, $value): bool */ public function insertAt($key, $value, int $position): bool { + $levels = $this->buildLevels($key); + /** @var TValue $default */ $default = []; /** @var array $oldValue */ - $oldValue = $this->get($key, $default); + $oldValue = $this->findValue($this->storage, $levels, $default); - $this->assertList($key, $oldValue); + $this->assertList($levels, $oldValue); $oldValue = \array_merge( \array_slice($oldValue, 0, $position), @@ -190,7 +196,7 @@ public function insertAt($key, $value, int $position): bool \array_slice($oldValue, $position) ); - return $this->set($key, $oldValue); + return $this->insertValue($this->storage, $levels, $oldValue); } /** @@ -199,14 +205,16 @@ public function insertAt($key, $value, int $position): bool */ public function deleteFrom($key, $value): bool { + $levels = $this->buildLevels($key); + /** @var array|null $oldValue */ - $oldValue = $this->get($key); + $oldValue = $this->findValue($this->storage, $levels); if ($oldValue === null) { return true; } - $this->assertList($key, $oldValue, 'delete'); + $this->assertList($levels, $oldValue, 'delete'); /** @var array $toRemove */ $toRemove = (array) $value; @@ -218,22 +226,20 @@ public function deleteFrom($key, $value): bool } if ($oldValue === []) { - $this->delete($key); + $this->deleteValue($this->storage, $levels); return true; } $oldValue = \array_merge($oldValue); - return $this->set($key, $oldValue); + return $this->insertValue($this->storage, $levels, $oldValue); } /** - * @param TKey|string|int|array $key + * @param array $levels Pre-computed key levels * @param mixed $value */ - private function assertList($key, $value, string $methodName = 'set'): void + private function assertList(array $levels, $value, string $methodName = 'set'): void { - $levels = $this->buildLevels($key); - if (!\is_array($value)) { throw new \RuntimeException( \sprintf( From c1d7fafd529bebbd89c2970aaef5df3a003d694a Mon Sep 17 00:00:00 2001 From: Enea Date: Mon, 29 Dec 2025 12:05:17 +0100 Subject: [PATCH 08/14] feat: enhance node manipulation methods and add storage synchronization tests --- src/ArrayObjectTrait.php | 17 +- src/Config.php | 41 +- tests/unit/ArrayAccessMethodsTest.php | 17 +- tests/unit/ConfigTest.php | 2 +- tests/unit/NodeManipulationTest.php | 72 +- tests/unit/StorageSynchronizationTest.php | 822 ++++++++++++++++++++++ 6 files changed, 946 insertions(+), 25 deletions(-) create mode 100644 tests/unit/StorageSynchronizationTest.php diff --git a/src/ArrayObjectTrait.php b/src/ArrayObjectTrait.php index bfdca52..edf8918 100644 --- a/src/ArrayObjectTrait.php +++ b/src/ArrayObjectTrait.php @@ -51,16 +51,19 @@ public function offsetUnset($index) $this->delete($index); } - public function count(): int + /** + * @param array $array + * @return array + */ + public function exchangeArray($array): array { - parent::exchangeArray($this->storage); - return parent::count(); - } + $oldData = $this->storage; - public function getArrayCopy(): array - { + // Replace storage with new array + $this->storage = $array; parent::exchangeArray($this->storage); - return parent::getArrayCopy(); + + return $oldData; } public function __clone() diff --git a/src/Config.php b/src/Config.php index e59bf85..bd60081 100644 --- a/src/Config.php +++ b/src/Config.php @@ -116,11 +116,15 @@ public function has($key): bool */ public function set($key, $value): bool { - return $this->insertValue( + $result = $this->insertValue( $this->storage, $this->buildLevels($key), $value ); + + parent::exchangeArray($this->storage); + + return $result; } public function update($key, $value): bool @@ -133,7 +137,9 @@ public function update($key, $value): bool */ public function delete($key): bool { - return $this->deleteValue($this->storage, $this->buildLevels($key)); + $result = $this->deleteValue($this->storage, $this->buildLevels($key)); + parent::exchangeArray($this->storage); + return $result; } /** @@ -149,10 +155,12 @@ public function appendTo($key, $value): bool /** @var array $oldValue */ $oldValue = $this->findValue($this->storage, $levels, $default); - $this->assertList($levels, $oldValue); + $this->assertList($oldValue, $levels); $oldValue = \array_merge($oldValue, (array)$value); - return $this->insertValue($this->storage, $levels, $oldValue); + $result = $this->insertValue($this->storage, $levels, $oldValue); + parent::exchangeArray($this->storage); + return $result; } /** @@ -168,10 +176,12 @@ public function prependTo($key, $value): bool /** @var array $oldValue */ $oldValue = $this->findValue($this->storage, $levels, $default); - $this->assertList($levels, $oldValue); + $this->assertList($oldValue, $levels); $oldValue = \array_merge((array)$value, $oldValue); - return $this->insertValue($this->storage, $levels, $oldValue); + $result = $this->insertValue($this->storage, $levels, $oldValue); + parent::exchangeArray($this->storage); + return $result; } /** @@ -187,8 +197,7 @@ public function insertAt($key, $value, int $position): bool /** @var array $oldValue */ $oldValue = $this->findValue($this->storage, $levels, $default); - - $this->assertList($levels, $oldValue); + $this->assertList($oldValue, $levels); $oldValue = \array_merge( \array_slice($oldValue, 0, $position), @@ -196,7 +205,9 @@ public function insertAt($key, $value, int $position): bool \array_slice($oldValue, $position) ); - return $this->insertValue($this->storage, $levels, $oldValue); + $result = $this->insertValue($this->storage, $levels, $oldValue); + parent::exchangeArray($this->storage); + return $result; } /** @@ -214,7 +225,7 @@ public function deleteFrom($key, $value): bool return true; } - $this->assertList($levels, $oldValue, 'delete'); + $this->assertList($oldValue, $levels, 'delete'); /** @var array $toRemove */ $toRemove = (array) $value; @@ -227,18 +238,21 @@ public function deleteFrom($key, $value): bool if ($oldValue === []) { $this->deleteValue($this->storage, $levels); + parent::exchangeArray($this->storage); return true; } $oldValue = \array_merge($oldValue); - return $this->insertValue($this->storage, $levels, $oldValue); + $result = $this->insertValue($this->storage, $levels, $oldValue); + parent::exchangeArray($this->storage); + return $result; } /** - * @param array $levels Pre-computed key levels * @param mixed $value + * @param array $levels Pre-computed key levels */ - private function assertList(array $levels, $value, string $methodName = 'set'): void + private function assertList($value, array $levels, string $methodName = 'set'): void { if (!\is_array($value)) { throw new \RuntimeException( @@ -295,6 +309,7 @@ public function merge(...$array_to_merge): Config public function traverse(callable ...$visitor): void { $this->traverseArray($this->storage, $visitor); + parent::exchangeArray($this->storage); } /** diff --git a/tests/unit/ArrayAccessMethodsTest.php b/tests/unit/ArrayAccessMethodsTest.php index dba76ef..fca6e03 100644 --- a/tests/unit/ArrayAccessMethodsTest.php +++ b/tests/unit/ArrayAccessMethodsTest.php @@ -74,15 +74,28 @@ public function itShouldExchangeArrayWorksAsExpected(): void $sut = $this->makeInstance($array); $this->assertCount(2, $sut, ''); + $this->assertSame($array, $sut->getArrayCopy(), ''); - $sut->add('new-key', 'new-value'); + $sut->set('new-key', 'new-value'); $this->assertCount(3, $sut, ''); + $this->assertSame([ + 'test' => 'val1', + 'test2' => 'val2', + 'new-key' => 'new-value', + ], $sut->getArrayCopy(), ''); - $sut->remove('test', 'test2'); + $sut->deleteMultiple(['test', 'test2']); $this->assertCount(1, $sut, ''); + $this->assertSame([ + 'new-key' => 'new-value', + ], $sut->getArrayCopy(), ''); $sut->merge(['add-key' => 'add-value']); $this->assertCount(2, $sut, ''); + $this->assertSame([ + 'new-key' => 'new-value', + 'add-key' => 'add-value', + ], $sut->getArrayCopy(), ''); } /** diff --git a/tests/unit/ConfigTest.php b/tests/unit/ConfigTest.php index 17ad774..d41f4a6 100644 --- a/tests/unit/ConfigTest.php +++ b/tests/unit/ConfigTest.php @@ -95,7 +95,7 @@ public function deprecatedPush(): void { $sut = $this->makeInstance(); $sut->push('key', 42); - $this->assertSame($sut->toArray(), ['key' => 42]); + $this->assertSame(['key' => 42], $sut->toArray()); } /** diff --git a/tests/unit/NodeManipulationTest.php b/tests/unit/NodeManipulationTest.php index bb51084..f3e0006 100644 --- a/tests/unit/NodeManipulationTest.php +++ b/tests/unit/NodeManipulationTest.php @@ -94,6 +94,72 @@ public function testAppendToWithMixedArrayTypes(): void ); } + public static function nonArrayThrowsExceptionDataProvider(): \Generator + { + yield 'appendTo on non-array throws exception' => [ + 'appendTo', + ['items' => 'not-an-array'], + 'items', + 'value', + 'set', + ]; + + yield 'prependTo on non-array throws exception' => [ + 'prependTo', + ['items' => 'not-an-array'], + 'items', + 'value', + 'set', + ]; + + yield 'insertAt on non-array throws exception' => [ + 'insertAt', + ['items' => 'not-an-array'], + 'items', + 'value', + 'set', + ]; + + yield 'deleteFrom on non-array throws exception' => [ + 'deleteFrom', + ['items' => 'not-an-array'], + 'items', + 'value', + 'delete', + ]; + } + + /** + * @dataProvider nonArrayThrowsExceptionDataProvider + * @param mixed $value + */ + public function testNodeManipulationOnNonArrayThrowsException( + string $method, + array $initialData, + string $key, + $value, + string $expectedMethodInMessage + ): void { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage( + \sprintf( + 'The value at "%s" is not an array, use the `%s::%s()` method instead', + $key, + \ItalyStrap\Config\Config::class, + $expectedMethodInMessage + ) + ); + + $config = $this->makeInstance($initialData); + + if ($method === 'insertAt') { + $config->insertAt($key, $value, 0); + return; + } + + $config->$method($key, $value); + } + public function testAppendToNonArrayThrowsException(): void { $this->expectException(\RuntimeException::class); @@ -298,14 +364,16 @@ public function testDeleteFromRemovesValues(array $initial, string $key, $value, public function testDeleteFromLastValueRemovesKey(): void { $config = $this->makeInstance(['items' => ['apple']]); - $config->deleteFrom('items', 'apple'); + $result = $config->deleteFrom('items', 'apple'); + $this->assertTrue($result); $this->assertNull($config->get('items')); } public function testDeleteFromAllValuesRemovesKey(): void { $config = $this->makeInstance(['settings' => ['plugins' => ['plugin1' => true]]]); - $config->deleteFrom('settings.plugins', ['plugin1' => true]); + $result = $config->deleteFrom('settings.plugins', ['plugin1' => true]); + $this->assertTrue($result); $this->assertNull($config->get('settings.plugins')); } diff --git a/tests/unit/StorageSynchronizationTest.php b/tests/unit/StorageSynchronizationTest.php new file mode 100644 index 0000000..862cce4 --- /dev/null +++ b/tests/unit/StorageSynchronizationTest.php @@ -0,0 +1,822 @@ +makeInstance(['key1' => 'value1']); + + $config->set('key2', 'value2'); + + $result = []; + foreach ($config as $key => $value) { + $result[$key] = $value; + } + + $this->assertArrayHasKey('key2', $result); + $this->assertEquals('value2', $result['key2']); + } + + public function testForeachAfterDeleteReturnsUpdatedData(): void + { + $config = $this->makeInstance(['key1' => 'value1', 'key2' => 'value2']); + + $config->delete('key1'); + + $result = []; + foreach ($config as $key => $value) { + $result[$key] = $value; + } + + $this->assertArrayNotHasKey('key1', $result); + } + + public function testPropertyAccessAfterSetReturnsUpdatedData(): void + { + $config = $this->makeInstance(['key1' => 'value1']); + + $config->set('key2', 'value2'); + + /** @var string $value */ + $value = $config->key2; + + $this->assertEquals('value2', $value); + } + + public function testPropertyAccessAfterDeleteReturnsUpdatedData(): void + { + $config = $this->makeInstance(['key1' => 'value1', 'key2' => 'value2']); + + $config->delete('key1'); + + // Property access should return null for deleted key + /** @var mixed $value */ + $value = $config->key1; + + $this->assertNull($value); + } + + public function testGetIteratorAfterSetReturnsUpdatedData(): void + { + $config = $this->makeInstance(['key1' => 'value1']); + + $config->set('key2', 'value2'); + + $iterator = $config->getIterator(); + $result = \iterator_to_array($iterator); + + $this->assertArrayHasKey('key2', $result); + $this->assertEquals('value2', $result['key2']); + } + + public function testGetIteratorAfterDeleteReturnsUpdatedData(): void + { + $config = $this->makeInstance(['key1' => 'value1', 'key2' => 'value2']); + + $config->delete('key1'); + + $iterator = $config->getIterator(); + $result = \iterator_to_array($iterator); + + $this->assertArrayNotHasKey('key1', $result); + } + + public function testCountAfterSetReturnsUpdatedCount(): void + { + $config = $this->makeInstance(['key1' => 'value1']); + + $config->set('key2', 'value2'); + + $this->assertCount(2, $config); + } + + public function testCountAfterDeleteReturnsUpdatedCount(): void + { + $config = $this->makeInstance(['key1' => 'value1', 'key2' => 'value2']); + + $config->delete('key1'); + + $this->assertCount(1, $config); + } + + public function testGetArrayCopyAfterSetReturnsUpdatedData(): void + { + $config = $this->makeInstance(['key1' => 'value1']); + + $config->set('key2', 'value2'); + + $copy = $config->getArrayCopy(); + + $this->assertArrayHasKey('key2', $copy); + $this->assertEquals('value2', $copy['key2']); + } + + public function testGetArrayCopyAfterDeleteReturnsUpdatedData(): void + { + $config = $this->makeInstance(['key1' => 'value1', 'key2' => 'value2']); + + $config->delete('key1'); + + $copy = $config->getArrayCopy(); + + $this->assertArrayNotHasKey('key1', $copy); + } + + public function testJsonSerializeAfterSetReturnsUpdatedData(): void + { + $config = $this->makeInstance(['key1' => 'value1']); + + $config->set('key2', 'value2'); + + $json = \json_encode($config); + $decoded = \json_decode($json, true); + + $this->assertArrayHasKey('key2', $decoded); + $this->assertEquals('value2', $decoded['key2']); + } + + public function testJsonSerializeAfterDeleteReturnsUpdatedData(): void + { + $config = $this->makeInstance(['key1' => 'value1', 'key2' => 'value2']); + + $config->delete('key1'); + + $json = \json_encode($config); + $decoded = \json_decode($json, true); + + $this->assertArrayNotHasKey('key1', $decoded); + } + + // ========================================================================= + // SCENARIO 1: Iteration after node manipulation methods + // ========================================================================= + + public function testForeachAfterAppendToReturnsUpdatedData(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin2']]); + + $config->appendTo('plugins', 'plugin3'); + + // Access via foreach (uses getIterator() internally) + $result = []; + foreach ($config as $key => $value) { + $result[$key] = $value; + } + + $this->assertArrayHasKey('plugins', $result); + $this->assertContains('plugin3', $result['plugins']); + } + + public function testForeachAfterPrependToReturnsUpdatedData(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin2']]); + + $config->prependTo('plugins', 'plugin0'); + + $result = []; + foreach ($config as $key => $value) { + $result[$key] = $value; + } + + $this->assertArrayHasKey('plugins', $result); + $this->assertEquals('plugin0', $result['plugins'][0]); + } + + public function testForeachAfterInsertAtReturnsUpdatedData(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin3']]); + + $config->insertAt('plugins', 'plugin2', 1); + + $result = []; + foreach ($config as $key => $value) { + $result[$key] = $value; + } + + $this->assertArrayHasKey('plugins', $result); + $this->assertEquals('plugin2', $result['plugins'][1]); + } + + public function testForeachAfterDeleteFromReturnsUpdatedData(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin2', 'plugin3']]); + + $config->deleteFrom('plugins', 'plugin2'); + + $result = []; + foreach ($config as $key => $value) { + $result[$key] = $value; + } + + $this->assertArrayHasKey('plugins', $result); + $this->assertNotContains('plugin2', $result['plugins']); + } + + /** + * Test that covers line 242 in deleteFrom: when all elements are removed, + * the key is deleted entirely and foreach reflects this change. + */ + public function testForeachAfterDeleteFromRemovesEntireKeyWhenArrayBecomesEmpty(): void + { + $config = $this->makeInstance([ + 'key1' => 'value1', + 'plugins' => ['plugin1'] + ]); + + // This triggers the $oldValue === [] branch (line 242) + $config->deleteFrom('plugins', 'plugin1'); + + $result = []; + foreach ($config as $key => $value) { + $result[$key] = $value; + } + + $this->assertArrayNotHasKey('plugins', $result); + $this->assertCount(1, $result); + $this->assertArrayHasKey('key1', $result); + } + + // ========================================================================= + // SCENARIO 2: Property access (ARRAY_AS_PROPS) after node manipulation + // ========================================================================= + + public function testPropertyAccessAfterAppendToReturnsUpdatedData(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin2']]); + + $config->appendTo('plugins', 'plugin3'); + + // Access via object property ($config->plugins) + /** @var array $plugins */ + $plugins = $config->plugins; + + $this->assertIsArray($plugins); + $this->assertContains('plugin3', $plugins); + } + + public function testPropertyAccessAfterPrependToReturnsUpdatedData(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin2']]); + + $config->prependTo('plugins', 'plugin0'); + + /** @var array $plugins */ + $plugins = $config->plugins; + + $this->assertIsArray($plugins); + $this->assertEquals('plugin0', $plugins[0]); + } + + public function testPropertyAccessAfterInsertAtReturnsUpdatedData(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin3']]); + + $config->insertAt('plugins', 'plugin2', 1); + + /** @var array $plugins */ + $plugins = $config->plugins; + + $this->assertIsArray($plugins); + $this->assertEquals('plugin2', $plugins[1]); + } + + public function testPropertyAccessAfterDeleteFromReturnsUpdatedData(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin2', 'plugin3']]); + + $config->deleteFrom('plugins', 'plugin2'); + + /** @var array $plugins */ + $plugins = $config->plugins; + + $this->assertIsArray($plugins); + $this->assertNotContains('plugin2', $plugins); + } + + // ========================================================================= + // SCENARIO 3: ArrayAccess after node manipulation + // ========================================================================= + + public function testArrayAccessAfterAppendToReturnsUpdatedData(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin2']]); + + $config->appendTo('plugins', 'plugin3'); + + // Access via ArrayAccess ($config['plugins']) + /** @var array $plugins */ + $plugins = $config['plugins']; + + $this->assertIsArray($plugins); + $this->assertContains('plugin3', $plugins); + } + + public function testArrayAccessAfterDeleteFromReturnsUpdatedData(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin2', 'plugin3']]); + + $config->deleteFrom('plugins', 'plugin2'); + + /** @var array $plugins */ + $plugins = $config['plugins']; + + $this->assertIsArray($plugins); + $this->assertNotContains('plugin2', $plugins); + } + + // ========================================================================= + // SCENARIO 4: count() after node manipulation + // ========================================================================= + + public function testCountAfterAppendToNewKeyReturnsUpdatedCount(): void + { + $config = $this->makeInstance(['key1' => 'value1']); + + // appendTo creates a new key if it doesn't exist + $config->appendTo('newKey', 'value'); + + $this->assertCount(2, $config); + } + + public function testCountAfterDeleteFromRemovesKeyReturnsUpdatedCount(): void + { + $config = $this->makeInstance([ + 'key1' => 'value1', + 'plugins' => ['plugin1'] + ]); + + // deleteFrom removes the key when the array becomes empty + $config->deleteFrom('plugins', 'plugin1'); + + $this->assertCount(1, $config); + } + + // ========================================================================= + // SCENARIO 5: getArrayCopy() / toArray() after node manipulation + // ========================================================================= + + public function testGetArrayCopyAfterAppendToReturnsUpdatedData(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin2']]); + + $config->appendTo('plugins', 'plugin3'); + + $copy = $config->getArrayCopy(); + + $this->assertArrayHasKey('plugins', $copy); + $this->assertContains('plugin3', $copy['plugins']); + } + + public function testToArrayAfterDeleteFromReturnsUpdatedData(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin2', 'plugin3']]); + + $config->deleteFrom('plugins', 'plugin2'); + + $array = $config->toArray(); + + $this->assertArrayHasKey('plugins', $array); + $this->assertNotContains('plugin2', $array['plugins']); + } + + // ========================================================================= + // SCENARIO 6: getIterator() after node manipulation + // ========================================================================= + + public function testGetIteratorAfterAppendToReturnsUpdatedData(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin2']]); + + $config->appendTo('plugins', 'plugin3'); + + $iterator = $config->getIterator(); + $result = \iterator_to_array($iterator); + + $this->assertArrayHasKey('plugins', $result); + $this->assertContains('plugin3', $result['plugins']); + } + + public function testGetIteratorAfterDeleteFromReturnsUpdatedData(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin2', 'plugin3']]); + + $config->deleteFrom('plugins', 'plugin2'); + + $iterator = $config->getIterator(); + $result = \iterator_to_array($iterator); + + $this->assertArrayHasKey('plugins', $result); + $this->assertNotContains('plugin2', $result['plugins']); + } + + // ========================================================================= + // SCENARIO 7: jsonSerialize() after node manipulation + // ========================================================================= + + public function testJsonSerializeAfterAppendToReturnsUpdatedData(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin2']]); + + $config->appendTo('plugins', 'plugin3'); + + $json = \json_encode($config); + $decoded = \json_decode($json, true); + + $this->assertArrayHasKey('plugins', $decoded); + $this->assertContains('plugin3', $decoded['plugins']); + } + + public function testJsonSerializeAfterDeleteFromReturnsUpdatedData(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin2', 'plugin3']]); + + $config->deleteFrom('plugins', 'plugin2'); + + $json = \json_encode($config); + $decoded = \json_decode($json, true); + + $this->assertArrayHasKey('plugins', $decoded); + $this->assertNotContains('plugin2', $decoded['plugins']); + } + + // ========================================================================= + // SCENARIO 8: Multiple operations in sequence + // ========================================================================= + + public function testMultipleNodeManipulationsFollowedByIteration(): void + { + $config = $this->makeInstance(['plugins' => ['plugin2']]); + + $config->prependTo('plugins', 'plugin1'); + $config->appendTo('plugins', 'plugin3'); + $config->insertAt('plugins', 'plugin2.5', 2); + + $result = []; + foreach ($config as $key => $value) { + $result[$key] = $value; + } + + $this->assertEquals(['plugin1', 'plugin2', 'plugin2.5', 'plugin3'], $result['plugins']); + } + + public function testMixedNodeManipulationsAndStandardSetOperations(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1']]); + + $config->appendTo('plugins', 'plugin2'); + $config->set('newKey', 'newValue'); + $config->deleteFrom('plugins', 'plugin1'); + + $result = $config->toArray(); + + $this->assertEquals(['plugin2'], $result['plugins']); + $this->assertEquals('newValue', $result['newKey']); + } + + // ========================================================================= + // SCENARIO 9: Nested path operations and iteration + // ========================================================================= + + public function testForeachAfterNestedAppendToReturnsUpdatedData(): void + { + $config = $this->makeInstance([ + 'settings' => [ + 'features' => ['feature1', 'feature2'] + ] + ]); + + $config->appendTo('settings.features', 'feature3'); + + $result = []; + foreach ($config as $key => $value) { + $result[$key] = $value; + } + + $this->assertArrayHasKey('settings', $result); + $this->assertContains('feature3', $result['settings']['features']); + } + + public function testPropertyAccessAfterNestedDeleteFromReturnsUpdatedData(): void + { + $config = $this->makeInstance([ + 'settings' => [ + 'features' => ['feature1', 'feature2', 'feature3'] + ] + ]); + + $config->deleteFrom('settings.features', 'feature2'); + + /** @var array $settings */ + $settings = $config->settings; + + $this->assertIsArray($settings); + $this->assertNotContains('feature2', $settings['features']); + } + + // ========================================================================= + // SCENARIO 10: Clone behavior after node manipulation + // ========================================================================= + + public function testCloneAfterNodeManipulationCreatesIndependentCopy(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin2']]); + + $config->appendTo('plugins', 'plugin3'); + + $cloned = clone $config; + + // Cloned should be empty according to __clone implementation + $this->assertCount(0, $cloned); + + // Original should still have data + $this->assertCount(1, $config); + $this->assertContains('plugin3', $config->get('plugins')); + } + + // ========================================================================= + // SCENARIO 11: exchangeArray behavior + // ========================================================================= + + public function testExchangeArrayAfterNodeManipulationReplacesData(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin2']]); + + $config->appendTo('plugins', 'plugin3'); + + // exchangeArray should work with the updated data + $oldData = $config->exchangeArray(['newKey' => 'newValue']); + + $this->assertSame(['plugins' => ['plugin1', 'plugin2', 'plugin3']], $oldData); + + $this->assertSame('newValue', $config->get('newKey')); + $this->assertNull($config->get('plugins')); + } + + public function testExchangeArrayReturnsAllOldData(): void + { + $config = $this->makeInstance([ + 'key1' => 'value1', + 'key2' => 'value2', + 'key3' => 'value3' + ]); + + $oldData = $config->exchangeArray(['newKey' => 'newValue']); + + // Must return ALL old keys, not just the first one + $this->assertCount(3, $oldData); + $this->assertArrayHasKey('key1', $oldData); + $this->assertArrayHasKey('key2', $oldData); + $this->assertArrayHasKey('key3', $oldData); + $this->assertSame('value1', $oldData['key1']); + $this->assertSame('value2', $oldData['key2']); + $this->assertSame('value3', $oldData['key3']); + } + + public function testForeachAfterExchangeArrayReturnsNewData(): void + { + $config = $this->makeInstance(['oldKey' => 'oldValue']); + + $config->exchangeArray(['newKey' => 'newValue', 'anotherKey' => 'anotherValue']); + + $result = []; + foreach ($config as $key => $value) { + $result[$key] = $value; + } + + $this->assertArrayNotHasKey('oldKey', $result); + $this->assertArrayHasKey('newKey', $result); + $this->assertArrayHasKey('anotherKey', $result); + $this->assertSame('newValue', $result['newKey']); + } + + // ========================================================================= + // SCENARIO 12: Direct ArrayObject method access + // ========================================================================= + + public function testOffsetExistsAfterAppendToNewKey(): void + { + $config = $this->makeInstance(['existingKey' => 'value']); + + $config->appendTo('newPlugins', 'plugin1'); + + $this->assertTrue($config->offsetExists('newPlugins')); + $this->assertTrue(isset($config['newPlugins'])); + } + + public function testOffsetGetAfterNodeManipulation(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1']]); + + $config->appendTo('plugins', 'plugin2'); + + $plugins = $config->offsetGet('plugins'); + + $this->assertIsArray($plugins); + $this->assertContains('plugin2', $plugins); + } + + // ========================================================================= + // SCENARIO 13: serialize/unserialize behavior + // ========================================================================= + + public function testSerializeAfterNodeManipulationPreservesData(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin2']]); + + $config->appendTo('plugins', 'plugin3'); + + $serialized = \serialize($config); + /** @var Config $unserialized */ + $unserialized = \unserialize($serialized); + + $plugins = $unserialized->get('plugins'); + + $this->assertIsArray($plugins); + $this->assertContains('plugin3', $plugins); + } + + // ========================================================================= + // SCENARIO 14: var_export / __set_state behavior (if implemented) + // ========================================================================= + + public function testVarExportAfterNodeManipulation(): void + { + $config = $this->makeInstance(['plugins' => ['plugin1', 'plugin2']]); + + $config->appendTo('plugins', 'plugin3'); + + // var_export uses getArrayCopy internally for ArrayObject + $exported = \var_export($config, true); + + $this->assertStringContainsString('plugin3', $exported); + } + + // ========================================================================= + // SCENARIO 15: traverse() method and storage synchronization + // ========================================================================= + + public function testForeachAfterTraverseModifiesValueReturnsUpdatedData(): void + { + $config = $this->makeInstance([ + 'key1' => 'value1', + 'key2' => 'value2' + ]); + + // Modify values via traverse + $config->traverse(static function (&$value, $key): void { + if (\is_string($value)) { + $value = \strtoupper($value); + } + }); + + $result = []; + foreach ($config as $key => $value) { + $result[$key] = $value; + } + + $this->assertEquals('VALUE1', $result['key1']); + $this->assertEquals('VALUE2', $result['key2']); + } + + public function testForeachAfterTraverseRemovesNodeReturnsUpdatedData(): void + { + $config = $this->makeInstance([ + 'key1' => 'value1', + 'key2' => 'value2', + 'key3' => 'value3' + ]); + + // Remove a node via traverse using SignalCode::REMOVE_NODE + $config->traverse(static function ($value, $key): ?int { + if ($key === 'key2') { + return \ItalyStrap\Config\SignalCode::REMOVE_NODE; + } + return null; + }); + + $result = []; + foreach ($config as $key => $value) { + $result[$key] = $value; + } + + $this->assertArrayHasKey('key1', $result); + $this->assertArrayNotHasKey('key2', $result); + $this->assertArrayHasKey('key3', $result); + } + + public function testPropertyAccessAfterTraverseModifiesValueReturnsUpdatedData(): void + { + $config = $this->makeInstance([ + 'plugins' => ['plugin1', 'plugin2'] + ]); + + // Modify nested values via traverse + $config->traverse(static function (&$value): void { + if ($value === 'plugin1') { + $value = 'modified_plugin1'; + } + }); + + /** @var array $plugins */ + $plugins = $config->plugins; + + $this->assertContains('modified_plugin1', $plugins); + } + + public function testCountAfterTraverseRemovesNodeReturnsUpdatedCount(): void + { + $config = $this->makeInstance([ + 'key1' => 'value1', + 'key2' => 'value2', + 'key3' => 'value3' + ]); + + $config->traverse(static function ($value, $key): ?int { + if ($key === 'key2') { + return \ItalyStrap\Config\SignalCode::REMOVE_NODE; + } + return null; + }); + + $this->assertCount(2, $config); + } + + public function testGetArrayCopyAfterTraverseModifiesValueReturnsUpdatedData(): void + { + $config = $this->makeInstance([ + 'nested' => [ + 'a' => 1, + 'b' => 2 + ] + ]); + + $config->traverse(static function (&$value): void { + if (\is_int($value)) { + $value *= 10; + } + }); + + $copy = $config->getArrayCopy(); + + $this->assertEquals(10, $copy['nested']['a']); + $this->assertEquals(20, $copy['nested']['b']); + } + + public function testJsonSerializeAfterTraverseRemovesNodeReturnsUpdatedData(): void + { + $config = $this->makeInstance([ + 'keep' => 'value', + 'remove' => 'this' + ]); + + $config->traverse(static function ($value, $key): ?int { + if ($key === 'remove') { + return \ItalyStrap\Config\SignalCode::REMOVE_NODE; + } + return null; + }); + + $json = \json_encode($config); + $decoded = \json_decode($json, true); + + $this->assertArrayHasKey('keep', $decoded); + $this->assertArrayNotHasKey('remove', $decoded); + } + + public function testArrayAccessAfterTraverseReturnsUpdatedData(): void + { + $config = $this->makeInstance([ + 'items' => ['a', 'b', 'c'] + ]); + + $config->traverse(static function (&$value): void { + if ($value === 'b') { + $value = 'B_MODIFIED'; + } + }); + + $items = $config['items']; + + $this->assertContains('B_MODIFIED', $items); + } +} From 219252cf5559325713a56537dcdd64c94a2d46f4 Mon Sep 17 00:00:00 2001 From: Enea Date: Mon, 29 Dec 2025 12:27:11 +0100 Subject: [PATCH 09/14] feat: add node manipulation methods with examples in the Config class --- example.php | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/example.php b/example.php index 49f2a3e..0131f96 100644 --- a/example.php +++ b/example.php @@ -178,3 +178,74 @@ static function (&$current, $key, ConfigInterface $config, array $keyPath) use ( ); $style->listing($visited); $style->newLine(); + +// ============================================================================= +// Node Manipulation Methods +// ============================================================================= + +$style->section('Node Manipulation Methods'); + +$style->writeln('appendTo - Add values to the end of an array:'); +$config = new Config([ + 'plugins' => ['plugin1', 'plugin2'], +]); +$style->text('Initial plugins: ' . \implode(', ', $config->get('plugins'))); +$config->appendTo('plugins', 'plugin3'); +$style->text('After appendTo("plugins", "plugin3"): ' . \implode(', ', $config->get('plugins'))); +$config->appendTo('plugins', ['plugin4', 'plugin5']); +$style->text('After appendTo("plugins", ["plugin4", "plugin5"]): ' . \implode(', ', $config->get('plugins'))); +$style->newLine(); + +$style->writeln('prependTo - Add values to the beginning of an array:'); +$config = new Config([ + 'queue' => ['task2', 'task3'], +]); +$style->text('Initial queue: ' . \implode(', ', $config->get('queue'))); +$config->prependTo('queue', 'task1'); +$style->text('After prependTo("queue", "task1"): ' . \implode(', ', $config->get('queue'))); +$config->prependTo('queue', ['urgent1', 'urgent2']); +$style->text('After prependTo("queue", ["urgent1", "urgent2"]): ' . \implode(', ', $config->get('queue'))); +$style->newLine(); + +$style->writeln('insertAt - Insert values at a specific position:'); +$config = new Config([ + 'steps' => ['step1', 'step3', 'step4'], +]); +$style->text('Initial steps: ' . \implode(', ', $config->get('steps'))); +$config->insertAt('steps', 'step2', 1); +$style->text('After insertAt("steps", "step2", 1): ' . \implode(', ', $config->get('steps'))); +$config->insertAt('steps', ['step2a', 'step2b'], 2); +$style->text('After insertAt("steps", ["step2a", "step2b"], 2): ' . \implode(', ', $config->get('steps'))); +$style->newLine(); + +$style->writeln('deleteFrom - Remove values from an array:'); +$config = new Config([ + 'tags' => ['php', 'javascript', 'python', 'ruby', 'go'], +]); +$style->text('Initial tags: ' . \implode(', ', $config->get('tags'))); +$config->deleteFrom('tags', 'javascript'); +$style->text('After deleteFrom("tags", "javascript"): ' . \implode(', ', $config->get('tags'))); +$config->deleteFrom('tags', ['python', 'ruby']); +$style->text('After deleteFrom("tags", ["python", "ruby"]): ' . \implode(', ', $config->get('tags'))); +$style->newLine(); + +$style->writeln('Node manipulation with dot notation:'); +$config = new Config([ + 'settings' => [ + 'features' => ['feature1', 'feature2'], + ], +]); +$style->text('Initial settings.features: ' . \implode(', ', $config->get('settings.features'))); +$config->appendTo('settings.features', 'feature3'); +$style->text('After appendTo("settings.features", "feature3"): ' . \implode(', ', $config->get('settings.features'))); +$config->deleteFrom('settings.features', 'feature1'); +$style->text('After deleteFrom("settings.features", "feature1"): ' . \implode(', ', $config->get('settings.features'))); +$style->newLine(); + +$style->writeln('Creating new arrays with appendTo:'); +$config = new Config([]); +$config->appendTo('newList', 'firstItem'); +$style->text('appendTo on non-existent key creates array: ' . \implode(', ', $config->get('newList'))); +$config->appendTo('newList', 'secondItem'); +$style->text('After another appendTo: ' . \implode(', ', $config->get('newList'))); +$style->newLine(); From c24b41b8d45cb12577825cd976c9ed2c14714288 Mon Sep 17 00:00:00 2001 From: Enea Date: Mon, 29 Dec 2025 12:47:36 +0100 Subject: [PATCH 10/14] feat: document deleteFrom behavior in tests and update usage notes --- docs/03_node-manipulation.md | 43 +++++++++- tests/unit/NodeManipulationTest.php | 123 ++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 1 deletion(-) diff --git a/docs/03_node-manipulation.md b/docs/03_node-manipulation.md index 103ea34..e50436e 100644 --- a/docs/03_node-manipulation.md +++ b/docs/03_node-manipulation.md @@ -24,6 +24,11 @@ These methods are useful when you want to: - [Example 4: Removing values from a list](#example-4-removing-values-from-a-list) - [Example 5: Using `traverse()` for bulk cleanup](#example-5-using-traverse-for-bulk-cleanup) - [Important Notes](#important-notes) + - [1) These methods work on lists (arrays)](#1-these-methods-work-on-lists-arrays) + - [2) Duplicates are allowed](#2-duplicates-are-allowed) + - [3) `deleteFrom()` removes only the first occurrence](#3-deletefrom-removes-only-the-first-occurrence) + - [4) `deleteFrom()` searches by VALUE, not by KEY](#4-deletefrom-searches-by-value-not-by-key) + - [5) Integers, strings, and strict comparisons](#5-integers-strings-and-strict-comparisons) - [Conclusion](#conclusion) --- @@ -280,7 +285,43 @@ This behavior is intentionally similar to list semantics in other languages. If you need to remove all occurrences, use `traverse()` or perform repeated `deleteFrom()` calls. -### 4) Integers, strings, and strict comparisons +### 4) `deleteFrom()` searches by VALUE, not by KEY + +`deleteFrom()` is designed for **list manipulation**, not associative array key removal. + +It uses `array_search()` internally, which means: + +- It searches for the **value** you want to remove, not the key. +- Passing a key name will **not** remove that key; it will search for an element whose value equals that key name. + +```php +$config = new Config([ + 'plugins' => [ + 'plugin1' => 'value1', + 'plugin2' => 'value2', + ], +]); + +// This does NOT remove the 'plugin1' key! +// It searches for an element with value 'plugin1' (which doesn't exist) +$config->deleteFrom('plugins', 'plugin1'); +// Result: ['plugin1' => 'value1', 'plugin2' => 'value2'] (unchanged) + +// To remove by value, pass the actual value: +$config->deleteFrom('plugins', 'value1'); +// Result: ['plugin2' => 'value2'] + +// To remove by key, use the delete() method instead: +$config->delete('plugins.plugin1'); +// or +$config->delete(['plugins', 'plugin1']); +``` + +**Rule of thumb:** +- Use `deleteFrom()` for **sequential lists** where you want to remove items by their value. +- Use `delete()` for **associative arrays** where you want to remove items by their key. + +### 5) Integers, strings, and strict comparisons `deleteFrom()` uses strict comparisons (`array_search(..., true)`), so: diff --git a/tests/unit/NodeManipulationTest.php b/tests/unit/NodeManipulationTest.php index f3e0006..bca267d 100644 --- a/tests/unit/NodeManipulationTest.php +++ b/tests/unit/NodeManipulationTest.php @@ -437,6 +437,129 @@ public function testDeleteFromSequentialOperations(): void ); } + // ========================================================================= + // deleteFrom behavior with associative arrays + // These tests document the current behavior where deleteFrom searches by VALUE, not by KEY + // ========================================================================= + + /** + * deleteFrom uses array_search which searches for VALUES, not KEYS. + * When trying to "delete" a key name, it will only work if that key name + * happens to also exist as a VALUE in the array. + */ + public function testDeleteFromSearchesByValueNotByKey(): void + { + $config = $this->makeInstance([ + 'plugins' => [ + 'pluginA' => 'enabled', + 'pluginB' => 'disabled', + 'pluginC' => 'enabled', + ] + ]); + + // Trying to delete 'pluginA' will search for the VALUE 'pluginA', not the KEY + // Since 'pluginA' is a KEY and not a VALUE, nothing will be deleted + $config->deleteFrom('plugins', 'pluginA'); + + // The array remains unchanged because 'pluginA' is not a value in the array + $this->assertSame([ + 'pluginA' => 'enabled', + 'pluginB' => 'disabled', + 'pluginC' => 'enabled', + ], $config->get('plugins')); + } + + /** + * When the value to delete matches an actual VALUE in the associative array, + * it removes the element with that value. + */ + public function testDeleteFromRemovesElementByValueInAssociativeArray(): void + { + $config = $this->makeInstance([ + 'plugins' => [ + 'pluginA' => 'enabled', + 'pluginB' => 'disabled', + 'pluginC' => 'enabled', + ] + ]); + + // Delete 'disabled' will remove the element with value 'disabled' + $config->deleteFrom('plugins', 'disabled'); + + // pluginB is removed because its VALUE was 'disabled' + $this->assertSame([ + 'pluginA' => 'enabled', + 'pluginC' => 'enabled', + ], $config->get('plugins')); + } + + /** + * When multiple elements have the same value, only the first occurrence is removed. + */ + public function testDeleteFromRemovesFirstOccurrenceInAssociativeArray(): void + { + $config = $this->makeInstance([ + 'plugins' => [ + 'pluginA' => 'enabled', + 'pluginB' => 'enabled', + 'pluginC' => 'disabled', + ] + ]); + + // Delete 'enabled' will only remove the first occurrence (pluginA) + $config->deleteFrom('plugins', 'enabled'); + + $this->assertSame([ + 'pluginB' => 'enabled', + 'pluginC' => 'disabled', + ], $config->get('plugins')); + } + + /** + * Boolean values in associative arrays - deleteFrom can remove by boolean value. + */ + public function testDeleteFromWithBooleanValuesInAssociativeArray(): void + { + $config = $this->makeInstance([ + 'features' => [ + 'darkMode' => true, + 'notifications' => false, + 'autoSave' => true, + ] + ]); + + // Delete false will remove the element with value false + $config->deleteFrom('features', false); + + $this->assertSame([ + 'darkMode' => true, + 'autoSave' => true, + ], $config->get('features')); + } + + /** + * Mixed array with string keys and numeric indices. + */ + public function testDeleteFromWithMixedKeysArray(): void + { + $config = $this->makeInstance([ + 'items' => [ + 'named' => 'namedValue', + 0 => 'indexedValue0', + 1 => 'indexedValue1', + ] + ]); + + // Delete 'indexedValue0' removes the element at index 0 + $config->deleteFrom('items', 'indexedValue0'); + + // Note: array_merge in deleteFrom reindexes numeric keys + $result = $config->get('items'); + $this->assertArrayHasKey('named', $result); + $this->assertContains('indexedValue1', $result); + $this->assertNotContains('indexedValue0', $result); + } + public function testComplexArrayBehaviourWithMixedTypes(): void { $config = $this->makeInstance(); From b801493c1e6473e7aa6b64a9bceb25e31440b26b Mon Sep 17 00:00:00 2001 From: Enea Date: Mon, 29 Dec 2025 16:02:13 +0100 Subject: [PATCH 11/14] test: update NodeManipulationTest to handle insertion at position greater than array length --- .github/workflows/test.yml | 7 ++----- tests/unit/NodeManipulationTest.php | 8 ++++++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b85695d..803c173 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,18 +14,15 @@ jobs: runs-on: ubuntu-latest if: "!contains(github.event.head_commit.message, '--skip ci') && !github.event.pull_request.draft" - continue-on-error: ${{ matrix.php_versions == '8.4' }} + continue-on-error: ${{ matrix.php_versions == '8.1' }} strategy: matrix: php_versions: - "7.4" - "8.0" - - "8.1" - - "8.2" - - "8.3" include: - - php-version: "8.4" + - php-version: "8.1" composer-options: "--ignore-platform-reqs" steps: - name: Checkout diff --git a/tests/unit/NodeManipulationTest.php b/tests/unit/NodeManipulationTest.php index bca267d..ee9e445 100644 --- a/tests/unit/NodeManipulationTest.php +++ b/tests/unit/NodeManipulationTest.php @@ -274,6 +274,14 @@ public static function insertAtDataProvider(): \Generator 1, ['value1', 'value2', 'value3'], ]; + + yield 'Insert at position greater than array length appends to end' => [ + ['items' => ['apple', 'banana']], + 'items', + 'orange', + 5, + ['apple', 'banana', 'orange'], + ]; } /** From 6c732135ab4c4fe2b73e0ddc04b168adb7e5931b Mon Sep 17 00:00:00 2001 From: Enea Date: Mon, 29 Dec 2025 17:59:25 +0100 Subject: [PATCH 12/14] docs: clarify behavior of deleteFrom method in documentation --- docs/03_node-manipulation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/03_node-manipulation.md b/docs/03_node-manipulation.md index e50436e..ca9456e 100644 --- a/docs/03_node-manipulation.md +++ b/docs/03_node-manipulation.md @@ -120,7 +120,7 @@ Current behavior: // Initial state: ['items' => ['apple', 'banana', 'orange', 'banana']] $config->deleteFrom('items', 'banana'); -// ['items' => ['apple', 'orange', 'banana']] +// ['items' => ['apple', 'orange', 'banana']] // only the first 'banana' is removed $config->deleteFrom('items', ['banana', 'orange']); // ['items' => ['apple']] From 6a2d24167db7a7c56bdd73e6282101dccf84b197 Mon Sep 17 00:00:00 2001 From: Enea Date: Mon, 29 Dec 2025 18:48:39 +0100 Subject: [PATCH 13/14] fix: update deleteFrom method to return result of deleteValue operation --- src/Config.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Config.php b/src/Config.php index bd60081..59a4eea 100644 --- a/src/Config.php +++ b/src/Config.php @@ -237,9 +237,9 @@ public function deleteFrom($key, $value): bool } if ($oldValue === []) { - $this->deleteValue($this->storage, $levels); + $result = $this->deleteValue($this->storage, $levels); parent::exchangeArray($this->storage); - return true; + return $result; } $oldValue = \array_merge($oldValue); From 20d956a98ba1b55f28a13d7a09ffcb70482477b8 Mon Sep 17 00:00:00 2001 From: Enea Date: Mon, 29 Dec 2025 19:09:40 +0100 Subject: [PATCH 14/14] fix: replace assertEquals with assertSame for stricter comparison in tests --- tests/unit/StorageSynchronizationTest.php | 32 +++++++++++------------ 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/unit/StorageSynchronizationTest.php b/tests/unit/StorageSynchronizationTest.php index 862cce4..e9ec8b4 100644 --- a/tests/unit/StorageSynchronizationTest.php +++ b/tests/unit/StorageSynchronizationTest.php @@ -34,7 +34,7 @@ public function testForeachAfterSetReturnsUpdatedData(): void } $this->assertArrayHasKey('key2', $result); - $this->assertEquals('value2', $result['key2']); + $this->assertSame('value2', $result['key2']); } public function testForeachAfterDeleteReturnsUpdatedData(): void @@ -60,7 +60,7 @@ public function testPropertyAccessAfterSetReturnsUpdatedData(): void /** @var string $value */ $value = $config->key2; - $this->assertEquals('value2', $value); + $this->assertSame('value2', $value); } public function testPropertyAccessAfterDeleteReturnsUpdatedData(): void @@ -86,7 +86,7 @@ public function testGetIteratorAfterSetReturnsUpdatedData(): void $result = \iterator_to_array($iterator); $this->assertArrayHasKey('key2', $result); - $this->assertEquals('value2', $result['key2']); + $this->assertSame('value2', $result['key2']); } public function testGetIteratorAfterDeleteReturnsUpdatedData(): void @@ -128,7 +128,7 @@ public function testGetArrayCopyAfterSetReturnsUpdatedData(): void $copy = $config->getArrayCopy(); $this->assertArrayHasKey('key2', $copy); - $this->assertEquals('value2', $copy['key2']); + $this->assertSame('value2', $copy['key2']); } public function testGetArrayCopyAfterDeleteReturnsUpdatedData(): void @@ -152,7 +152,7 @@ public function testJsonSerializeAfterSetReturnsUpdatedData(): void $decoded = \json_decode($json, true); $this->assertArrayHasKey('key2', $decoded); - $this->assertEquals('value2', $decoded['key2']); + $this->assertSame('value2', $decoded['key2']); } public function testJsonSerializeAfterDeleteReturnsUpdatedData(): void @@ -199,7 +199,7 @@ public function testForeachAfterPrependToReturnsUpdatedData(): void } $this->assertArrayHasKey('plugins', $result); - $this->assertEquals('plugin0', $result['plugins'][0]); + $this->assertSame('plugin0', $result['plugins'][0]); } public function testForeachAfterInsertAtReturnsUpdatedData(): void @@ -214,7 +214,7 @@ public function testForeachAfterInsertAtReturnsUpdatedData(): void } $this->assertArrayHasKey('plugins', $result); - $this->assertEquals('plugin2', $result['plugins'][1]); + $this->assertSame('plugin2', $result['plugins'][1]); } public function testForeachAfterDeleteFromReturnsUpdatedData(): void @@ -284,7 +284,7 @@ public function testPropertyAccessAfterPrependToReturnsUpdatedData(): void $plugins = $config->plugins; $this->assertIsArray($plugins); - $this->assertEquals('plugin0', $plugins[0]); + $this->assertSame('plugin0', $plugins[0]); } public function testPropertyAccessAfterInsertAtReturnsUpdatedData(): void @@ -297,7 +297,7 @@ public function testPropertyAccessAfterInsertAtReturnsUpdatedData(): void $plugins = $config->plugins; $this->assertIsArray($plugins); - $this->assertEquals('plugin2', $plugins[1]); + $this->assertSame('plugin2', $plugins[1]); } public function testPropertyAccessAfterDeleteFromReturnsUpdatedData(): void @@ -476,7 +476,7 @@ public function testMultipleNodeManipulationsFollowedByIteration(): void $result[$key] = $value; } - $this->assertEquals(['plugin1', 'plugin2', 'plugin2.5', 'plugin3'], $result['plugins']); + $this->assertSame(['plugin1', 'plugin2', 'plugin2.5', 'plugin3'], $result['plugins']); } public function testMixedNodeManipulationsAndStandardSetOperations(): void @@ -489,8 +489,8 @@ public function testMixedNodeManipulationsAndStandardSetOperations(): void $result = $config->toArray(); - $this->assertEquals(['plugin2'], $result['plugins']); - $this->assertEquals('newValue', $result['newKey']); + $this->assertSame(['plugin2'], $result['plugins']); + $this->assertSame('newValue', $result['newKey']); } // ========================================================================= @@ -694,8 +694,8 @@ public function testForeachAfterTraverseModifiesValueReturnsUpdatedData(): void $result[$key] = $value; } - $this->assertEquals('VALUE1', $result['key1']); - $this->assertEquals('VALUE2', $result['key2']); + $this->assertSame('VALUE1', $result['key1']); + $this->assertSame('VALUE2', $result['key2']); } public function testForeachAfterTraverseRemovesNodeReturnsUpdatedData(): void @@ -778,8 +778,8 @@ public function testGetArrayCopyAfterTraverseModifiesValueReturnsUpdatedData(): $copy = $config->getArrayCopy(); - $this->assertEquals(10, $copy['nested']['a']); - $this->assertEquals(20, $copy['nested']['b']); + $this->assertSame(10, $copy['nested']['a']); + $this->assertSame(20, $copy['nested']['b']); } public function testJsonSerializeAfterTraverseRemovesNodeReturnsUpdatedData(): void