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

Factory::resetDoctrineEvents()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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

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