Passed
Pull Request — master (#181)
by Mathieu
02:36
created

Factory::hasCascadePersist()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 24
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

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

225
        self::$configuration->/** @scrutinizer ignore-call */ 
226
                              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...
226
        self::$configuration = null;
227 934
    }
228
229 934
    /**
230
     * @internal
231
     * @psalm-suppress InvalidNullableReturnType
232
     * @psalm-suppress NullableReturnStatement
233 934
     */
234
    final public static function configuration(): Configuration
235
    {
236
        if (!self::isBooted()) {
237
            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?');
238
        }
239 1016
240
        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...
241 1016
    }
242
243
    /**
244 643
     * @internal
245
     */
246 643
    final public static function isBooted(): bool
247
    {
248
        return null !== self::$configuration;
249
    }
250
251
    final public static function faker(): Faker\Generator
252 813
    {
253
        return self::configuration()->faker();
254 813
    }
255
256
    /**
257
     * @internal
258
     *
259
     * @psalm-return class-string<TObject>
260
     */
261
    final protected function class(): string
262 753
    {
263
        return $this->class;
264 753
    }
265 80
266
    /**
267
     * @param array|callable $attributes
268 753
     */
269 70
    private static function normalizeAttributes($attributes): array
270
    {
271
        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...
272 753
    }
273
274 100
    /**
275
     * @param mixed $value
276 80
     *
277 100
     * @return mixed
278 100
     */
279
    private function normalizeAttribute($value)
280
    {
281
        if ($value instanceof Proxy) {
282 753
            return $value->isPersisted() ? $value->refresh()->object() : $value->object();
283 753
        }
284
285
        if ($value instanceof FactoryCollection) {
286 130
            $value = $this->normalizeCollection($value);
287
        }
288 30
289
        if (\is_array($value)) {
290
            // possible OneToMany/ManyToMany relationship
291 130
            return \array_map(
292
                function($value) {
293
                    return $this->normalizeAttribute($value);
294 70
                },
295
                $value
296 70
            );
297
        }
298 20
299 20
        if (!$value instanceof self) {
300 20
            return \is_object($value) ? self::normalizeObject($value) : $value;
301
        }
302
303 20
        if (!$this->isPersisting()) {
304
            // ensure attribute Factory's are also not persisted
305
            $value = $value->withoutPersisting();
306 50
        }
307
308
        // Check if the attribute is cascade persist
309 50
        if (self::configuration()->hasManagerRegistry()) {
310
            $relationField = $this->relationshipField($value) ?? $this->inverseRelationshipField($value);
311 50
            $cascadePersist = $this->hasCascadePersist($value, $relationField);
312 50
            if (true === $cascadePersist) {
313
                $value->setCascadePersist(true);
0 ignored issues
show
Bug introduced by
The method setCascadePersist() does not exist on Zenstruck\Foundry\Factory. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

313
                $value->/** @scrutinizer ignore-call */ 
314
                        setCascadePersist(true);
Loading history...
314 50
            }
315
        }
316 50
317 20
        return $value->create()->object();
318
    }
319
320
    private static function normalizeObject(object $object): object
321 30
    {
322
        try {
323
            return Proxy::createFromPersisted($object)->refresh()->object();
324 803
        } catch (\RuntimeException $e) {
325
            return $object;
326 803
        }
327
    }
328
329
    private function normalizeCollection(FactoryCollection $collection): array
330
    {
331
        $field = $this->inverseRelationshipField($collection->factory());
332
        $cascadePersist = $this->hasCascadePersist($collection->factory(), $field);
333
334
        if ($this->isPersisting() && $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
        return \array_map(
345
            function(self $factory) {
346
                $factory->cascadePersist = $this->cascadePersist;
347
348
                return $factory;
349
            },
350
            $collection->all()
351
        );
352
    }
353
354
    private function relationshipField(self $factory): ?string
355
    {
356
        $relationClass = $factory->class;
357
        $factoryClass = $this->class;
358
        $classMetadataFactory = self::configuration()->objectManagerFor($factoryClass)->getMetadataFactory()->getMetadataFor($factoryClass);
359
360
        foreach ($classMetadataFactory->getAssociationNames() as $field) {
361
            // ensure 1-1, n-1 and associated class matches
362
            if (!$classMetadataFactory->isAssociationInverseSide($field) && $classMetadataFactory->getAssociationTargetClass($field) === $relationClass) {
363
                return $field;
364
            }
365
        }
366
367
        return null; // no relationship found
368
    }
369
370
    private function inverseRelationshipField(self $factory): ?string
371
    {
372
        $collectionClass = $factory->class;
373
        $collectionMetadata = self::configuration()->objectManagerFor($collectionClass)->getClassMetadata($collectionClass);
374
375
        foreach ($collectionMetadata->getAssociationNames() as $field) {
376
            // ensure 1-n and associated class matches
377
            if ($collectionMetadata->isSingleValuedAssociation($field) && $collectionMetadata->getAssociationTargetClass($field) === $this->class) {
378
                return $field;
379
            }
380
        }
381
382
        return null; // no relationship found
383
    }
384
385
    private function hasCascadePersist(self $factory, ?string $field): bool
386
    {
387
        if (null === $field) {
388
            return false;
389
        }
390
391
        $collectionClass = $factory->class;
392
        $factoryClass = $this->class;
393
        $collectionMetadata = self::configuration()->objectManagerFor($collectionClass)->getClassMetadata($collectionClass);
394
        $classMetadataFactory = self::configuration()->objectManagerFor($factoryClass)->getMetadataFactory()->getMetadataFor($factoryClass);
395
396
        if (false === $collectionMetadata->hasAssociation($field)) {
397
            return false;
398
        }
399
400
        // Find cascade metatadata
401
        $collectionAssociationMapping = $collectionMetadata->getAssociationMapping($field);
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

401
        /** @scrutinizer ignore-call */ 
402
        $collectionAssociationMapping = $collectionMetadata->getAssociationMapping($field);

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...
402
        if (null !== $inversedBy = $collectionAssociationMapping['inversedBy']) {
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