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

j-schumann / import-export / 12915055311

22 Jan 2025 06:51PM UTC coverage: 94.485%. First build
12915055311

push

github

j-schumann
upd: property filter
upd: reflection handling
upd: tests
upd: readme

103 of 105 new or added lines in 2 files covered. (98.1%)

257 of 272 relevant lines covered (94.49%)

9.03 hits per line

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

94.12
/src/Helper.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Vrok\ImportExport;
6

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

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

34
    protected PropertyAccessorInterface $propertyAccessor;
35

36
    protected ?ObjectManager $objectManager = null;
37

38
    public function __construct()
39
    {
40
        $this->propertyAccessor = PropertyAccess::createPropertyAccessorBuilder()
47✔
41
            ->enableExceptionOnInvalidIndex()
47✔
42
            ->getPropertyAccessor();
47✔
43
    }
44

45
    /**
46
     * Required when importing data that uses references to existing records
47
     * by giving an identifier (int|string) instead of an array or object for
48
     * a property that holds a (list of) ExportableEntity.
49
     */
50
    public function setObjectManager(ObjectManager $objectManager): void
51
    {
52
        $this->objectManager = $objectManager;
1✔
53
    }
54

55
    /**
56
     * Creates an instance of the given entityClass and populates it with the
57
     * data given as array.
58
     * Alternatively the entityClass can be given as additional array element
59
     * with index _entityClass.
60
     * Can recurse over properties that themselves are entities or collections
61
     * of entities.
62
     * Also instantiates Datetime[Immutable] properties from strings.
63
     * To determine, which properties to populate the attribute
64
     * #[ImportableProperty] is used. Can infer the entityClass from the
65
     * property's type for classes that are tagged with #[ImportableEntity].
66
     *
67
     * @param array   $data            The list of all fields to set on the new
68
     *                                 object
69
     * @param ?string $entityClass     Class of the new object, necessary if the
70
     *                                 data does not contain  '_entityClass'
71
     * @param array   $propertyFilter  Only properties with the given names are
72
     *                                 imported, ignored if empty. May contain
73
     *                                 definitions for sub-records by using the
74
     *                                 property name as key and specifying an
75
     *                                 array of (sub-) properties as value.
76
     * @param bool    $filterAsExclude flips the meaning of the propertyFilter:
77
     *                                 only properties that are *not* in the
78
     *                                 list are imported, same for sub-records
79
     *
80
     * @throws \JsonException|\ReflectionException
81
     */
