Passed
Pull Request — master (#216)
by Charly
02:43
created

Factory   F

Complexity

Total Complexity 79

Size/Duplication

Total Lines 513
Duplicated Lines 0 %

Test Coverage

Coverage 99.02%

Importance

Changes 4
Bugs 0 Features 1
Metric Value
wmc 79
eloc 182
c 4
b 0
f 1
dl 0
loc 513
rs 2.08
ccs 101
cts 102
cp 0.9902

27 Methods

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

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