Factory::beforeInstantiate()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

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

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