82
    public function fromArray(
83
        array $data,
84
        ?string $entityClass = null,
85
        array $propertyFilter = [],
86
        bool $filterAsExclude = false,
87
    ): object {
88
        // let the defined _entityClass take precedence over the (possibly
89
        // inferred) $entityClass from a property type, which may be an abstract
90
        // superclass or an interface
91
        $className = $data['_entityClass'] ?? $entityClass;
31✔
92

93
        if (empty($className)) {
31✔
94
            $encoded = json_encode($data, JSON_THROW_ON_ERROR);
3✔
95
            throw new \RuntimeException("No entityClass given to instantiate the data: $encoded");
3✔
96
        }
97

98
        if (interface_exists($className)) {
29✔
99
            throw new \RuntimeException("Cannot create instance of the interface $className, concrete class needed!");
1✔
100
        }
101

102
        if (!class_exists($className)) {
29✔
103
            throw new \RuntimeException("Class $className does not exist!");
1✔
104
        }
105

106
        if ($entityClass && isset($data['_entityClass'])
28✔
107
            && !is_a($data['_entityClass'], $entityClass, true)
28✔
108
        ) {
109
            throw new \RuntimeException("Given '_entityClass' {$data['_entityClass']} is not a subclass/implementation of $entityClass!");
3✔
110
        }
111

112
        $classReflection = new \ReflectionClass($className);
27✔
113
        if ($classReflection->isAbstract()) {
27✔
114
            throw new \RuntimeException("Cannot create instance of the abstract class $className, concrete class needed!");
2✔
115
        }
116

117
        $instance = new $className();
25✔
118

119
        foreach ($this->getImportableProperties($className) as $propName => $propData) {
25✔
120
            // empty array also counts as "no filter applied"
121
            if ([] !== $propertyFilter && (
25✔
122
                (!in_array($propName, $propertyFilter, true) && !$filterAsExclude)
25✔
123
                || (in_array($propName, $propertyFilter, true) && $filterAsExclude)
25✔
124
            )
125
            ) {
126
                continue;
2✔
127
            }
128

129
            if (!array_key_exists($propName, $data)) {
25✔
130
                continue;
24✔
131
            }
132

133
            $value = null;
25✔
134
            $typeDetails = $this->getTypeDetails($className, $propData['reflection']);
25✔
135
            $listOf = $propData['attribute']->listOf;
24✔
136

137
            if (null === $data[$propName]) {
24✔
138
                if (!$typeDetails['allowsNull']) {
3✔
139
                    throw new \RuntimeException("Found NULL for $className::$propName, but property is not nullable!");
1✔
140
                }
141

142
                $value = null;
2✔
143
            } elseif ($typeDetails['isBuiltin']) {
22✔
144
                // check for listOf, the property could be an array of DTOs etc.
145
                $value = $listOf
16✔
146
                    ? $this->processList(
11✔
147
                        $data[$propName],
11✔
148
                        $propData['reflection'],
11✔
149
                        $listOf,
11✔
150
                        $propertyFilter[$propName] ?? [],
11✔
151
                        $filterAsExclude
11✔
152
                    )
11✔
153
                    // simply set standard properties, the propertyAccessor will throw
16✔
154
                    // an exception if the types don't match.
16✔
155
                    : $data[$propName];
11✔
156
            } elseif (is_object($data[$propName])) {
13✔
157
                // set already instantiated objects, we cannot modify/convert those,
158
                // and the may have different classes, e.g. when the type is a union.
159
                // If the object type is not allowed the propertyAccessor will throw
160
                // an exception.
161
                $value = $data[$propName];
2✔
162
            } elseif (is_array($data[$propName]) && !$typeDetails['classname']) {
12✔
163
                // We have an array but no type information -> the target property
164
                // could be a unionType that allows multiple classes or it could
165
                // be untyped. So if the importer expects us to create an instance
166
                // ('_entityClass' is set) try to create & set it, else use the
167
                // value as is.
168
                $value = isset($data[$propName]['_entityClass'])
1✔
169
                    ? $this->fromArray($data[$propName],
1✔
170
                        null,
1✔
171
                        $propertyFilter[$propName] ?? [],
1✔
172
                        $filterAsExclude
1✔
173
                    )
1✔
174
                    : $data[$propName];
1✔
175
            } elseif (!$typeDetails['classname']) {
12✔
176
                // if we are this deep in the IFs it means the data is no array and this
177
                // is a union type with no classes (e.g. int|string) -> let the
178
                // propertyAccessor try to set the value as is.
179
                $value = $data[$propName];
5✔
180
            } elseif ($this->isImportableEntity($typeDetails['classname'])) {
10✔
181
                if (is_int($data[$propName]) || is_string($data[$propName])) {
6✔
182
                    if (null === $this->objectManager) {
2✔
183
                        throw new \RuntimeException("Found ID for $className::$propName, but objectManager is not set to find object!");
1✔
184
                    }
185

186
                    $value = $this->objectManager->find(
1✔
187
                        $typeDetails['classname'],
1✔
188
                        $data[$propName]
1✔
189
                    );
1✔
190
                } else {
191
                    $value = $this->fromArray(
4✔
192
                        $data[$propName],
4✔
193
                        $typeDetails['classname'],
4✔
194
                        $propertyFilter[$propName] ?? [],
4✔
195
                        $filterAsExclude
4✔
196
                    );
4✔
197
                }
198
            } elseif (is_a($typeDetails['classname'], Collection::class, true)) {
6✔
199
                // @todo We simply assume here that
200
                // a) the collection members are importable
201
                // -> use Doctrine Schema data to determine the collection type
202
                // c) the collection can be set as array at once
203
                $value = [];
3✔
204
                foreach ($data[$propName] as $element) {
3✔
205
                    if (is_int($element) || is_string($element)) {
3✔
206
                        if (null === $this->objectManager) {
×
207
                            throw new \RuntimeException("Found ID for $className::$propName, but objectManager is not set to find object!");
×
208
                        }
209
                        if (!$listOf) {
×
210
                            throw new \RuntimeException("Cannot import elements for $className::$propName, 'listOf' setting is required as this helper cannot evaluate Doctrine's relation attributes!");
×
211
                        }
212

213
                        $value[] = $this->objectManager->find(
×
214
                            $listOf,
×
215
                            $element
×
216
                        );
×
217
                    } elseif (is_object($element)) {
3✔
218
                        // use objects directly...
219
                        $value[] = $element;
1✔
220
                    } elseif (is_array($element)) {
2✔
221
                        // ... or try to create, if listOf is not set than each
222
                        // element must contain an _entityClass
223
                        $value[] =  $this->fromArray($element, $listOf);
2✔
224
                    } else {
225
                        throw new \RuntimeException("Don't know how to import elements for $className::$propName!");
×
226
                    }
227
                }
228
            } elseif (is_a($typeDetails['classname'], \DateTimeInterface::class, true)) {
3✔
229
                $value = new ($typeDetails['classname'])($data[$propName]);
3✔
230
            } else {
NEW
231
                throw new \RuntimeException("Don't know how to import $className::$propName!");
×
232
            }
233

234
            $this->propertyAccessor->setValue(
18✔
235
                $instance,
18✔
236
                $propName,
18✔
237
                $value
18✔
238
            );
18✔
239
        }
240

241
        return $instance;
18✔
242
    }
