Skip to content

Commit 6cc9aa0

Browse files
[2.1] Add factory interface to support self initialized nullable value objects (#15)
* feat add value object factory interface to support nullable value objects in the resource * refactor: handle objects without attributes as empty array * feat: add ValueObjectFactoryTrait * feat: empty resource value arrays initialize value objects * fix: Fix a bug where uninitialized value objects are treated like valid instances, although they should be null (#17)
1 parent e19f99e commit 6cc9aa0

15 files changed

+230
-26
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## v2.1.0 - 2021-07-29
4+
5+
- Add support for self initialized nullable value objects
6+
- Model converter: Empty value objects are now converted to empty hashes
7+
- Model converter: Fix a bug where uninitialized value objects are treated like valid instances, although they should be `null`
8+
39
## v2.0.0 - 2021-07-11
410

511
- Dropped `doctrine/annotations` support. All JSON API model declarations must be php 8 attributes.

docs/08-models.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,12 @@ class User implements CustomAttributeGetterInterface, CustomAttributeSetterInter
9393
}
9494
```
9595

96-
The model conversion supports the filling and conversion of all types of member properties. This also includes object properties, under two conditions:
97-
* the value object also contains `Attribute` properties (`Id` and `Type` is only supported on root level)
98-
* when converting from JSON API resource to model, the object property has to be an initialized object instance
96+
The model conversion supports the filling and conversion of all types of member properties. This also applies to object properties.
97+
However, there are some points to consider here:
98+
* the value object should contain `Attribute` properties, although it's not required (`Id` and `Type` is only supported on root level)
99+
* when converting from JSON API resource to model the object property has to be an initialized object instance OR
100+
must implement the `\Dogado\JsonApi\Support\Model\ValueObjectFactoryInterface` when it's nullable. Feel free to use the
101+
`ValueObjectFactoryTrait` which contains a simple `create` factory method.
99102

100103
Whenever you want to use objects as JSON API attributes which do not hold attribute definitions themselves, you have to use either the `CustomAttributeGetterInterface` or `CustomAttributeSetterInterface`, depending on your needs.
101104

src/Converter/ResourceConverter.php

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Dogado\JsonApi\Model\Resource\ResourceInterface;
99
use Dogado\JsonApi\Support\Model\CustomAttributeSetterInterface;
1010
use Dogado\JsonApi\Support\Model\DataModelAnalyser;
11+
use Dogado\JsonApi\Support\Model\ValueObjectFactoryInterface;
1112
use ReflectionClass;
1213
use ReflectionException;
1314
use ReflectionNamedType;
@@ -66,7 +67,7 @@ public function toModel(?ResourceInterface $resource, object $model): object
6667
protected function setValue(ReflectionClass $reflection, object $model, array $propertyMap, mixed $value): void
6768
{
6869
$propertyName = array_shift($propertyMap);
69-
if (!$reflection->hasProperty($propertyName)) {
70+
if (null === $propertyName || !$reflection->hasProperty($propertyName)) {
7071
return;
7172
}
7273

@@ -78,34 +79,48 @@ protected function setValue(ReflectionClass $reflection, object $model, array $p
7879
}
7980

8081
$property = $reflection->getProperty($propertyName);
81-
if (null !== $property->getType() && !$property->getType() instanceof ReflectionNamedType) {
82+
$propertyType = $property->getType();
83+
if (null !== $propertyType && !$propertyType instanceof ReflectionNamedType) {
8284
// other reflection types, like union types, are not supported
8385
return;
8486
}
8587

8688
if (
87-
null === $property->getType() ||
88-
'mixed' === $property->getType()->getName() ||
89-
$property->getType()->getName() === gettype($value)
89+
null === $propertyType ||
90+
'mixed' === $propertyType->getName() ||
91+
$propertyType->getName() === gettype($value)
9092
) {
9193
$property->setAccessible(true);
9294
$property->setValue($model, $value);
9395
return;
9496
}
9597

96-
if (class_exists($property->getType()->getName()) && 0 < count($propertyMap)) {
98+
$propertyClassName = $propertyType->getName();
99+
if (class_exists($propertyClassName)) {
100+
if (null === $value) {
101+
return;
102+
}
103+
97104
$property->setAccessible(true);
98105
$valueObject = $property->getValue($model);
99106
if (null === $valueObject) {
100-
return;
107+
if (
108+
!(new ReflectionClass($propertyClassName))
109+
->implementsInterface(ValueObjectFactoryInterface::class)
110+
) {
111+
return;
112+
}
113+
/** @var ValueObjectFactoryInterface $propertyClassName */
114+
$valueObject = $propertyClassName::create();
115+
$property->setValue($model, $valueObject);
101116
}
102117

103118
$this->setValue(new ReflectionClass($valueObject), $valueObject, $propertyMap, $value);
104119
return;
105120
}
106121

107122
if (null === $value) {
108-
if (!$property->getType()->allowsNull()) {
123+
if (!$propertyType->allowsNull()) {
109124
throw DataModelSerializerException::propertyIsNotNullable(
110125
$this->resource ? $this->resource->type() : 'unknown',
111126
get_class($model),
@@ -122,7 +137,7 @@ protected function setValue(ReflectionClass $reflection, object $model, array $p
122137
return;
123138
}
124139

125-
switch ($property->getType()->getName()) {
140+
switch ($propertyType->getName()) {
126141
case 'boolean':
127142
case 'bool':
128143
$property->setAccessible(true);

src/Support/Model/DataModelAnalyser.php

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -188,12 +188,20 @@ protected function parseValueObject(
188188
// In case the value object has no attributes, we must register the attribute prefix with a null value.
189189
$this->propertyMap['attributes'] = array_merge(
190190
$this->propertyMap['attributes'],
191-
$self->getAttributesPropertyMap() ?: [$attributePrefix => $propertyPrefix]
192-
);
193-
$this->resourceValueMap['attributes'] = array_merge(
194-
$this->resourceValueMap['attributes'],
195-
$self->getAttributeValues() ?: [$attributePrefix => null]
191+
[$attributePrefix => $propertyPrefix],
192+
$self->getAttributesPropertyMap(),
196193
);
194+
if (null === $valueObject) {
195+
$this->resourceValueMap['attributes'] = array_merge(
196+
$this->resourceValueMap['attributes'],
197+
[$attributePrefix => null]
198+
);
199+
} else {
200+
$this->resourceValueMap['attributes'] = array_merge(
201+
$this->resourceValueMap['attributes'],
202+
$self->getAttributeValues() ?: [$attributePrefix => []]
203+
);
204+
}
197205
}
198206

199207
private function registerAttributeValue(
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Dogado\JsonApi\Support\Model;
6+
7+
interface ValueObjectFactoryInterface
8+
{
9+
public static function create(): self;
10+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Dogado\JsonApi\Support\Model;
6+
7+
trait ValueObjectFactoryTrait
8+
{
9+
public static function create(): self
10+
{
11+
return new self();
12+
}
13+
}

tests/Converter/ModelConverterTest.php

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,8 @@ public function testModelToResource(): void
3030
'values' => [
3131
'number' => $model->getValueObject()->getTest(),
3232
],
33-
'empty-values' => [
34-
'number' => null,
35-
],
33+
'empty-values' => null,
34+
'valueObjectWithoutAttributes' => [],
3635
'createdAt' => $date->format(DateTimeInterface::ATOM),
3736
'updatedAt' => null,
3837
]);

tests/Converter/ModelConverterTest/DataModel.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ class DataModel implements CustomAttributeGetterInterface, CustomAttributeSetter
2626
#[Attribute('empty-values')]
2727
private ?ValueObject $valueObjectNotInitialized = null;
2828

29+
#[Attribute]
30+
private ValueObjectWithoutAttributes $valueObjectWithoutAttributes;
31+
2932
#[Attribute(ignoreOnNull: true)]
3033
private ?string $ignoreOnNull = null;
3134

@@ -39,6 +42,7 @@ public function __construct(DateTime $createdAt)
3942
{
4043
$this->createdAt = $createdAt;
4144
$this->valueObject = new ValueObject();
45+
$this->valueObjectWithoutAttributes = new ValueObjectWithoutAttributes();
4246
}
4347

4448
public function __getAttribute(string $property): ?string
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
namespace Dogado\JsonApi\Tests\Converter\ModelConverterTest;
4+
5+
class ValueObjectWithoutAttributes
6+
{
7+
private ?int $someProperty = 1213435664;
8+
}

tests/Converter/ResourceConverterTest.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
use Dogado\JsonApi\Model\Resource\Resource;
99
use Dogado\JsonApi\Tests\Converter\ResourceConverterTest\DataModel;
1010
use Dogado\JsonApi\Tests\Converter\ResourceConverterTest\DataModelWithoutTypeAnnotation;
11+
use Dogado\JsonApi\Tests\Converter\ResourceConverterTest\ValueObjectWithFactory;
12+
use Dogado\JsonApi\Tests\Converter\ResourceConverterTest\ValueObjectWithFactoryWrapper;
1113
use Dogado\JsonApi\Tests\TestCase;
1214
use ReflectionException;
1315
use stdClass;
@@ -41,6 +43,9 @@ public function testResourceToModel(): void
4143
'number' => (string) $this->faker()->numberBetween(),
4244
'ignoreOnNull' => $this->faker()->text(),
4345
],
46+
'nullableValueObject' => [
47+
'item' => $this->faker()->numberBetween(),
48+
],
4449
'arrayItems' => [
4550
$this->faker->slug() => $this->faker->text(),
4651
$this->faker->slug() => $this->faker->text(),
@@ -82,6 +87,11 @@ public function testResourceToModel(): void
8287
$attributes->getSubCollection('values')->getRequired('ignoreOnNull'),
8388
$model->getValueObject()->getIgnoreOnNull()
8489
);
90+
$this->assertNotNull($model->getNullableValueObject());
91+
$this->assertEquals(
92+
(int) $attributes->getSubCollection('nullableValueObject')->getRequired('item'),
93+
$model->getNullableValueObject()->getItem()
94+
);
8595
$this->assertEquals(
8696
$attributes->get('arrayItems'),
8797
$model->getArrayItems()
@@ -168,4 +178,62 @@ public function testNullableResourceWithInvalidModel(): void
168178
$this->expectExceptionObject(DataModelSerializerException::typeAnnotationMissing(stdClass::class));
169179
(new ResourceConverter())->toModel(null, new stdClass());
170180
}
181+
182+
public function testNullableValueObject(): void
183+
{
184+
$resource = new Resource('dummy-deserializer-model', (string) $this->faker()->numberBetween(), [
185+
'notNullable' => $this->faker()->slug(),
186+
'createdAt' => $this->faker()->dateTime()->format(DateTimeInterface::ATOM),
187+
]);
188+
189+
$model = new DataModel();
190+
(new ResourceConverter())->toModel($resource, $model);
191+
$this->assertNull($model->getNullableValueObject());
192+
}
193+
194+
public function testEmptyArraysInitializeNullableValueObjects(): void
195+
{
196+
$resource = new Resource('dummy-deserializer-model', (string) $this->faker()->numberBetween(), [
197+
'notNullable' => $this->faker()->slug(),
198+
'createdAt' => $this->faker()->dateTime()->format(DateTimeInterface::ATOM),
199+
'nullableValueObject' => [],
200+
]);
201+
202+
$model = new DataModel();
203+
(new ResourceConverter())->toModel($resource, $model);
204+
$this->assertInstanceOf(ValueObjectWithFactory::class, $model->getNullableValueObject());
205+
$this->assertNull($model->getNested());
206+
}
207+
208+
public function testNestedEmptyArraysInitializeNullableValueObjects(): void
209+
{
210+
$resource = new Resource('dummy-deserializer-model', (string) $this->faker()->numberBetween(), [
211+
'notNullable' => $this->faker()->slug(),
212+
'createdAt' => $this->faker()->dateTime()->format(DateTimeInterface::ATOM),
213+
'nested' => [
214+
'nullableValueObject' => [],
215+
],
216+
]);
217+
218+
$model = new DataModel();
219+
(new ResourceConverter())->toModel($resource, $model);
220+
$this->assertInstanceOf(ValueObjectWithFactoryWrapper::class, $model->getNested());
221+
$this->assertInstanceOf(ValueObjectWithFactory::class, $model->getNested()->getNullableValueObject());
222+
}
223+
224+
public function testNestedMissingValueDoesNotInitializeValueObjects(): void
225+
{
226+
$resource = new Resource('dummy-deserializer-model', (string) $this->faker()->numberBetween(), [
227+
'notNullable' => $this->faker()->slug(),
228+
'createdAt' => $this->faker()->dateTime()->format(DateTimeInterface::ATOM),
229+
'nested' => [
230+
'nullableValueObject' => null,
231+
],
232+
]);
233+
234+
$model = new DataModel();
235+
(new ResourceConverter())->toModel($resource, $model);
236+
$this->assertInstanceOf(ValueObjectWithFactoryWrapper::class, $model->getNested());
237+
$this->assertNull($model->getNested()->getNullableValueObject());
238+
}
171239
}

tests/Converter/ResourceConverterTest/DataModel.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ class DataModel implements CustomAttributeSetterInterface
4040
#[Attribute('values')]
4141
private ValueObject $valueObject;
4242

43+
#[Attribute]
44+
private ?ValueObjectWithFactory $nullableValueObject = null;
45+
46+
#[Attribute]
47+
private ?ValueObjectWithFactoryWrapper $nested = null;
48+
4349
#[Attribute('arrayItems')]
4450
private ?array $arrayItems = null;
4551

@@ -138,6 +144,16 @@ public function getValueObject(): ValueObject
138144
return $this->valueObject;
139145
}
140146

147+
public function getNullableValueObject(): ?ValueObjectWithFactory
148+
{
149+
return $this->nullableValueObject;
150+
}
151+
152+
public function getNested(): ?ValueObjectWithFactoryWrapper
153+
{
154+
return $this->nested;
155+
}
156+
141157
public function getArrayItems(): ?array
142158
{
143159
return $this->arrayItems;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace Dogado\JsonApi\Tests\Converter\ResourceConverterTest;
4+
5+
use Dogado\JsonApi\Attribute\Attribute;
6+
use Dogado\JsonApi\Support\Model\ValueObjectFactoryInterface;
7+
use Dogado\JsonApi\Support\Model\ValueObjectFactoryTrait;
8+
9+
class ValueObjectWithFactory implements ValueObjectFactoryInterface
10+
{
11+
use ValueObjectFactoryTrait;
12+
13+
#[Attribute]
14+
private ?int $item = null;
15+
16+
public function getItem(): ?int
17+
{
18+
return $this->item;
19+
}
20+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace Dogado\JsonApi\Tests\Converter\ResourceConverterTest;
4+
5+
use Dogado\JsonApi\Attribute\Attribute;
6+
use Dogado\JsonApi\Support\Model\ValueObjectFactoryInterface;
7+
use Dogado\JsonApi\Support\Model\ValueObjectFactoryTrait;
8+
9+
class ValueObjectWithFactoryWrapper implements ValueObjectFactoryInterface
10+
{
11+
use ValueObjectFactoryTrait;
12+
13+
#[Attribute]
14+
private ?ValueObjectWithFactory $nullableValueObject = null;
15+
16+
public function getNullableValueObject(): ?ValueObjectWithFactory
17+
{
18+
return $this->nullableValueObject;
19+
}
20+
}

0 commit comments

Comments
 (0)