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

FastyBird / json-api / 10238182751

04 Aug 2024 05:56PM UTC coverage: 4.214% (-0.09%) from 4.302%
10238182751

push

github

web-flow
Merge pull request #2 from FastyBird/feature/constructor-params

Added parameters from entity constructor

0 of 41 new or added lines in 3 files covered. (0.0%)

9 existing lines in 2 files now uncovered.

41 of 973 relevant lines covered (4.21%)

0.13 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\Common;
22
use Doctrine\ORM;
23
use Doctrine\Persistence;
24
use FastyBird\JsonApi\Exceptions;
25
use FastyBird\JsonApi\Helpers;
26
use FastyBird\JsonApi\Hydrators;
27
use Fig\Http\Message\StatusCodeInterface;
28
use IPub\JsonAPIDocument;
29
use Nette;
30
use Nette\Localization;
31
use Nette\Utils;
32
use phpDocumentor;
33
use Ramsey\Uuid;
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_unique;
45
use function call_user_func;
46
use function class_exists;
47
use function count;
48
use function explode;
49
use function gettype;
50
use function in_array;
51
use function interface_exists;
52
use function is_array;
53
use function is_callable;
54
use function is_numeric;
55
use function is_object;
56
use function is_string;
57
use function method_exists;
58
use function sprintf;
59
use function str_contains;
60
use function str_replace;
61
use function strtolower;
62
use function strval;
63
use function trim;
64
use function ucfirst;
65
use function ucwords;
66

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

79
        use Nette\SmartObject;
80

81
        protected const IDENTIFIER_KEY = 'id';
82

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

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

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

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

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

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

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

142
        private Common\Annotations\Reader $annotationReader;
143

144
        private Exceptions\JsonApiMultipleError $errors;
145

146
        /**
147
         * @throws Common\Annotations\AnnotationException
148
         */
149
        public function __construct(
150
                private readonly Persistence\ManagerRegistry $managerRegistry,
151
                protected Localization\Translator $translator,
152
                private readonly Helpers\CrudReader|null $crudReader = null,
153
                Common\Cache\Cache|null $cache = null,
154
        )
155
        {
156
                $this->annotationReader = $cache !== null ? new Common\Annotations\PsrCachedReader(
×
157
                        new Common\Annotations\AnnotationReader(),
×
158
                        Common\Cache\Psr6\CacheAdapter::wrap($cache),
×
159
                ) : new Common\Annotations\AnnotationReader();
×
160

161
                $this->errors = new Exceptions\JsonApiMultipleError();
×
162
        }
163

164
        /**
165
         * @param T|null $entity
166
         *
167
         * @throws Exceptions\JsonApi
168
         * @throws Throwable
169
         */
170
        public function hydrate(
171
                JsonAPIDocument\IDocument $document,
172
                object|null $entity = null,
173
        ): Utils\ArrayHash
174
        {
175
                $entityMapping = $this->mapEntity($this->getEntityName());
×
176

177
                if (!$document->hasResource()) {
×
178
                        throw new Exceptions\JsonApiError(
×
179
                                StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY,
×
NEW
180
                                strval($this->translator->translate('//jsonApi.hydrator.resourceInvalid.heading')),
×
NEW
181
                                strval($this->translator->translate('//jsonApi.hydrator.resourceInvalid.message')),
×
182
                                [
×
183
                                        'pointer' => '/data',
×
184
                                ],
×
185
                        );
×
186
                }
187

188
                $resource = $document->getResource();
×
189

190
                $attributes = $this->hydrateAttributes(
×
191
                        $this->getEntityName(),
×
192
                        $resource->getAttributes(),
×
193
                        $entityMapping,
×
194
                        $entity,
×
195
                        null,
×
196
                );
×
197

198
                $relationships = $this->hydrateRelationships(
×
199
                        $resource->getRelationships(),
×
200
                        $entityMapping,
×
201
                        $document->hasIncluded() ? $document->getIncluded() : null,
×
202
                        $entity,
×
203
                );
×
204

205
                if ($this->errors->hasErrors()) {
×
206
                        throw $this->errors;
×
207
                }
208

209
                $result = Utils\ArrayHash::from(array_merge(
×
210
                        [
×
211
                                'entity' => $this->getEntityName(),
×
212
                        ],
×
213
                        $attributes,
×
214
                        $relationships,
×
215
                ));
×
216

217
                if ($entity === null) {
×
218
                        $identifierKey = $this->entityIdentifier ?? self::IDENTIFIER_KEY;
×
219

220
                        try {
221
                                $identifier = $resource->getId();
×
222

223
                                if ($identifier === null || !Uuid\Uuid::isValid($identifier)) {
×
224
                                        throw new Exceptions\JsonApiError(
×
225
                                                StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY,
×
NEW
226
                                                strval($this->translator->translate('//jsonApi.hydrator.identifierInvalid.heading')),
×
NEW
227
                                                strval($this->translator->translate('//jsonApi.hydrator.identifierInvalid.message')),
×
228
                                                [
×
229
                                                        'pointer' => '/data/id',
×
230
                                                ],
×
231
                                        );
×
232
                                }
233

234
                                $result[$identifierKey] = Uuid\Uuid::fromString($identifier);
×
235

236
                        } catch (JsonAPIDocument\Exceptions\RuntimeException) {
×
237
                                $result[$identifierKey] = Uuid\Uuid::uuid4();
×
238
                        }
239
                }
240

241
                return $result;
×
242
        }
243

