Skip to content

Commit 049c022

Browse files
authored
Merge pull request #1 from Dgame/feature/type-validation
Added Type-Annotation
2 parents 4e0970c + 8cf537b commit 049c022

File tree

8 files changed

+250
-77
lines changed

8 files changed

+250
-77
lines changed

Diff for: .github/workflows/php.yml

+68-68
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,68 @@
1-
name: Validation Workflow
2-
3-
on:
4-
pull_request:
5-
types:
6-
- opened
7-
- synchronize
8-
- reopened
9-
- ready_for_review
10-
paths:
11-
- '**/*.php'
12-
13-
jobs:
14-
lint-and-test:
15-
strategy:
16-
matrix:
17-
operating-system: ['ubuntu-latest']
18-
php-versions: ['8.0']
19-
runs-on: ${{ matrix.operating-system }}
20-
if: github.event.pull_request.draft == false
21-
steps:
22-
- name: Cancel Previous Runs
23-
uses: styfle/[email protected]
24-
with:
25-
ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
26-
27-
- name: Checkout
28-
uses: actions/checkout@v2
29-
30-
- name: Setup PHP
31-
uses: shivammathur/setup-php@v2
32-
with:
33-
php-version: ${{ matrix.php-versions }}
34-
tools: composer:v2
35-
coverage: none
36-
env:
37-
COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38-
39-
- name: Display PHP information
40-
run: |
41-
php -v
42-
php -m
43-
composer --version
44-
- name: Get composer cache directory
45-
id: composer-cache
46-
run: echo "::set-output name=dir::$(composer config cache-files-dir)"
47-
48-
- name: Cache dependencies
49-
uses: actions/cache@v2
50-
with:
51-
path: ${{ steps.composer-cache.outputs.dir }}
52-
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
53-
restore-keys: ${{ runner.os }}-composer-
54-
55-
- name: Run composer validate
56-
run: composer validate
57-
58-
- name: Install dependencies
59-
run: composer install --no-interaction --no-suggest --no-scripts --prefer-dist --ansi
60-
61-
- name: Run phpcstd
62-
run: vendor/bin/phpcstd --ci --ansi
63-
64-
- name: Setup problem matchers for PHPUnit
65-
run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
66-
67-
- name: Run Unit tests
68-
run: composer test --ansi
1+
name: Validation Workflow
2+
3+
on:
4+
pull_request:
5+
types:
6+
- opened
7+
- synchronize
8+
- reopened
9+
- ready_for_review
10+
paths:
11+
- '**/*.php'
12+
13+
jobs:
14+
lint-and-test:
15+
strategy:
16+
matrix:
17+
operating-system: ['ubuntu-latest']
18+
php-versions: ['8.0']
19+
runs-on: ${{ matrix.operating-system }}
20+
if: github.event.pull_request.draft == false
21+
steps:
22+
- name: Cancel Previous Runs
23+
uses: styfle/[email protected]
24+
with:
25+
ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
26+
27+
- name: Checkout
28+
uses: actions/checkout@v2
29+
30+
- name: Setup PHP
31+
uses: shivammathur/setup-php@v2
32+
with:
33+
php-version: ${{ matrix.php-versions }}
34+
tools: composer:v2
35+
coverage: none
36+
env:
37+
COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38+
39+
- name: Display PHP information
40+
run: |
41+
php -v
42+
php -m
43+
composer --version
44+
- name: Get composer cache directory
45+
id: composer-cache
46+
run: echo "::set-output name=dir::$(composer config cache-files-dir)"
47+
48+
- name: Cache dependencies
49+
uses: actions/cache@v2
50+
with:
51+
path: ${{ steps.composer-cache.outputs.dir }}
52+
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
53+
restore-keys: ${{ runner.os }}-composer-
54+
55+
- name: Run composer validate
56+
run: composer validate
57+
58+
- name: Install dependencies
59+
run: composer install --no-interaction --no-suggest --no-scripts --prefer-dist --ansi
60+
61+
- name: Run phpcstd
62+
run: vendor/bin/phpcstd --ci --ansi
63+
64+
- name: Setup problem matchers for PHPUnit
65+
run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
66+
67+
- name: Run Unit tests
68+
run: composer test --ansi

