Passed
Pull Request — master (#127)
by Wouter
03:31
created

Factory::create()   B

Complexity

Conditions 8
Paths 17

Size

Total Lines 50
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 8

Importance

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

296
        self::$configuration->/** @scrutinizer ignore-call */ 
297
                              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...
297
        self::$configuration = null;
298 20
    }
299 20
300 20
    /**
301
     * @internal
302
     * @psalm-suppress InvalidNullableReturnType
303 20
     * @psalm-suppress NullableReturnStatement
304
     */
305
    final public static function configuration(): Configuration
306 50
    {
307
        if (!self::isBooted()) {
308
            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?');
309 50
        }
310
311 50
        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...
312 50
    }
313
314 50
    /**
315
     * @internal
316 50
     */
317 20
    final public static function isBooted(): bool
318
    {
319
        return null !== self::$configuration;
320
    }
321 30
322
    final public static function faker(): Faker\Generator
323
    {
324 803
        return self::configuration()->faker();
325
    }
326 803
327
    /**
328
     * Instead of returning an instance of {@see Proxy}, use a generator to create a real proxy for the object.
329
     */
330
    public function withProxyGenerator(array $proxyMethods = []): self
331
    {
332
        $this->proxyGenerator = new ProxyGenerator(self::configuration());
333
        $this->proxyMethods = $proxyMethods;
334
335
        return $this;
336
    }
337
338
    /**
339
     * @internal
340
     *
341
     * @psalm-return class-string<TObject>
342
     */
343
    final protected function class(): string
344
    {
345
        return $this->class;
346
    }
347
348
    /**
349
     * @param array|callable $attributes
350
     */
351
    private static function normalizeAttributes($attributes): array
352
    {
353
        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...
354
    }
355
356
    /**
357
     * @param mixed $value
358
     *
359
     * @return mixed
360
     */
361
    private function normalizeAttribute($value)
362
    {
363
        if ($value instanceof Proxy) {
364
            return $value->isPersisted() ? $value->refresh()->object() : $value->object();
365
        }
366
367
        if ($value instanceof FactoryCollection) {
368
            $value = $this->normalizeCollection($value);
369
        }
370
371
        if (\is_array($value)) {
372
            // possible OneToMany/ManyToMany relationship
373
            return \array_map(
374
                function($value) {
375
                    return $this->normalizeAttribute($value);
376
                },
377
                $value
378
            );
379
        }
380
381
        if (!$value instanceof self) {
382
            return \is_object($value) ? self::normalizeObject($value) : $value;
383
        }
384
385
        if (!$this->isPersisting()) {
386
            // ensure attribute Factory's are also not persisted
387
            $value = $value->withoutPersisting();
388
        }
389
390
        return $value->create()->object();
391
    }
392
393
    private static function normalizeObject(object $object): object
394
    {
395
        try {
396
            return Proxy::createFromPersisted($object)->refresh()->object();
397
        } catch (\RuntimeException $e) {
398
            return $object;
399
        }
400
    }
401
402
    private function normalizeCollection(FactoryCollection $collection): array
403
    {
404
        if ($this->isPersisting() && $field = $this->inverseRelationshipField($collection->factory())) {
405
            $this->afterPersist[] = static function(Proxy $proxy) use ($collection, $field) {
406
                $collection->create([$field => $proxy]);
407
                $proxy->refresh();
408
            };
409
410
            // creation delegated to afterPersist event - return empty array here
411
            return [];
412
        }
413
414
        return $collection->all();
415
    }
416
417
    private function inverseRelationshipField(self $factory): ?string
418
    {
419
        $collectionClass = $factory->class;
420
        $collectionMetadata = self::configuration()->objectManagerFor($collectionClass)->getClassMetadata($collectionClass);
421
422
        foreach ($collectionMetadata->getAssociationNames() as $field) {
423
            // ensure 1-n and associated class matches
424
            if ($collectionMetadata->isSingleValuedAssociation($field) && $collectionMetadata->getAssociationTargetClass($field) === $this->class) {
425
                return $field;
426
            }
427
        }
428
429
        return null; // no relationship found
430
    }
431
432
    private function isPersisting(): bool
433
    {
434
        return self::configuration()->hasManagerRegistry() ? $this->persist : false;
435
    }
436
437
    /**
438
     * @template T of object
439
     * @psalm-param T|Proxy<T>|ValueHolderInterface<T> $proxyObject
440
     * @psalm-return T
441
     *
442
     * @psalm-suppress InvalidReturnType
443
     * @psalm-suppress InvalidReturnStatement
444
     */
445
    private static function realObject(object $proxyObject): object
446
    {
447
        if ($proxyObject instanceof Proxy) {
448
            return $proxyObject->object();
449
        }
450
451
        if ($proxyObject instanceof ValueHolderInterface && $valueHolder = $proxyObject->getWrappedValueHolderValue()) {
452
            return $valueHolder;
453
        }
454
455
        return $proxyObject;
456
    }
457
}
458