244
        /**
245
         * @return class-string<T>
246
         */
247
        abstract public function getEntityName(): string;
248

249
        /**
250
         * @param class-string $entityClassName
251
         *
252
         * @return array<Hydrators\Fields\Field>
253
         *
254
         * @throws Exceptions\InvalidState
255
         */
256
        protected function mapEntity(string $entityClassName): array
257
        {
258
                $entityManager = $this->managerRegistry->getManagerForClass($entityClassName);
×
259

260
                if ($entityManager === null) {
×
261
                        return [];
×
262
                }
263

264
                $classMetadata = $entityManager->getClassMetadata($entityClassName);
×
265

266
                $reflectionProperties = [];
×
267

268
                try {
269
                        if (class_exists($entityClassName)) {
×
270
                                $rc = new ReflectionClass($entityClassName);
×
271

272
                        } else {
273
                                throw new Exceptions\InvalidState('Entity could not be parsed');
×
274
                        }
275
                } catch (ReflectionException) {
×
276
                        throw new Exceptions\InvalidState('Entity could not be parsed');
×
277
                }
278

279
                foreach ($rc->getProperties() as $rp) {
×
280
                        $reflectionProperties[] = $rp->getName();
×
281
                }
282

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

NEW
291
                $constructorOptionalParameters = array_map(
×
NEW
292
                        static fn (ReflectionParameter $parameter): string => $parameter->getName(),
×
NEW
293
                        array_filter(
×
NEW
294
                                $rc->getConstructor()?->getParameters() ?? [],
×
NEW
295
                                static fn (ReflectionParameter $parameter): bool => $parameter->isOptional(),
×
NEW
296
                        ),
×
NEW
297
                );
×
298

299
                $entityFields = array_unique(array_merge(
×
300
                        $reflectionProperties,
×
301
                        $classMetadata->getFieldNames(),
×
302
                        $classMetadata->getAssociationNames(),
×
303
                ));
×
304

305
                $fields = [];
×
306

307
                foreach ($entityFields as $fieldName) {
×
308
                        try {
309
                                // Check if property in entity class exists
310
                                $rp = $rc->getProperty($fieldName);
×
311

312
                        } catch (ReflectionException) {
×
313
                                continue;
×
314
                        }
315

316
                        if (
NEW
317
                                in_array($fieldName, $constructorRequiredParameters, true)
×
NEW
318
                                || in_array($fieldName, $constructorOptionalParameters, true)
×
319
                        ) {
NEW
320
                                [$isRequired, $isWritable] = [
×
NEW
321
                                        in_array($fieldName, $constructorRequiredParameters, true),
×
NEW
322
                                        in_array($fieldName, $constructorOptionalParameters, true),
×
NEW
323
                                ];
×
324
                        } else {
NEW
325
                                if ($this->crudReader !== null) {
×
NEW
326
                                        [$isRequired, $isWritable] = $this->crudReader->read($rp) + [false, false];
×
327

328
                                } else {
NEW
329
                                        $isRequired = false;
×
NEW
330
                                        $isWritable = true;
×
331
                                }
332
                        }
333

334
                        // Check if field is updatable
335
                        if (!$isRequired && !$isWritable) {
×
336
                                continue;
×
337
                        }
338

339
                        $isRelationship = false;
×
340

341
                        if ($this->getRelationshipKey($fieldName) !== null) {
×
342
                                // Transform entity field name to schema relationship name
343
                                $mappedKey = $this->getRelationshipKey($fieldName);
×
344

345
                                $isRelationship = true;
×
346

347
                        } elseif ($this->getAttributeKey($fieldName) !== null) {
×
348
                                $mappedKey = $this->getAttributeKey($fieldName);
×
349

350
                        } elseif ($this->getCompositedAttributeKey($fieldName) !== null) {
×
351
                                $mappedKey = $this->getCompositedAttributeKey($fieldName);
×
352

353
                        } else {
354
                                continue;
×
355
                        }
356

357
                        // Extract all entity property annotations
358
                        $propertyAnnotations = array_merge(
×
359
                                array_map(
×
360
                                        (static fn ($annotation): string => $annotation::class),
×
361
                                        $this->annotationReader->getPropertyAnnotations($rp),
×
362
                                ),
×
363
                                array_map(
×
364
                                        (static fn ($attribute): string => $attribute->getName()),
×
365
                                        $rp->getAttributes(),
×
366
                                ),
×
367
                        );
×
368

369
                        if (in_array(ORM\Mapping\OneToOne::class, $propertyAnnotations, true)) {
×
370
                                $mapping = $this->annotationReader->getPropertyAnnotation($rp, ORM\Mapping\OneToOne::class);
×
371
                                $className = $mapping?->targetEntity;
×
372

373
                                if ($className === null) {
×
374
                                        $attributes = $rp->getAttributes(ORM\Mapping\OneToOne::class);
×
375
                                        $className = $attributes !== [] ? $attributes[0]->newInstance()->targetEntity : null;
×
376
                                }
377

378
                                // Check if class is callable
379
                                if (is_string($className) && class_exists($className)) {
×
380
                                        $fields[] = new Hydrators\Fields\SingleEntityField(
×
381
                                                $className,
×
382
                                                false,
×
383
                                                $mappedKey,
×
384
                                                $isRelationship,
×
385
                                                $fieldName,
×
386
                                                $isRequired,
×
387
                                                $isWritable,
×
388
                                        );
×
389
                                }
390
                        } elseif (in_array(ORM\Mapping\OneToMany::class, $propertyAnnotations, true)) {
×
391
                                $mapping = $this->annotationReader->getPropertyAnnotation($rp, ORM\Mapping\OneToMany::class);
×
392
                                $className = $mapping?->targetEntity;
×
393

394
                                if ($className === null) {
×
395
                                        $attributes = $rp->getAttributes(ORM\Mapping\OneToMany::class);
×
396
                                        $className = $attributes !== [] ? $attributes[0]->newInstance()->targetEntity : null;
×
397
                                }
398

399
                                // Check if class is callable
400
                                if (is_string($className) && class_exists($className)) {
×
401
                                        $fields[] = new Hydrators\Fields\CollectionField(
×
402
                                                $className,
×
403
                                                true,
×
404
                                                $mappedKey,
×
405
                                                $isRelationship,
×
406
                                                $fieldName,
×
407
                                                $isRequired,
×
408
                                                $isWritable,
×
409
                                        );
×
410
                                }
411
                        } elseif (in_array(ORM\Mapping\ManyToMany::class, $propertyAnnotations, true)) {
×
412
                                $mapping = $this->annotationReader->getPropertyAnnotation($rp, ORM\Mapping\ManyToMany::class);
×
413
                                $className = $mapping?->targetEntity;
×
414

415
                                if ($className === null) {
×
416
                                        $attributes = $rp->getAttributes(ORM\Mapping\ManyToMany::class);
×
417
                                        $className = $attributes !== [] ? $attributes[0]->newInstance()->targetEntity : null;
×
418
                                }
419

420
                                // Check if class is callable
421
                                if ($className !== null && class_exists($className)) {
×
422
                                        $fields[] = new Hydrators\Fields\CollectionField(
×
423
                                                $className,
×
424
                                                true,
×
425
                                                $mappedKey,
×
426
                                                $isRelationship,
×
427
                                                $fieldName,
×
428
                                                $isRequired,
×
429
                                                $isWritable,
×
430
                                        );
×
431
                                }
432
                        } elseif (in_array(ORM\Mapping\ManyToOne::class, $propertyAnnotations, true)) {
×
433
                                $mapping = $this->annotationReader->getPropertyAnnotation($rp, ORM\Mapping\ManyToOne::class);
×
434
                                $className = $mapping?->targetEntity;
×
435

436
                                if ($className === null) {
×
437
                                        $attributes = $rp->getAttributes(ORM\Mapping\ManyToOne::class);
×
438
                                        $className = $attributes !== [] ? $attributes[0]->newInstance()->targetEntity : null;
×
439
                                }
440

441
                                // Check if class is callable
442
                                if (is_string($className) && class_exists($className)) {
×
443
                                        $fields[] = new Hydrators\Fields\SingleEntityField(
×
444
                                                $className,
×
445
                                                false,
×
446
                                                $mappedKey,
×
447
                                                $isRelationship,
×
448
                                                $fieldName,
×
449
                                                $isRequired,
×
450
                                                $isWritable,
×
451
                                        );
×
452
                                }
453
                        } else {
454
                                $varAnnotation = $this->parseAnnotation($rp, 'var');
×
455

456
                                try {
457
                                        $propertyType = $rp->getType();
×
458

459
                                        if ($propertyType instanceof ReflectionNamedType) {
×
460
                                                $varAnnotation = ($varAnnotation === null ? '' : $varAnnotation . '|')
×
461
                                                        . $propertyType->getName() . ($propertyType->allowsNull() ? '|null' : '');
×
462
                                        }
463

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

466
                                        $returnType = $rm->getReturnType();
×
467

468
                                        if ($returnType instanceof ReflectionNamedType) {
×
469
                                                $varAnnotation = ($varAnnotation === null ? '' : $varAnnotation . '|')
×
470
                                                        . $returnType->getName() . ($returnType->allowsNull() ? '|null' : '');
×
471
                                        }
472
                                } catch (ReflectionException) {
×
473
                                        // Nothing to do
474
                                }
475

476
                                if ($varAnnotation === null) {
×
477
                                        continue;
×
478
                                }
479

480
                                $className = null;
×
481

482
                                $isString = false;
×
483
                                $isNumber = false;
×
484
                                $isDecimal = false;
×
485
                                $isArray = false;
×
486
                                $isBool = false;
×
487
                                $isClass = false;
×
488
                                $isMixed = false;
×
489

490
                                $isNullable = false;
×
491

492
                                $typesFound = 0;
×
493

494
                                if (str_contains($varAnnotation, '|')) {
×
495
                                        $varDatatypes = explode('|', $varAnnotation);
×
496
                                        $varDatatypes = array_unique($varDatatypes);
×
497

498
                                } else {
499
                                        $varDatatypes = [$varAnnotation];
×
500
                                }
501

502
                                foreach ($varDatatypes as $varDatatype) {
×
503
                                        if (class_exists($varDatatype) || interface_exists($varDatatype)) {
×
504
                                                $className = $varDatatype;
×
505
                                                $isClass = true;
×
506

507
                                                $typesFound++;
×
508

509
                                        } elseif (strtolower($varDatatype) === 'string') {
×
510
                                                $isString = true;
×
511

512
                                                $typesFound++;
×
513

514
                                        } elseif (strtolower($varDatatype) === 'int') {
×
515
                                                $isNumber = true;
×
516

517
                                                $typesFound++;
×
518

519
                                        } elseif (strtolower($varDatatype) === 'float') {
×
520
                                                $isDecimal = true;
×
521

522
                                                $typesFound++;
×
523

524
                                        } elseif (strtolower($varDatatype) === 'array' || strtolower($varDatatype) === 'mixed[]') {
×
525
                                                $isArray = true;
×
526

527
                                                $typesFound++;
×
528

529
                                        } elseif (strtolower($varDatatype) === 'bool') {
×
530
                                                $isBool = true;
×
531

532
                                                $typesFound++;
×
533

534
                                        } elseif (strtolower($varDatatype) === 'null') {
×
535
                                                $isNullable = true;
×
536

537
                                        } elseif (strtolower($varDatatype) === 'mixed') {
×
538
                                                $isMixed = true;
×
539

540
                                                $typesFound++;
×
541
                                        }
542
                                }
543

544
                                if ($typesFound > 0) {
×
545
                                        if ($typesFound > 1) {
×
546
                                                $fields[] = new Hydrators\Fields\MixedField(
×
547
                                                        $isNullable,
×
548
                                                        $mappedKey,
×
549
                                                        $fieldName,
×
550
                                                        $isRequired,
×
551
                                                        $isWritable,
×
552
                                                );
×
553

554
                                        } elseif ($isClass && $className !== null) {
×
555
                                                try {
556
                                                        $typeRc = new ReflectionClass($className);
×
557

558
                                                        if (
559
                                                                $typeRc->implementsInterface(
×
560
                                                                        DateTimeInterface::class,
×
561
                                                                )
×
562
                                                                || $className === DateTimeInterface::class
×
563
                                                        ) {
564
                                                                $fields[] = new Hydrators\Fields\DateTimeField(
×
565
                                                                        $isNullable,
×
566
                                                                        $mappedKey,
×
567
                                                                        $fieldName,
×
568
                                                                        $isRequired,
×
569
                                                                        $isWritable,
×
570
                                                                );
×
571

572
                                                        } elseif ($typeRc->isSubclassOf(BackedEnum::class)) {
×
573
                                                                $fields[] = new Hydrators\Fields\BackedEnumField(
×
574
                                                                        $className,
×
575
                                                                        $isNullable,
×
576
                                                                        $mappedKey,
×
577
                                                                        $fieldName,
×
578
                                                                        $isRequired,
×
579
                                                                        $isWritable,
×
580
                                                                );
×
581

582
                                                        } elseif ($typeRc->implementsInterface(ArrayAccess::class)) {
×
583
                                                                $fields[] = new Hydrators\Fields\ArrayField(
×
584
                                                                        $isNullable,
×
585
                                                                        $mappedKey,
×
586
                                                                        $fieldName,
×
587
                                                                        $isRequired,
×
588
                                                                        $isWritable,
×
589
                                                                );
×
590

591
                                                        } else {
592
                                                                $fields[] = new Hydrators\Fields\SingleEntityField(
×
593
                                                                        $className,
×
594
                                                                        $isNullable,
×
595
                                                                        $mappedKey,
×
596
                                                                        $isRelationship,
×
597
                                                                        $fieldName,
×
598
                                                                        $isRequired,
×
599
                                                                        $isWritable,
×
600
                                                                );
×
601
                                                        }
602
                                                } catch (ReflectionException) {
×
603
                                                        $fields[] = new Hydrators\Fields\SingleEntityField(
×
604
                                                                $className,
×
605
                                                                $isNullable,
×
606
                                                                $mappedKey,
×
607
                                                                $isRelationship,
×
608
                                                                $fieldName,
×
609
                                                                $isRequired,
×
610
                                                                $isWritable,
×
611
                                                        );
×
612
                                                }
613
                                        } elseif ($isString) {
×
614
                                                $fields[] = new Hydrators\Fields\TextField(
×
615
                                                        $isNullable,
×
616
                                                        $mappedKey,
×
617
                                                        $fieldName,
×
618
                                                        $isRequired,
×
619
                                                        $isWritable,
×
620
                                                );
×
621

622
                                        } elseif ($isNumber || $isDecimal) {
×
623
                                                $fields[] = new Hydrators\Fields\NumberField(
×
624
                                                        $isDecimal,
×
625
                                                        $isNullable,
×
626
                                                        $mappedKey,
×
627
                                                        $fieldName,
×
628
                                                        $isRequired,
×
629
                                                        $isWritable,
×
630
                                                );
×
631

632
                                        } elseif ($isArray) {
×
633
                                                $fields[] = new Hydrators\Fields\ArrayField(
×
634
                                                        $isNullable,
×
635
                                                        $mappedKey,
×
636
                                                        $fieldName,
×
637
                                                        $isRequired,
×
638
                                                        $isWritable,
×
639
                                                );
×
640

641
                                        } elseif ($isBool) {
×
642
                                                $fields[] = new Hydrators\Fields\BooleanField(
×
643
                                                        $isNullable,
×
644
                                                        $mappedKey,
×
645
                                                        $fieldName,
×
646
                                                        $isRequired,
×
647
                                                        $isWritable,
×
648
                                                );
×
649

650
                                        } elseif ($isMixed) {
×
651
                                                $fields[] = new Hydrators\Fields\MixedField(
×
652
                                                        $isNullable,
×
653
                                                        $mappedKey,
×
654
                                                        $fieldName,
×
655
                                                        $isRequired,
×
656
                                                        $isWritable,
×
657
                                                );
×
658
                                        }
659
                                }
660
                        }
661
                }
662

663
                return $fields;
×
664
        }
