Completed
Push — develop ( 914847...b47c28 )
by Nate
02:30
created

Relationship::addOne()   A

Complexity

Conditions 5
Paths 12

Size

Total Lines 31

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 0
Metric Value
dl 0
loc 31
ccs 0
cts 23
cp 0
rs 9.1128
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\helpers\ArrayHelper;
16
use flipbox\craft\element\lists\fields\RelationalInterface;
17
use flipbox\craft\element\lists\queries\AssociationQuery;
18
use flipbox\craft\element\lists\records\Association;
19
use flipbox\organizations\records\UserAssociation;
20
use Tightenco\Collect\Support\Collection;
21
use yii\base\BaseObject;
22
use yii\base\Exception;
23
use yii\base\UnknownPropertyException;
24
use yii\db\QueryInterface;
25
26
/**
27
 * Manages User Types associated to Organization/User associations
28
 *
29
 * @author Flipbox Factory <[email protected]>
30
 * @since 3.0.0
31
 */
32
class Relationship extends BaseObject implements RelationshipInterface
33
{
34
    /**
35
     * The element the relations are related to
36
     *
37
     * @var ElementInterface|null
38
     */
39
    private $element;
40
41
    /**
42
     * The field which accesses the relations
43
     *
44
     * @var RelationalInterface|Field
45
     */
46
    private $field;
47
48
    /**
49
     * @var Collection|null
50
     */
51
    private $relations;
52
53
    /**
54
     * @var bool
55
     */
56
    protected $mutated = false;
57
58
    /**
59
     * @param ElementInterface|null $element
60
     * @param RelationalInterface $field
61
     * @param array $config
62
     */
63
    public function __construct(RelationalInterface $field, ElementInterface $element = null, array $config = [])
64
    {
65
        $this->element = $element;
66
        $this->field = $field;
67
68
        parent::__construct($config);
69
    }
70
71
72
    /************************************************************
73
     * QUERY
74
     ************************************************************/
75
76
    /**
77
     * @param Association|ElementInterface|int|string $object
78
     * @return Association
79
     */
80
    public function findOrCreate($object): Association
81
    {
82
        if (null === ($association = $this->findOne($object))) {
83
            $association = $this->create($object);
84
        }
85
86
        return $association;
87
    }
88
89
    /**
90
     * @param Association|ElementInterface|int|string $object
91
     * @return Association
92
     * @throws Exception
93
     */
94
    public function findOrFail($object): Association
95
    {
96
        if (null === ($association = $this->findOne($object))) {
97
            throw new Exception("Relationship could not be found.");
98
        }
99
100
        return $association;
101
    }
102
103
    /**
104
     * @param Association|ElementInterface|int|string|null $object
105
     * @return Association|null
106
     */
107
    public function findOne($object = null)
108
    {
109
        if (null === ($key = $this->findKey($object))) {
110
            return null;
111
        }
112
113
        return $this->getRelationships()->get($key);
114
    }
115
116
    /************************************************************
117
     * COLLECTIONS
118
     ************************************************************/
119
120
    /**
121
     * @return Collection
122
     */
123
    public function getCollection(): Collection
124
    {
125
        if (null === $this->relations) {
126
            return Collection::make(
127
                $this->field->getQuery($this->element)->all()
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...
128
            );
129
        };
130
131
        return $this->getRelationships()
132
            ->pluck('target');
133
    }
134
135
    /**
136
     * @inheritDoc
137
     */
138
    public function getRelationships(): Collection
139
    {
140
        if (null === $this->relations) {
141
            $this->relations = $this->existingRelationships();
142
        }
143
144
        return $this->relations;
145
    }
146
147
    /**
148
     * @return Collection
149
     */
150
    protected function existingRelationships()
151
    {
152
        $relationships = $this->associationQuery()
153
            ->all();
154
155
        // 'eager' load where we'll pre-populate target associations
156
        $elements = $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...
157
            ->indexBy('id')
158
            ->all();
159
160
        return $this->createRelations($relationships)
161
            ->transform(function (Association $association) use ($elements) {
162
                if (isset($elements[$association->getTargetId()])) {
163
                    $association->setTarget($elements[$association->getTargetId()]);
164
                }
165
166
                $association->setSource($this->element);
167
                $association->setField($this->field);
168
169
                return $association;
170
            });
171
    }
172
173
    /************************************************************
174
     * ADD / REMOVE
175
     ************************************************************/
176
177
    /**
178
     * @inheritDoc
179
     */
180
    public function add($objects, array $attributes = []): RelationshipInterface
181
    {
182
        foreach ($this->objectArray($objects) as $object) {
183
            $this->addOne($object, $attributes);
184
        }
185
186
        return $this;
187
    }
188
189
    /**
190
     * @param $object
191
     * @param array $attributes
192
     * @return RelationshipInterface
193
     */
194
    protected function addOne($object, array $attributes = []): RelationshipInterface
195
    {
196
        $isNew = false;
197
198
        // Check if it's already linked
199
        if (null === ($association = $this->findOne($object))) {
200
            $association = $this->create($object);
201
            $isNew = true;
202
        }
203
204
        // Modify?
205
        if (!empty($attributes)) {
206
            Craft::configure(
207
                $association,
208
                $attributes
209
            );
210
211
            $this->mutated = true;
212
213
            if (!$isNew) {
214
                $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...
215
            }
216
        }
217
218
        if ($isNew) {
219
            $this->addToRelations($association);
220
            $this->mutated = true;
221
        }
222
223
        return $this;
224
    }
225
226
    /**
227
     * @inheritDoc
228
     */
229
    public function remove($objects): RelationshipInterface
230
    {
231
        foreach ($this->objectArray($objects) as $object) {
232
            if (null !== ($key = $this->findKey($object))) {
233
                $this->removeFromRelations($key);
234
            }
235
        }
236
237
        return $this;
238
    }
239
240
241
    /*******************************************
242
     * SAVE
243
     *******************************************/
244
245
    /**
246
     * @return bool
247
     */
248
    public function save(): bool
249
    {
250
        // No changes?
251
        if (!$this->isMutated()) {
252
            return true;
253
        }
254
255
        $success = true;
256
257
        list($save, $delete) = $this->delta();
258
259
        // Delete those removed
260
        foreach ($delete as $relationship) {
261
            if (!$relationship->delete()) {
262
                $success = false;
263
            }
264
        }
265
266
        foreach ($save as $relationship) {
267
            if (!$relationship->save()) {
268
                $success = false;
269
            }
270
        }
271
272
        $this->mutated = false;
273
274
        if (!$success && $this->element) {
275
            $this->element->addError($this->field->handle, 'Unable to save relationship.');
276
        }
277
278
        return $success;
279
    }
280
281
282
    /************************************************************
283
     * UTILITIES
284
     ************************************************************/
285
286
    /**
287
     * @inheritDoc
288
     */
289
    public function clear(): RelationshipInterface
290
    {
291
        $this->newRelations([]);
292
        return $this;
293
    }
294
295
    /**
296
     * @inheritDoc
297
     */
298
    public function reset(): RelationshipInterface
299
    {
300
        $this->relations = null;
301
        $this->mutated = false;
302
        return $this;
303
    }
304
305
    /**
306
     * @inheritDoc
307
     */
308
    public function isMutated(): bool
309
    {
310
        return $this->mutated;
311
    }
312
313
    /**
314
     * @inheritDoc
315
     */
316
    public function exists($object): bool
317
    {
318
        return null !== $this->findKey($object);
319
    }
320
321
    /**
322
     * @inheritDoc
323
     */
324
    public function count()
325
    {
326
        return $this->getCollection()->count();
327
    }
328
329
330
    /************************************************************
331
     * DELTA
332
     ************************************************************/
333
334
    /**
335
     * @inheritDoc
336
     */
337
    protected function delta(): array
338
    {
339
        $existingAssociations = $this->associationQuery()
340
            ->indexBy('targetId')
341
            ->all();
342
343
        $associations = [];
344
        $order = 1;
345
346
        /** @var Association $newAssociation */
347
        foreach ($this->getRelationships()->sortBy('sortOrder') as $newAssociation) {
348
            $association = ArrayHelper::remove(
349
                $existingAssociations,
350
                $newAssociation->getTargetId()
351
            );
352
353
            $newAssociation->sortOrder = $order++;
354
355
            /** @var Association $association */
356
            $association = $association ?: $newAssociation;
357
358
            // Has anything changed?
359
            if (!$association->getIsNewRecord() && !$this->hasChanged($newAssociation, $association)) {
360
                var_dump("SKIP:" . $association->targetId);
0 ignored issues
show
Security Debugging Code introduced by
var_dump('SKIP:' . $association->targetId); looks like debug code. Are you sure you do not want to remove it? This might expose sensitive data.
Loading history...
361
                continue;
362
            }
363
364
            var_dump("UPDATE:" . $association->targetId);
365
366
            $associations[] = $this->sync($association, $newAssociation);
367
        }
368
369
        return [$associations, $existingAssociations];
370
    }
371
372
    /**
373
     * @param Association $new
374
     * @param Association $existing
375
     * @return bool
376
     */
377
    private function hasChanged(Association $new, Association $existing): bool
378
    {
379
        return $new->sortOrder != $existing->sortOrder;
380
    }
381
382
    /**
383
     * @param Association $from
384
     * @param Association $to
385
     *
386
     * @return Association
387
     */
388
    private function sync(Association $to, Association $from): Association
389
    {
390
        $to->sourceId = $from->sourceId;
391
        $to->targetId = $from->targetId;
392
        $to->fieldId = $from->fieldId;
393
        $to->sourceSiteId = $from->sourceSiteId;
394
        $to->sortOrder = $from->sortOrder;
395
396
        $to->ignoreSortOrder();
0 ignored issues
show
Documentation Bug introduced by
The method ignoreSortOrder does not exist on object<flipbox\craft\ele...ts\records\Association>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
397
398
        return $to;
399
    }
400
401
402
    /*******************************************
403
     * RESOLVERS
404
     *******************************************/
405
406
    /**
407
     * Ensure we're working with an array of objects, not configs, etc
408
     *
409
     * @param array|QueryInterface|Collection|ElementInterface|Association $objects
410
     * @return array
411
     */
412
    protected function objectArray($objects): array
413
    {
414
        if ($objects instanceof QueryInterface || $objects instanceof Collection) {
415
            $objects = $objects->all();
416
        }
417
418
        // proper array
419
        if (!is_array($objects) || ArrayHelper::isAssociative($objects)) {
420
            $objects = [$objects];
421
        }
422
423
        return array_filter($objects);
424
    }
425
426
427
    /*******************************************
428
     * COLLECTION UTILS
429
     *******************************************/
430
431
    /**
432
     * @param Association[] $associations
433
     * @param bool $mutated
434
     * @return static
435
     */
436
    protected function newRelations(array $associations, bool $mutated = true): self
437
    {
438
        $this->relations = $this->createRelations($associations);
439
        $this->mutated = $mutated;
440
441
        return $this;
442
    }
443
444
    /**
445
     * @param Association $association
446
     * @return static
447
     */
448
    protected function addToRelations(Association $association): self
449
    {
450
        if (null === $this->relations) {
451
            return $this->newRelations([$association], true);
452
        }
453
454
        $this->relations->push($association);
455
        $this->mutated = true;
456
457
        return $this;
458
    }
459
460
    /**
461
     * @param int $key
462
     * @return static
463
     */
464
    protected function removeFromRelations(int $key): self
465
    {
466
        $this->relations->forget($key);
467
        $this->mutated = true;
468
469
        return $this;
470
    }
471
472
    /**
473
     * @param array $associations
474
     * @return Collection
475
     */
476
    protected function createRelations(array $associations = []): Collection
477
    {
478
        $collection = new Collection();
479
        foreach ($associations as $association) {
480
            $this->insertCollection($collection, $association);
481
        }
482
483
        return $collection;
484
    }
485
486
    /**
487
     * Position the relationship based on the sort order
488
     *
489
     * @inheritDoc
490
     */
491
    protected function insertCollection(Collection $collection, Association $association)
492
    {
493
        if ($association->sortOrder > 0) {
494
            $collection->splice($association->sortOrder - 1, 0, [$association]);
495
            return;
496
        }
497
498
        $collection->push($association);
499
    }
500
501
    /**
502
     * @inheritDoc
503
     */
504
    protected function updateCollection(Collection $collection, Association $association)
505
    {
506
        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...
507
            $collection->offsetUnset($key);
508
        }
509
510
        $this->insertCollection($collection, $association);
511
    }
