Completed
Push — master ( 674c19...7898d5 )
by Tom
03:52
created

DoctrineObject   D

Complexity

Total Complexity 102

Size/Duplication

Total Lines 625
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 9

Test Coverage

Coverage 94.09%

Importance

Changes 0
Metric Value
wmc 102
lcom 1
cbo 9
dl 0
loc 625
ccs 239
cts 254
cp 0.9409
rs 4.7712
c 0
b 0
f 0

21 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 7 1
A getDefaultByValueStrategy() 0 4 1
A setDefaultByValueStrategy() 0 5 1
A getDefaultByReferenceStrategy() 0 4 1
A setDefaultByReferenceStrategy() 0 5 1
A extract() 0 10 2
A hydrate() 0 10 2
A prepare() 0 5 1
B prepareStrategies() 0 34 6
B extractByReference() 0 22 5
D hydrateByValue() 0 45 10
C hydrateByReference() 0 38 7
A tryConvertArrayToObject() 0 20 4
A toOne() 0 16 4
D extractByValue() 0 33 10
C toMany() 0 73 18
D handleTypeConversions() 0 48 17
A find() 0 12 3
A isNullIdentifier() 0 19 4
A computeHydrateFieldName() 0 7 2
A computeExtractFieldName() 0 7 2

How to fix   Complexity   

Complex Class

Complex classes like DoctrineObject often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use DoctrineObject, and based on these observations, apply Extract Interface, too.

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
     * @var string
46
     */
47
    protected $defaultByValueStrategy = __NAMESPACE__ . '\Strategy\AllowRemoveByValue';
48
49
    /**
50
     * @var string
51
     */
52
    protected $defaultByReferenceStrategy = __NAMESPACE__ . '\Strategy\AllowRemoveByReference';
53
54
55
    /**
56
     * Constructor
57
     *
58
     * @param ObjectManager $objectManager The ObjectManager to use
59
     * @param bool          $byValue       If set to true, hydrator will always use entity's public API
60
     */
61 70
    public function __construct(ObjectManager $objectManager, $byValue = true)
62
    {
63 70
        parent::__construct();
64
65 70
        $this->objectManager = $objectManager;
66 70
        $this->byValue       = (bool) $byValue;
67 70
    }
68
69
    /**
70
     * @return string
71
     */
72 14
    public function getDefaultByValueStrategy()
73
    {
74 14
        return $this->defaultByValueStrategy;
75
    }
76
77
    /**
78
     * @param string $defaultByValueStrategy
79
     * @return DoctrineObject
80
     */
81 1
    public function setDefaultByValueStrategy($defaultByValueStrategy)
82
    {
83 1
        $this->defaultByValueStrategy = $defaultByValueStrategy;
84 1
        return $this;
85
    }
86
87
    /**
88
     * @return string
89
     */
90 9
    public function getDefaultByReferenceStrategy()
91
    {
92 9
        return $this->defaultByReferenceStrategy;
93
    }
94
95
    /**
96
     * @param string $defaultByReferenceStrategy
97
     * @return DoctrineObject
98
     */
99 1
    public function setDefaultByReferenceStrategy($defaultByReferenceStrategy)
100
    {
101 1
        $this->defaultByReferenceStrategy = $defaultByReferenceStrategy;
102 1
        return $this;
103
    }
104
105
    /**
106
     * Extract values from an object
107
     *
108
     * @param  object $object
109
     * @return array
110
     */
111 17
    public function extract($object)
112
    {
113 17
        $this->prepare($object);
114
115 17
        if ($this->byValue) {
116 10
            return $this->extractByValue($object);
117
        }
118
119 7
        return $this->extractByReference($object);
120
    }
121
122
    /**
123
     * Hydrate $object with the provided $data.
124
     *
125
     * @param  array  $data
126
     * @param  object $object
127
     * @return object
128
     */
129 54
    public function hydrate(array $data, $object)
130
    {
131 54
        $this->prepare($object);
132
133 54
        if ($this->byValue) {
134 41
            return $this->hydrateByValue($data, $object);
135
        }
136
137 27
        return $this->hydrateByReference($data, $object);
138
    }
139
140
    /**
141
     * Prepare the hydrator by adding strategies to every collection valued associations
142
     *
143
     * @param  object $object
144
     * @return void
145
     */
146 70
    protected function prepare($object)
