├── .env.dist
├── .docker
└── php
│ ├── php.ini
│ └── Dockerfile
├── .gitignore
├── tests
├── Stubs
│ ├── ChildStub.php
│ ├── StaticStub.php
│ ├── DenyUnknownStub.php
│ ├── IgnoreStub.php
│ ├── ArrayInstanceStub.php
│ ├── NestedStub.php
│ ├── RequiredStub.php
│ ├── NumericStub.php
│ ├── ValidationStub.php
│ ├── StrictCastStub.php
│ ├── UnionTypeStub.php
│ ├── NameAliasMixStub.php
│ ├── ValidationStrategyStub.php
│ ├── MatchesStub.php
│ ├── SelfValidationStub.php
│ ├── ArrayGenericTypeStub.php
│ ├── RejectStub.php
│ ├── BooleanStub.php
│ ├── FilterStubProvider.php
│ ├── QueryFieldStub.php
│ ├── OptionalStub.php
│ ├── PaginationStub.php
│ ├── FilterStub.php
│ ├── PersonStub.php
│ ├── LimitStub.php
│ └── CastStub.php
├── NestedStubTest.php
├── UnionTypeTest.php
├── IgnoreTest.php
├── StaticTest.php
├── Annotation
│ └── NumberBetween.php
├── RequiredTest.php
├── FilterTest.php
├── SelfValidationTest.php
├── QueryFieldTest.php
├── DenyUnknownFieldsTest.php
├── PaginationTest.php
├── NameAliasMixTest.php
├── ArrayInstanceTest.php
├── ValidationStrategyTest.php
├── RejectTest.php
├── ValidationTest.php
├── OptionalTest.php
├── LimitTest.php
├── PathTest.php
├── ArrayTypeTest.php
├── BooleanTest.php
├── MatchesTest.php
├── CastTest.php
└── NumericTest.php
├── src
├── ValidationException.php
├── Annotation
│ ├── Ignore.php
│ ├── Validation.php
│ ├── Finalize.php
│ ├── Transformation.php
│ ├── Name.php
│ ├── SelfValidation.php
│ ├── Alias.php
│ ├── Reject.php
│ ├── Required.php
│ ├── Trim.php
│ ├── Exceptional.php
│ ├── Matches.php
│ ├── Boolean.php
│ ├── Optional.php
│ ├── Numeric.php
│ ├── In.php
│ ├── NotIn.php
│ ├── Instance.php
│ ├── Max.php
│ ├── Min.php
│ ├── Type.php
│ ├── ValidationStrategy.php
│ ├── Date.php
│ ├── DenyUnknownFields.php
│ ├── Path.php
│ └── Cast.php
├── Failure
│ ├── FailureHandler.php
│ └── FailureCollection.php
├── DataTransfer.php
├── DataTransferValue.php
├── DataTransferObject.php
└── DataTransferProperty.php
├── .phpcstd.ini
├── docker-compose.yml
├── phpunit.xml
├── LICENSE
├── phpstan.neon
├── CHANGELOG.md
├── Makefile
├── ecs.php
├── .github
└── workflows
│ └── php.yml
├── composer.json
├── .editorconfig
└── README.md
/.env.dist:
--------------------------------------------------------------------------------
1 | USER_ID=1000
2 |
--------------------------------------------------------------------------------
/.docker/php/php.ini:
--------------------------------------------------------------------------------
1 | memory_limit = 128M
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor
2 | /.idea
3 | .env
4 | .phpunit.result.cache
5 | *.lock
6 |
--------------------------------------------------------------------------------
/tests/Stubs/ChildStub.php:
--------------------------------------------------------------------------------
1 | $input
11 | */
12 | public function finalize(array $input): void;
13 | }
14 |
--------------------------------------------------------------------------------
/tests/Stubs/StaticStub.php:
--------------------------------------------------------------------------------
1 | name;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/tests/Stubs/NumericStub.php:
--------------------------------------------------------------------------------
1 | method;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Annotation/Alias.php:
--------------------------------------------------------------------------------
1 | name;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 | ./tests
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/tests/Stubs/ValidationStub.php:
--------------------------------------------------------------------------------
1 | age;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/tests/Stubs/StrictCastStub.php:
--------------------------------------------------------------------------------
1 | age = $age;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/Failure/FailureHandler.php:
--------------------------------------------------------------------------------
1 | hasFailures()) {
14 | return;
15 | }
16 |
17 | throw new ValidationException(implode(PHP_EOL, $collection->getFailures()));
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/tests/Stubs/UnionTypeStub.php:
--------------------------------------------------------------------------------
1 | id;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/DataTransfer.php:
--------------------------------------------------------------------------------
1 | from($input);
20 |
21 | return $dto->getInstance();
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/tests/Stubs/NameAliasMixStub.php:
--------------------------------------------------------------------------------
1 | id > 0);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/tests/Stubs/ArrayGenericTypeStub.php:
--------------------------------------------------------------------------------
1 | > /var/www/.ssh/known_hosts
12 |
13 | WORKDIR /var/www/html
14 |
15 | COPY --chown=www-data:www-data --from=composer:2 /usr/bin/composer /usr/local/bin/composer
16 |
17 | RUN usermod -u $USER_ID www-data && chown -R www-data:www-data /var/www/ .
18 | USER www-data
19 |
20 | CMD ["php-fpm"]
21 |
--------------------------------------------------------------------------------
/tests/Stubs/FilterStubProvider.php:
--------------------------------------------------------------------------------
1 | $value) {
17 | $output[$key] = base64_encode((string) $value);
18 | }
19 |
20 | return $output;
21 | }
22 |
23 | public function toInt(int|string $id): int
24 | {
25 | return (int) $id;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/tests/Stubs/QueryFieldStub.php:
--------------------------------------------------------------------------------
1 | query;
23 | }
24 |
25 | public function getFields(): array
26 | {
27 | return $this->fields;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/tests/Stubs/OptionalStub.php:
--------------------------------------------------------------------------------
1 | ['offset' => 23, 'size' => 42], 'match' => ['query' => 'ab|c']];
15 | $stub = NestedStub::from($input);
16 | $this->assertInstanceOf(NestedStub::class, $stub);
17 | $this->assertEquals(23, $stub->limit->getFrom());
18 | $this->assertEquals(42, $stub->limit->getSize());
19 | $this->assertEquals('ab|c', $stub->query->getQuery());
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/tests/Stubs/PaginationStub.php:
--------------------------------------------------------------------------------
1 | offset = $from;
22 | $this->size = $size;
23 | }
24 |
25 | public function getOffset(): ?int
26 | {
27 | return $this->offset;
28 | }
29 |
30 | public function getSize(): ?int
31 | {
32 | return $this->size;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/Annotation/Reject.php:
--------------------------------------------------------------------------------
1 | $exception
19 | */
20 | public function __construct(string $reason, string $exception = InvalidArgumentException::class)
21 | {
22 | $this->exceptional = new Exceptional(message: $reason, exception: $exception);
23 | }
24 |
25 | public function execute(): void
26 | {
27 | $this->exceptional->execute();
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/tests/Stubs/FilterStub.php:
--------------------------------------------------------------------------------
1 | id;
27 | }
28 |
29 | public function getFilter(): array
30 | {
31 | return $this->filter;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Annotation/Required.php:
--------------------------------------------------------------------------------
1 | $exception
19 | */
20 | public function __construct(string $reason, string $exception = InvalidArgumentException::class)
21 | {
22 | $this->exceptional = new Exceptional(message: $reason, exception: $exception);
23 | }
24 |
25 | public function execute(): void
26 | {
27 | $this->exceptional->execute();
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/tests/UnionTypeTest.php:
--------------------------------------------------------------------------------
1 | assertEquals($expected, $ut->getId());
22 | }
23 |
24 | public function provideInput(): iterable
25 | {
26 | yield [['id' => 42], 42];
27 | yield [['id' => 'abc'], 'abc'];
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/Annotation/Trim.php:
--------------------------------------------------------------------------------
1 | $item) {
21 | $value[$key] = $this->transform($item, $property);
22 | }
23 |
24 | return $value;
25 | }
26 |
27 | return is_string($value) ? trim($value, $this->characters) : $value;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/tests/Stubs/PersonStub.php:
--------------------------------------------------------------------------------
1 | from;
28 | }
29 |
30 | public function getSize(): ?int
31 | {
32 | return $this->size;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/tests/Stubs/CastStub.php:
--------------------------------------------------------------------------------
1 | id = $id;
26 | $this->age = $age;
27 | $this->uid = $uid;
28 | }
29 |
30 | public static function toInt(string|int|float|bool $value): int
31 | {
32 | return (int) $value;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/Annotation/Exceptional.php:
--------------------------------------------------------------------------------
1 | $exception
15 | */
16 | public function __construct(private string $message, private string $exception = InvalidArgumentException::class)
17 | {
18 | }
19 |
20 | public function execute(): void
21 | {
22 | throw $this->getException();
23 | }
24 |
25 | private function getException(): Throwable
26 | {
27 | $exception = new ($this->exception)($this->message);
28 | /** @phpstan-ignore-next-line */
29 | assert($exception instanceof Throwable);
30 |
31 | return $exception;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/tests/IgnoreTest.php:
--------------------------------------------------------------------------------
1 | assertEquals('abc', $stub->uuid);
27 | $this->assertEquals($expectedId, $stub->id);
28 | }
29 |
30 | public function provideIgnoreData(): iterable
31 | {
32 | yield [['uuid' => 'xyz', 'id' => 42], 42];
33 | yield [['id' => 23], 23];
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/Annotation/Matches.php:
--------------------------------------------------------------------------------
1 | pattern, (string) $value) !== 1) {
19 | $validationStrategy->setFailure(
20 | strtr(
21 | $this->message ?? '{value} of {path} does not match pattern {pattern}',
22 | [
23 | '{value}' => var_export($value, true),
24 | '{values}' => $this->pattern
25 | ]
26 | )
27 | );
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/tests/StaticTest.php:
--------------------------------------------------------------------------------
1 | assertEquals($expectedId, $stub::$id);
27 | $this->assertEquals($expectedId, StaticStub::$id);
28 | }
29 |
30 | public function provideStaticData(): iterable
31 | {
32 | yield [[], 0];
33 | yield [['id' => 42], 42];
34 | yield [['id' => 23], 23];
35 | yield [[], 23];
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Annotation/Boolean.php:
--------------------------------------------------------------------------------
1 | setFailure(
22 | strtr(
23 | $this->message ?? 'Value {value} of {path} is not a bool',
24 | ['{value}' => var_export($value, true)]
25 | )
26 | );
27 | }
28 | }
29 |
30 | public function transform(mixed $value, ReflectionProperty $property): mixed
31 | {
32 | return bool($value) ?? $value;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/Annotation/Optional.php:
--------------------------------------------------------------------------------
1 | getType();
24 | if (!($reflectionNamedType instanceof ReflectionNamedType)) {
25 | throw new InvalidArgumentException('Cannot cast to unknown type');
26 | }
27 |
28 | $propertyType = PhpType::fromReflectionType($reflectionNamedType);
29 |
30 | return $this->value ?? ($propertyType instanceof Defaultable ? $propertyType->getDefaultValue() : $this->value);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/Annotation/Numeric.php:
--------------------------------------------------------------------------------
1 | setFailure(
22 | strtr(
23 | $this->message ?? 'Value {value} of {path} is not numeric',
24 | ['{value}' => var_export($value, true)]
25 | )
26 | );
27 | }
28 | }
29 |
30 | public function transform(mixed $value, ReflectionProperty $property): mixed
31 | {
32 | return number($value) ?? $value;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/Annotation/In.php:
--------------------------------------------------------------------------------
1 | $values
14 | */
15 | public function __construct(private array $values, private ?string $message = null)
16 | {
17 | }
18 |
19 | public function validate(mixed $value, ValidationStrategy $validationStrategy): void
20 | {
21 | if (!in_array(needle: $value, haystack: $this->values, strict: true)) {
22 | $validationStrategy->setFailure(
23 | strtr(
24 | $this->message ?? '{value} of {path} must be in {values}',
25 | [
26 | '{value}' => var_export($value, true),
27 | '{values}' => var_export($this->values, true)
28 | ]
29 | )
30 | );
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Annotation/NotIn.php:
--------------------------------------------------------------------------------
1 | $values
14 | */
15 | public function __construct(private array $values, private ?string $message = null)
16 | {
17 | }
18 |
19 | public function validate(mixed $value, ValidationStrategy $validationStrategy): void
20 | {
21 | if (in_array(needle: $value, haystack: $this->values, strict: true)) {
22 | $validationStrategy->setFailure(
23 | strtr(
24 | $this->message ?? '{value} of {path} must not be in {values}',
25 | [
26 | '{value}' => var_export($value, true),
27 | '{values}' => var_export($this->values, true)
28 | ]
29 | )
30 | );
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Failure/FailureCollection.php:
--------------------------------------------------------------------------------
1 | path[] = $path;
23 | }
24 |
25 | public function popPath(): ?string
26 | {
27 | return array_pop($this->path);
28 | }
29 |
30 | public function setFailure(string $failure): void
31 | {
32 | $this->failures[] = strtr($failure, [self::PATH => implode('.', $this->path)]);
33 | }
34 |
35 | public function hasFailures(): bool
36 | {
37 | return $this->failures !== [];
38 | }
39 |
40 | /**
41 | * @return string[]
42 | */
43 | public function getFailures(): array
44 | {
45 | return $this->failures;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/tests/Annotation/NumberBetween.php:
--------------------------------------------------------------------------------
1 | setFailure(var_export($value, true) . ' must be a numeric value');
22 |
23 | return;
24 | }
25 |
26 | if ($value < $this->min) {
27 | $validationStrategy->setFailure(var_export($value, true) . ' must be >= ' . $this->min);
28 | }
29 |
30 | if ($value > $this->max) {
31 | $validationStrategy->setFailure(var_export($value, true) . ' must be <= ' . $this->max);
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Randy Schütt
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/Annotation/Instance.php:
--------------------------------------------------------------------------------
1 | validate($item, $validationStrategy);
21 | }
22 |
23 | return;
24 | }
25 |
26 | if (!is_object($value)) {
27 | $validationStrategy->setFailure($this->message ?? var_export($value, true) . ' must be an object-instance of ' . $this->class . ' in {path}');
28 |
29 | return;
30 | }
31 |
32 | if (!($value instanceof $this->class)) {
33 | $validationStrategy->setFailure($this->message ?? var_export($value, true) . ' is not an instance of ' . $this->class . ' in {path}');
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/tests/RequiredTest.php:
--------------------------------------------------------------------------------
1 | expectException($exception);
28 | $this->expectExceptionMessage('We need an "id" to identify the class');
29 | }
30 |
31 | $stub = RequiredStub::from($input);
32 | $this->assertEquals($expectedId, $stub->id);
33 | }
34 |
35 | public function provideAbsentData(): iterable
36 | {
37 | yield [['id' => 23], 23];
38 | yield [[], 23];
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/tests/FilterTest.php:
--------------------------------------------------------------------------------
1 | assertEquals($expectedId, $filter->getId());
23 | $this->assertEquals($expectedFilter, $filter->getFilter());
24 | }
25 |
26 | public function provideData(): iterable
27 | {
28 | yield [
29 | ['filter' => [1, 2, 3], 'id' => '_'],
30 | [0 => base64_encode('1'), 1 => base64_encode('2'), 2 => base64_encode('3')],
31 | 0
32 | ];
33 |
34 | yield [
35 | ['filter' => ['a' => 'b', 'x' => 'y'], 'id' => '42a'],
36 | ['a' => base64_encode('b'), 'x' => base64_encode('y')],
37 | 42
38 | ];
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/phpstan.neon:
--------------------------------------------------------------------------------
1 | includes:
2 | - vendor/thecodingmachine/phpstan-safe-rule/phpstan-safe-rule.neon
3 | - vendor/thecodingmachine/phpstan-strict-rules/phpstan-strict-rules.neon
4 | - vendor/phpstan/phpstan-deprecation-rules/rules.neon
5 | - vendor/phpstan/phpstan-strict-rules/rules.neon
6 | - vendor/phpstan/phpstan/conf/bleedingEdge.neon
7 | - vendor/spaze/phpstan-disallowed-calls/extension.neon
8 | - vendor/spaze/phpstan-disallowed-calls/disallowed-dangerous-calls.neon
9 | - vendor/spaze/phpstan-disallowed-calls/disallowed-execution-calls.neon
10 |
11 | parameters:
12 | treatPhpDocTypesAsCertain: false
13 | checkInternalClassCaseSensitivity: true
14 | checkTooWideReturnTypesInProtectedAndPublicMethods: true
15 | checkUninitializedProperties: true
16 | checkMissingCallableSignature: true
17 | checkExplicitMixed: true
18 | level: max
19 | paths:
20 | - src
21 |
22 | rules:
23 | - Ergebnis\PHPStan\Rules\Expressions\NoCompactRule
24 | - Ergebnis\PHPStan\Rules\Expressions\NoEmptyRule
25 | - Ergebnis\PHPStan\Rules\Expressions\NoErrorSuppressionRule
26 | - Ergebnis\PHPStan\Rules\Expressions\NoEvalRule
27 | - Ergebnis\PHPStan\Rules\Expressions\NoIssetRule
28 | - Ergebnis\PHPStan\Rules\Files\DeclareStrictTypesRule
29 | - Ergebnis\PHPStan\Rules\Methods\PrivateInFinalClassRule
30 |
--------------------------------------------------------------------------------
/tests/SelfValidationTest.php:
--------------------------------------------------------------------------------
1 | assertEquals($expected, $stub);
29 | } catch (AssertionError $error) {
30 | $this->assertEquals('assert($this->id > 0)', $error->getMessage());
31 | }
32 | }
33 |
34 | public function provideSelfValidationData(): iterable
35 | {
36 | yield [['id' => 1], new SelfValidationStub(id: 1)];
37 | yield [['id' => 99], new SelfValidationStub(id: 99)];
38 | yield [['id' => 0], new SelfValidationStub(id: 0)];
39 | yield [['id' => -1], new SelfValidationStub(id: -1)];
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/tests/QueryFieldTest.php:
--------------------------------------------------------------------------------
1 | $input
14 | * @param string $expectedQuery
15 | * @param string[] $expectedFields
16 | *
17 | * @dataProvider provideQueryFieldInput
18 | */
19 | public function testQueryFieldStub(array $input, string $expectedQuery, array $expectedFields): void
20 | {
21 | $stub = QueryFieldStub::from($input);
22 | $this->assertEquals($expectedQuery, $stub->getQuery());
23 | $this->assertEquals($expectedFields, $stub->getFields());
24 | }
25 |
26 | public function provideQueryFieldInput(): iterable
27 | {
28 | yield 'Just Query' => [
29 | ['query' => 'abc | def'],
30 | 'abc | def',
31 | []
32 | ];
33 |
34 | yield 'Query char' => [
35 | ['query' => '*'],
36 | '*',
37 | []
38 | ];
39 |
40 | yield 'With Fields' => [
41 | ['query' => 'a+b', 'fields' => ['x', 'y', 'z']],
42 | 'a+b',
43 | ['x', 'y', 'z']
44 | ];
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/Annotation/Max.php:
--------------------------------------------------------------------------------
1 | $this->maxValue) {
20 | $validationStrategy->setFailure($this->message ?? 'Value ' . var_export($value, true) . ' of {path} must be <= ' . $this->maxValue);
21 | }
22 |
23 | return;
24 | }
25 |
26 | if (is_string($value)) {
27 | if (strlen($value) > $this->maxValue) {
28 | $validationStrategy->setFailure($this->message ?? 'Value ' . var_export($value, true) . ' of {path} must have at most a length of ' . $this->maxValue);
29 | }
30 |
31 | return;
32 | }
33 |
34 | if (is_array($value) && count($value) > $this->maxValue) {
35 | $validationStrategy->setFailure($this->message ?? 'Value ' . var_export($value, true) . ' of {path} must have at most a length of ' . $this->maxValue);
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Annotation/Min.php:
--------------------------------------------------------------------------------
1 | minValue) {
20 | $validationStrategy->setFailure($this->message ?? 'Value ' . var_export($value, true) . ' of {path} must be >= ' . $this->minValue);
21 | }
22 |
23 | return;
24 | }
25 |
26 | if (is_string($value)) {
27 | if (strlen($value) < $this->minValue) {
28 | $validationStrategy->setFailure($this->message ?? 'Value ' . var_export($value, true) . ' of {path} must have at least a length of ' . $this->minValue);
29 | }
30 |
31 | return;
32 | }
33 |
34 | if (is_array($value) && count($value) < $this->minValue) {
35 | $validationStrategy->setFailure($this->message ?? 'Value ' . var_export($value, true) . ' of {path} must have at least a length of ' . $this->minValue);
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## v0.4.0 - 12.09.2021
4 |
5 | ### Added
6 |
7 | - [Boolean](/README.md/#Boolean)
8 | - [Date](/README.md/#Date)
9 | - [In](/README.md/#In)
10 | - [Matches](/README.md/#Matches)
11 | - [NotIn](/README.md/#NotIn)
12 | - [Numeric](/README.md/#Numeric)
13 | - [Trim](/README.md/#Trim)
14 |
15 | ### Changed
16 |
17 | - `finalize`of `DataTransferObject` is now private an will be called at the end of `from`.
18 |
19 | ## v0.3.0 - 15.08.2021
20 |
21 | ### Added
22 |
23 | - [Transformations](/README.md/#Transformations) (e.g. [Cast](/README.md/#Cast))
24 | - [Optional](/README.md/#Optional)
25 | - [Path](/README.md/#Path)
26 | - [SelfValidation](/README.md/#SelfValidation)
27 | - [ValidationStrategy](/README.md/#ValidationStrategy)
28 |
29 | ### Changed
30 |
31 | - **Renamed** trait `From` to `DataTransfer`
32 | - **Improved** [Type](/README.md/#Type) so that it can even detect generic arrays
33 | - Validations can now be configured with a [ValidationStrategy](/README.md/#ValidationStrategy) to fail fast (default) or to collect **all** failures.
34 | The non-fast failure collection and handling can be configured by either implementing your own [ValidationStrategy](/README.md/#ValidationStrategy) or by overriding `FailureCollection` and `FailureHandler`.
35 |
36 | ### Removed
37 |
38 | - `Call`. [Cast](/README.md/#Cast) can be used for the most common tasks instead of `Call`.
39 |
--------------------------------------------------------------------------------
/tests/DenyUnknownFieldsTest.php:
--------------------------------------------------------------------------------
1 | expectException($exception);
28 | }
29 |
30 | if ($message !== null) {
31 | $this->expectExceptionMessage($message);
32 | }
33 |
34 | $stub = DenyUnknownStub::from($input);
35 | $this->assertInstanceOf(DenyUnknownStub::class, $stub);
36 | }
37 |
38 | public function provideData(): iterable
39 | {
40 | yield [['id' => 42], null, null];
41 | yield [['Id' => 42], null, 'The field "Id" is not expected'];
42 | yield [['a' => null, 'b' => null], null, 'The fields "a, b" are not expected'];
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/tests/PaginationTest.php:
--------------------------------------------------------------------------------
1 | $input
14 | * @param int|null $expectedOffset
15 | * @param int|null $expectedSize
16 | *
17 | * @dataProvider provideLimitInput
18 | */
19 | public function testPaginationStub(array $input, ?int $expectedOffset, ?int $expectedSize): void
20 | {
21 | $stub = PaginationStub::from($input);
22 | $this->assertEquals($expectedOffset, $stub->getOffset());
23 | $this->assertEquals($expectedSize, $stub->getSize());
24 | }
25 |
26 | public function provideLimitInput(): iterable
27 | {
28 | yield 'Empty' => [
29 | [],
30 | null,
31 | null
32 | ];
33 |
34 | yield 'Offset 0' => [
35 | ['offset' => 0],
36 | 0,
37 | null
38 | ];
39 |
40 | yield 'Size 42' => [
41 | ['size' => 42],
42 | null,
43 | 42
44 | ];
45 |
46 | yield 'Offset 23, Size 42' => [
47 | ['offset' => 23, 'size' => 42],
48 | 23,
49 | 42
50 | ];
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/tests/NameAliasMixTest.php:
--------------------------------------------------------------------------------
1 | expectException(InvalidArgumentException::class);
27 | $this->expectExceptionMessage('Expected one of "a, z"');
28 | }
29 |
30 | $stub = NameAliasMixStub::from($input);
31 | $this->assertEquals($expectedId, $stub->id);
32 | $this->assertEquals($expectedUuid, $stub->uuid);
33 | }
34 |
35 | public function provideNameAliasMixedData(): iterable
36 | {
37 | yield [['a' => 42, 'x' => 1], 42, 1];
38 | yield [['z' => 23, 'y' => 2], 23, 2];
39 | yield [['a' => 1337, 'uuid' => 3], 1337, 3];
40 | yield [['z' => 1337, 'uuid' => 4], 1337, 4];
41 | yield [['id' => 1337, 'uuid' => 42], 1337, 42];
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Annotation/Type.php:
--------------------------------------------------------------------------------
1 | type = PhpType::fromName($name, $allowsNull);
19 | }
20 |
21 | public static function from(ReflectionNamedType $type): self
22 | {
23 | return new self($type->getName(), allowsNull: $type->allowsNull());
24 | }
25 |
26 | public function validate(mixed $value, ValidationStrategy $validationStrategy): void
27 | {
28 | if ($value === null) {
29 | if (!$this->type->allowsNull()) {
30 | $validationStrategy->setFailure($this->message ?? 'Cannot assign null to non-nullable ' . $this->type->getName() . ' of {path}');
31 | }
32 |
33 | return;
34 | }
35 |
36 | $valueType = PhpType::fromValue($value);
37 | if (!$this->type->isAssignable($valueType)) {
38 | $validationStrategy->setFailure($this->message ?? 'Cannot assign ' . $valueType->getName() . ' ' . var_export($value, return: true) . ' to ' . $this->type->getName() . ' of {path}');
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Annotation/ValidationStrategy.php:
--------------------------------------------------------------------------------
1 | collection = $collection ?? new FailureCollection();
20 | $this->handler = $handler ?? new FailureHandler();
21 | }
22 |
23 | public function pushPath(string $path): void
24 | {
25 | $this->collection->pushPath($path);
26 | }
27 |
28 | public function popPath(): ?string
29 | {
30 | return $this->collection->popPath();
31 | }
32 |
33 | public function setFailure(string $failure): void
34 | {
35 | $this->collection->setFailure($failure);
36 | if ($this->failFast) {
37 | $this->handle();
38 | }
39 | }
40 |
41 | public function hasFailures(): bool
42 | {
43 | return $this->collection->hasFailures();
44 | }
45 |
46 | public function handle(): void
47 | {
48 | $this->handler->handle($this->collection);
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/tests/ArrayInstanceTest.php:
--------------------------------------------------------------------------------
1 | expectException(InvalidArgumentException::class);
28 | $this->expectExceptionMessage($message);
29 | }
30 |
31 | $stub = ArrayInstanceStub::from($input);
32 | $this->assertContainsOnlyInstancesOf(LimitStub::class, $stub->limits);
33 | }
34 |
35 | public function provideArrayData(): iterable
36 | {
37 | yield [['limits' => [new LimitStub(from: 42, size: 23)]]];
38 | yield [['limits' => [new LimitStub(from: 1, size: 2), new LimitStub(from: 3, size: 4)]]];
39 | yield [['limits' => []]];
40 | yield [['limits' => [null]], 'NULL must be an object-instance of ' . LimitStub::class];
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | DOCKER_SEARCH_SERVICE = dto
2 |
3 | env:
4 | cp .env.dist .env
5 |
6 | new: kill
7 | docker-compose up -d --build --remove-orphans
8 | make install
9 | up:
10 | docker-compose up -d
11 | make autoload
12 | stop:
13 | docker-compose stop
14 | kill:
15 | docker-compose kill
16 |
17 | test:
18 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer test
19 |
20 | lint:
21 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer lint
22 | lint-static:
23 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer lint:static
24 | lint-fix:
25 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer lint:fix
26 | lint-style:
27 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer lint:style
28 | lint-fix-style:
29 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer lint:fix-style
30 |
31 | autoload:
32 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer dump-autoload
33 | install:
34 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer install --no-interaction --prefer-dist
35 | normalize:
36 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer normalize
37 | update-lock:
38 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer update --lock
39 | update:
40 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer update
41 | upgrade:
42 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer upgrade
43 | validate:
44 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer validate
45 |
46 | bash:
47 | docker-compose exec $(DOCKER_SEARCH_SERVICE) bash
48 |
--------------------------------------------------------------------------------
/src/Annotation/Date.php:
--------------------------------------------------------------------------------
1 | format !== null) {
22 | /** @phpstan-ignore-next-line => short ternary */
23 | $dt = DateTime::createFromFormat($this->format, $value) ?: null;
24 | } else {
25 | /** @phpstan-ignore-next-line => short ternary */
26 | $info = date_parse($value) ?: [];
27 | if (($info['error_count'] ?? 0) === 0 && ($info['warning_count'] ?? 0) === 0) {
28 | /** @phpstan-ignore-next-line */
29 | $dt = new DateTime($value, $this->timezone === null ? null : new DateTimeZone($this->timezone));
30 | }
31 | }
32 |
33 | if ($dt === null) {
34 | $validationStrategy->setFailure(
35 | strtr(
36 | $this->message ?? '{value} of {path} is not a Date',
37 | ['{value}' => var_export($value, true)]
38 | )
39 | );
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/ecs.php:
--------------------------------------------------------------------------------
1 | parameters();
17 | $parameters->set(Option::PATHS, [
18 | __DIR__ . '/src',
19 | __DIR__ . '/tests'
20 | ]);
21 | $containerConfigurator->import(SetList::PSR_12);
22 | $parameters->set(Option::SKIP, [
23 | BinaryOperatorSpacesFixer::class, // So that union types like `int|bool` won't be replaced with `int | bool`
24 | FunctionDeclarationFixer::class // So that `fn(int $a)` won't be replaced with `fn (int $a)`
25 | ]);
26 | $containerConfigurator->import(SetList::CLEAN_CODE);
27 |
28 | $services = $containerConfigurator->services();
29 | $services->set(StrictParamFixer::class);
30 | $services->set(DeclareStrictTypesFixer::class);
31 | $services->set(NoUselessElseFixer::class);
32 | $services->set(NoUnusedImportsFixer::class);
33 | };
34 |
--------------------------------------------------------------------------------
/tests/ValidationStrategyTest.php:
--------------------------------------------------------------------------------
1 | expectException(ValidationException::class);
26 | $this->expectExceptionMessage($message);
27 |
28 | ValidationStrategyStub::from($input);
29 | }
30 |
31 | public function provideValidationStrategyData(): iterable
32 | {
33 | yield [[], 'Expected a value for ValidationStrategyStub.name'];
34 | yield [['id' => -1], 'Expected a value for ValidationStrategyStub.name' . PHP_EOL . 'Value -1 of ValidationStrategyStub.id must be >= 0'];
35 | yield [['name' => 'FooBar', 'id' => -42], 'Value -42 of ValidationStrategyStub.id must be >= 0'];
36 | yield [['name' => 'a', 'id' => 42], 'Value \'a\' of ValidationStrategyStub.name must have at least a length of 3'];
37 | yield [['id' => 23], 'Expected a value for ValidationStrategyStub.name'];
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/tests/RejectTest.php:
--------------------------------------------------------------------------------
1 | expectException($exception);
28 | $this->expectExceptionMessage('The attribute "uuid" is not supposed to be set');
29 | } elseif (array_key_exists('new', $input)) {
30 | $this->expectException($exception);
31 | $this->expectExceptionMessage('The attribute "new" is not supposed to be set');
32 | }
33 |
34 | $stub = RejectStub::from($input);
35 | $this->assertEquals('abc', $stub->uuid);
36 | $this->assertEquals($input['new'] ?? true, $stub->new);
37 | $this->assertEquals($expectedId, $stub->id);
38 | }
39 |
40 | public function provideIgnoreData(): iterable
41 | {
42 | yield [['uuid' => 'xyz', 'new' => false, 'id' => 42], 42];
43 | yield [['uuid' => 'xyz', 'id' => 42], 42];
44 | yield [['id' => 23], 23];
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/Annotation/DenyUnknownFields.php:
--------------------------------------------------------------------------------
1 | |null $exception
16 | * @param string|null $message
17 | */
18 | public function __construct(private ?string $exception = null, private ?string $message = null)
19 | {
20 | }
21 |
22 | /**
23 | * @inheritDoc
24 | * @throws Throwable
25 | */
26 | public function finalize(array $input): void
27 | {
28 | if ($input === []) {
29 | return;
30 | }
31 |
32 | $message = $this->getMessage(array_keys($input));
33 | if ($this->exception !== null) {
34 | $exception = new ($this->exception)($message);
35 | /** @phpstan-ignore-next-line */
36 | assert($exception instanceof Throwable);
37 |
38 | throw $exception;
39 | }
40 |
41 | throw new InvalidArgumentException($message);
42 | }
43 |
44 | /**
45 | * @param string[] $fields
46 | *
47 | * @return string
48 | */
49 | private function getMessage(array $fields): string
50 | {
51 | return $this->message ?? match (count($fields)) {
52 | 0 => 'Found unexpected fields',
53 | 1 => 'The field "' . implode(', ', $fields) . '" is not expected',
54 | default => 'The fields "' . implode(', ', $fields) . '" are not expected',
55 | };
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/tests/ValidationTest.php:
--------------------------------------------------------------------------------
1 | assertEquals($expectedAge, $vs->getAge());
23 | }
24 |
25 | /**
26 | * @param array $input
27 | * @param string $message
28 | *
29 | * @dataProvider provideInvalidAge
30 | */
31 | public function testInvalidAge(array $input, string $message): void
32 | {
33 | $this->expectException(InvalidArgumentException::class);
34 | $this->expectExceptionMessage($message);
35 | ValidationStub::from($input);
36 | }
37 |
38 | public function provideValidAge(): iterable
39 | {
40 | yield [['age' => 18], 18];
41 | yield [['age' => 25], 25];
42 | yield [['age' => 42], 42];
43 | yield [['age' => 99], 99];
44 | yield [['age' => 125], 125];
45 | }
46 |
47 | public function provideInvalidAge(): iterable
48 | {
49 | yield [['age' => 'abc'], '\'abc\' must be a numeric value'];
50 | yield [['age' => 0], '0 must be >= 18'];
51 | yield [['age' => 16], '16 must be >= 18'];
52 | yield [['age' => 126], '126 must be <= 125'];
53 | yield [['age' => 255], '255 must be <= 125'];
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/tests/OptionalTest.php:
--------------------------------------------------------------------------------
1 | assertEquals($expected, $stub);
27 | }
28 |
29 | public function provideOptionalData(): iterable
30 | {
31 | yield [[], new OptionalStub(id: 0, answer: 42, question: null, message: 'foobar', age: 18)];
32 | yield [['age' => 21], new OptionalStub(id: 0, answer: 42, question: null, message: 'foobar', age: 21)];
33 | yield [['id' => 23], new OptionalStub(id: 23, answer: 42, question: null, message: 'foobar', age: 18)];
34 | yield [['answer' => 23], new OptionalStub(id: 0, answer: 23, question: null, message: 'foobar', age: 18)];
35 | yield [['id' => 1337, 'answer' => 23], new OptionalStub(id: 1337, answer: 23, question: null, message: 'foobar', age: 18)];
36 | yield [['id' => 1337, 'answer' => 23, 'message' => 'quatz'], new OptionalStub(id: 1337, answer: 23, question: null, message: 'quatz', age: 18)];
37 | yield [['id' => 1337, 'answer' => 23, 'question' => 'quatz'], new OptionalStub(id: 1337, answer: 23, question: 'quatz', message: 'foobar', age: 18)];
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/tests/LimitTest.php:
--------------------------------------------------------------------------------
1 | $input
15 | * @param int|null $expectedOffset
16 | * @param string|int|null $expectedSize
17 | *
18 | * @throws \ReflectionException
19 | * @throws \Throwable
20 | * @dataProvider provideLimitInput
21 | */
22 | public function testLimitStub(array $input, ?int $expectedOffset, string|int|null $expectedSize): void
23 | {
24 | if (is_string($expectedSize)) {
25 | $this->expectException(ValidationException::class);
26 | $this->expectExceptionMessage('Cannot assign string \'' . $expectedSize . '\' to int');
27 | }
28 |
29 | $stub = LimitStub::from($input);
30 | $this->assertEquals($expectedOffset, $stub->getFrom());
31 | $this->assertEquals($expectedSize, $stub->getSize());
32 | }
33 |
34 | public function provideLimitInput(): iterable
35 | {
36 | yield 'Empty' => [
37 | [],
38 | null,
39 | null
40 | ];
41 |
42 | yield 'Offset 0' => [
43 | ['offset' => 0],
44 | 0,
45 | null
46 | ];
47 |
48 | yield 'Size 42' => [
49 | ['size' => 42],
50 | null,
51 | 42
52 | ];
53 |
54 | yield 'Size is string' => [
55 | ['size' => 'a'],
56 | null,
57 | 'a'
58 | ];
59 |
60 | yield 'Offset 23, Size 42' => [
61 | ['offset' => 23, 'size' => 42],
62 | 23,
63 | 42
64 | ];
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/.github/workflows/php.yml:
--------------------------------------------------------------------------------
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/cancel-workflow-action@0.9.0
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
69 |
--------------------------------------------------------------------------------
/tests/PathTest.php:
--------------------------------------------------------------------------------
1 | $input
16 | * @param PersonStub $expected
17 | *
18 | * @throws ReflectionException
19 | * @throws Throwable
20 | *
21 | * @dataProvider providePersonData
22 | */
23 | public function testPath(array $input, PersonStub $expected): void
24 | {
25 | $stub = PersonStub::from($input);
26 | $this->assertEquals($expected, $stub);
27 | }
28 |
29 | /**
30 | * @return iterable
31 | */
32 | public function providePersonData(): iterable
33 | {
34 | yield [
35 | ['id' => 42, 'person' => ['name' => 'Foo'], 'married' => ['$value' => true], 'first' => ['name' => ['#text' => 'Bar']]],
36 | new PersonStub(id: 42, name: 'Foo', married: true, firstname: 'Bar')
37 | ];
38 |
39 | yield [
40 | ['id' => 42, 'first' => ['name' => ['#text' => 'Bar']]],
41 | new PersonStub(id: 42, firstname: 'Bar')
42 | ];
43 |
44 | yield [
45 | ['id' => 42, 'person.name' => 'Foo', 'married' => ['$value' => true], 'first' => ['name' => ['#text' => 'Bar']]],
46 | new PersonStub(id: 42, married: true, firstname: 'Bar')
47 | ];
48 |
49 | yield [
50 | 'married' => ['$value' => false],
51 | new PersonStub(married: false)
52 | ];
53 |
54 | $stub = new PersonStub();
55 | $stub->firstChild = ['born' => 'Junior', 'age' => 42];
56 | yield [
57 | ['child' => ['born' => 'Junior', 'age' => 42]],
58 | $stub
59 | ];
60 |
61 | $stub = new PersonStub();
62 | $stub->parent = new PersonStub(id: 23, name: 'Odin');
63 | yield [
64 | ['ancestor' => ['name' => 'Odin', 'married' => true, 'id' => 23]],
65 | $stub
66 | ];
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dgame/php-dto",
3 | "description": "A data transfer object inspired by Rust's serde",
4 | "license": "MIT",
5 | "type": "package",
6 | "keywords": [
7 | "DTO",
8 | "data transfer object"
9 | ],
10 | "authors": [
11 | {
12 | "name": "Randy Schütt",
13 | "email": "rswhite4@gmail.com",
14 | "role": "lead"
15 | }
16 | ],
17 | "require": {
18 | "php": "^8.0",
19 | "dgame/php-cast": "^0.1.0",
20 | "dgame/php-type": "^1.0",
21 | "thecodingmachine/safe": "^1.3"
22 | },
23 | "require-dev": {
24 | "ergebnis/composer-normalize": "^2.4",
25 | "ergebnis/phpstan-rules": "^0.15",
26 | "php-parallel-lint/php-parallel-lint": "^1.2",
27 | "phpstan/phpstan": "^0.12",
28 | "phpstan/phpstan-deprecation-rules": "^0.12",
29 | "phpstan/phpstan-strict-rules": "^0.12",
30 | "phpunit/phpunit": "^9.4",
31 | "roave/security-advisories": "dev-latest",
32 | "slevomat/coding-standard": "dev-master",
33 | "spaceemotion/php-coding-standard": "dev-master",
34 | "spaze/phpstan-disallowed-calls": "^1.5",
35 | "symplify/easy-coding-standard": "^9.3",
36 | "thecodingmachine/phpstan-safe-rule": "^1.0",
37 | "thecodingmachine/phpstan-strict-rules": "^0.12"
38 | },
39 | "minimum-stability": "dev",
40 | "prefer-stable": true,
41 | "autoload": {
42 | "psr-4": {
43 | "Dgame\\DataTransferObject\\": "src/"
44 | }
45 | },
46 | "autoload-dev": {
47 | "psr-4": {
48 | "Dgame\\DataTransferObject\\Tests\\": "tests/"
49 | }
50 | },
51 | "config": {
52 | "allow-plugins": {
53 | "ergebnis/composer-normalize": true,
54 | "dealerdirect/phpcodesniffer-composer-installer": true
55 | },
56 | "optimize-autoloader": true,
57 | "platform": {
58 | "php": "8.0"
59 | },
60 | "preferred-install": "dist",
61 | "process-timeout": 0,
62 | "sort-packages": true
63 | },
64 | "scripts": {
65 | "coverage": "phpunit --coverage-clover=coverage",
66 | "lint": "phpcstd --continue",
67 | "lint:fix": "phpcstd --fix --continue",
68 | "lint:fix-style": "ecs --fix",
69 | "lint:static": "phpstan",
70 | "lint:style": "ecs",
71 | "test": "phpunit"
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/tests/ArrayTypeTest.php:
--------------------------------------------------------------------------------
1 | assertEquals($expected, $stub);
28 | }
29 |
30 | /**
31 | * @param array $input
32 | * @param string|null $expectedMessage
33 | *
34 | * @throws ReflectionException
35 | * @throws Throwable
36 | * @dataProvider provideArrayWrongTypeData
37 | */
38 | public function testArrayWrongType(array $input, string $expectedMessage = null): void
39 | {
40 | $this->expectException(ValidationException::class);
41 | $this->expectExceptionMessageMatches($expectedMessage);
42 |
43 | ArrayGenericTypeStub::from($input);
44 | }
45 |
46 | public function provideArrayTypeData(): iterable
47 | {
48 | yield [['names' => [], 'ids' => [[1]]], new ArrayGenericTypeStub(names: [], ids: [[1]])];
49 | yield [['names' => [''], 'ids' => [[1, 2]]], new ArrayGenericTypeStub(names: [''], ids: [[1, 2]])];
50 | yield [['names' => ['foo'], 'ids' => [[1, 2]]], new ArrayGenericTypeStub(names: ['foo'], ids: [[1, 2]])];
51 | yield [['names' => ['foo', 'bar'], 'ids' => [[1, 2]]], new ArrayGenericTypeStub(names: ['foo', 'bar'], ids: [[1, 2]])];
52 | yield [['names' => ['a', 'b', 'c'], 'ids' => [[1, 2]]], new ArrayGenericTypeStub(names: ['a', 'b', 'c'], ids: [[1, 2]])];
53 | }
54 |
55 | public function provideArrayWrongTypeData(): iterable
56 | {
57 | yield [['names' => null], '/Cannot assign null to non-nullable array/'];
58 | yield [['names' => [1]], '/Cannot assign array array \(.+?\) to array/s'];
59 | yield [['names' => [1, 2]], '/Cannot assign array array \(.+?\) to array/s'];
60 | yield [['names' => [1, 2, 3]], '/Cannot assign array array \(.+?\) to array/s'];
61 | yield [['names' => [], 'ids' => [1]], '/Cannot assign array array \(.+?\) to array>/s'];
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/DataTransferValue.php:
--------------------------------------------------------------------------------
1 | applyTransformations();
26 | $this->tryResolvingIntoObject();
27 | $this->validate($validationStrategy);
28 | }
29 |
30 | public function getValue(): mixed
31 | {
32 | return $this->value;
33 | }
34 |
35 | private function applyTransformations(): void
36 | {
37 | foreach ($this->property->getAttributes(Transformation::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
38 | /** @var Transformation $transformation */
39 | $transformation = $attribute->newInstance();
40 | $this->value = $transformation->transform($this->value, $this->property);
41 | }
42 | }
43 |
44 | /**
45 | * @throws ReflectionException
46 | * @throws Throwable
47 | */
48 | private function tryResolvingIntoObject(): void
49 | {
50 | if (!is_array($this->value)) {
51 | return;
52 | }
53 |
54 | $type = $this->property->getType();
55 | if ($type === null) {
56 | return;
57 | }
58 |
59 | if (!($type instanceof ReflectionNamedType)) {
60 | return;
61 | }
62 |
63 | if ($type->isBuiltin()) {
64 | return;
65 | }
66 |
67 | $typeName = $type->getName();
68 | if (!class_exists(class: $typeName, autoload: true)) {
69 | return;
70 | }
71 |
72 | $dto = new DataTransferObject($typeName);
73 | $dto->from($this->value);
74 | $this->value = $dto->getInstance();
75 | }
76 |
77 | private function validate(ValidationStrategy $validationStrategy): void
78 | {
79 | $typeChecked = false;
80 | foreach ($this->property->getAttributes(Validation::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
81 | /** @var Validation $validation */
82 | $validation = $attribute->newInstance();
83 | $validation->validate($this->value, $validationStrategy);
84 |
85 | $typeChecked = $typeChecked || $validation instanceof Type;
86 | }
87 |
88 | if ($typeChecked || $validationStrategy->hasFailures()) {
89 | return;
90 | }
91 |
92 | $type = $this->property->getType();
93 | if ($type instanceof ReflectionNamedType) {
94 | Type::from($type)->validate($this->value, $validationStrategy);
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/tests/BooleanTest.php:
--------------------------------------------------------------------------------
1 | assertEquals($expceted, $stub);
27 | }
28 |
29 | public function provideBooleans(): iterable
30 | {
31 | yield [['yes' => 'yes'], (static function(): BooleanStub {
32 | $stub = new BooleanStub();
33 | $stub->yes = true;
34 |
35 | return $stub;
36 | })()];
37 |
38 | yield [['no' => 'no'], (static function(): BooleanStub {
39 | $stub = new BooleanStub();
40 | $stub->no = false;
41 |
42 | return $stub;
43 | })()];
44 |
45 | yield [['on' => 'on'], (static function(): BooleanStub {
46 | $stub = new BooleanStub();
47 | $stub->on = true;
48 |
49 | return $stub;
50 | })()];
51 |
52 | yield [['off' => 'off'], (static function(): BooleanStub {
53 | $stub = new BooleanStub();
54 | $stub->off = false;
55 |
56 | return $stub;
57 | })()];
58 |
59 | yield [['one' => 1], (static function(): BooleanStub {
60 | $stub = new BooleanStub();
61 | $stub->one = true;
62 |
63 | return $stub;
64 | })()];
65 |
66 | yield [['zero' => 0], (static function(): BooleanStub {
67 | $stub = new BooleanStub();
68 | $stub->zero = false;
69 |
70 | return $stub;
71 | })()];
72 |
73 | yield [['yes' => true], (static function(): BooleanStub {
74 | $stub = new BooleanStub();
75 | $stub->yes = true;
76 |
77 | return $stub;
78 | })()];
79 |
80 | yield [['zero' => false], (static function(): BooleanStub {
81 | $stub = new BooleanStub();
82 | $stub->zero = false;
83 |
84 | return $stub;
85 | })()];
86 |
87 | yield [['yes' => 'true'], (static function(): BooleanStub {
88 | $stub = new BooleanStub();
89 | $stub->yes = true;
90 |
91 | return $stub;
92 | })()];
93 |
94 | yield [['zero' => 'false'], (static function(): BooleanStub {
95 | $stub = new BooleanStub();
96 | $stub->zero = false;
97 |
98 | return $stub;
99 | })()];
100 |
101 | yield [['one' => '1'], (static function(): BooleanStub {
102 | $stub = new BooleanStub();
103 | $stub->one = true;
104 |
105 | return $stub;
106 | })()];
107 |
108 | yield [['zero' => '0'], (static function(): BooleanStub {
109 | $stub = new BooleanStub();
110 | $stub->zero = false;
111 |
112 | return $stub;
113 | })()];
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/tests/MatchesTest.php:
--------------------------------------------------------------------------------
1 | assertEquals($expected, $stub);
28 | }
29 |
30 | /**
31 | * @param array $input
32 | *
33 | * @throws ReflectionException
34 | * @throws Throwable
35 | *
36 | * @dataProvider provideNotMatches
37 | */
38 | public function testNotMatch(array $input): void
39 | {
40 | $this->expectException(ValidationException::class);
41 | MatchesStub::from($input);
42 | }
43 |
44 | public function provideMatches(): iterable
45 | {
46 | yield [['name' => 'Dagobert Duck'], (static function (): MatchesStub {
47 | $stub = new MatchesStub();
48 | $stub->name = 'Dagobert Duck';
49 |
50 | return $stub;
51 | })()];
52 |
53 | yield [['name' => ' Daisy Duck '], (static function (): MatchesStub {
54 | $stub = new MatchesStub();
55 | $stub->name = 'Daisy Duck';
56 |
57 | return $stub;
58 | })()];
59 |
60 | yield [['name' => ' Foo'], (static function (): MatchesStub {
61 | $stub = new MatchesStub();
62 | $stub->name = 'Foo';
63 |
64 | return $stub;
65 | })()];
66 |
67 | yield [['age' => 99], (static function (): MatchesStub {
68 | $stub = new MatchesStub();
69 | $stub->age = 99;
70 |
71 | return $stub;
72 | })()];
73 |
74 | yield [['age' => 19], (static function (): MatchesStub {
75 | $stub = new MatchesStub();
76 | $stub->age = 19;
77 |
78 | return $stub;
79 | })()];
80 |
81 | yield [['age' => 10], (static function (): MatchesStub {
82 | $stub = new MatchesStub();
83 | $stub->age = 10;
84 |
85 | return $stub;
86 | })()];
87 | }
88 |
89 | public function provideNotMatches(): iterable
90 | {
91 | yield [['name' => 'A '], (static function (): MatchesStub {
92 | $stub = new MatchesStub();
93 | $stub->name = 'A';
94 |
95 | return $stub;
96 | })()];
97 |
98 | yield [['name' => ' '], (static function (): MatchesStub {
99 | $stub = new MatchesStub();
100 | $stub->name = '';
101 |
102 | return $stub;
103 | })()];
104 |
105 | yield [['age' => 100], (static function (): MatchesStub {
106 | $stub = new MatchesStub();
107 | $stub->age = 100;
108 |
109 | return $stub;
110 | })()];
111 |
112 | yield [['age' => 9], (static function (): MatchesStub {
113 | $stub = new MatchesStub();
114 | $stub->age = 9;
115 |
116 | return $stub;
117 | })()];
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/tests/CastTest.php:
--------------------------------------------------------------------------------
1 | expectExceptionObject($exception);
30 | }
31 |
32 | $stub = $expected::from($input);
33 | $this->assertEquals($expected, $stub);
34 | }
35 |
36 | public function provideCastData(): iterable
37 | {
38 | yield [['id' => 42], new CastStub(id: 42)];
39 | yield [['id' => true], new CastStub(id: 1)];
40 | yield [['id' => false], new CastStub(id: 0)];
41 | yield [['id' => '43'], new CastStub(id: 43)];
42 | yield [['id' => '43a'], new CastStub(id: 43)];
43 | yield [['id' => ' 43a'], new CastStub(id: 43)];
44 | yield [['id' => '-43a'], new CastStub(id: -43)];
45 | yield [['id' => 3.14], new CastStub(id: 3)];
46 |
47 | yield [['age' => 42], new CastStub(age: 42)];
48 | yield [['age' => true], new CastStub(age: 1)];
49 | yield [['age' => false], new CastStub(age: 0)];
50 | yield [['age' => '43'], new CastStub(age: 43)];
51 | yield [['age' => '43a'], new CastStub(age: 43)];
52 | yield [['age' => ' 43a'], new CastStub(age: 43)];
53 | yield [['age' => '-43a'], new CastStub(age: -43)];
54 | yield [['age' => 3.14], new CastStub(age: 3)];
55 | yield [['age' => null], new CastStub(age: null)];
56 |
57 | yield [['uid' => 42], new CastStub(uid: 42)];
58 | yield [['uid' => true], new CastStub(uid: 1)];
59 | yield [['uid' => false], new CastStub(uid: 0)];
60 | yield [['uid' => '43'], new CastStub(uid: 43)];
61 | yield [['uid' => '43a'], new CastStub(uid: 43)];
62 | yield [['uid' => ' 43a'], new CastStub(uid: 43)];
63 | yield [['uid' => '-43a'], new CastStub(uid: -43)];
64 | yield [['uid' => 3.14], new CastStub(uid: 3.14)];
65 | yield [['uid' => '3.14'], new CastStub(uid: 3)];
66 | yield [['uid' => null], new CastStub(uid: null)];
67 |
68 | yield [['age' => 42], new StrictCastStub(age: 42)];
69 | yield [['age' => true], new StrictCastStub(), new InvalidArgumentException("Only float is accepted, bool (true) given.")];
70 | yield [['age' => false], new StrictCastStub(), new InvalidArgumentException("Only float is accepted, bool (false) given.")];
71 | yield [['age' => '43'], new StrictCastStub(), new InvalidArgumentException("Only float is accepted, string ('43') given.")];
72 | yield [['age' => '43a'], new StrictCastStub(), new InvalidArgumentException("Only float is accepted, string ('43a') given.")];
73 | yield [['age' => ' 43a'], new StrictCastStub(), new InvalidArgumentException("Only float is accepted, string (' 43a') given.")];
74 | yield [['age' => '-43a'], new StrictCastStub(), new InvalidArgumentException("Only float is accepted, string ('-43a') given.")];
75 | yield [['age' => 3.14], new StrictCastStub(age: 3)];
76 | yield [['age' => null], new StrictCastStub(age: null)];
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/Annotation/Path.php:
--------------------------------------------------------------------------------
1 | key;
26 | }
27 |
28 | /**
29 | * @throws PcreException
30 | */
31 | public function extract(mixed $value): mixed
32 | {
33 | if (!is_array($value)) {
34 | return $value;
35 | }
36 |
37 | return $this->get($this->path, $value);
38 | }
39 |
40 | /**
41 | * @param string $path
42 | * @param array $input
43 | *
44 | * @return mixed
45 | * @throws PcreException
46 | */
47 | private function get(string $path, array $input): mixed
48 | {
49 | if ($input === []) {
50 | return null;
51 | }
52 |
53 | $pathSteps = $this->split($path);
54 | if ($this->key === null) {
55 | $this->key = key($pathSteps);
56 | }
57 |
58 | $output = $input;
59 | foreach ($pathSteps as $i => $step) {
60 | // Wildcard
61 | if ($step === self::WILDCARD) {
62 | $stepsLeft = array_slice($pathSteps, $i + 1);
63 | if (count($stepsLeft) === 0) {
64 | return $output;
65 | }
66 |
67 | $nestedPath = $this->assemble($stepsLeft);
68 | $results = [];
69 | foreach ($output as $value) {
70 | $results[] = $this->get($nestedPath, $value);
71 | }
72 |
73 | return $results;
74 | }
75 |
76 | // Multiselect
77 | if (\Safe\preg_match('/^{(.*?)}$/S', $step, $matches) === 1) {
78 | [, $subSteps] = $matches;
79 |
80 | $results = [];
81 | foreach (explode(',', $subSteps) as $subStep) {
82 | $subStep = trim($subStep);
83 | $results[$subStep] = $this->get($subStep, $output);
84 | }
85 |
86 | return $results;
87 | }
88 |
89 | if (!array_key_exists($step, $output)) {
90 | return null;
91 | }
92 |
93 | $output = $output[$step];
94 | }
95 |
96 | return $output;
97 | }
98 |
99 | /**
100 | * @param string $path
101 | *
102 | * @return string[]
103 | */
104 | private function split(string $path): array
105 | {
106 | if ($path === '') {
107 | throw new InvalidArgumentException('Path can\'t be empty.');
108 | }
109 |
110 | if (str_contains($path, '{')) {
111 | if (!str_contains($path, '}')) {
112 | throw new InvalidArgumentException('Multimatch syntax not closed');
113 | }
114 |
115 | if (strpos($path, '}') !== strlen($path) - 1) {
116 | throw new InvalidArgumentException('Multimatch must be used at the end of path');
117 | }
118 | }
119 |
120 | if (str_starts_with($path, '{') && str_contains($path, '}')) {
121 | return [$path];
122 | }
123 |
124 | return explode(self::SEPARATOR, $path);
125 | }
126 |
127 | /**
128 | * @param string[] $steps
129 | *
130 | * @return string
131 | */
132 | private function assemble(array $steps): string
133 | {
134 | return implode(self::SEPARATOR, $steps);
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/tests/NumericTest.php:
--------------------------------------------------------------------------------
1 | assertEquals($expected, $stub);
28 | }
29 |
30 | /**
31 | * @param array $input
32 | *
33 | * @throws ReflectionException
34 | * @throws Throwable
35 | *
36 | * @dataProvider provideNonNumeric
37 | */
38 | public function testNonNumeric(array $input): void
39 | {
40 | $this->expectException(ValidationException::class);
41 | NumericStub::from($input);
42 | }
43 |
44 | public function provideNumeric(): iterable
45 | {
46 | yield [['int' => '123'], (static function (): NumericStub {
47 | $stub = new NumericStub();
48 | $stub->int = 123;
49 |
50 | return $stub;
51 | })()];
52 |
53 | yield [['int' => ' -4'], (static function (): NumericStub {
54 | $stub = new NumericStub();
55 | $stub->int = -4;
56 |
57 | return $stub;
58 | })()];
59 |
60 | yield [['int' => true], (static function (): NumericStub {
61 | $stub = new NumericStub();
62 | $stub->int = 1;
63 |
64 | return $stub;
65 | })()];
66 |
67 | yield [['float' => '42'], (static function (): NumericStub {
68 | $stub = new NumericStub();
69 | $stub->float = 42.0;
70 |
71 | return $stub;
72 | })()];
73 |
74 | yield [['float' => '4.2'], (static function (): NumericStub {
75 | $stub = new NumericStub();
76 | $stub->float = 4.2;
77 |
78 | return $stub;
79 | })()];
80 |
81 | yield [['float' => (string) M_PI], (static function (): NumericStub {
82 | $stub = new NumericStub();
83 | $stub->float = M_PI;
84 |
85 | return $stub;
86 | })()];
87 | }
88 |
89 | public function provideNonNumeric(): iterable
90 | {
91 | yield [['int' => 'a123'], (static function (): NumericStub {
92 | $stub = new NumericStub();
93 | $stub->int = 123;
94 |
95 | return $stub;
96 | })()];
97 |
98 | yield [['int' => '123a'], (static function (): NumericStub {
99 | $stub = new NumericStub();
100 | $stub->int = 123;
101 |
102 | return $stub;
103 | })()];
104 |
105 | yield [['int' => ' '], (static function (): NumericStub {
106 | $stub = new NumericStub();
107 | $stub->int = 0;
108 |
109 | return $stub;
110 | })()];
111 |
112 | yield [['int' => ' - 4'], (static function (): NumericStub {
113 | $stub = new NumericStub();
114 | $stub->int = -4;
115 |
116 | return $stub;
117 | })()];
118 |
119 | yield [['int' => '4 2'], (static function (): NumericStub {
120 | $stub = new NumericStub();
121 | $stub->int = 42;
122 |
123 | return $stub;
124 | })()];
125 |
126 | yield [['int' => M_PI], (static function (): NumericStub {
127 | $stub = new NumericStub();
128 | $stub->int = 3;
129 |
130 | return $stub;
131 | })()];
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/src/DataTransferObject.php:
--------------------------------------------------------------------------------
1 |
25 | */
26 | private ReflectionClass $reflection;
27 | /**
28 | * @var T
29 | */
30 | private object $object;
31 | private ?ReflectionMethod $constructor;
32 | private ValidationStrategy $validationStrategy;
33 |
34 | /**
35 | * @param class-string $class
36 | *
37 | * @throws ReflectionException
38 | */
39 | public function __construct(string $class)
40 | {
41 | $this->reflection = new ReflectionClass($class);
42 | $this->constructor = $this->reflection->getConstructor();
43 | $this->createInstance();
44 | $this->createValidationStrategy();
45 | }
46 |
47 | /**
48 | * @param array $input
49 | *
50 | * @throws Throwable
51 | */
52 | public function from(array &$input): void
53 | {
54 | $this->validationStrategy->pushPath($this->reflection->getShortName());
55 |
56 | foreach ($this->reflection->getProperties() as $property) {
57 | $this->validationStrategy->pushPath($property->getName());
58 |
59 | $dtp = new DataTransferProperty($property, $this);
60 | if ($dtp->isIgnored()) {
61 | $dtp->ignoreIn($input);
62 | } else {
63 | $dtp->setValueFrom($input);
64 | }
65 |
66 | $this->validationStrategy->popPath();
67 | }
68 |
69 | $this->validationStrategy->popPath();
70 |
71 | $this->finalize($input);
72 | }
73 |
74 | /**
75 | * @param array $input
76 | *
77 | * @throws ReflectionException
78 | */
79 | private function finalize(array $input): void
80 | {
81 | $this->validationStrategy->handle();
82 |
83 | foreach ($this->reflection->getAttributes(Finalize::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
84 | /** @var Finalize $finalize */
85 | $finalize = $attribute->newInstance();
86 | $finalize->finalize($input);
87 | }
88 |
89 | foreach ($this->reflection->getAttributes(SelfValidation::class) as $attribute) {
90 | /** @var SelfValidation $validation */
91 | $validation = $attribute->newInstance();
92 | $method = $this->reflection->getMethod($validation->getMethod());
93 | $method->invoke($this->object);
94 | }
95 | }
96 |
97 | /**
98 | * @return T
99 | */
100 | public function getInstance(): object
101 | {
102 | return $this->object;
103 | }
104 |
105 | public function getConstructor(): ?ReflectionMethod
106 | {
107 | return $this->constructor;
108 | }
109 |
110 | public function getValidationStrategy(): ValidationStrategy
111 | {
112 | return $this->validationStrategy;
113 | }
114 |
115 | /**
116 | * @throws ReflectionException
117 | */
118 | private function createInstance(): void
119 | {
120 | if ($this->constructor === null || $this->constructor->getNumberOfRequiredParameters() === 0) {
121 | $this->object = $this->reflection->newInstance();
122 | } else {
123 | $this->object = $this->reflection->newInstanceWithoutConstructor();
124 | }
125 | }
126 |
127 | private function createValidationStrategy(): void
128 | {
129 | foreach ($this->reflection->getAttributes(ValidationStrategy::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
130 | /** @var ValidationStrategy $validationStrategy */
131 | $validationStrategy = $attribute->newInstance();
132 | $this->validationStrategy = $validationStrategy;
133 | break;
134 | }
135 |
136 | $this->validationStrategy ??= new ValidationStrategy(
137 | collection: new FailureCollection(),
138 | handler: new FailureHandler(),
139 | failFast: true
140 | );
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/src/Annotation/Cast.php:
--------------------------------------------------------------------------------
1 | class !== null) {
36 | $this->method ??= '__invoke';
37 |
38 | $refl = new ReflectionClass($this->class);
39 | if (!$refl->hasMethod($this->method)) {
40 | throw new InvalidArgumentException('Class ' . $this->class . ' needs to implement ' . $this->method);
41 | }
42 | }
43 |
44 | foreach ($types as $type) {
45 | $this->types[] = PhpType::fromName($type);
46 | }
47 | }
48 |
49 | public function transform(mixed $value, ReflectionProperty $property): mixed
50 | {
51 | $propertyType = $this->getType($property);
52 | if ($propertyType->accept($value)) {
53 | return $this->cast($value);
54 | }
55 |
56 | $this->validate($value);
57 |
58 | if (!($propertyType instanceof Castable) && (!($propertyType instanceof UnionType) || !$propertyType->isCastable())) {
59 | throw new InvalidArgumentException('Cannot cast to type ' . $propertyType->getName() . ' with value ' . var_export($value, true));
60 | }
61 |
62 | return $propertyType->cast($this->cast($value));
63 | }
64 |
65 | private function getType(ReflectionProperty $property): PhpType
66 | {
67 | $reflectionType = $property->getType();
68 | if ($reflectionType instanceof ReflectionNamedType) {
69 | return PhpType::fromReflectionType($reflectionType);
70 | }
71 |
72 | if ($reflectionType instanceof ReflectionUnionType) {
73 | $typeName = implode(
74 | '|',
75 | array_map(
76 | static fn(ReflectionNamedType $type) => $type->getName(),
77 | $reflectionType->getTypes()
78 | )
79 | );
80 |
81 | return PhpType::fromName($typeName);
82 | }
83 |
84 | throw new InvalidArgumentException('Cannot cast to unknown type');
85 | }
86 |
87 | private function validate(mixed $value): void
88 | {
89 | if ($this->types === []) {
90 | return;
91 | }
92 |
93 | $other = PhpType::fromValue($value);
94 | foreach ($this->types as $type) {
95 | if ($type instanceof $other) {
96 | return;
97 | }
98 | }
99 |
100 | throw new InvalidArgumentException(
101 | \Safe\sprintf(
102 | 'Only %s %s accepted, %s (%s) given.',
103 | implode(' and ', array_map(static fn(PhpType $type) => $type->getName(), $this->types)),
104 | count($this->types) === 1 ? 'is' : 'are',
105 | $other->getName(),
106 | var_export($value, true)
107 | )
108 | );
109 | }
110 |
111 | private function cast(mixed $value): mixed
112 | {
113 | if ($this->method === null) {
114 | return $value;
115 | }
116 |
117 | if ($this->class === null) {
118 | if (!is_callable($this->method)) {
119 | throw new InvalidArgumentException('Need an callable, not ' . $this->method);
120 | }
121 |
122 | return ($this->method)($value);
123 | }
124 |
125 | $refl = new ReflectionClass($this->class);
126 | if (!$refl->hasMethod($this->method)) {
127 | throw new InvalidArgumentException('Class ' . $this->class . ' needs to implement ' . $this->method);
128 | }
129 |
130 | $method = $refl->getMethod($this->method);
131 | if ($method->isStatic()) {
132 | return $method->invoke(null, $value);
133 | }
134 |
135 | return $method->invoke($refl->newInstance(), $value);
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/src/DataTransferProperty.php:
--------------------------------------------------------------------------------
1 | $parent
41 | *
42 | * @throws ReflectionException
43 | */
44 | public function __construct(private ReflectionProperty $property, DataTransferObject $parent)
45 | {
46 | $this->validationStrategy = $parent->getValidationStrategy();
47 |
48 | if (version_compare(PHP_VERSION, '8.1') < 0) {
49 | $property->setAccessible(true);
50 | }
51 |
52 | $this->ignore = $this->property->getAttributes(Ignore::class) !== [];
53 | $this->setNames();
54 |
55 | $this->instance = $parent->getInstance();
56 |
57 | if ($property->hasDefaultValue()) {
58 | $this->hasDefaultValue = true;
59 | $this->defaultValue = $property->getDefaultValue();
60 | } else {
61 | $parameter = $this->getPromotedConstructorParameter($parent->getConstructor(), $property->getName());
62 | if ($parameter !== null && $parameter->isOptional()) {
63 | $this->hasDefaultValue = true;
64 | $this->defaultValue = $parameter->getDefaultValue();
65 | } else {
66 | $this->hasDefaultValue = $property->getType()?->allowsNull() ?? false;
67 | $this->defaultValue = null;
68 | }
69 | }
70 | }
71 |
72 | public function isIgnored(): bool
73 | {
74 | return $this->ignore;
75 | }
76 |
77 | /**
78 | * @param array $input
79 | *
80 | * @throws Throwable
81 | */
82 | public function ignoreIn(array &$input): void
83 | {
84 | foreach ($this->names as $name) {
85 | if (!array_key_exists($name, $input)) {
86 | continue;
87 | }
88 |
89 | $this->handleRejected();
90 | unset($input[$name]);
91 | }
92 | }
93 |
94 | /**
95 | * @param array $input
96 | *
97 | * @throws Throwable
98 | */
99 | public function setValueFrom(array &$input): void
100 | {
101 | foreach ($this->names as $name) {
102 | $value = $this->handlePath($input);
103 | if ($value === null && !array_key_exists($name, $input)) {
104 | continue;
105 | }
106 |
107 | $this->handleRejected();
108 |
109 | if ($value === null) {
110 | $value = $input[$name];
111 | unset($input[$name]);
112 | }
113 |
114 | $value = new DataTransferValue($value, $this->property, $this->validationStrategy);
115 | $this->assign($value->getValue());
116 |
117 | return;
118 | }
119 |
120 | $this->handleRequired();
121 | $this->handleOptional();
122 | }
123 |
124 | /**
125 | * @param array $input
126 | *
127 | * @return mixed
128 | * @throws PcreException
129 | */
130 | private function handlePath(array &$input): mixed
131 | {
132 | foreach ($this->property->getAttributes(Path::class) as $attribute) {
133 | /** @var Path $path */
134 | $path = $attribute->newInstance();
135 | $value = $path->extract($input);
136 | $key = $path->getKey();
137 | if ($value !== null && $key !== null) {
138 | unset($input[$key]);
139 | }
140 |
141 | return $value;
142 | }
143 |
144 | return null;
145 | }
146 |
147 | private function handleRejected(): void
148 | {
149 | foreach ($this->property->getAttributes(Reject::class) as $attribute) {
150 | /** @var Reject $reject */
151 | $reject = $attribute->newInstance();
152 | $reject->execute();
153 | }
154 | }
155 |
156 | private function handleRequired(): void
157 | {
158 | foreach ($this->property->getAttributes(Required::class) as $attribute) {
159 | /** @var Required $required */
160 | $required = $attribute->newInstance();
161 | $required->execute();
162 | }
163 | }
164 |
165 | private function handleOptional(): void
166 | {
167 | if ($this->hasDefaultValue && $this->defaultValue !== null) {
168 | $this->assign($this->defaultValue);
169 |
170 | return;
171 | }
172 |
173 | foreach ($this->property->getAttributes(Optional::class) as $attribute) {
174 | /** @var Optional $optional */
175 | $optional = $attribute->newInstance();
176 | $value = $optional->getValue($this->property);
177 |
178 | $this->assign($value);
179 |
180 | return;
181 | }
182 |
183 | if (!$this->hasDefaultValue) {
184 | $this->handleMissingRequiredValue();
185 | } else {
186 | $this->assign($this->defaultValue);
187 | }
188 | }
189 |
190 | private function getPromotedConstructorParameter(?ReflectionMethod $constructor, string $name): ?ReflectionParameter
191 | {
192 | foreach ($constructor?->getParameters() ?? [] as $parameter) {
193 | if ($parameter->isPromoted() && $parameter->getName() === $name) {
194 | return $parameter;
195 | }
196 | }
197 |
198 | return null;
199 | }
200 |
201 | private function assign(mixed $value): void
202 | {
203 | $instance = $this->property->isStatic() ? null : $this->instance;
204 |
205 | $this->property->setValue($instance, $value);
206 | }
207 |
208 | private function setNames(): void
209 | {
210 | $names = [];
211 | foreach ($this->property->getAttributes(Name::class) as $attribute) {
212 | /** @var Name $name */
213 | $name = $attribute->newInstance();
214 | $names[$name->getName()] = true;
215 | }
216 |
217 | if ($names === []) {
218 | $names[$this->property->getName()] = true;
219 | }
220 |
221 | foreach ($this->property->getAttributes(Alias::class) as $attribute) {
222 | /** @var Alias $alias */
223 | $alias = $attribute->newInstance();
224 | $names[$alias->getName()] = true;
225 | }
226 |
227 | $this->names = array_keys($names);
228 | }
229 |
230 | private function handleMissingRequiredValue(): void
231 | {
232 | match (count($this->names)) {
233 | 0, 1 => $this->validationStrategy->setFailure('Expected a value for {path}'),
234 | default => $this->validationStrategy->setFailure('Expected one of "' . implode(', ', $this->names) . '"')
235 | };
236 | }
237 | }
238 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | indent_size = 4
7 | indent_style = space
8 | insert_final_newline = true
9 | max_line_length = 140
10 | tab_width = 4
11 | ij_continuation_indent_size = 8
12 | ij_formatter_off_tag = @formatter:off
13 | ij_formatter_on_tag = @formatter:on
14 | ij_formatter_tags_enabled = false
15 | ij_smart_tabs = false
16 | ij_visual_guides = none
17 | ij_wrap_on_typing = false
18 |
19 | [{*.ctp,*.hphp,*.inc,*.module,*.php,*.php4,*.php5,*.phtml}]
20 | ij_continuation_indent_size = 4
21 | ij_php_align_assignments = true
22 | ij_php_align_class_constants = false
23 | ij_php_align_group_field_declarations = false
24 | ij_php_align_inline_comments = false
25 | ij_php_align_key_value_pairs = true
26 | ij_php_align_multiline_array_initializer_expression = true
27 | ij_php_align_multiline_binary_operation = true
28 | ij_php_align_multiline_chained_methods = true
29 | ij_php_align_multiline_extends_list = true
30 | ij_php_align_multiline_for = true
31 | ij_php_align_multiline_parameters = true
32 | ij_php_align_multiline_parameters_in_calls = true
33 | ij_php_align_multiline_ternary_operation = true
34 | ij_php_align_phpdoc_comments = true
35 | ij_php_align_phpdoc_param_names = true
36 | ij_php_anonymous_brace_style = end_of_line
37 | ij_php_api_weight = 28
38 | ij_php_array_initializer_new_line_after_left_brace = false
39 | ij_php_array_initializer_right_brace_on_new_line = false
40 | ij_php_array_initializer_wrap = off
41 | ij_php_assignment_wrap = off
42 | ij_php_attributes_wrap = off
43 | ij_php_author_weight = 28
44 | ij_php_binary_operation_sign_on_next_line = false
45 | ij_php_binary_operation_wrap = off
46 | ij_php_blank_lines_after_class_header = 0
47 | ij_php_blank_lines_after_function = 1
48 | ij_php_blank_lines_after_imports = 1
49 | ij_php_blank_lines_after_opening_tag = 0
50 | ij_php_blank_lines_after_package = 1
51 | ij_php_blank_lines_around_class = 1
52 | ij_php_blank_lines_around_constants = 0
53 | ij_php_blank_lines_around_field = 0
54 | ij_php_blank_lines_around_method = 1
55 | ij_php_blank_lines_before_class_end = 0
56 | ij_php_blank_lines_before_imports = 1
57 | ij_php_blank_lines_before_method_body = 0
58 | ij_php_blank_lines_before_package = 1
59 | ij_php_blank_lines_before_return_statement = 1
60 | ij_php_blank_lines_between_imports = 0
61 | ij_php_block_brace_style = end_of_line
62 | ij_php_call_parameters_new_line_after_left_paren = false
63 | ij_php_call_parameters_right_paren_on_new_line = false
64 | ij_php_call_parameters_wrap = off
65 | ij_php_catch_on_new_line = false
66 | ij_php_category_weight = 28
67 | ij_php_class_brace_style = next_line
68 | ij_php_comma_after_last_array_element = false
69 | ij_php_concat_spaces = true
70 | ij_php_copyright_weight = 28
71 | ij_php_deprecated_weight = 28
72 | ij_php_do_while_brace_force = always
73 | ij_php_else_if_style = as_is
74 | ij_php_else_on_new_line = false
75 | ij_php_example_weight = 28
76 | ij_php_extends_keyword_wrap = off
77 | ij_php_extends_list_wrap = normal
78 | ij_php_fields_default_visibility = private
79 | ij_php_filesource_weight = 28
80 | ij_php_finally_on_new_line = false
81 | ij_php_for_brace_force = always
82 | ij_php_for_statement_new_line_after_left_paren = false
83 | ij_php_for_statement_right_paren_on_new_line = false
84 | ij_php_for_statement_wrap = off
85 | ij_php_force_short_declaration_array_style = true
86 | ij_php_getters_setters_naming_style = camel_case
87 | ij_php_getters_setters_order_style = getters_first
88 | ij_php_global_weight = 28
89 | ij_php_group_use_wrap = on_every_item
90 | ij_php_if_brace_force = always
91 | ij_php_if_lparen_on_next_line = false
92 | ij_php_if_rparen_on_next_line = false
93 | ij_php_ignore_weight = 28
94 | ij_php_import_sorting = alphabetic
95 | ij_php_indent_break_from_case = true
96 | ij_php_indent_case_from_switch = true
97 | ij_php_indent_code_in_php_tags = false
98 | ij_php_internal_weight = 28
99 | ij_php_keep_blank_lines_after_lbrace = 2
100 | ij_php_keep_blank_lines_before_right_brace = 0
101 | ij_php_keep_blank_lines_in_code = 1
102 | ij_php_keep_blank_lines_in_declarations = 1
103 | ij_php_keep_control_statement_in_one_line = false
104 | ij_php_keep_first_column_comment = false
105 | ij_php_keep_indents_on_empty_lines = false
106 | ij_php_keep_line_breaks = true
107 | ij_php_keep_rparen_and_lbrace_on_one_line = false
108 | ij_php_keep_simple_classes_in_one_line = false
109 | ij_php_keep_simple_methods_in_one_line = false
110 | ij_php_lambda_brace_style = end_of_line
111 | ij_php_license_weight = 28
112 | ij_php_line_comment_add_space = false
113 | ij_php_line_comment_at_first_column = true
114 | ij_php_link_weight = 28
115 | ij_php_lower_case_boolean_const = true
116 | ij_php_lower_case_keywords = true
117 | ij_php_lower_case_null_const = true
118 | ij_php_method_brace_style = next_line
119 | ij_php_method_call_chain_wrap = off
120 | ij_php_method_parameters_new_line_after_left_paren = false
121 | ij_php_method_parameters_right_paren_on_new_line = false
122 | ij_php_method_parameters_wrap = off
123 | ij_php_method_weight = 28
124 | ij_php_modifier_list_wrap = false
125 | ij_php_multiline_chained_calls_semicolon_on_new_line = false
126 | ij_php_namespace_brace_style = 1
127 | ij_php_new_line_after_php_opening_tag = false
128 | ij_php_null_type_position = in_the_end
129 | ij_php_package_weight = 28
130 | ij_php_param_weight = 0
131 | ij_php_parameters_attributes_wrap = off
132 | ij_php_parentheses_expression_new_line_after_left_paren = false
133 | ij_php_parentheses_expression_right_paren_on_new_line = false
134 | ij_php_phpdoc_blank_line_before_tags = false
135 | ij_php_phpdoc_blank_lines_around_parameters = true
136 | ij_php_phpdoc_keep_blank_lines = true
137 | ij_php_phpdoc_param_spaces_between_name_and_description = 1
138 | ij_php_phpdoc_param_spaces_between_tag_and_type = 1
139 | ij_php_phpdoc_param_spaces_between_type_and_name = 1
140 | ij_php_phpdoc_use_fqcn = false
141 | ij_php_phpdoc_wrap_long_lines = true
142 | ij_php_place_assignment_sign_on_next_line = false
143 | ij_php_place_parens_for_constructor = 1
144 | ij_php_property_read_weight = 28
145 | ij_php_property_weight = 28
146 | ij_php_property_write_weight = 28
147 | ij_php_return_type_on_new_line = false
148 | ij_php_return_weight = 1
149 | ij_php_see_weight = 28
150 | ij_php_since_weight = 28
151 | ij_php_sort_phpdoc_elements = true
152 | ij_php_space_after_colon = true
153 | ij_php_space_after_colon_in_named_argument = true
154 | ij_php_space_after_colon_in_return_type = true
155 | ij_php_space_after_comma = true
156 | ij_php_space_after_for_semicolon = true
157 | ij_php_space_after_quest = true
158 | ij_php_space_after_type_cast = true
159 | ij_php_space_after_unary_not = false
160 | ij_php_space_before_array_initializer_left_brace = false
161 | ij_php_space_before_catch_keyword = true
162 | ij_php_space_before_catch_left_brace = true
163 | ij_php_space_before_catch_parentheses = true
164 | ij_php_space_before_class_left_brace = true
165 | ij_php_space_before_closure_left_parenthesis = true
166 | ij_php_space_before_colon = true
167 | ij_php_space_before_colon_in_named_argument = false
168 | ij_php_space_before_colon_in_return_type = false
169 | ij_php_space_before_comma = false
170 | ij_php_space_before_do_left_brace = true
171 | ij_php_space_before_else_keyword = true
172 | ij_php_space_before_else_left_brace = true
173 | ij_php_space_before_finally_keyword = true
174 | ij_php_space_before_finally_left_brace = true
175 | ij_php_space_before_for_left_brace = true
176 | ij_php_space_before_for_parentheses = true
177 | ij_php_space_before_for_semicolon = false
178 | ij_php_space_before_if_left_brace = true
179 | ij_php_space_before_if_parentheses = true
180 | ij_php_space_before_method_call_parentheses = false
181 | ij_php_space_before_method_left_brace = true
182 | ij_php_space_before_method_parentheses = false
183 | ij_php_space_before_quest = true
184 | ij_php_space_before_short_closure_left_parenthesis = false
185 | ij_php_space_before_switch_left_brace = true
186 | ij_php_space_before_switch_parentheses = true
187 | ij_php_space_before_try_left_brace = true
188 | ij_php_space_before_unary_not = false
189 | ij_php_space_before_while_keyword = true
190 | ij_php_space_before_while_left_brace = true
191 | ij_php_space_before_while_parentheses = true
192 | ij_php_space_between_ternary_quest_and_colon = false
193 | ij_php_spaces_around_additive_operators = true
194 | ij_php_spaces_around_arrow = false
195 | ij_php_spaces_around_assignment_in_declare = false
196 | ij_php_spaces_around_assignment_operators = true
197 | ij_php_spaces_around_bitwise_operators = true
198 | ij_php_spaces_around_equality_operators = true
199 | ij_php_spaces_around_logical_operators = true
200 | ij_php_spaces_around_multiplicative_operators = true
201 | ij_php_spaces_around_null_coalesce_operator = true
202 | ij_php_spaces_around_relational_operators = true
203 | ij_php_spaces_around_shift_operators = true
204 | ij_php_spaces_around_unary_operator = false
205 | ij_php_spaces_around_var_within_brackets = false
206 | ij_php_spaces_within_array_initializer_braces = false
207 | ij_php_spaces_within_brackets = false
208 | ij_php_spaces_within_catch_parentheses = false
209 | ij_php_spaces_within_for_parentheses = false
210 | ij_php_spaces_within_if_parentheses = false
211 | ij_php_spaces_within_method_call_parentheses = false
212 | ij_php_spaces_within_method_parentheses = false
213 | ij_php_spaces_within_parentheses = false
214 | ij_php_spaces_within_short_echo_tags = true
215 | ij_php_spaces_within_switch_parentheses = false
216 | ij_php_spaces_within_while_parentheses = false
217 | ij_php_special_else_if_treatment = true
218 | ij_php_subpackage_weight = 28
219 | ij_php_ternary_operation_signs_on_next_line = false
220 | ij_php_ternary_operation_wrap = off
221 | ij_php_throws_weight = 2
222 | ij_php_todo_weight = 28
223 | ij_php_unknown_tag_weight = 28
224 | ij_php_upper_case_boolean_const = false
225 | ij_php_upper_case_null_const = false
226 | ij_php_uses_weight = 28
227 | ij_php_var_weight = 28
228 | ij_php_variable_naming_style = camel_case
229 | ij_php_version_weight = 28
230 | ij_php_while_brace_force = always
231 | ij_php_while_on_new_line = false
232 |
233 | [{*.har,*.jsb2,*.jsb3,*.json,*.lock,.babelrc,.eslintrc,.stylelintrc,bowerrc,jest.config}]
234 | ij_json_keep_blank_lines_in_code = 0
235 | ij_json_keep_indents_on_empty_lines = false
236 | ij_json_keep_line_breaks = true
237 | ij_json_space_after_colon = true
238 | ij_json_space_after_comma = true
239 | ij_json_space_before_colon = true
240 | ij_json_space_before_comma = false
241 | ij_json_spaces_within_braces = false
242 | ij_json_spaces_within_brackets = false
243 | ij_json_wrap_long_lines = false
244 |
245 | [{*.markdown,*.md}]
246 | ij_markdown_force_one_space_after_blockquote_symbol = true
247 | ij_markdown_force_one_space_after_header_symbol = true
248 | ij_markdown_force_one_space_after_list_bullet = true
249 | ij_markdown_force_one_space_between_words = true
250 | ij_markdown_keep_indents_on_empty_lines = false
251 | ij_markdown_max_lines_around_block_elements = 1
252 | ij_markdown_max_lines_around_header = 1
253 | ij_markdown_max_lines_between_paragraphs = 1
254 | ij_markdown_min_lines_around_block_elements = 1
255 | ij_markdown_min_lines_around_header = 1
256 | ij_markdown_min_lines_between_paragraphs = 1
257 |
258 | [{*.yaml,*.yml}]
259 | indent_size = 2
260 | ij_yaml_align_values_properties = do_not_align
261 | ij_yaml_autoinsert_sequence_marker = true
262 | ij_yaml_block_mapping_on_new_line = false
263 | ij_yaml_indent_sequence_value = true
264 | ij_yaml_keep_indents_on_empty_lines = false
265 | ij_yaml_keep_line_breaks = true
266 | ij_yaml_sequence_on_new_line = false
267 | ij_yaml_space_before_colon = false
268 | ij_yaml_spaces_within_braces = true
269 | ij_yaml_spaces_within_brackets = true
270 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Data Transfer Object
2 |
3 | Want to deserialize an object with data on the fly? Go for it by using the `From` trait.
4 |
5 | ---
6 |
7 | How is this package any different from [spaties](https://github.com/spatie) popular [data-transfer-object](https://github.com/spatie/data-transfer-object), you may ask?
8 | Well, it's not meant to be a replacement by any means. But while using it I've often come across some things I've missed since I knew them from [serde](https://serde.rs/), like renaming and ignoring properties, something that spatie's _data-transfer-object_ [might not get](https://github.com/spatie/data-transfer-object/issues/142#issuecomment-690418112) in the near future.
9 | So there it is, my own little DTO package :) I hope it helps someone, as it helps me in my daily work.
10 | Feel free to open issues or pull requests - any help is greatly appreciated!
11 |
12 | ### Requirements
13 |
14 | This package is designed for PHP ≥ 8.0 only since it's using [PHP 8.0 Attributes](https://stitcher.io/blog/attributes-in-php-8).
15 |
16 | # Attributes
17 |
18 | ## Name
19 |
20 | You get a parameter which is not named as the parameter in your class? `#[Name(...)]` to the rescue - just specify the name from the Request:
21 |
22 | ```php
23 | use Dgame\DataTransferObject\Annotation\Name;
24 | use Dgame\DataTransferObject\DataTransfer;
25 |
26 | final class Limit
27 | {
28 | use DataTransfer;
29 |
30 | public int $offset;
31 | #[Name('size')]
32 | public int $limit;
33 | }
34 | ```
35 |
36 | Now the key `size` will be mapped to the property `$limit` - but keep in mind: the name `limit` is no longer known
37 | since you overwrote it with `size`. If that is not your intention, take a look at the [Alias](#alias) Attribute.
38 |
39 | ## Alias
40 |
41 | You get a parameter which is not **always** named as the parameter in your class? `#[Alias(...)]` can help you - just specify the alias from the Request:
42 |
43 | ```php
44 | use Dgame\DataTransferObject\Annotation\Alias;
45 | use Dgame\DataTransferObject\DataTransfer;
46 |
47 | final class Limit
48 | {
49 | use DataTransfer;
50 |
51 | public int $offset;
52 | #[Alias('size')]
53 | public int $limit;
54 | }
55 | ```
56 |
57 | Now the keys `size` **and** `limit` will be mapped to the property `$limit`. You can mix `#[Name(...)]` and `#[Alias(...)]` as you want:
58 |
59 | ```php
60 | use Dgame\DataTransferObject\Annotation\Alias;
61 | use Dgame\DataTransferObject\Annotation\Name;
62 | use Dgame\DataTransferObject\DataTransfer;
63 |
64 | final class Foo
65 | {
66 | use DataTransfer;
67 |
68 | #[Name('a')]
69 | #[Alias('z')]
70 | public int $id;
71 | }
72 | ```
73 |
74 | The keys `a` and `z` are mapped to the property `id` - but not the key `id` since you overwrote it with `a`. But the following
75 |
76 | ```php
77 | use Dgame\DataTransferObject\Annotation\Alias;
78 | use Dgame\DataTransferObject\DataTransfer;
79 |
80 | final class Foo
81 | {
82 | use DataTransfer;
83 |
84 | #[Alias('a')]
85 | #[Alias('z')]
86 | public int $id;
87 | }
88 | ```
89 |
90 | will accept the keys `a`, `z` and `id`.
91 |
92 | ## Transformations
93 |
94 | If you want to _transform_ a value **before** it is assigned to the property, you can use Transformations.
95 | You just need to implement the _Transformation_ interface.
96 |
97 | ### Cast
98 |
99 | _Cast_ is currently the only built-in Transformation and let you apply a Type-Cast **before** the value is assigned to the property:
100 |
101 | If not told otherwise, a simple type-cast is performed. In the example below it would just call something like `$this->id = (int) $id`:
102 |
103 | ```php
104 | use Dgame\DataTransferObject\Annotation\Cast;
105 |
106 | final class Foo
107 | {
108 | use DataTransfer;
109 |
110 | #[Cast]
111 | public int $id;
112 | }
113 | ```
114 |
115 | But that would be tried for **any** input. If you want to limit this to certain types, you can use `types`:
116 |
117 | ```php
118 | use Dgame\DataTransferObject\Annotation\Cast;
119 |
120 | final class Foo
121 | {
122 | use DataTransfer;
123 |
124 | #[Cast(types: ['string', 'float', 'bool'])]
125 | public int $id;
126 | }
127 | ```
128 |
129 | Here the cast would only be performed if the incoming value is either an `int`, `string`, `float` or `bool`.
130 |
131 | If you want more control, you can use a static method inside of the class:
132 |
133 | ```php
134 | use Dgame\DataTransferObject\Annotation\Cast;
135 |
136 | final class Foo
137 | {
138 | use DataTransfer;
139 |
140 | #[Cast(method: 'toInt', class: self::class)]
141 | public int $id;
142 |
143 | public static function toInt(string|int|float|bool $value): int
144 | {
145 | return (int) $value;
146 | }
147 | }
148 | ```
149 |
150 | or a function:
151 |
152 | ```php
153 | use Dgame\DataTransferObject\Annotation\Cast;
154 |
155 | function toInt(string|int|float|bool $value): int
156 | {
157 | return (int) $value;
158 | }
159 |
160 | final class Foo
161 | {
162 | use DataTransfer;
163 |
164 | #[Cast(method: 'toInt')]
165 | public int $id;
166 | }
167 | ```
168 |
169 | If a class is given but not a `method`, by default `__invoke` will be used:
170 |
171 | ```php
172 | use Dgame\DataTransferObject\Annotation\Cast;
173 |
174 | final class Foo
175 | {
176 | use DataTransfer;
177 |
178 | #[Cast(class: self::class)]
179 | public int $id;
180 |
181 | public function __invoke(string|int|float|bool $value): int
182 | {
183 | return (int) $value;
184 | }
185 | }
186 | ```
187 |
188 | ## Validation
189 |
190 | You want to validate the value before it is assigned? We can do that. There are a few pre-defined validations prepared, but you can easily write your own by implementing the `Validation`-interface.
191 |
192 | ### Min
193 |
194 | ```php
195 | use Dgame\DataTransferObject\Annotation\Min;
196 | use Dgame\DataTransferObject\DataTransfer;
197 |
198 | final class Limit
199 | {
200 | use DataTransfer;
201 |
202 | #[Min(0)]
203 | public int $offset;
204 | #[Min(0)]
205 | public int $limit;
206 | }
207 | ```
208 |
209 | Both `$offset` and `$limit` must be at least have the value `0` (so they must be positive-integers). If not, an exception is thrown. You can configure the message of the exception by specifying the `message` parameter:
210 |
211 | ```php
212 | use Dgame\DataTransferObject\Annotation\Min;
213 | use Dgame\DataTransferObject\DataTransfer;
214 |
215 | final class Limit
216 | {
217 | use DataTransfer;
218 |
219 | #[Min(0, message: 'Offset must be positive!')]
220 | public int $offset;
221 | #[Min(0, message: 'Limit must be positive!')]
222 | public int $limit;
223 | }
224 | ```
225 |
226 | ### Max
227 |
228 | ```php
229 | use Dgame\DataTransferObject\Annotation\Max;
230 | use Dgame\DataTransferObject\DataTransfer;
231 |
232 | final class Limit
233 | {
234 | use DataTransfer;
235 |
236 | #[Max(1000)]
237 | public int $offset;
238 | #[Max(1000)]
239 | public int $limit;
240 | }
241 | ```
242 |
243 | Both `$offset` and `$limit` may not exceed `1000`. If they do, an exception is thrown. You can configure the message of the exception by specifying the `message` parameter:
244 |
245 | ```php
246 | use Dgame\DataTransferObject\Annotation\Max;
247 | use Dgame\DataTransferObject\DataTransfer;
248 |
249 | final class Limit
250 | {
251 | use DataTransfer;
252 |
253 | #[Max(1000, message: 'Offset may not be larger than 1000')]
254 | public int $offset;
255 | #[Max(1000, message: 'Limit may not be larger than 1000')]
256 | public int $limit;
257 | }
258 | ```
259 |
260 | ### Instance
261 |
262 | Do you want to make sure that a property is an instance of a certain class or that each item in an array is an instance of that said class?
263 |
264 | ```php
265 | use Dgame\DataTransferObject\Annotation\Instance;
266 |
267 | final class Collection
268 | {
269 | #[Instance(class: Entity::class, message: 'We need an array of Entities!')]
270 | private array $entities;
271 | }
272 | ```
273 |
274 | ### Type
275 |
276 | If you are trying to cover objects or other class instances, you should probably take a look at [Instance](#instance).
277 |
278 | 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.
279 | Without this validation, a `TypeError` would be thrown, which may not be desirable.
280 |
281 | So this code
282 | ```php
283 | final class Foo
284 | {
285 | private ?int $id;
286 | }
287 | ```
288 |
289 | is actually seen as this:
290 | ```php
291 | use Dgame\DataTransferObject\Annotation\Type;
292 |
293 | final class Foo
294 | {
295 | #[Type(name: '?int')]
296 | private ?int $id;
297 | }
298 | ```
299 |
300 | The following snippets are equivalent to the snippet above:
301 |
302 | ```php
303 | use Dgame\DataTransferObject\Annotation\Type;
304 |
305 | final class Foo
306 | {
307 | #[Type(name: 'int|null')]
308 | private ?int $id;
309 | }
310 | ```
311 |
312 | ```php
313 | use Dgame\DataTransferObject\Annotation\Type;
314 |
315 | final class Foo
316 | {
317 | #[Type(name: 'int', allowsNull: true)]
318 | private ?int $id;
319 | }
320 | ```
321 |
322 | ---
323 |
324 | If you want to change the exception message, you can do so using the `message` parameter:
325 |
326 | ```php
327 | use Dgame\DataTransferObject\Annotation\Type;
328 |
329 | final class Foo
330 | {
331 | #[Type(name: '?int', message: 'id is expected to be int or null')]
332 | private ?int $id;
333 | }
334 | ```
335 |
336 | ### Custom
337 |
338 | Do you want your own Validation? Just implement the `Validation`-interface:
339 |
340 | ```php
341 | use Dgame\DataTransferObject\Annotation\Validation;
342 | use Dgame\DataTransferObject\DataTransfer;
343 |
344 | #[Attribute(Attribute::TARGET_PROPERTY)]
345 | final class NumberBetween implements Validation
346 | {
347 | public function __construct(private int|float $min, private int|float $max)
348 | {
349 | }
350 |
351 | public function validate(mixed $value): void
352 | {
353 | if (!is_numeric($value)) {
354 | throw new InvalidArgumentException(var_export($value, true) . ' must be a numeric value');
355 | }
356 |
357 | if ($value < $this->min) {
358 | throw new InvalidArgumentException(var_export($value, true) . ' must be >= ' . $this->min);
359 | }
360 |
361 | if ($value > $this->max) {
362 | throw new InvalidArgumentException(var_export($value, true) . ' must be <= ' . $this->max);
363 | }
364 | }
365 | }
366 |
367 | final class ValidationStub
368 | {
369 | use DataTransfer;
370 |
371 | #[NumberBetween(18, 125)]
372 | private int $age;
373 |
374 | public function getAge(): int
375 | {
376 | return $this->age;
377 | }
378 | }
379 | ```
380 |
381 | ## Ignore
382 |
383 | You don't want a specific key-value to override your property? Just ignore it:
384 |
385 | ```php
386 | use Dgame\DataTransferObject\Annotation\Ignore;
387 | use Dgame\DataTransferObject\DataTransfer;
388 |
389 | final class Foo
390 | {
391 | use DataTransfer;
392 |
393 | #[Ignore]
394 | public string $uuid = 'abc';
395 | public int $id = 0;
396 | }
397 |
398 | $foo = Foo::from(['uuid' => 'xyz', 'id' => 42]);
399 | echo $foo->id; // 42
400 | echo $foo->uuid; // abc
401 | ```
402 |
403 | ## Reject
404 |
405 | You want to go one step further than simply ignoring a value? Then `Reject` it:
406 |
407 | ```php
408 | use Dgame\DataTransferObject\Annotation\Reject;
409 | use Dgame\DataTransferObject\DataTransfer;
410 |
411 | final class Foo
412 | {
413 | use DataTransfer;
414 |
415 | #[Reject(reason: 'The attribute "uuid" is not supposed to be set')]
416 | public string $uuid = 'abc';
417 | }
418 |
419 | $foo = Foo::from(['id' => 42]); // Works fine
420 | echo $foo->id; // 42
421 | echo $foo->uuid; // abc
422 |
423 | $foo = Foo::from(['uuid' => 'xyz', 'id' => 42]); // throws 'The attribute "uuid" is not supposed to be set'
424 | ```
425 |
426 | ## Required
427 |
428 | Normally, a nullable-property or a property with a provided default value is treated with said default-value or null if the property cannot be assigned from the provided data.
429 | If no default-value is provided and the property is not nullable, an error is thrown in case the property was not found.
430 | But in some cases you might want to specify the reason, why the property is required or even want to require an otherwise default-able property. You can do that by using `Required`:
431 |
432 | ```php
433 | use Dgame\DataTransferObject\Annotation\Required;
434 | use Dgame\DataTransferObject\DataTransfer;
435 |
436 | final class Foo
437 | {
438 | use DataTransfer;
439 |
440 | #[Required(reason: 'We need an "id"')]
441 | public ?int $id;
442 |
443 | #[Required(reason: 'We need a "name"')]
444 | public string $name;
445 | }
446 |
447 | Foo::from(['id' => 42, 'name' => 'abc']); // Works
448 | Foo::from(['name' => 'abc']); // Fails but would work without the `Required`-Attribute since $id is nullable
449 | Foo::from(['id' => 42]); // Fails and would fail regardless of the `Required`-Attribute since $name is not nullable and has no default-value - but the reason why it is required is now more clear.
450 | ```
451 |
452 | ## Optional
453 |
454 | The counterpart of [Required](#required).
455 | If you don't want to or can't provide a default/nullable value, `Optional` will assign the **default value** of the property-type in case of a missing value:
456 |
457 | ```php
458 | final class Foo
459 | {
460 | use DataTransfer;
461 |
462 | #[Optional]
463 | public int $id;
464 | }
465 |
466 | $foo = Foo::from([]);
467 | assert($foo->id === 0);
468 | ```
469 |
470 | Of course you can specify which value should be used if no data is provided:
471 |
472 | ```php
473 | final class Foo
474 | {
475 | use DataTransfer;
476 |
477 | #[Optional(value: 42)]
478 | public int $id;
479 | }
480 |
481 | $foo = Foo::from([]);
482 | assert($foo->id === 42);
483 | ```
484 |
485 | In case you're using `Optional` together with a provided default-value, the default-value has always priority:
486 |
487 | ```php
488 | final class Foo
489 | {
490 | use DataTransfer;
491 |
492 | #[Optional(value: 42)]
493 | public int $id = 23;
494 | }
495 |
496 | $foo = Foo::from([]);
497 | assert($foo->id === 23);
498 | ```
499 |
500 | ## Numeric
501 |
502 | You have `int` or `float` properties but aren't sure if those aren't delivered as e.g. `string`? `Numeric` to the rescue! It will translate the value to a numeric representation (to `int` or `float`):
503 |
504 | ```php
505 | use Dgame\DataTransferObject\Annotation\Numeric;
506 |
507 | final class Foo
508 | {
509 | use DataTransfer;
510 |
511 | #[Numeric(message: 'id must be numeric')]
512 | public int $id;
513 | }
514 |
515 | $foo = Foo::from(['id' => '23']);
516 | assert($foo->id === 23);
517 | ```
518 |
519 | ## Boolean
520 |
521 | You have `bool` properties but aren't sure if those aren't delivered as `string` or `int`? `Boolean` can help you with that!
522 |
523 | ```php
524 | use Dgame\DataTransferObject\Annotation\Boolean;
525 |
526 | final class Foo
527 | {
528 | use DataTransfer;
529 |
530 | #[Boolean(message: 'checked must be a bool')]
531 | public bool $checked;
532 | #[Boolean(message: 'verified must be a bool')]
533 | public bool $verified;
534 | }
535 |
536 | $foo = Foo::from(['checked' => 'yes', 'verified' => 0]);
537 | assert($foo->checked === true);
538 | assert($foo->verified === false);
539 | ```
540 |
541 | ## Date
542 |
543 | You want a `DateTime` but got a string? No problem:
544 |
545 | ```php
546 | use Dgame\DataTransferObject\Annotation\Date;
547 | use \DateTime;
548 |
549 | final class Foo
550 | {
551 | use DataTransfer;
552 |
553 | #[Date(format: 'd.m.Y', message: 'Your birthday must be a date')]
554 | public DateTime $birthday;
555 | }
556 |
557 | $foo = Foo::from(['birthday' => '19.09.1979']);
558 | assert($foo->birthday === DateTime::createFromFormat('d.m.Y', '19.09.1979'));
559 | ```
560 |
561 |
562 | ## In
563 |
564 | Your value must be one of a specific range or enumeration? You can ensure that with `In`:
565 |
566 | ```php
567 | use Dgame\DataTransferObject\Annotation\In;
568 |
569 | final class Foo
570 | {
571 | use DataTransfer;
572 |
573 | #[In(values: ['beginner', 'advanced', 'difficult'], message: 'Must be either "beginner", "advanced" or "difficult"')]
574 | public string $difficulty;
575 | }
576 |
577 | Foo::from(['difficulty' => 'foo']); // will throw a error, since difficulty is not in the provided values
578 | $foo = Foo::from(['difficulty' => 'advanced']);
579 | assert($foo->difficulty === 'advanced');
580 | ```
581 |
582 | ## NotIn
583 |
584 | Your value must **not** be one of a specific range or enumeration? You can ensure that with `NotIn`:
585 |
586 | ```php
587 | use Dgame\DataTransferObject\Annotation\NotIn;
588 |
589 | final class Foo
590 | {
591 | use DataTransfer;
592 |
593 | #[NotIn(values: ['holy', 'shit', 'wtf'], message: 'Must not be a swear word')]
594 | public string $word;
595 | }
596 | ```
597 |
598 | ## Matches
599 |
600 | You must be sure that your values match a specific pattern? You can do that for **all scalar** values by using `Matches`:
601 |
602 | ```php
603 | use Dgame\DataTransferObject\Annotation\Matches;
604 |
605 | final class Foo
606 | {
607 | use DataTransfer;
608 |
609 | #[Matches(pattern: '/^[a-z]+\w*/', message: 'Your name must start with a-z')]
610 | public string $name;
611 |
612 | #[Matches(pattern: '/[1-9][0-9]+/', message: 'products must be at least 10')]
613 | public int $products;
614 | }
615 |
616 | Foo::from(['name' => '_', 'products' => 99]); // will throw a error, since name does not start with a-z
617 | Foo::from(['name' => 'John', 'products' => 9]); // will throw a error, since products must be at least 10
618 | ```
619 |
620 | ## Trim
621 |
622 | You have to make sure, that `string` values are trimmed? No worries, we have `Trim`:
623 |
624 | ```php
625 | use Dgame\DataTransferObject\Annotation\Trim;
626 |
627 | final class Foo
628 | {
629 | use DataTransfer;
630 |
631 | #[Trim]
632 | public string $name;
633 | }
634 |
635 | $foo = Foo::from(['name' => ' John ']);
636 | assert($foo->name === 'John');
637 | ```
638 |
639 | ## Path
640 |
641 | Did you ever wanted to extract a value from a provided array? `Path` to the rescue:
642 |
643 | ```php
644 | final class Person
645 | {
646 | use DataTransfer;
647 |
648 | #[Path('person.name')]
649 | public string $name;
650 | }
651 | ```
652 |
653 | It helps while with JSON's special `$value` attribute
654 |
655 | ```php
656 | final class Person
657 | {
658 | use DataTransfer;
659 |
660 | #[Path('married.$value')]
661 | public bool $married;
662 | }
663 | ```
664 |
665 | and with XML's `#text`.
666 |
667 | ```php
668 | final class Person
669 | {
670 | use DataTransfer;
671 |
672 | #[Path('first.name.#text')]
673 | public string $firstname;
674 | }
675 | ```
676 |
677 | ---
678 |
679 | But we can do even more. You can choose which parts of the field are taken
680 |
681 | ```php
682 | final class Person
683 | {
684 | use DataTransfer;
685 |
686 | #[Path('child.{born, age}')]
687 | public array $firstChild = [];
688 | }
689 | ```
690 |
691 | and can even assign them directly to an object:
692 |
693 | ```php
694 | final class Person
695 | {
696 | use DataTransfer;
697 |
698 | public int $id;
699 | public string $name;
700 | public ?int $age = null;
701 |
702 | #[Path('ancestor.{id, name}')]
703 | public ?self $parent = null;
704 | }
705 | ```
706 |
707 | ## SelfValidation
708 |
709 | In addition to the [customary validations](#validation) you can specify a class-wide validation after **all** assignments are done:
710 |
711 | ```php
712 | #[SelfValidation(method: 'validate')]
713 | final class SelfValidationStub
714 | {
715 | use DataTransfer;
716 |
717 | public function __construct(public int $id)
718 | {
719 | }
720 |
721 | public function validate(): void
722 | {
723 | assert($this->id > 0);
724 | }
725 | }
726 | ```
727 |
728 | ## ValidationStrategy
729 |
730 | The default validation strategy is **fail-fast** which means an Exception is thrown as soon as an error is detected.
731 | But that might not desirable, so you can configure this with a `ValidationStrategy`:
732 |
733 | ```php
734 | #[ValidationStrategy(failFast: false)]
735 | final class Foo
736 | {
737 | use DataTransfer;
738 |
739 | #[Min(3)]
740 | public string $name;
741 | #[Min(0)]
742 | public int $id;
743 | }
744 |
745 | Foo::from(['name' => 'a', 'id' => -1]);
746 | ```
747 |
748 | The example above would throw a combined exception that `name` is not long enough and `id` must be at least 0.
749 | You can configure this as well by extending the `ValidationStrategy` and provide a `FailureHandler` and/or a `FailureCollection`.
750 |
751 | # Property promotion
752 |
753 | In the above examples, [property promotion](https://stitcher.io/blog/constructor-promotion-in-php-8) is not always used because it is more readable that way, but property promotion is supported. So the following example
754 |
755 | ```php
756 | use Dgame\DataTransferObject\Annotation\Min;
757 | use Dgame\DataTransferObject\DataTransfer;
758 |
759 | final class Limit
760 | {
761 | use DataTransfer;
762 |
763 | #[Min(0)]
764 | public int $offset;
765 | #[Min(0)]
766 | public int $limit;
767 | }
768 | ```
769 |
770 | can be rewritten as shown below
771 |
772 | ```php
773 | use Dgame\DataTransferObject\Annotation\Min;
774 | use Dgame\DataTransferObject\DataTransfer;
775 |
776 | final class Limit
777 | {
778 | use DataTransfer;
779 |
780 | public function __construct(
781 | #[Min(0)] public int $offset,
782 | #[Min(0)] public int $limit
783 | ) { }
784 | }
785 | ```
786 |
787 | and it still works.
788 |
789 | # Nested object detection
790 |
791 | You have nested objects and want to deserialize them all at once? That is a given:
792 |
793 | ```php
794 | use Dgame\DataTransferObject\DataTransfer;
795 |
796 | final class Bar
797 | {
798 | public int $id;
799 | }
800 |
801 | final class Foo
802 | {
803 | use DataTransfer;
804 |
805 | public Bar $bar;
806 | }
807 |
808 | $foo = Foo::from(['bar' => ['id' => 42]]);
809 | echo $foo->bar->id; // 42
810 | ```
811 |
812 | Have you noticed the missing `From` in `Bar`? `From` is just a little wrapper for the actual DTO. So your nested classes don't need to use it at all.
813 |
814 | There is no limit to the depth of nesting, the responsibility is yours! :)
815 |
--------------------------------------------------------------------------------