Passed
Push — master ( 064bed...d815ea )
by Gerrit
04:18
created

BlackMagicDataLoader   B

Complexity

Total Complexity 49

Size/Duplication

Total Lines 320
Duplicated Lines 0 %

Test Coverage

Coverage 35.7%

Importance

Changes 4
Bugs 2 Features 1
Metric Value
eloc 120
c 4
b 2
f 1
dl 0
loc 320
ccs 40
cts 112
cp 0.357
rs 8.48
wmc 49

10 Methods

Rating   Name   Duplication   Size   Complexity  
A boot() 0 21 4
B isDateTimeEqualToValueFromUnitOfWorkButNotSame() 0 36 8
A storeDBALDataForEntity() 0 2 1
A onColumnValueSetOnEntity() 0 6 1
B loadDBALDataForEntity() 0 49 7
A __construct() 0 3 1
A removeDBALDataForEntity() 0 2 1
B prepareOnMetadataLoad() 0 63 11
A columnToFieldName() 0 9 1
C onColumnValueRequestedFromEntity() 0 85 14

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.

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\Mapping\ClassMetadataInfo;
19
use Doctrine\ORM\UnitOfWork;
20
use Doctrine\DBAL\Schema\Column;
21
use Addiks\RDMBundle\Mapping\Drivers\MappingDriverInterface;
22
use ReflectionObject;
23
use Addiks\RDMBundle\Mapping\EntityMappingInterface;
24
use ReflectionProperty;
25
use Webmozart\Assert\Assert;
26
use ReflectionClass;
27
use Addiks\RDMBundle\DataLoader\BlackMagic\BlackMagicColumnReflectionPropertyMock;
28
use Addiks\RDMBundle\Hydration\HydrationContext;
29
use Doctrine\DBAL\Types\Type;
30
use Doctrine\Persistence\Mapping\ClassMetadataFactory;
31
use Doctrine\Persistence\Mapping\AbstractClassMetadataFactory;
32
use Addiks\RDMBundle\DataLoader\BlackMagic\BlackMagicReflectionServiceDecorator;
33
use Doctrine\Common\Util\ClassUtils;
34
use Doctrine\DBAL\Platforms\AbstractPlatform;
35
use Doctrine\Persistence\Mapping\RuntimeReflectionService;
36
use Doctrine\Persistence\Mapping\ReflectionService;
37
use DateTime;
38
39
/**
40
 * This data-loader works by injecting fake doctrine columns into the doctrine class-metadata instance(s), where the injected
41
 * Reflection* objects are replaced by custom mock objects that give the raw DB data from doctrine to this data-loader.
42
 * From doctrine's point of view, every database-column looks like an actual property on the entity, even if that property does
43
 * not actually exist.
44
 *
45
 * ... In other words: BLACK MAGIC!!! *woooo*
46
 *
47
 *  #####################################################################################
48
 *  ### WARNING: Be aware that this data-loader is considered EXPERIMENTAL!           ###
49
 *  ###          If you use this data-loader and bad things happen, it is YOUR FAULT! ###
50
 *  #####################################################################################
51
 *
52
 * @psalm-import-type FieldMapping from ClassMetadataInfo
53
 */
