Passed
Pull Request — master (#216)
by Charly
03:45
created

Factory   F

Complexity

Total Complexity 78

Size/Duplication

Total Lines 509
Duplicated Lines 0 %

Test Coverage

Coverage 99.02%

Importance

Changes 5
Bugs 0 Features 1
Metric Value
wmc 78
eloc 181
c 5
b 0
f 1
dl 0
loc 509
ccs 101
cts 102
cp 0.9902
rs 2.16

27 Methods

Rating   Name   Duplication   Size   Complexity  
A isBooted() 0 3 1
A configuration() 0 7 2
A afterPersist() 0 6 1
A afterInstantiate() 0 6 1
A faker() 0 3 1
A boot() 0 3 1
A shutdown() 0 8 2
A instantiateWith() 0 6 1
A beforeInstantiate() 0 6 1
A withAttributes() 0 6 1
A __call() 0 9 2
A __construct() 0 8 2
A isPersisting() 0 13 4
A disableDoctrineEvents() 0 23 5
A withoutPersisting() 0 6 1
A normalizeObject() 0 6 2
A class() 0 3 1
A withoutDoctrineEvents() 0 7 1
A normalizeAttributes() 0 3 2
B normalizeAttribute() 0 36 9
A normalizeCollection() 0 24 4
A inverseRelationshipField() 0 13 4
A resetDoctrineEvents() 0 19 4
A many() 0 3 1
B relationshipField() 0 29 9
A hasCascadePersist() 0 27 6
B create() 0 54 9

How to fix   Complexity   

Complex Class

Complex classes like Factory often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Factory, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace Zenstruck\Foundry;
4
5
use Doctrine\Common\EventSubscriber;
6
use Doctrine\ORM\EntityManagerInterface;
7
use Doctrine\ORM\Mapping\ClassMetadataInfo;
8
use Faker;
9
use Symfony\Bridge\Doctrine\ContainerAwareEventManager;
10
11
/**
12
 * @template TObject of object
13
 * @abstract
14
 *
15
 * @author Kevin Bond <[email protected]>
16
 */
17
class Factory
18
{
19
    /** @var Configuration|null */
20
    private static $configuration;
21
22
    /**
23
     * @var string
24
     * @psalm-var class-string<TObject>
25
     */
26
    private $class;
27
28
    /** @var callable|null */
29
    private $instantiator;
30
31
    /** @var bool */
32
    private $persist = true;
33
34
    /** @var bool */
35
    private $doctrineEvents = true;
36
37
    /** @var string|null */
38
    private $doctrineEventTypeName = null;
39
40
    /** @var array */
41
    private $disabledDoctrineEvents = [];
42
43
    /** @var bool */
44
    private $cascadePersist = false;
45
46 834
    /** @var array<array|callable> */
47
    private $attributeSet = [];
48 834
49 834
    /** @var callable[] */
50 834
    private $beforeInstantiate = [];
51
52
    /** @var callable[] */
53
    private $afterInstantiate = [];
54
55
    /** @var callable[] */
56
    private $afterPersist = [];
57
58
    /**
59 813
     * @param array|callable $defaultAttributes
60
     *
61
     * @psalm-param class-string<TObject> $class
62 813
     */
63
    public function __construct(string $class, $defaultAttributes = [])
64
    {
65 813
        if (self::class === static::class) {
0 ignored issues
show
introduced by
The condition self::class === static::class is always true.
Loading history...
66
            trigger_deprecation('zenstruck/foundry', '1.9', 'Instantiating "%s" directly is deprecated and this class will be abstract in 2.0, use "%s" instead.', self::class, AnonymousFactory::class);
67 813
        }
68 20
69
        $this->class = $class;
70 20
        $this->attributeSet[] = $defaultAttributes;
71 10
    }
72
73
    public function __call(string $name, array $arguments)
74
    {
75
        if ('createMany' !== $name) {
76 803
            throw new \BadMethodCallException(\sprintf('Call to undefined method "%s::%s".', static::class, $name));
77
        }
78 753
79 803
        trigger_deprecation('zenstruck/foundry', '1.7', 'Calling instance method "%1$s::createMany()" is deprecated and will be removed in 2.0, use the static "%1$s:createMany()" method instead.', static::class);
80 803
81
        return $this->many($arguments[0])->create($arguments[1] ?? []);
82
    }
83
84 803
    /**
85
     * @param array|callable $attributes
86 803
     *
87 10
     * @return Proxy<TObject>&TObject
88
     * @psalm-return Proxy<TObject>
89
     */
90 803
    final public function create($attributes = []): Proxy
91
    {
92 803
        if (false === $this->doctrineEvents) {
93 190
            $this->disableDoctrineEvents();
94
        }
95
96
        // merge the factory attribute set with the passed attributes
97 625
        $attributeSet = \array_merge($this->attributeSet, [$attributes]);
98 30
99
        // normalize each attribute set and collapse
100 625
        $attributes = \array_merge(...\array_map([$this, 'normalizeAttributes'], $attributeSet));
101
102
        foreach ($this->beforeInstantiate as $callback) {
103
            $attributes = $callback($attributes);
104
105
            if (!\is_array($attributes)) {
106
                throw new \LogicException('Before Instantiate event callback must return an array.');
107
            }
108 240
        }
109
110 240
        // filter each attribute to convert proxies and factories to objects
111
        $attributes = \array_map(
112
            function($value) {
113
                return $this->normalizeAttribute($value);
114
            },
115
            $attributes
116
        );
117
118
        // instantiate the object with the users instantiator or if not set, the default instantiator
119
        $object = ($this->instantiator ?? self::configuration()->instantiator())($attributes, $this->class);
120 190
121
        foreach ($this->afterInstantiate as $callback) {
122 190
            $callback($object, $attributes);
123
        }
124
125
        $proxy = new Proxy($object);
126
127
        if (!$this->isPersisting() || true === $this->cascadePersist) {
128 60
            return $proxy;
129
        }
130 60
131 60
        $proxy->save();
132
133 60
        $proxy->withoutAutoRefresh(function(Proxy $proxy) use ($attributes) {
134
            foreach ($this->afterPersist as $callback) {
135
                $proxy->executeCallback($callback, $attributes);
136
            }
137
        });
138
139
        if (false === $this->doctrineEvents) {
140
            $this->resetDoctrineEvents();
141 634
        }
142
143 634
        return $proxy;
144 634
    }
145
146 634
    /**
147
     * @see FactoryCollection::__construct()
148
     *
149
     * @return FactoryCollection<TObject>
150
     */
151
    final public function many(int $min, ?int $max = null): FactoryCollection
152
    {
153
        return new FactoryCollection($this, $min, $max);
154 30
    }
155
156 30
    /**
157 30
     * @return static
158
     */
159 30
    public function withoutPersisting(): self
160
    {
161
        $cloned = clone $this;
162
        $cloned->persist = false;
163
164
        return $cloned;
165
    }
166
167 20
    /**
168
     * @return static
169 20
     */
170 20
    public function withoutDoctrineEvents(?string $eventTypeName = null): self
171
    {
172 20
        $cloned = clone $this;
173
        $cloned->doctrineEvents = false;
174
        $cloned->doctrineEventTypeName = $eventTypeName;
175
176
        return $cloned;
177
    }
178
179
    /**
180 20
     * @param array|callable $attributes
181
     *
182 20
     * @return static
183 20
     */
184
    final public function withAttributes($attributes = []): self
185 20
    {
186
        $cloned = clone $this;
187
        $cloned->attributeSet[] = $attributes;
188
189
        return $cloned;
190
    }
191
192
    /**
193 20
     * @param callable $callback (array $attributes): array
194
     *
195 20
     * @return static
196 20
     */
197
    final public function beforeInstantiate(callable $callback): self
198 20
    {
199
        $cloned = clone $this;
200
        $cloned->beforeInstantiate[] = $callback;
201
202
        return $cloned;
203
    }
204 968
205
    /**
206 968
     * @param callable $callback (object $object, array $attributes): void
207 968
     *
208
     * @return static
209
     */
210
    final public function afterInstantiate(callable $callback): self
211
    {
212 964
        $cloned = clone $this;
213
        $cloned->afterInstantiate[] = $callback;
214 964
215 10
        return $cloned;
216
    }
217
218 964
    /**
219 964
     * @param callable $callback (object|Proxy $object, array $attributes): void
220 964
     *
221
     * @return static
222
     */
223
    final public function afterPersist(callable $callback): self
224
    {
225
        $cloned = clone $this;
226
        $cloned->afterPersist[] = $callback;
227 934
228
        return $cloned;
229 934
    }
230
231
    /**
232
     * @param callable $instantiator (array $attributes, string $class): object
233 934
     *
234
     * @return static
235
     */
236
    final public function instantiateWith(callable $instantiator): self
237
    {
238
        $cloned = clone $this;
239 1016
        $cloned->instantiator = $instantiator;
240
241 1016
        return $cloned;
242
    }
243
244 643
    /**
245
     * @internal
246 643
     */
247
    final public static function boot(Configuration $configuration): void
248
    {
249
        self::$configuration = $configuration;
250
    }
251
252 813
    /**
253
     * @internal
254 813
     */
255
    final public static function shutdown(): void
256
    {
257
        if (!self::isBooted()) {
258
            return;
259
        }
260
261
        self::$configuration->faker()->unique(true); // reset unique
0 ignored issues
show
Bug introduced by
The method faker() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

261
        self::$configuration->/** @scrutinizer ignore-call */ 
262
                              faker()->unique(true); // reset unique

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
262 753
        self::$configuration = null;
263
    }
264 753
265 80
    /**
266
     * @internal
267
     * @psalm-suppress InvalidNullableReturnType
268 753
     * @psalm-suppress NullableReturnStatement
269 70
     */
270
    final public static function configuration(): Configuration
271
    {
272 753
        if (!self::isBooted()) {
273
            throw new \RuntimeException('Foundry is not yet booted. Using in a test: is your Test case using the Factories trait? Using in a fixture: is ZenstruckFoundryBundle enabled for this environment?');
274 100
        }
275
276 80
        return self::$configuration;
0 ignored issues
show
Bug Best Practice introduced by
The expression return self::configuration could return the type null which is incompatible with the type-hinted return Zenstruck\Foundry\Configuration. Consider adding an additional type-check to rule them out.
Loading history...
277 100
    }
278 100
279
    /**
280
     * @internal
281
     */
282 753
    final public static function isBooted(): bool
283 753
    {
284
        return null !== self::$configuration;
285
    }
286 130
287
    final public static function faker(): Faker\Generator
288 30
    {
289
        return self::configuration()->faker();
290
    }
291 130
292
    /**
293
     * @internal
294 70
     *
295
     * @psalm-return class-string<TObject>
296 70
     */
297
    final protected function class(): string
298 20
    {
299 20
        return $this->class;
300 20
    }
301
302
    /**
303 20
     * @param array|callable $attributes
304
     */
305
    private static function normalizeAttributes($attributes): array
306 50
    {
307
        return \is_callable($attributes) ? $attributes(self::faker()) : $attributes;
0 ignored issues
show
Bug Best Practice introduced by
The expression return is_callable($attr...:faker()) : $attributes could return the type callable which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
308
    }
309 50
310
    /**
311 50
     * @param mixed $value
312 50
     *
313
     * @return mixed
314 50
     */
315
    private function normalizeAttribute($value)
316 50
    {
317 20
        if ($value instanceof Proxy) {
318
            return $value->isPersisted() ? $value->refresh()->object() : $value->object();
319
        }
320
321 30
        if ($value instanceof FactoryCollection) {
322
            $value = $this->normalizeCollection($value);
323
        }
324 803
325
        if (\is_array($value)) {
326 803
            // possible OneToMany/ManyToMany relationship
327
            return \array_map(
328
                function($value) {
329
                    return $this->normalizeAttribute($value);
330
                },
331
                $value
332
            );
333
        }
334
335
        if (!$value instanceof self) {
336
            return \is_object($value) ? self::normalizeObject($value) : $value;
337
        }
338
339
        if (!$this->isPersisting()) {
340
            // ensure attribute Factory's are also not persisted
341
            $value = $value->withoutPersisting();
342
        }
343
344
        // Check if the attribute is cascade persist
345
        if (self::configuration()->hasManagerRegistry()) {
346
            $relationField = $this->relationshipField($value);
347
            $value->cascadePersist = $this->hasCascadePersist($value, $relationField);
348
        }
349
350
        return $value->create()->object();
351
    }
352
353
    private static function normalizeObject(object $object): object
354
    {
355
        try {
356
            return Proxy::createFromPersisted($object)->refresh()->object();
357
        } catch (\RuntimeException $e) {
358
            return $object;
359
        }
360
    }
361
362
    private function normalizeCollection(FactoryCollection $collection): array
363
    {
364
        if ($this->isPersisting()) {
365
            $field = $this->inverseRelationshipField($collection->factory());
366
            $cascadePersist = $this->hasCascadePersist($collection->factory(), $field);
367
368
            if ($field && false === $cascadePersist) {
369
                $this->afterPersist[] = static function(Proxy $proxy) use ($collection, $field) {
370
                    $collection->create([$field => $proxy]);
371
                    $proxy->refresh();
372
                };
373
374
                // creation delegated to afterPersist event - return empty array here
375
                return [];
376
            }
377
        }
378
379
        return \array_map(
380
            function(self $factory) {
381
                $factory->cascadePersist = $this->cascadePersist;
382
383
                return $factory;
384
            },
385
            $collection->all()
386
        );
387
    }
388
389
    private function relationshipField(self $factory): ?string
390
    {
391
        $factoryClass = $this->class;
392
        $relationClass = $factory->class;
393
394
        // Check inversedBy side ($this is the owner of the relation)
395
        $factoryClassMetadata = self::configuration()->objectManagerFor($factoryClass)->getMetadataFactory()->getMetadataFor($factoryClass);
396
397
        foreach ($factoryClassMetadata->getAssociationNames() as $field) {
398
            if (!$factoryClassMetadata->isAssociationInverseSide($field) && $factoryClassMetadata->getAssociationTargetClass($field) === $relationClass) {
399
                return $field;
400
            }
401
        }
402
403
        try {
404
            // Check mappedBy side ($factory is the owner of the relation)
405
            $relationClassMetadata = self::configuration()->objectManagerFor($relationClass)->getClassMetadata($relationClass);
406
        } catch (\RuntimeException $e) {
407
            // relation not managed - could be embeddable
408
            return null;
409
        }
410
411
        foreach ($relationClassMetadata->getAssociationNames() as $field) {
412
            if (($relationClassMetadata->isSingleValuedAssociation($field) || $relationClassMetadata->isCollectionValuedAssociation($field)) && $relationClassMetadata->getAssociationTargetClass($field) === $factoryClass) {
413
                return $field;
414
            }
415
        }
416
417
        return null; // no relationship found
418
    }
419
420
    private function inverseRelationshipField(self $factory): ?string
421
    {
422
        $collectionClass = $factory->class;
423
        $collectionMetadata = self::configuration()->objectManagerFor($collectionClass)->getClassMetadata($collectionClass);
424
425
        foreach ($collectionMetadata->getAssociationNames() as $field) {
426
            // ensure 1-n and associated class matches
427
            if ($collectionMetadata->isSingleValuedAssociation($field) && $collectionMetadata->getAssociationTargetClass($field) === $this->class) {
428
                return $field;
429
            }
430
        }
431
432
        return null; // no relationship found
433
    }
434
435
    private function hasCascadePersist(self $factory, ?string $field): bool
436
    {
437
        if (null === $field) {
438
            return false;
439
        }
440
441
        $factoryClass = $this->class;
442
        $relationClass = $factory->class;
443
        $classMetadataFactory = self::configuration()->objectManagerFor($factoryClass)->getMetadataFactory()->getMetadataFor($factoryClass);
444
        $relationClassMetadata = self::configuration()->objectManagerFor($relationClass)->getClassMetadata($relationClass);
445
446
        if (!$relationClassMetadata instanceof ClassMetadataInfo || !$classMetadataFactory instanceof ClassMetadataInfo) {
447
            return false;
448
        }
449
450
        if ($relationClassMetadata->hasAssociation($field)) {
451
            $inversedBy = $relationClassMetadata->getAssociationMapping($field)['inversedBy'];
452
            if (null === $inversedBy) {
453
                return false;
454
            }
455
456
            $cascadeMetadata = $classMetadataFactory->getAssociationMapping($inversedBy)['cascade'] ?? [];
457
        } else {
458
            $cascadeMetadata = $classMetadataFactory->getAssociationMapping($field)['cascade'] ?? [];
459
        }
460
461
        return \in_array('persist', $cascadeMetadata, true);
462
    }
463
464
    private function isPersisting(): bool
465
    {
466
        if (!$this->persist || !self::configuration()->hasManagerRegistry()) {
467
            return false;
468
        }
469
470
        try {
471
            self::configuration()->objectManagerFor($this->class);
472
473
            return true;
474
        } catch (\RuntimeException $e) {
475
            // entity not managed (perhaps Embeddable)
476
            return false;
477
        }
478
    }
479
480
    private function resetDoctrineEvents(): void
481
    {
482
        $this->doctrineEvents = true;
483
        $this->doctrineEventTypeName = null;
484
485
        /** @var EntityManagerInterface $manager */
486
        $manager = self::configuration()->objectManagerFor($this->class);
487
        /** @var ContainerAwareEventManager $eventManager */
488
        $eventManager = $manager->getConnection()->getEventManager();
489
490
        foreach ($this->disabledDoctrineEvents as $type => $listenersByType) {
491
            foreach ($listenersByType as $listener) {
492
                if ($listener instanceof EventSubscriber) {
493
                    $eventManager->addEventSubscriber($listener);
494
495
                    continue;
496
                }
497
498
                $eventManager->addEventListener($type, $listener);
499
            }
500
        }
501
    }
502
503
    private function disableDoctrineEvents(): void
504
    {
505
        /** @var EntityManagerInterface $manager */
506
        $manager = self::configuration()->objectManagerFor($this->class);
507
        /** @var ContainerAwareEventManager $eventManager */
508
        $eventManager = $manager->getConnection()->getEventManager();
509
510
        $this->disabledDoctrineEvents = $manager->getConnection()->getEventManager()->getListeners($this->doctrineEventTypeName);
511
        foreach ($this->disabledDoctrineEvents as $type => $listenersByType) {
512
            foreach ($listenersByType as $name => $listener) {
513
                if ($listener instanceof EventSubscriber) {
514
                    $manager->getConnection()->getEventManager()->removeEventSubscriber($listener);
515
516
                    continue;
517
                }
518
519
                if (false === \mb_strpos($name, '_service_')) {
520
                    // Handle listeners with hash as name, ex: '000000001f7b9da2000000001c719ccc'
521
                    $eventManager->removeEventListener($type, $listener);
522
                } else {
523
                    // Symfony\Bridge\Doctrine\ContainerAwareEventManager allows container service id's to be registered as event listeners
524
                    // these are prefixed with "_service_"
525
                    $eventManager->removeEventListener($type, \str_replace('_service_', '', $name));
526
                }
527
            }
528
        }
529
    }
530
}
531