Completed
Push — 2.x ( d40bdd...37afce )
by Aleksei
15s queued 13s
created

ManyToMany   D

Complexity

Total Complexity 58

Size/Duplication

Total Lines 371
Duplicated Lines 0 %

Test Coverage

Coverage 96.02%

Importance

Changes 2
Bugs 0 Features 1
Metric Value
eloc 207
dl 0
loc 371
ccs 193
cts 201
cp 0.9602
rs 4.5599
c 2
b 0
f 1
wmc 58

15 Methods

Rating   Name   Duplication   Size   Complexity  
C prepare() 0 45 12
A __construct() 0 11 1
A initReference() 0 12 3
A queue() 0 10 3
A cast() 0 21 4
A initPivot() 0 12 4
B resolve() 0 87 6
A deleteChild() 0 7 2
A extract() 0 12 1
A extractRelated() 0 12 6
A init() 0 26 3
A finalize() 0 25 6
A newLink() 0 31 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\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 ($data instanceof PivotedStorage || $data instanceof PivotedCollectionInterface || \count($original) === 0) {
198 648
            return $related;
199 648
        }
200
        foreach ($related as $item) {
201
            if ($original->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

201
            if ($original->hasContext(/** @scrutinizer ignore-type */ $item)) {
Loading history...
202 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

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

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

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

336
            $storage->set($related, /** @scrutinizer ignore-type */ $pivot);
Loading history...
337 336
        }
338
339
        $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

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