665

666
        /**
667
         * Get the model method name for a resource relationship key
668
         */
669
        private function getRelationshipKey(string $entityKey): string|null
670
        {
671
                $this->normalizeRelationships();
×
672

673
                $key = $this->normalizedRelationships[$entityKey] ?? null;
×
674

675
                return is_string($key) ? $key : null;
×
676
        }
677

678
        private function normalizeRelationships(): void
679
        {
680
                if (is_array($this->normalizedRelationships)) {
×
681
                        return;
×
682
                }
683

684
                $this->normalizedRelationships = [];
×
685

686
                if ($this->relationships !== []) {
×
687
                        foreach ($this->relationships as $resourceKey => $entityKey) {
×
688
                                if (is_numeric($resourceKey)) {
×
689
                                        $resourceKey = $entityKey;
×
690
                                }
691

692
                                $this->normalizedRelationships[$entityKey] = $resourceKey;
×
693
                        }
694
                }
695
        }
696

697
        private function getAttributeKey(string $entityKey): string|null
698
        {
699
                $this->normalizeAttributes();
×
700

701
                $key = $this->normalizedAttributes[$entityKey] ?? null;
×
702

703
                return is_string($key) ? $key : null;
×
704
        }
705

706
        private function normalizeAttributes(): void
707
        {
708
                if (is_array($this->normalizedAttributes)) {
×
709
                        return;
×
710
                }
711

712
                $this->normalizedAttributes = [];
×
713

714
                if ($this->attributes !== []) {
×
715
                        foreach ($this->attributes as $resourceKey => $entityKey) {
×
716
                                if (is_numeric($resourceKey)) {
×
717
                                        $resourceKey = $entityKey;
×
718
                                }
719

720
                                $this->normalizedAttributes[$entityKey] = $resourceKey;
×
721
                        }
722
                }
723
        }
