Completed
Push — 2.x ( 983411...a8d8e2 )
by Aleksei
24s queued 12s
created

ManyToMany   C

Complexity

Total Complexity 57

Size/Duplication

Total Lines 375
Duplicated Lines 0 %

Test Coverage

Coverage 96.02%

Importance

Changes 2
Bugs 0 Features 1
Metric Value
eloc 209
c 2
b 0
f 1
dl 0
loc 375
ccs 193
cts 201
cp 0.9602
rs 5.04
wmc 57

15 Methods

Rating   Name   Duplication   Size   Complexity  
C prepare() 0 45 12
A __construct() 0 11 1
A queue() 0 10 3
A cast() 0 21 4
A extract() 0 12 1
A init() 0 26 3
A collect() 0 5 1
A initReference() 0 12 3
A initPivot() 0 12 4
B resolve() 0 87 6
A deleteChild() 0 7 2
A extractRelated() 0 16 5
A finalize() 0 25 6
A newLink() 0 31 4
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\ORMInterface;
15
use Cycle\ORM\Parser\RootNode;
16
use Cycle\ORM\Reference\EmptyReference;
17
use Cycle\ORM\Reference\Reference;
18
use Cycle\ORM\Reference\ReferenceInterface;
19
use Cycle\ORM\Relation;
20
use Cycle\ORM\Select\JoinableLoader;
21
use Cycle\ORM\Select\Loader\ManyToManyLoader;
22
use Cycle\ORM\Select\RootLoader;
23
use Cycle\ORM\Service\EntityFactoryInterface;
24
use Cycle\ORM\Service\SourceProviderInterface;
25
use Cycle\ORM\Transaction\Pool;
26
use Cycle\ORM\Transaction\Tuple;
27
use SplObjectStorage;
28
use Traversable;
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
43
    protected EntityFactoryInterface $entityFactory;
44
    protected SourceProviderInterface $sourceProvider;
45
    protected FactoryInterface $factory;
46
    private HeapInterface $heap;
47
48 942
    public function __construct(ORMInterface $orm, string $role, string $name, string $target, array $schema)
49
    {
50 942
        parent::__construct($orm, $role, $name, $target, $schema);
51 942
        $this->heap = $orm->getHeap();
52 942
        $this->sourceProvider = $orm->getService(SourceProviderInterface::class);
53 942
        $this->entityFactory = $orm->getService(EntityFactoryInterface::class);
54 942
        $this->factory = $orm->getFactory();
55 942
        $this->pivotRole = $this->schema[Relation::THROUGH_ENTITY];
56
57 942
        $this->throughInnerKeys = (array)$this->schema[Relation::THROUGH_INNER_KEY];
58 942
        $this->throughOuterKeys = (array)$this->schema[Relation::THROUGH_OUTER_KEY];
59
    }
60
61 346
    public function prepare(Pool $pool, Tuple $tuple, mixed $related, bool $load = true): void
62
    {
63 346
        $node = $tuple->node;
64
65
        /** @var PivotedStorage|ReferenceInterface|null $original */
66 346
        $original = $node->getRelation($this->getName());
67 346
        $tuple->state->setRelation($this->getName(), $related);
68
69 346
        if ($original instanceof ReferenceInterface) {
70 72
            if (!$load && $related === $original && !$original->hasValue()) {
71 40
                $this->finalize($pool, $tuple, $related);
72 40
                return;
73
            }
74 56
            $this->resolve($original, true);
75 56
            $original = $original->getValue();
76 56
            $node->setRelation($this->getName(), $original);
77
        }
78 338
        $original = $this->extract($original);
79
80 338
        if ($related instanceof ReferenceInterface && $this->resolve($related, true) !== null) {
81
            $related = $related->getValue();
82
            $tuple->state->setRelation($this->getName(), $related);
83
        }
84 338
        $related = $this->extractRelated($related, $original);
85
        // $tuple->state->setStorage($this->pivotEntity, $related);
86 338
        $tuple->state->setRelation($this->getName(), $related);
87
88
        // un-link old elements
89 338
        foreach ($original as $item) {
90 192
            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

90
            if (!$related->has(/** @scrutinizer ignore-type */ $item)) {
Loading history...
91 72
                $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

91
                $pivot = $original->get(/** @scrutinizer ignore-type */ $item);
Loading history...
92 72
                $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

92
                $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

92
                $this->deleteChild($pool, /** @scrutinizer ignore-type */ $pivot, $item);
Loading history...
93 72
                $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

93
                $original->getContext()->offsetUnset(/** @scrutinizer ignore-type */ $item);
Loading history...
94
            }
95
        }
96
97 338
        if ($this->inversion === null && \count($related) === 0) {
98
            $tuple->state->setRelationStatus($this->getName(), RelationInterface::STATUS_RESOLVED);
99
            return;
100
        }
101 338
        $tuple->state->setRelationStatus($this->getName(), RelationInterface::STATUS_PROCESS);
102
103
        // link/sync new and existed elements
104 338
        foreach ($related->getElements() as $item) {
105 336
            $this->newLink($pool, $tuple, $related, $item);
106
        }
107
    }
108
109 338
    public function queue(Pool $pool, Tuple $tuple): void
110
    {
111 338
        $related = $tuple->state->getRelation($this->getName());
112
113 338
        if ($related instanceof ReferenceInterface && !$related->hasValue()) {
114
            $tuple->state->setRelationStatus($this->getName(), RelationInterface::STATUS_RESOLVED);
115 338
            return;
116
        }
117
118
        $this->finalize($pool, $tuple, $related);
119
    }
120 338
121 338
    public function init(EntityFactoryInterface $factory, Node $node, array $data): iterable
122 338
    {
123 336
        $elements = [];
124 336
        $pivotData = new SplObjectStorage();
125 336
126 336
        $iterator = Iterator::createWithServices(
127 336
            $this->heap,
128 336
            $this->ormSchema,
129
            $this->entityFactory,
130
            $this->target,
131 338
            $data,
132 162
            true
133 162
        );
134 160
        foreach ($iterator as $pivot => $entity) {
135 96
            if (!\is_array($pivot)) {
136
                // skip partially selected entities (DB level filter)
137 64
                continue;
138 64
            }
139
140 162
            $pivotData[$entity] = $factory->make($this->pivotRole, $pivot, Node::MANAGED);
141
            $elements[] = $entity;
142
        }
143
        $collection = new PivotedStorage($elements, $pivotData);
144 480
        $node->setRelation($this->name, $collection);
145
146 480
        return $this->collect($collection);
147 480
    }
148
149 480
    public function cast(?array $data): array
150 480
    {
151 480
        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...
152 480
            return [];
153 480
        }
154
        $pivotMapper = $this->mapperProvider->getMapper($this->pivotRole);
155
        $targetMapper = $this->mapperProvider->getMapper($this->target);
156
157 480
        foreach ($data as $key => $pivot) {
158 480
            if (isset($pivot['@'])) {
159
                $d = $pivot['@'];
160 24
                // break link
161
                unset($pivot['@']);
162
                $pivot['@'] = $targetMapper->cast($d);
163 480
            }
164 480
            // break link
165
            unset($data[$key]);
166 480
            $data[$key] = $pivotMapper->cast($pivot);
167 480
        }
168
169 480
        return $data;
170
    }
171
172 632
    public function collect(mixed $data): iterable
173
    {
174 632
        return $this->factory->collection(
175 40
            $this->schema[Relation::COLLECTION_TYPE] ?? null
176
        )->collect($data);
177 632
    }
178 632
179
    public function extract(?iterable $data): PivotedStorage
180 632
    {
181 632
        return match (true) {
182 632
            $data instanceof PivotedStorage => $data,
183
            $data instanceof PivotedCollectionInterface => new PivotedStorage(
184 632
                $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

184
                $data->/** @scrutinizer ignore-call */ 
185
                       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...
185 632
                $data->getPivotContext()
186
            ),
187
            $data instanceof \Doctrine\Common\Collections\Collection => new PivotedStorage($data->toArray()),
188 632
            $data === null => new PivotedStorage(),
189 632
            $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

189
            $data instanceof Traversable => new PivotedStorage(iterator_to_array(/** @scrutinizer ignore-type */ $data)),
Loading history...
190
            default => new PivotedStorage((array)$data),
191
        };
192 632
    }
193
194
    public function extractRelated(?iterable $data, PivotedStorage $original): PivotedStorage
