• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

j-schumann / doctrine-addons / 15520691287

08 Jun 2025 05:03PM UTC coverage: 83.231%. Remained the same
15520691287

push

github

j-schumann
fix: apply rector

407 of 489 relevant lines covered (83.23%)

4.02 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.05
/src/ImportExport/Helper.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Vrok\DoctrineAddons\ImportExport;
6

7
use Doctrine\Common\Collections\Collection;
8
use Doctrine\Persistence\ObjectManager;
9
use Symfony\Component\PropertyAccess\PropertyAccess;
10
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
11

12
/**
13
 * Helper to convert arrays to (Doctrine) entities and export entities as arrays.
14
 * Uses Reflection to determine property types and check for properties tagged
15
 * with #[ImportableProperty] or #[ExportableProperty] and entity classes tagged
16
 * with #[ImportableEntity].
17
 * Uses Symfony's PropertyAccess to get/set properties using the correct
18
 * getters/setters (which also supports hassers and issers).
19
 *
20
 * @Deprecated use
21
 */
22
class Helper
23
{
24
    // static caches to reduce Reflection calls when im-/exporting multiple
25
    // objects of the same class
26
    protected static array $typeDetails = [];
27
    protected static array $exportableEntities = [];
28
    protected static array $importableEntities = [];
29
    protected static array $exportableProperties = [];
30
    protected static array $importableProperties = [];
31

32
    protected PropertyAccessorInterface $propertyAccessor;
33

34
    protected ?ObjectManager $objectManager = null;
35

36
    public function __construct()
37
    {
38
        @trigger_error(
38✔
39
            'ImportExport\Helper is deprecated and will be removed in 3.0, use the standalone package vrok\import-export instead!',
38✔
40
            \E_USER_DEPRECATED
38✔
41
        );
38✔
42

43
        $this->propertyAccessor = PropertyAccess::createPropertyAccessorBuilder()
38✔
44
            ->enableExceptionOnInvalidIndex()
38✔
45
            ->getPropertyAccessor();
38✔
46
    }
47

48
    public function setObjectManager(ObjectManager $objectManager): void
49
    {
50
        $this->objectManager = $objectManager;
1✔
51
    }
52

53
    /**
54
     * Creates an instance of the given entityClass and populates it with the
55
     * data given as array.
56
     * Alternatively the entityClass can be given as additional array element
57
     * with index _entityClass.
58
     * Can recurse over properties that themselves are entities or collections
59
     * of entities.
60
     * Also instantiates Datetime[Immutable] properties from strings.
61
     * To determine which properties to populate the attribute
62
     * #[ImportableProperty] is used. Can infer the entityClass from the
63
     * property's type for classes that are tagged with #[ImportableEntity].
64
     *
65
     * @throws \JsonException|\ReflectionException
66
     */
67
    public function fromArray(array $data, ?string $entityClass = null): object
68
    {
69
        // let the defined _entityClass take precedence over the (possibly
70
        // inferred) $entityClass from a property type, which may be an abstract
71
        // superclass or an interface
72
        $className = $data['_entityClass'] ?? $entityClass;
29✔
73

74
        if (empty($className)) {
29✔
75
            $encoded = json_encode($data, \JSON_THROW_ON_ERROR);
3✔
76
            throw new \RuntimeException("No entityClass given to instantiate the data: $encoded");
3✔
77
        }
78

79
        if (interface_exists($className)) {
27✔
80
            throw new \RuntimeException("Cannot create instance of the interface $className, concrete class needed!");
1✔
81
        }
82

83
        if (!class_exists($className)) {
27✔
84
            throw new \RuntimeException("Class $className does not exist!");
1✔
85
        }
86

87
        if ($entityClass && isset($data['_entityClass'])
26✔
88
            && !is_a($data['_entityClass'], $entityClass, true)
26✔
89
        ) {
90
            throw new \RuntimeException("Given '_entityClass' {$data['_entityClass']} is not a subclass/implementation of $entityClass!");
3✔
91
        }
92

93
        $classReflection = new \ReflectionClass($className);
25✔
94
        if ($classReflection->isAbstract()) {
25✔
95
            throw new \RuntimeException("Cannot create instance of the abstract class $className, concrete class needed!");
2✔
96
        }
97

98
        $instance = new $className();
23✔
99

100
        foreach ($this->getImportableProperties($className) as $property) {
23✔
101
            $propName = $property->getName();
23✔
102
            if (!\array_key_exists($propName, $data)) {
23✔
103
                continue;
23✔
104
            }
105

106
            $value = null;
23✔
107
            $typeDetails = $this->getTypeDetails($className, $property);
23✔
108
            $importAttrib = $property->getAttributes(ImportableProperty::class)[0];
22✔
109
            $listOf = $importAttrib->getArguments()['listOf'] ?? null;
22✔
110

111
            if (null === $data[$propName]) {
22✔
112
                if (!$typeDetails['allowsNull']) {
3✔
113
                    throw new \RuntimeException("Found NULL for $className::$propName, but property is not nullable!");
1✔
114
                }
115

116
                $value = null;
2✔
117
            } elseif ($typeDetails['isBuiltin']) {
20✔
118
                // check for listOf, the property could be an array of DTOs etc.
119
                $value = $listOf
14✔
120
                    ? $this->processList($data[$propName], $property, $listOf)
9✔
121
                    // simply set standard properties, the propertyAccessor will throw
14✔
122
                    // an exception if the types don't match.
14✔
123
                    : $data[$propName];
10✔
124
            } elseif (\is_object($data[$propName])) {
11✔
125
                // set already instantiated objects, we cannot modify/convert those,
126
                // and the may have different classes, e.g. when the type is a union.
127
                // If the object type is not allowed the propertyAccessor will throw
128
                // an exception.
129
                $value = $data[$propName];
2✔
130
            } elseif (\is_array($data[$propName]) && !$typeDetails['classname']) {
10✔
131
                // We have an array but no type information -> the target property
132
                // could be a unionType that allows multiple classes or it could
133
                // be untyped. So if the importer expects us to create an instance
134
                // ('_entityClass' is set) try to create & set it, else use the
135
                // value as is.
136
                $value = isset($data[$propName]['_entityClass'])
1✔
137
                    ? $this->fromArray($data[$propName])
1✔
138
                    : $data[$propName];
1✔
139
            } elseif (!$typeDetails['classname']) {
10✔
140
                // if we are this deep in the IFs it means the data is no array and this
141
                // is a uniontype with no classes (e.g. int|string) -> let the
142
                // propertyAccessor try to set the value as is.
143
                $value = $data[$propName];
3✔
144
            } elseif ($this->isImportableEntity($typeDetails['classname'])) {
8✔
145
                if (\is_int($data[$propName]) || \is_string($data[$propName])) {
4✔
146
                    if (null === $this->objectManager) {
2✔
147
                        throw new \RuntimeException("Found ID for $className::$propName, but objectManager is not set to find object!");
1✔
148
                    }
149

150
                    $value = $this->objectManager->find(
1✔
151
                        $typeDetails['classname'],
1✔
152
                        $data[$propName]
1✔
153
                    );
1✔
154
                } else {
155
                    $value = $this->fromArray($data[$propName], $typeDetails['classname']);
2✔
156
                }
157
            } elseif (is_a($typeDetails['classname'], Collection::class, true)) {
4✔
158
                // @todo We simply assume here that
159
                // a) the collection members are importable
160
                // -> use Doctrine Schema data to determine the collection type
161
                // c) the collection can be set as array at once
162
                $value = [];
3✔
163
                foreach ($data[$propName] as $element) {
3✔
164
                    if (!\is_array($element) && !\is_object($element)) {
3✔
165
                        throw new \RuntimeException("Elements imported for $className::$propName should be either an object or an array!");
×
166
                    }
167

168
                    $value[] = \is_object($element)
3✔
169
                        // use objects directly...
3✔
170
                        ? $element
1✔
171
                        // ... or try to create, if listOf is not set than each
3✔
172
                        // element must contain an _entityClass
3✔
173
                        : $this->fromArray($element, $listOf);
2✔
174
                }
175
            } elseif (is_a($typeDetails['classname'], \DateTimeInterface::class, true)) {
1✔
176
                $value = new ($typeDetails['classname'])($data[$propName]);
1✔
177
            } else {
178
                throw new \RuntimeException("Don't know how to import '$property' for $className!");
×
179
            }
180

181
            $this->propertyAccessor->setValue(
16✔
182
                $instance,
16✔
183
                $propName,
16✔
184
                $value
16✔
185
            );
16✔
186
        }
187

188
        return $instance;
16✔
189
    }
190

191
    // @todo: catch union types w/ multiple builtin types
192
    protected function getTypeDetails(string $classname, \ReflectionProperty $property): array
193
    {
194
        $propName = $property->getName();
23✔
195
        if (isset(self::$typeDetails["$classname::$propName"])) {
23✔
196
            return self::$typeDetails["$classname::$propName"];
20✔
197
        }
198

199
        $type = $property->getType();
9✔
200
        $data = [
9✔
201
            'allowsArray' => null === $type, // untyped allows arrays of course
9✔
202
            'allowsNull'  => $type?->allowsNull() ?? true, // also works for union types
9✔
203
            'classname'   => null,
9✔
204
            'typename'    => null,
9✔
205
            'isBuiltin'   => false,
9✔
206
            'isUnion'     => $type instanceof \ReflectionUnionType,
9✔
207
        ];
9✔
208

209
        if (null === $type) {
9✔
210
            self::$typeDetails["$classname::$propName"] = $data;
1✔
211

212
            return $data;
1✔
213
        }
214

215
        if ($data['isUnion']) {
9✔
216
            foreach ($type->getTypes() as $unionVariant) {
2✔
217
                /** @var \ReflectionNamedType $unionVariant */
218
                $variantName = $unionVariant->getName();
2✔
219
                if ('array' === $variantName) {
2✔
220
                    $data['allowsArray'] = true;
×
221
                    continue;
×
222
                }
223

224
                if (!$unionVariant->isBuiltin()) {
2✔
225
                    if (null !== $data['classname']) {
1✔
226
                        // @todo Improve this. We could store a list of classnames
227
                        // to check against in fromArray()
228
                        throw new \RuntimeException("Cannot import object, found ambiguous union type: $type");
1✔
229
                    }
230

231
                    $data['classname'] = $variantName;
1✔
232
                }
233
            }
234
        } elseif ($type->isBuiltin()) {
8✔
235
            $data['isBuiltin'] = true;
5✔
236
            $data['allowsNull'] = $type->allowsNull();
5✔
237
            $data['typename'] = $type->getName();
5✔
238
            if ('array' === $data['typename']) {
5✔
239
                $data['allowsArray'] = true;
3✔
240
            }
241
        } else {
242
            $propClass = $type->getName();
4✔
243
            $data['classname'] = 'self' === $propClass ? $classname : $propClass;
4✔
244
        }
245

246
        self::$typeDetails["$classname::$propName"] = $data;
8✔
247

248
        return $data;
8✔
249
    }
250

251
    /**
252
     * @throws \JsonException|\RuntimeException|\ReflectionException
253
     */
254
    protected function processList(mixed $list, \ReflectionProperty $property, string $listOf): array
255
    {
256
        if (null === $list) {
9✔
257
            return [];
×
258
        }
259

260
        if (!$this->isImportableEntity($listOf)) {
9✔
261
            throw new \LogicException("Property $property->class::$property->name is marked with ImportableProperty but its given listOf '$listOf' is no ImportableEntity!");
×
262
        }
263

264
        if (!\is_array($list)) {
9✔
265
            $json = json_encode($list, \JSON_THROW_ON_ERROR);
1✔
266
            throw new \RuntimeException("Property $property->class::$property->name is marked as list of '$listOf' but it is no array: $json!");
1✔
267
        }
268

269
        foreach ($list as $key => $entry) {
8✔
270
            if (!\is_array($entry)) {
7✔
271
                $json = json_encode($entry, \JSON_THROW_ON_ERROR);
1✔
272
                throw new \RuntimeException("Property $property->class::$property->name is marked as list of '$listOf' but entry is no array: $json!");
1✔
273
            }
274

275
            $list[$key] = $this->fromArray($entry, $listOf);
6✔
276
        }
277

278
        return $list;
4✔
279
    }
280

281
    /**
282
     * Converts the given (Doctrine) entity to an array, converting referenced entities
283
     * and collections to arrays too. Datetime instances are returned as ATOM strings.
284
     * Exports only properties that are marked with #[ExportableProperty]. If a reference
285
     * uses the referenceByIdentifier argument in the attribute the value of.
286
     *
287
     * @param object     $object         the entity to export, must be tagged with #[ExportableEntity]
288
     * @param array|null $propertyFilter if an array: only properties with the given names
289
     *                                   are returned
290
     *
291
     * @throws \ReflectionException
292
     */
293
    public function toArray(object $object, ?array $propertyFilter = null): array
294
    {
295
        $className = $object::class;
9✔
296
        if (!$this->isExportableEntity($className)) {
9✔
297
            throw new \RuntimeException("Don't know how to export instance of $className!");
1✔
298
        }
299

300
        $data = [];
8✔
301
        /** @var \ReflectionProperty $property */
302
        foreach ($this->getExportableProperties($className) as $property) {
8✔
303
            $propName = $property->getName();
8✔
304
            if (null !== $propertyFilter && !\in_array($propName, $propertyFilter, true)) {
8✔
305
                continue;
3✔
306
            }
307

308
            $propValue = $this->propertyAccessor->getValue($object, $propName);
8✔
309
            $exportAttrib = $property->getAttributes(ExportableProperty::class)[0];
8✔
310
            $referenceByIdentifier = $exportAttrib->getArguments()['referenceByIdentifier'] ?? null;
8✔
311

312
            if (null === $propValue) {
8✔
313
                $data[$propName] = null;
8✔
314
            } elseif ($propValue instanceof \DateTimeInterface) {
8✔
315
                $data[$propName] = $propValue->format(\DATE_ATOM);
1✔
316
            } elseif ($propValue instanceof Collection) {
8✔
317
                $data[$propName] = [];
7✔
318
                foreach ($propValue as $element) {
7✔
319
                    if (null !== $referenceByIdentifier) {
1✔
320
                        $identifier = $this->toArray($element, (array) $referenceByIdentifier);
1✔
321
                        $data[$propName][] = $identifier[$referenceByIdentifier];
1✔
322
                    } else {
323
                        $elementData = $this->toArray($element);
1✔
324
                        $elementData['_entityClass'] = $element::class;
1✔
325
                        $data[$propName][] = $elementData;
1✔
326
                    }
327
                }
328
            } elseif (\is_object($propValue) && $this->isExportableEntity($propValue::class)) {
8✔
329
                if (null !== $referenceByIdentifier) {
2✔
330
                    $identifier = $this->toArray($propValue, (array) $referenceByIdentifier);
1✔
331
                    $data[$propName] = $identifier[$referenceByIdentifier];
1✔
332
                } else {
333
                    $data[$propName] = $this->toArray($propValue);
2✔
334
                    if ($propValue::class !== $property->class) {
2✔
335
                        $data[$propName]['_entityClass'] = $propValue::class;
1✔
336
                    }
337
                }
338
            } elseif (\is_array($propValue)) {
8✔
339
                // @todo hacky solution. Maybe merge with collection handling
340
                // or determine if nested export is intended by another option
341
                // on the exportAttrib, or if the importAttrib has "listOf"
342
                $data[$propName] = [];
7✔
343
                foreach ($propValue as $key => $element) {
7✔
344
                    if (\is_object($element)) {
3✔
345
                        if (null !== $referenceByIdentifier) {
3✔
346
                            $identifier = $this->toArray($element, (array) $referenceByIdentifier);
×
347
                            $data[$propName][$key] = $identifier[$referenceByIdentifier];
×
348
                        } elseif ($this->isExportableEntity($element::class)) {
3✔
349
                            $elementData = $this->toArray($element);
3✔
350
                            $elementData['_entityClass'] = $element::class;
3✔
351
                            $data[$propName][$key] = $elementData;
3✔
352
                        } else {
353
                            // any other object is kept as-is
354
                            $data[$propName][$key] = $element;
1✔
355
                        }
356
                    } else {
357
                        // any other type is kept as-is
358
                        $data[$propName][$key] = $element;
×
359
                    }
360
                }
361
            } elseif (\is_int($propValue) || \is_float($propValue) || \is_bool($propValue) || \is_string($propValue)) {
8✔
362
                $data[$propName] = $propValue;
8✔
363
            } else {
364
                throw new \RuntimeException("Don't know how to export $className::$propName!");
×
365
            }
366
        }
367

368
        return $data;
8✔
369
    }
370

371
    /**
372
     * We use a static cache here as the properties of classes won't change
373
     * while the PHP instance is running and this method could be called
374
     * multiple times, e.g. when importing many objects of the same class.
375
     *
376
     * @throws \ReflectionException
377
     */
378
    protected function getImportableProperties(string $className): array
379
    {
380
        if (!isset(self::$importableProperties[$className])) {
23✔
381
            $reflection = new \ReflectionClass($className);
3✔
382
            self::$importableProperties[$className] = [];
3✔
383

384
            $properties = $reflection->getProperties();
3✔
385
            foreach ($properties as $property) {
3✔
386
                if ($this->isPropertyImportable($property)) {
3✔
387
                    self::$importableProperties[$className][] = $property;
3✔
388
                }
389
            }
390
        }
391

392
        return self::$importableProperties[$className];
23✔
393
    }
394

395
    /**
396
     * We use a static cache here as the properties of classes won't change
397
     * while the PHP instance is running and this method could be called
398
     * multiple times, e.g. when exporting many objects of the same class.
399
     *
400
     * @throws \ReflectionException
401
     */
402
    protected function getExportableProperties(string $className): array
403
    {
404
        if (!isset(self::$exportableProperties[$className])) {
8✔
405
            $reflection = new \ReflectionClass($className);
3✔
406
            self::$exportableProperties[$className] = [];
3✔
407

408
            $properties = $reflection->getProperties();
3✔
409
            foreach ($properties as $property) {
3✔
410
                if ($this->isPropertyExportable($property)) {
3✔
411
                    self::$exportableProperties[$className][] = $property;
3✔
412
                }
413
            }
414
        }
415

416
        return self::$exportableProperties[$className];
8✔
417
    }
418

419
    protected function isPropertyExportable(\ReflectionProperty $property): bool
420
    {
421
        return [] !== $property->getAttributes(ExportableProperty::class);
3✔
422
    }
423

424
    protected function isPropertyImportable(\ReflectionProperty $property): bool
425
    {
426
        return [] !== $property->getAttributes(ImportableProperty::class);
3✔
427
    }
428

429
    /**
430
     * @throws \ReflectionException
431
     */
432
    protected function isImportableEntity(string $className): bool
433
    {
434
        if (!isset(self::$importableEntities[$className])) {
16✔
435
            $reflection = new \ReflectionClass($className);
5✔
436
            $importable = $reflection->getAttributes(ImportableEntity::class);
5✔
437
            self::$importableEntities[$className] = [] !== $importable;
5✔
438
        }
439

440
        return self::$importableEntities[$className];
16✔
441
    }
442

443
    /**
444
     * @throws \ReflectionException
445
     */
446
    protected function isExportableEntity(string $className): bool
447
    {
448
        if (!isset(self::$exportableEntities[$className])) {
9✔
449
            $reflection = new \ReflectionClass($className);
5✔
450
            $exportable = $reflection->getAttributes(ExportableEntity::class);
5✔
451
            self::$exportableEntities[$className] = [] !== $exportable;
5✔
452
        }
453

454
        return self::$exportableEntities[$className];
9✔
455
    }
456
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc