diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index a4694fb..0b6fb39 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -1,68 +1,68 @@ -name: Validation Workflow - -on: - pull_request: - types: - - opened - - synchronize - - reopened - - ready_for_review - paths: - - '**/*.php' - -jobs: - lint-and-test: - strategy: - matrix: - operating-system: ['ubuntu-latest'] - php-versions: ['8.0'] - runs-on: ${{ matrix.operating-system }} - if: github.event.pull_request.draft == false - steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.0 - with: - ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Checkout - uses: actions/checkout@v2 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-versions }} - tools: composer:v2 - coverage: none - env: - COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Display PHP information - run: | - php -v - php -m - composer --version - - name: Get composer cache directory - id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Cache dependencies - uses: actions/cache@v2 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ runner.os }}-composer- - - - name: Run composer validate - run: composer validate - - - name: Install dependencies - run: composer install --no-interaction --no-suggest --no-scripts --prefer-dist --ansi - - - name: Run phpcstd - run: vendor/bin/phpcstd --ci --ansi - - - name: Setup problem matchers for PHPUnit - run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - - - name: Run Unit tests - run: composer test --ansi +name: Validation Workflow + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + paths: + - '**/*.php' + +jobs: + lint-and-test: + strategy: + matrix: + operating-system: ['ubuntu-latest'] + php-versions: ['8.0'] + runs-on: ${{ matrix.operating-system }} + if: github.event.pull_request.draft == false + steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.0 + with: + ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + tools: composer:v2 + coverage: none + env: + COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Display PHP information + run: | + php -v + php -m + composer --version + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Run composer validate + run: composer validate + + - name: Install dependencies + run: composer install --no-interaction --no-suggest --no-scripts --prefer-dist --ansi + + - name: Run phpcstd + run: vendor/bin/phpcstd --ci --ansi + + - name: Setup problem matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Run Unit tests + run: composer test --ansi diff --git a/README.md b/README.md index 228a202..64ee64f 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,66 @@ final class Collection } ``` +### Type + +As long as you specify a type for your properties, the `Type` validation is automatically added to ensure that the specified values can be assigned to the specified types. If not, a validation exception will be thrown. +Without this validation, a `TypeError` would be thrown, which may not be desirable. + +So this code +```php +final class Foo +{ + private ?int $id; +} +``` + +is actually seen as this: +```php +use Dgame\DataTransferObject\Annotation\Type; + +final class Foo +{ + #[Type(name: '?int')] + private ?int $id; +} +``` + +The following snippets are equivalent to the snippet above: + +```php +use Dgame\DataTransferObject\Annotation\Type; + +final class Foo +{ + #[Type(name: 'int|null')] + private ?int $id; +} +``` + +```php +use Dgame\DataTransferObject\Annotation\Type; + +final class Foo +{ + #[Type(name: 'int', allowsNull: true)] + private ?int $id; +} +``` + +--- + +If you want to change the exception message, you can do so using the `message` parameter: + +```php +use Dgame\DataTransferObject\Annotation\Type; + +final class Foo +{ + #[Type(name: '?int', message: 'id is expected to be int or null')] + private ?int $id; +} +``` + ### Custom Do you want your Validation? Just implement the `Validation`-interface: diff --git a/composer.json b/composer.json index de2924c..59a9006 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,9 @@ } ], "require": { - "php": "^8.0" + "php": "^8.0", + "sebastian/type": "^2.3", + "thecodingmachine/safe": "^1.3" }, "require-dev": { "ergebnis/composer-normalize": "^2.4", diff --git a/src/Annotation/Type.php b/src/Annotation/Type.php new file mode 100644 index 0000000..ae503ab --- /dev/null +++ b/src/Annotation/Type.php @@ -0,0 +1,69 @@ + AbstractType::fromName($typeName, allowsNull: false), + $typeNames + ); + if ($allowsNull) { + $typeNames[] = new NullType(); + } + $this->type = new UnionType(...$typeNames); + } else { + $this->type = AbstractType::fromName($name, allowsNull: $allowsNull); + } + } + + /** + * @throws StringsException + */ + public static function from(ReflectionNamedType $type): self + { + return new self($type->getName(), allowsNull: $type->allowsNull()); + } + + public function validate(mixed $value): void + { + if ($value === null) { + if (!$this->type->allowsNull()) { + throw new ValidationException($this->message ?? 'Cannot assign null to non-nullable ' . $this->type->name()); + } + + return; + } + + $valueType = AbstractType::fromValue($value, allowsNull: false); + if (!$this->type->isAssignable($valueType)) { + throw new ValidationException($this->message ?? 'Cannot assign ' . $valueType->name() . ' ' . var_export($value, true) . ' to ' . $this->type->name()); + } + } +} diff --git a/src/DataTransferProperty.php b/src/DataTransferProperty.php index 68b6de0..1cd29b4 100644 --- a/src/DataTransferProperty.php +++ b/src/DataTransferProperty.php @@ -32,7 +32,7 @@ final class DataTransferProperty private mixed $defaultValue; /** - * @param ReflectionProperty $property + * @param ReflectionProperty $property * @param DataTransferObject $parent * * @throws ReflectionException @@ -49,15 +49,15 @@ public function __construct(private ReflectionProperty $property, DataTransferOb if ($property->hasDefaultValue()) { $this->hasDefaultValue = true; - $this->defaultValue = $property->getDefaultValue(); + $this->defaultValue = $property->getDefaultValue(); } else { $parameter = $this->getPromotedConstructorParameter($parent->getConstructor(), $property->getName()); if ($parameter !== null && $parameter->isOptional()) { $this->hasDefaultValue = true; - $this->defaultValue = $parameter->getDefaultValue(); + $this->defaultValue = $parameter->getDefaultValue(); } else { $this->hasDefaultValue = $property->getType()?->allowsNull() ?? false; - $this->defaultValue = null; + $this->defaultValue = null; } } } @@ -159,7 +159,7 @@ private function setNames(): void $names = []; foreach ($this->property->getAttributes(Name::class) as $attribute) { /** @var Name $name */ - $name = $attribute->newInstance(); + $name = $attribute->newInstance(); $names[$name->getName()] = true; } @@ -169,7 +169,7 @@ private function setNames(): void foreach ($this->property->getAttributes(Alias::class) as $attribute) { /** @var Alias $alias */ - $alias = $attribute->newInstance(); + $alias = $attribute->newInstance(); $names[$alias->getName()] = true; } diff --git a/src/DataTransferValue.php b/src/DataTransferValue.php index 755bf6d..bd4cc5a 100644 --- a/src/DataTransferValue.php +++ b/src/DataTransferValue.php @@ -5,11 +5,13 @@ namespace Dgame\DataTransferObject; use Dgame\DataTransferObject\Annotation\Call; +use Dgame\DataTransferObject\Annotation\Type; use Dgame\DataTransferObject\Annotation\Validation; use ReflectionAttribute; use ReflectionException; use ReflectionNamedType; use ReflectionProperty; +use Safe\Exceptions\StringsException; use Throwable; final class DataTransferValue @@ -75,12 +77,27 @@ private function tryResolvingIntoObject(): void $this->value = $dto->getInstance(); } + /** + * @throws StringsException + */ private function validate(): void { + $typeChecked = false; foreach ($this->property->getAttributes(Validation::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { /** @var Validation $validation */ $validation = $attribute->newInstance(); $validation->validate($this->value); + + $typeChecked = $typeChecked || $validation instanceof Type; + } + + if ($typeChecked) { + return; + } + + $type = $this->property->getType(); + if ($type instanceof ReflectionNamedType) { + Type::from($type)->validate($this->value); } } } diff --git a/src/ValidationException.php b/src/ValidationException.php new file mode 100644 index 0000000..c4957c9 --- /dev/null +++ b/src/ValidationException.php @@ -0,0 +1,11 @@ + $input * @param int|null $expectedOffset - * @param int|null $expectedSize + * @param string|int|null $expectedSize * + * @throws \ReflectionException + * @throws \Throwable * @dataProvider provideLimitInput */ - public function testLimitStub(array $input, ?int $expectedOffset, ?int $expectedSize): void + public function testLimitStub(array $input, ?int $expectedOffset, string|int|null $expectedSize): void { + if (is_string($expectedSize)) { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Cannot assign string \'' . $expectedSize . '\' to int'); + } + $stub = LimitStub::from($input); $this->assertEquals($expectedOffset, $stub->getFrom()); $this->assertEquals($expectedSize, $stub->getSize()); @@ -43,6 +51,12 @@ public function provideLimitInput(): iterable 42 ]; + yield 'Size is string' => [ + ['size' => 'a'], + null, + 'a' + ]; + yield 'Offset 23, Size 42' => [ ['offset' => 23, 'size' => 42], 23,