Passed
Pull Request — master (#181)
by Mathieu
05:03 queued 59s
created

Factory::afterPersist()   A

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

226
        self::$configuration->/** @scrutinizer ignore-call */ 
227
                              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...
227 934
        self::$configuration = null;
228
    }
229 934
230
    /**
231
     * @internal
232
     * @psalm-suppress InvalidNullableReturnType
233 934
     * @psalm-suppress NullableReturnStatement
234
     */
235
    final public static function configuration(): Configuration
236
    {
237
        if (!self::isBooted()) {
238
            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?');
239 1016
        }
240
241 1016
        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...
242
    }
243
244 643
    /**
245
     * @internal
246 643
     */
247
    final public static function isBooted(): bool
248
    {
249
        return null !== self::$configuration;
250
    }
251
252 813
    final public static function faker(): Faker\Generator
253
    {
254 813
        return self::configuration()->faker();
255
    }
256
257
    /**
258
     * @internal
259
     *
260
     * @psalm-return class-string<TObject>
261
     */
262 753
    final protected function class(): string
263
    {
264 753
        return $this->class;
265 80
    }
266
267
    /**
268 753
     * @param array|callable $attributes
269 70
     */
270
    private static function normalizeAttributes($attributes): array
271
    {
272 753
        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...
273
    }
274 100
275
    /**
276 80
     * @param mixed $value
277 100
     *
278 100
     * @return mixed
279
     */
280
    private function normalizeAttribute($value)
281
    {
282 753
        if ($value instanceof Proxy) {
283 753
            return $value->isPersisted() ? $value->refresh()->object() : $value->object();
284
        }
285
286 130
        if ($value instanceof FactoryCollection) {
287
            $value = $this->normalizeCollection($value);
288 30
        }
289
290
        if (\is_array($value)) {
291 130
            // possible OneToMany/ManyToMany relationship
292
            return \array_map(
293
                function($value) {
294 70
                    return $this->normalizeAttribute($value);
295
                },
296 70
                $value
297
            );
298 20
        }
299 20
300 20
        if (!$value instanceof self) {
301
            return \is_object($value) ? self::normalizeObject($value) : $value;
302
        }
303 20
304
        if (!$this->isPersisting()) {
305
            // ensure attribute Factory's are also not persisted
306 50
            $value = $value->withoutPersisting();
307
        }
308
309 50
        // Check if the attribute is cascade persist
310
        if (self::configuration()->hasManagerRegistry()) {
311 50
            $relationField = $this->relationshipField($value) ?? $this->inverseRelationshipField($value);
312 50
            $value->cascadePersist = $this->hasCascadePersist($value, $relationField);
313
        }
314 50
315
        return $value->create()->object();
316 50
    }
317 20
318
    private static function normalizeObject(object $object): object
319
    {
320
        try {
321 30
            return Proxy::createFromPersisted($object)->refresh()->object();
322
        } catch (\RuntimeException $e) {
323
            return $object;
324 803
        }
325
    }
326 803
327
    private function normalizeCollection(FactoryCollection $collection): array
328
    {
329
        $field = $this->inverseRelationshipField($collection->factory());
330
        $cascadePersist = $this->hasCascadePersist($collection->factory(), $field);
331
332
        if ($this->isPersisting() && $field && false === $cascadePersist) {
333
            $this->afterPersist[] = static function(Proxy $proxy) use ($collection, $field) {
334
                $collection->create([$field => $proxy]);
335
                $proxy->refresh();
336
            };
337
338
            // creation delegated to afterPersist event - return empty array here
339
            return [];
340
        }
341
342
        return \array_map(
343
            function(self $factory) {
344
                $factory->cascadePersist = $this->cascadePersist;
345
346
                return $factory;
347
            },
348
            $collection->all()
349
        );
350
    }
351
352
    private function relationshipField(self $factory): ?string
353
    {
354
        $relationClass = $factory->class;
355
        $factoryClass = $this->class;
356
        $classMetadataFactory = self::configuration()->objectManagerFor($factoryClass)->getMetadataFactory()->getMetadataFor($factoryClass);
357
358
        foreach ($classMetadataFactory->getAssociationNames() as $field) {
359
            // ensure 1-1, n-1 and associated class matches
360
            if (!$classMetadataFactory->isAssociationInverseSide($field) && $classMetadataFactory->getAssociationTargetClass($field) === $relationClass) {
361
                return $field;
362
            }
363
        }
364
365
        return null; // no relationship found
366
    }
367
368
    private function inverseRelationshipField(self $factory): ?string
369
    {
370
        $collectionClass = $factory->class;
371
        $collectionMetadata = self::configuration()->objectManagerFor($collectionClass)->getClassMetadata($collectionClass);
372
373
        foreach ($collectionMetadata->getAssociationNames() as $field) {
374
            if (($collectionMetadata->isSingleValuedAssociation($field) || $collectionMetadata->isCollectionValuedAssociation($field)) && $collectionMetadata->getAssociationTargetClass($field) === $this->class) {
375
                return $field;
376
            }
377
        }
378
379
        return null; // no relationship found
380
    }
381
382
    private function hasCascadePersist(self $factory, ?string $field): bool
383
    {
384
        if (null === $field) {
385
            return false;
386
        }
387
388
        $collectionClass = $factory->class;
389
        $factoryClass = $this->class;
390
        $collectionMetadata = self::configuration()->objectManagerFor($collectionClass)->getClassMetadata($collectionClass);
391
        $classMetadataFactory = self::configuration()->objectManagerFor($factoryClass)->getMetadataFactory()->getMetadataFor($factoryClass);
392
393
        if (!$collectionMetadata instanceof ClassMetadataInfo && !$classMetadataFactory instanceof ClassMetadataInfo) {
394
            return false;
395
        }
396
397
        if ($collectionMetadata->hasAssociation($field)) {
398
            $inversedBy = $collectionMetadata->getAssociationMapping($field)['inversedBy'];
0 ignored issues
show
Bug introduced by
The method getAssociationMapping() does not exist on Doctrine\Persistence\Mapping\ClassMetadata. Did you maybe mean getAssociationNames()? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

398
            $inversedBy = $collectionMetadata->/** @scrutinizer ignore-call */ getAssociationMapping($field)['inversedBy'];

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...
399
            if (null === $inversedBy) {
400
                return false;
401
            }
402
403
            $cascadeMetadata = $classMetadataFactory->getAssociationMapping($inversedBy)['cascade'] ?? [];
404
        } else {
405
            $cascadeMetadata = $classMetadataFactory->getAssociationMapping($field)['cascade'] ?? [];
406
        }
407
408
        return \in_array('persist', $cascadeMetadata, true);
409
    }
410
411
    private function isPersisting(): bool
412
    {
413
        return self::configuration()->hasManagerRegistry() ? $this->persist : false;
414
    }
415
}
416