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

Factory::resetDoctrineEvents()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 21
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 0
Metric Value
eloc 10
c 0
b 0
f 0
dl 0
loc 21
ccs 0
cts 0
cp 0
rs 9.9332
cc 4
nc 4
nop 0
crap 20
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 array */
35
    private $disabledDoctrineEvents = [];
36
37
    /** @var array */
38
    private $doctrineEventsToDisable = [];
39
40
    /** @var bool */
41
    private $cascadePersist = false;
42
43
    /** @var array<array|callable> */
44
    private $attributeSet = [];
45
46 834
    /** @var callable[] */
47
    private $beforeInstantiate = [];
48 834
49 834
    /** @var callable[] */
50 834
    private $afterInstantiate = [];
51
52
    /** @var callable[] */
53
    private $afterPersist = [];
54
55
    /**
56
     * @param array|callable $defaultAttributes
57
     *
58
     * @psalm-param class-string<TObject> $class
59 813
     */
60
    public function __construct(string $class, $defaultAttributes = [])
61
    {
62 813
        if (self::class === static::class) {
0 ignored issues
show
introduced by
The condition self::class === static::class is always true.
Loading history...
63
            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);
64
        }
65 813
66
        $this->class = $class;
67 813
        $this->attributeSet[] = $defaultAttributes;
68 20
    }
69
70 20
    public function __call(string $name, array $arguments)
71 10
    {
72
        if ('createMany' !== $name) {
73
            throw new \BadMethodCallException(\sprintf('Call to undefined method "%s::%s".', static::class, $name));
74
        }
75
76 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);
77
78 753
        return $this->many($arguments[0])->create($arguments[1] ?? []);
79 803
    }
80 803
81
    /**
82
     * @param array|callable $attributes
83
     *
84 803
     * @return Proxy<TObject>&TObject
85
     * @psalm-return Proxy<TObject>
86 803
     */
87 10
    final public function create($attributes = []): Proxy
88
    {
89
        if (!empty($this->doctrineEventsToDisable)) {
90 803
            $this->disableDoctrineEvents();
91
        }
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
        $proxy->save()->withoutAutoRefresh(function(Proxy $proxy) use ($attributes) {
129
            foreach ($this->afterPersist as $callback) {
130 60
                $proxy->executeCallback($callback, $attributes);
131 60
            }
132
        });
133 60
134
        if (!empty($this->doctrineEventsToDisable)) {
135
            $this->resetDoctrineEvents();
136
        }
137
138
        return $proxy;
139
    }
140
141 634
    /**
142
     * @see FactoryCollection::__construct()
143 634
     *
144 634
     * @return FactoryCollection<TObject>
145
     */
146 634
    final public function many(int $min, ?int $max = null): FactoryCollection
147
    {
148
        return new FactoryCollection($this, $min, $max);
149
    }
150
151
    /**
152
     * @return static
153
     */
154 30
    public function withoutPersisting(): self
155
    {
156 30
        $cloned = clone $this;
157 30
        $cloned->persist = false;
158
159 30
        return $cloned;
160
    }
161
162
    /**
163
     * @return static
164
     */
165
    public function withoutDoctrineEvent(string $eventClass): self
166
    {
167 20
        $cloned = clone $this;
168
        $cloned->doctrineEventsToDisable[] = $eventClass;
169 20
170 20
        return $cloned;
171
    }
172 20
173
    /**
174
     * @param array|callable $attributes
175
     *
176
     * @return static
177
     */
178
    final public function withAttributes($attributes = []): self
179
    {
180 20
        $cloned = clone $this;
181
        $cloned->attributeSet[] = $attributes;
182 20
183 20
        return $cloned;
184
    }
185 20
186
    /**
187
     * @param callable $callback (array $attributes): array
188
     *
189
     * @return static
190
     */
191
    final public function beforeInstantiate(callable $callback): self
192
    {
193 20
        $cloned = clone $this;
194
        $cloned->beforeInstantiate[] = $callback;
195 20
196 20
        return $cloned;
197
    }
198 20
199
    /**
200
     * @param callable $callback (object $object, array $attributes): void
201
     *
202
     * @return static
203
     */
204 968
    final public function afterInstantiate(callable $callback): self
205
    {
206 968
        $cloned = clone $this;
207 968
        $cloned->afterInstantiate[] = $callback;
208
209
        return $cloned;
210
    }
211
212 964
    /**
213
     * @param callable $callback (object|Proxy $object, array $attributes): void
214 964
     *
215 10
     * @return static
216
     */
217
    final public function afterPersist(callable $callback): self
218 964
    {
219 964
        $cloned = clone $this;
220 964
        $cloned->afterPersist[] = $callback;
221
222
        return $cloned;
223
    }
224
225
    /**
226
     * @param callable $instantiator (array $attributes, string $class): object
227 934
     *
228
     * @return static
229 934
     */
230
    final public function instantiateWith(callable $instantiator): self
231
    {
232
        $cloned = clone $this;
233 934
        $cloned->instantiator = $instantiator;
234
235
        return $cloned;
236
    }
237
238
    /**
239 1016
     * @internal
240
     */
241 1016
    final public static function boot(Configuration $configuration): void
242
    {
243
        self::$configuration = $configuration;
244 643
    }
245
246 643
    /**
247
     * @internal
248
     */
249
    final public static function shutdown(): void
250
    {
251
        if (!self::isBooted()) {
252 813
            return;
253
        }
254 813
255
        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

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