147
    {
148 70
        $this->metadata = $this->objectManager->getClassMetadata(get_class($object));
149 70
        $this->prepareStrategies();
150 70
    }
151
152
    /**
153
     * Prepare strategies before the hydrator is used
154
     *
155
     * @throws \InvalidArgumentException
156
     * @return void
157
     */
158 70
    protected function prepareStrategies()
159
    {
160 70
        $associations = $this->metadata->getAssociationNames();
161
162 70
        foreach ($associations as $association) {
163 36
            if ($this->metadata->isCollectionValuedAssociation($association)) {
164
                // Add a strategy if the association has none set by user
165 23
                if (! $this->hasStrategy($association)) {
166 21
                    if ($this->byValue) {
167 14
                        $strategyClassName = $this->getDefaultByValueStrategy();
168
                    } else {
169 9
                        $strategyClassName = $this->getDefaultByReferenceStrategy();
170
                    }
171
172 21
                    $this->addStrategy($association, new $strategyClassName());
173
                }
174
175 23
                $strategy = $this->getStrategy($association);
176
177 23
                if (! $strategy instanceof Strategy\AbstractCollectionStrategy) {
178
                    throw new InvalidArgumentException(
179
                        sprintf(
180
                            'Strategies used for collections valued associations must inherit from '
181
                            . 'Strategy\AbstractCollectionStrategy, %s given',
182
                            get_class($strategy)
183
                        )
184
                    );
185
                }
186
187 23
                $strategy->setCollectionName($association)
188 36
                         ->setClassMetadata($this->metadata);
189
            }
190
        }
191 70
    }
192
193
    /**
194
     * Extract values from an object using a by-value logic (this means that it uses the entity
195
     * API, in this case, getters)
196
     *
197
     * @param  object $object
198
     * @throws RuntimeException
199
     * @return array
200
     */
201 10
    protected function extractByValue($object)
202
    {
203 10
        $fieldNames = array_merge($this->metadata->getFieldNames(), $this->metadata->getAssociationNames());
204 10
        $methods    = get_class_methods($object);
205 10
        $filter     = $object instanceof FilterProviderInterface
206
            ? $object->getFilter()
207 10
            : $this->filterComposite;
208
209 10
        $data = [];
210 10
        foreach ($fieldNames as $fieldName) {
211 10
            if ($filter && ! $filter->filter($fieldName)) {
212 1
                continue;
213
            }
214
215 10
            $getter = 'get' . Inflector::classify($fieldName);
216 10
            $isser  = 'is' . Inflector::classify($fieldName);
217
218 10
            $dataFieldName = $this->computeExtractFieldName($fieldName);
219 10
            if (in_array($getter, $methods)) {
220 10
                $data[$dataFieldName] = $this->extractValue($fieldName, $object->$getter(), $object);
221 1
            } elseif (in_array($isser, $methods)) {
222 1
                $data[$dataFieldName] = $this->extractValue($fieldName, $object->$isser(), $object);
223
            } elseif (substr($fieldName, 0, 2) === 'is'
224
                && ctype_upper(substr($fieldName, 2, 1))
225
                && in_array($fieldName, $methods)) {
226 10
                $data[$dataFieldName] = $this->extractValue($fieldName, $object->$fieldName(), $object);
227
            }
228
229
            // Unknown fields are ignored
230
        }
231
232 10
        return $data;
233
    }
234
235
    /**
236
     * Extract values from an object using a by-reference logic (this means that values are
237
     * directly fetched without using the public API of the entity, in this case, getters)
238
     *
239
     * @param  object $object
240
     * @return array
241
     */
242 7
    protected function extractByReference($object)
243
    {
244 7
        $fieldNames = array_merge($this->metadata->getFieldNames(), $this->metadata->getAssociationNames());
245 7
        $refl       = $this->metadata->getReflectionClass();
246 7
        $filter     = $object instanceof FilterProviderInterface
247
            ? $object->getFilter()
248 7
            : $this->filterComposite;
249
250 7
        $data = [];
251 7
        foreach ($fieldNames as $fieldName) {
252 7
            if ($filter && ! $filter->filter($fieldName)) {
253 1
                continue;
254
            }
255 7
            $reflProperty = $refl->getProperty($fieldName);
256 7
            $reflProperty->setAccessible(true);
257
258 7
            $dataFieldName        = $this->computeExtractFieldName($fieldName);
259 7
            $data[$dataFieldName] = $this->extractValue($fieldName, $reflProperty->getValue($object), $object);
260
        }
261
262 7
        return $data;
263
    }
