Skip to content

Commit d92c841

Browse files
committed
Added cloneable data contract and trait
1 parent 0ea7981 commit d92c841

File tree

5 files changed

+267
-1
lines changed

5 files changed

+267
-1
lines changed

src/Concerns/CloneableData.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
namespace Nuxtifyts\PhpDto\Concerns;
4+
5+
use Nuxtifyts\PhpDto\Contexts\ClassContext;
6+
use Nuxtifyts\PhpDto\Exceptions\DataCreationException;
7+
use Nuxtifyts\PhpDto\Support\Traits\HasNormalizers;
8+
use ReflectionClass;
9+
use Throwable;
10+
11+
trait CloneableData
12+
{
13+
use HasNormalizers;
14+
15+
/**
16+
* @throws DataCreationException
17+
*/
18+
public function with(mixed ...$args): static
19+
{
20+
try {
21+
$value = static::normalizeValue($args, static::class);
22+
23+
/** @var ClassContext<static> $context */
24+
$context = ClassContext::getInstance(new ReflectionClass(static::class));
25+
26+
return $context->hasComputedProperties
27+
? $this->cloneInstanceWithConstructorCall($context, $value)
28+
: $this->cloneInstanceWithoutConstructorCall($context, $value);
29+
} catch (Throwable $t) {
30+
throw DataCreationException::unableToCloneInstanceWithNewData($t);
31+
}
32+
}
33+
34+
/**
35+
* @param ClassContext<static> $context
36+
* @param array<string, mixed> $value
37+
*
38+
* @throws Throwable
39+
*/
40+
protected function cloneInstanceWithConstructorCall(ClassContext $context, array $value): static
41+
{
42+
/** @var array<string, mixed> $args */
43+
$args = [];
44+
45+
foreach ($context->constructorParams as $paramName) {
46+
$propertyContext = $context->properties[$paramName] ?? null;
47+
48+
if (!$propertyContext) {
49+
DataCreationException::invalidProperty();
50+
}
51+
52+
$args[$paramName] = array_key_exists($propertyContext->propertyName, $value)
53+
? $value[$paramName]
54+
: $this->{$propertyContext->propertyName};
55+
}
56+
57+
return $context->newInstanceWithConstructorCall(...$args);
58+
}
59+
60+
/**
61+
* @param ClassContext<static> $context
62+
* @param array<string, mixed> $value
63+
*
64+
* @throws Throwable
65+
*/
66+
protected function cloneInstanceWithoutConstructorCall(ClassContext $context, array $value): static
67+
{
68+
$instance = $context->newInstanceWithoutConstructor();
69+
70+
foreach ($context->properties as $propertyContext) {
71+
$instance->{$propertyContext->propertyName} =
72+
array_key_exists($propertyContext->propertyName, $value)
73+
? $value[$propertyContext->propertyName]
74+
: $this->{$propertyContext->propertyName};
75+
}
76+
77+
return $instance;
78+
}
79+
}

src/Contracts/CloneableData.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace Nuxtifyts\PhpDto\Contracts;
4+
5+
use Nuxtifyts\PhpDto\Exceptions\DataCreationException;
6+
7+
interface CloneableData
8+
{
9+
/**
10+
* @throws DataCreationException
11+
*/
12+
public function with(mixed ...$args): static;
13+
}

src/Data.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@
44

55
use Nuxtifyts\PhpDto\Contracts\BaseData as BaseDataContract;
66
use Nuxtifyts\PhpDto\Contracts\EmptyData as EmptyDataContract;
7+
use Nuxtifyts\PhpDto\Contracts\CloneableData as CloneableDataContract;
78
use Nuxtifyts\PhpDto\Concerns\BaseData;
89
use Nuxtifyts\PhpDto\Concerns\EmptyData;
10+
use Nuxtifyts\PhpDto\Concerns\CloneableData;
911

1012
abstract readonly class Data implements
1113
BaseDataContract,
12-
EmptyDataContract
14+
EmptyDataContract,
15+
CloneableDataContract
1316
{
1417
use BaseData;
1518
use EmptyData;
19+
use CloneableData;
1620
}

src/Exceptions/DataCreationException.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class DataCreationException extends Exception
1010
protected const int UNABLE_TO_CREATE_INSTANCE = 0;
1111
protected const int INVALID_PROPERTY = 1;
1212
protected const int UNABLE_TO_CREATE_EMPTY_INSTANCE = 2;
13+
protected const int UNABLE_TO_CLONE_INSTANCE_WITH_NEW_DATA = 3;
1314