243

244
    // @todo: catch union types w/ multiple builtin types
245
    protected function getTypeDetails(
246
        string $classname,
247
        \ReflectionProperty $property,
248
    ): array {
249
        $propName = $property->getName();
25✔
250
        if (isset(self::$typeDetails["$classname::$propName"])) {
25✔
251
            return self::$typeDetails["$classname::$propName"];
22✔
252
        }
253

254
        $type = $property->getType();
9✔
255
        $data = [
9✔
256
            'allowsArray' => null === $type, // untyped allows arrays of course
9✔
257
            'allowsNull'  => $type?->allowsNull() ?? true, // also works for union types
9✔
258
            'classname'   => null,
9✔
259
            'typename'    => null,
9✔
260
            'isBuiltin'   => false,
9✔
261
            'isUnion'     => $type instanceof \ReflectionUnionType,
9✔
262
        ];
9✔
263

264
        if (null === $type) {
9✔
265
            self::$typeDetails["$classname::$propName"] = $data;
1✔
266

267
            return $data;
1✔
268
        }
269

270
        if ($data['isUnion']) {
9✔
271
            foreach ($type->getTypes() as $unionVariant) {
2✔
272
                /** @var \ReflectionNamedType $unionVariant */
273
                $variantName = $unionVariant->getName();
2✔
274
                if ('array' === $variantName) {
2✔
275
                    $data['allowsArray'] = true;
×
276
                    continue;
×
277
                }
278

279
                if (!$unionVariant->isBuiltin()) {
2✔
280
                    if (null !== $data['classname']) {
1✔
281
                        // @todo Improve this. We could store a list of classnames
282
                        // to check against in fromArray()
283
                        throw new \RuntimeException("Cannot import object, found ambiguous union type: $type");
1✔
284
                    }
285

286
                    $data['classname'] = $variantName;
1✔
287
                }
288
            }
289
        } elseif ($type->isBuiltin()) {
8✔
290
            $data['isBuiltin'] = true;
5✔
291
            $data['allowsNull'] = $type->allowsNull();
5✔
292
            $data['typename'] = $type->getName();
5✔
293
            if ('array' === $data['typename']) {
5✔
294
                $data['allowsArray'] = true;
3✔
295
            }
296
        } else {
297
            $propClass = $type->getName();
4✔
298
            $data['classname'] = 'self' === $propClass ? $classname : $propClass;
4✔
299
        }
300

301
        self::$typeDetails["$classname::$propName"] = $data;
8✔
302

303
        return $data;
8✔
304
    }