724

725
        private function getCompositedAttributeKey(string $entityKey): string|null
726
        {
727
                $this->normalizeCompositeAttributes();
×
728

729
                $key = $this->normalizedCompositedAttributes[$entityKey] ?? null;
×
730

731
                return is_string($key) ? $key : null;
×
732
        }
733

734
        private function normalizeCompositeAttributes(): void
735
        {
736
                if (is_array($this->normalizedCompositedAttributes)) {
×
737
                        return;
×
738
                }
739

740
                $this->normalizedCompositedAttributes = [];
×
741

742
                if ($this->compositedAttributes !== []) {
×
743
                        foreach ($this->compositedAttributes as $resourceKey => $entityKey) {
×
744
                                if (is_numeric($resourceKey)) {
×
745
                                        $resourceKey = $entityKey;
×
746
                                }
747

748
                                $this->normalizedCompositedAttributes[$entityKey] = $resourceKey;
×
749
                        }
750
                }
751
        }
752

753
        private function parseAnnotation(ReflectionProperty $rp, string $name): string|null
754
        {
755
                if ($rp->getDocComment() === false) {
×
756
                        return null;
×
757
                }
758

759
                $factory = phpDocumentor\Reflection\DocBlockFactory::createInstance();
×
760
                $docblock = $factory->create($rp->getDocComment());
×
761

762
                foreach ($docblock->getTags() as $tag) {
×
763
                        if ($tag->getName() === $name) {
×
764
                                return trim((string) $tag);
×
765
                        }
766
                }
767

768
                if ($name === 'var' && $rp->getType() instanceof ReflectionNamedType) {
×
769
                        return $rp->getType()->getName();
×
770
                }
771

772
                return null;
×
773
        }