512
513
514
    /************************************************************
515
     * QUERY
516
     ************************************************************/
517
518
    /**
519
     * @return AssociationQuery
520
     */
521
    protected function associationQuery(): AssociationQuery
522
    {
523
        return Association::find()
524
            ->setSource($this->element->getId() ?: false)
525
            ->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...
526
            ->orderBy([
527
                'sortOrder' => SORT_ASC
528
            ])
529
            ->limit(null);
530
    }
531
532
533
    /*******************************************
534
     * CREATE
535
     *******************************************/
536
537
    /**
538
     * Create a new relationship object
539
     *
540
     * @param $object
541
     * @return Association
542
     */
543
    protected function create($object): Association
544
    {
545
        if ($object instanceof Association) {
546
            return $object;
547
        }
548
549
        $element = $this->resolveElement($object);
550
551
        return new Association([
552
            'fieldId' => $this->field->id,
553
            'sourceId' => $this->element ? $this->element->getId() : null,
554
            'targetId' => $element->getId()
555
        ]);
556
    }
557
558
559
    /*******************************************
560
     * UTILS
561
     *******************************************/
562
563
    /**
564
     * @param UserAssociation|int|array|null $object
565
     * @return int|null
566
     */
567
    protected function findKey($object = null)
568
    {
569
        if ($object instanceof Association) {
570
            return $this->findRelationshipKey($object->targetId);
571
        }
572
573
        if (null === ($element = $this->resolveElement($object))) {
574
            return null;
575
        }
576
577
        return $this->findRelationshipKey($element->getId());
578
    }
