• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

FastyBird / json-api / 10239548501

04 Aug 2024 09:53PM UTC coverage: 4.133% (-0.08%) from 4.214%
10239548501

push

github

web-flow
Merge pull request #3 from FastyBird/feature/drop-annotations

Drop doctrine annotations

0 of 67 new or added lines in 2 files covered. (0.0%)

2 existing lines in 2 files now uncovered.

41 of 992 relevant lines covered (4.13%)

0.12 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/Hydrators/Hydrator.php
1
<?php declare(strict_types = 1);
2

3
/**
4
 * Hydrator.php
5
 *
6
 * @license        More in LICENSE.md
7
 * @copyright      https://www.fastybird.com
8
 * @author         Adam Kadlec <adam.kadlec@fastybird.com>
9
 * @package        FastyBird:JsonApi!
10
 * @subpackage     Hydrators
11
 * @since          0.1.0
12
 *
13
 * @date           26.05.20
14
 */
15

16
namespace FastyBird\JsonApi\Hydrators;
17

18
use ArrayAccess;
19
use BackedEnum;
20
use DateTimeInterface;
21
use Doctrine\ORM;
22
use Doctrine\Persistence;
23
use FastyBird\JsonApi\Exceptions;
24
use FastyBird\JsonApi\Helpers;
25
use FastyBird\JsonApi\Hydrators;
26
use Fig\Http\Message\StatusCodeInterface;
27
use IPub\JsonAPIDocument;
28
use Nette;
29
use Nette\Localization;
30
use Nette\Utils;
31
use phpDocumentor;
32
use Ramsey\Uuid;
33
use ReflectionAttribute;
34
use ReflectionClass;
35
use ReflectionException;
36
use ReflectionNamedType;
37
use ReflectionParameter;
38
use ReflectionProperty;
39
use Throwable;
40
use function array_filter;
41
use function array_key_exists;
42
use function array_map;
43
use function array_merge;
44
use function array_reduce;
45
use function array_unique;
46
use function assert;
47
use function call_user_func;
48
use function class_exists;
49
use function count;
50
use function explode;
51
use function gettype;
52
use function in_array;
53
use function interface_exists;
54
use function is_array;
55
use function is_callable;
56
use function is_numeric;
57
use function is_object;
58
use function is_string;
59
use function method_exists;
60
use function sprintf;
61
use function str_contains;
62
use function str_replace;
63
use function strtolower;
64
use function strval;
65
use function trim;
66
use function ucfirst;
67
use function ucwords;
68

69
/**
70
 * Entity hydrator
71
 *
72
 * @template T of object
73
 *
74
 * @package        FastyBird:JsonApi!
75
 * @subpackage     Hydrators
76
 * @author         Adam Kadlec <adam.kadlec@fastybird.com>
77
 */
