BlackMagicDataLoader::prepareOnMetadataLoad()   B
last analyzed

Complexity

Conditions 11
Paths 15

Size

Total Lines 63
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 11.0113

Importance

Changes 2
Bugs 0 Features 1
Metric Value
cc 11
eloc 29
c 2
b 0
f 1
nc 15
nop 2
dl 0
loc 63
ccs 21
cts 22
cp 0.9545
crap 11.0113
rs 7.3166

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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

199
                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...
200 1
                    $classMetadata->columnNames[$fieldName] = $columnName;
201
                }
202
203
                if (!isset($classMetadata->reflFields[$fieldName])) {
204
                    $classMetadata->reflFields[$fieldName] = new BlackMagicColumnReflectionPropertyMock(
205
                        $entityManager,
206
                        $classMetadata,
207
                        $column,
208
                        $fieldName,
209 1
                        $this
210
                    );
211 1
                }
212 1
213
                if (!isset($classMetadata->fieldMappings[$fieldName])) {
214 1
                    /** @var FieldMapping $mapping */
215
                    $mapping = array_merge(
216 1
                        $column->toArray(),
217
                        [
218
                            'columnName' => $columnName,
219
                            'fieldName' => $fieldName,
220 1
                            'nullable' => !$column->getNotnull(),
221 1
                        ]
222
                    );
223
224
                    if (isset($mapping['type']) && $mapping['type'] instanceof Type) {
225 1
                        $mapping['type'] = $mapping['type']->getName();
226
                    }
227
228
                    #$classMetadata->mapField($mapping);
229
                    $classMetadata->fieldMappings[$fieldName] = $mapping;
230
                }
231
            }
232
        }
233
    }
234
235
    public function onColumnValueSetOnEntity(
236
        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

236
        /** @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...
237
        ?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

237
        /** @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...
238
        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

238
        /** @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...
239
        $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

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