579
580
    /**
581
     * @param $identifier
582
     * @return int|string|null
583
     */
584
    private function findRelationshipKey($identifier)
585
    {
586
        /** @var Association $association */
587
        foreach ($this->getRelationships()->all() as $key => $association) {
588
            if ($association->targetId == $identifier) {
589
                return $key;
590
            }
591
        }
592
593
        return null;
594
    }
595
596
    /**
597
     * @param ElementInterface|Association|int|array|null $element
598
     * @return ElementInterface|null
599
     */
600
    protected function resolveElement($element = null)
601
    {
602
        if (null === $element) {
603
            return null;
604
        }
605
606
        if ($element instanceof ElementInterface) {
607
            return $element;
608
        }
609
610
        if ($element instanceof Association) {
611
            $element = $element->targetId;
612
        }
613
614
        if (is_array($element) &&
615
            null !== ($id = ArrayHelper::getValue($element, 'id'))
616
        ) {
617
            $element = $id;
618
        }
619
620
        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...
621
    }
622
623
624
    /*******************************************
625
     * MAGIC (pass calls onto query)
626
     *******************************************/
627
628
    /**
629
     * @param string $name
630
     * @return mixed
631
     */
632
    public function __get($name)
633
    {
634
        try {
635
            return parent::__get($name);
636
        } catch (UnknownPropertyException $e) {
637
            return $this->field->getQuery($this->element)->{$name};
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...
638
        }
639
    }
640
641
    /**
642
     * @param string $name
643
     * @param mixed $value
644
     */
645
    public function __set($name, $value)
646
    {
647
        try {
648
            return parent::__set($name, $value);
649
        } catch (UnknownPropertyException $e) {
650
            return $this->field->getQuery($this->element)->{$name}($value);
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...
651
        }
652
    }
653
654
    /**
655
     * @param string $name
656
     * @param array $params
657
     * @return mixed
658
     */
659
    public function __call($name, $params)
660
    {
661
        /** @var ElementQuery $query */
662
        $query = $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...
663
        if ($query->hasMethod($name)) {
664
            return call_user_func_array([$query, $name], $params);
665
        }
666
667
        return parent::__call($name, $params);
668
    }
669
}
670