774

775
        /**
776
         * @param JsonAPIDocument\Objects\IStandardObject<string, mixed> $attributes
777
         * @param array<Hydrators\Fields\Field> $entityMapping
778
         * @param T|null $entity
779
         *
780
         * @return array<mixed>
781
         *
782
         * @throws Exceptions\InvalidState
783
         */
784
        protected function hydrateAttributes(
785
                string $className,
786
                JsonAPIDocument\Objects\IStandardObject $attributes,
787
                array $entityMapping,
788
                object|null $entity,
789
                string|null $rootField,
790
        ): array
791
        {
792
                $data = [];
×
793

794
                $isNew = $entity === null;
×
795

796
                foreach ($entityMapping as $field) {
×
797
                        if ($field instanceof Hydrators\Fields\EntityField && $field->isRelationship()) {
×
798
                                continue;
×
799
                        }
800

801
                        // Continue only if attribute is present
802
                        if (
803
                                !$attributes->has($field->getMappedName())
×
804
                                && !in_array($field->getFieldName(), $this->compositedAttributes, true)
×
805
                        ) {
806
                                continue;
×
807
                        }
808

809
                        // If there is a specific method for this attribute, we'll hydrate that
810
                        $value = $this->hasCustomHydrateAttribute($field->getFieldName(), $attributes)
×
811
                                ? $this->callHydrateAttribute($field->getFieldName(), $attributes, $entity)
×
812
                                : $field->getValue($attributes);
×
813

814
                        if ($value === null && $field->isRequired() && $isNew) {
×
815
                                $this->errors->addError(
×
816
                                        StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY,
×
NEW
817
                                        strval($this->translator->translate('//jsonApi.hydrator.missingRequiredAttribute.heading')),
×
NEW
818
                                        strval($this->translator->translate('//jsonApi.hydrator.missingRequiredAttribute.message')),
×
819
                                        [
×
820
                                                'pointer' => '/data/attributes/' . $field->getMappedName(),
×
821
                                        ],
×
822
                                );
×
823

824
                        } elseif ($field->isWritable() || ($isNew && $field->isRequired())) {
×
825
                                if ($field instanceof Hydrators\Fields\SingleEntityField) {
×
826
                                        // Get attribute entity class name
827
                                        $fieldClassName = $field->getClassName();
×
828

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

832
                                        if ($fieldAttributes instanceof JsonAPIDocument\Objects\IStandardObject) {
×
833
                                                $data[$field->getFieldName()] = $this->hydrateAttributes(
×
834
                                                        $fieldClassName,
×
835
                                                        $fieldAttributes,
×
836
                                                        $this->mapEntity($fieldClassName),
×
837
                                                        $entity,
×
838
                                                        $field->getMappedName(),
×
839
                                                );
×
840

841
                                                if (
842
                                                        isset($data[$field->getFieldName()])
×
843
                                                        && is_array($data[$field->getFieldName()])
×
844
                                                        && !isset($data[$field->getFieldName()]['entity'])
×
845
                                                ) {
846
                                                        $data[$field->getFieldName()]['entity'] = $fieldClassName;
×
847
                                                }
848
                                        } elseif ($value !== null || $field->isNullable()) {
×
849
                                                $data[$field->getFieldName()] = $value;
×
850
                                        }
851
                                } else {
852
                                        $data[$field->getFieldName()] = $value;
×
853
                                }
854
                        }
855
                }
856

857
                try {
858
                        if (class_exists($className)) {
×
859
                                $rc = new ReflectionClass($className);
×
860

861
                                if ($rc->getConstructor() !== null) {
×
862
                                        $constructor = $rc->getConstructor();
×
863

864
                                        foreach ($constructor->getParameters() as $num => $parameter) {
×
865
                                                if (
866
                                                        !$parameter->isVariadic()
×
867
                                                        && $attributes->has($this->getAttributeKey($parameter->getName()) ?? $parameter->getName())
×
868
                                                ) {
869
                                                        if (array_key_exists($parameter->getName(), $data)) {
×
870
                                                                continue;
×
871
                                                        }
872

873
                                                        // If there is a specific method for this attribute, we'll hydrate that
874
                                                        $value = $this->hasCustomHydrateAttribute(
×
875
                                                                $parameter->getName(),
×
876
                                                                $attributes,
×
877
                                                        ) ? $this->callHydrateAttribute(
×
878
                                                                $parameter->getName(),
×
879
                                                                $attributes,
×
880
                                                                $entity,
×
881
                                                        ) : $attributes->get(
×
882
                                                                $this->getAttributeKey($parameter->getName()) ?? $parameter->getName(),
×
883
                                                        );
×
884

885
                                                        $data[$parameter->getName()] = $value;
×
886

887
                                                } elseif ($attributes->has($this->getAttributeKey((string) $num) ?? (string) $num)) {
×
888
                                                        if (array_key_exists((string) $num, $data)) {
×
889
                                                                continue;
×
890
                                                        }
891

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

904
                                                        $data[(string) $num] = $value;
×
905
                                                }
906
                                        }
907
                                }
908

909
                                $data['entity'] = $className;
×
910
                        }
911
                } catch (Throwable) {
×
912
                        // Nothing to do here
913
                }
914

915
                if (!$isNew) {
×
916
                        foreach ($data as $attribute => $value) {
×
917
                                $isAllowed = false;
×
918

919
                                foreach ($entityMapping as $field) {
×
920
                                        if ($field instanceof Hydrators\Fields\EntityField && $field->isRelationship()) {
×
921
                                                $isAllowed = true;
×
922

923
                                                continue;
×
924
                                        }
925

926
                                        if ($field->getFieldName() === $attribute) {
×
927
                                                $isAllowed = true;
×
928
                                        }
929
                                }
930

931
                                if (!$isAllowed) {
×
932
                                        unset($data[$attribute]);
×
933
                                }
934
                        }
935
                }
936

937
                return $data;
×
938
        }
