diff --git a/docs/CloneableData.md b/docs/CloneableData.md new file mode 100644 index 0000000..545bf90 --- /dev/null +++ b/docs/CloneableData.md @@ -0,0 +1,119 @@ +Cloneable Data += + +Sometimes we may want to alter the data of a `Data` object (Partially or completely). +And since `Data` objects are immutable by default, we can't change the data directly. + +To solve this, we can use the `with` function that will return a new instance of the `Data` object with the new data. +Let take the `TodoData` class as an example: + +```php +use Nuxtifyts\PhpDto\Data; +use DateTimeImmutable; + +final readonly class TodoData 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 IN_PROGRESS = 'in_progress'; + case DONE = 'done'; +} +``` + +Using `with` function, we can easily create new instances of the `TodoData` class with the new data: + +```php +$emptyTodo = Todo::empty(); + +// ... + +$todo = $emptyTodo->with( + title: 'Learn PHP DTO', + content: 'Learn how to use PHP DTO', + status: Status::IN_PROGRESS +); + +// ... + +$todoWithDueDate = $todo->with( + dueDate: new DateTimeImmutable('2025-01-06') +); +``` + +> We are using the `empty` method +> from [Empty Data](https://github.com/nuxtifyts/php-dto/blob/main/docs/EmptyData.md) +> here + +> `emptyTodo`, `todo` and `todoWithDueDate` are all different instances. + +Computed properties +- + +When cloning a `Data` object, computed properties are automatically updated with the new data. + +```php +use Nuxtifyts\PhpDto\Data; +use Nuxtifyts\PhpDto\Attributes\Property\Computed; + +final readonly class PersonData extends Data +{ + #[Computed] + public string $fullName; + + public function __construct( + public string $firstName, + public string $lastName + ) {} +} +``` + +For example: + +```php +$johnDoe = new PersonData(firstName: 'John', lastName: 'Doe'); + +$janeDoe = $johnDoe->with(firstName: 'Jane'); + +$janeDoe->fullName; // 'Jane Doe' +``` + +Normalizers +- + +When cloning a `Data` object, normalizers that are typically used when hydrating a `Data` object +using `from` method are also used. + +This will allow the ability to pass `json` data, `ArrayAccess` or `stdClass` objects for example to the `with` method. +If a custom normalizer is implemented for the `Data` class, it can be used as well. + +```php +$johnDoe = new PersonDaa('John', 'Doe'); + +$janeDoe = $johnDoe->with('{"firstName": "Jane"}'); + +$janeDoe->fullName; // 'Jane Doe' +``` + +Using an `stdClass` object: + +```php +$object = new stdClass(); +$object->firstName = 'Jake'; + +$jakeDoe = $janeDoe->with($object); + +$jakeDoe->fullName; // 'Jake Doe' +``` diff --git a/docs/EmptyData.md b/docs/EmptyData.md index 0472617..cb6b39a 100644 --- a/docs/EmptyData.md +++ b/docs/EmptyData.md @@ -36,6 +36,8 @@ By calling the `empty()` method, we can create a new instance of the `Todo` clas $emptyTodo = Todo::empty(); ``` +> This is really useful with [Cloneable Data](https://github.com/nuxtifyts/php-dto/blob/main/docs/CloneableData.md) + The `$emptyTodo` variable will contain the following data: ``` diff --git a/docs/Quickstart.md b/docs/Quickstart.md index e796df2..c3434bc 100644 --- a/docs/Quickstart.md +++ b/docs/Quickstart.md @@ -81,3 +81,4 @@ can be found here: - [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) +- [Cloneable Data](https://github.com/nuxtifyts/php-dto/blob/main/docs/CloneableData.md) diff --git a/src/Concerns/BaseData.php b/src/Concerns/BaseData.php index 0475adb..d5cc8c7 100644 --- a/src/Concerns/BaseData.php +++ b/src/Concerns/BaseData.php @@ -21,20 +21,12 @@ trait BaseData */ final public static function create(mixed ...$args): static { - if (array_any( - array_keys($args), - static fn (string|int $arg) => is_numeric($arg) - )) { - throw DataCreationException::invalidProperty(); - } - try { - $value = static::normalizeValue($args, static::class); + $value = static::normalizeValue($args, static::class) + ?: static::normalizeValue($args[0] ?? [], static::class); if ($value === false) { - throw new DeserializeException( - code: DeserializeException::INVALID_VALUE_ERROR_CODE - ); + throw DataCreationException::invalidParamsPassed(static::class); } /** @var ClassContext $context */ diff --git a/src/Concerns/CloneableData.php b/src/Concerns/CloneableData.php new file mode 100644 index 0000000..44fbb57 --- /dev/null +++ b/src/Concerns/CloneableData.php @@ -0,0 +1,88 @@ + $context */ + $context = ClassContext::getInstance(new ReflectionClass(static::class)); + + return $context->hasComputedProperties + ? $this->cloneInstanceWithConstructorCall($context, $value) + : $this->cloneInstanceWithoutConstructorCall($context, $value); + } catch (Throwable $t) { + throw DataCreationException::unableToCloneInstanceWithNewData($t); + } + } + + /** + * @param ClassContext $context + * @param array $value + * + * @throws Throwable + */ + protected function cloneInstanceWithConstructorCall(ClassContext $context, array $value): static + { + /** @var array $args */ + $args = []; + + foreach ($context->constructorParams as $paramName) { + $propertyContext = $context->properties[$paramName] ?? null; + + if (!$propertyContext) { + throw DataCreationException::invalidProperty(); + } + + $args[$paramName] = array_key_exists($propertyContext->propertyName, $value) + ? $value[$paramName] + : $this->{$propertyContext->propertyName}; + } + + return $context->newInstanceWithConstructorCall(...$args); + } + + /** + * @param ClassContext $context + * @param array $value + * + * @throws Throwable + */ + protected function cloneInstanceWithoutConstructorCall(ClassContext $context, array $value): static + { + $instance = $context->newInstanceWithoutConstructor(); + + foreach ($context->properties as $propertyContext) { + $instance->{$propertyContext->propertyName} = + array_key_exists($propertyContext->propertyName, $value) + ? $value[$propertyContext->propertyName] + : $this->{$propertyContext->propertyName}; + } + + return $instance; + } +} diff --git a/src/Contracts/CloneableData.php b/src/Contracts/CloneableData.php new file mode 100644 index 0000000..6d5e4c7 --- /dev/null +++ b/src/Contracts/CloneableData.php @@ -0,0 +1,13 @@ +jsonSerialize(); + } + + #[Test] + public function will_throw_an_exception_when_invalid_data_is_passed_to_create_function(): void + { + self::expectException(DataCreationException::class); + + PointData::create('{"x: 1, "y": 2}'); + } + /** * @throws Throwable */ @@ -606,6 +634,12 @@ public function will_be_able_to_create_an_instance_using_create(): void self::assertEquals(1, $point->x); self::assertEquals(2, $point->y); + $point = PointData::create('{"x": 3, "y": 4}'); + + self::assertInstanceOf(PointData::class, $point); + self::assertEquals(3, $point->x); + self::assertEquals(4, $point->y); + // Make sure we skip deciphering the key $pointGroup = PointGroupData::create( key: 'random-key' diff --git a/tests/Unit/Concerns/CloneableDataTest.php b/tests/Unit/Concerns/CloneableDataTest.php new file mode 100644 index 0000000..5cd89ef --- /dev/null +++ b/tests/Unit/Concerns/CloneableDataTest.php @@ -0,0 +1,184 @@ +with(firstName: new DateTimeImmutable()); + } + + /** + * @throws Throwable + */ + #[Test] + public function will_throw_an_exception_if_invalid_arguments_are_passed_using_with_function(): void + { + $person = new PersonData(firstName: 'John', lastName: 'Doe'); + + self::expectException(DataCreationException::class); + + $person->with('{firstName: "Jane"'); + } + + /** + * @throws Throwable + */ + #[Test] + public function will_throw_an_exception_if_dto_declaration_is_invalid(): void + { + $object = new readonly class ('firstName', 'lastName') extends Data { + #[Computed] + public string $fullName; + public string $familyName; + + public function __construct( + public string $firstName, + string $lastName, + ) { + $this->familyName = $lastName; + $this->fullName = $this->firstName . ' ' . $this->familyName; + } + }; + + self::expectException(DataCreationException::class); + $object->with(lastName: 'Doe'); + } + + /** + * @throws Throwable + */ + #[Test] + public function will_throw_an_exception_if_no_param_is_passed(): void + { + $person = new PersonData(firstName: 'John', lastName: 'Doe'); + + self::expectException(DataCreationException::class); + + $person->with(); + } + + /** + * @param array $args + * @param array $expectedProperties + * + * @throws Throwable + */ + #[Test] + #[DataProvider('will_be_able_to_clone_data_provider')] + public function will_be_able_to_clone_data( + CloneableDataContract $object, + array $args, + array $expectedProperties + ): void { + $newObject = $object->with(...$args); + + self::assertNotSame($object, $newObject); + + foreach ($expectedProperties as $propertyName => $value) { + self::assertObjectHasProperty($propertyName, $newObject); + self::assertEquals($value, $newObject->{$propertyName}); + } + } + + /** + * @return array + */ + public static function will_be_able_to_clone_data_provider(): array + { + return [ + 'Will be able to clone scalar type properties' => [ + 'object' => new PersonData('John', 'Doe'), + 'args' => [ + 'firstName' => 'Jane', + 'lastName' => 'Doe', + ], + 'expectedProperties' => [ + 'firstName' => 'Jane', + 'lastName' => 'Doe', + ], + ], + 'Will be able to clone data type properties' => [ + 'object' => new AddressData( + '1234 Elm St', + 'City', + 'State', + '12345', + new CountryData( + 'XX', + 'Country' + ), + null + ), + 'args' => [ + 'country' => new CountryData( + 'YY', + 'Country 2' + ), + 'coordinates' => new CoordinatesData( + 1.234, + 5.678 + ) + ], + 'expectedProperties' => [ + 'street' => '1234 Elm St', + 'city' => 'City', + 'state' => 'State', + 'zip' => '12345', + 'country' => new CountryData( + 'YY', + 'Country 2' + ), + 'coordinates' => new CoordinatesData( + 1.234, + 5.678 + ) + ], + ], + 'Will be able to update computed properties' => [ + 'object' => new PersonData('John', 'Doe'), + 'args' => [ + 'firstName' => 'Jane', + 'lastName' => 'Doe', + ], + 'expectedProperties' => [ + 'firstName' => 'Jane', + 'lastName' => 'Doe', + 'fullName' => 'Jane Doe', + ], + ] + ]; + } +} diff --git a/tests/Unit/Documentation/CloneableDataExampleTest.php b/tests/Unit/Documentation/CloneableDataExampleTest.php new file mode 100644 index 0000000..25709a4 --- /dev/null +++ b/tests/Unit/Documentation/CloneableDataExampleTest.php @@ -0,0 +1,99 @@ + '', + 'content' => '', + 'status' => Status::BACKLOG->value, + 'dueDate' => null + ], $emptyTodo->toArray()); + + $todo = $emptyTodo->with( + title: 'Learn PHP DTO', + content: 'Learn how to use PHP DTO', + status: Status::IN_PROGRESS + ); + + self::assertEquals([ + 'title' => 'Learn PHP DTO', + 'content' => 'Learn how to use PHP DTO', + 'status' => Status::IN_PROGRESS->value, + 'dueDate' => null + ], $todo->toArray()); + + $dueDate = new DateTimeImmutable('2021-10-10'); + $todoWithDueDate = $todo->with(dueDate: $dueDate); + + self::assertEquals( + $dueDate->format('Y-m-d H:i'), + $todoWithDueDate->dueDate?->format('Y-m-d H:i') + ); + } + + /** + * @throws Throwable + */ + #[Test] + public function it_can_clone_data_instances_with_computed_properties(): void + { + $person = new PersonData(firstName: 'John', lastName: 'Doe'); + + self::assertEquals('John Doe', $person->fullName); + + $personWithFirstName = $person->with(firstName: 'Jane'); + + self::assertEquals('Jane Doe', $personWithFirstName->fullName); + } + + /** + * @throws Throwable + */ + #[Test] + public function it_can_clone_data_instances_by_passing_json(): void + { + $person = new PersonData(firstName: 'John', lastName: 'Doe'); + + $personWithFirstName = $person->with('{"firstName": "Jane"}'); + + self::assertEquals('Jane Doe', $personWithFirstName->fullName); + } + + /** + * @throws Throwable + */ + #[Test] + public function it_can_clone_data_instances_by_passing_std_object(): void + { + $person = new PersonData(firstName: 'John', lastName: 'Doe'); + + $object = new stdClass(); + $object->firstName = 'Jane'; + + $personWithFirstName = $person->with($object); + + self::assertEquals('Jane Doe', $personWithFirstName->fullName); + } +} diff --git a/tests/Unit/Documentation/NormalizersExampleTest.php b/tests/Unit/Documentation/NormalizersExampleTest.php index f749615..5762c74 100644 --- a/tests/Unit/Documentation/NormalizersExampleTest.php +++ b/tests/Unit/Documentation/NormalizersExampleTest.php @@ -6,13 +6,13 @@ use Nuxtifyts\PhpDto\Tests\Unit\UnitCase; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; -use Nuxtifyts\PhpDto\Tests\Dummies\DocsDummies\Todo; +use Nuxtifyts\PhpDto\Tests\Dummies\DocsDummies\TodoData; use Nuxtifyts\PhpDto\Tests\Dummies\DocsDummies\NonData\Goal; use DateTimeImmutable; use DateTimeInterface; use Throwable; -#[UsesClass(Todo::class)] +#[UsesClass(TodoData::class)] #[UsesClass(Goal::class)] final class NormalizersExampleTest extends UnitCase { @@ -30,7 +30,7 @@ public function will_be_able_to_normalize_instance_of_goal_class_to_todo_class() dueDate: $now ); - $todo = Todo::from($goal); + $todo = TodoData::from($goal); self::assertEquals( [ diff --git a/tests/Unit/Documentation/QuickStartExampleTest.php b/tests/Unit/Documentation/QuickStartExampleTest.php index e2d7778..bb3fb29 100644 --- a/tests/Unit/Documentation/QuickStartExampleTest.php +++ b/tests/Unit/Documentation/QuickStartExampleTest.php @@ -2,7 +2,7 @@ namespace Nuxtifyts\PhpDto\Tests\Unit\Documentation; -use Nuxtifyts\PhpDto\Tests\Dummies\DocsDummies\Todo; +use Nuxtifyts\PhpDto\Tests\Dummies\DocsDummies\TodoData; use Nuxtifyts\PhpDto\Tests\Unit\UnitCase; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -11,7 +11,7 @@ use DateTimeInterface; use Throwable; -#[UsesClass(Todo::class)] +#[UsesClass(TodoData::class)] final class QuickStartExampleTest extends UnitCase { /** @@ -29,14 +29,14 @@ public function will_perform_serialize_and_deserialize_on_data_transfer_objects_ 'dueDate' => $now->format(DateTimeInterface::ATOM) ]; - $todo = new Todo( + $todo = new TodoData( title: 'Learn PHP DTO', content: 'Learn how to use PHP DTO', status: Status::READY, dueDate: $now ); - $todoFrom = Todo::from([ + $todoFrom = TodoData::from([ 'title' => 'Learn PHP DTO', 'content' => 'Learn how to use PHP DTO', 'status' => 'ready',