Completed
Push — 2.x ( 0b6590...78009d )
by Aleksei
20s queued 15s
created

ManyToMany   D

Complexity

Total Complexity 58

Size/Duplication

Total Lines 391
Duplicated Lines 0 %

Test Coverage

Coverage 96.02%

Importance

Changes 0
Metric Value
eloc 216
dl 0
loc 391
ccs 193
cts 201
cp 0.9602
rs 4.5599
c 0
b 0
f 0
wmc 58

15 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 16 1
C prepare() 0 49 13
A initReference() 0 12 3
A queue() 0 10 3
A cast() 0 29 4
A initPivot() 0 12 4
B resolve() 0 89 6
A deleteChild() 0 7 2
A extract() 0 12 1
A extractRelated() 0 16 5
A init() 0 26 3
A finalize() 0 25 6
A newLink() 0 29 4
A collect() 0 5 1
A applyPivotChanges() 0 4 2

How to fix   Complexity   

Complex Class

Complex classes like ManyToMany often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ManyToMany, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Cycle\ORM\Relation;
6
7
use Cycle\ORM\Collection\Pivoted\PivotedCollectionInterface;
8
use Cycle\ORM\Collection\Pivoted\PivotedStorage;
9
use Cycle\ORM\FactoryInterface;
10
use Cycle\ORM\Heap\HeapInterface;
11
use Cycle\ORM\Heap\Node;
12
use Cycle\ORM\Heap\State;
13
use Cycle\ORM\Iterator;
14
use Cycle\ORM\MapperInterface;
15
use Cycle\ORM\ORMInterface;
16
use Cycle\ORM\Parser\RootNode;
17
use Cycle\ORM\Reference\EmptyReference;
18
use Cycle\ORM\Reference\Reference;
19
use Cycle\ORM\Reference\ReferenceInterface;
20
use Cycle\ORM\Relation;
21
use Cycle\ORM\Select\JoinableLoader;
22
use Cycle\ORM\Select\Loader\ManyToManyLoader;
23
use Cycle\ORM\Select\LoaderInterface;
24
use Cycle\ORM\Select\RootLoader;
25
use Cycle\ORM\Service\EntityFactoryInterface;
26
use Cycle\ORM\Service\SourceProviderInterface;
27
use Cycle\ORM\Transaction\Pool;
28
use Cycle\ORM\Transaction\Tuple;
29
30
/**
31
 * @internal
32
 */
