Passed
Push — master ( be9aba...cedee4 )
by Gerrit
02:49
created

BlackMagicDataLoader::isFakedFieldName()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 3
ccs 1
cts 1
cp 1
crap 1
rs 10
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
    public function __construct(MappingDriverInterface $mappingDriver)
70
    {
71 1
        $this->mappingDriver = $mappingDriver;
72
    }
73 1
74
    public function boot(EntityManagerInterface $entityManager): void
75
    {
76 1
        /** @var ClassMetadataFactory $metadataFactory */
77
        $metadataFactory = $entityManager->getMetadataFactory();
78 1
79 1
        if ($metadataFactory instanceof AbstractClassMetadataFactory) {
80
            /** @var ReflectionService|null $reflectionService */
81
            $reflectionService = $metadataFactory->getReflectionService();
82 1
83
            if (!$reflectionService instanceof BlackMagicReflectionServiceDecorator) {
84 1
                $reflectionService = new BlackMagicReflectionServiceDecorator(
85
                    $reflectionService ?? new RuntimeReflectionService(),
86 1
                    $this->mappingDriver,
87
                    $entityManager,
88 1
                    $this
89 1
                );
90 1
91 1
                $metadataFactory->setReflectionService($reflectionService);
92
            }
93
        }
94
    }
95
96 1
    public function loadDBALDataForEntity($entity, EntityManagerInterface $entityManager): array
97
    {
98
        /** @var array<string, string> $dbalData */
99
        $dbalData = array();
100
101
        /** @var class-string $className */
102
        $className = get_class($entity);
103
104
        if (class_exists(ClassUtils::class)) {
105
            $className = ClassUtils::getRealClass($className);
106
            Assert::classExists($className);
107
        }
108
109
        /** @var ClassMetadata $classMetaData */
110
        $classMetaData = $entityManager->getClassMetadata($className);
0 ignored issues
show
Unused Code introduced by
The assignment to $classMetaData is dead and can be removed.
Loading history...
111
112
        /** @var EntityMappingInterface|null $entityMapping */
113
        $entityMapping = $this->mappingDriver->loadRDMMetadataForClass($className);
114
115
        if ($entityMapping instanceof EntityMappingInterface) {
116
            /** @var array<Column> $columns */
117
            $columns = $entityMapping->collectDBALColumns();
118
119
            if (!empty($columns)) {
120
                /** @var UnitOfWork $unitOfWork */
121
                $unitOfWork = $entityManager->getUnitOfWork();
122
123
                /** @var array<string, mixed> $originalEntityData */
124
                $originalEntityData = $unitOfWork->getOriginalEntityData($entity);
125
126
                /** @var Column $column */
127
                foreach ($columns as $column) {
128
                    /** @var string $columnName */
129
                    $columnName = $column->getName();
130
131
                    /** @var string $fieldName */
132
                    $fieldName = $this->columnToFieldName($column);
133
134
                    if (array_key_exists($fieldName, $originalEntityData)) {
135
                        $dbalData[$columnName] = $originalEntityData[$fieldName];
136
137
                    } elseif (array_key_exists($columnName, $originalEntityData)) {
138
                        $dbalData[$columnName] = $originalEntityData[$columnName];
139
                    }
140
                }
141
            }
142
        }
143
144
        return $dbalData;
145
    }
146
147
    public function storeDBALDataForEntity($entity, EntityManagerInterface $entityManager)
148
    {
149
        # This happens after doctrine has already UPDATE'd the row itself, do nothing here.
150
    }
151
152
    public function removeDBALDataForEntity($entity, EntityManagerInterface $entityManager)
153
    {
154
        # Doctrine DELETE's the row for us, we dont need to do anything here.
155
    }
156
157
    public function prepareOnMetadataLoad(EntityManagerInterface $entityManager, ClassMetadata $classMetadata)
158
    {
159
        $this->boot($entityManager);
160
161
        /** @var class-string $className */
162
        $className = $classMetadata->getName();
163 1
164
        /** @var EntityMappingInterface|null $entityMapping */
165 1
        $entityMapping = $this->mappingDriver->loadRDMMetadataForClass($className);
166
167
        if ($entityMapping instanceof EntityMappingInterface) {
168 1
            /** @var array<Column> $dbalColumns */
169
            $dbalColumns = $entityMapping->collectDBALColumns();
170
171 1
            /** @var Column $column */
172
            foreach ($dbalColumns as $column) {
173 1
                /** @var string $columnName */
174
                $columnName = $column->getName();
175 1
176
                /** @var string $fieldName */
177
                $fieldName = $this->columnToFieldName($column);
178 1
179
                /** @psalm-suppress DeprecatedProperty */
180 1
                if (isset ($classMetadata->fieldNames) && isset($classMetadata->fieldNames[$columnName])) {
181
                    # This is a native doctrine column, do not touch! Otherwise the column might get unwanted UPDATE's.
182
                    continue;
183 1
                }
184
185
                /** @psalm-suppress DeprecatedProperty */
186 1
                $classMetadata->fieldNames[$columnName] = $fieldName;
187
188
                /** @psalm-suppress DeprecatedProperty */
189
                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

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

226
        /** @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...
227
        ?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

227
        /** @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...
228
        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

228
        /** @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...
229
        $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

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