From 26d5257a0d9fdbc5145271e74109d8bd0b704da8 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Sun, 2 Jun 2024 18:19:40 +0200 Subject: [PATCH] Update to v2 (#1) * Update min PHP version to 8 * Add more linters * Update type-hinting system to PHPDoc/PHPStan * Rework casting logic + a few bug fixes --- .gitattributes | 10 ++- .github/workflows/ci.yml | 40 +++++----- .gitignore | 3 + .php-cs-fixer.dist.php | 14 ++++ README.md | 15 ++-- composer.json | 31 +++++++- phpstan.dist.neon | 14 ++++ phpunit.dist.xml | 13 ++++ phpunit.xml.dist | 22 ------ src/.phpstorm.meta.php | 6 -- src/Castable.php | 8 +- src/NotCastableException.php | 5 +- src/functions.php | 49 ++++++++----- tests/BaseTestCase.php | 13 ---- tests/CastTest.php | 130 ++++++++++++++++++--------------- tests/ExampleCastableClass.php | 9 +-- 16 files changed, 216 insertions(+), 166 deletions(-) create mode 100644 .php-cs-fixer.dist.php create mode 100644 phpstan.dist.neon create mode 100644 phpunit.dist.xml delete mode 100644 phpunit.xml.dist delete mode 100644 src/.phpstorm.meta.php delete mode 100644 tests/BaseTestCase.php diff --git a/.gitattributes b/.gitattributes index 9be7991..b153f3b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,7 @@ -/tests export-ignore -.github export-ignore -phpunit.xml.dist export-ignore +.github/ export-ignore +tests/ export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.php-cs-fixer.dist.php export-ignore +phpstan.dist.neon export-ignore +phpunit.dist.xml export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5aacd86..2a2b350 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,35 +8,31 @@ on: jobs: - build: - name: Test + Lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + - run: composer update --ansi --no-progress --prefer-dist --no-interaction + - run: composer run lint + + Test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - php: [ '5.6', '7.0', '7.4', '8.0' ] - + php: [ '8.0', '8.1', '8.2', '8.3' ] steps: - - name: Set up PHP - uses: shivammathur/setup-php@v2 + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} coverage: xdebug - - - name: Checkout code - uses: actions/checkout@v2 - with: - fetch-depth: 2 - - - name: Download dependencies - uses: ramsey/composer-install@v1 - with: - composer-options: --no-interaction --prefer-dist --optimize-autoloader - - - name: Run tests - run: ./vendor/bin/phpunit --coverage-clover coverage.xml - - - name: Upload to Codecov - env: + - run: composer update --ansi --no-progress --prefer-dist --no-interaction + - run: composer run test:cover + - env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} run: bash <(curl -s https://codecov.io/bash) diff --git a/.gitignore b/.gitignore index b3f378b..cc7ac72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ /vendor .phpunit.result.cache +.php-cs-fixer.cache +phpstan.neon phpunit.xml .idea composer.lock +coverage.xml diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..bd5d04e --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,14 @@ +setRiskyAllowed(true) + ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) + ->setRules([ + '@PER-CS2.0' => true, + '@PER-CS2.0:risky' => true, + 'trailing_comma_in_multiline' => false, + ]) + ->setFinder( + (new PhpCsFixer\Finder()) + ->in(__DIR__) + ); diff --git a/README.md b/README.md index fa57256..bf4a3a4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/uuf6429/php-castable/actions/workflows/ci.yml/badge.svg)](https://github.com/uuf6429/php-castable/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/uuf6429/php-castable/branch/main/graph/badge.svg)](https://codecov.io/gh/uuf6429/php-castable) -[![Minimum PHP Version](https://img.shields.io/badge/php-%5E5.6%20%7C%20%5E7%20%7C%20%5E8-8892BF.svg)](https://php.net/) +[![Minimum PHP Version](https://img.shields.io/badge/php-%5E8-8892BF.svg)](https://php.net/) [![License](https://poser.pugx.org/uuf6429/php-castable/license)](https://packagist.org/packages/uuf6429/php-castable) [![Latest Stable Version](https://poser.pugx.org/uuf6429/php-castable/version)](https://packagist.org/packages/uuf6429/php-castable) [![Latest Unstable Version](https://poser.pugx.org/uuf6429/php-castable/v/unstable)](https://packagist.org/packages/uuf6429/php-castable) @@ -21,9 +21,8 @@ composer require uuf6429/php-castable "^1.0" - Works with simple types and objects - `cast($value, $type)` function that converts a value to a target type. - `Castable` interface, exposes method that is called whenever `cast()` is called on objects implementing this interface. -- Error handling - all errors routed to `NotCastableException` -- Fixes type-hinting for PhpStorm -- PHP 5.6+ (but seriously, stop using PHP 5 :)) +- Error handling - all errors routed to `NotCastableException`. +- Fixes type-hinting for IDEs understanding PHPDoc Generics. While `cast()` is just a regular PHP function, it would be the equivalent to type-casting operators in other languages (e.g. `val as Type`, `(Type)val`, `val.to(Type)`, `CAST(val, TYPE)`...). @@ -51,9 +50,9 @@ $cat = \uuf6429\Castable\cast($dog, Cat::class); // not allowed ## 🔍 Casting Behaviour The casting process follows these steps: -1. If the value to be type-casted is not an object, PHP's `settype()` is used. -2. If, instead, it is an object that implements `Castable` interface, `castTo()` is called and its value returned. -3. Otherwise, if the object is the same or a subclass of the desired type, then it is returned unchanged. +1. If the value is an object or value of the desired type, then it is returned unchanged. +2. If the value is an *object* that *implements `Castable` interface*, `castTo()` is called and its value returned. +3. Otherwise, PHP's `settype()` is attempted. At any point in time, errors or unsupported type-casting could occur, in which case a `NotCastableException` is thrown. @@ -61,4 +60,4 @@ At any point in time, errors or unsupported type-casting could occur, in which c In many cases, having specific `castToX()` methods in your classes is enough, and it typically works adequately. -However, this could get very repetitive and somewhat error-prone, until a more dynamic solution is needed. This package helps to safely avoid all that boilerplate code. \ No newline at end of file +However, this could get very repetitive and somewhat error-prone, until a more dynamic solution is needed. This package helps to safely avoid all that boilerplate code. diff --git a/composer.json b/composer.json index 20dabed..697729a 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,16 @@ "email": "christian@sciberras.me" } ], + "require": { + "php": "^8" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.7", + "friendsofphp/php-cs-fixer": "^3.3", + "phpstan/phpstan": "^1.4", + "phpunit/phpunit": "^9 || ^10 || ^11", + "roave/security-advisories": "dev-latest" + }, "autoload": { "psr-4": { "uuf6429\\Castable\\": "src/" @@ -22,10 +32,23 @@ "uuf6429\\Castable\\": "tests/" } }, - "require": { - "php": "^5.6 || ^7 || ^8" + "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true + }, + "process-timeout": 0 }, - "require-dev": { - "phpunit/phpunit": "^5 | ^6 | ^7 | ^8 | ^9" + "scripts": { + "lint": [ + "composer normalize --dry-run", + "composer exec phpstan -- analyse --no-progress", + "composer exec php-cs-fixer -- fix --dry-run --show-progress=none --diff" + ], + "lint:fix": [ + "composer normalize", + "composer exec php-cs-fixer -- fix --diff" + ], + "test": "phpunit ./tests/", + "test:cover": "@php -dzend_extension=php_xdebug -dxdebug.mode=coverage vendor/bin/phpunit --coverage-clover coverage.xml ./tests/" } } diff --git a/phpstan.dist.neon b/phpstan.dist.neon new file mode 100644 index 0000000..0ab4d9a --- /dev/null +++ b/phpstan.dist.neon @@ -0,0 +1,14 @@ +parameters: + level: 9 + paths: + - src + - tests + ignoreErrors: + - + message: "#^Function uuf6429\\\\Castable\\\\cast\\(\\) should return T but returns object\\.$#" + count: 1 + path: src/functions.php + - + message: "#^Unable to resolve the template type T in call to method uuf6429\\\\Castable\\\\Castable\\:\\:castTo\\(\\)$#" + count: 1 + path: src/functions.php diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..b220152 --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,13 @@ + + + + + ./tests/ + + + + + ./src/ + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist deleted file mode 100644 index 8c43c07..0000000 --- a/phpunit.xml.dist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - ./tests/ - - - - - - src - - - src/.phpstorm.meta.php - - - diff --git a/src/.phpstorm.meta.php b/src/.phpstorm.meta.php deleted file mode 100644 index 43bb50e..0000000 --- a/src/.phpstorm.meta.php +++ /dev/null @@ -1,6 +0,0 @@ - '@'])); - override(\uuf6429\Castable\Castable::castTo(0), map(['' => '@'])); -} diff --git a/src/Castable.php b/src/Castable.php index 1d474b8..075df34 100644 --- a/src/Castable.php +++ b/src/Castable.php @@ -5,8 +5,10 @@ interface Castable { /** - * @param string $type - * @return mixed + * @template T + * @param class-string $type + * @return T + * @throws NotCastableException */ - public function castTo($type); + public function castTo(string $type); } diff --git a/src/NotCastableException.php b/src/NotCastableException.php index 872c378..9831135 100644 --- a/src/NotCastableException.php +++ b/src/NotCastableException.php @@ -4,7 +4,4 @@ use RuntimeException; -class NotCastableException extends RuntimeException -{ - -} +class NotCastableException extends RuntimeException {} diff --git a/src/functions.php b/src/functions.php index 19d08cd..f6729bc 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,36 +2,47 @@ namespace uuf6429\Castable; -use Exception; use Throwable; -function cast($value, $type) +/** + * @template T + * @param mixed $value + * @param class-string $type + * @return T + * @throws NotCastableException + */ +function cast(mixed $value, string $type) { + static $basicTypes = ['bool', 'string', 'int', 'float', 'array', 'object', 'null']; + static $typeAliases = ['boolean' => 'bool', 'integer' => 'int', 'double' => 'float']; + $type = $typeAliases[$type] ?? $type; + try { - if (!is_object($value)) { - if (@settype($value, $type)) { - return $value; - } - throw new NotCastableException( - sprintf('Value of type %s cannot be cast to %s', gettype($value), $type) - ); + if (is_object($value) && is_a($value, $type)) { + return $value; + } + + if (gettype($value) === $type) { + return $value; } if ($value instanceof Castable) { return $value->castTo($type); } - if ($type !== 'object' && !is_a($value, $type)) { - throw new NotCastableException( - sprintf('Object of class %s is not compatible with class %s', get_class($value), $type) - ); + if (in_array($type, $basicTypes, true)) { + @settype($value, $type); + return $value; } - return $value; - } catch (Exception $exception) { - } catch (Throwable $exception) { + throw new NotCastableException( + sprintf('Cannot cast %s to %s', get_debug_type($value), $type) + ); + } catch (Throwable $ex) { + throw new NotCastableException( + sprintf('Cannot cast %s to %s: %s', get_debug_type($value), $type, $ex->getMessage()), + 0, + $ex + ); } - throw new NotCastableException( - sprintf('Castable object could not be cast to %s', $type), 0, $exception - ); } diff --git a/tests/BaseTestCase.php b/tests/BaseTestCase.php deleted file mode 100644 index 37ad3b0..0000000 --- a/tests/BaseTestCase.php +++ /dev/null @@ -1,13 +0,0 @@ -expectException(NotCastableException::class); cast($originalValue, $targetType); } - public function invalidCastingDataProvider() + /** + * @return iterable + */ + public static function invalidCastingDataProvider(): iterable { - return [ - 'invalid data type of target type' => [ - '$originalValue' => 1, - '$targetType' => 1, - ], - 'invalid target type' => [ - '$originalValue' => 1, - '$targetType' => 'invalid', - ], - 'invalid conversion; object to integer' => [ - '$originalValue' => (object)[], - '$targetType' => 'integer', - ], - 'invalid conversion; object to specific class' => [ - '$originalValue' => (object)[], - '$targetType' => ArrayObject::class, - ], - 'converting example object unsupported type' => [ - '$originalValue' => new ExampleCastableClass(), - '$targetType' => 'someType', - ], - 'as per php, aliases should not work' => [ - '$originalValue' => new ExampleCastableClass(), - '$targetType' => 'integer', - ], + yield 'invalid target type' => [ + 'originalValue' => 1, + 'targetType' => 'invalid', + ]; + + yield 'invalid conversion; object to string' => [ + 'originalValue' => (object) [], + 'targetType' => 'string', + ]; + + yield 'invalid conversion; object to specific class' => [ + 'originalValue' => (object) [], + 'targetType' => ArrayObject::class, + ]; + + yield 'converting example object unsupported type' => [ + 'originalValue' => new ExampleCastableClass(), + 'targetType' => 'someType', ]; } /** * @dataProvider validCastingDataProvider + * @param mixed $originalValue + * @param class-string $targetType + * @param mixed $expectedValue */ - public function test_that_valid_casting_returns_expected_value($originalValue, $targetType, $expectedValue) - { + public function test_that_valid_casting_returns_expected_value( + mixed $originalValue, + string $targetType, + mixed $expectedValue + ): void { $this->assertSame($expectedValue, cast($originalValue, $targetType)); } - public function validCastingDataProvider() + /** + * @return iterable + */ + public static function validCastingDataProvider(): iterable { - return [ - 'converting number to string' => [ - '$originalValue' => 123, - '$targetType' => 'string', - '$expectedValue' => '123', - ], - 'converting number to boolean' => [ - '$originalValue' => 1, - '$targetType' => 'bool', - '$expectedValue' => true, - ], - 'converting float to integer (lossy)' => [ - '$originalValue' => 123.456, - '$targetType' => 'int', - '$expectedValue' => 123, - ], - 'converting specific object to generic object' => [ - '$originalValue' => ($inst = new ArrayObject()), - '$targetType' => 'object', - '$expectedValue' => $inst, - ], - 'converting example object to number should work' => [ - '$originalValue' => new ExampleCastableClass(), - '$targetType' => 'int', - '$expectedValue' => 123, - ], + yield 'converting number to string' => [ + 'originalValue' => 123, + 'targetType' => 'string', + 'expectedValue' => '123', + ]; + + yield 'converting number to boolean' => [ + 'originalValue' => 1, + 'targetType' => 'bool', + 'expectedValue' => true, + ]; + + yield 'converting float to integer (lossy)' => [ + 'originalValue' => 123.456, + 'targetType' => 'int', + 'expectedValue' => 123, + ]; + + yield 'converting specific object to generic object' => [ + 'originalValue' => ($inst = new ArrayObject()), + 'targetType' => 'object', + 'expectedValue' => $inst, + ]; + + yield 'converting example object to number should work' => [ + 'originalValue' => new ExampleCastableClass(), + 'targetType' => 'int', + 'expectedValue' => 123, + ]; + + yield 'example object to example object should be unchanged' => [ + 'originalValue' => $orig = new ExampleCastableClass(), + 'targetType' => ExampleCastableClass::class, + 'expectedValue' => $orig, ]; } } diff --git a/tests/ExampleCastableClass.php b/tests/ExampleCastableClass.php index 41b8439..f77a7e6 100644 --- a/tests/ExampleCastableClass.php +++ b/tests/ExampleCastableClass.php @@ -2,12 +2,11 @@ namespace uuf6429\Castable; -use InvalidArgumentException; - class ExampleCastableClass implements Castable { - public function castTo($type) + public function castTo(string $type): int|string { + /** @noinspection PhpSwitchCanBeReplacedWithMatchExpressionInspection */ switch ($type) { case 'int': return 123; @@ -16,7 +15,7 @@ public function castTo($type) return 'example'; default: - throw new InvalidArgumentException("Unsupported cast type: $type"); + throw new NotCastableException("Unsupported cast type: $type"); } } -} \ No newline at end of file +}