33
class ManyToMany extends Relation\AbstractRelation
34
{
35
    /** @var string[] */
36
    protected array $throughInnerKeys;
37
38
    /** @var string[] */
39
    protected array $throughOuterKeys;
40
41
    protected string $pivotRole;
42
    protected EntityFactoryInterface $entityFactory;
43
    protected SourceProviderInterface $sourceProvider;
44
    protected FactoryInterface $factory;
45
    private HeapInterface $heap;
46
47
    public function __construct(
48 942
        ORMInterface $orm,
49
        private string $role,
50 942
        string $name,
51 942
        string $target,
52 942
        array $schema,
53 942
    ) {
54 942
        parent::__construct($orm, $role, $name, $target, $schema);
55 942
        $this->heap = $orm->getHeap();
56
        $this->sourceProvider = $orm->getService(SourceProviderInterface::class);
57 942
        $this->entityFactory = $orm->getService(EntityFactoryInterface::class);
58 942
        $this->factory = $orm->getFactory();
59
        $this->pivotRole = $this->schema[Relation::THROUGH_ENTITY];
60
61 346
        $this->throughInnerKeys = (array) $this->schema[Relation::THROUGH_INNER_KEY];
62
        $this->throughOuterKeys = (array) $this->schema[Relation::THROUGH_OUTER_KEY];
63 346
    }
64
65
    public function prepare(Pool $pool, Tuple $tuple, mixed $related, bool $load = true): void
66 346
    {
67 346
        $node = $tuple->node;
68
69 346
        /** @var PivotedStorage|ReferenceInterface|null $original */
70 72
        $original = $node->getRelation($this->getName());
71 40
        $tuple->state->setRelation($this->getName(), $related);
72 40
73
        if ($original instanceof ReferenceInterface) {
74 56
            if (!$load && $related === $original && !$original->hasValue()) {
75 56
                $this->finalize($pool, $tuple, $related);
76 56
                return;
77
            }
78 338
            $this->resolve($original, true);
79
            $original = $original->getValue();
80 338
            $node->setRelation($this->getName(), $original);
81
        }
82
        $original = $this->extract($original);
83
84 338
        if ($related instanceof ReferenceInterface && $this->resolve($related, true) !== null) {
85
            $related = $related->getValue();
86 338
            $tuple->state->setRelation($this->getName(), $related);
87
        } elseif (SpecialValue::isNotSet($related)) {
88
            $tuple->state->setRelationStatus($this->getName(), RelationInterface::STATUS_RESOLVED);
89 338
            return;
90 192
        }
91 72
92 72
        $related = $this->extractRelated($related, $original);
93 72
        // $tuple->state->setStorage($this->pivotEntity, $related);
94
        $tuple->state->setRelation($this->getName(), $related);
95
96
        // un-link old elements
97 338
        foreach ($original as $item) {
98
            if (!$related->has($item)) {
0 ignored issues
show
Bug introduced by
$item of type array is incompatible with the type object expected by parameter $entity of Cycle\ORM\Collection\Pivoted\PivotedStorage::has(). ( Ignorable by Annotation )

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

98
            if (!$related->has(/** @scrutinizer ignore-type */ $item)) {
Loading history...
99
                $pivot = $original->get($item);
0 ignored issues
show
Bug introduced by
$item of type array is incompatible with the type object expected by parameter $entity of Cycle\ORM\Collection\Pivoted\PivotedStorage::get(). ( Ignorable by Annotation )

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

99
                $pivot = $original->get(/** @scrutinizer ignore-type */ $item);
Loading history...
100
                $this->deleteChild($pool, $pivot, $item);
0 ignored issues
show
Bug introduced by
$item of type array is incompatible with the type object expected by parameter $child of Cycle\ORM\Relation\ManyToMany::deleteChild(). ( Ignorable by Annotation )

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

100
                $this->deleteChild($pool, $pivot, /** @scrutinizer ignore-type */ $item);
Loading history...
Bug introduced by
It seems like $pivot can also be of type array; however, parameter $pivot of Cycle\ORM\Relation\ManyToMany::deleteChild() does only seem to accept null|object, maybe add an additional type check? ( Ignorable by Annotation )

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

100
                $this->deleteChild($pool, /** @scrutinizer ignore-type */ $pivot, $item);
Loading history...
101 338
                $original->getContext()->offsetUnset($item);
0 ignored issues
show
Bug introduced by
$item of type array is incompatible with the type object expected by parameter $object of SplObjectStorage::offsetUnset(). ( Ignorable by Annotation )

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

101
                $original->getContext()->offsetUnset(/** @scrutinizer ignore-type */ $item);
Loading history...
102
            }
103
        }
104 338
105 336
        if ($this->inversion === null && \count($related) === 0) {
106
            $tuple->state->setRelationStatus($this->getName(), RelationInterface::STATUS_RESOLVED);
107
            return;
108
        }
109 338
        $tuple->state->setRelationStatus($this->getName(), RelationInterface::STATUS_PROCESS);
110
111 338
        // link/sync new and existed elements
112
        foreach ($related->getElements() as $item) {
113 338
            $this->newLink($pool, $tuple, $related, $item);
114
        }
115 338
    }
116
117
    public function queue(Pool $pool, Tuple $tuple): void
118
    {
119
        $related = $tuple->state->getRelation($this->getName());
120 338
121 338
        if ($related instanceof ReferenceInterface && !$related->hasValue()) {
122 338
            $tuple->state->setRelationStatus($this->getName(), RelationInterface::STATUS_RESOLVED);
123 336
            return;
124 336
        }
125 336
126 336
        $this->finalize($pool, $tuple, $related);
127 336
    }
128 336
129
    public function init(EntityFactoryInterface $factory, Node $node, array $data): iterable
130
    {
131 338
        $elements = [];
132 162
        $pivotData = new \SplObjectStorage();
133 162
134 160
        $iterator = Iterator::createWithServices(
135 96
            $this->heap,
136
            $this->ormSchema,
137 64
            $this->entityFactory,
138 64
            $this->target,
139
            $data,
140 162
            true,
141
        );
142
        foreach ($iterator as $pivot => $entity) {
143
            if (!\is_array($pivot)) {
144 480
                // skip partially selected entities (DB level filter)
145
                continue;
146 480
            }
147 480
148
            $pivotData[$entity] = $factory->make($this->pivotRole, $pivot, Node::MANAGED);
149 480
            $elements[] = $entity;
150 480
        }
151 480
        $collection = new PivotedStorage($elements, $pivotData);
152 480
        $node->setRelation($this->name, $collection);
153 480
154
        return $this->collect($collection);
155
    }
156
157 480
    public function cast(?array $data): array
158 480
    {
159
        if (!$data) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $data of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
160 24
            return [];
161
        }
162
        /**
163 480
         * @var array<non-empty-string, MapperInterface> $targetMappers Target Mappers cache
164 480
         * @var array<non-empty-string, MapperInterface> $pivotMappers Pivot Mappers cache
165
         */
166 480
        $pivotMappers = [];
167 480
        $targetMappers = [];
168
169 480
        foreach ($data as $key => $pivot) {
170
            if (isset($pivot['@'])) {
171
                $d = $pivot['@'];
172 632
                // break link
173
                unset($pivot['@']);
174 632
175 40
                $targetRole = $d[LoaderInterface::ROLE_KEY] ?? $this->target;
176
                $pivot['@'] = ($targetMappers[$targetRole] ??= $this->mapperProvider->getMapper($targetRole))->cast($d);
177 632
            }
178 632
            // break link
179
            unset($data[$key]);
180 632
181 632
            $pivotRole = $pivot[LoaderInterface::ROLE_KEY] ?? $this->pivotRole;
182 632
            $data[$key] = ($pivotMappers[$pivotRole] ??= $this->mapperProvider->getMapper($pivotRole))->cast($pivot);
183
        }
184 632
185 632
        return $data;
186
    }
187
188 632
    public function collect(mixed $data): iterable
189 632
    {
190
        return $this->factory->collection(
191
            $this->schema[Relation::COLLECTION_TYPE] ?? null,
192 632
        )->collect($data);
193
    }
194
195 648
    public function extract(?iterable $data): PivotedStorage
196
    {
197 648
        return match (true) {
198 648
            $data instanceof PivotedStorage => $data,
199 648
            $data instanceof PivotedCollectionInterface => new PivotedStorage(
200
                $data->toArray(),
0 ignored issues
show
Bug introduced by
The method toArray() 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

200
                $data->/** @scrutinizer ignore-call */ 
201
                       toArray(),

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...
201
                $data->getPivotContext(),
202 338
            ),
203
            $data instanceof \Doctrine\Common\Collections\Collection => new PivotedStorage($data->toArray()),
204
            $data === null => new PivotedStorage(),
205 338
            $data instanceof \Traversable => new PivotedStorage(\iterator_to_array($data)),
0 ignored issues
show
Bug introduced by
It seems like $data can also be of type null; however, parameter $iterator of iterator_to_array() does only seem to accept Traversable, maybe add an additional type check? ( Ignorable by Annotation )

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

205
            $data instanceof \Traversable => new PivotedStorage(\iterator_to_array(/** @scrutinizer ignore-type */ $data)),
Loading history...
206 338
            default => new PivotedStorage((array) $data),
207 240
        };
208 240
    }
209
210 258
    public function extractRelated(?iterable $data, PivotedStorage $original): PivotedStorage
211 258
    {
212 56
        $related = $this->extract($data);
213 338
        if (\count($original) === 0) {
214
            return $related;
215
        }
216
        // Merge pivots
217 338
        foreach ($related as $item) {
218
            if ($related->hasContext($item)) {
0 ignored issues
show
Bug introduced by
$item of type array is incompatible with the type object expected by parameter $entity of Cycle\ORM\Collection\Piv...edStorage::hasContext(). ( Ignorable by Annotation )

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

218
            if ($related->hasContext(/** @scrutinizer ignore-type */ $item)) {
Loading history...
219 338
                continue;
220 338
            }
221 322
            if ($original->hasContext($item)) {
222
                $related->set($item, $original->getContext()->offsetGet($item));
0 ignored issues
show
Bug introduced by
$item of type array is incompatible with the type object expected by parameter $entity of Cycle\ORM\Collection\Pivoted\PivotedStorage::set(). ( Ignorable by Annotation )

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

222
                $related->set(/** @scrutinizer ignore-type */ $item, $original->getContext()->offsetGet($item));
Loading history...
Bug introduced by
$item of type array is incompatible with the type object expected by parameter $object of SplObjectStorage::offsetGet(). ( Ignorable by Annotation )

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

222
                $related->set($item, $original->getContext()->offsetGet(/** @scrutinizer ignore-type */ $item));
Loading history...
223 32
            }
224 32
        }
225 32
        return $related;
226
    }
227
228 32
    public function initReference(Node $node): ReferenceInterface
229
    {
230
        $scope = [];
231 308
        $nodeData = $node->getData();
232
        foreach ($this->innerKeys as $key) {
233 308
            if (!isset($nodeData[$key])) {
234 308
                return new EmptyReference($node->getRole(), new PivotedStorage());
235 308
            }
236 308
            $scope[$key] = $nodeData[$key];
237 56
        }
238
239 268
        return new Reference($this->target, $scope);
240
    }
241
242 268
    public function resolve(ReferenceInterface $reference, bool $load): ?iterable
243
    {
244
        if ($reference->hasValue()) {
245 224
            return $reference->getValue();
246
        }
247 224
        if ($load === false) {
248 80
            return null;
249
        }
250 192
        $scope = $reference->getScope();
251 24
        if ($scope === []) {
252
            $result = new PivotedStorage();
253 168
            $reference->setValue($result);
254 168
            return $result;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $result returns the type Cycle\ORM\Collection\Pivoted\PivotedStorage which is incompatible with the type-hinted return iterable|null.
Loading history...
255
        }
256
257
        $source = $this->sourceProvider->getSource($this->target);
258
        // getting scoped query
259
        $query = (new RootLoader(
260
            $this->ormSchema,
261 168
            $this->sourceProvider,
262 168
            $this->factory,
263 168
            $this->target,
264 168
            loadRelations: false,
265 168
        ))->buildQuery();
266 168
267
        // responsible for all the scoping
268
        $loader = new ManyToManyLoader(
269 168
            $this->ormSchema,
270 168
            $this->sourceProvider,
271 168
            $this->factory,
272 168
            $source->getTable(),
273 168
            $this->target,
274 168
            $this->schema,
275 168
        );
276
277
        /** @var ManyToManyLoader $loader */
278
        $loader = $loader->withContext($loader, [
279 168
            'scope' => $source->getScope(),
280 168
            'as' => $this->target,
281 168
            'method' => JoinableLoader::POSTLOAD,
282
        ]);
283
284
        $query = $loader->configureQuery($query, [$scope]);
285 168
286
        // we are going to add pivot node into virtual root node (only ID) to aggregate the results
287
        $root = new RootNode(
288 168
            (array) $this->schema[Relation::INNER_KEY],
289 168
            (array) $this->schema[Relation::INNER_KEY],
290 168
        );
291
292
        $node = $loader->createNode();
293 168
        $root->linkNode('output', $node);
294 168
295
        // emulate presence of parent entity
296
        $root->parseRow(0, $this->mapperProvider->getMapper($this->role)->uncast($scope));
297 168
298
        $iterator = $query->getIterator();
299 168
        foreach ($iterator as $row) {
300 168
            $node->parseRow(0, $row);
301 168
        }
302
        $iterator->close();
303 168
304
        // load all eager relations, forbid loader to re-fetch data (make it think it was joined)
305
        $loader->withContext($loader, ['method' => JoinableLoader::INLOAD])->loadData($node);
306 168
307
        $elements = [];
308 168
        $pivotData = new \SplObjectStorage();
309 168
        $iterator = Iterator::createWithServices(
310 168
            $this->heap,
311 168
            $this->ormSchema,
312 168
            $this->entityFactory,
313 168
            $this->target,
314 168
            $root->getResult()[0]['output'],
315 168
            true,
316
            typecast: true,
317
        );
318
        foreach ($iterator as $pivot => $entity) {
319 168
            $pivotData[$entity] = $this->entityFactory->make(
320 168
                $this->schema[Relation::THROUGH_ENTITY],
321 168
                $pivot,
322
                Node::MANAGED,
323
                typecast: true,
324
            );
325
326
            $elements[] = $entity;
327 168
        }
328
        $result = new PivotedStorage($elements, $pivotData);
329 168
        $reference->setValue($result);
330 168
        return $result;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $result returns the type Cycle\ORM\Collection\Pivoted\PivotedStorage which is incompatible with the type-hinted return iterable|null.
Loading history...
331 168
    }
332
333
    protected function applyPivotChanges(State $parentState, State $state): void
334 336
    {
335
        foreach ($this->innerKeys as $i => $innerKey) {
336 336
            $state->register($this->throughInnerKeys[$i], $parentState->getValue($innerKey));
337 336
        }
338
    }
339
340
    protected function newLink(Pool $pool, Tuple $tuple, PivotedStorage $storage, object $related): void
341 72
    {
342
        $rTuple = $pool->attachStore($related, $this->isCascade());
343
        $this->assertValid($rTuple->node);
344 72
345 72
        $pivot = $storage->get($related);
346
        if (!\is_object($pivot)) {
347 72
            // first time initialization
348
            $pivot = $this->initPivot($tuple->entity, $storage, $rTuple, $pivot);
349
            $storage->set($related, $pivot);
0 ignored issues
show
Bug introduced by
It seems like $pivot can also be of type null; however, parameter $pivot of Cycle\ORM\Collection\Pivoted\PivotedStorage::set() does only seem to accept array|object, maybe add an additional type check? ( Ignorable by Annotation )

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

349
            $storage->set($related, /** @scrutinizer ignore-type */ $pivot);
Loading history...
350 336
        }
351
352 336
        $pTuple = $pool->attachStore($pivot, $this->isCascade());
0 ignored issues
show
Bug introduced by
It seems like $pivot can also be of type null; however, parameter $entity of Cycle\ORM\Transaction\Pool::attachStore() does only seem to accept object, maybe add an additional type check? ( Ignorable by Annotation )

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

352
        $pTuple = $pool->attachStore(/** @scrutinizer ignore-type */ $pivot, $this->isCascade());
Loading history...
353 336
        // $pRelationName = $tuple->node->getRole() . '.' . $this->getName() . ':' . $this->pivotEntity;
354
        // $pNode->setRelationStatus($pRelationName, RelationInterface::STATUS_RESOLVED);
355 336
356 336
        foreach ($this->throughInnerKeys as $i => $pInnerKey) {
357
            $pTuple->state->register($pInnerKey, $tuple->state->getTransactionData()[$this->innerKeys[$i]] ?? null);
358 312
        }
359 312
360
        if ($this->inversion === null) {
361
            // send the Pivot into child's State for the ShadowHasMany relation
362 336
            // $relName = $tuple->node->getRole() . '.' . $this->name . ':' . $this->target;
363
            $relName = $this->getTargetRelationName();
364
            $pivots = $rTuple->state->getRelations()[$relName] ?? [];
365
            $pivots[] = $pivot;
366 336
            $rTuple->state->setRelation($relName, $pivots);
367 336
        } else {
368
            $rTuple->state->addToStorage($this->inversion, $pTuple->state);
369
        }
370
    }
371
372 336
    /**
373
     * Since many to many relation can overlap from two directions we have to properly resolve the pivot entity upon
374
     * it's generation. This is achieved using temporary mapping associated with each of the entity states.
375 176
     */
376 176
    protected function initPivot(object $parent, PivotedStorage $storage, Tuple $rTuple, ?array $pivot): ?object
377 176
    {
378 176
        if ($this->inversion !== null) {
379
            $relatedStorage = $rTuple->state->getRelation($this->inversion);
380 160
            if ($relatedStorage instanceof PivotedStorage && $relatedStorage->hasContext($parent)) {
381
                return $relatedStorage->get($parent);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $relatedStorage->get($parent) could return the type array which is incompatible with the type-hinted return null|object. Consider adding an additional type-check to rule them out.
Loading history...
382
            }
383
        }
384
385
        $entity = $this->entityFactory->make($this->pivotRole, $pivot ?? []);
386
        $storage->set($rTuple->entity, $entity);
387
        return $entity;
388 312
    }
389
390 312
    private function deleteChild(Pool $pool, ?object $pivot, object $child, ?Node $relatedNode = null): void
0 ignored issues
show
Unused Code introduced by
The parameter $relatedNode is not used and could be removed. ( Ignorable by Annotation )

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

390
    private function deleteChild(Pool $pool, ?object $pivot, object $child, /** @scrutinizer ignore-unused */ ?Node $relatedNode = null): void

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
391 160
    {
392 160
        // todo: add supporting for nullable pivot entities?
393 96
        if ($pivot !== null) {
394
            $pool->attachDelete($pivot, $this->isCascade());
395
        }
396
        $pool->attachStore($child, true);
397 312
    }
398 312
399 312
    private function finalize(Pool $pool, Tuple $tuple, mixed $related): void
400
    {
401
        $tuple->state->setRelationStatus($this->getName(), RelationInterface::STATUS_RESOLVED);
402
403
        $relationName = $tuple->node->getRole() . '.' . $this->name . ':' . $this->pivotRole;
404
        $pStates = [];
405
        foreach ($related as $item) {
406
            $pivot = $related->get($item);
407
            if ($pivot !== null) {
408
                $pTuple = $pool->offsetGet($pivot);
409
                $this->applyPivotChanges($tuple->state, $pTuple->state);
410
                $pStates[] = $pTuple->state;
411
                $pTuple->state->setRelationStatus($relationName, RelationInterface::STATUS_RESOLVED);
412
            }
413
        }
414
        if ($this->inversion !== null) {
415
            $storage = $tuple->state->getStorage($this->name);
416
            foreach ($storage as $pState) {
417
                if (\in_array($pState, $pStates, true)) {
418
                    continue;
419
                }
420
                $this->applyPivotChanges($tuple->state, $pState);
421
                $pState->setRelationStatus($relationName, RelationInterface::STATUS_RESOLVED);
422
            }
423
            $tuple->state->clearStorage($this->name);
424
        }
425
    }
426
}
427