264
265
    /**
266
     * Hydrate the object using a by-value logic (this means that it uses the entity API, in this
267
     * case, setters)
268
     *
269
     * @param  array  $data
270
     * @param  object $object
271
     * @throws RuntimeException
272
     * @return object
273
     */
274 42
    protected function hydrateByValue(array $data, $object)
275
    {
276 42
        $tryObject = $this->tryConvertArrayToObject($data, $object);
277 42
        $metadata  = $this->metadata;
278
279 42
        if (is_object($tryObject)) {
280 39
            $object = $tryObject;
281
        }
282
283 42
        foreach ($data as $field => $value) {
284 40
            $field  = $this->computeHydrateFieldName($field);
285 40
            $value  = $this->handleTypeConversions($value, $metadata->getTypeOfField($field));
286 40
            $setter = 'set' . Inflector::classify($field);
287
288 40
            if ($metadata->hasAssociation($field)) {
289 18
                $target = $metadata->getAssociationTargetClass($field);
290
291 18
                if ($metadata->isSingleValuedAssociation($field)) {
292 7
                    if (! is_callable([$object, $setter])) {
293
                        continue;
294
                    }
295
296 7
                    $value = $this->toOne($target, $this->hydrateValue($field, $value, $data));
297
298 7
                    if (null === $value
299 7
                        && ! current($metadata->getReflectionClass()->getMethod($setter)->getParameters())->allowsNull()
300
                    ) {
301 1
                        continue;
302
                    }
303
304 6
                    $object->$setter($value);
305 11
                } elseif ($metadata->isCollectionValuedAssociation($field)) {
306 17
                    $this->toMany($object, $field, $target, $value);
307
                }
308
            } else {
309 25
                if (! is_callable([$object, $setter])) {
310 1
                    continue;
311
                }
312
313 38
                $object->$setter($this->hydrateValue($field, $value, $data));
314
            }
315
        }
316
317 42
        return $object;
318
    }
319
320
    /**
321
     * Hydrate the object using a by-reference logic (this means that values are modified directly without
322
     * using the public API, in this case setters, and hence override any logic that could be done in those
323
     * setters)
324
     *
325
     * @param  array  $data
326
     * @param  object $object
327
     * @return object
328
     */
329 28
    protected function hydrateByReference(array $data, $object)
330
    {
331 28
        $tryObject = $this->tryConvertArrayToObject($data, $object);
332 28
        $metadata  = $this->metadata;
333 28
        $refl      = $metadata->getReflectionClass();
334
335 28
        if (is_object($tryObject)) {
336 27
            $object = $tryObject;
337
        }
338
339 28
        foreach ($data as $field => $value) {
340 26
            $field = $this->computeHydrateFieldName($field);
341
342
            // Ignore unknown fields
343 26
            if (! $refl->hasProperty($field)) {
344
                continue;
345
            }
346
347 26
            $value        = $this->handleTypeConversions($value, $metadata->getTypeOfField($field));
348 26
            $reflProperty = $refl->getProperty($field);
349 26
            $reflProperty->setAccessible(true);
350
351 26
            if ($metadata->hasAssociation($field)) {
352 10
                $target = $metadata->getAssociationTargetClass($field);
353
354 10
                if ($metadata->isSingleValuedAssociation($field)) {
355 4
                    $value = $this->toOne($target, $this->hydrateValue($field, $value, $data));
356 4
                    $reflProperty->setValue($object, $value);
357 6
                } elseif ($metadata->isCollectionValuedAssociation($field)) {
358 10
                    $this->toMany($object, $field, $target, $value);
359
                }
360
            } else {
361 26
                $reflProperty->setValue($object, $this->hydrateValue($field, $value, $data));
362
            }
363
        }
364
365 28
        return $object;
366
    }
367
368
    /**
369
     * This function tries, given an array of data, to convert it to an object if the given array contains
370
     * an identifier for the object. This is useful in a context of updating existing entities, without ugly
371
     * tricks like setting manually the existing id directly into the entity
372
     *
373
     * @param  array  $data   The data that may contain identifiers keys
374
     * @param  object $object
375
     * @return object
376
     */
377 54
    protected function tryConvertArrayToObject($data, $object)
