diff --git a/CHANGELOG.md b/CHANGELOG.md index ccbd994..a7ccccc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 1.2.1 - WIP +- Added configurable missing-key behavior: silent, warning, or exception. + ## 1.2.0 - 2026-01-08 - Removing PestPHP, using phpunit 11 - Support from PHP 8.2 to 8.5 diff --git a/README.md b/README.md index 7371022..3ddfd12 100644 --- a/README.md +++ b/README.md @@ -423,6 +423,60 @@ For example, `$data->getBlock("avocado.color")` returns a Block object with just If you are going to access a non-valid key, an empty Block object is returned, so the `$data->getBlock("avocado.notexists")` returns a Block object with a length equal to 0. +### Missing key behavior + +By default, accessing a non-existing key returns the provided default value **silently**. + +You can configure three behaviors: + +- **Silent** (default) +- **Warning** (non-fatal) +- **Exception** + + +#### Silent (default) + +```php +$fruits = Block::make($fruitsArray); +// OR if you want to be more explicit: $fruits = Block::make($fruitsArray)->silentOnMissingKey(); + +$nothing = $fruits->get("a-missing-key", "DEFAULT VALUE"); // no warning, no exception +``` + +#### Warning + +```php +$fruits = Block::make($fruitsArray)->warnOnMissingKey(); + +$nothing = $fruits->get("a-missing-key", "DEFAULT VALUE"); // PHP warning +``` + +#### Exception + +```php +$fruits = Block::make($fruitsArray) + ->throwOnMissingKey(); + +$nothing = $fruits->get("a-missing-key"); // throws exception + + +$fruits->throwOnMissingKey( + MyMissingKeyException::class, + "The key in the configuration JSON file doens't exist?" +); +``` + +You can also pass your own exception class (must extend `\Throwable`). + +#### Summary for the "missing key behavior" + +| Mode | Result | +| --------- | ------------------------------------ | +| Silent (default behavior) | Returns default value | +| Warning | Emits warning, returns default value | +| Exception | Throws exception | + + ### The `set()` method The `set()` method supports keys with the dot (or custom) notation for setting values for nested data. If a key doesn't exist, the `set()` method creates one and sets the value. diff --git a/src/Block.php b/src/Block.php index 7100f7a..638635a 100644 --- a/src/Block.php +++ b/src/Block.php @@ -38,12 +38,119 @@ final class Block implements Iterator, ArrayAccess, Countable /** @var array */ private array $data; + /** + * Missing key handling mode: return default value silently. + */ + private const MISSING_KEY_SILENT = 0; + + /** + * Missing key handling mode: emit a warning and continue execution. + */ + private const MISSING_KEY_WARNING = 1; + + /** + * Missing key handling mode: throw an exception. + */ + private const MISSING_KEY_EXCEPTION = 2; + + /** + * Current missing key handling mode. + * + * One of: + * - self::MISSING_KEY_SILENT + * - self::MISSING_KEY_WARNING + * - self::MISSING_KEY_EXCEPTION + */ + private int $missingKeyMode = self::MISSING_KEY_SILENT; + + + /** @var class-string<\Throwable>|null Exception class to throw on missing key */ + private ?string $missingKeyExceptionClass = null; + /** @var string|null Optional hint appended to the exception message */ + private ?string $missingKeyExceptionHint = null; + /** @param array $data */ public function __construct(array $data = [], private bool $iteratorReturnsBlock = true) { $this->data = $data; } + /** + * Use silent mode when accessing missing keys (return default value). + */ + public function silentOnMissingKey(): self + { + $this->missingKeyMode = self::MISSING_KEY_SILENT; + $this->missingKeyExceptionClass = null; + $this->missingKeyExceptionHint = null; + return $this; + } + + /** + * Emit a warning when accessing a missing key. + */ + public function warnOnMissingKey(): self + { + $this->missingKeyMode = self::MISSING_KEY_WARNING; + + return $this; + } + + /** + * Configure the Block object to throw an exception when accessing a missing key. + * + * When enabled, any access to a non-existing key will throw an exception of the + * given class instead of returning the default value. + * + * An optional hint can be provided to add extra context to the exception message. + * + * @param class-string<\Throwable> $exceptionClass Exception class to throw (must extend Throwable) + * @param string|null $hint Optional additional context appended to the exception message + * + * @return self Returns the current instance for method chaining. + * + * @throws \InvalidArgumentException If the given class does not extend Throwable + */ + public function throwOnMissingKey( + string $exceptionClass = \OutOfBoundsException::class, + ?string $hint = null, + ): self { + /** @phpstan-ignore function.alreadyNarrowedType, booleanAnd.alwaysFalse */ + if (!is_subclass_of($exceptionClass, \Throwable::class) && $exceptionClass !== \Throwable::class) { + throw new \InvalidArgumentException("Exception class must extend Throwable"); + } + + $this->missingKeyMode = self::MISSING_KEY_EXCEPTION; + $this->missingKeyExceptionClass = $exceptionClass; + $this->missingKeyExceptionHint = $hint; + + return $this; + } + + private function handleMissingKey(int|string $key, mixed $defaultValue): mixed + { + switch ($this->missingKeyMode) { + case self::MISSING_KEY_WARNING: + trigger_error("Undefined array key: " . $key, E_USER_WARNING); + return $defaultValue; + + case self::MISSING_KEY_EXCEPTION: + $class = $this->missingKeyExceptionClass ?? \OutOfBoundsException::class; + $message = "Undefined array key: " . $key; + if ($this->missingKeyExceptionHint) { + $message = $message . " (" . $this->missingKeyExceptionHint . ")"; + } + + throw new $class($message); + + case self::MISSING_KEY_SILENT: + default: + return $defaultValue; + } + } + + + public function iterateBlock(bool $returnsBlock = true): self { $this->iteratorReturnsBlock = $returnsBlock; @@ -89,13 +196,17 @@ public function get(int|string $key, mixed $defaultValue = null, string $charNes } elseif ($nestedValue instanceof Block) { $nestedValue = $nestedValue->get($nestedKey); } else { - return $defaultValue; + return $this->handleMissingKey($key, $defaultValue); } } return $nestedValue; } } - return $this->data[$key] ?? $defaultValue; + if (!array_key_exists($key, $this->data)) { + return $this->handleMissingKey($key, $defaultValue); + } + + return $this->data[$key]; } @@ -247,9 +358,4 @@ public function applyField( $this->set($targetKey, $callable($this->get($key))); return $this; } - - - - - } diff --git a/tests/BlockJsonTest.php b/tests/BlockJsonTest.php index f87a435..d42c3fc 100644 --- a/tests/BlockJsonTest.php +++ b/tests/BlockJsonTest.php @@ -109,4 +109,10 @@ public function testSaveToJsonWithExistingFile(): void unlink("fruits.json"); } + + public function testEmptyJson(): void + { + $data = Block::fromJsonFile("file-not-exists"); + $this->assertSame(0, $data->count()); + } }