Diff for: README.md

+60
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,66 @@ final class Collection
199199
}
200200
```
201201

202+
### Type
203+
204+
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.
205+
Without this validation, a `TypeError` would be thrown, which may not be desirable.
206+
207+
So this code
208+
```php
209+
final class Foo
210+
{
211+
private ?int $id;
212+
}
213+
```
214+
215+
is actually seen as this:
216+
```php
217+
use Dgame\DataTransferObject\Annotation\Type;
218+
219+
final class Foo
220+
{
221+
#[Type(name: '?int')]
222+
private ?int $id;
223+
}
224+
```
225+
226+
The following snippets are equivalent to the snippet above:
227+
228+
```php
229+
use Dgame\DataTransferObject\Annotation\Type;
230+
231+
final class Foo
232+
{
233+
#[Type(name: 'int|null')]
234+
private ?int $id;
235+
}
236+
```
237+
238+
```php
239+
use Dgame\DataTransferObject\Annotation\Type;
240+
241+
final class Foo
242+
{
243+
#[Type(name: 'int', allowsNull: true)]
244+
private ?int $id;
245+
}
246+
```
247+
248+
---
249+
250+
If you want to change the exception message, you can do so using the `message` parameter:
251+
252+
```php
253+
use Dgame\DataTransferObject\Annotation\Type;
254+
255+
final class Foo
256+
{
257+
#[Type(name: '?int', message: 'id is expected to be int or null')]
258+
private ?int $id;
259+
}
260+
```
261+
202262
### Custom
203263

204264
Do you want your Validation? Just implement the `Validation`-interface:

Diff for: composer.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
}
1212
],
1313
"require": {
14-
"php": "^8.0"
14+
"php": "^8.0",
15+
"sebastian/type": "^2.3",
16+
"thecodingmachine/safe": "^1.3"
1517
},
1618
"require-dev": {
1719
"ergebnis/composer-normalize": "^2.4",

Diff for: src/Annotation/Type.php

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Dgame\DataTransferObject\Annotation;
6+
7+
use Attribute;
8+
use Dgame\DataTransferObject\ValidationException;
9+
use ReflectionNamedType;
10+
use Safe\Exceptions\StringsException;
11+
use SebastianBergmann\Type\NullType;
12+
use SebastianBergmann\Type\Type as AbstractType;
13+
use SebastianBergmann\Type\UnionType;
14+
15+
#[Attribute(flags: Attribute::TARGET_PROPERTY)]
16+
final class Type implements Validation
17+
{
18+
private AbstractType $type;
19+
20+
/**
21+
* @throws StringsException
22+
*/
23+
public function __construct(string $name, bool $allowsNull = false, private ?string $message = null)
24+
{
25+
$name = trim($name);
26+
if (str_starts_with($name, '?')) {
27+
$allowsNull = true;
28+
$name = \Safe\substr($name, start: 1);
29+
}
30+
31+
$typeNames = array_map('trim', explode('|', $name));
32+
if ($typeNames !== [$name]) {
33+
$typeNames = array_map(
34+
static fn(string $typeName) => AbstractType::fromName($typeName, allowsNull: false),
35+
$typeNames
36+
);
37+
if ($allowsNull) {
38+
$typeNames[] = new NullType();
39+
}
40+
$this->type = new UnionType(...$typeNames);
41+
} else {
42+
$this->type = AbstractType::fromName($name, allowsNull: $allowsNull);
43+
}
44+
}
45+
46+
/**
47+
* @throws StringsException
48+
*/
49+
public static function from(ReflectionNamedType $type): self
50+
{
51+
return new self($type->getName(), allowsNull: $type->allowsNull());
52+
}
53+
54+
public function validate(mixed $value): void
55+
{
56+
if ($value === null) {
57+
if (!$this->type->allowsNull()) {
58+
throw new ValidationException($this->message ?? 'Cannot assign null to non-nullable ' . $this->type->name());
59+
}
60+
61+
return;
62+
}
63+
64+
$valueType = AbstractType::fromValue($value, allowsNull: false);
65+
if (!$this->type->isAssignable($valueType)) {
66+
throw new ValidationException($this->message ?? 'Cannot assign ' . $valueType->name() . ' ' . var_export($value, true) . ' to ' . $this->type->name());
67+
}
68+
}
69+
}

Diff for: src/DataTransferProperty.php

+6-6
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ final class DataTransferProperty
3232
private mixed $defaultValue;
3333

3434
/**
35-
* @param ReflectionProperty $property
35+
* @param ReflectionProperty $property
3636
* @param DataTransferObject<T> $parent
3737
*
3838
* @throws ReflectionException
@@ -49,15 +49,15 @@ public function __construct(private ReflectionProperty $property, DataTransferOb
4949

5050
if ($property->hasDefaultValue()) {
5151
$this->hasDefaultValue = true;
52-
$this->defaultValue = $property->getDefaultValue();
52+
$this->defaultValue = $property->getDefaultValue();
5353
} else {
5454
$parameter = $this->getPromotedConstructorParameter($parent->getConstructor(), $property->getName());
5555
if ($parameter !== null && $parameter->isOptional()) {
5656
$this->hasDefaultValue = true;
57-
$this->defaultValue = $parameter->getDefaultValue();
57+
$this->defaultValue = $parameter->getDefaultValue();
5858
} else {
5959
$this->hasDefaultValue = $property->getType()?->allowsNull() ?? false;
60-
$this->defaultValue = null;
60+
$this->defaultValue = null;
6161
}
6262
}
6363
}
@@ -159,7 +159,7 @@ private function setNames(): void
159159
$names = [];
160160
foreach ($this->property->getAttributes(Name::class) as $attribute) {
161161
/** @var Name $name */
162-
$name = $attribute->newInstance();
162+
$name = $attribute->newInstance();
163163
$names[$name->getName()] = true;
164164
}
165165

@@ -169,7 +169,7 @@ private function setNames(): void
169169

170170
foreach ($this->property->getAttributes(Alias::class) as $attribute) {
171171
/** @var Alias $alias */
172-
$alias = $attribute->newInstance();
172+
$alias = $attribute->newInstance();
173173
$names[$alias->getName()] = true;
174174
}
175175

Diff for: src/DataTransferValue.php

+17
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
namespace Dgame\DataTransferObject;
66

77
use Dgame\DataTransferObject\Annotation\Call;
8+
use Dgame\DataTransferObject\Annotation\Type;
89
use Dgame\DataTransferObject\Annotation\Validation;
910
use ReflectionAttribute;
1011
use ReflectionException;
1112
use ReflectionNamedType;
1213
use ReflectionProperty;
14+
use Safe\Exceptions\StringsException;
1315
use Throwable;
1416

1517
final class DataTransferValue
@@ -75,12 +77,27 @@ private function tryResolvingIntoObject(): void
7577
$this->value = $dto->getInstance();
7678
}
7779

80+
/**
81+
* @throws StringsException
82+
*/
7883
private function validate(): void
7984
{
85+
$typeChecked = false;
8086
foreach ($this->property->getAttributes(Validation::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
8187
/** @var Validation $validation */
8288
$validation = $attribute->newInstance();
8389
$validation->validate($this->value);
90+
91+
$typeChecked = $typeChecked || $validation instanceof Type;
92+
}
93+
94+
if ($typeChecked) {
95+
return;
96+
}
97+
98+
$type = $this->property->getType();
99+
if ($type instanceof ReflectionNamedType) {
100+
Type::from($type)->validate($this->value);
84101
}
85102
}
86103
}

Diff for: src/ValidationException.php

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Dgame\DataTransferObject;
6+
7+
use InvalidArgumentException;
8+
9+
final class ValidationException extends InvalidArgumentException
10+
{
11+
}

0 commit comments

Comments
 (0)