305

306
    /**
307
     * @throws \JsonException|\RuntimeException|\ReflectionException
308
     */
309
    protected function processList(
310
        mixed $list,
311
        \ReflectionProperty $property,
312
        string $listOf,
313
        array $propertyFilter = [],
314
        bool $filterAsExclude = false,
315
    ): array {
316
        if (null === $list) {
11✔
317
            return [];
×
318
        }
319

320
        if (!$this->isImportableEntity($listOf)) {
11✔
321
            throw new \LogicException("Property $property->class::$property->name is marked with ImportableProperty but its given listOf '$listOf' is no ImportableEntity!");
×
322
        }
323

324
        if (!is_array($list)) {
11✔
325
            $json = json_encode($list, JSON_THROW_ON_ERROR);
1✔
326
            throw new \RuntimeException("Property $property->class::$property->name is marked as list of '$listOf' but it is no array: $json!");
1✔
327
        }
328

329
        foreach ($list as $key => $entry) {
10✔
330
            if (!is_array($entry)) {
9✔
331
                $json = json_encode($entry, JSON_THROW_ON_ERROR);
1✔
332
                throw new \RuntimeException("Property $property->class::$property->name is marked as list of '$listOf' but entry is no array: $json!");
1✔
333
            }
334

335
            $list[$key] = $this->fromArray($entry, $listOf, $propertyFilter, $filterAsExclude);
8✔
336
        }
337

338
        return $list;
6✔
339
    }
340

341
    /**
342
     * Converts the given object to an array, converting referenced entities
343
     * and collections to arrays too. Datetime instances are returned as ATOM
344
     * strings. Only works with objects that are marked with #[ExportableEntity].
345
     * Exports only properties that are marked with #[ExportableProperty]. If a
346
     * reference uses the referenceByIdentifier argument in the attribute, only
347
     * the value of the field named in that argument is returned.
348
     *
349
     * @param object $object          the object to export, must be tagged with
350
     *                                #[ExportableEntity]
351
     * @param array  $propertyFilter  Only properties with the given names are
352
     *                                returned, ignored if empty. May contain
353
     *                                definitions for sub-records by using the
354
     *                                property name as key and specifying an
355
     *                                array of ignored (sub) properties as value.
356
     * @param bool   $filterAsExclude flips the meaning of the propertyFilter:
357
     *                                only properties that are *not* in the
358
     *                                list are returned, same for sub-records
359
     *
360
     * @throws \ReflectionException
361
     */