939

940
        /**
941
         * Check if hydrator has custom attribute hydration method
942
         *
943
         * @param JsonAPIDocument\Objects\IStandardObject<string, mixed> $attributes
944
         */
945
        private function hasCustomHydrateAttribute(
946
                string $attributeKey,
947
                JsonAPIDocument\Objects\IStandardObject $attributes,
948
        ): bool
949
        {
950
                $method = $this->methodForAttribute($attributeKey);
×
951

952
                if ($method === '' || !method_exists($this, $method)) {
×
953
                        return false;
×
954
                }
955

956
                $callable = [$this, $method];
×
957

958
                return is_callable($callable);
×
959
        }
960

961
        /**
962
         * Return the method name to call for hydrating the specific attribute.
963
         *
964
         * If this method returns an empty value, or a value that is not callable, hydration
965
         * of the relationship will be skipped
966
         */
967
        private function methodForAttribute(string $key): string
968
        {
969
                return sprintf('hydrate%sAttribute', $this->classify($key));
×
970
        }
971

972
        /**
973
         * Gets the upper camel case form of a string.
974
         */
975
        private function classify(string $value): string
976
        {
977
                $converted = ucwords(str_replace(['-', '_'], ' ', $value));
×
978

979
                return str_replace(' ', '', $converted);
×
980
        }
981

982
        /**
983
         * Hydrate a attribute by invoking a method on this hydrator.
984
         *
985
         * @param JsonAPIDocument\Objects\IStandardObject<string, mixed> $attributes
986
         * @param T|null $entity
987
         */
