Skip to content

Commit

Permalink
Merge pull request #1 from Dgame/feature/type-validation
Browse files Browse the repository at this point in the history
Added Type-Annotation
  • Loading branch information
Dgame authored Jul 18, 2021
2 parents 4e0970c + 8cf537b commit 049c022
Show file tree
Hide file tree
Showing 8 changed files with 250 additions and 77 deletions.
136 changes: 68 additions & 68 deletions .github/workflows/php.yml
Original file line number Diff line number Diff line change
@@ -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/[email protected]
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/[email protected]
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
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
69 changes: 69 additions & 0 deletions src/Annotation/Type.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace Dgame\DataTransferObject\Annotation;

use Attribute;
use Dgame\DataTransferObject\ValidationException;
use ReflectionNamedType;
use Safe\Exceptions\StringsException;
use SebastianBergmann\Type\NullType;
use SebastianBergmann\Type\Type as AbstractType;
use SebastianBergmann\Type\UnionType;

#[Attribute(flags: Attribute::TARGET_PROPERTY)]
final class Type implements Validation
{
private AbstractType $type;

/**
* @throws StringsException
*/
public function __construct(string $name, bool $allowsNull = false, private ?string $message = null)
{
$name = trim($name);
if (str_starts_with($name, '?')) {
$allowsNull = true;
$name = \Safe\substr($name, start: 1);
}

$typeNames = array_map('trim', explode('|', $name));
if ($typeNames !== [$name]) {
$typeNames = array_map(
static fn(string $typeName) => 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());
}
}
}
12 changes: 6 additions & 6 deletions src/DataTransferProperty.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ final class DataTransferProperty
private mixed $defaultValue;

/**
* @param ReflectionProperty $property
* @param ReflectionProperty $property
* @param DataTransferObject<T> $parent
*
* @throws ReflectionException
Expand All @@ -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;
}
}
}
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand Down
17 changes: 17 additions & 0 deletions src/DataTransferValue.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
}
11 changes: 11 additions & 0 deletions src/ValidationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Dgame\DataTransferObject;

use InvalidArgumentException;

final class ValidationException extends InvalidArgumentException
{
}
Loading

0 comments on commit 049c022

Please sign in to comment.