378
    {
379 54
        $metadata         = $this->metadata;
380 54
        $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...
381 54
        $identifierValues = [];
382
383 54
        if (empty($identifierNames)) {
384 30
            return $object;
385
        }
386
387 24
        foreach ($identifierNames as $identifierName) {
388 24
            if (! isset($data[$identifierName])) {
389 20
                return $object;
390
            }
391
392 4
            $identifierValues[$identifierName] = $data[$identifierName];
393
        }
394
395 4
        return $this->find($identifierValues, $metadata->getName());
396
    }
397
398
    /**
399
     * Handle ToOne associations
400
     *
401
     * When $value is an array but is not the $target's identifiers, $value is
402
     * most likely an array of fieldset data. The identifiers will be determined
403
     * and a target instance will be initialized and then hydrated. The hydrated
404
     * target will be returned.
405
     *
406
     * @param  string $target
407
     * @param  mixed  $value
408
     * @return object
409
     */
410 11
    protected function toOne($target, $value)
411
    {
412 11
        $metadata = $this->objectManager->getClassMetadata($target);
413
414 11
        if (is_array($value) && array_keys($value) != $metadata->getIdentifier()) {
415
            // $value is most likely an array of fieldset data
416 1
            $identifiers = array_intersect_key(
417 1
                $value,
418 1
                array_flip($metadata->getIdentifier())
419
            );
420 1
            $object      = $this->find($identifiers, $target) ?: new $target;
421 1
            return $this->hydrate($value, $object);
422
        }
423
424 10
        return $this->find($value, $target);
425
    }
426
427
    /**
428
     * Handle ToMany associations. In proper Doctrine design, Collections should not be swapped, so
429
     * collections are always handled by reference. Internally, every collection is handled using specials
430
     * strategies that inherit from AbstractCollectionStrategy class, and that add or remove elements but without
431
     * changing the collection of the object
432
     *
433
     * @param  object $object
434
     * @param  mixed  $collectionName
435
     * @param  string $target
436
     * @param  mixed  $values
437
     *
438
     * @throws \InvalidArgumentException
439
     *
440
     * @return void
441
     */
442 17
    protected function toMany($object, $collectionName, $target, $values)
443
    {
444 17
        $metadata   = $this->objectManager->getClassMetadata(ltrim($target, '\\'));
445 17
        $identifier = $metadata->getIdentifier();
446
447 17
        if (! is_array($values) && ! $values instanceof Traversable) {
448
            $values = (array)$values;
449
        }
450
451 17
        $collection = [];
452
453
        // If the collection contains identifiers, fetch the objects from database
454 17
        foreach ($values as $value) {
455 17
            if ($value instanceof $target) {
456
                // assumes modifications have already taken place in object
457 7
                $collection[] = $value;
458 7
                continue;
459
            } elseif (empty($value)) {
460
                // assumes no id and retrieves new $target
461 1
                $collection[] = $this->find($value, $target);
462 1
                continue;
463
            }
464
465 9
            $find = [];
466 9
            if (is_array($identifier)) {
467 9
                foreach ($identifier as $field) {
468 9
                    switch (gettype($value)) {
469 9
                        case 'object':
470 1
                            $getter = 'get' . Inflector::classify($field);
471
472 1
                            if (is_callable([$value, $getter])) {
473
                                $find[$field] = $value->$getter();
474 1
                            } elseif (property_exists($value, $field)) {
475 1
                                $find[$field] = $value->$field;
476
                            }
477 1
                            break;
478 8
                        case 'array':
479 5
                            if (array_key_exists($field, $value) && $value[$field] != null) {
480 5
                                $find[$field] = $value[$field];
481 5
                                unset($value[$field]); // removed identifier from persistable data
482
                            }
483 5
                            break;
484
                        default:
485 3
                            $find[$field] = $value;
486 9
                            break;
487
                    }
488
                }
489
            }
490
491 9
            if (! empty($find) && $found = $this->find($find, $target)) {
492 9
                $collection[] = (is_array($value)) ? $this->hydrate($value, $found) : $found;
493
            } else {
494 9
                $collection[] = (is_array($value)) ? $this->hydrate($value, new $target) : new $target;
495
            }
496
        }
497
498 17
        $collection = array_filter(
499 17
            $collection,
500 17
            function ($item) {
501 17
                return null !== $item;
502 17
            }
503
        );
504
505
        // Set the object so that the strategy can extract the Collection from it
506
507
        /** @var \DoctrineModule\Stdlib\Hydrator\Strategy\AbstractCollectionStrategy $collectionStrategy */
508 17
        $collectionStrategy = $this->getStrategy($collectionName);
509 17
        $collectionStrategy->setObject($object);
510
511
        // We could directly call hydrate method from the strategy, but if people want to override
512
        // hydrateValue function, they can do it and do their own stuff
513 17
        $this->hydrateValue($collectionName, $collection, $values);
0 ignored issues
show
Bug introduced by
It seems like $values defined by parameter $values on line 442 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...
514 17
    }