988
        private function callHydrateAttribute(
989
                string $attributeKey,
990
                JsonAPIDocument\Objects\IStandardObject $attributes,
991
                object|null $entity = null,
992
        ): mixed
993
        {
994
                $method = $this->methodForAttribute($attributeKey);
×
995

996
                if ($method === '' || !method_exists($this, $method)) {
×
997
                        return null;
×
998
                }
999

1000
                $callable = [$this, $method];
×
1001

1002
                if (is_callable($callable)) {
×
1003
                        return call_user_func($callable, $attributes, $entity);
×
1004
                }
1005

1006
                return null;
×
1007
        }
1008

1009
        /**
1010
         * @param array<Hydrators\Fields\Field> $entityMapping
1011
         * @param JsonAPIDocument\Objects\IResourceObjectCollection<JsonAPIDocument\Objects\IResourceObject>|null $included
1012
         * @param T|null $entity
1013
         *
1014
         * @return  array<mixed>
1015
         *
1016
         * @throws Exceptions\InvalidState
1017
         */
1018
        protected function hydrateRelationships(
1019
                JsonAPIDocument\Objects\IRelationshipObjectCollection $relationships,
1020
                array $entityMapping,
1021
                JsonAPIDocument\Objects\IResourceObjectCollection|null $included = null,
1022
                object|null $entity = null,
1023
        ): array
1024
        {
1025
                $data = [];
×
1026

1027
                foreach ($entityMapping as $field) {
×
1028
                        if ($field instanceof Hydrators\Fields\EntityField && $field->isRelationship()) {
×
1029
                                if ($relationships->has($field->getMappedName())) {
×
1030
                                        $relationship = $relationships->get($field->getMappedName());
×
1031

1032
                                        // If there is a specific method for this relationship, we'll hydrate that
1033
                                        $result = $this->callHydrateRelationship(
×
1034
                                                $field->getMappedName(),
×
1035
                                                $relationship,
×
1036
                                                $included,
×
1037
                                                $entity,
×
1038
                                        );
×
1039

1040
                                        if ($result !== null) {
×
1041
                                                $data[$field->getFieldName()] = $result;
×
1042

1043
                                                continue;
×
1044
                                        }
1045

1046
                                        // If this is a has-one, we'll hydrate it
1047
                                        if ($relationship->isHasOne()) {
×
1048
                                                $relationshipEntity = $this->hydrateHasOne(
×
1049
                                                        $field,
×
1050
                                                        $relationship,
×
1051
                                                        $entity,
×
1052
                                                        $entityMapping,
×
1053
                                                );
×
1054

1055
                                                $data[$field->getFieldName()] = $relationshipEntity;
×
1056

1057
                                        } elseif ($relationship->isHasMany()) {
×
1058
                                                $relationshipEntities = $this->hydrateHasMany(
×
1059
                                                        $field,
×
1060
                                                        $relationship,
×
1061
                                                        $entity,
×
1062
                                                        $entityMapping,
×
1063
                                                );
×
1064

1065
                                                $data[$field->getFieldName()] = $relationshipEntities;
×
1066
                                        }
1067
                                } elseif ($field->isRequired() && $entity === null) {
×
1068
                                        $this->errors->addError(
×
1069
                                                StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY,
×
NEW
1070
                                                strval($this->translator->translate('//jsonApi.hydrator.missingRequiredRelation.heading')),
×
NEW
1071
                                                strval($this->translator->translate('//jsonApi.hydrator.missingRequiredRelation.message')),
×
1072
                                                [
×
1073
                                                        'pointer' => '/data/relationships/' . $field->getMappedName() . '/data/id',
×
1074
                                                ],
×
1075
                                        );
×
1076
                                }
1077
                        }
1078
                }
1079

1080
                return $data;
×
1081
        }
1082

1083
        /**
1084
         * Hydrate a relationship by invoking a method on this hydrator.
1085
         *
1086
         * @param JsonAPIDocument\Objects\IResourceObjectCollection<JsonAPIDocument\Objects\IResourceObject>|null $included
1087
         * @param T|null $entity
1088
         *
1089
         * @return  array<mixed>|object|null
1090
         *
1091
         * @throws Exceptions\InvalidState
1092
         */
1093
        private function callHydrateRelationship(
1094
                string $relationshipKey,
1095
                JsonAPIDocument\Objects\IRelationshipObject $relationship,
1096
                JsonAPIDocument\Objects\IResourceObjectCollection|null $included = null,
1097
                object|null $entity = null,
1098
        ): array|object|null
1099
        {
1100
                $method = $this->methodForRelationship($relationshipKey);
×
1101

1102
                if ($method === '' || !method_exists($this, $method)) {
×
1103
                        return null;
×
1104
                }
1105

1106
                $callable = [$this, $method];
×
1107

1108
                if (is_callable($callable)) {
×
1109
                        $result = call_user_func($callable, $relationship, $included, $entity);
×
1110

1111
                        if ($result === null || is_array($result) || is_object($result)) {
×
1112
                                return $result;
×
1113
                        }
1114

1115
                        throw new Exceptions\InvalidState(
×
1116
                                sprintf('Relationship have to be an array or entity instance, %s provided.', gettype($result)),
×
1117
                        );
×
1118
                }
1119

1120
                return null;
×
1121
        }
1122

1123
        /**
1124
         * Return the method name to call for hydrating the specific relationship.
1125
         *
1126
         * If this method returns an empty value, or a value that is not callable, hydration
1127
         * of the the relationship will be skipped
1128
         */