195 648
    {
196
        $related = $this->extract($data);
197 648
        if (\count($original) === 0) {
198 648
            return $related;
199 648
        }
200
        // Merge pivots
201
        foreach ($related as $item) {
202 338
            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

202
            if ($related->hasContext(/** @scrutinizer ignore-type */ $item)) {
Loading history...
203
                continue;
204
            }
205 338
            if ($original->hasContext($item)) {
206 338
                $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 $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

206
                $related->set($item, $original->getContext()->offsetGet(/** @scrutinizer ignore-type */ $item));
Loading history...
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

206
                $related->set(/** @scrutinizer ignore-type */ $item, $original->getContext()->offsetGet($item));
Loading history...
207 240
            }
208 240
        }
209
        return $related;
210 258
    }
211 258
212 56
    public function initReference(Node $node): ReferenceInterface
213 338
    {
214
        $scope = [];
215
        $nodeData = $node->getData();
216
        foreach ($this->innerKeys as $key) {
217 338
            if (!isset($nodeData[$key])) {
218
                return new EmptyReference($node->getRole(), new PivotedStorage());
219 338
            }
220 338
            $scope[$key] = $nodeData[$key];
221 322
        }
222
223 32
        return new Reference($this->target, $scope);
224 32
    }
225 32
226
    public function resolve(ReferenceInterface $reference, bool $load): ?iterable
227
    {
228 32
        if ($reference->hasValue()) {
229
            return $reference->getValue();
230
        }
231 308
        if ($load === false) {
232
            return null;
233 308
        }
234 308
        $scope = $reference->getScope();
235 308
        if ($scope === []) {
236 308
            $result = new PivotedStorage();
237 56
            $reference->setValue($result);
238
            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...
239 268
        }
240
241
        // getting scoped query
242 268
        $query = (new RootLoader(
243
            $this->ormSchema,
244
            $this->sourceProvider,
245 224
            $this->factory,
246
            $this->target
247 224
        ))->buildQuery();
248 80
249
        // responsible for all the scoping
250 192
        $loader = new ManyToManyLoader(
251 24
            $this->ormSchema,
252
            $this->sourceProvider,
253 168
            $this->factory,
254 168
            $this->sourceProvider->getSource($this->target)->getTable(),
255
            $this->target,
256
            $this->schema
257
        );
258
259
        /** @var ManyToManyLoader $loader */
260
        $loader = $loader->withContext($loader, [
261 168
            'scope' => $this->sourceProvider->getSource($this->target)->getScope(),
262 168
            'as' => $this->target,
263 168
            'method' => JoinableLoader::POSTLOAD,
264 168
        ]);
265 168
266 168
        $query = $loader->configureQuery($query, [$scope]);
267
268
        // we are going to add pivot node into virtual root node (only ID) to aggregate the results
269 168
        $root = new RootNode(
270 168
            (array)$this->schema[Relation::INNER_KEY],
271 168
            (array)$this->schema[Relation::INNER_KEY]
272 168
        );
273 168
274 168
        $node = $loader->createNode();
275 168
        $root->linkNode('output', $node);
276
277
        // emulate presence of parent entity
278
        $root->parseRow(0, $scope);
279 168
280 168
        $iterator = $query->getIterator();
281 168
        foreach ($iterator as $row) {
282
            $node->parseRow(0, $row);
283
        }
284
        $iterator->close();
285 168
286
        // load all eager relations, forbid loader to re-fetch data (make it think it was joined)
287
        $loader->withContext($loader, ['method' => JoinableLoader::INLOAD])->loadData($node);
288 168
289 168
        $elements = [];
290 168
        $pivotData = new SplObjectStorage();
291
        $iterator = Iterator::createWithServices(
292
            $this->heap,
293 168
            $this->ormSchema,
294 168
            $this->entityFactory,
295
            $this->target,
296
            $root->getResult()[0]['output'],
297 168
            true,
298
            typecast: true
299 168
        );
300 168
        foreach ($iterator as $pivot => $entity) {
301 168
            $pivotData[$entity] = $this->entityFactory->make(
302
                $this->schema[Relation::THROUGH_ENTITY],
303 168
                $pivot,
304
                Node::MANAGED,
305
                typecast: true
306 168
            );
307
308 168
            $elements[] = $entity;
309 168
        }
310 168
        $result = new PivotedStorage($elements, $pivotData);
311 168
        $reference->setValue($result);
312 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...
313 168
    }
314 168
315 168
    protected function applyPivotChanges(State $parentState, State $state): void
316
    {
317
        foreach ($this->innerKeys as $i => $innerKey) {
318
            $state->register($this->throughInnerKeys[$i], $parentState->getValue($innerKey));
319 168
        }
320 168
    }
321 168
322
    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

322
    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...
323
    {
324
        // todo: add supporting for nullable pivot entities?
325
        if ($pivot !== null) {
326
            $pool->attachDelete($pivot, $this->isCascade());
327 168
        }
328
        $pool->attachStore($child, true);
329 168
    }
330 168
331 168
    protected function newLink(Pool $pool, Tuple $tuple, PivotedStorage $storage, object $related): void
332
    {
333
        $rTuple = $pool->attachStore($related, $this->isCascade());
334 336
        $this->assertValid($rTuple->node);
335
336 336
        $pivot = $storage->get($related);
337 336
        if (!\is_object($pivot)) {
338
            // first time initialization
339
            $pivot = $this->initPivot($tuple->entity, $storage, $rTuple, $pivot);
340
            $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

340
            $storage->set($related, /** @scrutinizer ignore-type */ $pivot);
Loading history...
341 72
        }
342
343
        $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

343
        $pTuple = $pool->attachStore(/** @scrutinizer ignore-type */ $pivot, $this->isCascade());
Loading history...
344 72
        // $pRelationName = $tuple->node->getRole() . '.' . $this->getName() . ':' . $this->pivotEntity;
345 72
        // $pNode->setRelationStatus($pRelationName, RelationInterface::STATUS_RESOLVED);
346
347 72
        foreach ($this->throughInnerKeys as $i => $pInnerKey) {
348
            $pTuple->state->register($pInnerKey, $tuple->state->getTransactionData()[$this->innerKeys[$i]] ?? null);
349
350 336
            // $rState->forward($this->outerKeys[$i], $pState, $this->throughOuterKeys[$i]);
351
        }
352 336
353 336
        if ($this->inversion === null) {
354
            // send the Pivot into child's State for the ShadowHasMany relation
355 336
            // $relName = $tuple->node->getRole() . '.' . $this->name . ':' . $this->target;
356 336
            $relName = $this->getTargetRelationName();
357
            $pivots = $rTuple->state->getRelations()[$relName] ?? [];
358 312
            $pivots[] = $pivot;
359 312
            $rTuple->state->setRelation($relName, $pivots);
360
        } else {
361
            $rTuple->state->addToStorage($this->inversion, $pTuple->state);
362 336
        }
363
    }
364
365
    /**
366 336
     * Since many to many relation can overlap from two directions we have to properly resolve the pivot entity upon
367 336
     * it's generation. This is achieved using temporary mapping associated with each of the entity states.
368
     */
369
    protected function initPivot(object $parent, PivotedStorage $storage, Tuple $rTuple, ?array $pivot): ?object
370
    {
371
        if ($this->inversion !== null) {
372 336
            $relatedStorage = $rTuple->state->getRelation($this->inversion);
373
            if ($relatedStorage instanceof PivotedStorage && $relatedStorage->hasContext($parent)) {
374
                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...
375 176
            }
376 176
        }
377 176
378 176
        $entity = $this->entityFactory->make($this->pivotRole, $pivot ?? []);
379
        $storage->set($rTuple->entity, $entity);
380 160
        return $entity;
381
    }
382
383
    private function finalize(Pool $pool, Tuple $tuple, mixed $related): void
384
    {
385
        $tuple->state->setRelationStatus($this->getName(), RelationInterface::STATUS_RESOLVED);
386
387
        $relationName = $tuple->node->getRole() . '.' . $this->name . ':' . $this->pivotRole;
388 312
        $pStates = [];
389
        foreach ($related as $item) {
390 312
            $pivot = $related->get($item);
391 160
            if ($pivot !== null) {
392 160
                $pTuple = $pool->offsetGet($pivot);
393 96
                $this->applyPivotChanges($tuple->state, $pTuple->state);
394
                $pStates[] = $pTuple->state;
395
                $pTuple->state->setRelationStatus($relationName, RelationInterface::STATUS_RESOLVED);
396
            }
397 312
        }
398 312
        if ($this->inversion !== null) {
399 312
            $storage = $tuple->state->getStorage($this->name);
400
            foreach ($storage as $pState) {
401
                if (\in_array($pState, $pStates, true)) {
402
                    continue;
403
                }
404
                $this->applyPivotChanges($tuple->state, $pState);
405
                $pState->setRelationStatus($relationName, RelationInterface::STATUS_RESOLVED);
406
            }
407
            $tuple->state->clearStorage($this->name);
408
        }
409
    }
410
}
411