Relationship::addOne()   A
last analyzed

Complexity

Conditions 5
Paths 12

Size

Total Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 0
Metric Value
dl 0
loc 30
ccs 0
cts 22
cp 0
rs 9.1288
c 0
b 0
f 0
cc 5
nc 12
nop 2
crap 30
1
<?php
2
3
/**
4
 * @copyright  Copyright (c) Flipbox Digital Limited
5
 * @license    https://github.com/flipboxfactory/craft-element-lists/LICENSE
6
 * @link       https://github.com/flipboxfactory/craft-element-lists/
7
 */
8
9
namespace flipbox\craft\element\lists\relationships;
10
11
use Craft;
12
use craft\base\ElementInterface;
13
use craft\base\Field;
14
use craft\elements\db\ElementQuery;
15
use craft\elements\db\ElementQueryInterface;
16
use craft\helpers\ArrayHelper;
17
use flipbox\craft\element\lists\fields\RelationalInterface;
18
use flipbox\craft\element\lists\queries\AssociationQuery;
19
use flipbox\craft\element\lists\records\Association;
20
use flipbox\organizations\records\UserAssociation;
21
use Tightenco\Collect\Support\Collection;
22
use yii\base\BaseObject;
23
use yii\base\Exception;
24
use yii\base\UnknownPropertyException;
25
use yii\db\QueryInterface;
26
27
/**
28
 * Manages User Types associated to Organization/User associations
29
 *
30
 * @author Flipbox Factory <[email protected]>
31
 * @since 3.0.0
32
 */