1129
        private function methodForRelationship(string $key): string
1130
        {
1131
                return sprintf('hydrate%sRelationship', $this->classify($key));
×
1132
        }
1133

1134
        /**
1135
         * Hydrate a resource has-one relationship
1136
         *
1137
         * @param T|null $entity
1138
         * @param array<Hydrators\Fields\Field> $entityMapping
1139
         */
1140
        protected function hydrateHasOne(
1141
                Hydrators\Fields\Field $field,
1142
                JsonAPIDocument\Objects\IRelationshipObject $relationship,
1143
                object|null $entity,
1144
                array $entityMapping,
1145
        ): object|null
1146
        {
1147
                // Find relationship field
1148
                if (
1149
                        $field instanceof Hydrators\Fields\EntityField
×
1150
                        && $field->isRelationship()
×
1151
                ) {
1152
                        if ($field->isWritable() || ($entity === null && $field->isRequired())) {
×
1153
                                if ($relationship->hasIdentifier()) {
×
1154
                                        $relationEntity = $this->findRelated($field->getClassName(), $relationship->getIdentifier());
×
1155

1156
                                        if ($relationEntity !== null) {
×
1157
                                                return $relationEntity;
×
1158
                                        } elseif ($entity === null && $field->isRequired()) {
×
1159
                                                $this->errors->addError(
×
1160
                                                        StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY,
×
NEW
1161
                                                        strval($this->translator->translate('//jsonApi.hydrator.missingRequiredRelation.heading')),
×
NEW
1162
                                                        strval($this->translator->translate('//jsonApi.hydrator.missingRequiredRelation.message')),
×
1163
                                                        [
×
1164
                                                                'pointer' => '/data/relationships/' . $field->getMappedName() . '/data/id',
×
1165
                                                        ],
×
1166
                                                );
×
1167
                                        }
1168
                                } elseif ($entity === null && $field->isRequired()) {
×
1169
                                        $this->errors->addError(
×
1170
                                                StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY,
×
NEW
1171
                                                strval($this->translator->translate('//jsonApi.hydrator.missingRequiredRelation.heading')),
×
NEW
1172
                                                strval($this->translator->translate('//jsonApi.hydrator.missingRequiredRelation.message')),
×
1173
                                                [
×
1174
                                                        'pointer' => '/data/relationships/' . $field->getMappedName() . '/data/id',
×
1175
                                                ],
×
1176
                                        );
×
1177
                                }
1178
                        }
1179
                }
1180

1181
                return null;
×
1182
        }
1183

1184
        /**
1185
         * @param class-string $entityClassName
1186
         */
1187
        private function findRelated(
1188
                string $entityClassName,
1189
                JsonAPIDocument\Objects\IResourceIdentifierObject $identifier,
1190
        ): object|null
1191
        {
1192
                if ($identifier->getId() === null || !Uuid\Uuid::isValid($identifier->getId())) {
×
1193
                        return null;
×
1194
                }
1195

1196
                if (!class_exists($entityClassName)) {
×
1197
                        return null;
×
1198
                }
1199

1200
                $entityManager = $this->managerRegistry->getManagerForClass($entityClassName);
×
1201

1202
                if ($entityManager !== null) {
×
1203
                        return $entityManager
×
1204
                                ->getRepository($entityClassName)
×
1205
                                ->find($identifier->getId());
×
1206
                }
1207

1208
                return null;
×
1209
        }
1210

1211
        /**
1212
         * Hydrate a resource has-many relationship
1213
         *
1214
         * @param T|null $entity
1215
         * @param array<Hydrators\Fields\Field> $entityMapping
1216
         *
1217
         * @return array<int, object>
1218
         */
1219
        protected function hydrateHasMany(
1220
                Hydrators\Fields\Field $field,
1221
                JsonAPIDocument\Objects\IRelationshipObject $relationship,
1222
                object|null $entity,
1223
                array $entityMapping,
1224
        ): array
1225
        {
1226
                $relations = [];
×
1227

1228
                // Find relationship field
1229
                if (
1230
                        $field instanceof Hydrators\Fields\EntityField
×
1231
                        && $field->isRelationship()
×
1232
                ) {
1233
                        if ($field->isWritable() || ($entity === null && $field->isRequired())) {
×
1234
                                if ($relationship->isHasMany()) {
×
1235
                                        foreach ($relationship->getIdentifiers() as $identifier) {
×
1236
                                                $relationEntity = $this->findRelated($field->getClassName(), $identifier);
×
1237

1238
                                                if ($relationEntity !== null) {
×
1239
                                                        $relations[] = $relationEntity;
×
1240
                                                }
1241
                                        }
1242
                                }
1243

1244
                                if ($entity === null && $field->isRequired() && count($relations) === 0) {
×
1245
                                        $this->errors->addError(
×
1246
                                                StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY,
×
NEW
1247
                                                strval($this->translator->translate('//jsonApi.hydrator.missingRequiredRelation.heading')),
×
NEW
1248
                                                strval($this->translator->translate('//jsonApi.hydrator.missingRequiredRelation.message')),
×
1249
                                                [
×
1250
                                                        'pointer' => '/data/relationships/' . $field->getMappedName() . '/data',
×
1251
                                                ],
×
1252
                                        );
×
1253
                                }
1254
                        }
1255

1256
                        return $relations;
×
1257
                }
1258

1259
                return [];
×
1260
        }
1261

1262
}
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