From 61d317d357645d19c2aa26a362ca93b63ace744a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 24 May 2026 14:41:01 +0300 Subject: [PATCH 1/2] Add editorconfig, gitattributes, CI workflow, php-cs-fixer, gitignore updates --- .editorconfig | 18 + .gitattributes | 14 + .github/workflows/ci.yml | 84 +++ .gitignore | 7 +- .php-cs-fixer.dist.php | 32 ++ README.md | 169 ++++-- composer.json | 31 +- docs/README.md | 37 ++ docs/api-reference.md | 183 +++++++ docs/configuration.md | 85 +++ docs/exceptions.md | 38 ++ docs/faq.md | 82 +++ docs/getting-started.md | 46 ++ docs/recipes/config-loader.md | 59 ++ docs/recipes/dependency-injection.md | 72 +++ docs/recipes/request-parameters.md | 70 +++ docs/upgrading-from-v1.md | 156 ++++++ docs/usage/basic-usage.md | 90 +++ docs/usage/case-sensitivity.md | 66 +++ docs/usage/iteration-and-counting.md | 80 +++ docs/usage/merging.md | 72 +++ docs/usage/nested-data.md | 110 ++++ phpstan.neon.dist | 7 + phpunit.xml.dist | 23 + .../ParameterBagInvalidArgumentException.php | 27 +- src/ParameterBag.php | 515 +++++++++++++----- src/ParameterBagInterface.php | 134 ++++- tests/AdditionalApiTest.php | 87 +++ tests/CacheCoherenceTest.php | 64 +++ tests/CaseSensitivityTest.php | 110 ++++ tests/ConstructorTest.php | 81 +++ tests/EdgeCasesTest.php | 171 ++++++ tests/InfrastructureTest.php | 24 + tests/MergeTest.php | 119 ++++ tests/OptionsValidationTest.php | 53 ++ tests/RemoveTest.php | 90 +++ tests/SentinelTest.php | 56 ++ tests/StdInterfacesTest.php | 148 +++++ tests/ValueIntegrityTest.php | 57 ++ 39 files changed, 3151 insertions(+), 216 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .php-cs-fixer.dist.php create mode 100644 docs/README.md create mode 100644 docs/api-reference.md create mode 100644 docs/configuration.md create mode 100644 docs/exceptions.md create mode 100644 docs/faq.md create mode 100644 docs/getting-started.md create mode 100644 docs/recipes/config-loader.md create mode 100644 docs/recipes/dependency-injection.md create mode 100644 docs/recipes/request-parameters.md create mode 100644 docs/upgrading-from-v1.md create mode 100644 docs/usage/basic-usage.md create mode 100644 docs/usage/case-sensitivity.md create mode 100644 docs/usage/iteration-and-counting.md create mode 100644 docs/usage/merging.md create mode 100644 docs/usage/nested-data.md create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 tests/AdditionalApiTest.php create mode 100644 tests/CacheCoherenceTest.php create mode 100644 tests/CaseSensitivityTest.php create mode 100644 tests/ConstructorTest.php create mode 100644 tests/EdgeCasesTest.php create mode 100644 tests/InfrastructureTest.php create mode 100644 tests/MergeTest.php create mode 100644 tests/OptionsValidationTest.php create mode 100644 tests/RemoveTest.php create mode 100644 tests/SentinelTest.php create mode 100644 tests/StdInterfacesTest.php create mode 100644 tests/ValueIntegrityTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4640e2b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[composer.json] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c426d89 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +* text=auto eol=lf + +# Files to exclude from the distributed Composer package. +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.github export-ignore +/.php-cs-fixer.dist.php export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore +/docs export-ignore +/CONTRIBUTING.md export-ignore +/CODE_OF_CONDUCT.md export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0fefc51 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI + +on: + push: + branches: [main, "feat/*", "fix/*"] + pull_request: + branches: [main] + +jobs: + tests: + name: PHP ${{ matrix.php }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + php: ["7.4", "8.0", "8.1", "8.2", "8.3", "8.4"] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: xdebug + tools: composer:v2 + + - name: Validate composer.json + run: composer validate --strict + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-php${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: | + ${{ runner.os }}-php${{ matrix.php }}-composer- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run test suite + run: vendor/bin/phpunit --testdox + + static-analysis: + name: Static analysis (PHPStan) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: PHPStan + run: vendor/bin/phpstan analyse --no-progress + + code-style: + name: Code style (PHP-CS-Fixer) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: PHP-CS-Fixer (dry-run) + run: vendor/bin/php-cs-fixer fix --dry-run --diff diff --git a/.gitignore b/.gitignore index a879886..f91d99e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,9 @@ /.vs/ /.vscode/ /vendor/ -/composer.lock \ No newline at end of file +/composer.lock +/.phpunit.cache/ +/.phpunit.result.cache +/.php-cs-fixer.cache +/build/ +/coverage/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..549e048 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,32 @@ +in([__DIR__ . '/src', __DIR__ . '/tests']) + ->name('*.php'); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@PSR12' => true, + '@PHP74Migration' => true, + 'declare_strict_types' => true, + 'array_syntax' => ['syntax' => 'short'], + 'no_unused_imports' => true, + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'single_quote' => true, + 'trailing_comma_in_multiline' => true, + 'no_trailing_whitespace' => true, + 'no_whitespace_in_blank_line' => true, + 'blank_line_after_opening_tag' => true, + 'blank_line_before_statement' => ['statements' => ['return']], + 'method_chaining_indentation' => true, + 'native_function_invocation' => false, + 'phpdoc_align' => ['align' => 'vertical'], + 'phpdoc_separation' => true, + 'phpdoc_trim' => true, + 'no_superfluous_phpdoc_tags' => false, + ]) + ->setFinder($finder) + ->setCacheFile(__DIR__ . '/.php-cs-fixer.cache'); diff --git a/README.md b/README.md index a7674a3..5257f6a 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,164 @@ # InitPHP ParameterBag -Single and multi-dimensional parameter bag. +A small, dependency-free parameter container for PHP that handles both +flat and nested (dotted-path) data with the same API. -[![Latest Stable Version](http://poser.pugx.org/initphp/parameterbag/v)](https://packagist.org/packages/initphp/parameterbag) [![Total Downloads](http://poser.pugx.org/initphp/parameterbag/downloads)](https://packagist.org/packages/initphp/parameterbag) [![Latest Unstable Version](http://poser.pugx.org/initphp/parameterbag/v/unstable)](https://packagist.org/packages/initphp/parameterbag) [![License](http://poser.pugx.org/initphp/parameterbag/license)](https://packagist.org/packages/initphp/parameterbag) [![PHP Version Require](http://poser.pugx.org/initphp/parameterbag/require/php)](https://packagist.org/packages/initphp/parameterbag) +[![Latest Stable Version](https://poser.pugx.org/initphp/parameterbag/v)](https://packagist.org/packages/initphp/parameterbag) +[![Total Downloads](https://poser.pugx.org/initphp/parameterbag/downloads)](https://packagist.org/packages/initphp/parameterbag) +[![CI](https://github.com/InitPHP/ParameterBag/actions/workflows/ci.yml/badge.svg)](https://github.com/InitPHP/ParameterBag/actions/workflows/ci.yml) +[![License](https://poser.pugx.org/initphp/parameterbag/license)](https://packagist.org/packages/initphp/parameterbag) +[![PHP Version Require](https://poser.pugx.org/initphp/parameterbag/require/php)](https://packagist.org/packages/initphp/parameterbag) ![parameterbag](https://initphp.github.io/logos/parameterbag.png) -## Installation +--- -``` -composer require initphp/parameterbag -``` +## Features -## Requirements +- Single API for flat and nested data; nesting is auto-detected from + the constructor payload or toggled explicitly. +- Dotted-path access (`$bag->get('database.user')`) with a + configurable separator. +- Optional, opt-in case-insensitive key handling. +- Implements PHP's standard collection contracts: `ArrayAccess`, + `Countable`, `IteratorAggregate`. +- Strict validation of constructor options (typos throw instead of + being silently ignored). +- Zero runtime dependencies; PHPStan level 8 clean. -- PHP 7.2 or later - -## Usage +## Requirements -```php -require_once "vendor/autoload.php"; -use \InitPHP\ParameterBag\ParameterBag; +- PHP 7.4 or later (including 8.0–8.4) -$parameter = new ParameterBag($_GET); +## Installation -// GET /?user=muhametsafak -echo $parameter->get('user', null); // "muhametsafak" +```bash +composer require initphp/parameterbag ``` -### Using nested arrays +## Quick start ```php -require_once "vendor/autoload.php"; -use \InitPHP\ParameterBag\ParameterBag; +use InitPHP\ParameterBag\ParameterBag; -$data = [ - 'database' => [ - 'dsn' => 'mysql:host=localhost', - 'username' => 'root', - 'password' => '123456' - ] -]; +$bag = new ParameterBag($_GET); -$parameter = new ParameterBag($data, ['isMulti' => true, 'separator' => '.']); +// GET /?user=alice +echo $bag->get('user', 'guest'); // 'alice' -$parameter->get('database.username'); // "root" -$parameter->has('database.charset'); // false +$bag->set('locale', 'en_US')->set('debug', true); +$bag->has('debug'); // true +$bag->remove('debug'); ``` -### Methods +### Nested data (multi mode) -#### `has()` +Pass a nested array (or set `isMulti => true` explicitly) and the bag +will treat the separator (`.` by default) as a path delimiter: ```php -public function has(string $key): bool; +$config = new ParameterBag([ + 'database' => [ + 'dsn' => 'mysql:host=localhost', + 'username' => 'root', + 'password' => 'secret', + ], +]); + +$config->get('database.username'); // 'root' +$config->has('database.charset'); // false +$config->set('database.charset', 'utf8mb4'); +$config->remove('database.password'); ``` -#### `get()` +Use a custom separator if dots are part of your keys: ```php -public function get(string $key, mixed $default = null): mixed; +$bag = new ParameterBag($data, ['separator' => '|']); +$bag->get('database|username'); ``` -#### `set()` +### Native PHP idioms ```php -public function set(string $key, mixed $value): \InitPHP\ParameterBag\ParameterBagInterface; -``` +$bag = new ParameterBag(['a' => 1, 'b' => 2]); -#### `remove()` +count($bag); // 2 +$bag['c'] = 3; // ArrayAccess write +isset($bag['c']); // true +foreach ($bag as $key => $value) { /* ... */ } +``` -```php -public function remove(string ...$keys): \InitPHP\ParameterBag\ParameterBagInterface; +## Public API + +| Method | Purpose | Docs | +| --- | --- | --- | +| `get(string $key, mixed $default = null): mixed` | Look up a value (dotted paths in multi mode). | [usage/basic-usage](docs/usage/basic-usage.md) | +| `has(string $key): bool` | Existence check (null values count as present). | [usage/basic-usage](docs/usage/basic-usage.md) | +| `set(string $key, mixed $value): self` | Assign or replace a value. | [usage/basic-usage](docs/usage/basic-usage.md) | +| `remove(string ...$keys): self` | Delete one or more keys. | [usage/basic-usage](docs/usage/basic-usage.md) | +| `merge(array\|ParameterBagInterface ...$payloads): self` | Shallow merge (flat) or recursive replace (multi). | [usage/merging](docs/usage/merging.md) | +| `replace(array $data): self` | Swap the entire stack. | [api-reference](docs/api-reference.md) | +| `all(): array` | Return the current stack as a plain array. | [api-reference](docs/api-reference.md) | +| `keys(): array` / `values(): array` | Top-level keys / values in insertion order. | [api-reference](docs/api-reference.md) | +| `count(): int` | Top-level entry count (also via `count($bag)`). | [usage/iteration-and-counting](docs/usage/iteration-and-counting.md) | +| `getIterator(): ArrayIterator` | Iterates top-level entries. | [usage/iteration-and-counting](docs/usage/iteration-and-counting.md) | +| `isEmpty(): bool` | True when the stack has no entries. | [api-reference](docs/api-reference.md) | +| `clear(): void` | Empty the stack, keep options. | [api-reference](docs/api-reference.md) | +| `close(): void` | Empty the stack and reset options to defaults. | [api-reference](docs/api-reference.md) | + +## Configuration options + +The constructor accepts a second array of options. Unknown keys raise +`ParameterBagInvalidArgumentException`. + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `isMulti` | `bool` | auto-detected from `$data` | Enables dotted-path semantics. | +| `separator` | `non-empty-string` | `'.'` | Delimiter for dotted paths. Ignored in flat mode. | +| `caseInsensitive` | `bool` | `false` | When true, every key (constructor payload, set/get/has/remove arguments, merge input) is folded to lower-case. Matches the legacy v1 behaviour. | + +See [docs/configuration.md](docs/configuration.md) and +[docs/usage/case-sensitivity.md](docs/usage/case-sensitivity.md). + +## Exceptions + +| Exception | Raised when | +| --- | --- | +| `InitPHP\ParameterBag\Exception\ParameterBagInvalidArgumentException` | Unknown option key, non-array/non-ParameterBag argument to `merge()`, or `$bag[] = $v` ArrayAccess append. Extends `\InvalidArgumentException`. | + +See [docs/exceptions.md](docs/exceptions.md). + +## Development + +```bash +composer install +composer test # PHPUnit +composer analyse # PHPStan (level 8) +composer cs:check # PHP-CS-Fixer dry-run +composer cs:fix # PHP-CS-Fixer apply ``` -#### `all()` +CI runs the matrix across PHP 7.4, 8.0, 8.1, 8.2, 8.3, and 8.4. -```php -public function all(): array; -``` +## Upgrading from v1 -#### `merge()` +v2 introduces a small set of intentional behaviour changes (cache +removed, `isMulti` auto-detect inverted, value-trim bug fixed, +case-sensitive by default, strict option validation, new methods). +A full migration guide lives at +[docs/upgrading-from-v1.md](docs/upgrading-from-v1.md). -```php -public function merge(array|\InitPHP\ParameterBag\ParameterBagInterface ...$merge): \InitPHP\ParameterBag\ParameterBagInterface; -``` +## Contributing & Security + +- [Contributing guidelines](https://github.com/InitPHP/.github/blob/main/CONTRIBUTING.md) +- [Code of Conduct](https://github.com/InitPHP/.github/blob/main/CODE_OF_CONDUCT.md) +- [Security policy](https://github.com/InitPHP/.github/blob/main/SECURITY.md) ## Credits -- [Muhammet ŞAFAK](https://www.muhammetsafak.com.tr) <> +- [Muhammet ŞAFAK](https://www.muhammetsafak.com.tr) <> ## License -Copyright © 2022 - [MIT License](./LICENSE) +Released under the [MIT License](./LICENSE). diff --git a/composer.json b/composer.json index 158ba2a..3300442 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,25 @@ { "name": "initphp/parameterbag", - "description": "InitPHP Parameter Bag Library", + "description": "Single and multi-dimensional parameter bag with dot-path access for PHP.", "type": "library", "license": "MIT", + "keywords": [ + "parameter-bag", + "config", + "container", + "dot-notation", + "initphp" + ], "autoload": { "psr-4": { "InitPHP\\ParameterBag\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "InitPHP\\ParameterBag\\Tests\\": "tests/" + } + }, "support": { "source": "https://github.com/InitPHP/ParameterBag", "issues": "https://github.com/InitPHP/ParameterBag/issues" @@ -22,6 +34,21 @@ ], "minimum-stability": "stable", "require": { - "php": ">=7.2" + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6", + "phpstan/phpstan": "^1.12", + "friendsofphp/php-cs-fixer": "^3.64" + }, + "scripts": { + "test": "phpunit", + "test:coverage": "phpunit --coverage-html=build/coverage", + "analyse": "phpstan analyse", + "cs:check": "php-cs-fixer fix --dry-run --diff", + "cs:fix": "php-cs-fixer fix" + }, + "config": { + "sort-packages": true } } diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e41f4a1 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,37 @@ +# Documentation + +Developer documentation for the `initphp/parameterbag` package. The +project [README](../README.md) gives a one-page overview; this +directory goes deeper. + +## Index + +- [Getting started](getting-started.md) — install, instantiate, and + read your first parameter. +- **Usage** + - [Basic usage](usage/basic-usage.md) — `get`, `set`, `has`, + `remove` in flat mode. + - [Nested data](usage/nested-data.md) — multi-mode and dotted paths. + - [Merging](usage/merging.md) — flat vs. recursive merge. + - [Iteration & counting](usage/iteration-and-counting.md) — + `ArrayAccess`, `Countable`, `IteratorAggregate`. + - [Case sensitivity](usage/case-sensitivity.md) — the + `caseInsensitive` option. +- [Configuration options](configuration.md) — full options reference. +- [API reference](api-reference.md) — every public method, listed. +- [Exceptions](exceptions.md) — when and why the package throws. +- **Recipes** + - [Config loader](recipes/config-loader.md) — load a PHP config file + and expose it as a bag. + - [Request parameters](recipes/request-parameters.md) — wrap + `$_GET` / `$_POST`. + - [Dependency injection](recipes/dependency-injection.md) — inject + a `ParameterBagInterface` into your services. +- [Upgrading from v1](upgrading-from-v1.md) — BC notes for v2. +- [FAQ](faq.md) — common pitfalls and clarifications. + +## How to read these docs + +Every page is structured as **Goal → Working example → Expected output +→ Common mistakes**. Snippets are copy-paste ready against the +released package; outputs were generated against the test suite. diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..6518bf5 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,183 @@ +# API reference + +Every public method on `InitPHP\ParameterBag\ParameterBag` and +`InitPHP\ParameterBag\ParameterBagInterface`. Behavioural details are +linked into the [Usage](usage/) section. + +## Constructor + +```php +public function __construct(array $data = [], array $options = []); +``` + +Initialises the bag with `$data` and applies `$options`. See +[Configuration options](configuration.md). Throws +`ParameterBagInvalidArgumentException` if any unknown option key is +supplied. + +## `get` + +```php +public function get(string $key, mixed $default = null): mixed; +``` + +Returns the value at `$key`, or `$default` if the key is absent. +Dotted paths walk nested arrays when `isMulti` is on. A scalar leaf +at a non-leaf path returns `$default` (the bag does not probe into +strings). See [Basic usage](usage/basic-usage.md) and +[Nested data](usage/nested-data.md). + +## `has` + +```php +public function has(string $key): bool; +``` + +`true` when the key exists, even when the stored value is `null`. + +## `set` + +```php +public function set(string $key, mixed $value): self; +``` + +Assigns `$value` to `$key`. Array values are normalised through the +constructor's code path (recursive lower-casing when +`caseInsensitive` is on). Returns `$this` for chaining. + +## `remove` + +```php +public function remove(string ...$keys): self; +``` + +Deletes one or more keys. Missing keys are a no-op. Returns `$this`. + +## `merge` + +```php +public function merge(array|ParameterBagInterface ...$payloads): self; +``` + +Flat mode → `array_merge` (shallow). Multi mode → +`array_replace_recursive`. Empty arguments are skipped silently. +Throws `ParameterBagInvalidArgumentException` if any argument is +neither an array nor a `ParameterBagInterface`. See +[Merging](usage/merging.md). + +## `replace` + +```php +public function replace(array $data): self; +``` + +Swaps the entire stack. Options are preserved. + +## `all` + +```php +public function all(): array; +``` + +Returns the underlying stack as a plain array, preserving nesting. + +## `keys` / `values` + +```php +public function keys(): array; +public function values(): array; +``` + +Top-level keys / values in insertion order. + +## `isEmpty` + +```php +public function isEmpty(): bool; +``` + +`true` when the stack has no top-level entries. + +## `count` + +```php +public function count(): int; +``` + +Top-level entry count. Also available via `count($bag)` (Countable). + +## `getIterator` + +```php +public function getIterator(): \ArrayIterator; +``` + +Yields top-level entries. Iteration is repeatable. See +[Iteration & counting](usage/iteration-and-counting.md). + +## `offsetGet` / `offsetSet` / `offsetExists` / `offsetUnset` + +```php +public function offsetExists(mixed $offset): bool; +public function offsetGet(mixed $offset): mixed; +public function offsetSet(mixed $offset, mixed $value): void; +public function offsetUnset(mixed $offset): void; +``` + +Delegate to `has`, `get`, `set`, and `remove`. Appending without a +key (`$bag[] = $v`) raises +`ParameterBagInvalidArgumentException`. + +## `clear` + +```php +public function clear(): void; +``` + +Empties the stack but leaves options intact. + +## `close` + +```php +public function close(): void; +``` + +Empties the stack AND resets every option to its default +(`isMulti=false`, `separator='.'`, `caseInsensitive=false`). + +## `__debugInfo` + +```php +public function __debugInfo(): array; +``` + +Returns a `var_dump`-friendly snapshot: + +```php +[ + 'isMulti' => 'yes' | 'no', + 'separator' => string, + 'data' => array, +] +``` + +## Protected hooks (subclass override points) + +### `getKey` + +```php +protected function getKey(string $key): string; +``` + +Normalises a caller-supplied key (case fold + separator trim). +Override to change the normalisation policy in a subclass. + +### `setOptions` + +```php +protected function setOptions(array $options): void; +``` + +Applies recognised options. Subclasses that introduce additional +options should override this method, extend the validation, and call +back into `parent::setOptions()`. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..1528fd8 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,85 @@ +# Configuration options + +The `ParameterBag` constructor takes two arguments: + +```php +new ParameterBag(array $data = [], array $options = []); +``` + +`$options` may contain the keys listed below. **Unknown keys raise +`ParameterBagInvalidArgumentException`** — there is no silent +swallowing of typos in v2. + +## `isMulti` + +| | | +| --- | --- | +| Type | `bool` | +| Default | auto-detected from `$data` | +| Effect | Enables dotted-path semantics (and `array_replace_recursive` merge). | + +```php +new ParameterBag([], ['isMulti' => true]); + +// Without the flag, auto-detection looks at $data: +// ['user' => 'a'] → flat (isMulti = false) +// ['db' => ['user' => 'a']] → multi (isMulti = true) +``` + +Pass `['isMulti' => false]` to force flat mode even if `$data` is +nested. + +## `separator` + +| | | +| --- | --- | +| Type | non-empty `string` | +| Default | `'.'` | +| Effect | Delimiter used to split dotted keys in multi mode. | + +```php +new ParameterBag(['db' => ['user' => 'root']], ['separator' => '|']); +// $bag->get('db|user'); // 'root' +``` + +An empty string is rejected silently (the previous value is kept). +The separator must be a non-empty string; multi-character separators +(`'::'`, `'->'`) are allowed. + +## `caseInsensitive` + +| | | +| --- | --- | +| Type | `bool` | +| Default | `false` | +| Effect | When true, every key (constructor payload, `get`/`set`/`has`/`remove` arguments, `merge` input) is folded to lower-case on entry. Matches the legacy v1 behaviour. | + +See [Case sensitivity](usage/case-sensitivity.md) for examples. + +## Strict validation + +```php +new ParameterBag([], ['is_multi' => true]); +// ParameterBagInvalidArgumentException: +// "Unknown ParameterBag option(s): is_multi. Known options: isMulti, separator, caseInsensitive." +``` + +This catches every typo at construction time, which is the only place +options are read. + +## Resetting options + +`close()` resets all three options to their defaults along with +clearing the stack: + +```php +$bag = new ParameterBag( + ['db' => ['user' => 'root']], + ['isMulti' => true, 'separator' => '|', 'caseInsensitive' => true] +); + +$bag->close(); +// Now: isMulti=false, separator='.', caseInsensitive=false, stack=[] +``` + +`clear()` only empties the stack. diff --git a/docs/exceptions.md b/docs/exceptions.md new file mode 100644 index 0000000..20b3a6b --- /dev/null +++ b/docs/exceptions.md @@ -0,0 +1,38 @@ +# Exceptions + +The package throws a single exception type: +`InitPHP\ParameterBag\Exception\ParameterBagInvalidArgumentException`. +It extends `\InvalidArgumentException`, so `catch +(\InvalidArgumentException $e)` blocks continue to work. + +## When it is raised + +| Trigger | Example | +| --- | --- | +| Unknown option key in the constructor | `new ParameterBag([], ['is_multi' => true]);` | +| Non-array, non-ParameterBag argument to `merge()` | `$bag->merge('string');` | +| Array-access append (`$bag[] = $value`) | `$bag[] = 'value';` | + +## Catching it + +```php +use InitPHP\ParameterBag\Exception\ParameterBagInvalidArgumentException; +use InitPHP\ParameterBag\ParameterBag; + +try { + $bag = new ParameterBag([], ['seperator' => '|']); +} catch (ParameterBagInvalidArgumentException $e) { + // Handle the typo. +} +``` + +## Hierarchy + +``` +InvalidArgumentException +└── InitPHP\ParameterBag\Exception\ParameterBagInvalidArgumentException +``` + +If you wrap the package in your own service layer and want callers to +catch a single, domain-specific type, re-throw the exception as a +subclass of your own. diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..c4aea30 --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,82 @@ +# FAQ + +## Why is my dotted key stored verbatim? + +You are in flat mode. Either supply nested data so multi mode is +auto-detected, or pass `['isMulti' => true]` explicitly. See +[Nested data](usage/nested-data.md). + +## Why does `get('user')` return `null` even though I set it as `User`? + +v2 keys are case-sensitive by default. Either store and retrieve +with consistent casing, or opt into the legacy behaviour: + +```php +new ParameterBag($data, ['caseInsensitive' => true]); +``` + +See [Case sensitivity](usage/case-sensitivity.md). + +## Why did my second `merge()` wipe values from the first? + +You are likely in flat mode (or pre-v2). Flat merge is shallow; +multi-mode merge is recursive. See [Merging](usage/merging.md). + +## How do I iterate nested data? + +`foreach` over the bag yields **top-level** entries. To walk a +specific subtree, pull it out with `get()` and recurse: + +```php +foreach ($bag->get('database', []) as $key => $value) { /* ... */ } +``` + +Or call `all()` and recurse over the plain array. + +## How do I append a value without specifying a key? + +You cannot — `$bag[] = $value` raises +`ParameterBagInvalidArgumentException` because the bag is a +string-keyed structure. Use a numerically-keyed array value +instead: + +```php +$bag->set('items', [...($bag->get('items', [])), $newItem]); +``` + +## Is the bag immutable? + +No. Every `set`/`remove`/`merge`/`replace` mutates in place. If you +need immutability, wrap reads in a service that only exposes +`get`/`has`/`count`/iteration, or clone the bag's data via +`all()` before passing it on. + +## Does it serialise / deserialise? + +Yes — internally it is just `array` properties. PHP's +`serialize()`/`unserialize()` work, but the package does not provide +a JSON serializer; convert via `$bag->all()` and `json_encode()`. + +## How do I subclass safely? + +Override the documented `protected` hooks: + +- `getKey(string $key): string` — change the key normalisation + (e.g. snake_case fold, custom trim). +- `setOptions(array $options): void` — add new options. Validate + them, call `parent::setOptions()` to handle the built-in ones. + +Anything else (private properties, private helpers) is internal. + +## Does it depend on any libraries? + +No. The runtime requirement is PHP only. Dev tools (PHPUnit, +PHPStan, PHP-CS-Fixer) live under `require-dev`. + +## Does it work on PHP 7.4 specifically? + +Yes — CI runs the matrix across 7.4, 8.0, 8.1, 8.2, 8.3, and 8.4 on +every PR. The `#[\ReturnTypeWillChange]` attribute on `offsetGet()` +parses as a comment on 7.4 and suppresses the PHP 8.1+ deprecation +warning that the standard `ArrayAccess` interface would otherwise +trigger. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..572adf4 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,46 @@ +# Getting started + +## Install + +```bash +composer require initphp/parameterbag +``` + +Requires PHP 7.4 or later (tested up to 8.4). + +## Your first parameter bag + +```php + 'demo', + 'debug' => true, +]); + +echo $bag->get('app_name'), PHP_EOL; // demo +var_export($bag->has('missing')); // false +$bag->set('app_name', 'production')->remove('debug'); +print_r($bag->all()); +``` + +Expected output: + +``` +demo +false +Array +( + [app_name] => production +) +``` + +## Next steps + +- Walk through the everyday methods in [Basic usage](usage/basic-usage.md). +- Learn how the bag handles nested data in [Nested data](usage/nested-data.md). +- See the full [API reference](api-reference.md) for an exhaustive list. diff --git a/docs/recipes/config-loader.md b/docs/recipes/config-loader.md new file mode 100644 index 0000000..4b5d8e0 --- /dev/null +++ b/docs/recipes/config-loader.md @@ -0,0 +1,59 @@ +# Recipe: config loader + +Goal: load a PHP config file from disk and expose its contents through +a `ParameterBag` so the rest of the application reads it with dotted +paths. + +## The config file + +`config/app.php`: + +```php + [ + 'name' => 'demo', + 'debug' => false, + ], + 'database' => [ + 'dsn' => 'mysql:host=localhost;dbname=demo', + 'username' => 'root', + 'password' => 'secret', + ], +]; +``` + +## The loader + +```php +use InitPHP\ParameterBag\ParameterBag; + +$config = new ParameterBag(require __DIR__ . '/config/app.php'); + +echo $config->get('app.name'); // 'demo' +echo $config->get('database.dsn'); // 'mysql:host=localhost;dbname=demo' +$config->get('cache.driver', 'array'); // 'array' (default) +$config->set('app.debug', true); +``` + +Multi mode is auto-detected from the nested payload — no options are +required. + +## Merging environment overrides + +```php +$config = new ParameterBag(require __DIR__ . '/config/app.php'); +$config->merge(require __DIR__ . '/config/local.php'); +// In multi mode, sibling keys at every depth are preserved. +``` + +## Common pitfalls + +- **Numeric-indexed lists**: if a value is a list (e.g. allowed + hosts), prefer `get('allowed_hosts', [])` rather than dotted access + into the list. Dotted lookups treat numeric keys the same as string + keys, but it reads better. +- **Mutation of injected configs**: a `ParameterBag` is mutable. If + you inject it into many services, document or freeze the contract + (e.g. wrap reads in a service that only calls `get()`). diff --git a/docs/recipes/dependency-injection.md b/docs/recipes/dependency-injection.md new file mode 100644 index 0000000..0cc9d4e --- /dev/null +++ b/docs/recipes/dependency-injection.md @@ -0,0 +1,72 @@ +# Recipe: dependency injection + +Goal: register a single `ParameterBagInterface` in your container so +services can declare it as a constructor dependency without coupling +to the concrete `ParameterBag` class. + +## Service definition + +```php +use InitPHP\ParameterBag\ParameterBag; +use InitPHP\ParameterBag\ParameterBagInterface; + +// PSR-11 container example (pseudocode). +$container->set(ParameterBagInterface::class, function () { + return new ParameterBag(require __DIR__ . '/../config/app.php'); +}); +``` + +## Consumer + +```php +final class MailerFactory +{ + public function __construct( + private readonly ParameterBagInterface $config + ) { + } + + public function create(): Mailer + { + return new Mailer( + $this->config->get('mailer.dsn'), + $this->config->get('mailer.from'), + $this->config->get('mailer.timeout', 30) + ); + } +} +``` + +In PHP 7.4 the syntax is slightly different (no constructor property +promotion, no `readonly`), but the principle is the same: depend on +the **interface**, not the implementation. + +## Multi-bag layouts + +Some apps want a separate bag per concern (config, request, session). +Register each under its own key: + +```php +$container->set('config.bag', fn () => new ParameterBag(require '...config.php')); +$container->set('request.bag', fn () => new ParameterBag($_REQUEST)); +$container->set('session.bag', fn () => new ParameterBag($_SESSION)); +``` + +Consumers then receive whichever bag they were declared against: + +```php +public function __construct( + private readonly ParameterBagInterface $config, + private readonly ParameterBagInterface $request +) {} +``` + +## Common pitfalls + +- **Mutable shared state**: a single bag injected into many services + is a shared mutable. Either freeze the contract (`get()` only) or + reach for an immutable wrapper. +- **Compiling containers**: if your container compiles definitions + (Symfony's compiled container, php-di's compiled mode, etc.), make + sure the factory closure can be serialised or replaced by a + service definition class. diff --git a/docs/recipes/request-parameters.md b/docs/recipes/request-parameters.md new file mode 100644 index 0000000..60ab8de --- /dev/null +++ b/docs/recipes/request-parameters.md @@ -0,0 +1,70 @@ +# Recipe: request parameters + +Goal: wrap PHP's superglobals (`$_GET`, `$_POST`, `$_SERVER`) in a +`ParameterBag` so request data is read through a consistent, +default-aware API. + +## Wrapping `$_GET` + +```php +use InitPHP\ParameterBag\ParameterBag; + +$query = new ParameterBag($_GET); + +$query->get('page', 1); +$query->get('q', ''); +$query->has('debug'); +``` + +## Wrapping `$_POST` + +```php +$body = new ParameterBag($_POST); + +$email = $body->get('email'); +$password = $body->get('password'); +``` + +## Wrapping `$_SERVER` with case-insensitive header lookup + +HTTP header names are case-insensitive. If you intend to read them +through the bag, enable `caseInsensitive`: + +```php +$headers = new ParameterBag( + array_filter( + $_SERVER, + static fn (string $name) => str_starts_with($name, 'HTTP_'), + ARRAY_FILTER_USE_KEY + ), + ['caseInsensitive' => true] +); + +$headers->get('HTTP_AUTHORIZATION'); +$headers->get('http_authorization'); // same value +``` + +## Combining into a single request bag + +```php +$request = new ParameterBag([ + 'query' => $_GET, + 'body' => $_POST, + 'server' => $_SERVER, +]); + +$request->get('query.page'); +$request->get('body.email'); +$request->get('server.REMOTE_ADDR'); +``` + +The nested constructor payload auto-enables multi mode. + +## Common pitfalls + +- **Trusting input directly**: ParameterBag does not validate or + sanitise values. Treat what comes out of it the same way you would + treat the raw superglobal. +- **`$_FILES`**: file uploads have a peculiar structure (`name`, + `tmp_name`, `error`, `size`, `type`). A bag can carry them, but + prefer a dedicated upload handler for parsing. diff --git a/docs/upgrading-from-v1.md b/docs/upgrading-from-v1.md new file mode 100644 index 0000000..685c91b --- /dev/null +++ b/docs/upgrading-from-v1.md @@ -0,0 +1,156 @@ +# Upgrading from v1 to v2 + +v2 is a major release. It fixes bugs that changed observable +behaviour, removes a problematic feature (the internal cache), and +modernises the API surface. Most users only need to add one option +to keep their old behaviour; some need to update assumptions about +keys, merge semantics, and option validation. + +## TL;DR + +Add `caseInsensitive => true` to your constructor call and you are +likely done: + +```php +new ParameterBag($data, ['caseInsensitive' => true]); +``` + +For everything else, read on. + +## Behavioural changes + +### Keys are case-sensitive by default + +v1 silently lowercased every key. v2 preserves case unless you opt +in with `['caseInsensitive' => true]`. + +Migration: + +```php +// v1 +$bag = new ParameterBag(['User' => 'alice']); +$bag->get('user'); // 'alice' + +// v2 (default) +$bag = new ParameterBag(['User' => 'alice']); +$bag->get('user'); // null — case-sensitive +$bag->get('User'); // 'alice' + +// v2 (legacy behaviour) +$bag = new ParameterBag(['User' => 'alice'], ['caseInsensitive' => true]); +$bag->get('user'); // 'alice' +``` + +### `isMulti` auto-detection is corrected + +v1 inverted the comparison and set `isMulti = true` for FLAT arrays. +The bug was masked by callers who supplied the option explicitly. v2 +auto-detects correctly: nested → multi, flat → flat. + +If you wrote `new ParameterBag(['user' => 'a'])` and relied on the +broken auto-detection to give you multi-mode dotted writes, supply +`['isMulti' => true]` explicitly. + +### Multi-mode `merge()` is recursive + +v1's `merge()` used `array_merge`, which is shallow. v2 dispatches +to `array_replace_recursive` when `isMulti` is on, so sibling keys +at every depth are preserved. + +```php +$bag = new ParameterBag(['db' => ['user' => 'root']], ['isMulti' => true]); +$bag->merge(['db' => ['pass' => 'secret']]); +$bag->all(); +// v1: ['db' => ['pass' => 'secret']] (user was wiped) +// v2: ['db' => ['user' => 'root', 'pass' => 'secret']] +``` + +### `clear()` (and `close()`) now actually clear + +v1 kept an internal cache that survived `clear()`, so a subsequent +`get()` could return a value that no longer existed in the stack. +v2 removed the cache entirely; `clear()` is authoritative. + +### `null` is distinguishable from "missing" + +v1 used the same magic string sentinel for both, so storing `null` +made `has()` return false. v2's `has()` uses `array_key_exists`, so +`null` values are detected as present. + +### Storing the legacy sentinel string works + +v1 used `'__InitPHPP@r@m£t£rB@gN0tF0undV@lu€__'` as an internal +"not found" sentinel. Storing that exact string as data broke +`has()` and `get()`. v2 uses a private object sentinel that callers +cannot construct, so any string can be stored safely. + +### Values are no longer trimmed with the separator + +v1's normaliser ran `trim($value, $separator)` on every string leaf +in multi mode, silently corrupting data like `'.example.com.'` → +`'example.com'`. v2 only trims keys. + +### `remove()` correctly targets each key + +v1's `remove()` used the first argument as the parent slot for every +iteration in the multi-mode branch, so `remove('db.pass', 'cache.ttl')` +either deleted the wrong slot or created a phantom one. v2 derives +the parent slot from the current iteration. + +## API additions + +- `isEmpty(): bool` +- `keys(): array` / `values(): array` +- `replace(array $data): self` +- `\ArrayAccess`, `\Countable`, `\IteratorAggregate` are now + implemented on the bag and on `ParameterBagInterface`. + +## API removals & renames + +- The internal `_PBStack`, `_PBOptions`, `_PBCache` properties were + private; no public consumer is affected. If you subclassed the + package and accessed these names through reflection, switch to + the new typed properties (`$stack`, `$isMulti`, `$separator`, + `$caseInsensitive`). +- `__destruct()` was removed. PHP's garbage collector reclaims + memory automatically. If you relied on the destructor to clear + options, call `close()` explicitly. + +## Stricter option validation + +```php +// v1: silently ignored +new ParameterBag([], ['is_multi' => true]); + +// v2: throws +new ParameterBag([], ['is_multi' => true]); +// ParameterBagInvalidArgumentException +``` + +The exception message lists every accepted key — useful for quickly +finding the right name. + +## PHP version + +v2 requires PHP 7.4 or later (including 8.0–8.4). v1 advertised 7.2, +which has been EOL since 2019. + +## Drop-in shim + +If you cannot make the full migration immediately, the following +factory mimics v1 defaults: + +```php +use InitPHP\ParameterBag\ParameterBag; + +function legacyParameterBag(array $data = [], array $options = []): ParameterBag +{ + return new ParameterBag( + $data, + $options + ['caseInsensitive' => true] + ); +} +``` + +You still get all the v2 bug fixes; only the case-sensitivity +default is rolled back. diff --git a/docs/usage/basic-usage.md b/docs/usage/basic-usage.md new file mode 100644 index 0000000..26ba555 --- /dev/null +++ b/docs/usage/basic-usage.md @@ -0,0 +1,90 @@ +# Basic usage + +Flat-mode workflows: a single namespace of string-keyed values. + +## Reading values + +```php +use InitPHP\ParameterBag\ParameterBag; + +$bag = new ParameterBag(['user' => 'alice', 'role' => null]); + +$bag->get('user'); // 'alice' +$bag->get('missing'); // null +$bag->get('missing', 'fallback'); // 'fallback' +$bag->get('role', 'fallback'); // null — the stored value wins, + // even when it is null, + // because the key exists. +``` + +`has()` is the authoritative existence check — it returns `true` for +keys that were explicitly set to `null`: + +```php +$bag->has('user'); // true +$bag->has('role'); // true (null value counts as present) +$bag->has('missing'); // false +``` + +## Writing values + +```php +$bag = new ParameterBag(); + +$bag->set('host', 'localhost') + ->set('port', 5432); +``` + +`set()` returns the bag, so writes chain naturally. Setting an array +also normalises it through the constructor's code path: + +```php +$bag->set('headers', ['Accept' => 'application/json']); +$bag->get('headers'); // ['Accept' => 'application/json'] +``` + +## Removing values + +```php +$bag = new ParameterBag(['a' => 1, 'b' => 2, 'c' => 3]); + +$bag->remove('a'); // removes 'a' +$bag->remove('b', 'c'); // removes both +$bag->remove('does-not-exist'); // silent no-op +``` + +`remove()` is variadic, returns the bag, and ignores missing keys. + +## Listing and counting + +```php +$bag = new ParameterBag(['a' => 1, 'b' => 2]); + +count($bag); // 2 (also $bag->count()) +$bag->keys(); // ['a', 'b'] +$bag->values(); // [1, 2] +$bag->all(); // ['a' => 1, 'b' => 2] +$bag->isEmpty(); // false +``` + +## Clearing the bag + +```php +$bag = new ParameterBag(['a' => 1], ['caseInsensitive' => true]); + +$bag->clear(); // empties the stack, keeps options +$bag->close(); // empties the stack AND resets options to defaults +``` + +## Common mistakes + +- **`$bag->get('foo.bar')` in flat mode**: the literal string + `'foo.bar'` is the key; the dot has no special meaning unless + `isMulti` is enabled. See [Nested data](nested-data.md). +- **Forgetting that v2 is case-sensitive**: `set('User', 'a')` + followed by `get('user')` returns `null`. Opt into the legacy + behaviour with `['caseInsensitive' => true]` if you need it. See + [Case sensitivity](case-sensitivity.md). +- **`$bag[]` append**: ParameterBag is string-keyed, so + `$bag[] = $value` raises + `ParameterBagInvalidArgumentException`. diff --git a/docs/usage/case-sensitivity.md b/docs/usage/case-sensitivity.md new file mode 100644 index 0000000..81ac00e --- /dev/null +++ b/docs/usage/case-sensitivity.md @@ -0,0 +1,66 @@ +# Case sensitivity + +By default, v2 keys are **case-sensitive** — `User` and `user` are +two different entries. The v1 behaviour (everything lower-cased) is +still available, but it's opt-in. + +## Default behaviour (case-sensitive) + +```php +use InitPHP\ParameterBag\ParameterBag; + +$bag = new ParameterBag(); +$bag->set('User', 'alice'); + +$bag->has('User'); // true +$bag->has('user'); // false +$bag->get('User'); // 'alice' +$bag->get('user'); // null +``` + +Constructor payloads keep their key case too: + +```php +$bag = new ParameterBag([ + 'Database' => ['User' => 'root'], +]); + +$bag->all(); +// ['Database' => ['User' => 'root']] +$bag->get('Database.User'); // 'root' +``` + +## Opt-in: case-insensitive mode + +Pass `caseInsensitive => true` to fold every key (constructor payload, +`get`/`set`/`has`/`remove` arguments, `merge` input) to lower-case +on entry: + +```php +$bag = new ParameterBag( + ['Database' => ['User' => 'root']], + ['caseInsensitive' => true] +); + +$bag->all(); +// ['database' => ['user' => 'root']] + +$bag->set('Cache.DRIVER', 'redis'); +$bag->get('cache.driver'); // 'redis' +$bag->has('CACHE.DRIVER'); // true +``` + +## Migrating from v1 + +If you upgraded from v1 and your callers relied on the implicit +lowercasing, add `caseInsensitive => true` to your constructor +calls. The rest of the API is unchanged. + +## Common mistakes + +- **Mixing modes between bags**: a case-insensitive bag merged into a + case-sensitive one will land with already-lowercased keys, which + may not match anything the case-sensitive side stored. +- **Expecting `close()` to keep `caseInsensitive` on**: `close()` + restores every option to its default, including this one. Use + `clear()` if you only want to empty the stack. diff --git a/docs/usage/iteration-and-counting.md b/docs/usage/iteration-and-counting.md new file mode 100644 index 0000000..3fde9b8 --- /dev/null +++ b/docs/usage/iteration-and-counting.md @@ -0,0 +1,80 @@ +# Iteration & counting + +`ParameterBag` implements `\ArrayAccess`, `\Countable`, and +`\IteratorAggregate`, so it behaves like a native PHP collection +wherever those contracts are recognised. + +## ArrayAccess + +Reads and writes go through the same code path as `get/set/has/remove`, +including dotted-path support in multi mode: + +```php +$bag = new ParameterBag(['user' => 'alice']); + +$bag['user']; // 'alice' +$bag['locale'] = 'en_US'; // set +isset($bag['user']); // true +unset($bag['user']); // remove +``` + +In multi mode the offset can be a dotted path: + +```php +$bag = new ParameterBag(['db' => ['user' => 'root']], ['isMulti' => true]); + +$bag['db.user']; // 'root' +$bag['db.pass'] = 'secret'; +isset($bag['db.user']); // true +unset($bag['db.pass']); +``` + +`$bag[] = $value` (append without a key) raises +`ParameterBagInvalidArgumentException` because the bag is string-keyed, +not a numeric list. + +## Countable + +`count()` reports the number of TOP-LEVEL entries — nested arrays are +not unwound: + +```php +$bag = new ParameterBag([ + 'db' => ['user' => 'root', 'pass' => 'x'], + 'cache' => ['ttl' => 60], +]); + +count($bag); // 2 + +$bag->isEmpty(); // false +``` + +## Iteration + +`getIterator()` yields top-level entries in insertion order via an +`\ArrayIterator`, so iteration is repeatable: + +```php +$bag = new ParameterBag(['a' => 1, 'b' => 2, 'c' => 3]); + +foreach ($bag as $key => $value) { + echo "$key=$value\n"; +} +// a=1 +// b=2 +// c=3 +``` + +To walk nested data yourself, call `all()` and recurse, or pick a +specific subtree with `get()`: + +```php +foreach ($bag->get('db', []) as $key => $value) { /* ... */ } +``` + +## Common mistakes + +- **Assuming iteration recurses**: it does not. Use `get('subtree')` + or `all()` and recurse yourself. +- **Mixing ArrayAccess append with multi mode**: `$bag[] = ...` + always throws; in multi mode there is no "append to root" concept. diff --git a/docs/usage/merging.md b/docs/usage/merging.md new file mode 100644 index 0000000..0ecf9c4 --- /dev/null +++ b/docs/usage/merging.md @@ -0,0 +1,72 @@ +# Merging + +`merge()` accepts one or more arrays and/or `ParameterBagInterface` +instances. Its behaviour depends on the mode: + +| Mode | Strategy | PHP equivalent | +| --- | --- | --- | +| Flat (`isMulti = false`) | Shallow merge; later entries win on collision. | `array_merge` | +| Multi (`isMulti = true`) | Recursive replace; sibling keys at every depth are preserved. | `array_replace_recursive` | + +Empty arguments (`[]` or an empty bag) are skipped silently. + +## Flat merge + +```php +$bag = new ParameterBag(['a' => 1, 'b' => 2]); +$bag->merge(['b' => 20, 'c' => 30]); + +$bag->all(); +// ['a' => 1, 'b' => 20, 'c' => 30] +``` + +## Multi-mode merge preserves siblings + +```php +$bag = new ParameterBag( + ['db' => ['user' => 'root']], + ['isMulti' => true] +); + +$bag->merge(['db' => ['pass' => 'secret']]); + +$bag->all(); +// ['db' => ['user' => 'root', 'pass' => 'secret']] +``` + +In v1 this would have wiped `db.user` because the merge was shallow. +v2 uses `array_replace_recursive` whenever `isMulti` is on. + +## Multiple payloads in one call + +```php +$bag = new ParameterBag([], ['isMulti' => true]); + +$bag->merge( + ['db' => ['user' => 'root']], + ['db' => ['pass' => 'secret']], + new ParameterBag(['cache' => ['driver' => 'redis']], ['isMulti' => true]) +); + +$bag->all(); +// [ +// 'db' => ['user' => 'root', 'pass' => 'secret'], +// 'cache' => ['driver' => 'redis'], +// ] +``` + +## Error cases + +```php +$bag->merge('not an array'); // ParameterBagInvalidArgumentException +$bag->merge(new \stdClass()); // ParameterBagInvalidArgumentException +``` + +## Common mistakes + +- **Expecting flat-mode merge to deep-merge**: pass + `['isMulti' => true]` (or supply nested data so auto-detection + switches it on). +- **Trying to merge into a closed bag**: `close()` resets the options + too. If you `close()` a bag that was created in multi mode, the + next `merge()` runs in flat mode. diff --git a/docs/usage/nested-data.md b/docs/usage/nested-data.md new file mode 100644 index 0000000..0c96024 --- /dev/null +++ b/docs/usage/nested-data.md @@ -0,0 +1,110 @@ +# Nested data (multi mode) + +When the bag is in multi mode it interprets the configured separator +(`.` by default) inside keys as a path delimiter into a nested +associative array. + +## Enabling multi mode + +Auto-detection (recommended): if the constructor payload contains any +nested arrays, multi mode is enabled automatically. + +```php +$bag = new ParameterBag([ + 'database' => ['user' => 'root'], // nested → multi mode +]); +``` + +Explicit: + +```php +$bag = new ParameterBag([], ['isMulti' => true]); +``` + +> If you want a flat bag with literal dotted keys, pass +> `['isMulti' => false]` explicitly to override auto-detection. + +## Reading nested values + +```php +$bag = new ParameterBag([ + 'database' => [ + 'dsn' => 'mysql:host=localhost', + 'username' => 'root', + 'password' => 'secret', + ], +]); + +$bag->get('database.username'); // 'root' +$bag->get('database.charset'); // null (missing path) +$bag->get('database.charset', 'utf8mb4'); // 'utf8mb4' +$bag->has('database.password'); // true +``` + +## Writing nested values + +```php +$bag = new ParameterBag([], ['isMulti' => true]); + +$bag->set('cache.driver', 'redis'); +$bag->set('cache.host', '127.0.0.1'); + +$bag->all(); +// ['cache' => ['driver' => 'redis', 'host' => '127.0.0.1']] +``` + +If a scalar sits at a parent path, descending into it silently +replaces it with a fresh subtree: + +```php +$bag = new ParameterBag([], ['isMulti' => true]); +$bag->set('db', 'a-string'); +$bag->set('db.user', 'root'); + +$bag->all(); +// ['db' => ['user' => 'root']] +``` + +## Removing nested values + +```php +$bag = new ParameterBag( + ['db' => ['user' => 'root', 'pass' => 'x'], 'cache' => ['ttl' => 60]], + ['isMulti' => true] +); + +$bag->remove('db.pass', 'cache.ttl'); + +$bag->all(); +// ['db' => ['user' => 'root'], 'cache' => []] +``` + +`remove('db')` deletes the whole subtree. + +## Choosing a separator + +```php +$bag = new ParameterBag( + ['user' => ['name' => 'alice']], + ['isMulti' => true, 'separator' => '|'] +); + +$bag->get('user|name'); // 'alice' +``` + +The separator must be a non-empty string; an empty string is rejected +and the previous value is kept. + +## Common mistakes + +- **Indexing into a scalar leaf**: `set('user', 'alice')` followed by + `get('user.name')` returns the default (null), not a character of + `'alice'`. The bag will not probe inside scalars. +- **Auto-detection with all-numeric arrays**: if every element of the + payload is itself an array (e.g. a list of records), multi mode is + enabled and the numeric keys participate in dotted lookups. Pass + `['isMulti' => false]` if you intend a list of opaque rows. +- **Changing the separator at runtime**: the constructor is the only + supported entry point. Subclass and override + [`setOptions()`](../api-reference.md#setOptions) if you need + another flow. diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..8130d1b --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,7 @@ +parameters: + level: 8 + paths: + - src + - tests + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: true diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..95efc0e --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,23 @@ + + + + + tests + + + + + src + + + diff --git a/src/Exception/ParameterBagInvalidArgumentException.php b/src/Exception/ParameterBagInvalidArgumentException.php index 0798995..6aa1e09 100644 --- a/src/Exception/ParameterBagInvalidArgumentException.php +++ b/src/Exception/ParameterBagInvalidArgumentException.php @@ -1,20 +1,33 @@ * - * This file is part of ParameterBag. + * For the full copyright and license information, please view the + * LICENSE file that was distributed with this source code. * - * @author Muhammet ŞAFAK - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.1.2 - * @link https://www.muhammetsafak.com.tr + * @link https://github.com/InitPHP/ParameterBag */ declare(strict_types=1); namespace InitPHP\ParameterBag\Exception; +/** + * Thrown when a caller supplies an argument the bag cannot accept: + * + * - an unknown option key in the constructor's `$options` payload; + * - a non-array, non-{@see \InitPHP\ParameterBag\ParameterBagInterface} + * argument to {@see \InitPHP\ParameterBag\ParameterBagInterface::merge()}; + * - appending to the bag without a key via ArrayAccess + * (`$bag[] = $value`), which is unsupported because the bag is + * string-keyed rather than a numeric list. + * + * Extends the SPL {@see \InvalidArgumentException} so catch blocks + * written against either type continue to work. + */ class ParameterBagInvalidArgumentException extends \InvalidArgumentException { } diff --git a/src/ParameterBag.php b/src/ParameterBag.php index f6cbd2c..129785a 100644 --- a/src/ParameterBag.php +++ b/src/ParameterBag.php @@ -1,14 +1,14 @@ * - * This file is part of InitPHP. + * For the full copyright and license information, please view the + * LICENSE file that was distributed with this source code. * - * @author Muhammet ŞAFAK - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.1.2 - * @link https://www.muhammetsafak.com.tr + * @link https://github.com/InitPHP/ParameterBag */ declare(strict_types=1); @@ -16,59 +16,140 @@ namespace InitPHP\ParameterBag; use InitPHP\ParameterBag\Exception\ParameterBagInvalidArgumentException; +use stdClass; -use const COUNT_RECURSIVE; -use const CASE_LOWER; +use function array_change_key_case; +use function array_diff; +use function array_key_exists; +use function array_keys; +use function array_map; +use function array_merge; +use function array_replace_recursive; +use function array_shift; +use function array_values; use function count; -use function strtolower; -use function strpos; -use function is_array; -use function trim; use function explode; -use function array_shift; use function implode; -use function array_merge; -use function is_string; +use function is_array; use function is_bool; -use function array_map; -use function array_change_key_case; -use function array_key_exists; +use function is_string; +use function sprintf; +use function strpos; +use function strtolower; +use function trim; + +use const CASE_LOWER; +use const COUNT_RECURSIVE; +/** + * Default {@see ParameterBagInterface} implementation. + * + * Stores parameters in a single array. When `isMulti` is enabled, + * dotted keys (e.g. `database.user`) are interpreted as paths into a + * nested associative array; in flat mode the dot is part of the key. + * + * The class is intentionally open to extension — protected hooks are + * documented as safe override points: + * + * - {@see self::getKey()} — change how caller-supplied keys are + * normalized (case folding, trimming). + * - {@see self::setOptions()} — react to additional options if the + * subclass introduces them. + * + * @see ParameterBagInterface + */ class ParameterBag implements ParameterBagInterface { + /** + * The complete set of options recognised by {@see self::setOptions()}. + * Any other key in the array supplied to the constructor will be + * rejected with {@see ParameterBagInvalidArgumentException}. + * + * @var array + */ + private const KNOWN_OPTIONS = ['isMulti', 'separator', 'caseInsensitive']; + + /** + * Sentinel returned by {@see self::multiSubParameterGet()} when a + * dotted path cannot be resolved. Using a private static object + * (rather than a magic string or null) guarantees the value can + * never collide with anything a caller might legitimately store. + * + * @var stdClass|null + */ + private static ?stdClass $notFound = null; + + /** + * The underlying flat or nested storage. + * + * @var array + */ + private array $stack = []; - /** @var array */ - private $_PBStack = []; + /** + * Whether dotted keys are interpreted as paths into a nested array. + */ + private bool $isMulti = false; - private $_PBOptions = [ - 'isMulti' => false, - 'separator' => '.', - ]; + /** + * Delimiter used to split dotted keys when {@see self::$isMulti} + * is enabled. + * + * @var non-empty-string + */ + private string $separator = '.'; - /** @var array */ - private $_PBCache = []; + /** + * When true every key (constructor payload, get/set/has/remove + * arguments, merge() input) is folded to lower-case before being + * stored or compared. Defaults to false in v2; pass + * `['caseInsensitive' => true]` to the constructor to restore the + * legacy (v1) behaviour. + */ + private bool $caseInsensitive = false; + /** + * @param array $data Initial stack. When the + * `isMulti` option is not + * supplied explicitly, this + * is also used to auto- + * detect nesting. + * @param array $options Recognised keys: + * - `isMulti` (bool, default auto) + * - `separator` (non-empty string, default ".") + * - `caseInsensitive` (bool, default false) + * + * @throws ParameterBagInvalidArgumentException If $options contains + * any unrecognised key. + */ public function __construct(array $data = [], array $options = []) { - if(!empty($data)){ - if(!isset($options['isMulti']) || !is_bool($options['isMulti'])){ - $this->_PBOptions['isMulti'] = (count($data) === count($data, COUNT_RECURSIVE)); + if (self::$notFound === null) { + self::$notFound = new stdClass(); + } + // Options must be applied BEFORE normalizing $data because the + // normalizer reads $this->isMulti / $this->separator. Auto- + // detection of isMulti only runs when the caller has not + // supplied an explicit boolean. + if (!isset($options['isMulti']) || !is_bool($options['isMulti'])) { + if (!empty($data)) { + $options['isMulti'] = (count($data) !== count($data, COUNT_RECURSIVE)); } - $this->_PBStack = $this->arrayChangeKeyCaseLower($data); } $this->setOptions($options); + if (!empty($data)) { + $this->stack = $this->normalizeKeys($data); + } } - public function __destruct() - { - $this->close(); - } - - public function __debugInfo() + /** + * @return array{isMulti: string, separator: string, data: array} + */ + public function __debugInfo(): array { return [ - 'isMulti' => ($this->_PBOptions['isMulti'] ? 'yes' : 'no'), - 'separator' => $this->_PBOptions['separator'], + 'isMulti' => $this->isMulti ? 'yes' : 'no', + 'separator' => $this->separator, 'data' => $this->all(), ]; } @@ -79,10 +160,9 @@ public function __debugInfo() public function close(): void { $this->clear(); - $this->_PBOptions = [ - 'isMulti' => false, - 'separator' => '.' - ]; + $this->isMulti = false; + $this->separator = '.'; + $this->caseInsensitive = false; } /** @@ -90,15 +170,57 @@ public function close(): void */ public function clear(): void { - $this->_PBStack = []; + $this->stack = []; } /** * @inheritDoc + * + * @return array */ public function all(): array { - return $this->_PBStack ?? []; + return $this->stack; + } + + /** + * @inheritDoc + */ + public function isEmpty(): bool + { + return $this->stack === []; + } + + /** + * @inheritDoc + * + * @return array + */ + public function keys(): array + { + return array_keys($this->stack); + } + + /** + * @inheritDoc + * + * @return array + */ + public function values(): array + { + return array_values($this->stack); + } + + /** + * @inheritDoc + * + * @param array $data + */ + public function replace(array $data): ParameterBagInterface + { + $this->stack = $this->normalizeKeys($data); + + return $this; } /** @@ -107,46 +229,57 @@ public function all(): array public function has(string $key): bool { $key = $this->getKey($key); - if($this->_PBOptions['isMulti'] && strpos($key, $this->_PBOptions['separator']) !== FALSE){ - return ($this->multiSubParameterGet($key) !== '__InitPHPP@r@m£t£rB@gN0tF0undV@lu€__'); + if ($this->isMulti && strpos($key, $this->separator) !== false) { + return $this->multiSubParameterGet($key) !== self::$notFound; } - return isset($this->_PBStack[$key]) || array_key_exists($key, $this->_PBStack); + + return array_key_exists($key, $this->stack); } /** * @inheritDoc + * + * @param mixed $default + * + * @return mixed */ public function get(string $key, $default = null) { $key = $this->getKey($key); - if(isset($this->_PBCache[$key])){ - return $this->_PBCache[$key]; - } - if($this->_PBOptions['isMulti'] !== FALSE && strpos($key, $this->_PBOptions['separator']) !== FALSE){ + if ($this->isMulti && strpos($key, $this->separator) !== false) { $value = $this->multiSubParameterGet($key); - return ($value !== '__InitPHPP@r@m£t£rB@gN0tF0undV@lu€__') ? $value : $default; + + return $value !== self::$notFound ? $value : $default; } - return $this->_PBStack[$key] ?? $default; + + return array_key_exists($key, $this->stack) ? $this->stack[$key] : $default; } /** * @inheritDoc + * + * @param mixed $value */ public function set(string $key, $value): ParameterBagInterface { $key = $this->getKey($key); - if(is_array($value)){ - $value = $this->arrayChangeKeyCaseLower($value); + if (is_array($value)) { + $value = $this->normalizeKeys($value); } - $this->_PBCache[$key] = $value; - if($this->_PBOptions['isMulti'] !== FALSE && strpos($key, $this->_PBOptions['separator']) !== FALSE){ - $split = explode($this->_PBOptions['separator'], $key); + if ($this->isMulti && strpos($key, $this->separator) !== false) { + $split = explode($this->separator, $key); $id = $split[0]; array_shift($split); - $this->_PBStack[$id] = $this->multiSubParameterSet(implode($this->_PBOptions['separator'], $split), $value, ($this->_PBStack[$id] ?? [])); + $this->stack[$id] = $this->multiSubParameterSet( + implode($this->separator, $split), + $value, + $this->arrayOrEmpty($this->stack[$id] ?? null) + ); + return $this; } - $this->_PBStack[$key] = $value; + $this->stack[$key] = $value; + return $this; } @@ -157,155 +290,281 @@ public function remove(string ...$keys): ParameterBagInterface { foreach ($keys as $key) { $key = $this->getKey($key); - if(array_key_exists($key, $this->_PBCache)){ - unset($this->_PBCache[$key]); - } - if($this->_PBOptions['isMulti'] !== FALSE && strpos($key, $this->_PBOptions['separator']) !== FALSE){ - $split = explode($this->_PBOptions['separator'], $key); - $id = $keys[0]; + if ($this->isMulti && strpos($key, $this->separator) !== false) { + $split = explode($this->separator, $key); + $id = $split[0]; array_shift($split); - $this->_PBStack[$id] = $this->multiSubParameterRemove(implode($this->_PBOptions['separator'], $split), ($this->_PBStack[$id] ?? [])); + $this->stack[$id] = $this->multiSubParameterRemove( + implode($this->separator, $split), + $this->arrayOrEmpty($this->stack[$id] ?? null) + ); continue; } - if(isset($this->_PBStack[$key])){ - unset($this->_PBStack[$key]); + if (array_key_exists($key, $this->stack)) { + unset($this->stack[$key]); } } + return $this; } /** * @inheritDoc + * + * @param array|ParameterBagInterface ...$merge */ public function merge(...$merge): ParameterBagInterface { - foreach ($merge as &$data) { - if($data instanceof ParameterBagInterface){ + $normalized = []; + foreach ($merge as $data) { + if ($data instanceof ParameterBagInterface) { $data = $data->all(); } - if(!is_array($data)){ - throw new ParameterBagInvalidArgumentException('Only an array or a ParameterBag object can be combined.'); + if (!is_array($data)) { + throw new ParameterBagInvalidArgumentException( + 'Only an array or a ParameterBag object can be combined.' + ); } - if(empty($data)){ + if (empty($data)) { continue; } - $data = $this->arrayChangeKeyCaseLower($data); + $normalized[] = $this->normalizeKeys($data); } - $this->_PBStack = array_merge($this->_PBStack, ...$merge); + if ($normalized === []) { + return $this; + } + $this->stack = $this->isMulti + ? array_replace_recursive($this->stack, ...$normalized) + : array_merge($this->stack, ...$normalized); + return $this; } /** - * @param string $key - * @return string + * {@see \Countable::count()} — returns the number of TOP-LEVEL + * entries in the bag (nested arrays are not unwound). + */ + public function count(): int + { + return count($this->stack); + } + + /** + * {@see \IteratorAggregate::getIterator()} — yields top-level + * entries in insertion order. + * + * @return \ArrayIterator + */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->stack); + } + + /** + * {@see \ArrayAccess::offsetExists()} — delegates to {@see self::has()}. + * + * @param array-key $offset + */ + public function offsetExists($offset): bool + { + return $this->has((string) $offset); + } + + /** + * {@see \ArrayAccess::offsetGet()} — delegates to {@see self::get()}. + * + * @param array-key $offset + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->get((string) $offset); + } + + /** + * {@see \ArrayAccess::offsetSet()} — delegates to {@see self::set()}. + * Appending without a key ($bag[] = $v) is rejected because a bag + * is a string-keyed structure, not a list. + * + * @param array-key|null $offset + * @param mixed $value + */ + public function offsetSet($offset, $value): void + { + if ($offset === null) { + throw new ParameterBagInvalidArgumentException( + 'ParameterBag is a string-keyed structure; assignment without a key is not supported.' + ); + } + $this->set((string) $offset, $value); + } + + /** + * {@see \ArrayAccess::offsetUnset()} — delegates to {@see self::remove()}. + * + * @param array-key $offset + */ + public function offsetUnset($offset): void + { + $this->remove((string) $offset); + } + + /** + * Normalize a caller-supplied key. Override to change the case or + * trim policy in a subclass. */ protected function getKey(string $key): string { - $key = strtolower($key); - if($this->_PBOptions['isMulti'] !== FALSE){ - $key = trim($key, $this->_PBOptions['separator']); + if ($this->caseInsensitive) { + $key = strtolower($key); + } + if ($this->isMulti) { + $key = trim($key, $this->separator); } + return $key; } /** - * Sınıf/Nesne seçeneklerini tanımlar. + * Apply recognised options from $options to this instance. * - * @param array $options - * @return void + * @param array $options */ protected function setOptions(array $options): void { - if(empty($options)){ + if (empty($options)) { return; } - if(isset($options['isMulti']) && is_bool($options['isMulti'])){ - $this->_PBOptions['isMulti'] = $options['isMulti']; + $unknown = array_diff(array_keys($options), self::KNOWN_OPTIONS); + if ($unknown !== []) { + throw new ParameterBagInvalidArgumentException(sprintf( + 'Unknown ParameterBag option(s): %s. Known options: %s.', + implode(', ', $unknown), + implode(', ', self::KNOWN_OPTIONS) + )); + } + if (isset($options['isMulti']) && is_bool($options['isMulti'])) { + $this->isMulti = $options['isMulti']; } - if(isset($options['separator']) && is_string($options['separator']) && !empty($options['separator'])){ - $this->_PBOptions['separator'] = $options['separator']; + if (isset($options['separator']) && is_string($options['separator']) && $options['separator'] !== '') { + $this->separator = $options['separator']; + } + if (isset($options['caseInsensitive']) && is_bool($options['caseInsensitive'])) { + $this->caseInsensitive = $options['caseInsensitive']; } } /** - * İlişkisel bir dizinin anahtarlarını küçük karakterli haline çevirerek geriye döndürür. + * Return $value if it is already an array, otherwise an empty + * array. Used when descending into a nested path so that a + * scalar leaf at a non-leaf position is silently replaced by a + * fresh subtree (rather than raising a TypeError). + * + * @param mixed $value * - * @param array $array - * @return array + * @return array */ - private function arrayChangeKeyCaseLower(array $array): array + private function arrayOrEmpty($value): array { - return array_map(function($row) { - if(is_array($row)){ - $row = $this->arrayChangeKeyCaseLower($row); - } - if(is_string($row) && $this->_PBOptions['isMulti'] !== FALSE){ - $row = trim($row, $this->_PBOptions['separator']); + return is_array($value) ? $value : []; + } + + /** + * Normalize $array for storage. In case-insensitive mode every + * string key is folded to lower-case (recursively). In the default + * case-sensitive mode the input is returned unchanged. Values are + * never modified. + * + * @param array $array + * @return array + */ + private function normalizeKeys(array $array): array + { + if (!$this->caseInsensitive) { + return $array; + } + + return array_map(function ($row) { + if (is_array($row)) { + $row = $this->normalizeKeys($row); } + return $row; }, array_change_key_case($array, CASE_LOWER)); } /** - * Çoklu parametre çantası kullanımında belirtilen ayırıcı/separator ile ilgili alt elemanın değerini geririr. + * Resolve a dotted $key against $this->stack. Returns + * {@see self::$notFound} (the sentinel) when any segment is + * missing or a scalar leaf is reached before the final segment. * - * @param string $key - * @return array|mixed|string + * @return mixed */ private function multiSubParameterGet(string $key) { - $keys = explode($this->_PBOptions['separator'], $key); - $res = $this->_PBStack ?? []; - foreach ($keys as $key) { - if(!isset($res[$key])){ - return '__InitPHPP@r@m£t£rB@gN0tF0undV@lu€__'; + $res = $this->stack; + foreach (explode($this->separator, $key) as $segment) { + if (!is_array($res) || !array_key_exists($segment, $res)) { + return self::$notFound; } - $res = $res[$key]; + $res = $res[$segment]; } + return $res; } /** - * Çoklu parametre çantası kullanımında belirtilen ayırıcı/separator ile ilgili anahtara ulaşarak değerini tanımlar ve yeni bir oluşturarak oluştruduğu diziyi geri döndürür. + * Recursively assign $value at the dotted $key inside $parameters + * and return the rebuilt subtree. * - * @param string|int $key - * @param mixed $value - * @param array $parameters - * @return array + * @param mixed $value + * @param array $parameters + * @return array */ - private function multiSubParameterSet($key, $value, $parameters): array + private function multiSubParameterSet(string $key, $value, array $parameters): array { - if(strpos($key, $this->_PBOptions['separator']) !== FALSE){ - $keys = explode($this->_PBOptions['separator'], $key); + if (strpos($key, $this->separator) !== false) { + $keys = explode($this->separator, $key); $id = $keys[0]; array_shift($keys); - $parameters[$id] = $this->multiSubParameterSet(implode($this->_PBOptions['separator'], $keys), $value, ($parameters[$id] ?? [])); + $parameters[$id] = $this->multiSubParameterSet( + implode($this->separator, $keys), + $value, + $this->arrayOrEmpty($parameters[$id] ?? null) + ); + return $parameters; } $parameters[$key] = $value; + return $parameters; } /** - * Çoklu parametre çantası kullanımında belirtilen ayırıcı/separator ile ilgili anahtara ulaşarak kaldırır/siler ve yeni bir oluşturarak oluştruduğu diziyi geri döndürür. + * Recursively remove the dotted $key from $parameters and return + * the rebuilt subtree. * - * @param string|int $key - * @param array $parameters - * @return array + * @param array $parameters + * @return array */ - private function multiSubParameterRemove($key, $parameters): array + private function multiSubParameterRemove(string $key, array $parameters): array { - if(strpos($key, $this->_PBOptions['separator']) !== FALSE){ - $keys = explode($this->_PBOptions['separator'], $key); + if (strpos($key, $this->separator) !== false) { + $keys = explode($this->separator, $key); $id = $keys[0]; array_shift($keys); - $parameters[$id] = $this->multiSubParameterRemove(implode($this->_PBOptions['separator'], $keys), ($parameters[$id] ?? [])); + $parameters[$id] = $this->multiSubParameterRemove( + implode($this->separator, $keys), + $this->arrayOrEmpty($parameters[$id] ?? null) + ); + return $parameters; } - if(isset($parameters[$key])){ + if (array_key_exists($key, $parameters)) { unset($parameters[$key]); } + return $parameters; } - } diff --git a/src/ParameterBagInterface.php b/src/ParameterBagInterface.php index d79cd9f..8482e2f 100644 --- a/src/ParameterBagInterface.php +++ b/src/ParameterBagInterface.php @@ -1,14 +1,14 @@ * - * This file is part of ParameterBag. + * For the full copyright and license information, please view the + * LICENSE file that was distributed with this source code. * - * @author Muhammet ŞAFAK - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.1.2 - * @link https://www.muhammetsafak.com.tr + * @link https://github.com/InitPHP/ParameterBag */ declare(strict_types=1); @@ -17,71 +17,149 @@ use InitPHP\ParameterBag\Exception\ParameterBagInvalidArgumentException; -interface ParameterBagInterface +/** + * Contract for a string-keyed parameter container that supports both + * flat (single-level) and nested (dotted-path) access. + * + * Implementations also satisfy the standard PHP collection contracts: + * + * - {@see \ArrayAccess} — `$bag['foo']`, `$bag['foo'] = $v`, etc. + * - {@see \Countable} — top-level entry count via `count($bag)`. + * - {@see \IteratorAggregate} — top-level traversal via `foreach`. + * + * Dotted-path semantics (`get('db.user')`, `set('db.pass', '…')`, + * `has('cache.driver')`, `remove('db.pass')`) are only active when the + * implementation is configured with `isMulti = true`. In flat mode the + * dot is part of the key itself. + * + * @extends \ArrayAccess + * @extends \IteratorAggregate + */ +interface ParameterBagInterface extends \ArrayAccess, \Countable, \IteratorAggregate { - /** - * Parametre çantasındaki verileri boşaltır ve sınıf varsayılanlarını geri yükler. + * Clear the stack AND restore the implementation's option defaults. * - * @return void + * Useful when a single instance is being reused across unrelated + * request lifecycles and the caller wants a guaranteed clean + * state. */ public function close(): void; /** - * Parametre çantasındaki verileri boşaltır/sıfırlar ama sınıf varsayılanlarını geri yüklemez. + * Clear the stack but leave the current options untouched. * - * @return void + * Equivalent to {@see self::replace([])} except it does not run + * the data normalizer. */ public function clear(): void; /** - * Parametre çantasındaki tüm veriyi bir dizi olarak verir. + * Return the underlying stack as a plain array, preserving nesting. * - * @return array + * @return array */ public function all(): array; /** - * Belirtilen parametrenin varlığını kontrol eder. + * True when the stack has no top-level entries. + */ + public function isEmpty(): bool; + + /** + * The stack's top-level keys, in insertion order. * - * @param string $key - * @return bool + * @return array + */ + public function keys(): array; + + /** + * The stack's top-level values, in insertion order. + * + * @return array + */ + public function values(): array; + + /** + * Replace the entire stack with $data. Active options + * (`isMulti`, `separator`, `caseInsensitive`) are preserved and + * the incoming payload is normalised through the same code path + * the constructor uses. + * + * @param array $data + * + * @return $this + */ + public function replace(array $data): self; + + /** + * Whether $key exists in the bag. + * + * - In flat mode: tests for a top-level key whose name is exactly + * $key (after the optional caseInsensitive fold). + * - In multi mode: when $key contains the separator, walks the + * nested structure; otherwise behaves like the flat mode. + * + * A value of `null` stored under $key is still considered present + * — has() is the authoritative existence check. */ public function has(string $key): bool; /** - * Belirtilen anahtarın değerini döndürür. Parametre yoksa $default döner. + * Return the value at $key or $default if the key is absent. + * + * Multi-mode behaviour mirrors {@see self::has()}: dotted paths + * walk the nested structure, single segments behave like flat + * lookups. A scalar leaf at a non-leaf path is treated as + * "missing" and yields $default rather than probing inside the + * scalar. * - * @param string $key * @param mixed $default + * * @return mixed */ public function get(string $key, $default = null); /** - * Parametre çantasına bir parametre tanımlar ya da değerini değiştirir. + * Assign $value to $key, creating intermediate arrays as needed + * in multi mode. + * + * If $value is an array it is normalised through the same code + * path the constructor uses (recursive lowercasing when + * caseInsensitive is on). * - * @param string $key * @param mixed $value + * * @return $this */ public function set(string $key, $value): self; /** - * Bir yada daha fazla parametreyi çantadan çıkarır. Belirtilen parametreler bulunamazsa bir değişiklik yapılmaz. + * Remove one or more keys from the bag. + * + * Missing keys are a no-op. In multi mode, dotted keys remove a + * specific nested leaf without touching its siblings. * - * @param string ...$keys * @return $this */ public function remove(string ...$keys): self; /** - * Belirtilen ilişkisel diziyi ya da ParameterBag nesnesinin içeriğini array_merge() işlevini kullanarak parametre çantasının tuttuğu dizi ile birleştirir. + * Merge one or more arrays / ParameterBag instances into the + * stack. + * + * - Flat mode: shallow merge (later entries win on key collision). + * - Multi mode: recursive replace (`array_replace_recursive`); + * sibling keys at every depth are preserved. + * + * Empty arguments are skipped silently. + * + * @param array|ParameterBagInterface ...$merge * - * @param array|ParameterBagInterface ...$merge * @return $this - * @throws ParameterBagInvalidArgumentException + * + * @throws ParameterBagInvalidArgumentException If any argument is + * neither an array nor a {@see ParameterBagInterface}. */ public function merge(...$merge): self; - } diff --git a/tests/AdditionalApiTest.php b/tests/AdditionalApiTest.php new file mode 100644 index 0000000..8be0c62 --- /dev/null +++ b/tests/AdditionalApiTest.php @@ -0,0 +1,87 @@ +isEmpty()); + } + + public function testIsEmptyAfterAdditionAndRemoval(): void + { + $bag = new ParameterBag(); + $bag->set('a', 1); + self::assertFalse($bag->isEmpty()); + + $bag->remove('a'); + self::assertTrue($bag->isEmpty()); + } + + public function testKeysReturnsTopLevelKeys(): void + { + $bag = new ParameterBag(['a' => 1, 'b' => 2, 'c' => 3]); + + self::assertSame(['a', 'b', 'c'], $bag->keys()); + } + + public function testKeysOnEmptyBagReturnsEmptyArray(): void + { + self::assertSame([], (new ParameterBag())->keys()); + } + + public function testValuesReturnsTopLevelValuesInInsertionOrder(): void + { + $bag = new ParameterBag(); + $bag->set('z', 'zzz'); + $bag->set('a', 'aaa'); + + self::assertSame(['zzz', 'aaa'], $bag->values()); + } + + public function testReplaceSwapsEntireStack(): void + { + $bag = new ParameterBag(['old' => 1, 'kept-only-until-replace' => 2]); + $bag->replace(['new' => 'value']); + + self::assertSame(['new' => 'value'], $bag->all()); + } + + public function testReplaceHonoursCaseInsensitiveOption(): void + { + $bag = new ParameterBag([], ['caseInsensitive' => true]); + $bag->replace(['Foo' => 'bar']); + + self::assertSame(['foo' => 'bar'], $bag->all()); + self::assertSame('bar', $bag->get('FOO')); + } + + public function testReplaceHonoursMultiModeNesting(): void + { + $bag = new ParameterBag([], ['isMulti' => true]); + $bag->replace(['db' => ['user' => 'root']]); + + self::assertSame('root', $bag->get('db.user')); + } + + public function testReplaceReturnsSelfForChaining(): void + { + $bag = new ParameterBag(); + $result = $bag->replace(['a' => 1]); + + self::assertInstanceOf(ParameterBagInterface::class, $result); + self::assertSame($bag, $result); + } +} diff --git a/tests/CacheCoherenceTest.php b/tests/CacheCoherenceTest.php new file mode 100644 index 0000000..cb337d6 --- /dev/null +++ b/tests/CacheCoherenceTest.php @@ -0,0 +1,64 @@ +set('foo', 'bar'); + + // Prime any internal cache. + self::assertSame('bar', $bag->get('foo')); + + $bag->clear(); + + self::assertSame('default', $bag->get('foo', 'default')); + self::assertSame([], $bag->all()); + } + + public function testOverwritingParentPathInvalidatesChildLookup(): void + { + $bag = new ParameterBag([], ['isMulti' => true]); + $bag->set('database.pass', 'old'); + + // Prime any internal cache via the read path. + self::assertSame('old', $bag->get('database.pass')); + + // Replace the entire parent — child must no longer surface. + $bag->set('database', ['user' => 'admin']); + + self::assertNull($bag->get('database.pass')); + self::assertSame('admin', $bag->get('database.user')); + } + + public function testRemovingParentPathInvalidatesChildLookup(): void + { + $bag = new ParameterBag([], ['isMulti' => true]); + $bag->set('database.pass', 'secret'); + + self::assertSame('secret', $bag->get('database.pass')); + + $bag->remove('database'); + + self::assertNull($bag->get('database.pass')); + self::assertFalse($bag->has('database.pass')); + } +} diff --git a/tests/CaseSensitivityTest.php b/tests/CaseSensitivityTest.php new file mode 100644 index 0000000..bfe9a1b --- /dev/null +++ b/tests/CaseSensitivityTest.php @@ -0,0 +1,110 @@ +set('User', 'alice'); + + self::assertTrue($bag->has('User')); + self::assertFalse($bag->has('user')); + self::assertSame('alice', $bag->get('User')); + self::assertNull($bag->get('user')); + } + + public function testConstructorDataPreservesKeyCase(): void + { + $bag = new ParameterBag([ + 'Database' => ['User' => 'root'], + ]); + + self::assertSame( + ['Database' => ['User' => 'root']], + $bag->all() + ); + self::assertSame('root', $bag->get('Database.User')); + } + + public function testCaseInsensitiveOptionLowercasesKeysOnSet(): void + { + $bag = new ParameterBag([], ['caseInsensitive' => true]); + $bag->set('User', 'alice'); + + self::assertTrue($bag->has('user')); + self::assertTrue($bag->has('USER')); + self::assertSame('alice', $bag->get('User')); + self::assertSame('alice', $bag->get('uSeR')); + } + + public function testCaseInsensitiveOptionLowercasesConstructorData(): void + { + $bag = new ParameterBag( + ['Database' => ['User' => 'root']], + ['caseInsensitive' => true] + ); + + self::assertSame( + ['database' => ['user' => 'root']], + $bag->all() + ); + self::assertSame('root', $bag->get('DATABASE.USER')); + } + + /** + * B2 regression: when isMulti and a custom separator and caseInsensitive + * are all supplied via the constructor, options must be applied BEFORE + * the payload is normalized — otherwise the first pass uses default + * options and produces a structurally-wrong stack. + */ + public function testConstructorAppliesOptionsBeforeNormalizingData(): void + { + $bag = new ParameterBag( + ['DB' => ['Host' => 'localhost']], + ['isMulti' => true, 'separator' => '|', 'caseInsensitive' => true] + ); + + self::assertSame( + ['db' => ['host' => 'localhost']], + $bag->all() + ); + self::assertSame('localhost', $bag->get('DB|Host')); + } + + public function testRemoveIsCaseSensitiveByDefault(): void + { + $bag = new ParameterBag(['User' => 'a', 'user' => 'b']); + + $bag->remove('user'); + + self::assertSame(['User' => 'a'], $bag->all()); + } + + public function testCloseResetsCaseInsensitive(): void + { + $bag = new ParameterBag([], ['caseInsensitive' => true]); + $bag->set('Foo', 'bar'); + self::assertSame('bar', $bag->get('foo')); + + $bag->close(); + $bag->set('Foo', 'baz'); + + self::assertSame('baz', $bag->get('Foo')); + self::assertNull($bag->get('foo')); + } +} diff --git a/tests/ConstructorTest.php b/tests/ConstructorTest.php new file mode 100644 index 0000000..7611529 --- /dev/null +++ b/tests/ConstructorTest.php @@ -0,0 +1,81 @@ + 'a', 'pass' => 'b']); + + // With a flat payload the bag must operate in flat mode, so a + // dotted key is stored verbatim rather than split into a nested + // structure. + $bag->set('foo.bar', 'baz'); + + self::assertSame('baz', $bag->get('foo.bar')); + self::assertSame( + ['user' => 'a', 'pass' => 'b', 'foo.bar' => 'baz'], + $bag->all() + ); + } + + public function testNestedArrayIsTreatedAsMultiDimensional(): void + { + $bag = new ParameterBag([ + 'database' => ['user' => 'root', 'pass' => '123'], + ]); + + self::assertSame('root', $bag->get('database.user')); + self::assertSame('123', $bag->get('database.pass')); + self::assertTrue($bag->has('database.user')); + self::assertFalse($bag->has('database.unknown')); + } + + public function testExplicitIsMultiTrueOverridesAutoDetection(): void + { + $bag = new ParameterBag( + ['user' => 'a', 'pass' => 'b'], + ['isMulti' => true] + ); + + $bag->set('database.user', 'root'); + + self::assertSame('root', $bag->get('database.user')); + } + + public function testExplicitIsMultiFalseOverridesAutoDetection(): void + { + $bag = new ParameterBag( + ['database' => ['user' => 'root']], + ['isMulti' => false] + ); + + // In flat mode 'database.user' is a single key, not a path, + // so the nested value must NOT be reachable through it. + self::assertNull($bag->get('database.user')); + self::assertSame(['user' => 'root'], $bag->get('database')); + } + + public function testEmptyConstructorDefaultsAreFlatMode(): void + { + $bag = new ParameterBag(); + $bag->set('a.b', 'x'); + + // Defaults: isMulti=false → dotted key stored verbatim. + self::assertSame(['a.b' => 'x'], $bag->all()); + } +} diff --git a/tests/EdgeCasesTest.php b/tests/EdgeCasesTest.php new file mode 100644 index 0000000..f3f33d7 --- /dev/null +++ b/tests/EdgeCasesTest.php @@ -0,0 +1,171 @@ + ['user' => 'root']], + ['isMulti' => true, 'separator' => '|', 'caseInsensitive' => true] + ); + + $bag->close(); + + self::assertSame([], $bag->all()); + // After close() the defaults are flat + dot + case-sensitive, + // so a key that previously walked into a nested value now + // becomes a flat literal. + $bag->set('Db|User', 'verbatim'); + self::assertSame(['Db|User' => 'verbatim'], $bag->all()); + } + + public function testClearKeepsOptions(): void + { + $bag = new ParameterBag([], ['isMulti' => true, 'separator' => '/']); + $bag->set('a/b', 1); + + $bag->clear(); + + // Stack is empty … + self::assertSame([], $bag->all()); + // … but the multi-mode + custom separator are intact. + $bag->set('x/y', 2); + self::assertSame(['x' => ['y' => 2]], $bag->all()); + } + + public function testDebugInfoExposesCurrentState(): void + { + $bag = new ParameterBag( + ['db' => ['user' => 'root']], + ['isMulti' => true, 'separator' => '|'] + ); + + self::assertSame( + [ + 'isMulti' => 'yes', + 'separator' => '|', + 'data' => ['db' => ['user' => 'root']], + ], + $bag->__debugInfo() + ); + } + + public function testSetRemoveAndReplaceAreChainable(): void + { + $bag = new ParameterBag(); + + $result = $bag + ->set('a', 1) + ->set('b', 2) + ->remove('a') + ->merge(['c' => 3]) + ->replace(['only' => 'value']); + + self::assertInstanceOf(ParameterBagInterface::class, $result); + self::assertSame($bag, $result); + self::assertSame(['only' => 'value'], $bag->all()); + } + + public function testNumericKeysAreSupportedInConstructorData(): void + { + $bag = new ParameterBag([0 => 'zero', 1 => 'one', 'two' => 2]); + + self::assertSame('zero', $bag->get('0')); + self::assertSame('one', $bag->get('1')); + self::assertSame(2, $bag->get('two')); + self::assertSame([0 => 'zero', 1 => 'one', 'two' => 2], $bag->all()); + } + + public function testEmptyStringKeyRoundTrips(): void + { + $bag = new ParameterBag(); + $bag->set('', 'value-at-empty-key'); + + self::assertTrue($bag->has('')); + self::assertSame('value-at-empty-key', $bag->get('')); + } + + public function testMultiModeScalarLeafAtNonLeafPathReturnsDefault(): void + { + $bag = new ParameterBag([], ['isMulti' => true]); + $bag->set('user', 'alice'); // scalar at top level + + // Querying through it as if it were nested must NOT probe + // into the scalar — return the default instead. + self::assertNull($bag->get('user.name')); + self::assertFalse($bag->has('user.name')); + self::assertSame('fallback', $bag->get('user.name', 'fallback')); + } + + public function testInvalidSeparatorOptionIsIgnored(): void + { + // Empty separator must not silently switch the splitter to a + // no-op; it is rejected and the previous value is kept. + $bag = new ParameterBag([], ['isMulti' => true, 'separator' => '']); + $bag->set('a.b', 'c'); + + self::assertSame('c', $bag->get('a.b')); + } + + public function testMultiModeSetOverwritesParentScalarWithNestedValue(): void + { + $bag = new ParameterBag([], ['isMulti' => true]); + $bag->set('db', 'a-string'); + // The path 'db.user' must replace the scalar 'a-string' with + // a fresh nested array — it should not silently throw or + // append into the string. + $bag->set('db.user', 'root'); + + self::assertSame(['db' => ['user' => 'root']], $bag->all()); + } + + public function testCaseInsensitiveSetWithArrayValueAlsoLowercasesNestedKeys(): void + { + $bag = new ParameterBag([], ['caseInsensitive' => true]); + $bag->set('Outer', ['Inner' => ['Leaf' => 'v']]); + + self::assertSame( + ['outer' => ['inner' => ['leaf' => 'v']]], + $bag->all() + ); + } + + public function testMergeWithMixedParameterBagAndArrayArguments(): void + { + $a = new ParameterBag(['x' => 1]); + $b = new ParameterBag(['y' => 2]); + + $a->merge($b, ['z' => 3]); + + self::assertSame(['x' => 1, 'y' => 2, 'z' => 3], $a->all()); + } + + public function testGetReturnsNullDefaultWhenNoArgumentSupplied(): void + { + $bag = new ParameterBag(); + + self::assertNull($bag->get('nope')); + } + + public function testRemoveWithEmptyVariadicIsNoOp(): void + { + $bag = new ParameterBag(['a' => 1, 'b' => 2]); + $bag->remove(); + + self::assertSame(['a' => 1, 'b' => 2], $bag->all()); + } +} diff --git a/tests/InfrastructureTest.php b/tests/InfrastructureTest.php new file mode 100644 index 0000000..d37225f --- /dev/null +++ b/tests/InfrastructureTest.php @@ -0,0 +1,24 @@ +all()); + } +} diff --git a/tests/MergeTest.php b/tests/MergeTest.php new file mode 100644 index 0000000..ae2ebdc --- /dev/null +++ b/tests/MergeTest.php @@ -0,0 +1,119 @@ + 1, 'b' => 2]); + $bag->merge(['b' => 20, 'c' => 30]); + + self::assertSame(['a' => 1, 'b' => 20, 'c' => 30], $bag->all()); + } + + public function testMergeAcceptsAnotherParameterBag(): void + { + $a = new ParameterBag(['x' => 1]); + $b = new ParameterBag(['y' => 2]); + + $a->merge($b); + + self::assertSame(['x' => 1, 'y' => 2], $a->all()); + } + + public function testMergeWithEmptyArrayIsNoOp(): void + { + $bag = new ParameterBag(['a' => 1]); + $bag->merge([]); + + self::assertSame(['a' => 1], $bag->all()); + } + + public function testMergeRejectsScalarArguments(): void + { + $bag = new ParameterBag(); + + $this->expectException(ParameterBagInvalidArgumentException::class); + /** @phpstan-ignore-next-line — intentionally passing wrong type */ + $bag->merge('not an array'); + } + + public function testMergeRejectsObjectsOtherThanParameterBag(): void + { + $bag = new ParameterBag(); + + $this->expectException(ParameterBagInvalidArgumentException::class); + /** @phpstan-ignore-next-line — intentionally passing wrong type */ + $bag->merge(new \stdClass()); + } + + /** + * Regression test for B8: in multi-mode, merging two payloads that + * share a parent key must preserve siblings on both sides. + */ + public function testMultiModeMergeIsRecursive(): void + { + $bag = new ParameterBag( + ['db' => ['user' => 'root']], + ['isMulti' => true] + ); + + $bag->merge(['db' => ['pass' => 'secret']]); + + self::assertSame( + ['db' => ['user' => 'root', 'pass' => 'secret']], + $bag->all() + ); + } + + public function testMultiModeMergeOverwritesLeafScalars(): void + { + $bag = new ParameterBag( + ['db' => ['user' => 'root', 'pass' => 'old']], + ['isMulti' => true] + ); + + $bag->merge(['db' => ['pass' => 'new']]); + + self::assertSame( + ['db' => ['user' => 'root', 'pass' => 'new']], + $bag->all() + ); + } + + public function testMultiModeMergeMultipleArguments(): void + { + $bag = new ParameterBag( + ['db' => ['user' => 'root']], + ['isMulti' => true] + ); + + $bag->merge( + ['db' => ['pass' => 'secret']], + ['cache' => ['driver' => 'redis']] + ); + + self::assertSame( + [ + 'db' => ['user' => 'root', 'pass' => 'secret'], + 'cache' => ['driver' => 'redis'], + ], + $bag->all() + ); + } +} diff --git a/tests/OptionsValidationTest.php b/tests/OptionsValidationTest.php new file mode 100644 index 0000000..0f5e7ee --- /dev/null +++ b/tests/OptionsValidationTest.php @@ -0,0 +1,53 @@ + true` would not toggle anything but would + * also not warn. v2 rejects every key it does not recognise. + */ +final class OptionsValidationTest extends TestCase +{ + public function testKnownOptionsAreAccepted(): void + { + $bag = new ParameterBag([], [ + 'isMulti' => true, + 'separator' => '|', + 'caseInsensitive' => false, + ]); + + $bag->set('a|b', 'c'); + self::assertSame('c', $bag->get('a|b')); + } + + public function testUnknownOptionKeyThrows(): void + { + $this->expectException(ParameterBagInvalidArgumentException::class); + $this->expectExceptionMessageMatches('/is_multi/'); + + new ParameterBag([], ['is_multi' => true]); + } + + public function testMisspelledKnownOptionStillThrows(): void + { + $this->expectException(ParameterBagInvalidArgumentException::class); + + new ParameterBag([], ['seperator' => '|']); + } + + public function testEmptyOptionsArrayIsAccepted(): void + { + $this->expectNotToPerformAssertions(); + + new ParameterBag([], []); + } +} diff --git a/tests/RemoveTest.php b/tests/RemoveTest.php new file mode 100644 index 0000000..7486fd7 --- /dev/null +++ b/tests/RemoveTest.php @@ -0,0 +1,90 @@ + 1, 'b' => 2]); + + $bag->remove('a'); + + self::assertFalse($bag->has('a')); + self::assertTrue($bag->has('b')); + } + + public function testRemoveMultipleFlatKeys(): void + { + $bag = new ParameterBag(['a' => 1, 'b' => 2, 'c' => 3]); + + $bag->remove('a', 'c'); + + self::assertSame(['b' => 2], $bag->all()); + } + + public function testRemoveMissingKeyIsNoOp(): void + { + $bag = new ParameterBag(['a' => 1]); + + $bag->remove('does-not-exist'); + + self::assertSame(['a' => 1], $bag->all()); + } + + public function testRemoveSingleNestedPath(): void + { + $bag = new ParameterBag( + ['db' => ['user' => 'root', 'pass' => 'x']], + ['isMulti' => true] + ); + + $bag->remove('db.pass'); + + self::assertTrue($bag->has('db.user')); + self::assertFalse($bag->has('db.pass')); + self::assertSame(['db' => ['user' => 'root']], $bag->all()); + } + + /** + * Regression test for B3. + * + * In the legacy code, the second iteration of the loop computed + * $id = $keys[0] (== 'db.pass'), causing the wrong top-level slot + * to be rewritten. With the fix, the second key 'cache.ttl' must + * cleanly remove only that nested entry. + */ + public function testRemoveMultipleNestedPathsB3Regression(): void + { + $bag = new ParameterBag( + [ + 'db' => ['user' => 'root', 'pass' => 'x'], + 'cache' => ['ttl' => 60, 'driver' => 'redis'], + ], + ['isMulti' => true] + ); + + $bag->remove('db.pass', 'cache.ttl'); + + self::assertSame( + [ + 'db' => ['user' => 'root'], + 'cache' => ['driver' => 'redis'], + ], + $bag->all() + ); + } +} diff --git a/tests/SentinelTest.php b/tests/SentinelTest.php new file mode 100644 index 0000000..5c19984 --- /dev/null +++ b/tests/SentinelTest.php @@ -0,0 +1,56 @@ + true]); + $bag->set('db.user', self::LEGACY_SENTINEL); + + self::assertTrue($bag->has('db.user')); + self::assertSame(self::LEGACY_SENTINEL, $bag->get('db.user')); + } + + public function testNullValueIsDistinctFromMissingKey(): void + { + $bag = new ParameterBag([], ['isMulti' => true]); + $bag->set('db.user', null); + + self::assertTrue($bag->has('db.user')); + self::assertNull($bag->get('db.user', 'fallback')); + } + + public function testNullStoredInFlatModeIsDistinctFromMissing(): void + { + $bag = new ParameterBag(); + $bag->set('user', null); + + self::assertTrue($bag->has('user')); + // get() falls back to default when the underlying value is null + // because the legacy ?? operator cannot tell the two cases apart. + // The has()/get() contract for v2: has() is authoritative. + self::assertFalse($bag->has('missing')); + } +} diff --git a/tests/StdInterfacesTest.php b/tests/StdInterfacesTest.php new file mode 100644 index 0000000..9d5f013 --- /dev/null +++ b/tests/StdInterfacesTest.php @@ -0,0 +1,148 @@ + 'alice']); + + self::assertSame('alice', $bag['user']); + self::assertNull($bag['missing']); + } + + public function testArrayAccessWriteDelegatesToSet(): void + { + $bag = new ParameterBag(); + $bag['user'] = 'alice'; + + self::assertSame('alice', $bag->get('user')); + } + + public function testArrayAccessExistsDelegatesToHas(): void + { + $bag = new ParameterBag(['user' => null]); + + self::assertTrue(isset($bag['user'])); // exists, even though null + self::assertFalse(isset($bag['missing'])); + } + + public function testArrayAccessUnsetDelegatesToRemove(): void + { + $bag = new ParameterBag(['user' => 'alice', 'pass' => 'x']); + + unset($bag['user']); + + self::assertFalse($bag->has('user')); + self::assertTrue($bag->has('pass')); + } + + public function testArrayAccessSupportsDottedPathInMultiMode(): void + { + $bag = new ParameterBag( + ['db' => ['user' => 'root']], + ['isMulti' => true] + ); + + self::assertSame('root', $bag['db.user']); + + $bag['db.pass'] = 'secret'; + self::assertSame('secret', $bag->get('db.pass')); + + unset($bag['db.pass']); + self::assertFalse($bag->has('db.pass')); + } + + public function testArrayAccessSetWithNullOffsetThrows(): void + { + $bag = new ParameterBag(); + + $this->expectException(\InitPHP\ParameterBag\Exception\ParameterBagInvalidArgumentException::class); + $bag[] = 'value'; + } + + public function testCountReturnsTopLevelEntryCount(): void + { + $bag = new ParameterBag(); + self::assertCount(0, $bag); + + $bag->set('a', 1); + $bag->set('b', 2); + self::assertCount(2, $bag); + + $bag->remove('a'); + self::assertCount(1, $bag); + } + + public function testCountIgnoresNestedDepth(): void + { + $bag = new ParameterBag( + ['db' => ['user' => 'root', 'pass' => 'x'], 'cache' => ['ttl' => 60]], + ['isMulti' => true] + ); + + // count() reports top-level entries only. + self::assertCount(2, $bag); + } + + public function testGetIteratorYieldsTopLevelEntries(): void + { + $bag = new ParameterBag(['a' => 1, 'b' => 2, 'c' => 3]); + + $iterator = $bag->getIterator(); + self::assertInstanceOf(Traversable::class, $iterator); + + $collected = iterator_to_array($iterator); + self::assertSame(['a' => 1, 'b' => 2, 'c' => 3], $collected); + } + + public function testIterationCanBeRepeated(): void + { + $bag = new ParameterBag(['x' => 10, 'y' => 20]); + + $first = []; + foreach ($bag as $k => $v) { + $first[$k] = $v; + } + + $second = []; + foreach ($bag as $k => $v) { + $second[$k] = $v; + } + + self::assertSame($first, $second); + self::assertSame(['x' => 10, 'y' => 20], $first); + } + + public function testIteratorIsArrayIterator(): void + { + $bag = new ParameterBag(['a' => 1]); + + self::assertInstanceOf(ArrayIterator::class, $bag->getIterator()); + } +} diff --git a/tests/ValueIntegrityTest.php b/tests/ValueIntegrityTest.php new file mode 100644 index 0000000..e58d047 --- /dev/null +++ b/tests/ValueIntegrityTest.php @@ -0,0 +1,57 @@ + ['host' => '.example.com.']], + ['isMulti' => true, 'separator' => '.'] + ); + + self::assertSame('.example.com.', $bag->get('db.host')); + } + + public function testTopLevelStringValuesAreNotTrimmed(): void + { + $bag = new ParameterBag( + ['greeting' => '.hello.'], + ['isMulti' => true, 'separator' => '.'] + ); + + self::assertSame('.hello.', $bag->get('greeting')); + } + + public function testSetPreservesStringValuesVerbatim(): void + { + $bag = new ParameterBag([], ['isMulti' => true, 'separator' => '.']); + $bag->set('db.host', '|leading-and-trailing|'); + + self::assertSame('|leading-and-trailing|', $bag->get('db.host')); + } + + public function testCustomSeparatorDoesNotAlterValues(): void + { + $bag = new ParameterBag( + ['db' => ['host' => '|example.com|']], + ['isMulti' => true, 'separator' => '|'] + ); + + self::assertSame('|example.com|', $bag->get('db|host')); + } +} From 8d3c8bf8b6112e71851a1fcbe222b4bf3f884c4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 24 May 2026 14:50:48 +0300 Subject: [PATCH 2/2] Update debug info for case insensitivity in ParameterBag & EdgeCasesTest --- src/ParameterBag.php | 9 +++++---- tests/EdgeCasesTest.php | 7 ++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/ParameterBag.php b/src/ParameterBag.php index 129785a..edd980e 100644 --- a/src/ParameterBag.php +++ b/src/ParameterBag.php @@ -143,14 +143,15 @@ public function __construct(array $data = [], array $options = []) } /** - * @return array{isMulti: string, separator: string, data: array} + * @return array{isMulti: string, separator: string, caseInsensitive: string, data: array} */ public function __debugInfo(): array { return [ - 'isMulti' => $this->isMulti ? 'yes' : 'no', - 'separator' => $this->separator, - 'data' => $this->all(), + 'isMulti' => $this->isMulti ? 'yes' : 'no', + 'separator' => $this->separator, + 'caseInsensitive' => $this->caseInsensitive ? 'yes' : 'no', + 'data' => $this->all(), ]; } diff --git a/tests/EdgeCasesTest.php b/tests/EdgeCasesTest.php index f3f33d7..ac96af8 100644 --- a/tests/EdgeCasesTest.php +++ b/tests/EdgeCasesTest.php @@ -56,9 +56,10 @@ public function testDebugInfoExposesCurrentState(): void self::assertSame( [ - 'isMulti' => 'yes', - 'separator' => '|', - 'data' => ['db' => ['user' => 'root']], + 'isMulti' => 'yes', + 'separator' => '|', + 'caseInsensitive' => 'no', + 'data' => ['db' => ['user' => 'root']], ], $bag->__debugInfo() );