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
![]() |
|||||
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
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
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. ![]() |
|||||
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
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
This check looks for parameters that have been defined for a function or method, but which are not used in the method body. ![]() |
|||||
237 | ?object $entity, |
||||
0 ignored issues
–
show
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
This check looks for parameters that have been defined for a function or method, but which are not used in the method body. ![]() |
|||||
238 | string $columnName, |
||||
0 ignored issues
–
show
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
This check looks for parameters that have been defined for a function or method, but which are not used in the method body. ![]() |
|||||
239 | $value = null |
||||
0 ignored issues
–
show
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
This check looks for parameters that have been defined for a function or method, but which are not used in the method body. ![]() |
|||||
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 |