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

Factory::disableDoctrineEvents()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 23
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 12
c 1
b 0
f 0
dl 0
loc 23
ccs 0
cts 0
cp 0
rs 9.5555
cc 5
nc 5
nop 0
crap 30
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()->withoutAutoRefresh(function(Proxy $proxy) use ($attributes) {
132
            foreach ($this->afterPersist as $callback) {
133 60
                $proxy->executeCallback($callback, $attributes);
134
            }
135
        });
136
137
        if (false === $this->doctrineEvents) {
138
            $this->resetDoctrineEvents();
139
        }
140
141 634
        return $proxy;
142
    }
143 634
144 634
    /**
145
     * @see FactoryCollection::__construct()
146 634
     *
147
     * @return FactoryCollection<TObject>
148
     */
149
    final public function many(int $min, ?int $max = null): FactoryCollection
150
    {
151
        return new FactoryCollection($this, $min, $max);
152
    }
153
154 30
    /**
155
     * @return static
156 30
     */
157 30
    public function withoutPersisting(): self
158
    {
159 30
        $cloned = clone $this;
160
        $cloned->persist = false;
161
162
        return $cloned;
163
    }
164
165
    /**
166
     * @return static
167 20
     */
168
    public function withoutDoctrineEvents(?string $eventTypeName = null): self
169 20
    {
170 20
        $cloned = clone $this;
171
        $cloned->doctrineEvents = false;
172 20
        $cloned->doctrineEventTypeName = $eventTypeName;
173
174
        return $cloned;
175
    }
176
177
    /**
178
     * @param array|callable $attributes
179
     *
180 20
     * @return static
181
     */
182 20
    final public function withAttributes($attributes = []): self
183 20
    {
184
        $cloned = clone $this;
185 20
        $cloned->attributeSet[] = $attributes;
186
187
        return $cloned;
188
    }
189
190
    /**
191
     * @param callable $callback (array $attributes): array
192
     *
193 20
     * @return static
194
     */
195 20
    final public function beforeInstantiate(callable $callback): self
196 20
    {
197
        $cloned = clone $this;
198 20
        $cloned->beforeInstantiate[] = $callback;
199
200
        return $cloned;
201
    }
202
203
    /**
204 968
     * @param callable $callback (object $object, array $attributes): void
205
     *
206 968
     * @return static
207 968
     */
208
    final public function afterInstantiate(callable $callback): self
209
    {
210
        $cloned = clone $this;
211
        $cloned->afterInstantiate[] = $callback;
212 964
213
        return $cloned;
214 964
    }
215 10
216
    /**
217
     * @param callable $callback (object|Proxy $object, array $attributes): void
218 964
     *
219 964
     * @return static
220 964
     */
221
    final public function afterPersist(callable $callback): self
222
    {
223
        $cloned = clone $this;
224
        $cloned->afterPersist[] = $callback;
225
226
        return $cloned;
227 934
    }
228
229 934
    /**
230
     * @param callable $instantiator (array $attributes, string $class): object
231
     *
232
     * @return static
233 934
     */
234
    final public function instantiateWith(callable $instantiator): self
235
    {
236
        $cloned = clone $this;
237
        $cloned->instantiator = $instantiator;
238
239 1016
        return $cloned;
240
    }
241 1016
242
    /**
243
     * @internal
244 643
     */
245
    final public static function boot(Configuration $configuration): void
246 643
    {
247
        self::$configuration = $configuration;
248
    }
249
250
    /**
251
     * @internal
252 813
     */
253
    final public static function shutdown(): void
254 813
    {
255
        if (!self::isBooted()) {
256
            return;
257
        }
258
259
        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

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