From bb06f960955b76c955ab6a3f75797285abcd1360 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Fri, 25 Nov 2022 17:17:42 +0100 Subject: [PATCH] TASK: Initial Commit --- .editorconfig | 15 + .github/workflows/Quality Assurance.yml | 43 ++ .gitignore | 5 + README.md | 290 +++++++++++++ composer.json | 36 ++ src/Extractor.php | 385 ++++++++++++++++++ src/ExtractorException.php | 83 ++++ src/Internal/TypeDescriber.php | 115 ++++++ tests/Unit/ExtractorArrayAccessTest.php | 229 +++++++++++ tests/Unit/ExtractorForArrayTest.php | 173 ++++++++ tests/Unit/ExtractorForBooleanTest.php | 150 +++++++ tests/Unit/ExtractorForFloatTest.php | 150 +++++++ tests/Unit/ExtractorForIntegerOrFloatTest.php | 139 +++++++ tests/Unit/ExtractorForIntegerTest.php | 150 +++++++ tests/Unit/ExtractorForNullTest.php | 107 +++++ tests/Unit/ExtractorForStringTest.php | 150 +++++++ tests/Unit/ExtractorIterableTest.php | 144 +++++++ tests/Unit/Internal/TypeDescriberTest.php | 136 +++++++ 18 files changed, 2500 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/Quality Assurance.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100755 composer.json create mode 100644 src/Extractor.php create mode 100644 src/ExtractorException.php create mode 100644 src/Internal/TypeDescriber.php create mode 100644 tests/Unit/ExtractorArrayAccessTest.php create mode 100644 tests/Unit/ExtractorForArrayTest.php create mode 100644 tests/Unit/ExtractorForBooleanTest.php create mode 100644 tests/Unit/ExtractorForFloatTest.php create mode 100644 tests/Unit/ExtractorForIntegerOrFloatTest.php create mode 100644 tests/Unit/ExtractorForIntegerTest.php create mode 100644 tests/Unit/ExtractorForNullTest.php create mode 100644 tests/Unit/ExtractorForStringTest.php create mode 100644 tests/Unit/ExtractorIterableTest.php create mode 100644 tests/Unit/Internal/TypeDescriberTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8260bb8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 4 + +[*.neon] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/Quality Assurance.yml b/.github/workflows/Quality Assurance.yml new file mode 100644 index 0000000..06329ae --- /dev/null +++ b/.github/workflows/Quality Assurance.yml @@ -0,0 +1,43 @@ +name: Quality Assurance + +on: [push, pull_request] + +jobs: + quality-assurance: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + tools: phpcs + + - name: Get composer cache directory + id: composercache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composercache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install composer dependencies + run: | + composer install + + - name: Check Code Quality (PHP Code Sniffer) + run: | + composer lint + + - name: Static Code Analysis (PHPStan) + run: | + composer lint + + - name: Unit Tests (PHPUnit) + run: | + composer test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..609a7d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +vendor/ +build/coverage-report/ +composer.lock +.phpunit.result.cache +.idea diff --git a/README.md b/README.md new file mode 100644 index 0000000..efb17bf --- /dev/null +++ b/README.md @@ -0,0 +1,290 @@ +# PackageFactory.Extractor + +> A fluent interface that allows to validate primitive PHP data structures while also reading them + +## Installation + +``` +composer require --dev packagefactory/extractor +``` + +## Usage + +Let's say, you have a PHP-native array structure like this one: + +```php +$configuration = [ + 'mailer' => [ + 'transport' => 'smtp', + 'host' => 'smtp.example.com', + 'port' => 465 + ] +]; +``` + +It contains configuration for a mailing service. In a lot of PHP projects, configuration comes in this format, usually by being parsed from YAML or JSON sources. While these formats are nicely readable and writable, the result PHP array data structure is completely exempt from type safety. + +It is much more desirable to handle the given configuration using a value object like this one: + +```php +final class MailerConfiguration +{ + private function __construct( + public readonly MailerTransport $transport, + public readonly string $host, + public readonly int $port + ) { + } +} +``` + +To convert the array structure into this object, it may be suitable to write a static factory method: + +```php +final class MailerConfiguration +{ + /* ... */ + + public static function fromArray(array $array): self + { + if (!isset($array['transport']) || !is_string($array['transport'])) { + throw new \Exception('Transport must be a string!'); + } + + if (!isset($array['host']) || !is_string($array['host'])) { + throw new \Exception('Host must be a string!'); + } + + if (!isset($array['port']) || !is_int($array['port'])) { + throw new \Exception('Port must be an integer!'); + } + + return new self( + transport: MailerTransport::from($array['transport']), + host: $array['host'], + port: $array['port'] + ); + } +} +``` + +Unfortunately, this is a lot of code to write and it would become even more, if we'd actually like to have more helpful error messages. + +This is where the `Extractor` comes in. Using the `Extractor` API, we can write a static factory method like this: + +```php +final class MailerConfiguration +{ + /* ... */ + + public static function fromExtractor(Extractor $extractor): self + { + return new self( + transport: MailerTransport::from($extractor['transport']->string()), + host: $extractor['host']->string(), + port: $extractor['port']->int() + ); + } +} +``` + +The extractor handles the runtime type checks for us and throws helpful error messages, if the datastructure doesn't follow our assumptions. + +To complete the example from the beginning: + +```php +$configuration = [ + 'mailer' => [ + 'transport' => 'smtp', + 'host' => 'smtp.example.com', + 'port' => 465 + ] +]; + +$mailerConfiguration = MailerConfiguration::fromExtractor( + Extractor::for($configuration)['mailer'] +); +``` + +## API + +### Type Guards + +#### `bool` and `boolOrNull` + +```php +Extractor::for(true)->bool(); // returns `true` +Extractor::for(false)->bool(); // returns `false` +Extractor::for(true)->boolOrNull(); // returns `true` +Extractor::for(false)->boolOrNull(); // returns `false` +Extractor::for(null)->boolOrNull(); // returns `null` +``` + +Checks if the data given to the extractor is a boolean and returns it if thats the case. When `boolOrNull` is used, `null` will pass as well. + +#### `int` and `intOrNull` + +```php +Extractor::for(42)->int(); // returns `42` +Extractor::for(42)->intOrNull(); // returns `42` +Extractor::for(null)->intOrNull(); // returns `null` +``` + +Checks if the data given to the extractor is an integer and returns it if thats the case. When `intOrNull` is used, `null` will pass as well. + +#### `float` and `floatOrNull` + +```php +Extractor::for(47.11)->float(); // returns `47.11` +Extractor::for(47.11)->floatOrNull(); // returns `47.11` +Extractor::for(null)->floatOrNull(); // returns `null` +``` + +Checks if the data given to the extractor is a float and returns it if thats the case. When `floatOrNull` is used, `null` will pass as well. + +#### `intOrFloat` and `intOrFloatOrNull` + +```php +Extractor::for(42)->intOrFloat(); // returns `42` +Extractor::for(47.11)->intOrFloat(); // returns `47.11` +Extractor::for(42)->intOrfloatOrNull(); // returns `42` +Extractor::for(47.11)->intOrfloatOrNull(); // returns `47.11` +Extractor::for(null)->intOrfloatOrNull(); // returns `null` +``` + +In `JSON` there's no distinction between integer and float types. Everything is just a `number`. These two methods check if the data given to the extractor is a float or an integer (and therefore a `number`) and returns it if thats the case. When `intOrfloatOrNull` is used, `null` will pass as well. + +#### `string` and `stringOrNull` + +```php +Extractor::for('string')->string(); // returns `"string"` +Extractor::for('string')->stringOrNull(); // returns `"string"` +Extractor::for(null)->stringOrNull(); // returns `null` +``` + +Checks if the data given to the extractor is a string and returns it if thats the case. When `stringOrNull` is used, `null` will pass as well. + +#### `array` and `arrayOrNull` + +```php +Extractor::for([])->array(); // returns `[]` +Extractor::for([])->arrayOrNull(); // returns `[]` +Extractor::for(null)->arrayOrNull(); // returns `null` +``` + +Checks if the data given to the extractor is an array and returns it if thats the case. When `stringOrNull` is used, `null` will pass as well. + +### Array Access + +In order to deal with nested array structures, `Extractor` implements the `\ArrayAccess` interface. + +Given you have an `Extractor` that wraps an array, when you access a key, you'll receive the value for that key wrapped in another `Extractor` instance: + +```php +$extractor = Extractor::for([ 'key' => 'value' ]); +$extractor['key']->string(); // returns `"value"` +$extractor['key']->int(); // throws +``` + +If you access an unknown key, it'll be treated like `Extractor::for(null)`: + +```php +$extractor['unknown key']->stringOrNull(); // returns `null` +$extractor['unknown key']->string(); // throws +``` + +If you access a key on something other than an array, `Extractor` will throw: + +```php +$extractor = Extractor::for('This is not an array...'); +$extractor['key']; // throws +``` + +#### `getPath` + +Each `Extractor` instance provides you with the access path by which it has been retrieved: + +```php +$extractor = Extractor::for([ + 'some' => [ + 'deep' => [ + 'path' => '1234' + ] + ] +]); + +$nested = $extractor['some']['deep']['path']; +var_dump($nested->getPath()); +// Output: +// array(3) { +// [0] => +// string(4) "some" +// [1] => +// string(4) "deep" +// [2] => +// string(4) "path" +// } +``` + +### Iterable + +`Extractor` implements the `\IterableAggregate` interface, which allows you to loop over it using `foreach`: + +```php +foreach (Extractor::for([ 'key' => 'value' ]) as $key => $value) { + $key->string(); // returns `"key"` + $value->string(); // returns `"value"` + + $key->int(); // throws +} +``` + +As you see, both `$key` and `$value` are themselves instances of `Extractor`. + +If you try to iterate over an `Extractor` that wraps something other than an array, the `Extractor` will throw: + +```php +foreach (Extractor::for('This is not an array...') as $key => $value) { // throws +} +``` + +### Error Handling + +`Extractor` may throw instances of `ExtractorException`. Each `ExtractorException` carries the access path by which the throwing `Extractor` has been retrieved and tries to provide a helpful error message: + +```php +$extractor = Extractor::for([ + 'some' => [ + 'deep' => [ + 'path' => '1234' + ] + ] +]); + +try { + $extractor['some']['deep']['path']->int(); +} catch (ExtractorException $e) { + var_dump($e->getPath()); + // Output: + // array(3) { + // [0] => + // string(4) "some" + // [1] => + // string(4) "deep" + // [2] => + // string(4) "path" + // } + + var_dump($e->getMessage()); + // Output: + // string(65) "Value was expected to be of type int, got string("1234") instead." +} +``` + +## Contribution + +We will gladly accept contributions. Please send us pull requests. + +## License + +see [LICENSE](./LICENSE) diff --git a/composer.json b/composer.json new file mode 100755 index 0000000..0ee764c --- /dev/null +++ b/composer.json @@ -0,0 +1,36 @@ +{ + "name": "packagefactory/extractor", + "type": "library", + "description": "A fluent interface that allows to validate primitive PHP data structures while also reading them", + "license": [ + "GPL-3.0-or-later" + ], + "scripts": { + "cleanup": [ + "rm -rf build", + "rm -rf vendor", + "rm -f composer.lock" + ], + "lint": "phpcs --standard=PSR2 --extensions=php src/", + "analyse": "phpstan analyse --level 8 src tests", + "test": "phpunit --enforce-time-limit --bootstrap vendor/autoload.php --testdox tests --coverage-html build/coverage-report --whitelist src" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "phpstan/phpstan": "^1.9", + "squizlabs/php_codesniffer": "^3.7" + }, + "autoload": { + "psr-4": { + "PackageFactory\\Extractor\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "PackageFactory\\Extractor\\Tests\\": "tests" + } + } +} diff --git a/src/Extractor.php b/src/Extractor.php new file mode 100644 index 0000000..cdae1cf --- /dev/null +++ b/src/Extractor.php @@ -0,0 +1,385 @@ +. + */ + +declare(strict_types=1); + +namespace PackageFactory\Extractor; + +use PackageFactory\Extractor\Internal\TypeDescriber; + +/** + * @api + * @implements \ArrayAccess + * @implements \IteratorAggregate + */ +final class Extractor implements \ArrayAccess, \IteratorAggregate +{ + /** + * @param null|boolean|integer|float|string|array $data + * @param (int|string)[] $path + */ + private function __construct( + private readonly null|bool|int|float|string|array $data, + private readonly array $path, + private readonly bool $isKey + ) { + } + + /** + * @api + * @param null|boolean|integer|float|string|array $data + */ + public static function for(null|bool|int|float|string|array $data): self + { + return new self($data, [], false); + } + + /** + * @param int|string $key + */ + private function forKey(int|string $key): self + { + return new self($key, [...$this->path, $key], true); + } + + /** + * @api + * @return (int|string)[] + */ + public function getPath(): array + { + return $this->path; + } + + /** + * @api + * @return boolean + */ + public function bool(): bool + { + if ($this->data === null) { + throw ExtractorException::becauseDataIsRequiredButNullWasPassed($this->path); + } + + if (is_bool($this->data)) { + return $this->data; + } + + throw ExtractorException::becauseDataDidNotMatchExpectedType( + path: $this->path, + expectedType: 'bool', + attemptedData: $this->data, + isKey: $this->isKey + ); + } + + /** + * @api + * @return boolean|null + */ + public function boolOrNull(): bool|null + { + if ($this->data === null || is_bool($this->data)) { + return $this->data; + } + + throw ExtractorException::becauseDataDidNotMatchExpectedType( + path: $this->path, + expectedType: 'bool or null', + attemptedData: $this->data, + isKey: $this->isKey + ); + } + + /** + * @api + * @return integer + */ + public function int(): int + { + if ($this->data === null) { + throw ExtractorException::becauseDataIsRequiredButNullWasPassed($this->path); + } + + if (is_int($this->data)) { + return $this->data; + } + + throw ExtractorException::becauseDataDidNotMatchExpectedType( + path: $this->path, + expectedType: 'int', + attemptedData: $this->data, + isKey: $this->isKey + ); + } + + /** + * @api + * @return integer|null + */ + public function intOrNull(): int|null + { + if ($this->data === null || is_int($this->data)) { + return $this->data; + } + + throw ExtractorException::becauseDataDidNotMatchExpectedType( + path: $this->path, + expectedType: 'int or null', + attemptedData: $this->data, + isKey: $this->isKey + ); + } + + /** + * @api + * @return float + */ + public function float(): float + { + if ($this->data === null) { + throw ExtractorException::becauseDataIsRequiredButNullWasPassed($this->path); + } + + if (is_float($this->data)) { + return $this->data; + } + + throw ExtractorException::becauseDataDidNotMatchExpectedType( + path: $this->path, + expectedType: 'float', + attemptedData: $this->data, + isKey: $this->isKey + ); + } + + /** + * @api + * @return float|null + */ + public function floatOrNull(): float|null + { + if ($this->data === null || is_float($this->data)) { + return $this->data; + } + + throw ExtractorException::becauseDataDidNotMatchExpectedType( + path: $this->path, + expectedType: 'float or null', + attemptedData: $this->data, + isKey: $this->isKey + ); + } + + /** + * @api + * @return int|float + */ + public function intOrFloat(): int|float + { + if ($this->data === null) { + throw ExtractorException::becauseDataIsRequiredButNullWasPassed($this->path); + } + + if (is_int($this->data) || is_float($this->data)) { + return $this->data; + } + + throw ExtractorException::becauseDataDidNotMatchExpectedType( + path: $this->path, + expectedType: 'int or float', + attemptedData: $this->data, + isKey: $this->isKey + ); + } + + /** + * @api + * @return int|float|null + */ + public function intOrfloatOrNull(): int|float|null + { + if ($this->data === null || is_int($this->data) || is_float($this->data)) { + return $this->data; + } + + throw ExtractorException::becauseDataDidNotMatchExpectedType( + path: $this->path, + expectedType: 'int or float or null', + attemptedData: $this->data, + isKey: $this->isKey + ); + } + + /** + * @api + * @return string + */ + public function string(): string + { + if ($this->data === null) { + throw ExtractorException::becauseDataIsRequiredButNullWasPassed($this->path); + } + + if (is_string($this->data)) { + return $this->data; + } + + throw ExtractorException::becauseDataDidNotMatchExpectedType( + path: $this->path, + expectedType: 'string', + attemptedData: $this->data, + isKey: $this->isKey + ); + } + + /** + * @api + * @return string|null + */ + public function stringOrNull(): string|null + { + if ($this->data === null || is_string($this->data)) { + return $this->data; + } + + throw ExtractorException::becauseDataDidNotMatchExpectedType( + path: $this->path, + expectedType: 'string or null', + attemptedData: $this->data, + isKey: $this->isKey + ); + } + + /** + * @api + * @return array + */ + public function array(): array + { + if ($this->data === null) { + throw ExtractorException::becauseDataIsRequiredButNullWasPassed($this->path); + } + + if (is_array($this->data)) { + return $this->data; + } + + throw ExtractorException::becauseDataDidNotMatchExpectedType( + path: $this->path, + expectedType: 'array', + attemptedData: $this->data, + isKey: $this->isKey + ); + } + + /** + * @api + * @return array|null + */ + public function arrayOrNull(): null|array + { + if ($this->data === null || is_array($this->data)) { + return $this->data; + } + + throw ExtractorException::becauseDataDidNotMatchExpectedType( + path: $this->path, + expectedType: 'array or null', + attemptedData: $this->data, + isKey: $this->isKey + ); + } + + /** + * @api + * @param mixed $offset + * @return boolean + */ + public function offsetExists(mixed $offset): bool + { + return is_array($this->data) && (is_string($offset) || is_int($offset) || is_float($offset)); + } + + /** + * @api + * @param mixed $offset + * @return self + */ + public function offsetGet(mixed $offset): mixed + { + if ($this->data === null) { + return new self(null, [...$this->path, $offset], false); + } + + $data = $this->array(); + + return array_key_exists($offset, $data) + ? new self($data[$offset], [...$this->path, $offset], false) + : new self(null, [...$this->path, $offset], false); + } + + /** + * @param mixed $offset + * @param mixed $value + * @return void + */ + public function offsetSet(mixed $offset, mixed $value): void + { + throw new \LogicException( + sprintf( + 'Extractor is read-only! Tried to set: "%s"', + is_string($offset) || is_int($offset) || is_float($offset) + ? $offset + : TypeDescriber::describeTypeOf($offset) + ) + ); + } + + /** + * @param mixed $offset + * @return void + */ + public function offsetUnset(mixed $offset): void + { + throw new \LogicException( + sprintf( + 'Extractor is read-only! Tried to unset: "%s"', + is_string($offset) || is_int($offset) || is_float($offset) + ? $offset + : TypeDescriber::describeTypeOf($offset) + ) + ); + } + + /** + * @api + * @return \Traversable + */ + public function getIterator(): \Traversable + { + if ($this->data !== null) { + foreach ($this->array() as $key => $value) { + yield $this->forKey($key) => new self($value, [...$this->path, $key], false); + } + } + } +} diff --git a/src/ExtractorException.php b/src/ExtractorException.php new file mode 100644 index 0000000..9808f98 --- /dev/null +++ b/src/ExtractorException.php @@ -0,0 +1,83 @@ +. + */ + +declare(strict_types=1); + +namespace PackageFactory\Extractor; + +use PackageFactory\Extractor\Internal\TypeDescriber; + +/** + * @api + */ +final class ExtractorException extends \Exception +{ + /** + * @param (int|string)[] $path + * @param string $message + */ + private function __construct( + private readonly array $path, + string $message + ) { + parent::__construct($message, 1669042598); + } + + /** + * @return (int|string)[] + */ + public function getPath(): array + { + return $this->path; + } + + /** + * @param (int|string)[] $path + * @return self + */ + public static function becauseDataIsRequiredButNullWasPassed(array $path): self + { + return new self($path, 'Value is required, but was null.'); + } + + /** + * @param (int|string)[] $path + * @param string $expectedType + * @param mixed $attemptedData + * @return self + */ + public static function becauseDataDidNotMatchExpectedType( + array $path, + string $expectedType, + mixed $attemptedData, + bool $isKey + ): self { + return new self( + path: $path, + message: sprintf( + '%s was expected to be of type %s, got %s instead.', + $isKey ? 'Key' : 'Value', + $expectedType, + TypeDescriber::describeTypeOf($attemptedData) + ) + ); + } +} diff --git a/src/Internal/TypeDescriber.php b/src/Internal/TypeDescriber.php new file mode 100644 index 0000000..8b5c8e3 --- /dev/null +++ b/src/Internal/TypeDescriber.php @@ -0,0 +1,115 @@ +. + */ + +declare(strict_types=1); + +namespace PackageFactory\Extractor\Internal; + +/** + * @internal + */ +final class TypeDescriber +{ + private const STRING_LENGTH_LIMIT = 10; + + public static function describeTypeOf(mixed $value): string + { + return match (true) { + is_bool($value) => self::describeBoolean($value), + is_int($value) => self::describeInteger($value), + is_float($value) => self::describeFloat($value), + is_string($value) => self::describeString($value), + is_array($value) => self::describeArray($value), + default => 'unknown(???)' + }; + } + + private static function describeBoolean(bool $value): string + { + return $value ? 'bool(true)' : 'bool(false)'; + } + + private static function describeInteger(int $value): string + { + return sprintf('int(%s)', $value); + } + + private static function describeFloat(float $value): string + { + return sprintf('float(%s)', $value); + } + + private static function describeString(string $value): string + { + return sprintf('string("%s")', self::truncateStringIfNecessary($value)); + } + + /** + * @param array $value + * @return string + */ + private static function describeArray(array $value): string + { + $count = count($value); + + if ($count === 0) { + return 'array(length=0)'; + } + + foreach ($value as $itemKey => $itemValue) { + $valueDescription = is_array($itemValue) + ? '[...]' + : self::describeTypeOf($itemValue); + + if (is_string($itemKey)) { + $keyDescription = self::truncateStringIfNecessary($itemKey); + + return $count === 1 + ? sprintf( + 'array(["%s" => %s])', + $keyDescription, + $valueDescription + ) + : sprintf( + 'array(["%s" => %s, ...], length=%s)', + $keyDescription, + $valueDescription, + $count + ); + } else { + return $count === 1 + ? sprintf('array([%s])', $valueDescription) + : sprintf('array([%s, ...], length=%s)', $valueDescription, $count); + } + } + } + + private static function truncateStringIfNecessary(string $string): string + { + $length = mb_strlen($string); + + if ($length > self::STRING_LENGTH_LIMIT) { + return mb_substr($string, 0, self::STRING_LENGTH_LIMIT) . '...'; + } else { + return $string; + } + } +} diff --git a/tests/Unit/ExtractorArrayAccessTest.php b/tests/Unit/ExtractorArrayAccessTest.php new file mode 100644 index 0000000..6537f65 --- /dev/null +++ b/tests/Unit/ExtractorArrayAccessTest.php @@ -0,0 +1,229 @@ +. + */ + +declare(strict_types=1); + +namespace PackageFactory\Extractor\Tests\Unit; + +use PackageFactory\Extractor\Extractor; +use PackageFactory\Extractor\ExtractorException; +use PHPUnit\Framework\TestCase; + +final class ExtractorArrayAccessTest extends TestCase +{ + /** + * @test + * @return void + */ + public function allowsForFirstLevelArrayAccess(): void + { + $extractor = Extractor::for([ + 'some' => [ + 'deep' => [ + 'path' => '1234' + ] + ] + ]); + + $this->assertInstanceOf(Extractor::class, $extractor['some']); + $this->assertEquals(['deep' => ['path' => '1234']], $extractor['some']->array()); + } + + /** + * @test + * @return void + */ + public function allowsForFirstLevelArrayAccessWithUnknownKeys(): void + { + $extractor = Extractor::for([ + 'some' => [ + 'deep' => [ + 'path' => '1234' + ] + ] + ]); + + $this->assertInstanceOf(Extractor::class, $extractor['foo']); + $this->assertNull($extractor['foo']->arrayOrNull()); + } + + /** + * @test + * @return void + */ + public function allowsForNthLevelArrayAccess(): void + { + $extractor = Extractor::for([ + 'some' => [ + 'deep' => [ + 'path' => '1234' + ] + ] + ]); + + $this->assertInstanceOf(Extractor::class, $extractor['some']['deep']); + $this->assertEquals(['path' => '1234'], $extractor['some']['deep']->array()); + + $this->assertInstanceOf(Extractor::class, $extractor['some']['deep']['path']); + $this->assertEquals('1234', $extractor['some']['deep']['path']->string()); + } + + /** + * @test + * @return void + */ + public function allowsForNthLevelArrayAccessWithUnknownKeys(): void + { + $extractor = Extractor::for([ + 'some' => [ + 'deep' => [ + 'path' => '1234' + ] + ] + ]); + + $this->assertInstanceOf(Extractor::class, $extractor['foo']['bar']); + $this->assertNull($extractor['foo']['bar']->stringOrNull()); + + $this->assertInstanceOf(Extractor::class, $extractor['foo']['bar']['baz']); + $this->assertNull($extractor['foo']['bar']['baz']->stringOrNull()); + + $this->assertInstanceOf(Extractor::class, $extractor['some']['foo']); + $this->assertNull($extractor['some']['foo']->stringOrNull()); + + $this->assertInstanceOf(Extractor::class, $extractor['some']['deep']['foo']); + $this->assertNull($extractor['some']['deep']['foo']->stringOrNull()); + } + + /** + * @test + * @return void + */ + public function valuesCannotBeSetWithArrayAccess(): void + { + $this->expectException(\LogicException::class); + + $extractor = Extractor::for([ + 'some' => [ + 'deep' => [ + 'path' => '1234' + ] + ] + ]); + + $extractor['some']['deep'] = 'something'; + } + + /** + * @test + * @return void + */ + public function valuesCannotBeUnsetWithArrayAccess(): void + { + $this->expectException(\LogicException::class); + + $extractor = Extractor::for([ + 'some' => [ + 'deep' => [ + 'path' => '1234' + ] + ] + ]); + + unset($extractor['some']['deep']); + } + + /** + * @return array + */ + public function nonArrayExamples(): array + { + return [ + 'bool' => [ + true, + 'Value was expected to be of type array, got bool(true) instead.' + ], + 'int' => [ + 42, + 'Value was expected to be of type array, got int(42) instead.' + ], + 'float' => [ + 47.11, + 'Value was expected to be of type array, got float(47.11) instead.' + ], + 'string' => [ + 'foo', + 'Value was expected to be of type array, got string("foo") instead.' + ], + ]; + } + + /** + * @dataProvider nonArrayExamples + * @test + * @param mixed $data + * @param string $expectedErrorMessage + * @return void + */ + public function throwsIfArrayAccessIsAttemptedOnNonArrayValue(mixed $data, string $expectedErrorMessage): void + { + $this->expectException(ExtractorException::class); + $this->expectExceptionMessage($expectedErrorMessage); + + $extractor = Extractor::for($data); + print_r($extractor['foo']); + } + + /** + * @test + * @return void + */ + public function keepsTrackOfPathWhenExtractorExceptionHappensAtADeeperLevel(): void + { + $this->expectException(ExtractorException::class); + + try { + $extractor = Extractor::for([]); + $extractor['foo']['bar']['baz']->array(); + } catch (ExtractorException $e) { + $this->assertEquals( + ['foo', 'bar', 'baz'], + $e->getPath() + ); + + throw $e; + } + } + + /** + * @test + * @return void + */ + public function exposesCurrentPathForOutsideConsumption(): void + { + $extractor = Extractor::for([]); + + $this->assertEquals([], $extractor->getPath()); + $this->assertEquals(['foo'], $extractor['foo']->getPath()); + $this->assertEquals(['foo', 'bar'], $extractor['foo']['bar']->getPath()); + $this->assertEquals(['foo', 'bar', 'baz'], $extractor['foo']['bar']['baz']->getPath()); + } +} diff --git a/tests/Unit/ExtractorForArrayTest.php b/tests/Unit/ExtractorForArrayTest.php new file mode 100644 index 0000000..34ae6d3 --- /dev/null +++ b/tests/Unit/ExtractorForArrayTest.php @@ -0,0 +1,173 @@ +. + */ + +declare(strict_types=1); + +namespace PackageFactory\Extractor\Tests\Unit; + +use PackageFactory\Extractor\Extractor; +use PackageFactory\Extractor\ExtractorException; +use PHPUnit\Framework\TestCase; + +final class ExtractorForArrayTest extends TestCase +{ + /** + * @test + * @return void + */ + public function canBeCreatedForArray(): void + { + $extractor = Extractor::for([]); + + $this->assertInstanceOf(Extractor::class, $extractor); + } + + /** + * @return array + */ + public function arrayExamples(): array + { + return [ + 'empty' => [[]], + 'one-dimensional, numeric index' => [[1, 2, 3]], + 'two-dimensional, numeric index' => [[[1, 2, 3]]], + 'deeply nested, numeric index' => [[[[[[1, 2, 3]]]]]], + 'one-dimensional, string index' => [['foo' => 1, 'bar' => 2]], + 'deeply nested, string index' => [[ + 'deeply' => [ + 'nested' => ['foo' => 1, 'bar' => 2] + ] + ]], + ]; + } + + /** + * @dataProvider arrayExamples + * @test + * @param array $example + * @return void + */ + public function canExtractArray(array $example): void + { + $this->assertEquals( + $example, + Extractor::for($example)->array() + ); + } + + /** + * @dataProvider arrayExamples + * @test + * @param array $example + * @return void + */ + public function canExtractarrayOrNull(array $example): void + { + $this->assertEquals( + $example, + Extractor::for($example)->arrayOrNull() + ); + } + + /** + * @return array + */ + public function requiredNonArrayExamples(): array + { + return [ + 'null' => [ + null, + 'Value is required, but was null.' + ], + 'bool' => [ + true, + 'Value was expected to be of type array, got bool(true) instead.' + ], + 'int' => [ + 42, + 'Value was expected to be of type array, got int(42) instead.' + ], + 'float' => [ + 47.11, + 'Value was expected to be of type array, got float(47.11) instead.' + ], + 'string' => [ + 'foo', + 'Value was expected to be of type array, got string("foo") instead.' + ], + ]; + } + + /** + * @dataProvider requiredNonArrayExamples + * @test + * @param mixed $data + * @param string $expectedErrorMessage + * @return void + */ + public function throwsIfArrayCannotBeExtracted(mixed $data, string $expectedErrorMessage): void + { + $this->expectException(ExtractorException::class); + $this->expectExceptionMessage($expectedErrorMessage); + + Extractor::for($data)->array(); + } + + /** + * @return array + */ + public function optionalNonArrayExamples(): array + { + return [ + 'bool' => [ + true, + 'Value was expected to be of type array or null, got bool(true) instead.' + ], + 'int' => [ + 42, + 'Value was expected to be of type array or null, got int(42) instead.' + ], + 'float' => [ + 47.11, + 'Value was expected to be of type array or null, got float(47.11) instead.' + ], + 'string' => [ + 'foo', + 'Value was expected to be of type array or null, got string("foo") instead.' + ], + ]; + } + + /** + * @dataProvider optionalNonArrayExamples + * @test + * @param mixed $data + * @param string $expectedErrorMessage + * @return void + */ + public function throwsIfArrayOrNullCannotBeExtracted(mixed $data, string $expectedErrorMessage): void + { + $this->expectException(ExtractorException::class); + $this->expectExceptionMessage($expectedErrorMessage); + + Extractor::for($data)->arrayOrNull(); + } +} diff --git a/tests/Unit/ExtractorForBooleanTest.php b/tests/Unit/ExtractorForBooleanTest.php new file mode 100644 index 0000000..5ec1d4e --- /dev/null +++ b/tests/Unit/ExtractorForBooleanTest.php @@ -0,0 +1,150 @@ +. + */ + +declare(strict_types=1); + +namespace PackageFactory\Extractor\Tests\Unit; + +use PackageFactory\Extractor\Extractor; +use PackageFactory\Extractor\ExtractorException; +use PHPUnit\Framework\TestCase; + +final class ExtractorForBooleanTest extends TestCase +{ + /** + * @test + * @return void + */ + public function canBeCreatedForBoolean(): void + { + $extractor = Extractor::for(true); + + $this->assertInstanceOf(Extractor::class, $extractor); + + $extractor = Extractor::for(false); + + $this->assertInstanceOf(Extractor::class, $extractor); + } + + /** + * @test + * @return void + */ + public function canExtractBoolean(): void + { + $this->assertTrue(Extractor::for(true)->bool()); + $this->assertFalse(Extractor::for(false)->bool()); + } + + /** + * @test + * @return void + */ + public function canExtractboolOrNullean(): void + { + $this->assertTrue(Extractor::for(true)->boolOrNull()); + $this->assertFalse(Extractor::for(false)->boolOrNull()); + } + + /** + * @return array + */ + public function requiredNonBooleanExamples(): array + { + return [ + 'null' => [ + null, + 'Value is required, but was null.' + ], + 'int' => [ + 42, + 'Value was expected to be of type bool, got int(42) instead.' + ], + 'float' => [ + 47.11, + 'Value was expected to be of type bool, got float(47.11) instead.' + ], + 'string' => [ + 'foobar', + 'Value was expected to be of type bool, got string("foobar") instead.' + ], + 'array' => [ + [], + 'Value was expected to be of type bool, got array(length=0) instead.' + ], + ]; + } + + /** + * @dataProvider requiredNonBooleanExamples + * @test + * @param mixed $data + * @param string $expectedErrorMessage + * @return void + */ + public function throwsIfBooleanCannotBeExtracted(mixed $data, string $expectedErrorMessage): void + { + $this->expectException(ExtractorException::class); + $this->expectExceptionMessage($expectedErrorMessage); + + Extractor::for($data)->bool(); + } + + /** + * @return array + */ + public function optionalNonBooleanExamples(): array + { + return [ + 'int' => [ + 42, + 'Value was expected to be of type bool or null, got int(42) instead.' + ], + 'float' => [ + 47.11, + 'Value was expected to be of type bool or null, got float(47.11) instead.' + ], + 'string' => [ + 'foobar', + 'Value was expected to be of type bool or null, got string("foobar") instead.' + ], + 'array' => [ + [], + 'Value was expected to be of type bool or null, got array(length=0) instead.' + ], + ]; + } + + /** + * @dataProvider optionalNonBooleanExamples + * @test + * @param mixed $data + * @param string $expectedErrorMessage + * @return void + */ + public function throwsIfBooleanOrNullCannotBeExtracted(mixed $data, string $expectedErrorMessage): void + { + $this->expectException(ExtractorException::class); + $this->expectExceptionMessage($expectedErrorMessage); + + Extractor::for($data)->boolOrNull(); + } +} diff --git a/tests/Unit/ExtractorForFloatTest.php b/tests/Unit/ExtractorForFloatTest.php new file mode 100644 index 0000000..467f443 --- /dev/null +++ b/tests/Unit/ExtractorForFloatTest.php @@ -0,0 +1,150 @@ +. + */ + +declare(strict_types=1); + +namespace PackageFactory\Extractor\Tests\Unit; + +use PackageFactory\Extractor\Extractor; +use PackageFactory\Extractor\ExtractorException; +use PHPUnit\Framework\TestCase; + +final class ExtractorForFloatTest extends TestCase +{ + /** + * @test + * @return void + */ + public function canBeCreatedForFloat(): void + { + $extractor = Extractor::for(47.11); + + $this->assertInstanceOf(Extractor::class, $extractor); + } + + /** + * @test + * @return void + */ + public function canExtractFloat(): void + { + $this->assertEquals( + 47.11, + Extractor::for(47.11)->float() + ); + } + + /** + * @test + * @return void + */ + public function canExtractfloatOrNull(): void + { + $this->assertEquals( + 47.11, + Extractor::for(47.11)->floatOrNull() + ); + } + + /** + * @return array + */ + public function requiredNonFloatExamples(): array + { + return [ + 'null' => [ + null, + 'Value is required, but was null.' + ], + 'bool' => [ + true, + 'Value was expected to be of type float, got bool(true) instead.' + ], + 'int' => [ + 42, + 'Value was expected to be of type float, got int(42) instead.' + ], + 'string' => [ + 'foobar', + 'Value was expected to be of type float, got string("foobar") instead.' + ], + 'array' => [ + [], + 'Value was expected to be of type float, got array(length=0) instead.' + ], + ]; + } + + /** + * @dataProvider requiredNonFloatExamples + * @test + * @param mixed $data + * @param string $expectedErrorMessage + * @return void + */ + public function throwsIfFloatCannotBeExtracted(mixed $data, string $expectedErrorMessage): void + { + $this->expectException(ExtractorException::class); + $this->expectExceptionMessage($expectedErrorMessage); + + Extractor::for($data)->float(); + } + + /** + * @return array + */ + public function optionalNonFloatExamples(): array + { + return [ + 'bool' => [ + true, + 'Value was expected to be of type float or null, got bool(true) instead.' + ], + 'int' => [ + 42, + 'Value was expected to be of type float or null, got int(42) instead.' + ], + 'string' => [ + 'foobar', + 'Value was expected to be of type float or null, got string("foobar") instead.' + ], + 'array' => [ + [], + 'Value was expected to be of type float or null, got array(length=0) instead.' + ], + ]; + } + + /** + * @dataProvider optionalNonFloatExamples + * @test + * @param mixed $data + * @param string $expectedErrorMessage + * @return void + */ + public function throwsIfFloatOrNullCannotBeExtracted(mixed $data, string $expectedErrorMessage): void + { + $this->expectException(ExtractorException::class); + $this->expectExceptionMessage($expectedErrorMessage); + + Extractor::for($data)->floatOrNull(); + } +} diff --git a/tests/Unit/ExtractorForIntegerOrFloatTest.php b/tests/Unit/ExtractorForIntegerOrFloatTest.php new file mode 100644 index 0000000..ef58f0b --- /dev/null +++ b/tests/Unit/ExtractorForIntegerOrFloatTest.php @@ -0,0 +1,139 @@ +. + */ + +declare(strict_types=1); + +namespace PackageFactory\Extractor\Tests\Unit; + +use PackageFactory\Extractor\Extractor; +use PackageFactory\Extractor\ExtractorException; +use PHPUnit\Framework\TestCase; + +final class ExtractorForIntegerOrFloatTest extends TestCase +{ + /** + * @test + * @return void + */ + public function canExtractIntegerOrFloat(): void + { + $this->assertEquals( + 42, + Extractor::for(42)->intOrFloat() + ); + $this->assertEquals( + 47.11, + Extractor::for(47.11)->intOrFloat() + ); + } + + /** + * @test + * @return void + */ + public function canExtractintOrNullegerOrFloat(): void + { + $this->assertEquals( + 42, + Extractor::for(42)->intOrfloatOrNull() + ); + $this->assertEquals( + 47.11, + Extractor::for(47.11)->intOrfloatOrNull() + ); + } + + /** + * @return array + */ + public function requiredNonIntegerOrFloatExamples(): array + { + return [ + 'null' => [ + null, + 'Value is required, but was null.' + ], + 'bool' => [ + true, + 'Value was expected to be of type int or float, got bool(true) instead.' + ], + 'string' => [ + 'foobar', + 'Value was expected to be of type int or float, got string("foobar") instead.' + ], + 'array' => [ + [], + 'Value was expected to be of type int or float, got array(length=0) instead.' + ], + ]; + } + + /** + * @dataProvider requiredNonIntegerOrFloatExamples + * @test + * @param mixed $data + * @param string $expectedErrorMessage + * @return void + */ + public function throwsIfIntegerOrFloatCannotBeExtracted(mixed $data, string $expectedErrorMessage): void + { + $this->expectException(ExtractorException::class); + $this->expectExceptionMessage($expectedErrorMessage); + + Extractor::for($data)->intOrFloat(); + } + + /** + * @return array + */ + public function optionalNonIntOrFloatExamples(): array + { + return [ + 'bool' => [ + true, + 'Value was expected to be of type int or float or null, got bool(true) instead.' + ], + 'string' => [ + 'foobar', + 'Value was expected to be of type int or float or null, got string("foobar") instead.' + ], + 'array' => [ + [], + 'Value was expected to be of type int or float or null, got array(length=0) instead.' + ], + ]; + } + + /** + * @dataProvider optionalNonIntOrFloatExamples + * @test + * @param mixed $data + * @param string $expectedErrorMessage + * @return void + */ + public function throwsIfIntegerOrNullOrFloatCannotBeExtracted(mixed $data, string $expectedErrorMessage): void + { + $this->expectException(ExtractorException::class); + $this->expectExceptionMessage($expectedErrorMessage); + + Extractor::for($data)->intOrfloatOrNull(); + } +} diff --git a/tests/Unit/ExtractorForIntegerTest.php b/tests/Unit/ExtractorForIntegerTest.php new file mode 100644 index 0000000..10d26f4 --- /dev/null +++ b/tests/Unit/ExtractorForIntegerTest.php @@ -0,0 +1,150 @@ +. + */ + +declare(strict_types=1); + +namespace PackageFactory\Extractor\Tests\Unit; + +use PackageFactory\Extractor\Extractor; +use PackageFactory\Extractor\ExtractorException; +use PHPUnit\Framework\TestCase; + +final class ExtractorForIntegerTest extends TestCase +{ + /** + * @test + * @return void + */ + public function canBeCreatedForInteger(): void + { + $extractor = Extractor::for(42); + + $this->assertInstanceOf(Extractor::class, $extractor); + } + + /** + * @test + * @return void + */ + public function canExtractInteger(): void + { + $this->assertEquals( + 42, + Extractor::for(42)->int() + ); + } + + /** + * @test + * @return void + */ + public function canExtractintOrNulleger(): void + { + $this->assertEquals( + 42, + Extractor::for(42)->intOrNull() + ); + } + + /** + * @return array + */ + public function requiredNonIntegerExamples(): array + { + return [ + 'null' => [ + null, + 'Value is required, but was null.' + ], + 'bool' => [ + true, + 'Value was expected to be of type int, got bool(true) instead.' + ], + 'float' => [ + 47.11, + 'Value was expected to be of type int, got float(47.11) instead.' + ], + 'string' => [ + 'foobar', + 'Value was expected to be of type int, got string("foobar") instead.' + ], + 'array' => [ + [], + 'Value was expected to be of type int, got array(length=0) instead.' + ], + ]; + } + + /** + * @dataProvider requiredNonIntegerExamples + * @test + * @param mixed $data + * @param string $expectedErrorMessage + * @return void + */ + public function throwsIfIntegerCannotBeExtracted(mixed $data, string $expectedErrorMessage): void + { + $this->expectException(ExtractorException::class); + $this->expectExceptionMessage($expectedErrorMessage); + + Extractor::for($data)->int(); + } + + /** + * @return array + */ + public function optionalNonIntegerExamples(): array + { + return [ + 'bool' => [ + true, + 'Value was expected to be of type int or null, got bool(true) instead.' + ], + 'float' => [ + 47.11, + 'Value was expected to be of type int or null, got float(47.11) instead.' + ], + 'string' => [ + 'foobar', + 'Value was expected to be of type int or null, got string("foobar") instead.' + ], + 'array' => [ + [], + 'Value was expected to be of type int or null, got array(length=0) instead.' + ], + ]; + } + + /** + * @dataProvider optionalNonIntegerExamples + * @test + * @param mixed $data + * @param string $expectedErrorMessage + * @return void + */ + public function throwsIfIntegerOrNullCannotBeExtracted(mixed $data, string $expectedErrorMessage): void + { + $this->expectException(ExtractorException::class); + $this->expectExceptionMessage($expectedErrorMessage); + + Extractor::for($data)->intOrNull(); + } +} diff --git a/tests/Unit/ExtractorForNullTest.php b/tests/Unit/ExtractorForNullTest.php new file mode 100644 index 0000000..03fa4e0 --- /dev/null +++ b/tests/Unit/ExtractorForNullTest.php @@ -0,0 +1,107 @@ +. + */ + +declare(strict_types=1); + +namespace PackageFactory\Extractor\Tests\Unit; + +use PackageFactory\Extractor\Extractor; +use PHPUnit\Framework\TestCase; + +final class ExtractorForNullTest extends TestCase +{ + /** + * @test + * @return void + */ + public function canBeCreatedForNull(): void + { + $extractor = Extractor::for(null); + + $this->assertInstanceOf(Extractor::class, $extractor); + } + + /** + * @test + * @return void + */ + public function canExtractBooleanOrNull(): void + { + $this->assertNull( + Extractor::for(null)->boolOrNull() + ); + } + + /** + * @test + * @return void + */ + public function canExtractIntegerOrNull(): void + { + $this->assertNull( + Extractor::for(null)->intOrNull() + ); + } + + /** + * @test + * @return void + */ + public function canExtractFloatOrNull(): void + { + $this->assertNull( + Extractor::for(null)->floatOrNull() + ); + } + + /** + * @test + * @return void + */ + public function canExtractIntegerOrFloatOrNull(): void + { + $this->assertNull( + Extractor::for(null)->intOrfloatOrNull() + ); + } + + /** + * @test + * @return void + */ + public function canExtractStringOrNull(): void + { + $this->assertNull( + Extractor::for(null)->stringOrNull() + ); + } + + /** + * @test + * @return void + */ + public function canExtractArrayOrNull(): void + { + $this->assertNull( + Extractor::for(null)->arrayOrNull() + ); + } +} diff --git a/tests/Unit/ExtractorForStringTest.php b/tests/Unit/ExtractorForStringTest.php new file mode 100644 index 0000000..1160587 --- /dev/null +++ b/tests/Unit/ExtractorForStringTest.php @@ -0,0 +1,150 @@ +. + */ + +declare(strict_types=1); + +namespace PackageFactory\Extractor\Tests\Unit; + +use PackageFactory\Extractor\Extractor; +use PackageFactory\Extractor\ExtractorException; +use PHPUnit\Framework\TestCase; + +final class ExtractorForStringTest extends TestCase +{ + /** + * @test + * @return void + */ + public function canBeCreatedForString(): void + { + $extractor = Extractor::for(47.11); + + $this->assertInstanceOf(Extractor::class, $extractor); + } + + /** + * @test + * @return void + */ + public function canExtractString(): void + { + $this->assertEquals( + 'Hello World!', + Extractor::for('Hello World!')->string() + ); + } + + /** + * @test + * @return void + */ + public function canExtractstringOrNull(): void + { + $this->assertEquals( + 'Hello World!', + Extractor::for('Hello World!')->stringOrNull() + ); + } + + /** + * @return array + */ + public function requiredNonStringExamples(): array + { + return [ + 'null' => [ + null, + 'Value is required, but was null.' + ], + 'bool' => [ + true, + 'Value was expected to be of type string, got bool(true) instead.' + ], + 'int' => [ + 42, + 'Value was expected to be of type string, got int(42) instead.' + ], + 'float' => [ + 47.11, + 'Value was expected to be of type string, got float(47.11) instead.' + ], + 'array' => [ + [], + 'Value was expected to be of type string, got array(length=0) instead.' + ], + ]; + } + + /** + * @dataProvider requiredNonStringExamples + * @test + * @param mixed $data + * @param string $expectedErrorMessage + * @return void + */ + public function throwsIfStringCannotBeExtracted(mixed $data, string $expectedErrorMessage): void + { + $this->expectException(ExtractorException::class); + $this->expectExceptionMessage($expectedErrorMessage); + + Extractor::for($data)->string(); + } + + /** + * @return array + */ + public function optionalNonStringExamples(): array + { + return [ + 'bool' => [ + true, + 'Value was expected to be of type string or null, got bool(true) instead.' + ], + 'int' => [ + 42, + 'Value was expected to be of type string or null, got int(42) instead.' + ], + 'float' => [ + 47.11, + 'Value was expected to be of type string or null, got float(47.11) instead.' + ], + 'array' => [ + [], + 'Value was expected to be of type string or null, got array(length=0) instead.' + ], + ]; + } + + /** + * @dataProvider optionalNonStringExamples + * @test + * @param mixed $data + * @param string $expectedErrorMessage + * @return void + */ + public function throwsIfStringOrNullCannotBeExtracted(mixed $data, string $expectedErrorMessage): void + { + $this->expectException(ExtractorException::class); + $this->expectExceptionMessage($expectedErrorMessage); + + Extractor::for($data)->stringOrNull(); + } +} diff --git a/tests/Unit/ExtractorIterableTest.php b/tests/Unit/ExtractorIterableTest.php new file mode 100644 index 0000000..d6fff3b --- /dev/null +++ b/tests/Unit/ExtractorIterableTest.php @@ -0,0 +1,144 @@ +. + */ + +declare(strict_types=1); + +namespace PackageFactory\Extractor\Tests\Unit; + +use PackageFactory\Extractor\Extractor; +use PackageFactory\Extractor\ExtractorException; +use PHPUnit\Framework\TestCase; + +final class ExtractorIterableTest extends TestCase +{ + /** + * @test + * @return void + */ + public function allowsIterationIfDataIsArrayAndProvidesExtractorsForKeysAndValues(): void + { + $extractor = Extractor::for([ + 'a' => 1, + 'b' => 2, + 'c' => 3, + ]); + + $count = 0; + foreach ($extractor as $key => $value) { + $count++; + $this->assertIsString($key->string()); + $this->assertIsInt($value->int()); + } + + $this->assertEquals(3, $count); + } + + /** + * @test + * @return void + */ + public function allowsIterationIfDataIsNull(): void + { + $this->expectNotToPerformAssertions(); + + $extractor = Extractor::for(null); + + foreach ($extractor as $key => $value) { + $key->string(); + $value->int(); + } + } + + /** + * @test + * @return void + */ + public function throwsIfKeyIsIntegerButWasAttemptedToBeExtractedAsString(): void + { + $this->expectException(ExtractorException::class); + $this->expectExceptionMessage('Key was expected to be of type string, got int(0) instead.'); + + $extractor = Extractor::for(['foo', 'bar']); + + foreach ($extractor as $key => $value) { + $key->string(); + } + } + + /** + * @test + * @return void + */ + public function throwsIfKeyIsStringButWasAttemptedToBeExtractedAsInteger(): void + { + $this->expectException(ExtractorException::class); + $this->expectExceptionMessage('Key was expected to be of type int, got string("foo") instead.'); + + $extractor = Extractor::for(['foo' => 'bar']); + + foreach ($extractor as $key => $value) { + $key->int(); + } + } + + /** + * @return array + */ + public function nonArrayExamples(): array + { + return [ + 'bool' => [ + true, + 'Value was expected to be of type array, got bool(true) instead.' + ], + 'int' => [ + 42, + 'Value was expected to be of type array, got int(42) instead.' + ], + 'float' => [ + 47.11, + 'Value was expected to be of type array, got float(47.11) instead.' + ], + 'string' => [ + 'foo', + 'Value was expected to be of type array, got string("foo") instead.' + ], + ]; + } + + /** + * @param mixed $data + * @param string $expectedErrorMessage + * @return void + */ + public function throwsIfIterationIsAttemptedOnNonArrayData(mixed $data, string $expectedErrorMessage): void + { + $this->expectException(ExtractorException::class); + $this->expectExceptionMessage($expectedErrorMessage); + + $extractor = Extractor::for($data); + + foreach ($extractor as $key => $value) { + $key->string(); + $value->string(); + } + } +} diff --git a/tests/Unit/Internal/TypeDescriberTest.php b/tests/Unit/Internal/TypeDescriberTest.php new file mode 100644 index 0000000..714e8a0 --- /dev/null +++ b/tests/Unit/Internal/TypeDescriberTest.php @@ -0,0 +1,136 @@ +. + */ + +declare(strict_types=1); + +namespace PackageFactory\Extractor\Tests\Unit\Internal; + +use PackageFactory\Extractor\Internal\TypeDescriber; +use PHPUnit\Framework\TestCase; + +final class TypeDescriberTest extends TestCase +{ + /** + * @test + * @return void + */ + public function describesBooleans(): void + { + $this->assertEquals( + 'bool(true)', + TypeDescriber::describeTypeOf(true) + ); + $this->assertEquals( + 'bool(false)', + TypeDescriber::describeTypeOf(false) + ); + } + + /** + * @test + * @return void + */ + public function describesIntegers(): void + { + $this->assertEquals( + 'int(42)', + TypeDescriber::describeTypeOf(42) + ); + } + + /** + * @test + * @return void + */ + public function describesFloats(): void + { + $this->assertEquals( + 'float(47.11)', + TypeDescriber::describeTypeOf(47.11) + ); + } + + /** + * @test + * @return void + */ + public function describesStrings(): void + { + $this->assertEquals( + 'string("foo")', + TypeDescriber::describeTypeOf('foo') + ); + $this->assertEquals( + 'string("waytoolong...")', + TypeDescriber::describeTypeOf('waytoolongstring') + ); + } + + /** + * @test + * @return void + */ + public function describesArrays(): void + { + $this->assertEquals( + 'array(length=0)', + TypeDescriber::describeTypeOf([]) + ); + $this->assertEquals( + 'array([int(1)])', + TypeDescriber::describeTypeOf([1]) + ); + $this->assertEquals( + 'array([string("two")])', + TypeDescriber::describeTypeOf(['two']) + ); + $this->assertEquals( + 'array([int(1), ...], length=4)', + TypeDescriber::describeTypeOf([1, 'two', null, true]) + ); + $this->assertEquals( + 'array(["foo" => int(1234)])', + TypeDescriber::describeTypeOf(['foo' => 1234]) + ); + $this->assertEquals( + 'array(["foo" => int(1234), ...], length=2)', + TypeDescriber::describeTypeOf(['foo' => 1234, 'bar' => 5678]) + ); + $this->assertEquals( + 'array(["waytoolong..." => int(1234)])', + TypeDescriber::describeTypeOf(['waytoolongkey' => 1234]) + ); + $this->assertEquals( + 'array(["deeply" => [...]])', + TypeDescriber::describeTypeOf([ + 'deeply' => [ + 'nested' => [ + 'foo' => 1234 + ] + ] + ]) + ); + $this->assertEquals( + 'array([[...]])', + TypeDescriber::describeTypeOf([[1, 2, 3, 4]]) + ); + } +}