diff --git a/composer.json b/composer.json index ff236c9..a09f12f 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,6 @@ }, "scripts": { "ci-test": "XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=ci --configuration phpunit.xml", - "phpstan": "vendor/bin/phpstan analyse --configuration phpstan.neon" + "phpstan": "vendor/bin/phpstan analyse --configuration phpstan.neon --memory-limit=256M" } } diff --git a/docs/EmptyData.md b/docs/EmptyData.md new file mode 100644 index 0000000..27e2c52 --- /dev/null +++ b/docs/EmptyData.md @@ -0,0 +1,60 @@ +Empty Data += + +Sometimes we may need to create a fresh instance of a DTO without any data, +and by default `Data` classes have the ability to create an `"empty"` instance: + +```php +use Nuxtifyts\PhpDto\Data; +use DateTimeImmutable; + +final reaconly class Todo extends Data +{ + public function __construct( + public string $title, + public string $content, + public Status $status, + public ?DateTimeImmutable $dueDate + ) {} +} +``` + +The `Status` enum is defined as follows: + +```php +enum Status: string +{ + case DEFAULT = 'default'; + case DONE = 'done'; + case CANCELED = 'canceled'; +} +``` + +By calling the `empty()` method, we can create a new instance of the `Todo` class with all properties set to `null`: + +```php +$emptyTodo = Todo::empty(); +``` + +The `$emptyTodo` variable will contain the following data: + +``` +[ + 'title' => '', + 'comtent' => '', + 'status' => Status::DEFAULT, + 'dueDate' => null +] +``` + +This is useful when we want to gradually fill in the data of a DTO instance, +here is a list of the empty values for each type: + +- `NULL`: `null` (Null takes priority over everything) +- `STRING`: `''` +- `INT`: `0` +- `FLOAT`: `0.0` +- `BOOLEAN`: `false` +- `ARRAY`: `[]` (Any type of array will default to an empty one) +- `DATETIME`: New instance of DateTime/DateTimeImmutable +- `BACKEDENUM`: First case of the enum diff --git a/docs/Quickstart.md b/docs/Quickstart.md index 1e3867f..e796df2 100644 --- a/docs/Quickstart.md +++ b/docs/Quickstart.md @@ -80,3 +80,4 @@ can be found here: - [Normalizers](https://github.com/nuxtifyts/php-dto/blob/main/docs/Normalizers.md) - [Property Attributes](https://github.com/nuxtifyts/php-dto/blob/main/docs/PropertyAttributes.md) - [Data Refiners](https://github.com/nuxtifyts/php-dto/blob/main/docs/DataRefiners.md) +- [Empty Data](https://github.com/nuxtifyts/php-dto/blob/main/docs/EmptyData.md) diff --git a/src/Concerns/BaseData.php b/src/Concerns/BaseData.php index 634ebb8..0475adb 100644 --- a/src/Concerns/BaseData.php +++ b/src/Concerns/BaseData.php @@ -16,6 +16,9 @@ trait BaseData { use HasNormalizers; + /** + * @throws DataCreationException + */ final public static function create(mixed ...$args): static { if (array_any( @@ -124,13 +127,7 @@ protected static function instanceWithConstructorCallFrom(ClassContext $context, $args[$paramName] = $propertyContext->deserializeFrom($value); } - $instance = $context->newInstanceWithConstructorCall(...$args); - - if (!$instance instanceof static) { - throw new DeserializeException('Could not create instance of ' . static::class); - } - - return $instance; + return $context->newInstanceWithConstructorCall(...$args); } /** @@ -159,4 +156,17 @@ final public function jsonSerialize(): array throw new SerializeException($e->getMessage(), $e->getCode(), $e); } } + + /** + * @throws SerializeException + */ + final public function toArray(): array + { + return $this->jsonSerialize(); + } + + final public function toJson(): false|string + { + return json_encode($this); + } } diff --git a/src/Concerns/EmptyData.php b/src/Concerns/EmptyData.php new file mode 100644 index 0000000..a50036c --- /dev/null +++ b/src/Concerns/EmptyData.php @@ -0,0 +1,26 @@ + $classContext */ + $classContext = ClassContext::getInstance(new ReflectionClass(static::class)); + + return $classContext->emptyValue(); + } catch (Throwable $t) { + throw DataCreationException::unableToCreateEmptyInstance(static::class, $t); + } + } +} diff --git a/src/Contexts/ClassContext.php b/src/Contexts/ClassContext.php index 7f82f1f..7444a1f 100644 --- a/src/Contexts/ClassContext.php +++ b/src/Contexts/ClassContext.php @@ -2,13 +2,15 @@ namespace Nuxtifyts\PhpDto\Contexts; +use Nuxtifyts\PhpDto\Data; +use Nuxtifyts\PhpDto\Exceptions\DataCreationException; use Nuxtifyts\PhpDto\Exceptions\UnsupportedTypeException; -use ReflectionClass; use ReflectionException; use ReflectionParameter; +use ReflectionClass; /** - * @template T of object + * @template T of Data */ class ClassContext { @@ -101,9 +103,36 @@ public function newInstanceWithoutConstructor(): mixed /** * @throws ReflectionException + * + * @return T */ public function newInstanceWithConstructorCall(mixed ...$args): mixed { return $this->reflection->newInstance(...$args); } + + /** + * @return T + * + * @throws ReflectionException + * @throws UnsupportedTypeException + * @throws DataCreationException + */ + public function emptyValue(): mixed + { + /** @var array $args */ + $args = []; + + foreach ($this->constructorParams as $paramName) { + $propertyContext = $this->properties[$paramName] ?? null; + + if (!$propertyContext) { + throw DataCreationException::invalidProperty(); + } + + $args[$paramName] = $propertyContext->emptyValue(); + } + + return $this->newInstanceWithConstructorCall(...$args); + } } diff --git a/src/Contexts/PropertyContext.php b/src/Contexts/PropertyContext.php index 54200c1..3619ae0 100644 --- a/src/Contexts/PropertyContext.php +++ b/src/Contexts/PropertyContext.php @@ -2,11 +2,15 @@ namespace Nuxtifyts\PhpDto\Contexts; +use BackedEnum; +use Nuxtifyts\PhpDto\Exceptions\DataCreationException; +use UnitEnum; use Nuxtifyts\PhpDto\Attributes\Property\Aliases; use Nuxtifyts\PhpDto\Attributes\Property\CipherTarget; use Nuxtifyts\PhpDto\Attributes\Property\Computed; use Nuxtifyts\PhpDto\Attributes\Property\DefaultsTo; use Nuxtifyts\PhpDto\Attributes\Property\WithRefiner; +use Nuxtifyts\PhpDto\Data; use Nuxtifyts\PhpDto\DataCiphers\CipherConfig; use Nuxtifyts\PhpDto\DataRefiners\DataRefiner; use Nuxtifyts\PhpDto\Enums\Property\Type; @@ -18,8 +22,12 @@ use Nuxtifyts\PhpDto\Serializers\Serializer; use Nuxtifyts\PhpDto\Support\Traits\HasSerializers; use Nuxtifyts\PhpDto\Support\Traits\HasTypes; +use DateTimeInterface; +use ReflectionEnum; +use ReflectionException; use ReflectionProperty; use ReflectionAttribute; +use ReflectionClass; use Exception; class PropertyContext @@ -225,4 +233,61 @@ public function serializeFrom(object $object): array throw new SerializeException('Could not serialize value for property: ' . $this->propertyName); } } + + /** + * @throws UnsupportedTypeException + * @throws ReflectionException + * @throws DataCreationException + */ + public function emptyValue(): mixed + { + if ($this->isNullable) { + return null; + } + + if (! $typeContext = $this->typeContexts[0] ?? null) { + throw UnsupportedTypeException::emptyType(); + } + + switch (true) { + case $typeContext->type === Type::STRING: + return ''; + + case $typeContext->type === Type::INT: + return 0; + + case $typeContext->type === Type::FLOAT: + return 0.0; + + case $typeContext->type === Type::BOOLEAN: + return false; + + case $typeContext->type === Type::ARRAY: + return []; + + case $typeContext->type === Type::DATA: + /** @var null|ReflectionClass $reflection */ + $reflection = $typeContext->reflection; + + return !$reflection + ? throw UnsupportedTypeException::invalidReflection() + : ClassContext::getInstance($reflection)->emptyValue(); + + case $typeContext->type === Type::BACKED_ENUM: + /** @var null|ReflectionEnum $reflection */ + $reflection = $typeContext->reflection; + + return $reflection instanceof ReflectionEnum && $reflection->isBacked() + ? $reflection->getCases()[0]->getValue() + : throw UnsupportedTypeException::invalidReflection(); + + default: + /** @var null|DateTimeInterface $dateTime */ + $dateTime = $typeContext->reflection?->newInstance(); + + return $dateTime instanceof DateTimeInterface + ? $dateTime + : throw UnsupportedTypeException::invalidReflection(); + } + } } diff --git a/src/Contexts/TypeContext.php b/src/Contexts/TypeContext.php index 338ec09..4e502a1 100644 --- a/src/Contexts/TypeContext.php +++ b/src/Contexts/TypeContext.php @@ -109,7 +109,7 @@ public static function getInstances(PropertyContext $property): array ); break; default: - throw UnsupportedTypeException::from($type); + throw UnsupportedTypeException::unknownType($type); } } diff --git a/src/Contracts/BaseData.php b/src/Contracts/BaseData.php index 896a834..07d89ea 100644 --- a/src/Contracts/BaseData.php +++ b/src/Contracts/BaseData.php @@ -21,6 +21,20 @@ public static function create(mixed ...$args): static; */ public function jsonSerialize(): array; + /** + * @return array + * + * @throws SerializeException + */ + public function toArray(): array; + + /** + * @return false|string + * + * @throws SerializeException + */ + public function toJson(): false|string; + /** * @throws DeserializeException */ diff --git a/src/Contracts/EmptyData.php b/src/Contracts/EmptyData.php new file mode 100644 index 0000000..0fc80f6 --- /dev/null +++ b/src/Contracts/EmptyData.php @@ -0,0 +1,13 @@ + - * - * @throws SerializeException - */ - final public function toArray(): array - { - return $this->jsonSerialize(); - } - - final public function toJson(): false|string - { - return json_encode($this); - } + use EmptyData; } diff --git a/src/Exceptions/DataCreationException.php b/src/Exceptions/DataCreationException.php index e383ccd..9a8e3fc 100644 --- a/src/Exceptions/DataCreationException.php +++ b/src/Exceptions/DataCreationException.php @@ -9,6 +9,7 @@ class DataCreationException extends Exception { protected const int UNABLE_TO_CREATE_INSTANCE = 0; protected const int INVALID_PROPERTY = 1; + protected const int UNABLE_TO_CREATE_EMPTY_INSTANCE = 2; public static function unableToCreateInstance( string $class, @@ -28,4 +29,15 @@ public static function invalidProperty(): self code: self::INVALID_PROPERTY ); } + + public static function unableToCreateEmptyInstance( + string $class, + ?Throwable $previous = null + ): self { + return new self( + message: "Unable to create empty instance of class {$class}", + code: self::UNABLE_TO_CREATE_EMPTY_INSTANCE, + previous: $previous + ); + } } diff --git a/src/Exceptions/FallbackResolverException.php b/src/Exceptions/FallbackResolverException.php index a91c322..8600299 100644 --- a/src/Exceptions/FallbackResolverException.php +++ b/src/Exceptions/FallbackResolverException.php @@ -7,7 +7,6 @@ class FallbackResolverException extends Exception { protected const int UNABLE_TO_FIND_RESOLVER_CLASS = 0; - protected const int UNABLE_TO_RESOLVE_DEFAULT_VALUE = 1; public static function unableToFindResolverClass(string $resolverClass): self { @@ -16,12 +15,4 @@ public static function unableToFindResolverClass(string $resolverClass): self self::UNABLE_TO_FIND_RESOLVER_CLASS ); } - - public static function unableToResolveDefaultValue(): self - { - return new self( - 'Unable to resolve default value', - self::UNABLE_TO_RESOLVE_DEFAULT_VALUE - ); - } } diff --git a/src/Exceptions/UnknownTypeException.php b/src/Exceptions/UnknownTypeException.php index 0213406..528fb88 100644 --- a/src/Exceptions/UnknownTypeException.php +++ b/src/Exceptions/UnknownTypeException.php @@ -7,19 +7,22 @@ class UnknownTypeException extends Exception { - final protected function __construct(string $message = "") - { - parent::__construct($message); - } + protected const int UNKNOWN_TYPE_EXCEPTION_CODE = 0; - public static function from( - Type $type, - Type ...$additionalTypes - ): static { - $types = implode(', ', array_column([$type, ...$additionalTypes], 'value')); + public static function unknownType(Type $type, Type ...$types): self + { + $types = array_map( + static fn (Type $type): string => $type->value, + $types + ); - return new static( - "Unknown type '{$types}'" + return new self( + sprintf( + 'Unknown type "%s". Known types are: %s.', + $type->value, + implode(', ', $types) + ), + self::UNKNOWN_TYPE_EXCEPTION_CODE ); } } diff --git a/src/Exceptions/UnsupportedTypeException.php b/src/Exceptions/UnsupportedTypeException.php index f33a400..9604b18 100644 --- a/src/Exceptions/UnsupportedTypeException.php +++ b/src/Exceptions/UnsupportedTypeException.php @@ -6,15 +6,36 @@ class UnsupportedTypeException extends Exception { - final protected function __construct(string $message) + protected const int UNKNOWN_TYPE = 0; + protected const int EMPTY_TYPE = 1; + protected const int INVALID_REFLECTION = 2; + protected const int INVALID_TYPE = 3; + + public static function unknownType(?string $type = null): self + { + return new self( + 'Unknown type' . ($type ? " '{$type}'" : ''), + ); + } + + public static function emptyType(): self { - parent::__construct($message); + return new self( + 'Got empty type', + ); + } + + public static function invalidReflection(): self + { + return new self( + 'Invalid reflection for type', + ); } - public static function from(string $type): static + public static function invalidType(): self { - return new static( - "Unknown type '{$type}'" + return new self( + 'Invalid type', ); } } diff --git a/src/Support/Traits/HasSerializers.php b/src/Support/Traits/HasSerializers.php index 844434f..0d94c68 100644 --- a/src/Support/Traits/HasSerializers.php +++ b/src/Support/Traits/HasSerializers.php @@ -36,7 +36,7 @@ protected function getSerializersFromPropertyContext( array_column($serializer::supportedTypes(), 'value') )) ? new $serializer() : null, self::serializersList() - ))) ?: throw UnknownTypeException::from(...$propertyContext->types); + ))) ?: throw UnknownTypeException::unknownType(...$propertyContext->types); } /** @@ -82,5 +82,8 @@ public function serializers(): array return $this->_serializers ??= $this->resolveSerializers(); } + /** + * @return list + */ abstract protected function resolveSerializers(): array; } diff --git a/tests/Dummies/Serializers/HasSerializersDummyClass.php b/tests/Dummies/Serializers/HasSerializersDummyClass.php new file mode 100644 index 0000000..2b7660f --- /dev/null +++ b/tests/Dummies/Serializers/HasSerializersDummyClass.php @@ -0,0 +1,42 @@ + + */ + public static function testGetSerializersFromPropertyContext(PropertyContext $propertyContext): array + { + return new self()->getSerializersFromPropertyContext($propertyContext); + } + + /** + * @return list> + */ + protected static function serializersList(): array + { + return [ + DateTimeSerializer::class + ]; + } + + /** + * @return list + */ + protected function resolveSerializers(): array + { + return []; + } +} diff --git a/tests/Unit/Concerns/BaseDataTest.php b/tests/Unit/Concerns/BaseDataTest.php index 06f45e3..281e9bd 100644 --- a/tests/Unit/Concerns/BaseDataTest.php +++ b/tests/Unit/Concerns/BaseDataTest.php @@ -4,6 +4,7 @@ use DateTimeImmutable; use DateTimeInterface; +use Nuxtifyts\PhpDto\Attributes\Property\Computed; use Nuxtifyts\PhpDto\Data; use Nuxtifyts\PhpDto\Exceptions\DeserializeException; use Nuxtifyts\PhpDto\Exceptions\SerializeException; @@ -112,6 +113,31 @@ public function will_throw_an_exception_if_an_invalid_value_is_passed_to_from_fu PersonData::from(false); } + /** + * @throws Throwable + */ + #[Test] + public function will_throw_deserialization_exception_when_invalid_parameter_is_used_with_computed_properties(): void + { + $object = new readonly class ('a', 'b') extends Data { + #[Computed] + public string $c; + + public function __construct( + public string $a, + string $b + ) { + $this->c = $a . $b; + } + }; + + self::expectException(DeserializeException::class); + $object::from([ + 'a' => 'a', + 'b' => 'b' + ]); + } + /** * @throws Throwable */ diff --git a/tests/Unit/Concerns/EmptyDataTest.php b/tests/Unit/Concerns/EmptyDataTest.php new file mode 100644 index 0000000..dc13741 --- /dev/null +++ b/tests/Unit/Concerns/EmptyDataTest.php @@ -0,0 +1,192 @@ +|EmptyDataContract $object + * @param array $expectedEmptyValues + * + * @throws Throwable + */ + #[Test] + #[DataProvider('will_be_able_to_create_empty_data_provider')] + public function will_be_able_to_create_empty_data( + EmptyDataContract|string $object, + array $expectedEmptyValues + ): void { + $emptyData = $object::empty(); + + foreach ($expectedEmptyValues as $property => $value) { + self::assertObjectHasProperty($property, $emptyData); + + if ($value instanceof DateTimeInterface) { + self::assertInstanceOf($value::class, $emptyData->{$property}); + self::assertEquals( + $value->format('Y-m-d H:i'), + $emptyData->{$property}->format('Y-m-d H:i') + ); + } else { + self::assertEquals($value, $emptyData->{$property}); + } + } + } + + /** + * @return array + */ + public static function will_be_able_to_create_empty_data_provider(): array + { + return [ + 'Will default to null if property is nullable' => [ + 'object' => new readonly class ('') extends Data { + public function __construct( + public ?string $value + ) { + } + }, + 'expectedEmptyValues' => [ + 'value' => null + ] + ], + 'Will be able to create empty data with scalar types' => [ + 'object' => new readonly class ('', 0, 0.0, false, null) extends Data { + public function __construct( + public string $value, + public int $number, + public float $float, + public bool $bool, + public ?string $nullableString = null + ) { + } + }, + 'expectedEmptyValues' => [ + 'value' => '', + 'number' => 0, + 'float' => 0.0, + 'bool' => false, + 'nullableString' => null + ] + ], + 'Will be able to create empty data with array types' => [ + 'object' => new readonly class([], []) extends Data { + /** + * @param array $arrayOfIntegersOrFloats + * @param ?array $nullableArrayOfIntegers + */ + public function __construct( + #[ArrayOfScalarTypes([Type::INT, Type::FLOAT])] + public array $arrayOfIntegersOrFloats, + #[ArrayOfScalarTypes([Type::INT])] + public ?array $nullableArrayOfIntegers + ) { + } + }, + 'expectedEmptyValues' => [ + 'arrayOfIntegersOrFloats' => [], + 'nullableArrayOfIntegers' => null + ] + ], + 'Will be able to create empty data with data types array' => [ + 'object' => PointGroupData::class, + 'expectedEmptyValues' => [ + 'key' => '', + 'points' => [] + ] + ], + 'Will be able to create empty data with data types direct data type' => [ + 'object' => AddressData::class, + 'expectedEmptyValues' => [ + 'street' => '', + 'city' => '', + 'state' => '', + 'zip' => '', + 'country' => new CountryData('', ''), + 'coordinates' => null + ] + ], + 'will take first value of backed enum when calling empty' => [ + 'object' => YesOrNoData::class, + 'expectedEmptyValues' => [ + 'yesNo' => YesNoBackedEnum::YES, + ] + ], + 'Will default to a new instance for date time type' => [ + 'object' => new readonly class (new DateTime()) extends Data { + public function __construct( + public DateTime $dateTime + ) { + } + }, + 'expectedEmptyValues' => [ + 'dateTime' => new DateTime() + ] + ], + 'Will default to a new instance for date time immutable type' => [ + 'object' => new readonly class (new DateTimeImmutable()) extends Data { + public function __construct( + public DateTimeImmutable $dateTimeImmutable + ) { + } + }, + 'expectedEmptyValues' => [ + 'dateTimeImmutable' => new DateTimeImmutable() + ] + ], + ]; + } + + /** + * @throws Throwable + */ + #[Test] + public function will_throw_exception_when_parameter_is_not_linked_to_a_property(): void + { + $object = new readonly class('') extends Data { + public string $otherPropertyName; + + public function __construct( + string $parameterName + ) { + $this->otherPropertyName = $parameterName; + } + }; + + self::expectException(DataCreationException::class); + $object::empty(); + } +} diff --git a/tests/Unit/Contexts/PropertyContextTest.php b/tests/Unit/Contexts/PropertyContextTest.php index 015ec45..4859d9d 100644 --- a/tests/Unit/Contexts/PropertyContextTest.php +++ b/tests/Unit/Contexts/PropertyContextTest.php @@ -2,14 +2,16 @@ namespace Nuxtifyts\PhpDto\Tests\Unit\Contexts; +use Nuxtifyts\PhpDto\Tests\Dummies\Serializers\HasSerializersDummyClass; use Nuxtifyts\PhpDto\Attributes\Property\Computed; use Nuxtifyts\PhpDto\Attributes\Property\WithRefiner; -use Nuxtifyts\PhpDto\Contexts\ClassContext; use Nuxtifyts\PhpDto\Contexts\PropertyContext; use Nuxtifyts\PhpDto\Contexts\TypeContext; use Nuxtifyts\PhpDto\Data; use Nuxtifyts\PhpDto\DataRefiners\DateTimeRefiner; use Nuxtifyts\PhpDto\Enums\Property\Type; +use Nuxtifyts\PhpDto\Exceptions\UnknownTypeException; +use Nuxtifyts\PhpDto\Exceptions\UnsupportedTypeException; use Nuxtifyts\PhpDto\Serializers\ScalarTypeSerializer; use Nuxtifyts\PhpDto\Tests\Dummies\ComputedPropertiesData; use Nuxtifyts\PhpDto\Tests\Dummies\Enums\YesNoBackedEnum; @@ -31,6 +33,8 @@ #[CoversClass(TypeContext::class)] #[CoversClass(Computed::class)] #[CoversClass(WithRefiner::class)] +#[CoversClass(UnsupportedTypeException::class)] +#[CoversClass(UnknownTypeException::class)] #[UsesClass(ComputedPropertiesData::class)] #[UsesClass(ScalarTypeSerializer::class)] #[UsesClass(PersonData::class)] @@ -38,6 +42,7 @@ #[UsesClass(CoordinatesData::class)] #[UsesClass(UnionMultipleTypeData::class)] #[UsesClass(PersonData::class)] +#[UsesClass(HasSerializersDummyClass::class)] final class PropertyContextTest extends UnitCase { /** @@ -80,6 +85,44 @@ public function can_resolve_serializers_of_property(): void self::assertInstanceOf(ScalarTypeSerializer::class, $serializers[0]); } + /** + * @throws Throwable + */ + #[Test] + public function will_throw_an_exception_if_property_type_is_not_supported(): void + { + $object = new readonly class ('') extends Data { + public function __construct( + public mixed $value + ) { + } + }; + + $reflectionProperty = new ReflectionProperty($object::class, 'value'); + self::expectException(UnsupportedTypeException::class); + PropertyContext::getInstance($reflectionProperty); + } + + /** + * @throws Throwable + */ + #[Test] + public function will_throw_and_exception_if_it_fails_to_resolve_a_serializer(): void + { + $object = new readonly class (1) extends Data { + public function __construct( + public int $value + ) { + } + }; + + $reflectionProperty = new ReflectionProperty($object::class, 'value'); + $propertyContext = PropertyContext::getInstance($reflectionProperty); + + self::expectException(UnknownTypeException::class); + HasSerializersDummyClass::testGetSerializersFromPropertyContext($propertyContext); + } + /** * @throws Throwable */