33
class Relationship extends BaseObject implements RelationshipInterface
34
{
35
    /**
36
     * The element the relations are related to
37
     *
38
     * @var ElementInterface|null
39
     */
40
    private $element;
41
42
    /**
43
     * The field which accesses the relations
44
     *
45
     * @var RelationalInterface|Field
46
     */
47
    private $field;
48
49
    /**
50
     * @var Collection|null
51
     */
52
    private $relations;
53
54
    /**
55
     * @var bool
56
     */
57
    protected $mutated = false;
58
59
    /**
60
     * @param ElementInterface|null $element
61
     * @param RelationalInterface $field
62
     * @param array $config
63
     */
64
    public function __construct(RelationalInterface $field, ElementInterface $element = null, array $config = [])
65
    {
66
        $this->element = $element;
67
        $this->field = $field;
68
69
        parent::__construct($config);
70
    }
71
72
73
    /************************************************************
74
     * QUERY
75
     ************************************************************/
76
77
    /**
78
     * @param Association|ElementInterface|int|string $object
79
     * @return Association
80
     */
81
    public function findOrCreate($object): Association
82
    {
83
        if (null === ($association = $this->findOne($object))) {
84
            $association = $this->create($object);
85
        }
86
87
        return $association;
88
    }
89
90
    /**
91
     * @param Association|ElementInterface|int|string $object
92
     * @return Association
93
     * @throws Exception
94
     */
95
    public function findOrFail($object): Association
96
    {
97
        if (null === ($association = $this->findOne($object))) {
98
            throw new Exception("Relationship could not be found.");
99
        }
100
101
        return $association;
102
    }
103
104
    /**
105
     * @param Association|ElementInterface|int|string|null $object
106
     * @return Association|null
107
     */
108
    public function findOne($object = null)
109
    {
110
        if (null === ($key = $this->findKey($object))) {
111
            return null;
112
        }
113
114
        return $this->getRelationships()->get($key);
115
    }
116
117
    /************************************************************
118
     * COLLECTIONS
119
     ************************************************************/
120
121
    /**
122
     * @return Collection
123
     */
124
    public function getCollection(): Collection
125
    {
126
        if (null === $this->relations) {
127
            return Collection::make(
128
                $this->field->getQuery($this->element)
0 ignored issues
show
Bug introduced by
The method getQuery does only exist in flipbox\craft\element\li...lds\RelationalInterface, but not in craft\base\Field.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
129
                    ->anyStatus()
130
                    ->all()
131
            );
132
        };
133
134
        $ids = $this->getRelationships()->pluck('targetId')->all();
135
        if (empty($ids)) {
136
            return $this->getRelationships()->pluck('target');
137
        }
138
139
        return new Collection(
140
            $this->field->getQuery($this->element)
141
                ->id($ids)
142
                ->fixedOrder(true)
143
                ->anyStatus()
144
                ->all()
145
        );
146
    }
147
148
    /**
149
     * @inheritDoc
150
     */
151
    public function getRelationships(): Collection
152
    {
153
        if (null === $this->relations) {
154
            $this->relations = $this->existingRelationships();
155
        }
156
157
        return $this->relations;
158
    }
159
    
160
    /**
161
     * @return Collection
162
     */
163
    protected function existingRelationships()
164
    {
165
        return $this->createRelations(
166
            $this->associationQuery()->all()
167
        );
168
    }
169
170
171
    /************************************************************
172
     * QUERY
173
     ************************************************************/
174
175
    /**
176
     * @return ElementQueryInterface
177
     */
178
    public function getQuery(): ElementQueryInterface
179
    {
180
        return $this->field->getQuery($this->element);
0 ignored issues
show
Bug introduced by
The method getQuery does only exist in flipbox\craft\element\li...lds\RelationalInterface, but not in craft\base\Field.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
181
    }
182
183
    /************************************************************
184
     * ADD / REMOVE
185
     ************************************************************/
186
187
    /**
188
     * @inheritDoc
189
     */
190
    public function add($objects, array $attributes = []): RelationshipInterface
191
    {
192
        foreach ($this->objectArray($objects) as $object) {
193
            $this->addOne($object, $attributes);
194
        }
195
196
        return $this;
197
    }
198
199
    /**
200
     * @param $object
201
     * @param array $attributes
202
     * @return RelationshipInterface
203
     */
204
    protected function addOne($object, array $attributes = []): RelationshipInterface
205
    {
206
        $isNew = false;
207
208
        // Check if it's already linked
209
        if (null === ($association = $this->findOne($object))) {
210
            $association = $this->create($object);
211
            $isNew = true;
212
        }
213
214
        // Modify?
215
        if (!empty($attributes)) {
216
            Craft::configure(
217
                $association,
218
                $attributes
219
            );
220
221
            $this->mutated = true;
222
223
            if (!$isNew) {
224
                $this->updateCollection($this->relations, $association);
0 ignored issues
show
Bug introduced by
It seems like $this->relations can be null; however, updateCollection() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
225
            }
226
        }
227
228
        if ($isNew) {
229
            $this->addToRelations($association);
230
        }
231
232
        return $this;
233
    }
234
235
    /**
236
     * @inheritDoc
237
     */
238
    public function remove($objects): RelationshipInterface
239
    {
240
        foreach ($this->objectArray($objects) as $object) {
241
            if (null !== ($key = $this->findKey($object))) {
242
                $this->removeFromRelations($key);
243
            }
244
        }
245
246
        return $this;
247
    }
248
249
250
    /*******************************************
251
     * SAVE
252
     *******************************************/
253
254
    /**
255
     * @return bool
256
     */
257
    public function save(): bool
258
    {
259
        // No changes?
260
        if (!$this->isMutated()) {
261
            return true;
262
        }
263
264
        $success = true;
265
266
        list($save, $delete) = $this->delta();
267
268
        // Delete those removed
269
        foreach ($delete as $relationship) {
270
            if (!$relationship->delete()) {
271
                $success = false;
272
            }
273
        }
274
275
        foreach ($save as $relationship) {
276
            if (!$relationship->save()) {
277
                $success = false;
278
            }
279
        }
280
281
        $this->reset();
282
283
        if (!$success && $this->element) {
284
            $this->element->addError($this->field->handle, 'Unable to save relationship.');
285
        }
286
287
        return $success;
288
    }
289
290
291
    /************************************************************
292
     * UTILITIES
293
     ************************************************************/
294
295
    /**
296
     * @inheritDoc
297
     */
298
    public function clear(): RelationshipInterface
299
    {
300
        $this->newRelations([]);
301
        return $this;
302
    }
303
304
    /**
305
     * @inheritDoc
306
     */
307
    public function reset(): RelationshipInterface
308
    {
309
        $this->relations = null;
310
        $this->mutated = false;
311
        return $this;
312
    }
313
314
    /**
315
     * @inheritDoc
316
     */
317
    public function isMutated(): bool
318
    {
319
        return $this->mutated;
320
    }
321
322
    /**
323
     * @inheritDoc
324
     */
325
    public function exists($object): bool
326
    {
327
        return null !== $this->findKey($object);
328
    }
329
330
    /**
331
     * @inheritDoc
332
     */
333
    public function count()
334
    {
335
        return $this->getCollection()->count();
336
    }
337
338
339
    /************************************************************
340
     * DELTA
341
     ************************************************************/
342
343
    /**
344
     * @inheritDoc
345
     */
346
    protected function delta(): array
347
    {
348
        $existingAssociations = $this->associationQuery()
349
            ->indexBy('targetId')
350
            ->all();
351
352
        $associations = [];
353
        $order = 1;
354
355
        /** @var Association $newAssociation */
356
        foreach ($this->getRelationships() as $newAssociation) {
357
            $association = ArrayHelper::remove(
358
                $existingAssociations,
359
                $newAssociation->getTargetId()
360
            );
361
362
            $newAssociation->sortOrder = $order++;
363
364
            /** @var Association $association */
365
            $association = $association ?: $newAssociation;
366
367
            // Has anything changed?
368
            if (!$association->getIsNewRecord() && !$this->hasChanged($newAssociation, $association)) {
369
                continue;
370
            }
371
372
            $associations[] = $this->sync($association, $newAssociation);
373
        }
374
375
        return [$associations, $existingAssociations];
376
    }
377
378
    /**
379
     * @param Association $new
380
     * @param Association $existing
381
     * @return bool
382
     */
383
    private function hasChanged(Association $new, Association $existing): bool
384
    {
385
        return $this->field->ensureSortOrder() &&
0 ignored issues
show
Bug introduced by
The method ensureSortOrder does only exist in flipbox\craft\element\li...lds\RelationalInterface, but not in craft\base\Field.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
386
            $new->sortOrder != $existing->sortOrder;
387
    }
388
389
    /**
390
     * @param Association $from
391
     * @param Association $to
392
     *
393
     * @return Association
394
     */
395
    private function sync(Association $to, Association $from): Association
396
    {
397
        $to->sortOrder = $from->sortOrder;
398
399
        $to->ignoreSortOrder();
400
401
        return $to;
402
    }
403
404
405
    /*******************************************
406
     * RESOLVERS
407
     *******************************************/
408
409
    /**
410
     * Ensure we're working with an array of objects, not configs, etc
411
     *
412
     * @param array|QueryInterface|Collection|ElementInterface|Association $objects
413
     * @return array
414
     */
415
    protected function objectArray($objects): array
416
    {
417
        if ($objects instanceof QueryInterface || $objects instanceof Collection) {
418
            $objects = $objects->all();
419
        }
420
421
        // proper array
422
        if (!is_array($objects) || ArrayHelper::isAssociative($objects)) {
423
            $objects = [$objects];
424
        }
425
426
        return array_filter($objects);
427
    }
428
429
430
    /*******************************************
431
     * COLLECTION UTILS
432
     *******************************************/
433
434
    /**
435
     * @param Association[] $associations
436
     * @param bool $mutated
437
     * @return static
438
     */
439
    protected function newRelations(array $associations, bool $mutated = true): self
440
    {
441
        $this->relations = $this->createRelations($associations);
442
        $this->mutated = $mutated;
443
444
        return $this;
445
    }
446
447
    /**
448
     * @param Association $association
449
     * @return static
450
     */
451
    protected function addToRelations(Association $association): self
452
    {
453
        $this->insertCollection($this->getRelationships(), $association);
0 ignored issues
show
Bug introduced by
It seems like $this->getRelationships() targeting flipbox\craft\element\li...hip::getRelationships() can also be of type array<integer,object<fli...s\records\Association>>; however, flipbox\craft\element\li...hip::insertCollection() does only seem to accept object<Tightenco\Collect\Support\Collection>, maybe add an additional type check?

This check looks at variables that are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
454
        $this->mutated = true;
455
456
        return $this;
457
    }
458
459
    /**
460
     * @param int $key
461
     * @return static
462
     */
463
    protected function removeFromRelations(int $key): self
464
    {
465
        $this->relations->forget($key);
466
        $this->mutated = true;
467
468
        return $this;
469
    }
470
471
    /**
472
     * @param array $associations
473
     * @return Collection
474
     */
475
    protected function createRelations(array $associations = []): Collection
476
    {
477
        $collection = new Collection();
478
        foreach ($associations as $association) {
479
            $this->insertCollection($collection, $association);
480
        }
481
482
        return $collection;
483
    }
484
485
    /**
486
     * Position the relationship based on the sort order
487
     *
488
     * @inheritDoc
489
     */
490
    protected function insertCollection(Collection $collection, Association $association)
491
    {
492
        if ($this->field->ensureSortOrder() && $association->sortOrder > 0) {
0 ignored issues
show
Bug introduced by
The method ensureSortOrder does only exist in flipbox\craft\element\li...lds\RelationalInterface, but not in craft\base\Field.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
493
            $collection->splice($association->sortOrder - 1, 0, [$association]);
494
            return;
495
        }
496
497
        $collection->push($association);
498
    }
499
500
    /**
501
     * @inheritDoc
502
     */
503
    protected function updateCollection(Collection $collection, Association $association)
504
    {
505
        if (!$this->field->ensureSortOrder()) {
0 ignored issues
show
Bug introduced by
The method ensureSortOrder does only exist in flipbox\craft\element\li...lds\RelationalInterface, but not in craft\base\Field.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
506
            return;
507
        }
508
509
        if (null !== ($key = $this->findKey($association))) {
0 ignored issues
show
Documentation introduced by
$association is of type object<flipbox\craft\ele...ts\records\Association>, but the function expects a object<flipbox\organizat...ion>|integer|array|null.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
510
            $collection->offsetUnset($key);
511
        }
512
513
        $this->insertCollection($collection, $association);
514
    }
515
516
517
    /************************************************************
518
     * QUERY
519
     ************************************************************/
520
521
    /**
522
     * @return AssociationQuery
523
     */
524
    protected function associationQuery(): AssociationQuery
525
    {
526
        return Association::find()
527
            ->setSource($this->element->getId() ?: false)
528
            ->setField($this->field)
0 ignored issues
show
Bug introduced by
It seems like $this->field can also be of type object<flipbox\craft\ele...ds\RelationalInterface>; however, flipbox\craft\ember\quer...ributeTrait::setField() does only seem to accept string|array<integer,str...e\FieldInterface>>|null, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
529
            ->orderBy([
530
                'sortOrder' => SORT_ASC
531
            ])
532
            ->limit(null);
533
    }
534
535
536
    /*******************************************
537
     * CREATE
538
     *******************************************/
539
540
    /**
541
     * Create a new relationship object
542
     *
543
     * @param $object
544
     * @return Association
545
     */
546
    protected function create($object): Association
547
    {
548
        if ($object instanceof Association) {
549
            return $object;
550
        }
551
552
        $element = $this->resolveElement($object);
553
554
        return new Association([
555
            'fieldId' => $this->field->id,
556
            'sourceId' => $this->element ? $this->element->getId() : null,
557
            'targetId' => $element->getId()
558
        ]);
559
    }
560
561
562
    /*******************************************
563
     * UTILS
564
     *******************************************/
565
566
    /**
567
     * @param UserAssociation|int|array|null $object
568
     * @return int|null
569
     */
570
    protected function findKey($object = null)
571
    {
572
        if ($object instanceof Association) {
573
            return $this->findRelationshipKey($object->targetId);
574
        }
575
576
        if (null === ($element = $this->resolveElement($object))) {
577
            return null;
578
        }
579
580
        return $this->findRelationshipKey($element->getId());
581
    }
582
583
    /**
584
     * @param $identifier
585
     * @return int|string|null
586
     */
587
    private function findRelationshipKey($identifier)
588
    {
589
        if (null === $identifier) {
590
            return null;
591
        }
592
593
        /** @var Association $association */
594
        foreach ($this->getRelationships()->all() as $key => $association) {
595
            if ($association->targetId == $identifier) {
596
                return $key;
597
            }
598
        }
599
600
        return null;
601
    }
602
603
    /**
604
     * @param ElementInterface|Association|int|array|null $element
605
     * @return ElementInterface|null
606
     */
607
    protected function resolveElement($element = null)
608
    {
609
        if (null === $element) {
610
            return null;
611
        }
612
613
        if ($element instanceof ElementInterface) {
614
            return $element;
615
        }
616
617
        if ($element instanceof Association) {
618
            $element = $element->targetId;
619
        }
620
621
        if (is_array($element) &&
622
            null !== ($id = ArrayHelper::getValue($element, 'id'))
623
        ) {
624
            $element = $id;
625
        }
626
627
        return $this->field->resolveElement($element);
0 ignored issues
show
Bug introduced by
The method resolveElement does only exist in flipbox\craft\element\li...lds\RelationalInterface, but not in craft\base\Field.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
628
    }
629
630
631
    /*******************************************
632
     * MAGIC (pass calls onto query)
633
     *******************************************/
634
635
    /**
636
     * @param string $name
637
     * @return mixed
638
     */
639
    public function __get($name)
640
    {
641
        try {
642
            return parent::__get($name);
643
        } catch (UnknownPropertyException $e) {
644
            return $this->getQuery()->{$name};
645
        }
646
    }
647
648
    /**
649
     * @param string $name
650
     * @param mixed $value
651
     */
652
    public function __set($name, $value)
653
    {
654
        try {
655
            return parent::__set($name, $value);
656
        } catch (UnknownPropertyException $e) {
657
            return $this->getQuery()->{$name}($value);
658
        }
659
    }
660
661
    /**
662
     * @param string $name
663
     * @param array $params
664
     * @return mixed
665
     */
666
    public function __call($name, $params)
667
    {
668
        /** @var ElementQuery $query */
669
        $query = $this->getQuery();
670
        if ($query->hasMethod($name)) {
671
            return call_user_func_array([$query, $name], $params);
672
        }
673
674
        return parent::__call($name, $params);
675
    }
676
}
677