Passed
Push — master ( 93d3ce...a8bc4c )
by Gerrit
02:43
created

BlackMagicDataLoader   B

Complexity

Total Complexity 47

Size/Duplication

Total Lines 308
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 14

Test Coverage

Coverage 35.9%

Importance

Changes 0
Metric Value
wmc 47
lcom 1
cbo 14
dl 0
loc 308
ccs 42
cts 117
cp 0.359
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 80 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 1
                if (!isset($classMetadata->reflFields[$fieldName])) {
173 1
                    $classMetadata->reflFields[$fieldName] = new BlackMagicColumnReflectionPropertyMock(
174 1
                        $entityManager,
175
                        $classMetadata,
176
                        $column,
177
                        $fieldName,
178
                        $this
179
                    );
180
                }
181
182 1
                if (!isset($classMetadata->fieldMappings[$fieldName])) {
183
                    /** @var array<string, mixed> $mapping */
184 1
                    $mapping = array_merge(
185 1
                        $column->toArray(),
186
                        [
187 1
                            'columnName' => $columnName,
188 1
                            'fieldName' => $fieldName,
189 1
                            'nullable' => !$column->getNotnull(),
190
                        ]
191
                    );
192
193 1
                    if (isset($mapping['type']) && $mapping['type'] instanceof Type) {
194 1
                        $mapping['type'] = $mapping['type']->getName();
195
                    }
196
197
                    #$classMetadata->mapField($mapping);
198 1
                    $classMetadata->fieldMappings[$fieldName] = $mapping;
199
                }
200
201
                /** @psalm-suppress DeprecatedProperty */
202 1
                if (isset ($classMetadata->fieldNames) && !isset($classMetadata->fieldNames[$columnName])) {
203 1
                    $classMetadata->fieldNames[$columnName] = $fieldName;
204
                }
205
206
                /** @psalm-suppress DeprecatedProperty */
207 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...
208 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...
209
                }
210
            }
211
        }
212 1
    }
213
214
    public function onColumnValueSetOnEntity(
215
        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...
216
        ?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...
217
        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...
218
        $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...
219
    ): void {
220
        # Do nothing here, we first let doctrine collect all the data and the use that in "loadDBALDataForEntity" above.
221
    }
222
223
    public function onColumnValueRequestedFromEntity(
224
        EntityManagerInterface $entityManager,
225
        $entity,
226
        string $columnName
227
    ) {
228
        /** @var array<string, mixed> $entityData */
229
        $entityData = array();
230
231
        /** @var array<string, Column> $dbalColumns */
232
        $dbalColumns = array();
233
234
        if (is_object($entity)) {
235
            if ($entity === $this->entityDataCacheSource
236
            && is_array($this->entityDataCached)
237
            && array_key_exists($columnName, $this->entityDataCached)) {
238
                # This caching mechanism stores only the data of the current entity
239
                # and relies on doctrine only reading one entity at a time.
240
241
                $entityData = $this->entityDataCached;
242
                $dbalColumns = $this->dbalColumnsCached;
243
244
                unset($this->entityDataCached[$columnName]);
245
246
            } else {
247
                /** @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...
248
                $className = get_class($entity);
249
250
                if (class_exists(ClassUtils::class)) {
251
                    $className = ClassUtils::getRealClass($className);
252
                    Assert::classExists($className);
253
                }
254
255
                /** @var EntityMappingInterface|null $entityMapping */
256
                $entityMapping = $this->mappingDriver->loadRDMMetadataForClass($className);
257
258
                if ($entityMapping instanceof EntityMappingInterface) {
259
                    $context = new HydrationContext($entity, $entityManager);
260
261
                    $entityData = $entityMapping->revertValue($context, $entity);
262
263
                    /** @var Column $column */
264
                    foreach ($entityMapping->collectDBALColumns() as $column) {
265
                        $dbalColumns[$column->getName()] = $column;
266
                    }
267
                }
268
269
                $this->entityDataCached = $entityData;
270
                $this->entityDataCacheSource = $entity;
271
                $this->dbalColumnsCached = $dbalColumns;
272
            }
273
        }
274
275
        $value = $entityData[$columnName] ?? null;
276
277
        if (!is_null($value) && isset($dbalColumns[$columnName])) {
278
            /** @var AbstractPlatform $platform */
279
            $platform = $entityManager->getConnection()->getDatabasePlatform();
280
281
            /** @var Column $column */
282
            $column = $dbalColumns[$columnName];
283
284
            /** @var Type $type */
285
            $type = $column->getType();
286
287
            $value = $type->convertToPHPValue($value, $platform);
288
289
            if (is_int($value) && $type->getName() === 'string') {
290
                $value = (string)$value;
291
292
            } elseif ($value instanceof DateTime) {
293
                /** @var UnitOfWork $unitOfWork */
294
                $unitOfWork = $entityManager->getUnitOfWork();
295
296
                /** @var mixed $originalValue */
297
                $originalValue = null;
298
299
                if ($this->isDateTimeEqualToValueFromUnitOfWorkButNotSame($value, $unitOfWork, $column, $entity, $originalValue)) {
300
                    # Because doctrine uses '===' to compute changesets when compaing original with actual,
301
                    # we need to keep the identity of DateTime objects if they are actually the "same".
302
                    $value = $originalValue;
303
                }
304
            }
305
        }
306
307
        return $value;
308
    }
309
310 1
    public function columnToFieldName(Column $column): string
311
    {
312
        /** @var string $columnName */
313 1
        $columnName = $column->getName();
314
315
        /** @var string $fieldName */
316 1
        $fieldName = '__COLUMN__' . $columnName;
317
318 1
        return $fieldName;
319
    }
320
321
    private function isDateTimeEqualToValueFromUnitOfWorkButNotSame(
322
        DateTime $value,
323
        UnitOfWork $unitOfWork,
324
        Column $column,
325
        object $entity,
326
        &$originalValue
327
    ) {
328
        /** @var bool $isDateTimeEqualToValueFromUnitOfWorkButNotSame */
329
        $isDateTimeEqualToValueFromUnitOfWorkButNotSame = false;
330
331
        /** @var string $fieldName */
332
        $fieldName = $this->columnToFieldName($column);
333
334
        /** @var array<string, mixed> $originalEntityData */
335
        $originalEntityData = $unitOfWork->getOriginalEntityData($entity);
336
337
        if (isset($originalEntityData[$fieldName])) {
338
            /** @var mixed $originalValue */
339
            $originalValue = $originalEntityData[$fieldName];
340
341
            if (is_object($originalValue) && get_class($originalValue) === get_class($value)) {
342
                /** @var DateTime $originalDateTime */
343
                $originalDateTime = $originalValue;
344
345
                if ($originalDateTime !== $value) {
346
                    /** @var array<string, int|float> $diff */
347
                    $diff = (array)$value->diff($originalDateTime);
348
349
                    if (!empty($diff) && 0 === (int)array_sum($diff) && 0 === (int)max($diff)) {
350
                        $isDateTimeEqualToValueFromUnitOfWorkButNotSame = true;
351
                    }
352
                }
353
            }
354
        }
355
356
        return $isDateTimeEqualToValueFromUnitOfWorkButNotSame;
357
    }
358
359
}
360