78
abstract class Hydrator
79
{
80

81
        use Nette\SmartObject;
82

83
        protected const IDENTIFIER_KEY = 'id';
84

85
        /**
86
         * Whether the resource has a client generated id
87
         */
88
        protected string|null $entityIdentifier = null;
89

90
        /**
91
         * The resource attribute keys to hydrate
92
         *
93
         * For example:
94
         *
95
         * ```
96
         * $attributes = [
97
         *  'foo',
98
         *  'bar' => 'baz'
99
         * ];
100
         * ```
101
         *
102
         * Will transfer the `foo` resource attribute to the model `foo` attribute, and the
103
         * resource `bar` attribute to the model `baz` attribute.
104
         *
105
         * @var array<int|string, string>
106
         */
107
        protected array $attributes = [];
108

109
        /**
110
         * The resource composited attribute keys to hydrate
111
         *
112
         * For example:
113
         *
114
         * ```
115
         * $attributes = [
116
         *  'params',
117
         *  'bar' => 'baz'
118
         * ];
119
         * ```
120
         *
121
         * Will transfer the `foo` resource attribute to the model `foo` attribute, and the
122
         * resource `bar` attribute to the model `baz` attribute.
123
         *
124
         * @var array<int|string, string>
125
         */
126
        protected array $compositedAttributes = [];
127

128
        /**
129
         * Resource relationship keys that should be automatically hydrated
130
         *
131
         * @var array<string>
132
         */
133
        protected array $relationships = [];
134

135
        /** @var array<string, string>|null */
136
        private array|null $normalizedAttributes = null;
137

138
        /** @var array<string, string>|null */
139
        private array|null $normalizedCompositedAttributes = null;
140

141
        /** @var array<string, string>|null */
142
        private array|null $normalizedRelationships = null;
143

144
        private Exceptions\JsonApiMultipleError $errors;
145

146
        public function __construct(
147
                protected readonly Persistence\ManagerRegistry $managerRegistry,
148
                protected readonly Localization\Translator $translator,
149
                protected readonly Helpers\CrudReader|null $crudReader = null,
150
        )
151
        {
152
                $this->errors = new Exceptions\JsonApiMultipleError();
×
153
        }
154

155
        /**
156
         * @param T|null $entity
157
         *
158
         * @throws Exceptions\JsonApi
159
         * @throws Throwable
160
         */
161
        public function hydrate(
162
                JsonAPIDocument\IDocument $document,
163
                object|null $entity = null,
164
        ): Utils\ArrayHash
165
        {
166
                $entityMapping = $this->mapEntity($this->getEntityName());
×
167

168
                if (!$document->hasResource()) {
×
169
                        throw new Exceptions\JsonApiError(
×
170
                                StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY,
×
171
                                strval($this->translator->translate('//jsonApi.hydrator.resourceInvalid.heading')),
×
172
                                strval($this->translator->translate('//jsonApi.hydrator.resourceInvalid.message')),
×
173
                                [
×
174
                                        'pointer' => '/data',
×
175
                                ],
×
176
                        );
×
177
                }
178

179
                $resource = $document->getResource();
×
180

181
                $attributes = $this->hydrateAttributes(
×
182
                        $this->getEntityName(),
×
183
                        $resource->getAttributes(),
×
184
                        $entityMapping,
×
185
                        $entity,
×
186
                        null,
×
187
                );
×
188

189
                $relationships = $this->hydrateRelationships(
×
190
                        $resource->getRelationships(),
×
191
                        $entityMapping,
×
192
                        $document->hasIncluded() ? $document->getIncluded() : null,
×
193
                        $entity,
×
194
                );
×
195

196
                if ($this->errors->hasErrors()) {
×
197
                        throw $this->errors;
×
198
                }
199

200
                $result = Utils\ArrayHash::from(array_merge(
×
201
                        [
×
202
                                'entity' => $this->getEntityName(),
×
203
                        ],
×
204
                        $attributes,
×
205
                        $relationships,
×
206
                ));
×
207

208
                if ($entity === null) {
×
209
                        $identifierKey = $this->entityIdentifier ?? self::IDENTIFIER_KEY;
×
210

211
                        try {
212
                                $identifier = $resource->getId();
×
213

214
                                if ($identifier === null || !Uuid\Uuid::isValid($identifier)) {
×
215
                                        throw new Exceptions\JsonApiError(
×
216
                                                StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY,
×
217
                                                strval($this->translator->translate('//jsonApi.hydrator.identifierInvalid.heading')),
×
218
                                                strval($this->translator->translate('//jsonApi.hydrator.identifierInvalid.message')),
×
219
                                                [
×
220
                                                        'pointer' => '/data/id',
×
221
                                                ],
×
222
                                        );
×
223
                                }
224

225
                                $result[$identifierKey] = Uuid\Uuid::fromString($identifier);
×
226

227
                        } catch (JsonAPIDocument\Exceptions\RuntimeException) {
×
228
                                $result[$identifierKey] = Uuid\Uuid::uuid4();
×
229
                        }
230
                }
231

232
                return $result;
×
233
        }
234

235
        /**
236
         * @return class-string<T>
237
         */
238
        abstract public function getEntityName(): string;
239

240
        /**
241
         * @param class-string $entityClassName
242
         *
243
         * @return array<Hydrators\Fields\Field>
244
         *
245
         * @throws Exceptions\InvalidState
246
         */
247
        protected function mapEntity(string $entityClassName): array
248
        {
249
                $entityManager = $this->managerRegistry->getManagerForClass($entityClassName);
×
250

251
                if ($entityManager === null) {
×
252
                        return [];
×
253
                }
254

255
                $classMetadata = $entityManager->getClassMetadata($entityClassName);
×
256

257
                $reflectionProperties = [];
×
258

259
                try {
260
                        if (class_exists($entityClassName)) {
×
261
                                $rc = new ReflectionClass($entityClassName);
×
262

263
                        } else {
264
                                throw new Exceptions\InvalidState('Entity could not be parsed');
×
265
                        }
266
                } catch (ReflectionException) {
×
267
                        throw new Exceptions\InvalidState('Entity could not be parsed');
×
268
                }
269

270
                foreach ($rc->getProperties() as $rp) {
×
271
                        $reflectionProperties[] = $rp->getName();
×
272
                }
273

274
                $constructorRequiredParameters = array_map(
×
275
                        static fn (ReflectionParameter $parameter): string => $parameter->getName(),
×
276
                        array_filter(
×
277
                                $rc->getConstructor()?->getParameters() ?? [],
×
278
                                static fn (ReflectionParameter $parameter): bool => !$parameter->isOptional(),
×
279
                        ),
×
280
                );
×
281

282
                $constructorOptionalParameters = array_map(
×
283
                        static fn (ReflectionParameter $parameter): string => $parameter->getName(),
×
284
                        array_filter(
×
285
                                $rc->getConstructor()?->getParameters() ?? [],
×
286
                                static fn (ReflectionParameter $parameter): bool => $parameter->isOptional(),
×
287
                        ),
×
288
                );
×
289

290
                $entityFields = array_unique(array_merge(
×
291
                        $reflectionProperties,
×
292
                        $classMetadata->getFieldNames(),
×
293
                        $classMetadata->getAssociationNames(),
×
294
                ));
×
295

296
                $fields = [];
×
297

298
                foreach ($entityFields as $fieldName) {
×
299
                        try {
300
                                // Check if property in entity class exists
301
                                $rp = $rc->getProperty($fieldName);
×
302

303
                        } catch (ReflectionException) {
×
304
                                continue;
×
305
                        }
306

307
                        if (
308
                                in_array($fieldName, $constructorRequiredParameters, true)
×
309
                                || in_array($fieldName, $constructorOptionalParameters, true)
×
310
                        ) {
311
                                [$isRequired, $isWritable] = [
×
312
                                        in_array($fieldName, $constructorRequiredParameters, true),
×
313
                                        in_array($fieldName, $constructorOptionalParameters, true),
×
314
                                ];
×
315
                        } else {
316
                                if ($this->crudReader !== null) {
×
317
                                        [$isRequired, $isWritable] = $this->crudReader->read($rp) + [false, false];
×
318

319
                                } else {
320
                                        $isRequired = false;
×
321
                                        $isWritable = true;
×
322
                                }
323
                        }
324

325
                        // Check if field is updatable
326
                        if (!$isRequired && !$isWritable) {
×
327
                                continue;
×
328
                        }
329

330
                        $isRelationship = false;
×
331

332
                        if ($this->getRelationshipKey($fieldName) !== null) {
×
333
                                // Transform entity field name to schema relationship name
334
                                $mappedKey = $this->getRelationshipKey($fieldName);
×
335

336
                                $isRelationship = true;
×
337

338
                        } elseif ($this->getAttributeKey($fieldName) !== null) {
×
339
                                $mappedKey = $this->getAttributeKey($fieldName);
×
340

341
                        } elseif ($this->getCompositedAttributeKey($fieldName) !== null) {
×
342
                                $mappedKey = $this->getCompositedAttributeKey($fieldName);
×
343

344
                        } else {
345
                                continue;
×
346
                        }
347

348
                        // Extract all entity property annotations
NEW
349
                        $propertyAttributes = array_map(
×
NEW
350
                                (static fn ($attribute): string => $attribute->getName()),
×
NEW
351
                                $rp->getAttributes(),
×
UNCOV
352
                        );
×
353

NEW
354
                        if (in_array(ORM\Mapping\OneToOne::class, $propertyAttributes, true)) {
×
NEW
355
                                $propertyAttribute = array_reduce(
×
NEW
356
                                        $rp->getAttributes(),
×
NEW
357
                                        static function (ReflectionAttribute|null $carry, ReflectionAttribute $attribute): ReflectionAttribute|null {
×
NEW
358
                                                if ($carry === null && $attribute->getName() === ORM\Mapping\OneToOne::class) {
×
NEW
359
                                                        return $attribute;
×
360
                                                }
361

NEW
362
                                                return $carry;
×
NEW
363
                                        },
×
NEW
364
                                );
×
NEW
365
                                assert($propertyAttribute instanceof ReflectionAttribute);
×
366

NEW
367
                                $propertyAttribute = $propertyAttribute->newInstance();
×
NEW
368
                                assert($propertyAttribute instanceof ORM\Mapping\OneToOne);
×
369

NEW
370
                                $className = $propertyAttribute->targetEntity;
×
371

372
                                // Check if class is callable
373
                                if (is_string($className) && class_exists($className)) {
×
374
                                        $fields[] = new Hydrators\Fields\SingleEntityField(
×
375
                                                $className,
×
376
                                                false,
×
377
                                                $mappedKey,
×
378
                                                $isRelationship,
×
379
                                                $fieldName,
×
380
                                                $isRequired,
×
381
                                                $isWritable,
×
382
                                        );
×
383
                                }
NEW
384
                        } elseif (in_array(ORM\Mapping\OneToMany::class, $propertyAttributes, true)) {
×
NEW
385
                                $propertyAttribute = array_reduce(
×
NEW
386
                                        $rp->getAttributes(),
×
NEW
387
                                        static function (ReflectionAttribute|null $carry, ReflectionAttribute $attribute): ReflectionAttribute|null {
×
NEW
388
                                                if ($carry === null && $attribute->getName() === ORM\Mapping\OneToMany::class) {
×
NEW
389
                                                        return $attribute;
×
390
                                                }
391

NEW
392
                                                return $carry;
×
NEW
393
                                        },
×
NEW
394
                                );
×
NEW
395
                                assert($propertyAttribute instanceof ReflectionAttribute);
×
396

NEW
397
                                $propertyAttribute = $propertyAttribute->newInstance();
×
NEW
398
                                assert($propertyAttribute instanceof ORM\Mapping\OneToOne);
×
399

NEW
400
                                $className = $propertyAttribute->targetEntity;
×
401

402
                                // Check if class is callable
403
                                if (is_string($className) && class_exists($className)) {
×
404
                                        $fields[] = new Hydrators\Fields\CollectionField(
×
405
                                                $className,
×
406
                                                true,
×
407
                                                $mappedKey,
×
408
                                                $isRelationship,
×
409
                                                $fieldName,
×
410
                                                $isRequired,
×
411
                                                $isWritable,
×
412
                                        );
×
413
                                }
NEW
414
                        } elseif (in_array(ORM\Mapping\ManyToMany::class, $propertyAttributes, true)) {
×
NEW
415
                                $propertyAttribute = array_reduce(
×
NEW
416
                                        $rp->getAttributes(),
×
NEW
417
                                        static function (ReflectionAttribute|null $carry, ReflectionAttribute $attribute): ReflectionAttribute|null {
×
NEW
418
                                                if ($carry === null && $attribute->getName() === ORM\Mapping\ManyToMany::class) {
×
NEW
419
                                                        return $attribute;
×
420
                                                }
421

NEW
422
                                                return $carry;
×
NEW
423
                                        },
×
NEW
424
                                );
×
NEW
425
                                assert($propertyAttribute instanceof ReflectionAttribute);
×
426

NEW
427
                                $propertyAttribute = $propertyAttribute->newInstance();
×
NEW
428
                                assert($propertyAttribute instanceof ORM\Mapping\OneToOne);
×
429

NEW
430
                                $className = $propertyAttribute->targetEntity;
×
431

432
                                // Check if class is callable
433
                                if ($className !== null && class_exists($className)) {
×
434
                                        $fields[] = new Hydrators\Fields\CollectionField(
×
435
                                                $className,
×
436
                                                true,
×
437
                                                $mappedKey,
×
438
                                                $isRelationship,
×
439
                                                $fieldName,
×
440
                                                $isRequired,
×
441
                                                $isWritable,
×
442
                                        );
×
443
                                }
NEW
444
                        } elseif (in_array(ORM\Mapping\ManyToOne::class, $propertyAttributes, true)) {
×
NEW
445
                                $propertyAttribute = array_reduce(
×
NEW
446
                                        $rp->getAttributes(),
×
NEW
447
                                        static function (ReflectionAttribute|null $carry, ReflectionAttribute $attribute): ReflectionAttribute|null {
×
NEW
448
                                                if ($carry === null && $attribute->getName() === ORM\Mapping\ManyToOne::class) {
×
NEW
449
                                                        return $attribute;
×
450
                                                }
451

NEW
452
                                                return $carry;
×
NEW
453
                                        },
×
NEW
454
                                );
×
NEW
455
                                assert($propertyAttribute instanceof ReflectionAttribute);
×
456

NEW
457
                                $propertyAttribute = $propertyAttribute->newInstance();
×
NEW
458
                                assert($propertyAttribute instanceof ORM\Mapping\OneToOne);
×
459

NEW
460
                                $className = $propertyAttribute->targetEntity;
×
461

462
                                // Check if class is callable
463
                                if (is_string($className) && class_exists($className)) {
×
464
                                        $fields[] = new Hydrators\Fields\SingleEntityField(
×
465
                                                $className,
×
466
                                                false,
×
467
                                                $mappedKey,
×
468
                                                $isRelationship,
×
469
                                                $fieldName,
×
470
                                                $isRequired,
×
471
                                                $isWritable,
×
472
                                        );
×
473
                                }
474
                        } else {
475
                                $varAnnotation = $this->parseAnnotation($rp, 'var');
×
476

477
                                try {
478
                                        $propertyType = $rp->getType();
×
479

480
                                        if ($propertyType instanceof ReflectionNamedType) {
×
481
                                                $varAnnotation = ($varAnnotation === null ? '' : $varAnnotation . '|')
×
482
                                                        . $propertyType->getName() . ($propertyType->allowsNull() ? '|null' : '');
×
483
                                        }
484

485
                                        $rm = $rc->getMethod('get' . ucfirst($fieldName));
×
486

487
                                        $returnType = $rm->getReturnType();
×
488

489
                                        if ($returnType instanceof ReflectionNamedType) {
×
490
                                                $varAnnotation = ($varAnnotation === null ? '' : $varAnnotation . '|')
×
491
                                                        . $returnType->getName() . ($returnType->allowsNull() ? '|null' : '');
×
492
                                        }
493
                                } catch (ReflectionException) {
×
494
                                        // Nothing to do
495
                                }
496

497
                                if ($varAnnotation === null) {
×
498
                                        continue;
×
499
                                }
500

501
                                $className = null;
×
502

503
                                $isString = false;
×
504
                                $isNumber = false;
×
505
                                $isDecimal = false;
×
506
                                $isArray = false;
×
507
                                $isBool = false;
×
508
                                $isClass = false;
×
509
                                $isMixed = false;
×
510

511
                                $isNullable = false;
×
512

513
                                $typesFound = 0;
×
514

515
                                if (str_contains($varAnnotation, '|')) {
×
516
                                        $varDatatypes = explode('|', $varAnnotation);
×
517
                                        $varDatatypes = array_unique($varDatatypes);
×
518

519
                                } else {
520
                                        $varDatatypes = [$varAnnotation];
×
521
                                }
522

523
                                foreach ($varDatatypes as $varDatatype) {
×
524
                                        if (class_exists($varDatatype) || interface_exists($varDatatype)) {
×
525
                                                $className = $varDatatype;
×
526
                                                $isClass = true;
×
527

528
                                                $typesFound++;
×
529

530
                                        } elseif (strtolower($varDatatype) === 'string') {
×
531
                                                $isString = true;
×
532

533
                                                $typesFound++;
×
534

535
                                        } elseif (strtolower($varDatatype) === 'int') {
×
536
                                                $isNumber = true;
×
537

538
                                                $typesFound++;
×
539

540
                                        } elseif (strtolower($varDatatype) === 'float') {
×
541
                                                $isDecimal = true;
×
542

543
                                                $typesFound++;
×
544

545
                                        } elseif (strtolower($varDatatype) === 'array' || strtolower($varDatatype) === 'mixed[]') {
×
546
                                                $isArray = true;
×
547

548
                                                $typesFound++;
×
549

550
                                        } elseif (strtolower($varDatatype) === 'bool') {
×
551
                                                $isBool = true;
×
552

553
                                                $typesFound++;
×
554

555
                                        } elseif (strtolower($varDatatype) === 'null') {
×
556
                                                $isNullable = true;
×
557

558
                                        } elseif (strtolower($varDatatype) === 'mixed') {
×
559
                                                $isMixed = true;
×
560

561
                                                $typesFound++;
×
562
                                        }
563
                                }
564

565
                                if ($typesFound > 0) {
×
566
                                        if ($typesFound > 1) {
×
567
                                                $fields[] = new Hydrators\Fields\MixedField(
×
568
                                                        $isNullable,
×
569
                                                        $mappedKey,
×
570
                                                        $fieldName,
×
571
                                                        $isRequired,
×
572
                                                        $isWritable,
×
573
                                                );
×
574

575
                                        } elseif ($isClass && $className !== null) {
×
576
                                                try {
577
                                                        $typeRc = new ReflectionClass($className);
×
578

579
                                                        if (
580
                                                                $typeRc->implementsInterface(
×
581
                                                                        DateTimeInterface::class,
×
582
                                                                )
×
583
                                                                || $className === DateTimeInterface::class
×
584
                                                        ) {
585
                                                                $fields[] = new Hydrators\Fields\DateTimeField(
×
586
                                                                        $isNullable,
×
587
                                                                        $mappedKey,
×
588
                                                                        $fieldName,
×
589
                                                                        $isRequired,
×
590
                                                                        $isWritable,
×
591
                                                                );
×
592

593
                                                        } elseif ($typeRc->isSubclassOf(BackedEnum::class)) {
×
594
                                                                $fields[] = new Hydrators\Fields\BackedEnumField(
×
595
                                                                        $className,
×
596
                                                                        $isNullable,
×
597
                                                                        $mappedKey,
×
598
                                                                        $fieldName,
×
599
                                                                        $isRequired,
×
600
                                                                        $isWritable,
×
601
                                                                );
×
602

603
                                                        } elseif ($typeRc->implementsInterface(ArrayAccess::class)) {
×
604
                                                                $fields[] = new Hydrators\Fields\ArrayField(
×
605
                                                                        $isNullable,
×
606
                                                                        $mappedKey,
×
607
                                                                        $fieldName,
×
608
                                                                        $isRequired,
×
609
                                                                        $isWritable,
×
610
                                                                );
×
611

612
                                                        } else {
613
                                                                $fields[] = new Hydrators\Fields\SingleEntityField(
×
614
                                                                        $className,
×
615
                                                                        $isNullable,
×
616
                                                                        $mappedKey,
×
617
                                                                        $isRelationship,
×
618
                                                                        $fieldName,
×
619
                                                                        $isRequired,
×
620
                                                                        $isWritable,
×
621
                                                                );
×
622
                                                        }
623
                                                } catch (ReflectionException) {
×
624
                                                        $fields[] = new Hydrators\Fields\SingleEntityField(
×
625
                                                                $className,
×
626
                                                                $isNullable,
×
627
                                                                $mappedKey,
×
628
                                                                $isRelationship,
×
629
                                                                $fieldName,
×
630
                                                                $isRequired,
×
631
                                                                $isWritable,
×
632
                                                        );
×
633
                                                }
634
                                        } elseif ($isString) {
×
635
                                                $fields[] = new Hydrators\Fields\TextField(
×
636
                                                        $isNullable,
×
637
                                                        $mappedKey,
×
638
                                                        $fieldName,
×
639
                                                        $isRequired,
×
640
                                                        $isWritable,
×
641
                                                );
×
642

643
                                        } elseif ($isNumber || $isDecimal) {
×
644
                                                $fields[] = new Hydrators\Fields\NumberField(
×
645
                                                        $isDecimal,
×
646
                                                        $isNullable,
×
647
                                                        $mappedKey,
×
648
                                                        $fieldName,
×
649
                                                        $isRequired,
×
650
                                                        $isWritable,
×
651
                                                );
×
652

653
                                        } elseif ($isArray) {
×
654
                                                $fields[] = new Hydrators\Fields\ArrayField(
×
655
                                                        $isNullable,
×
656
                                                        $mappedKey,
×
657
                                                        $fieldName,
×
658
                                                        $isRequired,
×
659
                                                        $isWritable,
×
660
                                                );
×
661

662
                                        } elseif ($isBool) {
×
663
                                                $fields[] = new Hydrators\Fields\BooleanField(
×
664
                                                        $isNullable,
×
665
                                                        $mappedKey,
×
666
                                                        $fieldName,
×
667
                                                        $isRequired,
×
668
                                                        $isWritable,
×
669
                                                );
×
670

671
                                        } elseif ($isMixed) {
×
672
                                                $fields[] = new Hydrators\Fields\MixedField(
×
673
                                                        $isNullable,
×
674
                                                        $mappedKey,
×
675
                                                        $fieldName,
×
676
                                                        $isRequired,
×
677
                                                        $isWritable,
×
678
                                                );
×
679
                                        }
680
                                }
681
                        }
682
                }
683

684
                return $fields;
×
685
        }
686

687
        /**
688
         * Get the model method name for a resource relationship key
689
         */
690
        private function getRelationshipKey(string $entityKey): string|null
691
        {
692
                $this->normalizeRelationships();
×
693

694
                $key = $this->normalizedRelationships[$entityKey] ?? null;
×
695

696
                return is_string($key) ? $key : null;
×
697
        }
698

699
        private function normalizeRelationships(): void
700
        {
701
                if (is_array($this->normalizedRelationships)) {
×
702
                        return;
×
703
                }
704

705
                $this->normalizedRelationships = [];
×
706

707
                if ($this->relationships !== []) {
×
708
                        foreach ($this->relationships as $resourceKey => $entityKey) {
×
709
                                if (is_numeric($resourceKey)) {
×
710
                                        $resourceKey = $entityKey;
×
711
                                }
712

713
                                $this->normalizedRelationships[$entityKey] = $resourceKey;
×
714
                        }
715
                }
716
        }
717

718
        private function getAttributeKey(string $entityKey): string|null
719
        {
720
                $this->normalizeAttributes();
×
721

722
                $key = $this->normalizedAttributes[$entityKey] ?? null;
×
723

724
                return is_string($key) ? $key : null;
×
725
        }
726

727
        private function normalizeAttributes(): void
728
        {
729
                if (is_array($this->normalizedAttributes)) {
×
730
                        return;
×
731
                }
732

733
                $this->normalizedAttributes = [];
×
734

735
                if ($this->attributes !== []) {
×
736
                        foreach ($this->attributes as $resourceKey => $entityKey) {
×
737
                                if (is_numeric($resourceKey)) {
×
738
                                        $resourceKey = $entityKey;
×
739
                                }
740

741
                                $this->normalizedAttributes[$entityKey] = $resourceKey;
×
742
                        }
743
                }
744
        }
745

746
        private function getCompositedAttributeKey(string $entityKey): string|null
747
        {
748
                $this->normalizeCompositeAttributes();
×
749

750
                $key = $this->normalizedCompositedAttributes[$entityKey] ?? null;
×
751

752
                return is_string($key) ? $key : null;
×
753
        }
754

755
        private function normalizeCompositeAttributes(): void
756
        {
757
                if (is_array($this->normalizedCompositedAttributes)) {
×
758
                        return;
×
759
                }
760

761
                $this->normalizedCompositedAttributes = [];
×
762

763
                if ($this->compositedAttributes !== []) {
×
764
                        foreach ($this->compositedAttributes as $resourceKey => $entityKey) {
×
765
                                if (is_numeric($resourceKey)) {
×
766
                                        $resourceKey = $entityKey;
×
767
                                }
768

769
                                $this->normalizedCompositedAttributes[$entityKey] = $resourceKey;
×
770
                        }
771
                }
772
        }
773

774
        private function parseAnnotation(ReflectionProperty $rp, string $name): string|null
775
        {
776
                if ($rp->getDocComment() === false) {
×
777
                        return null;
×
778
                }
779

780
                $factory = phpDocumentor\Reflection\DocBlockFactory::createInstance();
×
781
                $docblock = $factory->create($rp->getDocComment());
×
782

783
                foreach ($docblock->getTags() as $tag) {
×
784
                        if ($tag->getName() === $name) {
×
785
                                return trim((string) $tag);
×
786
                        }
787
                }
788

789
                if ($name === 'var' && $rp->getType() instanceof ReflectionNamedType) {
×
790
                        return $rp->getType()->getName();
×
791
                }
792

793
                return null;
×
794
        }
795

796
        /**
797
         * @param JsonAPIDocument\Objects\IStandardObject<string, mixed> $attributes
798
         * @param array<Hydrators\Fields\Field> $entityMapping
799
         * @param T|null $entity
800
         *
801
         * @return array<mixed>
802
         *
803
         * @throws Exceptions\InvalidState
804
         */
805
        protected function hydrateAttributes(
806
                string $className,
807
                JsonAPIDocument\Objects\IStandardObject $attributes,
808
                array $entityMapping,
809
                object|null $entity,
810
                string|null $rootField,
811
        ): array
812
        {
813
                $data = [];
×
814

815
                $isNew = $entity === null;
×
816

817
                foreach ($entityMapping as $field) {
×
818
                        if ($field instanceof Hydrators\Fields\EntityField && $field->isRelationship()) {
×
819
                                continue;
×
820
                        }
821

822
                        // Continue only if attribute is present
823
                        if (
824
                                !$attributes->has($field->getMappedName())
×
825
                                && !in_array($field->getFieldName(), $this->compositedAttributes, true)
×
826
                        ) {
827
                                continue;
×
828
                        }
829

830
                        // If there is a specific method for this attribute, we'll hydrate that
831
                        $value = $this->hasCustomHydrateAttribute($field->getFieldName(), $attributes)
×
832
                                ? $this->callHydrateAttribute($field->getFieldName(), $attributes, $entity)
×
833
                                : $field->getValue($attributes);
×
834

835
                        if ($value === null && $field->isRequired() && $isNew) {
×
836
                                $this->errors->addError(
×
837
                                        StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY,
×
838
                                        strval($this->translator->translate('//jsonApi.hydrator.missingRequiredAttribute.heading')),
×
839
                                        strval($this->translator->translate('//jsonApi.hydrator.missingRequiredAttribute.message')),
×
840
                                        [
×
841
                                                'pointer' => '/data/attributes/' . $field->getMappedName(),
×
842
                                        ],
×
843
                                );
×
844

845
                        } elseif ($field->isWritable() || ($isNew && $field->isRequired())) {
×
846
                                if ($field instanceof Hydrators\Fields\SingleEntityField) {
×
847
                                        // Get attribute entity class name
848
                                        $fieldClassName = $field->getClassName();
×
849

850
                                        /** @var string|JsonAPIDocument\Objects\IStandardObject<string, mixed> $fieldAttributes */
851
                                        $fieldAttributes = $attributes->get($field->getMappedName());
×
852

853
                                        if ($fieldAttributes instanceof JsonAPIDocument\Objects\IStandardObject) {
×
854
                                                $data[$field->getFieldName()] = $this->hydrateAttributes(
×
855
                                                        $fieldClassName,
×
856
                                                        $fieldAttributes,
×
857
                                                        $this->mapEntity($fieldClassName),
×
858
                                                        $entity,
×
859
                                                        $field->getMappedName(),
×
860
                                                );
×
861

862
                                                if (
863
                                                        isset($data[$field->getFieldName()])
×
864
                                                        && is_array($data[$field->getFieldName()])
×
865
                                                        && !isset($data[$field->getFieldName()]['entity'])
×
866
                                                ) {
867
                                                        $data[$field->getFieldName()]['entity'] = $fieldClassName;
×
868
                                                }
869
                                        } elseif ($value !== null || $field->isNullable()) {
×
870
                                                $data[$field->getFieldName()] = $value;
×
871
                                        }
872
                                } else {
873
                                        $data[$field->getFieldName()] = $value;
×
874
                                }
875
                        }
876
                }
877

878
                try {
879
                        if (class_exists($className)) {
×
880
                                $rc = new ReflectionClass($className);
×
881

882
                                if ($rc->getConstructor() !== null) {
×
883
                                        $constructor = $rc->getConstructor();
×
884

885
                                        foreach ($constructor->getParameters() as $num => $parameter) {
×
886
                                                if (
887
                                                        !$parameter->isVariadic()
×
888
                                                        && $attributes->has($this->getAttributeKey($parameter->getName()) ?? $parameter->getName())
×
889
                                                ) {
890
                                                        if (array_key_exists($parameter->getName(), $data)) {
×
891
                                                                continue;
×
892
                                                        }
893

894
                                                        // If there is a specific method for this attribute, we'll hydrate that
895
                                                        $value = $this->hasCustomHydrateAttribute(
×
896
                                                                $parameter->getName(),
×
897
                                                                $attributes,
×
898
                                                        ) ? $this->callHydrateAttribute(
×
899
                                                                $parameter->getName(),
×
900
                                                                $attributes,
×
901
                                                                $entity,
×
902
                                                        ) : $attributes->get(
×
903
                                                                $this->getAttributeKey($parameter->getName()) ?? $parameter->getName(),
×
904
                                                        );
×
905

906
                                                        $data[$parameter->getName()] = $value;
×
907

908
                                                } elseif ($attributes->has($this->getAttributeKey((string) $num) ?? (string) $num)) {
×
909
                                                        if (array_key_exists((string) $num, $data)) {
×
910
                                                                continue;
×
911
                                                        }
912

913
                                                        // If there is a specific method for this attribute, we'll hydrate that
914
                                                        $value = $this->hasCustomHydrateAttribute(
×
915
                                                                (string) $num,
×
916
                                                                $attributes,
×
917
                                                        ) ? $this->callHydrateAttribute(
×
918
                                                                (string) $num,
×
919
                                                                $attributes,
×
920
                                                                $entity,
×
921
                                                        ) : $attributes->get(
×
922
                                                                $this->getAttributeKey((string) $num) ?? (string) $num,
×
923
                                                        );
×
924

925
                                                        $data[(string) $num] = $value;
×
926
                                                }
927
                                        }
928
                                }
929

930
                                $data['entity'] = $className;
×
931
                        }
932
                } catch (Throwable) {
×
933
                        // Nothing to do here
934
                }
935

936
                if (!$isNew) {
×
937
                        foreach ($data as $attribute => $value) {
×
938
                                $isAllowed = false;
×
939

940
                                foreach ($entityMapping as $field) {
×
941
                                        if ($field instanceof Hydrators\Fields\EntityField && $field->isRelationship()) {
×
942
                                                $isAllowed = true;
×
943

944
                                                continue;
×
945
                                        }
946

947
                                        if ($field->getFieldName() === $attribute) {
×
948
                                                $isAllowed = true;
×
949
                                        }
950
                                }
951

952
                                if (!$isAllowed) {
×
953
                                        unset($data[$attribute]);
×
954
                                }
955
                        }
956
                }
957

958
                return $data;
×
959
        }
960

961
        /**
962
         * Check if hydrator has custom attribute hydration method
963
         *
964
         * @param JsonAPIDocument\Objects\IStandardObject<string, mixed> $attributes
965
         */
966
        private function hasCustomHydrateAttribute(
967
                string $attributeKey,
968
                JsonAPIDocument\Objects\IStandardObject $attributes,
969
        ): bool
970
        {
971
                $method = $this->methodForAttribute($attributeKey);
×
972

973
                if ($method === '' || !method_exists($this, $method)) {
×
974
                        return false;
×
975
                }
976

977
                $callable = [$this, $method];
×
978

979
                return is_callable($callable);
×
980
        }
981

982
        /**
983
         * Return the method name to call for hydrating the specific attribute.
984
         *
985
         * If this method returns an empty value, or a value that is not callable, hydration
986
         * of the relationship will be skipped
987
         */
988
        private function methodForAttribute(string $key): string
989
        {
990
                return sprintf('hydrate%sAttribute', $this->classify($key));
×
991
        }
992

993
        /**
994
         * Gets the upper camel case form of a string.
995
         */
996
        private function classify(string $value): string
997
        {
998
                $converted = ucwords(str_replace(['-', '_'], ' ', $value));
×
999

1000
                return str_replace(' ', '', $converted);
×
1001
        }
1002

1003
        /**
1004
         * Hydrate a attribute by invoking a method on this hydrator.
1005
         *
1006
         * @param JsonAPIDocument\Objects\IStandardObject<string, mixed> $attributes
1007
         * @param T|null $entity
1008
         */
1009
        private function callHydrateAttribute(
1010
                string $attributeKey,
1011
                JsonAPIDocument\Objects\IStandardObject $attributes,
1012
                object|null $entity = null,
1013
        ): mixed
1014
        {
1015
                $method = $this->methodForAttribute($attributeKey);
×
1016

1017
                if ($method === '' || !method_exists($this, $method)) {
×
1018
                        return null;
×
1019
                }
1020

1021
                $callable = [$this, $method];
×
1022

1023
                if (is_callable($callable)) {
×
1024
                        return call_user_func($callable, $attributes, $entity);
×
1025
                }
1026

1027
                return null;
×
1028
        }
1029

1030
        /**
1031
         * @param array<Hydrators\Fields\Field> $entityMapping
1032
         * @param JsonAPIDocument\Objects\IResourceObjectCollection<JsonAPIDocument\Objects\IResourceObject>|null $included
1033
         * @param T|null $entity
1034
         *
1035
         * @return  array<mixed>
1036
         *
1037
         * @throws Exceptions\InvalidState
1038
         */
1039
        protected function hydrateRelationships(
1040
                JsonAPIDocument\Objects\IRelationshipObjectCollection $relationships,
1041
                array $entityMapping,
1042
                JsonAPIDocument\Objects\IResourceObjectCollection|null $included = null,
1043
                object|null $entity = null,
1044
        ): array
1045
        {
1046
                $data = [];
×
1047

1048
                foreach ($entityMapping as $field) {
×
1049
                        if ($field instanceof Hydrators\Fields\EntityField && $field->isRelationship()) {
×
1050
                                if ($relationships->has($field->getMappedName())) {
×
1051
                                        $relationship = $relationships->get($field->getMappedName());
×
1052

1053
                                        // If there is a specific method for this relationship, we'll hydrate that
1054
                                        $result = $this->callHydrateRelationship(
×
1055
                                                $field->getMappedName(),
×
1056
                                                $relationship,
×
1057
                                                $included,
×
1058
                                                $entity,
×
1059
                                        );
×
1060

1061
                                        if ($result !== null) {
×
1062
                                                $data[$field->getFieldName()] = $result;
×
1063

1064
                                                continue;
×
1065
                                        }
1066

1067
                                        // If this is a has-one, we'll hydrate it
1068
                                        if ($relationship->isHasOne()) {
×
1069
                                                $relationshipEntity = $this->hydrateHasOne(
×
1070
                                                        $field,
×
1071
                                                        $relationship,
×
1072
                                                        $entity,
×
1073
                                                        $entityMapping,
×
1074
                                                );
×
1075

1076
                                                $data[$field->getFieldName()] = $relationshipEntity;
×
1077

1078
                                        } elseif ($relationship->isHasMany()) {
×
1079
                                                $relationshipEntities = $this->hydrateHasMany(
×
1080
                                                        $field,
×
1081
                                                        $relationship,
×
1082
                                                        $entity,
×
1083
                                                        $entityMapping,
×
1084
                                                );
×
1085

1086
                                                $data[$field->getFieldName()] = $relationshipEntities;
×
1087
                                        }
1088
                                } elseif ($field->isRequired() && $entity === null) {
×
1089
                                        $this->errors->addError(
×
1090
                                                StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY,
×
1091
                                                strval($this->translator->translate('//jsonApi.hydrator.missingRequiredRelation.heading')),
×
1092
                                                strval($this->translator->translate('//jsonApi.hydrator.missingRequiredRelation.message')),
×
1093
                                                [
×
1094
                                                        'pointer' => '/data/relationships/' . $field->getMappedName() . '/data/id',
×
1095
                                                ],
×
1096
                                        );
×
1097
                                }
1098
                        }
1099
                }
1100

1101
                return $data;
×
1102
        }
1103

1104
        /**
1105
         * Hydrate a relationship by invoking a method on this hydrator.
1106
         *
1107
         * @param JsonAPIDocument\Objects\IResourceObjectCollection<JsonAPIDocument\Objects\IResourceObject>|null $included
1108
         * @param T|null $entity
1109
         *
1110
         * @return  array<mixed>|object|null
1111
         *
1112
         * @throws Exceptions\InvalidState
1113
         */
1114
        private function callHydrateRelationship(
1115
                string $relationshipKey,
1116
                JsonAPIDocument\Objects\IRelationshipObject $relationship,
1117
                JsonAPIDocument\Objects\IResourceObjectCollection|null $included = null,
1118
                object|null $entity = null,
1119
        ): array|object|null
1120
        {
1121
                $method = $this->methodForRelationship($relationshipKey);
×
1122

1123
                if ($method === '' || !method_exists($this, $method)) {
×
1124
                        return null;
×
1125
                }
1126

1127
                $callable = [$this, $method];
×
1128

1129
                if (is_callable($callable)) {
×
1130
                        $result = call_user_func($callable, $relationship, $included, $entity);
×
1131

1132
                        if ($result === null || is_array($result) || is_object($result)) {
×
1133
                                return $result;
×
1134
                        }
1135

1136
                        throw new Exceptions\InvalidState(
×
1137
                                sprintf('Relationship have to be an array or entity instance, %s provided.', gettype($result)),
×
1138
                        );
×
1139
                }
1140

1141
                return null;
×
1142
        }
1143

1144
        /**
1145
         * Return the method name to call for hydrating the specific relationship.
1146
         *
1147
         * If this method returns an empty value, or a value that is not callable, hydration
1148
         * of the the relationship will be skipped
1149
         */
1150
        private function methodForRelationship(string $key): string
1151
        {
1152
                return sprintf('hydrate%sRelationship', $this->classify($key));
×
1153
        }
1154

1155
        /**
1156
         * Hydrate a resource has-one relationship
1157
         *
1158
         * @param T|null $entity
1159
         * @param array<Hydrators\Fields\Field> $entityMapping
1160
         */
1161
        protected function hydrateHasOne(
1162
                Hydrators\Fields\Field $field,
1163
                JsonAPIDocument\Objects\IRelationshipObject $relationship,
1164
                object|null $entity,
1165
                array $entityMapping,
1166
        ): object|null
1167
        {
1168
                // Find relationship field
1169
                if (
1170
                        $field instanceof Hydrators\Fields\EntityField
×
1171
                        && $field->isRelationship()
×
1172
                ) {
1173
                        if ($field->isWritable() || ($entity === null && $field->isRequired())) {
×
1174
                                if ($relationship->hasIdentifier()) {
×
1175
                                        $relationEntity = $this->findRelated($field->getClassName(), $relationship->getIdentifier());
×
1176

1177
                                        if ($relationEntity !== null) {
×
1178
                                                return $relationEntity;
×
1179
                                        } elseif ($entity === null && $field->isRequired()) {
×
1180
                                                $this->errors->addError(
×
1181
                                                        StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY,
×
1182
                                                        strval($this->translator->translate('//jsonApi.hydrator.missingRequiredRelation.heading')),
×
1183
                                                        strval($this->translator->translate('//jsonApi.hydrator.missingRequiredRelation.message')),
×
1184
                                                        [
×
1185
                                                                'pointer' => '/data/relationships/' . $field->getMappedName() . '/data/id',
×
1186
                                                        ],
×
1187
                                                );
×
1188
                                        }
1189
                                } elseif ($entity === null && $field->isRequired()) {
×
1190
                                        $this->errors->addError(
×
1191
                                                StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY,
×
1192
                                                strval($this->translator->translate('//jsonApi.hydrator.missingRequiredRelation.heading')),
×
1193
                                                strval($this->translator->translate('//jsonApi.hydrator.missingRequiredRelation.message')),
×
1194
                                                [
×
1195
                                                        'pointer' => '/data/relationships/' . $field->getMappedName() . '/data/id',
×
1196
                                                ],
×
1197
                                        );
×
1198
                                }
1199
                        }
1200
                }
1201

1202
                return null;
×
1203
        }
1204

1205
        /**
1206
         * @param class-string $entityClassName
1207
         */
1208
        private function findRelated(
1209
                string $entityClassName,
1210
                JsonAPIDocument\Objects\IResourceIdentifierObject $identifier,
1211
        ): object|null
1212
        {
1213
                if ($identifier->getId() === null || !Uuid\Uuid::isValid($identifier->getId())) {
×
1214
                        return null;
×
1215
                }
1216

1217
                if (!class_exists($entityClassName)) {
×
1218
                        return null;
×
1219
                }
1220

1221
                $entityManager = $this->managerRegistry->getManagerForClass($entityClassName);
×
1222

1223
                if ($entityManager !== null) {
×
1224
                        return $entityManager
×
1225
                                ->getRepository($entityClassName)
×
1226
                                ->find($identifier->getId());
×
1227
                }
1228

1229
                return null;
×
1230
        }
1231

1232
        /**
1233
         * Hydrate a resource has-many relationship
1234
         *
1235
         * @param T|null $entity
1236
         * @param array<Hydrators\Fields\Field> $entityMapping
1237
         *
1238
         * @return array<int, object>
1239
         */
1240
        protected function hydrateHasMany(
1241
                Hydrators\Fields\Field $field,
1242
                JsonAPIDocument\Objects\IRelationshipObject $relationship,
1243
                object|null $entity,
1244
                array $entityMapping,
1245
        ): array
1246
        {
1247
                $relations = [];
×
1248

1249
                // Find relationship field
1250
                if (
1251
                        $field instanceof Hydrators\Fields\EntityField
×
1252
                        && $field->isRelationship()
×
1253
                ) {
1254
                        if ($field->isWritable() || ($entity === null && $field->isRequired())) {
×
1255
                                if ($relationship->isHasMany()) {
×
1256
                                        foreach ($relationship->getIdentifiers() as $identifier) {
×
1257
                                                $relationEntity = $this->findRelated($field->getClassName(), $identifier);
×
1258

1259
                                                if ($relationEntity !== null) {
×
1260
                                                        $relations[] = $relationEntity;
×
1261
                                                }
1262
                                        }
1263
                                }
1264

1265
                                if ($entity === null && $field->isRequired() && count($relations) === 0) {
×
1266
                                        $this->errors->addError(
×
1267
                                                StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY,
×
1268
                                                strval($this->translator->translate('//jsonApi.hydrator.missingRequiredRelation.heading')),
×
1269
                                                strval($this->translator->translate('//jsonApi.hydrator.missingRequiredRelation.message')),
×
1270
                                                [
×
1271
                                                        'pointer' => '/data/relationships/' . $field->getMappedName() . '/data',
×
1272
                                                ],
×
1273
                                        );
×
1274
                                }
1275
                        }
1276

1277
                        return $relations;
×
1278
                }
1279

1280
                return [];
×
1281
        }
1282

1283
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc