Serializer::getClassMetadata()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
ccs 1
cts 1
cp 1
crap 1
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Mapado\RestClientSdk\Model;
6
7
use DateTime;
8
use DateTimeImmutable;
9
use DateTimeInterface;
10
use libphonenumber\PhoneNumber;
11
use libphonenumber\PhoneNumberFormat;
12
use libphonenumber\PhoneNumberUtil;
13
use Mapado\RestClientSdk\Exception\MissingSetterException;
14
use Mapado\RestClientSdk\Exception\SdkException;
15
use Mapado\RestClientSdk\Helper\ArrayHelper;
16
use Mapado\RestClientSdk\Mapping;
17
use Mapado\RestClientSdk\Mapping\ClassMetadata;
18
use Mapado\RestClientSdk\SdkClient;
19
use Mapado\RestClientSdk\UnitOfWork;
20
use Symfony\Component\PropertyAccess\Exception\InvalidArgumentException;
21
use Symfony\Component\PropertyAccess\PropertyAccess;
22
use Symfony\Component\PropertyAccess\PropertyAccessor;
23
24
/**
25
 * Class Serializer
26
 *
27
 * @author Julien Deniau <[email protected]>
28
 */
29
class Serializer
30
{
31
    /**
32
     * mapping
33
     *
34
     * @var Mapping
35
     */
36
    private $mapping;
37
38
    /**
39
     * @var SdkClient
40
     */
41
    private $sdk;
42
43
    /**
44
     * @var UnitOfWork
45
     */
46
    private $unitOfWork;
47
48
    /**
49
     * @var PropertyAccessor
50
     */
51
    private $propertyAccessor;
52
53
    public function __construct(Mapping $mapping, UnitOfWork $unitOfWork)
54
    {
55 1
        $this->mapping = $mapping;
56 1
        $this->unitOfWork = $unitOfWork;
57 1
        $this->propertyAccessor = PropertyAccess::createPropertyAccessor();
58 1
    }
59
60
    /**
61
     * @required
62
     */
63
    public function setSdk(SdkClient $sdk): self
64
    {
65 1
        $this->sdk = $sdk;
66
67 1
        return $this;
68
    }
69
70
    /**
71
     * serialize entity for POST and PUT
72
     */
73
    public function serialize(
74
        object $entity,
75
        string $modelName,
76
        array $context = []
77
    ): array {
78 1
        $out = $this->recursiveSerialize($entity, $modelName, 0, $context);
79
80 1
        if (is_string($out)) {
81
            throw new \RuntimeException(
82
                'recursiveSerialize should return an array for level 0 of serialization. This should not happen.'
83
            );
84
        }
85
86 1
        return $out;
87
    }
88
89
    public function deserialize(array $data, string $className): object
90
    {
91 1
        $className = $this->resolveRealClassName($data, $className);
92
93 1
        $classMetadata = $this->mapping->getClassMetadata($className);
94
95 1
        $attributeList = $classMetadata->getAttributeList();
96
97 1
        $instance = new $className();
98
99 1
        if ($attributeList) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $attributeList of type Mapado\RestClientSdk\Mapping\Attribute[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
100 1
            foreach ($attributeList as $attribute) {
101 1
                $key = $attribute->getSerializedKey();
102
103 1
                if (!ArrayHelper::arrayHas($data, $key)) {
104 1
                    continue;
105
                }
106
107 1
                $value = ArrayHelper::arrayGet($data, $key);
108
109 1
                $attributeName = $attribute->getAttributeName();
110 1
                $this->throwIfAttributeIsNotWritable($instance, $attributeName);
111
112 1
                $relation = $classMetadata->getRelation($key);
113 1
                if ($relation) {
114 1
                    if (is_string($value)) {
115 1
                        $value = $this->sdk->createProxy($value);
116 1
                    } elseif (is_array($value)) {
117 1
                        $targetEntity = $relation->getTargetEntity();
118 1
                        $relationClassMetadata = $this->mapping->getClassMetadata(
119 1
                            $targetEntity
120
                        );
121
122 1
                        if ($relation->isManyToOne()) {
123 1
                            $value = $this->deserialize(
124 1
                                $value,
125 1
                                $relationClassMetadata->getModelName()
126
                            );
127
                        } else {
128
                            // One-To-Many association
129 1
                            $list = [];
130 1
                            foreach ($value as $item) {
131 1
                                if (is_string($item)) {
132
                                    $list[] = $this->sdk->createProxy($item);
133 1
                                } elseif (is_array($item)) {
134 1
                                    $list[] = $this->deserialize(
135 1
                                        $item,
136 1
                                        $relationClassMetadata->getModelName()
137
                                    );
138
                                }
139
                            }
140
141 1
                            $value = $list;
142
                        }
143
                    }
144
                }
145
146 1
                if (isset($value)) {
147 1
                    if ('datetime' === $attribute->getType()) {
148 1
                        $this->setDateTimeValue(
149 1
                            $instance,
150
                            $attributeName,
151
                            $value
152
                        );
153
                    } else {
154 1
                        $this->propertyAccessor->setValue(
155 1
                            $instance,
156
                            $attributeName,
157
                            $value
158
                        );
159
                    }
160
                }
161
            }
162
        }
163
164 1
        $classMetadata = $this->getClassMetadata($instance);
165 1
        if ($classMetadata->hasIdentifierAttribute()) {
166 1
            $idGetter = $classMetadata->getIdGetter();
167
168 1
            if ($idGetter) {
169 1
                $callable = [$instance, $idGetter];
170 1
                $identifier = is_callable($callable)
171 1
                    ? call_user_func($callable)
172 1
                    : null;
173
174 1
                if ($identifier) {
175 1
                    $this->unitOfWork->registerClean(
176 1
                        (string) $identifier,
177
                        $instance
178
                    );
179
                }
180
            }
181
        }
182
183 1
        return $instance;
184
    }
185
186
    /**
187
     * If provided class name is abstract (a base class), the real class name (child class)
188
     * may be available in some data fields.
189
     */
190
    private function resolveRealClassName(
191
        array $data,
192
        string $className
193
    ): string {
194 1
        if (!empty($data['@id'])) {
195 1
            $classMetadata = $this->mapping->tryGetClassMetadataById(
196 1
                $data['@id']
197
            );
198
199 1
            if ($classMetadata) {
200 1
                return $classMetadata->getModelName();
201
            }
202
        }
203
204
        // Real class name could also be retrieved from @type property.
205 1
        return $className;
206
    }
207
208
    /**
209
     * @return array|string
210
     */
211
    private function recursiveSerialize(
212
        object $entity,
213
        string $modelName,
214
        int $level = 0,
215
        array $context = []
216
    ) {
217 1
        $classMetadata = $this->mapping->getClassMetadata($modelName);
218
219 1
        if ($level > 0 && empty($context['serializeRelation'])) {
220 1
            if ($classMetadata->hasIdentifierAttribute()) {
221 1
                $tmpId = $entity->{$classMetadata->getIdGetter()}();
222 1
                if ($tmpId) {
223 1
                    return $tmpId;
224
                }
225
            }
226
        }
227
228 1
        $attributeList = $classMetadata->getAttributeList();
229
230 1
        $out = [];
231 1
        if (!empty($attributeList)) {
232 1
            foreach ($attributeList as $attribute) {
233 1
                $method = 'get' . ucfirst($attribute->getAttributeName());
234
235 1
                if ($attribute->isIdentifier() && !$entity->{$method}()) {
236 1
                    continue;
237
                }
238 1
                $relation = $classMetadata->getRelation(
239 1
                    $attribute->getSerializedKey()
240
                );
241
242 1
                $data = $entity->{$method}();
243
244
                if (
245 1
                    null === $data &&
246 1
                    $relation &&
247 1
                    $relation->isManyToOne() &&
248 1
                    $level > 0
249
                ) {
250
                    /*
251
                        We only serialize the root many-to-one relations to prevent, hopefully,
252
                        unlinked and/or duplicated content. For instance, a cart with cartItemList containing
253
                        null values for the cart [{ cart => null, ... }] may lead the creation of
254
                        CartItem entities explicitly bound to a null Cart instead of the created/updated Cart.
255
                     */
256 1
                    continue;
257 1
                } elseif ($data instanceof DateTimeInterface) {
258 1
                    $data = $data->format('c');
259 1
                } elseif (is_object($data) && $data instanceof PhoneNumber) {
260 1
                    $phoneNumberUtil = PhoneNumberUtil::getInstance();
261 1
                    $data = $phoneNumberUtil->format(
262 1
                        $data,
263 1
                        PhoneNumberFormat::INTERNATIONAL
264
                    );
265
                } elseif (
266 1
                    is_object($data) &&
267 1
                    $relation &&
268 1
                    $this->mapping->hasClassMetadata(
269 1
                        $relation->getTargetEntity()
270
                    )
271
                ) {
272 1
                    $relationClassMetadata = $this->mapping->getClassMetadata(
273 1
                        $relation->getTargetEntity()
274
                    );
275
276 1
                    if (!$relationClassMetadata->hasIdentifierAttribute()) {
277 1
                        $data = $this->recursiveSerialize(
278 1
                            $data,
279 1
                            $relation->getTargetEntity(),
280 1
                            $level + 1,
281 1
                            $context
282
                        );
283
                    } else {
284 1
                        $idAttribute = $relationClassMetadata->getIdentifierAttribute();
285
                        $idGetter =
286 1
                            'get' . ucfirst($idAttribute->getAttributeName());
287
288
                        if (
289 1
                            method_exists($data, $idGetter) &&
290 1
                            $data->{$idGetter}()
291
                        ) {
292 1
                            $data = $data->{$idGetter}();
293 1
                        } elseif ($relation->isManyToOne()) {
294 1
                            if ($level > 0) {
295 1
                                continue;
296
                            } else {
297 1
                                throw new SdkException(
298 1
                                    'Case not allowed for now'
299
                                );
300
                            }
301
                        }
302
                    }
303 1
                } elseif (is_array($data)) {
304 1
                    $newData = [];
305 1
                    foreach ($data as $key => $item) {
306 1
                        if ($item instanceof DateTimeInterface) {
307 1
                            $newData[$key] = $item->format('c');
308
                        } elseif (
309 1
                            is_object($item) &&
310 1
                            $relation &&
311 1
                            $this->mapping->hasClassMetadata(
312 1
                                $relation->getTargetEntity()
313
                            )
314
                        ) {
315
                            $serializeRelation =
316 1
                                !empty($context['serializeRelations']) &&
317 1
                                in_array(
318 1
                                    $relation->getSerializedKey(),
319 1
                                    $context['serializeRelations']
320
                                );
321
322 1
                            $newData[$key] = $this->recursiveSerialize(
323 1
                                $item,
324 1
                                $relation->getTargetEntity(),
325 1
                                $level + 1,
326 1
                                ['serializeRelation' => $serializeRelation]
327
                            );
328
                        } else {
329 1
                            $newData[$key] = $item;
330
                        }
331
                    }
332 1
                    $data = $newData;
333
                }
334
335 1
                $key = $attribute->getSerializedKey();
336
337 1
                $out[$key] = $data;
338
            }
339
        }
340
341 1
        return $out;
342
    }
343
344
    private function getClassMetadataFromId(string $id): ?ClassMetadata
345
    {
346
        $key = $this->mapping->getKeyFromId($id);
347
348
        return $this->mapping->getClassMetadataByKey($key);
349
    }
350
351
    private function getClassMetadata(object $entity): ClassMetadata
352
    {
353 1
        return $this->mapping->getClassMetadata(get_class($entity));
354
    }
355
356
    private function throwIfAttributeIsNotWritable(
357
        object $instance,
358
        string $attribute
359
    ): void {
360 1
        if (!$this->propertyAccessor->isWritable($instance, $attribute)) {
361 1
            throw new MissingSetterException(
362 1
                sprintf(
363 1
                    'Property %s is not writable for class %s. Please make it writable. You can check the property-access documentation here : https://symfony.com/doc/current/components/property_access.html#writing-to-objects',
364
                    $attribute,
365 1
                    get_class($instance)
366
                )
367
            );
368
        }
369 1
    }
370
371
    private function setDateTimeValue(
372
        object $instance,
373
        string $attributeName,
374
        string $value
375
    ): void {
376
        try {
377 1
            $this->propertyAccessor->setValue(
378 1
                $instance,
379
                $attributeName,
380 1
                new DateTime($value)
381
            );
382 1
        } catch (InvalidArgumentException $e) {
383
            if (
384
                false ===
385 1
                    mb_strpos(
386 1
                        $e->getMessage(),
387 1
                        'Expected argument of type "DateTimeImmutable", "DateTime" given' // symfony < 4.4 message
388
                    ) &&
389
                false ===
390 1
                    mb_strpos(
391 1
                        $e->getMessage(),
392 1
                        'Expected argument of type "DateTimeImmutable", "instance of DateTime" given' // symfony >= 4.4 message
393
                    )
394
            ) {
395
                // not an issue with DateTimeImmutable, then rethrow exception
396
                throw $e;
397
            }
398
399
            // The excepted value is a DateTimeImmutable, so let's do that
400 1
            $this->propertyAccessor->setValue(
401 1
                $instance,
402
                $attributeName,
403 1
                new DateTimeImmutable($value)
404
            );
405
        } catch (\TypeError $e) {
406
            // this `catch` block can be dropped when minimum support of symfony/property-access is 3.4
407
            if (
408
                false ===
409
                mb_strpos(
410
                    $e->getMessage(),
411
                    'must be an instance of DateTimeImmutable, instance of DateTime given'
412
                )
413
            ) {
414
                // not an issue with DateTimeImmutable, then rethrow exception
415
                throw $e;
416
            }
417
418
            // The excepted value is a DateTimeImmutable, so let's do that
419
            $this->propertyAccessor->setValue(
420
                $instance,
421
                $attributeName,
422
                new DateTimeImmutable($value)
423
            );
424
        }
425 1
    }
426
}
427