362
    public function toArray(
363
        object $object,
364
        array $propertyFilter = [],
365
        bool $filterAsExclude = false,
366
    ): array {
367
        $className = ClassUtils::getClass($object);
16✔
368
        if (!$this->isExportableEntity($className)) {
16✔
369
            throw new \RuntimeException("Don't know how to export instance of $className!");
1✔
370
        }
371

372
        $data = [];
15✔
373
        /* @var \ReflectionProperty $property */
374
        foreach ($this->getExportableProperties($className) as $propertyName => $attribute) {
15✔
375
            // empty array also counts as "no filter applied"
376
            if ([] !== $propertyFilter && (
15✔
377
                (!in_array($propertyName, $propertyFilter, true) && !$filterAsExclude)
15✔
378
                || (in_array($propertyName, $propertyFilter, true) && $filterAsExclude)
15✔
379
            )
380
            ) {
381
                continue;
9✔
382
            }
383

384
            $propValue = $this->propertyAccessor->getValue($object, $propertyName);
15✔
385

386
            if (null === $propValue) {
15✔
387
                $data[$propertyName] = null;
12✔
388
            } elseif ($propValue instanceof \DateTimeInterface) {
15✔
389
                $data[$propertyName] = $propValue->format(DATE_ATOM);
1✔
390
            } elseif ($attribute->asList || $propValue instanceof Collection) {
15✔
391
                if ('' !== $attribute->referenceByIdentifier) {
12✔
392
                    $data[$propertyName] = $this->exportCollection(
8✔
393
                        $propValue,
8✔
394
                        [$attribute->referenceByIdentifier]
8✔
395
                    );
8✔
396
                } else {
397
                    $data[$propertyName] = $this->exportCollection(
12✔
398
                        $propValue,
12✔
399
                        $propertyFilter[$propertyName] ?? [],
12✔
400
                        $filterAsExclude
12✔
401
                    );
12✔
402
                }
403
            } elseif (is_object($propValue) && $this->isExportableEntity($propValue::class)) {
15✔
404
                if ('' !== $attribute->referenceByIdentifier) {
3✔
405
                    $identifier = $this->toArray(
1✔
406
                        $propValue,
1✔
407
                        [$attribute->referenceByIdentifier]
1✔
408
                    );
1✔
409
                    $data[$propertyName] = $identifier[$attribute->referenceByIdentifier];
1✔
410
                } else {
411
                    $data[$propertyName] = $this->toArray(
3✔
412
                        $propValue,
3✔
413
                        $propertyFilter[$propertyName] ?? [],
3✔
414
                        $filterAsExclude
3✔
415
                    );
3✔
416

417
                    // We always store the classname, even when only one class
418
                    // is currently possible, to be future-proof in case a
419
                    // property can accept an interface / parent class later.
420
                    // This is also consistent with the handling of collections,
421
                    // which also always store the _entityClass.
422
                    $data[$propertyName]['_entityClass'] = ClassUtils::getClass($propValue);
3✔
423
                }
424
            } elseif (
425
                // Keep base types as-is. This can/will cause errors if an array
426
                // contains objects. Lists of DTOs should be marked with
427
                // 'asList' on the ExportableProperty attribute.
428
                is_array($propValue)
15✔
429
                || is_int($propValue)
15✔
430
                || is_float($propValue)
15✔
431
                || is_bool($propValue)
15✔
432
                || is_string($propValue)
15✔
433
            ) {
434
                $data[$propertyName] = $propValue;
15✔
435
            } else {
NEW
436
                throw new \RuntimeException("Don't know how to export $className::$propertyName!");
×
437
            }
438
        }
439

440
        return $data;
15✔
441
    }
442

443
    /**
444
     * Allows to export a list of elements at once. Used by toArray() if it
445
     * finds a property that is a Collection/list. Can also be used to export
446
     * Doctrine collections, e.g. of a complete table.
447
     *
448
     * The propertyFilter is applied to each collection element, see toArray().
449
     * If it contains no value it is ignored. If it contains exactly one value
450
     * this is interpreted as name of an identifier property (e.g. "id"), so
451
     * only a list of identifiers is returned.
452
     */
453
    public function exportCollection(
454
        Collection|array $collection,
455
        array $propertyFilter = [],
456
        bool $filterAsExclude = false,
457
    ): array {
458
        // If the propertyFilter contains only one element we assume that this
459
        // is the identifier that is to be exported, instead of the whole
460
        // entity.
461
        $referenceByIdentifier = false;
14✔
462
        if (!$filterAsExclude && 1 === count($propertyFilter)) {
14✔
463
            $referenceByIdentifier = array_values($propertyFilter)[0];
9✔
464
        }
465

466
        $values = [];
14✔
467
        foreach ($collection as $element) {
14✔
468
            // fail-safe for mixed collections, e.g. either DTO or string
469
            if (!is_object($element)) {
10✔
470
                $values[] = $element;
1✔
471
                continue;
1✔
472
            }
473

474
            if (false !== $referenceByIdentifier) {
10✔
475
                // in this case we return only an array of identifiers instead
476
                // of an array of arrays
477
                $export = $this->toArray(
2✔
478
                    $element,
2✔
479
                    [$referenceByIdentifier]
2✔
480
                );
2✔
481
                $values[] = $export[$referenceByIdentifier];
2✔
482
            } else {
483
                $export = $this->toArray(
9✔
484
                    $element,
9✔
485
                    $propertyFilter,
9✔
486
                    $filterAsExclude
9✔
487
                );
9✔
488

489
                // we need the entityClass here, as the collection may contain
490
                // mixed types (table inheritance or different DTO versions)
491
                $export['_entityClass'] = ClassUtils::getClass($element);
9✔
492

493
                $values[] = $export;
9✔
494
            }
495
        }
496

497
        return $values;
14✔
498
    }
