Passed
Push — master ( c42630...1bfa7d )
by Gerrit
08:20 queued 11s
created

BlackMagicDataLoader   B

Complexity

Total Complexity 47

Size/Duplication

Total Lines 312
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 14

Test Coverage

Coverage 35.59%

Importance

Changes 0
Metric Value
wmc 47
lcom 1
cbo 14
dl 0
loc 312
ccs 42
cts 118
cp 0.3559
rs 8.64
c 0
b 0
f 0

9 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 4 1
B loadDBALDataForEntity() 0 50 7
A storeDBALDataForEntity() 0 4 1
A removeDBALDataForEntity() 0 4 1
C prepareOnMetadataLoad() 0 84 13
A onColumnValueSetOnEntity() 0 8 1
C onColumnValueRequestedFromEntity() 0 86 14
A columnToFieldName() 0 10 1
B isDateTimeEqualToValueFromUnitOfWorkButNotSame() 0 37 8

How to fix   Complexity   

Complex Class

Complex classes like BlackMagicDataLoader 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 BlackMagicDataLoader, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * Copyright (C) 2019 Gerrit Addiks.
4
 * This package (including this file) was released under the terms of the GPL-3.0.
5
 * You should have received a copy of the GNU General Public License along with this program.
6
 * If not, see <http://www.gnu.org/licenses/> or send me a mail so i can send you a copy.
7
 *
8
 * @license GPL-3.0
9
 *
10
 * @author Gerrit Addiks <[email protected]>
11
 */
12
13
namespace Addiks\RDMBundle\DataLoader\BlackMagic;
14
15
use Addiks\RDMBundle\DataLoader\DataLoaderInterface;
16
use Doctrine\ORM\EntityManagerInterface;
17
use Doctrine\ORM\Mapping\ClassMetadata;
18
use Doctrine\ORM\UnitOfWork;
19
use Doctrine\DBAL\Schema\Column;
20
use Addiks\RDMBundle\Mapping\Drivers\MappingDriverInterface;
21
use ReflectionObject;
22
use Addiks\RDMBundle\Mapping\EntityMappingInterface;
23
use ReflectionProperty;
24
use Webmozart\Assert\Assert;
25
use ReflectionClass;
26
use Addiks\RDMBundle\DataLoader\BlackMagic\BlackMagicColumnReflectionPropertyMock;
27
use Addiks\RDMBundle\Hydration\HydrationContext;
28
use Doctrine\DBAL\Types\Type;
29
use Doctrine\Common\Persistence\Mapping\ClassMetadataFactory;
30
use Doctrine\Common\Persistence\Mapping\AbstractClassMetadataFactory;
31
use Addiks\RDMBundle\DataLoader\BlackMagic\BlackMagicReflectionServiceDecorator;
32
use Doctrine\Common\Util\ClassUtils;
33
use Doctrine\DBAL\Platforms\AbstractPlatform;
34
use Doctrine\Persistence\Mapping\RuntimeReflectionService;
35
use Doctrine\Persistence\Mapping\ReflectionService;
36
use DateTime;
37
38
/**
39
 * This data-loader works by injecting fake doctrine columns into the doctrine class-metadata instance(s), where the injected
40
 * Reflection* objects are replaced by custom mock objects that give the raw DB data from doctrine to this data-loader.
41
 * From doctrine's point of view, every database-column looks like an actual property on the entity, even if that property does
42
 * not actually exist.
43
 *
44
 * ... In other words: BLACK MAGIC!!! *woooo*
45
 *
46
 *  #####################################################################################
47
 *  ### WARNING: Be aware that this data-loader is considered EXPERIMENTAL!           ###
48
 *  ###          If you use this data-loader and bad things happen, it is YOUR FAULT! ###
49
 *  #####################################################################################
50
 *
51
 */