1415
public static function unableToCreateInstance(
1516
string $class,
@@ -40,4 +41,15 @@ public static function unableToCreateEmptyInstance(
4041
previous: $previous
4142
);
4243
}
44+
45+
public static function unableToCloneInstanceWithNewData(
46+
string $class,
47+
?Throwable $previous = null
48+
): self {
49+
return new self(
50+
message: "Unable to clone instance of class {$class} with new data",
51+
code: self::UNABLE_TO_CLONE_INSTANCE_WITH_NEW_DATA,
52+
previous: $previous
53+
);
54+
}
4355
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
<?php
2+
3+
namespace Nuxtifyts\PhpDto\Tests\Unit\Concerns;
4+
5+
use Nuxtifyts\PhpDto\Attributes\Property\Computed;
6+
use Nuxtifyts\PhpDto\Contracts\CloneableData as CloneableDataContract;
7+
use Nuxtifyts\PhpDto\Data;
8+
use Nuxtifyts\PhpDto\Exceptions\DataCreationException;
9+
use Nuxtifyts\PhpDto\Tests\Dummies\AddressData;
10+
use Nuxtifyts\PhpDto\Tests\Dummies\CoordinatesData;
11+
use Nuxtifyts\PhpDto\Tests\Dummies\CountryData;
12+
use Nuxtifyts\PhpDto\Tests\Dummies\PersonData;
13+
use Nuxtifyts\PhpDto\Tests\Unit\UnitCase;
14+
use PHPUnit\Framework\Attributes\CoversClass;
15+
use PHPUnit\Framework\Attributes\DataProvider;
16+
use PHPUnit\Framework\Attributes\Test;
17+
use PHPUnit\Framework\Attributes\UsesClass;
18+
use DateTimeImmutable;
19+
use Throwable;
20+
21+
#[CoversClass(Data::class)]
22+
#[CoversClass(DataCreationException::class)]
23+
#[UsesClass(PersonData::class)]
24+
#[UsesClass(Computed::class)]
25+
#[UsesClass(AddressData::class)]
26+
#[UsesClass(CoordinatesData::class)]
27+
#[UsesClass(CountryData::class)]
28+
final class CloneableDataTest extends UnitCase
29+
{
30+
/**
31+
* @throws Throwable
32+
*/
33+
#[Test]
34+
public function will_throw_an_exception_if_invalid_value_for_property_is_passed(): void
35+
{
36+
$person = new PersonData(firstName: 'John', lastName: 'Doe');
37+
38+
self::expectException(DataCreationException::class);
39+
40+
$person->with(firstName: new DateTimeImmutable());
41+
}
42+
43+
/**
44+
* @throws Throwable
45+
*/
46+
#[Test]
47+
public function will_throw_an_exception_if_dto_declaration_is_invalid(): void
48+
{
49+
$object = new readonly class ('firstName', 'lastName') extends Data {
50+
#[Computed]
51+
public string $fullName;
52+
public string $familyName;
53+
54+
public function __construct(
55+
public string $firstName,
56+
string $lastName,
57+
) {
58+
$this->familyName = $lastName;
59+
$this->fullName = $this->firstName . ' ' . $this->familyName;
60+
}
61+
};
62+
63+
self::expectException(DataCreationException::class);
64+
$object->with(lastName: 'Doe');
65+
}
66+
67+
/**
68+
* @param array<string, mixed> $args
69+
* @param array<string, mixed> $expectedProperties
70+
*
71+
* @throws Throwable
72+
*/
73+
#[Test]
74+
#[DataProvider('will_be_able_to_clone_data_provider')]
75+
public function will_be_able_to_clone_data(
76+
CloneableDataContract $object,
77+
array $args,
78+
array $expectedProperties
79+
): void {
80+
$newObject = $object->with(...$args);
81+
82+
self::assertNotSame($object, $newObject);
83+
84+
foreach ($expectedProperties as $propertyName => $value) {
85+
self::assertObjectHasProperty($propertyName, $newObject);
86+
self::assertEquals($value, $newObject->{$propertyName});
87+
}
88+
}
89+
90+
/**
91+
* @return array<string, mixed>
92+
*/
93+
public static function will_be_able_to_clone_data_provider(): array
94+
{
95+
return [
96+
'Will be able to clone scalar type properties' => [
97+
'object' => new PersonData('John', 'Doe'),
98+
'args' => [
99+
'firstName' => 'Jane',
100+
'lastName' => 'Doe',
101+
],
102+
'expectedProperties' => [
103+
'firstName' => 'Jane',
104+
'lastName' => 'Doe',
105+
],
106+
],
107+
'Will be able to clone data type properties' => [
108+
'object' => new AddressData(
109+
'1234 Elm St',
110+
'City',
111+
'State',
112+
'12345',
113+
new CountryData(
114+
'XX',
115+
'Country'
116+
),
117+
null
118+
),
119+
'args' => [
120+
'country' => new CountryData(
121+
'YY',
122+
'Country 2'
123+
),
124+
'coordinates' => new CoordinatesData(
125+
1.234,
126+
5.678
127+
)
128+
],
129+
'expectedProperties' => [
130+
'street' => '1234 Elm St',
131+
'city' => 'City',
132+
'state' => 'State',
133+
'zip' => '12345',
134+
'country' => new CountryData(
135+
'YY',
136+
'Country 2'
137+
),
138+
'coordinates' => new CoordinatesData(
139+
1.234,
140+
5.678
141+
)
142+
],
143+
],
144+
'Will be able to update computed properties' => [
145+
'object' => new PersonData('John', 'Doe'),
146+
'args' => [
147+
'firstName' => 'Jane',
148+
'lastName' => 'Doe',
149+
],
150+
'expectedProperties' => [
151+
'firstName' => 'Jane',
152+
'lastName' => 'Doe',
153+
'fullName' => 'Jane Doe',
154+
],
155+
]
156+
];
157+
}
158+
}

0 commit comments

Comments
 (0)