499

500
    /**
501
     * We use a static cache here as the properties of classes won't change
502
     * while the PHP instance is running and this method could be called
503
     * multiple times, e.g. when importing many objects of the same class.
504
     *
505
     * @return array [
506
     *               propertyName => [
507
     *               'reflection' => ReflectionProperty
508
     *               'attribute' => ImportableProperty
509
     *               ]
510
     *               ]
511
     *
512
     * @throws \ReflectionException
513
     */
514
    protected function getImportableProperties(string $className): array
515
    {
516
        if (!isset(self::$importableProperties[$className])) {
25✔
517
            $reflection = new \ReflectionClass($className);
3✔
518
            self::$importableProperties[$className] = [];
3✔
519

520
            $properties = $reflection->getProperties();
3✔
521
            foreach ($properties as $property) {
3✔
522
                $attribute = ReflectionHelper::getPropertyAttribute(
3✔
523
                    $property,
3✔
524
                    ImportableProperty::class
3✔
525
                );
3✔
526
                if ($attribute instanceof ImportableProperty) {
3✔
527
                    // cache the reflection object, we need it for type analysis
528
                    // later and reflection is expensive, also the attribute
529
                    // instance, to check for "listOf"
530
                    self::$importableProperties[$className][$property->name] = [
3✔
531
                        'reflection' => $property,
3✔
532
                        'attribute'  => $attribute,
3✔
533
                    ];
3✔
534
                }
535
            }
536
        }
537

538
        return self::$importableProperties[$className];
25✔
539
    }
540

541
    /**
542
     * We use a static cache here as the properties of classes won't change
543
     * while the PHP instance is running and this method could be called
544
     * multiple times, e.g. when exporting many objects of the same class.
545
     *
546
     * @return array [propertyName => ExportableProperty instance]
547
     *
548
     * @throws \ReflectionException
549
     */
550
    protected function getExportableProperties(string $className): array
551
    {
552
        if (!isset(self::$exportableProperties[$className])) {
15✔
553
            $reflection = new \ReflectionClass($className);
3✔
554
            self::$exportableProperties[$className] = [];
3✔
555

556
            $properties = $reflection->getProperties();
3✔
557
            foreach ($properties as $property) {
3✔
558
                $attribute = ReflectionHelper::getPropertyAttribute(
3✔
559
                    $property,
3✔
560
                    ExportableProperty::class
3✔
561
                );
3✔
562
                if ($attribute instanceof ExportableProperty) {
3✔
563
                    // cache the attribute instance, to check for "asList" and
564
                    // "referenceByIdentifier"
565
                    self::$exportableProperties[$className][$property->name]
3✔
566
                        = $attribute;
3✔
567
                }
568
            }
569
        }
570

571
        return self::$exportableProperties[$className];
15✔
572
    }
573

574
    /**
575
     * @throws \ReflectionException
576
     */
577
    protected function isImportableEntity(string $className): bool
578
    {
579
        if (!isset(self::$importableEntities[$className])) {
18✔
580
            $attrib = ReflectionHelper::getClassAttribute(
5✔
581
                $className,
5✔
582
                ImportableEntity::class
5✔
583
            );
5✔
584

585
            self::$importableEntities[$className] = $attrib instanceof ImportableEntity;
5✔
586
        }
587

588
        return self::$importableEntities[$className];
18✔
589
    }
590

591
    /**
592
     * @throws \ReflectionException
593
     */
594
    protected function isExportableEntity(string $className): bool
595
    {
596
        if (!isset(self::$exportableEntities[$className])) {
16✔
597
            $attrib = ReflectionHelper::getClassAttribute(
4✔
598
                $className,
4✔
599
                ExportableEntity::class
4✔
600
            );
4✔
601

602
            self::$exportableEntities[$className] = $attrib instanceof ExportableEntity;
4✔
603
        }
604

605
        return self::$exportableEntities[$className];
16✔
606
    }
607
}
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