52
class BlackMagicDataLoader implements DataLoaderInterface
53
{
54
55
    /** @var MappingDriverInterface */
56
    private $mappingDriver;
57
58
    /** @var array<string, mixed>|null */
59
    private $entityDataCached;
60
61
    /** @var array<string, Column>|null */
62
    private $dbalColumnsCached;
63
64
    /** @var object|null */
65
    private $entityDataCacheSource;
66
67 1
    public function __construct(MappingDriverInterface $mappingDriver)
68
    {
69 1
        $this->mappingDriver = $mappingDriver;
70 1
    }
71
72
    public function loadDBALDataForEntity($entity, EntityManagerInterface $entityManager): array
73
    {
74
        /** @var array<string, string> $dbalData */
75
        $dbalData = array();
76
77
        /** @var class-string $className */
0 ignored issues
show
Documentation introduced by
The doc-type class-string could not be parsed: Unknown type name "class-string" at position 0. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
78
        $className = get_class($entity);
79
80
        if (class_exists(ClassUtils::class)) {
81
            $className = ClassUtils::getRealClass($className);
82
            Assert::classExists($className);
83
        }
84
85
        /** @var ClassMetadata $classMetaData */
86
        $classMetaData = $entityManager->getClassMetadata($className);
0 ignored issues
show
Unused Code introduced by
$classMetaData is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
87
88
        /** @var EntityMappingInterface|null $entityMapping */
89
        $entityMapping = $this->mappingDriver->loadRDMMetadataForClass($className);
90
91
        if ($entityMapping instanceof EntityMappingInterface) {
92
            /** @var array<Column> $columns */
93
            $columns = $entityMapping->collectDBALColumns();
94
95
            if (!empty($columns)) {
96
                /** @var UnitOfWork $unitOfWork */
97
                $unitOfWork = $entityManager->getUnitOfWork();
98
99
                /** @var array<string, mixed> $originalEntityData */
100
                $originalEntityData = $unitOfWork->getOriginalEntityData($entity);
101
102
                /** @var Column $column */
103
                foreach ($columns as $column) {
104
                    /** @var string $columnName */
105
                    $columnName = $column->getName();
106
107
                    /** @var string $fieldName */
108
                    $fieldName = $this->columnToFieldName($column);
109
110
                    if (array_key_exists($fieldName, $originalEntityData)) {
111
                        $dbalData[$columnName] = $originalEntityData[$fieldName];
112
113
                    } elseif (array_key_exists($columnName, $originalEntityData)) {
114
                        $dbalData[$columnName] = $originalEntityData[$columnName];
115
                    }
116
                }
117
            }
118
        }
119
120
        return $dbalData;
121
    }
122
123 1
    public function storeDBALDataForEntity($entity, EntityManagerInterface $entityManager)
124
    {
125
        # This happens after doctrine has already UPDATE'd the row itself, do nothing here.
126 1
    }
127
128
    public function removeDBALDataForEntity($entity, EntityManagerInterface $entityManager)
129
    {
130
        # Doctrine DELETE's the row for us, we dont need to do anything here.
131
    }
132
133 1
    public function prepareOnMetadataLoad(EntityManagerInterface $entityManager, ClassMetadata $classMetadata)
134
    {
135
        /** @var ClassMetadataFactory $metadataFactory */
136 1
        $metadataFactory = $entityManager->getMetadataFactory();
137
138 1
        if ($metadataFactory instanceof AbstractClassMetadataFactory) {
139
            /** @var ReflectionService|null $reflectionService */
140 1
            $reflectionService = $metadataFactory->getReflectionService();
141
142 1
            if (!$reflectionService instanceof BlackMagicReflectionServiceDecorator) {
143 1
                $reflectionService = new BlackMagicReflectionServiceDecorator(
144 1
                    $reflectionService ?? new RuntimeReflectionService(),
145 1
                    $this->mappingDriver,
146
                    $entityManager,
147
                    $this
148
                );
149
150 1
                $metadataFactory->setReflectionService($reflectionService);
151
            }
152
        }
153
154
        /** @var class-string $className */
0 ignored issues
show
Documentation introduced by
The doc-type class-string could not be parsed: Unknown type name "class-string" at position 0. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
155 1
        $className = $classMetadata->getName();
156
157
        /** @var EntityMappingInterface|null $entityMapping */
158 1
        $entityMapping = $this->mappingDriver->loadRDMMetadataForClass($className);
159
160 1
        if ($entityMapping instanceof EntityMappingInterface) {
161
            /** @var array<Column> $dbalColumns */
162 1
            $dbalColumns = $entityMapping->collectDBALColumns();
163
164
            /** @var Column $column */
165 1
            foreach ($dbalColumns as $column) {
166
                /** @var string $columnName */
167 1
                $columnName = $column->getName();
168
169
                /** @var string $fieldName */
170 1
                $fieldName = $this->columnToFieldName($column);
171
172
                /** @psalm-suppress DeprecatedProperty */
173 1
                if (isset ($classMetadata->fieldNames) && isset($classMetadata->fieldNames[$columnName])) {
174
                    # This is a native doctrine column, do not touch! Otherwise the column might get unwanted UPDATE's.
175
                    continue;
176
                }
177
178
                /** @psalm-suppress DeprecatedProperty */
179 1
                $classMetadata->fieldNames[$columnName] = $fieldName;
180
181
                /** @psalm-suppress DeprecatedProperty */
182 1
                if (isset ($classMetadata->columnNames) && !isset($classMetadata->columnNames[$fieldName])) {
0 ignored issues
show
Deprecated Code introduced by
The property Doctrine\ORM\Mapping\Cla...adataInfo::$columnNames has been deprecated with message: 3.0 Remove this.

This property has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the property will be removed from the class and what other property to use instead.

Loading history...
183 1
                    $classMetadata->columnNames[$fieldName] = $columnName;
0 ignored issues
show
Deprecated Code introduced by
The property Doctrine\ORM\Mapping\Cla...adataInfo::$columnNames has been deprecated with message: 3.0 Remove this.

This property has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the property will be removed from the class and what other property to use instead.

Loading history...
184
                }
185
186 1
                if (!isset($classMetadata->reflFields[$fieldName])) {
187 1
                    $classMetadata->reflFields[$fieldName] = new BlackMagicColumnReflectionPropertyMock(
188 1
                        $entityManager,
189
                        $classMetadata,
190
                        $column,
191
                        $fieldName,
192
                        $this
193
                    );
194
                }
195
196 1
                if (!isset($classMetadata->fieldMappings[$fieldName])) {
197
                    /** @var array<string, mixed> $mapping */
198 1
                    $mapping = array_merge(
199 1
                        $column->toArray(),
200
                        [
201 1
                            'columnName' => $columnName,
202 1
                            'fieldName' => $fieldName,
203 1
                            'nullable' => !$column->getNotnull(),
204
                        ]
205
                    );
206
207 1
                    if (isset($mapping['type']) && $mapping['type'] instanceof Type) {
208 1
                        $mapping['type'] = $mapping['type']->getName();
209
                    }
210
211
                    #$classMetadata->mapField($mapping);
212 1
                    $classMetadata->fieldMappings[$fieldName] = $mapping;
213
                }
214
            }
215
        }
216 1
    }
217
218
    public function onColumnValueSetOnEntity(
219
        EntityManagerInterface $entityManager,
0 ignored issues
show
Unused Code introduced by
The parameter $entityManager is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
220
        ?object $entity,
0 ignored issues
show
Unused Code introduced by
The parameter $entity is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
221
        string $columnName,
0 ignored issues
show
Unused Code introduced by
The parameter $columnName is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
222
        $value = null
0 ignored issues
show
Unused Code introduced by
The parameter $value is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
223
    ): void {
224
        # Do nothing here, we first let doctrine collect all the data and the use that in "loadDBALDataForEntity" above.
225
    }
226
227
    public function onColumnValueRequestedFromEntity(
228
        EntityManagerInterface $entityManager,
229
        $entity,
230
        string $columnName
231
    ) {
232
        /** @var array<string, mixed> $entityData */
233
        $entityData = array();
234
235
        /** @var array<string, Column> $dbalColumns */
236
        $dbalColumns = array();
237
238
        if (is_object($entity)) {
239
            if ($entity === $this->entityDataCacheSource
240
            && is_array($this->entityDataCached)
241
            && array_key_exists($columnName, $this->entityDataCached)) {
242
                # This caching mechanism stores only the data of the current entity
243
                # and relies on doctrine only reading one entity at a time.
244
245
                $entityData = $this->entityDataCached;
246
                $dbalColumns = $this->dbalColumnsCached;
247
248
                unset($this->entityDataCached[$columnName]);
249
250
            } else {
251
                /** @var class-string $className */
0 ignored issues
show
Documentation introduced by
The doc-type class-string could not be parsed: Unknown type name "class-string" at position 0. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
252
                $className = get_class($entity);
253
254
                if (class_exists(ClassUtils::class)) {
255
                    $className = ClassUtils::getRealClass($className);
256
                    Assert::classExists($className);
257
                }
258
259
                /** @var EntityMappingInterface|null $entityMapping */
260
                $entityMapping = $this->mappingDriver->loadRDMMetadataForClass($className);
261
262
                if ($entityMapping instanceof EntityMappingInterface) {
263
                    $context = new HydrationContext($entity, $entityManager);
264
265
                    $entityData = $entityMapping->revertValue($context, $entity);
266
267
                    /** @var Column $column */
268
                    foreach ($entityMapping->collectDBALColumns() as $column) {
269
                        $dbalColumns[$column->getName()] = $column;
270
                    }
271
                }
272
273
                $this->entityDataCached = $entityData;
274
                $this->entityDataCacheSource = $entity;
275
                $this->dbalColumnsCached = $dbalColumns;
276
            }
277
        }
278
279
        $value = $entityData[$columnName] ?? null;
280
281
        if (!is_null($value) && isset($dbalColumns[$columnName])) {
282
            /** @var AbstractPlatform $platform */
283
            $platform = $entityManager->getConnection()->getDatabasePlatform();
284
285
            /** @var Column $column */
286
            $column = $dbalColumns[$columnName];
287
288
            /** @var Type $type */
289
            $type = $column->getType();
290
291
            $value = $type->convertToPHPValue($value, $platform);
292
293
            if (is_int($value) && $type->getName() === 'string') {
294
                $value = (string)$value;
295
296
            } elseif ($value instanceof DateTime) {
297
                /** @var UnitOfWork $unitOfWork */
298
                $unitOfWork = $entityManager->getUnitOfWork();
299
300
                /** @var mixed $originalValue */
301
                $originalValue = null;
302
303
                if ($this->isDateTimeEqualToValueFromUnitOfWorkButNotSame($value, $unitOfWork, $column, $entity, $originalValue)) {
304
                    # Because doctrine uses '===' to compute changesets when compaing original with actual,
305
                    # we need to keep the identity of DateTime objects if they are actually the "same".
306
                    $value = $originalValue;
307
                }
308
            }
309
        }
310
311
        return $value;
312
    }
313
314 1
    public function columnToFieldName(Column $column): string
315
    {
316
        /** @var string $columnName */
317 1
        $columnName = $column->getName();
318
319
        /** @var string $fieldName */
320 1
        $fieldName = '__COLUMN__' . $columnName;
321
322 1
        return $fieldName;
323
    }
324
325
    private function isDateTimeEqualToValueFromUnitOfWorkButNotSame(
326
        DateTime $value,
327
        UnitOfWork $unitOfWork,
328
        Column $column,
329
        object $entity,
330
        &$originalValue
331
    ) {
332
        /** @var bool $isDateTimeEqualToValueFromUnitOfWorkButNotSame */
333
        $isDateTimeEqualToValueFromUnitOfWorkButNotSame = false;
334
335
        /** @var string $fieldName */
336
        $fieldName = $this->columnToFieldName($column);
337
338
        /** @var array<string, mixed> $originalEntityData */
339
        $originalEntityData = $unitOfWork->getOriginalEntityData($entity);
340
341
        if (isset($originalEntityData[$fieldName])) {
342
            /** @var mixed $originalValue */
343
            $originalValue = $originalEntityData[$fieldName];
344
345
            if (is_object($originalValue) && get_class($originalValue) === get_class($value)) {
346
                /** @var DateTime $originalDateTime */
347
                $originalDateTime = $originalValue;
348
349
                if ($originalDateTime !== $value) {
350
                    /** @var array<string, int|float> $diff */
351
                    $diff = (array)$value->diff($originalDateTime);
352
353
                    if (!empty($diff) && 0 === (int)array_sum($diff) && 0 === (int)max($diff)) {
354
                        $isDateTimeEqualToValueFromUnitOfWorkButNotSame = true;
355
                    }
356
                }
357
            }
358
        }
359
360
        return $isDateTimeEqualToValueFromUnitOfWorkButNotSame;
361
    }
362
363
}
364