54
class BlackMagicDataLoader implements DataLoaderInterface
55
{
56
57
    /** @var MappingDriverInterface */
58
    private $mappingDriver;
59
60
    /** @var array<string, mixed>|null */
61
    private $entityDataCached;
62
63
    /** @var array<string, Column>|null */
64
    private $dbalColumnsCached;
65
66
    /** @var object|null */
67
    private $entityDataCacheSource;
68
69
    private bool $hasBooted = false;
70
71 1
    public function __construct(MappingDriverInterface $mappingDriver)
72
    {
73 1
        $this->mappingDriver = $mappingDriver;
74
    }
75
76 1
    public function boot(EntityManagerInterface $entityManager): void
77
    {
78 1
        if (!$this->hasBooted) {
79 1
            $this->hasBooted = true;
80
81
            /** @var ClassMetadataFactory $metadataFactory */
82 1
            $metadataFactory = $entityManager->getMetadataFactory();
83
84 1
            if ($metadataFactory instanceof AbstractClassMetadataFactory) {
85
                /** @var ReflectionService|null $reflectionService */
86 1
                $reflectionService = $metadataFactory->getReflectionService();
87
88 1
                if (!$reflectionService instanceof BlackMagicReflectionServiceDecorator) {
89 1
                    $reflectionService = new BlackMagicReflectionServiceDecorator(
90 1
                        $reflectionService ?? new RuntimeReflectionService(),
91 1
                        $this->mappingDriver,
92
                        $entityManager,
93
                        $this
94
                    );
95
96 1
                    $metadataFactory->setReflectionService($reflectionService);
97
                }
98
            }
99
        }
100
    }
101
102
    public function loadDBALDataForEntity($entity, EntityManagerInterface $entityManager): array
103
    {
104
        /** @var array<string, string> $dbalData */
105
        $dbalData = array();
106
107
        /** @var class-string $className */
108
        $className = get_class($entity);
109
110
        if (class_exists(ClassUtils::class)) {
111
            $className = ClassUtils::getRealClass($className);
112
            Assert::classExists($className);
113
        }
114
115
        /** @var ClassMetadata $classMetaData */
116
        $classMetaData = $entityManager->getClassMetadata($className);
0 ignored issues
show
Unused Code introduced by
The assignment to $classMetaData is dead and can be removed.
Loading history...
117
118
        /** @var EntityMappingInterface|null $entityMapping */
119
        $entityMapping = $this->mappingDriver->loadRDMMetadataForClass($className);
120
121
        if ($entityMapping instanceof EntityMappingInterface) {
122
            /** @var array<Column> $columns */
123
            $columns = $entityMapping->collectDBALColumns();
124
125
            if (!empty($columns)) {
126
                /** @var UnitOfWork $unitOfWork */
127
                $unitOfWork = $entityManager->getUnitOfWork();
128
129
                /** @var array<string, mixed> $originalEntityData */
130
                $originalEntityData = $unitOfWork->getOriginalEntityData($entity);
131
132
                /** @var Column $column */
133
                foreach ($columns as $column) {
134
                    /** @var string $columnName */
135
                    $columnName = $column->getName();
136
137
                    /** @var string $fieldName */
138
                    $fieldName = $this->columnToFieldName($column);
139
140
                    if (array_key_exists($fieldName, $originalEntityData)) {
141
                        $dbalData[$columnName] = $originalEntityData[$fieldName];
142
143
                    } elseif (array_key_exists($columnName, $originalEntityData)) {
144
                        $dbalData[$columnName] = $originalEntityData[$columnName];
145
                    }
146
                }
147
            }
148
        }
149
150
        return $dbalData;
151
    }
152
153
    public function storeDBALDataForEntity($entity, EntityManagerInterface $entityManager)
154
    {
155
        # This happens after doctrine has already UPDATE'd the row itself, do nothing here.
156
    }
157
158
    public function removeDBALDataForEntity($entity, EntityManagerInterface $entityManager)
159
    {
160
        # Doctrine DELETE's the row for us, we dont need to do anything here.
161
    }
162
163 1
    public function prepareOnMetadataLoad(EntityManagerInterface $entityManager, ClassMetadata $classMetadata)
164
    {
165 1
        $this->boot($entityManager);
166
167
        /** @var class-string $className */
168 1
        $className = $classMetadata->getName();
169
170
        /** @var EntityMappingInterface|null $entityMapping */
171 1
        $entityMapping = $this->mappingDriver->loadRDMMetadataForClass($className);
172
173 1
        if ($entityMapping instanceof EntityMappingInterface) {
174
            /** @var array<Column> $dbalColumns */
175 1
            $dbalColumns = $entityMapping->collectDBALColumns();
176
177
            /** @var Column $column */
178 1
            foreach ($dbalColumns as $column) {
179
                /** @var string $columnName */
180 1
                $columnName = $column->getName();
181
182
                /** @var string $fieldName */
183 1
                $fieldName = $this->columnToFieldName($column);
184
185
                /** @psalm-suppress DeprecatedProperty */
186 1
                if (isset ($classMetadata->fieldNames) && isset($classMetadata->fieldNames[$columnName])) {
187
                    # This is a native doctrine column, do not touch! Otherwise the column might get unwanted UPDATE's.
188
                    continue;
189
                }
190
191
                /** @psalm-suppress DeprecatedProperty */
192 1
                $classMetadata->fieldNames[$columnName] = $fieldName;
193
194
                /** @psalm-suppress DeprecatedProperty */
195 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: 3.0 Remove this. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

195
                if (isset (/** @scrutinizer ignore-deprecated */ $classMetadata->columnNames) && !isset($classMetadata->columnNames[$fieldName])) {

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...
196 1
                    $classMetadata->columnNames[$fieldName] = $columnName;
197
                }
198
199 1
                if (!isset($classMetadata->reflFields[$fieldName])) {
200 1
                    $classMetadata->reflFields[$fieldName] = new BlackMagicColumnReflectionPropertyMock(
201
                        $entityManager,
202
                        $classMetadata,
203
                        $column,
204
                        $fieldName,
205
                        $this
206
                    );
207
                }
208
209 1
                if (!isset($classMetadata->fieldMappings[$fieldName])) {
210
                    /** @var FieldMapping $mapping */
211 1
                    $mapping = array_merge(
212 1
                        $column->toArray(),
213
                        [
214 1
                            'columnName' => $columnName,
215
                            'fieldName' => $fieldName,
216 1
                            'nullable' => !$column->getNotnull(),
217
                        ]
218
                    );
219
220 1
                    if (isset($mapping['type']) && $mapping['type'] instanceof Type) {
221 1
                        $mapping['type'] = $mapping['type']->getName();
222
                    }
223
224
                    #$classMetadata->mapField($mapping);
225 1
                    $classMetadata->fieldMappings[$fieldName] = $mapping;
226
                }
227
            }
228
        }
229
    }
230
231
    public function onColumnValueSetOnEntity(
232
        EntityManagerInterface $entityManager,
0 ignored issues
show
Unused Code introduced by
The parameter $entityManager is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

232
        /** @scrutinizer ignore-unused */ EntityManagerInterface $entityManager,

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

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

233
        /** @scrutinizer ignore-unused */ ?object $entity,

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

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

234
        /** @scrutinizer ignore-unused */ string $columnName,

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

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

235
        /** @scrutinizer ignore-unused */ $value = null

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

Loading history...
236
    ): void {
237
        # Do nothing here, we first let doctrine collect all the data and the use that in "loadDBALDataForEntity" above.
238
    }
239
240
    public function onColumnValueRequestedFromEntity(
241
        EntityManagerInterface $entityManager,
242
        $entity,
243
        string $columnName
244
    ) {
245
        /** @var array<string, mixed> $entityData */
246
        $entityData = array();
247
248
        /** @var array<string, Column> $dbalColumns */
249
        $dbalColumns = array();
250
251
        if (is_object($entity)) {
252
            if ($entity === $this->entityDataCacheSource
253
            && is_array($this->entityDataCached)
254
            && array_key_exists($columnName, $this->entityDataCached)) {
255
                # This caching mechanism stores only the data of the current entity
256
                # and relies on doctrine only reading one entity at a time.
257
258
                $entityData = $this->entityDataCached;
259
                $dbalColumns = $this->dbalColumnsCached;
260
261
                unset($this->entityDataCached[$columnName]);
262
263
            } else {
264
                /** @var class-string $className */
265
                $className = get_class($entity);
266
267
                if (class_exists(ClassUtils::class)) {
268
                    $className = ClassUtils::getRealClass($className);
269
                    Assert::classExists($className);
270
                }
271
272
                /** @var EntityMappingInterface|null $entityMapping */
273
                $entityMapping = $this->mappingDriver->loadRDMMetadataForClass($className);
274
275
                if ($entityMapping instanceof EntityMappingInterface) {
276
                    $context = new HydrationContext($entity, $entityManager);
277
278
                    $entityData = $entityMapping->revertValue($context, $entity);
279
280
                    /** @var Column $column */
281
                    foreach ($entityMapping->collectDBALColumns() as $column) {
282
                        $dbalColumns[$column->getName()] = $column;
283
                    }
284
                }
285
286
                $this->entityDataCached = $entityData;
287
                $this->entityDataCacheSource = $entity;
288
                $this->dbalColumnsCached = $dbalColumns;
289
            }
290
        }
291
292
        $value = $entityData[$columnName] ?? null;
293
294
        if (!is_null($value) && isset($dbalColumns[$columnName])) {
295
            /** @var AbstractPlatform $platform */
296
            $platform = $entityManager->getConnection()->getDatabasePlatform();
297
298
            /** @var Column $column */
299
            $column = $dbalColumns[$columnName];
300
301
            /** @var Type $type */
302
            $type = $column->getType();
303
304
            $value = $type->convertToPHPValue($value, $platform);
305
306
            if (is_int($value) && $type->getName() === 'string') {
307
                $value = (string)$value;
308
309
            } elseif ($value instanceof DateTime) {
310
                /** @var UnitOfWork $unitOfWork */
311
                $unitOfWork = $entityManager->getUnitOfWork();
312
313
                /** @var mixed $originalValue */
314
                $originalValue = null;
315
316
                if ($this->isDateTimeEqualToValueFromUnitOfWorkButNotSame($value, $unitOfWork, $column, $entity, $originalValue)) {
317
                    # Because doctrine uses '===' to compute changesets when compaing original with actual,
318
                    # we need to keep the identity of DateTime objects if they are actually the "same".
319
                    $value = $originalValue;
320
                }
321
            }
322
        }
323
324
        return $value;
325
    }
326
327 1
    public function columnToFieldName(Column $column): string
328
    {
329
        /** @var string $columnName */
330 1
        $columnName = $column->getName();
331
332
        /** @var string $fieldName */
333 1
        $fieldName = '__COLUMN__' . $columnName;
334
335 1
        return $fieldName;
336
    }
337
338
    private function isDateTimeEqualToValueFromUnitOfWorkButNotSame(
339
        DateTime $value,
340
        UnitOfWork $unitOfWork,
341
        Column $column,
342
        object $entity,
343
        &$originalValue
344
    ) {
345
        /** @var bool $isDateTimeEqualToValueFromUnitOfWorkButNotSame */
346
        $isDateTimeEqualToValueFromUnitOfWorkButNotSame = false;
347
348
        /** @var string $fieldName */
349
        $fieldName = $this->columnToFieldName($column);
350
351
        /** @var array<string, mixed> $originalEntityData */
352
        $originalEntityData = $unitOfWork->getOriginalEntityData($entity);
353
354
        if (isset($originalEntityData[$fieldName])) {
355
            /** @var mixed $originalValue */
356
            $originalValue = $originalEntityData[$fieldName];
357
358
            if (is_object($originalValue) && get_class($originalValue) === get_class($value)) {
359
                /** @var DateTime $originalDateTime */
360
                $originalDateTime = $originalValue;
361
362
                if ($originalDateTime !== $value) {
363
                    /** @var array<string, int|float> $diff */
364
                    $diff = (array)$value->diff($originalDateTime);
365
366
                    if (!empty($diff) && 0 === (int)array_sum($diff) && 0 === (int)max($diff)) {
367
                        $isDateTimeEqualToValueFromUnitOfWorkButNotSame = true;
368
                    }
369
                }
370
            }
371
        }
372
373
        return $isDateTimeEqualToValueFromUnitOfWorkButNotSame;
374
    }
375
376
}
377