Passed
Pull Request — master (#211)
by Kevin
03:16
created

Factory::__construct()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 2

Importance

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

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