Completed
Pull Request — master (#615)
by Filippo
14:02 queued 03:29
created

DoctrineObject::hydrateByReference()   C

Complexity

Conditions 7
Paths 12

Size

Total Lines 38
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 7.0046

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 38
ccs 21
cts 22
cp 0.9545
rs 6.7272
cc 7
eloc 23
nc 12
nop 2
crap 7.0046
1
<?php
2
3
namespace DoctrineModule\Stdlib\Hydrator;
4
5
use DateTime;
6
use Doctrine\Common\Persistence\Mapping\ClassMetadata;
7
use Doctrine\Common\Persistence\ObjectManager;
8
use Doctrine\Common\Util\Inflector;
9
use InvalidArgumentException;
10
use RuntimeException;
11
use Traversable;
12
use Zend\Stdlib\ArrayUtils;
13
use Zend\Hydrator\AbstractHydrator;
14
use Zend\Hydrator\Filter\FilterProviderInterface;
15
16
/**
17
 * This hydrator has been completely refactored for DoctrineModule 0.7.0. It provides an easy and powerful way
18
 * of extracting/hydrator objects in Doctrine, by handling most associations types.
19
 *
20
 * Starting from DoctrineModule 0.8.0, the hydrator can be used multiple times with different objects
21
 *
22
 * @license MIT
23
 * @link    http://www.doctrine-project.org/
24
 * @since   0.7.0
25
 * @author  Michael Gallego <[email protected]>
26
 */
27
class DoctrineObject extends AbstractHydrator
28
{
29
    /**
30
     * @var ObjectManager
31
     */
32
    protected $objectManager;
33
34
    /**
35
     * @var ClassMetadata
36
     */
37
    protected $metadata;
38
39
    /**
40
     * @var bool
41
     */
42
    protected $byValue = true;
43
44
45
    /**
46
     * Constructor
47
     *
48
     * @param ObjectManager $objectManager The ObjectManager to use
49
     * @param bool          $byValue       If set to true, hydrator will always use entity's public API
50
     */
51 56
    public function __construct(ObjectManager $objectManager, $byValue = true)
52
    {
53 56
        parent::__construct();
54
55 56
        $this->objectManager = $objectManager;
56 56
        $this->byValue       = (bool) $byValue;
57 56
    }
58
59
    /**
60
     * Extract values from an object
61
     *
62
     * @param  object $object
63
     * @return array
64
     */
65 17
    public function extract($object)
66
    {
67 17
        $this->prepare($object);
68
69 17
        if ($this->byValue) {
70 10
            return $this->extractByValue($object);
71
        }
72
73 7
        return $this->extractByReference($object);
74
    }
75
76
    /**
77
     * Hydrate $object with the provided $data.
78
     *
79
     * @param  array  $data
80
     * @param  object $object
81
     * @return object
82
     */
83 40
    public function hydrate(array $data, $object)
84
    {
85 40
        $this->prepare($object);
86
87 40
        if ($this->byValue) {
88 27
            return $this->hydrateByValue($data, $object);
89
        }
90
91 13
        return $this->hydrateByReference($data, $object);
92
    }
93
94
    /**
95
     * Prepare the hydrator by adding strategies to every collection valued associations
96
     *
97
     * @param  object $object
98
     * @return void
99
     */
100 56
    protected function prepare($object)
101
    {
102 56
        $this->metadata = $this->objectManager->getClassMetadata(get_class($object));
103 56
        $this->prepareStrategies();
104 56
    }
105
106
    /**
107
     * Prepare strategies before the hydrator is used
108
     *
109
     * @throws \InvalidArgumentException
110
     * @return void
111
     */
112 56
    protected function prepareStrategies()
113
    {
114 56
        $associations = $this->metadata->getAssociationNames();
115
116 56
        foreach ($associations as $association) {
117 34
            if ($this->metadata->isCollectionValuedAssociation($association)) {
118
                // Add a strategy if the association has none set by user
119 21
                if (! $this->hasStrategy($association)) {
120 19
                    if ($this->byValue) {
121 12
                        $this->addStrategy($association, new Strategy\AllowRemoveByValue());
122
                    } else {
123 7
                        $this->addStrategy($association, new Strategy\AllowRemoveByReference());
124
                    }
125
                }
126
127 21
                $strategy = $this->getStrategy($association);
128
129 21
                if (! $strategy instanceof Strategy\AbstractCollectionStrategy) {
130
                    throw new InvalidArgumentException(
131
                        sprintf(
132
                            'Strategies used for collections valued associations must inherit from '
133
                            . 'Strategy\AbstractCollectionStrategy, %s given',
134
                            get_class($strategy)
135
                        )
136
                    );
137
                }
138
139 21
                $strategy->setCollectionName($association)
140 34
                         ->setClassMetadata($this->metadata);
141
            }
142
        }
143 56
    }
144
145
    /**
146
     * Extract values from an object using a by-value logic (this means that it uses the entity
147
     * API, in this case, getters)
148
     *
149
     * @param  object $object
150
     * @throws RuntimeException
151
     * @return array
152
     */
153 10
    protected function extractByValue($object)
154
    {
155 10
        $fieldNames = array_merge($this->metadata->getFieldNames(), $this->metadata->getAssociationNames());
156 10
        $methods    = get_class_methods($object);
157 10
        $filter     = $object instanceof FilterProviderInterface
158
            ? $object->getFilter()
159 10
            : $this->filterComposite;
160
161 10
        $data = [];
162 10
        foreach ($fieldNames as $fieldName) {
163 10
            if ($filter && ! $filter->filter($fieldName)) {
164 1
                continue;
165
            }
166
167 10
            $getter = 'get' . Inflector::classify($fieldName);
168 10
            $isser  = 'is' . Inflector::classify($fieldName);
169
170 10
            $dataFieldName = $this->computeExtractFieldName($fieldName);
171 10
            if (in_array($getter, $methods)) {
172 10
                $data[$dataFieldName] = $this->extractValue($fieldName, $object->$getter(), $object);
173 2
            } elseif (in_array($isser, $methods)) {
174 1
                $data[$dataFieldName] = $this->extractValue($fieldName, $object->$isser(), $object);
175 1
            } elseif (substr($fieldName, 0, 2) === 'is'
176 1
                && ctype_upper(substr($fieldName, 2, 1))
177 1
                && in_array($fieldName, $methods)) {
178 10
                $data[$dataFieldName] = $this->extractValue($fieldName, $object->$fieldName(), $object);
179
            }
180
181
            // Unknown fields are ignored
182
        }
183
184 10
        return $data;
185
    }
186
187
    /**
188
     * Extract values from an object using a by-reference logic (this means that values are
189
     * directly fetched without using the public API of the entity, in this case, getters)
190
     *
191
     * @param  object $object
192
     * @return array
193
     */
194 7
    protected function extractByReference($object)
195
    {
196 7
        $fieldNames = array_merge($this->metadata->getFieldNames(), $this->metadata->getAssociationNames());
197 7
        $refl       = $this->metadata->getReflectionClass();
198 7
        $filter     = $object instanceof FilterProviderInterface
199
            ? $object->getFilter()
200 7
            : $this->filterComposite;
201
202 7
        $data = [];
203 7
        foreach ($fieldNames as $fieldName) {
204 7
            if ($filter && ! $filter->filter($fieldName)) {
205 1
                continue;
206
            }
207 7
            $reflProperty = $refl->getProperty($fieldName);
208 7
            $reflProperty->setAccessible(true);
209
210 7
            $dataFieldName        = $this->computeExtractFieldName($fieldName);
211 7
            $data[$dataFieldName] = $this->extractValue($fieldName, $reflProperty->getValue($object), $object);
212
        }
213
214 7
        return $data;
215
    }
216
217
    /**
218
     * Hydrate the object using a by-value logic (this means that it uses the entity API, in this
219
     * case, setters)
220
     *
221
     * @param  array  $data
222
     * @param  object $object
223
     * @throws RuntimeException
224
     * @return object
225
     */
226 28
    protected function hydrateByValue(array $data, $object)
227
    {
228 28
        $tryObject = $this->tryConvertArrayToObject($data, $object);
229 28
        $metadata  = $this->metadata;
230
231 28
        if (is_object($tryObject)) {
232 25
            $object = $tryObject;
233
        }
234
235 28
        foreach ($data as $field => $value) {
236 28
            $field  = $this->computeHydrateFieldName($field);
237 28
            $value  = $this->handleTypeConversions($value, $metadata->getTypeOfField($field));
238 28
            $setter = 'set' . Inflector::classify($field);
239
240 28
            if ($metadata->hasAssociation($field)) {
241 18
                $target = $metadata->getAssociationTargetClass($field);
242
243 18
                if ($metadata->isSingleValuedAssociation($field)) {
244 7
                    if (! is_callable([$object, $setter])) {
245
                        continue;
246
                    }
247
248 7
                    $value = $this->toOne($target, $this->hydrateValue($field, $value, $data));
249
250 7
                    if (null === $value
251 7
                        && ! current($metadata->getReflectionClass()->getMethod($setter)->getParameters())->allowsNull()
252
                    ) {
253 1
                        continue;
254
                    }
255
256 6
                    $object->$setter($value);
257 11
                } elseif ($metadata->isCollectionValuedAssociation($field)) {
258 17
                    $this->toMany($object, $field, $target, $value);
259
                }
260
            } else {
261 13
                if (! is_callable([$object, $setter])) {
262 1
                    continue;
263
                }
264
265 26
                $object->$setter($this->hydrateValue($field, $value, $data));
266
            }
267
        }
268
269 28
        return $object;
270
    }
271
272
    /**
273
     * Hydrate the object using a by-reference logic (this means that values are modified directly without
274
     * using the public API, in this case setters, and hence override any logic that could be done in those
275
     * setters)
276
     *
277
     * @param  array  $data
278
     * @param  object $object
279
     * @return object
280
     */
281 14
    protected function hydrateByReference(array $data, $object)
282
    {
283 14
        $tryObject = $this->tryConvertArrayToObject($data, $object);
284 14
        $metadata  = $this->metadata;
285 14
        $refl      = $metadata->getReflectionClass();
286
287 14
        if (is_object($tryObject)) {
288 13
            $object = $tryObject;
289
        }
290
291 14
        foreach ($data as $field => $value) {
292 14
            $field = $this->computeHydrateFieldName($field);
293
294
            // Ignore unknown fields
295 14
            if (! $refl->hasProperty($field)) {
296
                continue;
297
            }
298
299 14
            $value        = $this->handleTypeConversions($value, $metadata->getTypeOfField($field));
300 14
            $reflProperty = $refl->getProperty($field);
301 14
            $reflProperty->setAccessible(true);
302
303 14
            if ($metadata->hasAssociation($field)) {
304 10
                $target = $metadata->getAssociationTargetClass($field);
305
306 10
                if ($metadata->isSingleValuedAssociation($field)) {
307 4
                    $value = $this->toOne($target, $this->hydrateValue($field, $value, $data));
308 4
                    $reflProperty->setValue($object, $value);
309 6
                } elseif ($metadata->isCollectionValuedAssociation($field)) {
310 10
                    $this->toMany($object, $field, $target, $value);
311
                }
312
            } else {
313 14
                $reflProperty->setValue($object, $this->hydrateValue($field, $value, $data));
314
            }
315
        }
316
317 14
        return $object;
318
    }
319
320
    /**
321
     * This function tries, given an array of data, to convert it to an object if the given array contains
322
     * an identifier for the object. This is useful in a context of updating existing entities, without ugly
323
     * tricks like setting manually the existing id directly into the entity
324
     *
325
     * @param  array  $data   The data that may contain identifiers keys
326
     * @param  object $object
327
     * @return object
328
     */
329 40
    protected function tryConvertArrayToObject($data, $object)
330
    {
331 40
        $metadata         = $this->metadata;
332 40
        $identifierNames  = $metadata->getIdentifierFieldNames($object);
0 ignored issues
show
Unused Code introduced by
The call to ClassMetadata::getIdentifierFieldNames() has too many arguments starting with $object.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
333 40
        $identifierValues = [];
334
335 40
        if (empty($identifierNames)) {
336 28
            return $object;
337
        }
338
339 12
        foreach ($identifierNames as $identifierName) {
340 12
            if (! isset($data[$identifierName])) {
341 8
                return $object;
342
            }
343
344 4
            $identifierValues[$identifierName] = $data[$identifierName];
345
        }
346
347 4
        return $this->find($identifierValues, $metadata->getName());
348
    }
349
350
    /**
351
     * Handle ToOne associations
352
     *
353
     * When $value is an array but is not the $target's identifiers, $value is
354
     * most likely an array of fieldset data. The identifiers will be determined
355
     * and a target instance will be initialized and then hydrated. The hydrated
356
     * target will be returned.
357
     *
358
     * @param  string $target
359
     * @param  mixed  $value
360
     * @return object
361
     */
362 11
    protected function toOne($target, $value)
363
    {
364 11
        $metadata = $this->objectManager->getClassMetadata($target);
365
366 11
        if (is_array($value) && array_keys($value) != $metadata->getIdentifier()) {
367
            // $value is most likely an array of fieldset data
368 1
            $identifiers = array_intersect_key(
369 1
                $value,
370 1
                array_flip($metadata->getIdentifier())
371
            );
372 1
            $object      = $this->find($identifiers, $target) ?: new $target;
373 1
            return $this->hydrate($value, $object);
374
        }
375
376 10
        return $this->find($value, $target);
377
    }
378
379
    /**
380
     * Handle ToMany associations. In proper Doctrine design, Collections should not be swapped, so
381
     * collections are always handled by reference. Internally, every collection is handled using specials
382
     * strategies that inherit from AbstractCollectionStrategy class, and that add or remove elements but without
383
     * changing the collection of the object
384
     *
385
     * @param  object $object
386
     * @param  mixed  $collectionName
387
     * @param  string $target
388
     * @param  mixed  $values
389
     *
390
     * @throws \InvalidArgumentException
391
     *
392
     * @return void
393
     */
394 17
    protected function toMany($object, $collectionName, $target, $values)
395
    {
396 17
        $metadata   = $this->objectManager->getClassMetadata(ltrim($target, '\\'));
397 17
        $identifier = $metadata->getIdentifier();
398
399 17
        if (! is_array($values) && ! $values instanceof Traversable) {
400
            $values = (array)$values;
401
        }
402
403 17
        $collection = [];
404
405
        // If the collection contains identifiers, fetch the objects from database
406 17
        foreach ($values as $value) {
407 17
            if ($value instanceof $target) {
408
                // assumes modifications have already taken place in object
409 7
                $collection[] = $value;
410 7
                continue;
411
            } elseif (empty($value)) {
412
                // assumes no id and retrieves new $target
413 1
                $collection[] = $this->find($value, $target);
414 1
                continue;
415
            }
416
417 9
            $find = [];
418 9
            if (is_array($identifier)) {
419 9
                foreach ($identifier as $field) {
420 9
                    switch (gettype($value)) {
421 9
                        case 'object':
422 1
                            $getter = 'get' . ucfirst($field);
423 1
                            if (is_callable([$value, $getter])) {
424
                                $find[$field] = $value->$getter();
425 1
                            } elseif (property_exists($value, $field)) {
426 1
                                $find[$field] = $value->$field;
427
                            }
428 1
                            break;
429 8
                        case 'array':
430 5
                            if (array_key_exists($field, $value) && $value[$field] != null) {
431 5
                                $find[$field] = $value[$field];
432 5
                                unset($value[$field]); // removed identifier from persistable data
433
                            }
434 5
                            break;
435
                        default:
436 3
                            $find[$field] = $value;
437 9
                            break;
438
                    }
439
                }
440
            }
441
442 9
            if (! empty($find) && $found = $this->find($find, $target)) {
443 9
                $collection[] = (is_array($value)) ? $this->hydrate($value, $found) : $found;
444
            } else {
445 9
                $collection[] = (is_array($value)) ? $this->hydrate($value, new $target) : new $target;
446
            }
447
        }
448
449 17
        $collection = array_filter(
450 17
            $collection,
451 17
            function ($item) {
452 17
                return null !== $item;
453 17
            }
454
        );
455
456
        // Set the object so that the strategy can extract the Collection from it
457
458
        /** @var \DoctrineModule\Stdlib\Hydrator\Strategy\AbstractCollectionStrategy $collectionStrategy */
459 17
        $collectionStrategy = $this->getStrategy($collectionName);
460 17
        $collectionStrategy->setObject($object);
461
462
        // We could directly call hydrate method from the strategy, but if people want to override
463
        // hydrateValue function, they can do it and do their own stuff
464 17
        $this->hydrateValue($collectionName, $collection, $values);
0 ignored issues
show
Bug introduced by
It seems like $values defined by parameter $values on line 394 can also be of type object<Traversable>; however, Zend\Hydrator\AbstractHydrator::hydrateValue() does only seem to accept array|null, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
465 17
    }
466
467
    /**
468
     * Handle various type conversions that should be supported natively by Doctrine (like DateTime)
469
     *
470
     * @param  mixed  $value
471
     * @param  string $typeOfField
472
     * @return DateTime
473
     */
474 40
    protected function handleTypeConversions($value, $typeOfField)
475
    {
476 40
        switch ($typeOfField) {
477 40
            case 'datetimetz':
478 40
            case 'datetime':
479 38
            case 'time':
480 38
            case 'date':
481 2
                if ('' === $value) {
482 1
                    return null;
483
                }
484
485 1
                if (is_int($value)) {
486 1
                    $dateTime = new DateTime();
487 1
                    $dateTime->setTimestamp($value);
488 1
                    $value = $dateTime;
489
                } elseif (is_string($value)) {
490
                    $value = new DateTime($value);
491
                }
492
493 1
                break;
494
            default:
495
        }
496
497 39
        return $value;
498
    }
499
500
    /**
501
     * Find an object by a given target class and identifier
502
     *
503
     * @param  mixed   $identifiers
504
     * @param  string  $targetClass
505
     *
506
     * @return object|null
507
     */
508 25
    protected function find($identifiers, $targetClass)
509
    {
510 25
        if ($identifiers instanceof $targetClass) {
511 2
            return $identifiers;
512
        }
513
514 23
        if ($this->isNullIdentifier($identifiers)) {
515 4
            return null;
516
        }
517
518 19
        return $this->objectManager->find($targetClass, $identifiers);
519
    }
520
521
    /**
522
     * Verifies if a provided identifier is to be considered null
523
     *
524
     * @param  mixed $identifier
525
     *
526
     * @return bool
527
     */
528 23
    private function isNullIdentifier($identifier)
529
    {
530 23
        if (null === $identifier) {
531 4
            return true;
532
        }
533
534 19
        if ($identifier instanceof Traversable || is_array($identifier)) {
535 16
            $nonNullIdentifiers = array_filter(
536 16
                ArrayUtils::iteratorToArray($identifier),
537 16
                function ($value) {
538 16
                    return null !== $value;
539 16
                }
540
            );
541
542 16
            return empty($nonNullIdentifiers);
543
        }
544
545 3
        return false;
546
    }
547
548
    /**
549
     * Applies the naming strategy if there is one set
550
     *
551
     * @param string $field
552
     *
553
     * @return string
554
     */
555 40
    protected function computeHydrateFieldName($field)
556
    {
557 40
        if ($this->hasNamingStrategy()) {
558 2
            $field = $this->getNamingStrategy()->hydrate($field);
559
        }
560 40
        return $field;
561
    }
562
563
    /**
564
     * Applies the naming strategy if there is one set
565
     *
566
     * @param string $field
567
     *
568
     * @return string
569
     */
570 17
    protected function computeExtractFieldName($field)
571
    {
572 17
        if ($this->hasNamingStrategy()) {
573 2
            $field = $this->getNamingStrategy()->extract($field);
574
        }
575 17
        return $field;
576
    }
577
}
578