515
516
    /**
517
     * Handle various type conversions that should be supported natively by Doctrine (like DateTime)
518
     * See Documentation of Doctrine Mapping Types for defaults
519
     *
520
     * @link http://docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/basic-mapping.html#doctrine-mapping-types
521
     * @param  mixed  $value
522
     * @param  string $typeOfField
523
     * @return DateTime
524
     */
525 52
    protected function handleTypeConversions($value, $typeOfField)
526
    {
527 52
        switch ($typeOfField) {
528 52
            case 'boolean':
529 1
                $value = (bool)$value;
530 1
                break;
531 51
            case 'string':
532 44
            case 'text':
533 43
            case 'bigint':
534 42
            case 'decimal':
535 15
                $value = (string)$value;
536 15
                break;
537 41
            case 'integer':
538 36
            case 'smallint':
539 7
                $value = (int)$value;
540 7
                break;
541 35
            case 'float':
542 1
                $value = (double)$value;
543 1
                break;
544 34
            case 'datetimetz':
545 33
            case 'datetime':
546 30
            case 'time':
547 29
            case 'date':
548 6
                if ($value === '') {
549 1
                    return null;
550
                }
551
552 5
                if ($value instanceof Datetime) {
553 4
                    return $value;
554
                }
555
556 5
                if (is_int($value)) {
557 5
                    $dateTime = new DateTime();
558 5
                    $dateTime->setTimestamp($value);
559 5
                    return $dateTime;
560
                }
561
562 4
                if (is_string($value)) {
563 4
                    return new DateTime($value);
564
                }
565
566
                break;
567
            default:
568 28
                break;
569
        }
570
571 46
        return $value;
572
    }
573
574
    /**
575
     * Find an object by a given target class and identifier
576
     *
577
     * @param  mixed   $identifiers
578
     * @param  string  $targetClass
579
     *
580
     * @return object|null
581
     */
582 25
    protected function find($identifiers, $targetClass)
583
    {
584 25
        if ($identifiers instanceof $targetClass) {
585 2
            return $identifiers;
586
        }
587
588 23
        if ($this->isNullIdentifier($identifiers)) {
589 4
            return null;
590
        }
591
592 19
        return $this->objectManager->find($targetClass, $identifiers);
593
    }
594
595
    /**
596
     * Verifies if a provided identifier is to be considered null
597
     *
598
     * @param  mixed $identifier
599
     *
600
     * @return bool
601
     */
602 23
    private function isNullIdentifier($identifier)
603
    {
604 23
        if (null === $identifier) {
605 4
            return true;
606
        }
607
608 19
        if ($identifier instanceof Traversable || is_array($identifier)) {
609 16
            $nonNullIdentifiers = array_filter(
610 16
                ArrayUtils::iteratorToArray($identifier),
611 16
                function ($value) {
612 16
                    return null !== $value;
613 16
                }
614
            );
615
616 16
            return empty($nonNullIdentifiers);
617
        }
618
619 3
        return false;
620
    }
621
622
    /**
623
     * Applies the naming strategy if there is one set
624
     *
625
     * @param string $field
626
     *
627
     * @return string
628
     */
629 52
    protected function computeHydrateFieldName($field)
630
    {
631 52
        if ($this->hasNamingStrategy()) {
632 2
            $field = $this->getNamingStrategy()->hydrate($field);
633
        }
634 52
        return $field;
635
    }
636
637
    /**
638
     * Applies the naming strategy if there is one set
639
     *
640
     * @param string $field
641
     *
642
     * @return string
643
     */
644 17
    protected function computeExtractFieldName($field)
645
    {
646 17
        if ($this->hasNamingStrategy()) {
647 2
            $field = $this->getNamingStrategy()->extract($field);
648
        }
649 17
        return $field;
650
    }
651
}
652