Completed
Branch develop (96c692)
by Nate
01:25
created

SortableAssociations::ensureSequential()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 0
Metric Value
dl 0
loc 11
ccs 0
cts 10
cp 0
rs 9.2
c 0
b 0
f 0
cc 4
eloc 6
nc 5
nop 1
crap 20
1
<?php
2
3
/**
4
 * @copyright  Copyright (c) Flipbox Digital Limited
5
 * @license    https://github.com/flipboxfactory/craft-sortable-associations/blob/master/LICENSE
6
 * @link       https://github.com/flipboxfactory/craft-sortable-associations
7
 */
8
9
namespace flipbox\craft\sortable\associations\services;
10
11
use Craft;
12
use craft\helpers\ArrayHelper;
13
use flipbox\craft\sortable\associations\db\SortableAssociationQueryInterface;
14
use flipbox\craft\sortable\associations\records\SortableAssociationInterface;
15
use yii\base\Component;
16
use yii\db\ActiveQuery;
17
use yii\db\ActiveRecord;
18
19
/**
20
 * @author Flipbox Factory <[email protected]>
21
 * @since 1.0.0
22
 */
23
abstract class SortableAssociations extends Component
24
{
25
    /**
26
     * The source attribute name
27
     * @return string
28
     */
29
    const SOURCE_ATTRIBUTE = '';
30
31
    /**
32
     * The source attribute name
33
     * @return string
34
     */
35
    const TARGET_ATTRIBUTE = '';
36
37
    /**
38
     * The sort order attribute name
39
     * @return string
40
     */
41
    const SORT_ORDER_ATTRIBUTE = 'sortOrder';
42
43
    /**
44
     * @return string
45
     */
46
    abstract protected static function tableAlias(): string;
47
48
    /**
49
     * @param array $config
50
     * @return SortableAssociationQueryInterface
51
     */
52
    abstract public function getQuery($config = []): SortableAssociationQueryInterface;
53
54
    /**
55
     * @param SortableAssociationInterface $record
56
     * @return SortableAssociationQueryInterface|ActiveQuery
57
     */
58
    abstract protected function associationQuery(
59
        SortableAssociationInterface $record
60
    ): SortableAssociationQueryInterface;
61
62
    /**
63
     * @param SortableAssociationQueryInterface $query
64
     * @return array
65
     */
66
    abstract protected function existingAssociations(
67
        SortableAssociationQueryInterface $query
68
    ): array;
69
70
    /**
71
     * @param SortableAssociationQueryInterface $query
72
     * @return bool
73
     * @throws \Exception
74
     */
75
    public function save(
76
        SortableAssociationQueryInterface $query
77
    ): bool {
78
        if (null === ($associations = $query->getCachedResult())) {
79
            return true;
80
        }
81
82
        $existingAssociations = $this->existingAssociations($query);
83
84
        $transaction = Craft::$app->getDb()->beginTransaction();
85
        try {
86
            $newOrder = [];
87
            if (!$this->associateAll($associations, $existingAssociations, $newOrder) ||
88
                !$this->dissociateAll($existingAssociations)
89
            ) {
90
                $transaction->rollBack();
91
                return false;
92
            }
93
        } catch (\Exception $e) {
94
            $transaction->rollBack();
95
            throw $e;
96
        }
97
98
        $transaction->commit();
99
        return true;
100
    }
101
102
    /*******************************************
103
     * ASSOCIATE / DISSOCIATE
104
     *******************************************/
105
106
    /**
107
     * @param SortableAssociationInterface $record
108
     * @param bool $reOrder
109
     * @return bool
110
     * @throws \Exception
111
     */
112
    public function associate(
113
        SortableAssociationInterface $record,
114
        bool $reOrder = true
115
    ): bool {
116
        if (true === $this->existingAssociation($record)) {
117
            $reOrder = true;
118
        }
119
120
        if ($record->save() === false) {
121
            return false;
122
        }
123
124
        if ($reOrder === true) {
125
            return $this->applySortOrder($record);
126
        }
127
128
        return true;
129
    }
130
131
    /**
132
     * @param SortableAssociationInterface $record
133
     * @param bool $reOrder
134
     * @return bool
135
     * @throws \yii\db\Exception
136
     */
137
    public function dissociate(
138
        SortableAssociationInterface $record,
139
        bool $reOrder = true
140
    ) {
141
        if (false === $this->existingAssociation($record, false)) {
142
            return true;
143
        }
144
145
        if ($record->delete() === false) {
146
            return false;
147
        }
148
149
        if ($reOrder === true) {
150
            $this->autoReOrder($record);
151
        }
152
153
        return true;
154
    }
155
156
157
    /*******************************************
158
     * ASSOCIATE / DISSOCIATE MANY
159
     *******************************************/
160
161
    /**
162
     * @param array $associations
163
     * @param array $currentModels
164
     * @param array $newOrder
165
     * @return bool
166
     * @throws \Exception
167
     */
168
    protected function associateAll(
169
        array $associations,
170
        array &$currentModels,
171
        array &$newOrder
172
    ): bool {
173
        /** @var SortableAssociationInterface $association */
174
        $ct = 1;
175
        foreach ($associations as $association) {
176
            $target = $association->{static::TARGET_ATTRIBUTE};
177
            $newOrder[$target] = $ct++;
178
179
            ArrayHelper::remove($currentModels, $target);
180
181
            if (!$this->associate($association, false)) {
182
                return false;
183
            }
184
        }
185
186
        return true;
187
    }
188
189
    /**
190
     * @param array $targets
191
     * @return bool
192
     * @throws \Exception
193
     */
194
    protected function dissociateAll(
195
        array $targets
196
    ): bool {
197
        /** @var SortableAssociationInterface $target */
198
        foreach ($targets as $target) {
199
            if (!$this->dissociate($target)) {
200
                return false;
201
            }
202
        }
203
204
        return true;
205
    }
206
207
208
    /*******************************************
209
     * RECORD SORT ORDER
210
     *******************************************/
211
212
    /**
213
     * @param SortableAssociationInterface $current
214
     * @param SortableAssociationInterface|null $existing
215
     */
216
    private function ensureSortOrder(
217
        SortableAssociationInterface $current,
218
        SortableAssociationInterface $existing = null
219
    ) {
220
        if ($current->{static::SORT_ORDER_ATTRIBUTE} !== null) {
221
            return;
222
        }
223
224
        $current->{static::SORT_ORDER_ATTRIBUTE} = $existing ?
225
            $existing->{static::SORT_ORDER_ATTRIBUTE} :
226
            $this->nextSortOrder($current);
227
    }
228
229
    /**
230
     * @param SortableAssociationInterface $record
231
     * @return bool
232
     * @throws \Exception
233
     */
234
    private function applySortOrder(
235
        SortableAssociationInterface $record
236
    ): bool {
237
        $sortOrder = $this->sortOrder($record);
238
239
        if (count($sortOrder) < $record->{static::SORT_ORDER_ATTRIBUTE}) {
240
            $record->{static::SORT_ORDER_ATTRIBUTE} = count($sortOrder);
241
        }
242
243
        $order = $this->insertIntoOrder($record, $sortOrder);
244
245
        if ($order === true || $order === false) {
246
            return $order;
247
        }
248
249
        return $this->reOrder(
250
            $record->getPrimaryKey(true),
251
            (array)$order
252
        );
253
    }
254
255
    /**
256
     * @param SortableAssociationInterface $record
257
     * @param array $sortOrder
258
     * @return array|bool
259
     * @throws \Exception
260
     */
261
    private function insertIntoOrder(
262
        SortableAssociationInterface $record,
263
        array $sortOrder
264
    ) {
265
266
        if($record->{static::SORT_ORDER_ATTRIBUTE} !== null) {
267
            return true;
268
        }
269
270
        $order = $this->insertSequential(
271
            $sortOrder,
272
            $record->{static::TARGET_ATTRIBUTE},
273
            $record->{static::SORT_ORDER_ATTRIBUTE} ?: 1
274
        );
275
276
        if ($order === false) {
277
            return $this->associate($record);
278
        }
279
280
        if ($order === true) {
281
            return true;
282
        }
283
284
        return $order;
285
    }
286
287
    /**
288
     * @param SortableAssociationInterface $record
289
     * @return bool
290
     * @throws \yii\db\Exception
291
     */
292
    protected function autoReOrder(
293
        SortableAssociationInterface $record
294
    ): bool {
295
        $sortOrder = $this->sortOrder($record);
296
297
        if (empty($sortOrder)) {
298
            return true;
299
        }
300
301
        return $this->reOrder(
302
            $record->getPrimaryKey(true),
303
            array_flip(array_combine(
304
                range(1, count($sortOrder)),
305
                array_keys($sortOrder)
306
            ))
307
        );
308
    }
309
310
    /**
311
     * @param SortableAssociationInterface $record
312
     * @return array
313
     */
314
    protected function sortOrder(
315
        SortableAssociationInterface $record
316
    ): array {
317
        return $this->associationQuery($record)
1 ignored issue
show
Bug introduced by
The method indexBy does only exist in yii\db\ActiveQuery, but not in flipbox\craft\sortable\a...sociationQueryInterface.

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...
318
            ->indexBy(static::TARGET_ATTRIBUTE)
319
            ->select([static::SORT_ORDER_ATTRIBUTE])
320
            ->column();
321
    }
322
323
    /**
324
     * @param SortableAssociationInterface $record
325
     * @return int
326
     */
327
    private function nextSortOrder(
328
        SortableAssociationInterface $record
329
    ): int {
330
        $maxSortOrder = $this->associationQuery($record)
1 ignored issue
show
Bug introduced by
The method max does only exist in yii\db\ActiveQuery, but not in flipbox\craft\sortable\a...sociationQueryInterface.

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...
331
            ->max(static::SORT_ORDER_ATTRIBUTE);
332
333
        return ++$maxSortOrder;
334
    }
335
336
    /*******************************************
337
     * RAW SORT ORDER
338
     *******************************************/
339
340
    /**
341
     * @param array $condition
342
     * @param array $sortOrder
343
     * @return bool
344
     * @throws \yii\db\Exception
345
     */
346
    protected function reOrder(
347
        array $condition,
348
        array $sortOrder
349
    ): bool {
350
        foreach ($sortOrder as $target => $order) {
351
            Craft::$app->getDb()->createCommand()
352
                ->update(
353
                    '{{%' . $this->tableAlias() . '}}',
354
                    [static::SORT_ORDER_ATTRIBUTE => $order],
355
                    array_merge(
356
                        $condition,
357
                        [
358
                            static::TARGET_ATTRIBUTE => $target
359
                        ]
360
                    )
361
                )
362
                ->execute();
363
        }
364
365
        return true;
366
    }
367
368
369
    /*******************************************
370
     * EXISTING / SYNC
371
     *******************************************/
372
373
    /**
374
     * @param SortableAssociationInterface|ActiveRecord $record
375
     * @param bool $ensureSortOrder
376
     * @return bool
377
     */
378
    protected function existingAssociation(
379
        SortableAssociationInterface $record,
380
        bool $ensureSortOrder = true
381
    ): bool {
382
        if (null !== ($existing = $this->lookupAssociation($record))) {
383
            $record->setOldAttributes(
1 ignored issue
show
Bug introduced by
The method setOldAttributes() does not exist on flipbox\craft\sortable\a...bleAssociationInterface. Did you maybe mean attributes()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
384
                $existing->getOldAttributes()
1 ignored issue
show
Bug introduced by
The method getOldAttributes() does not exist on flipbox\craft\sortable\a...bleAssociationInterface. Did you maybe mean attributes()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
385
            );
386
        }
387
388
        if (true === $ensureSortOrder) {
389
            $this->ensureSortOrder($record, $existing);
390
        }
391
392
        return $existing !== null;
393
    }
394
395
    /**
396
     * @param SortableAssociationInterface $record
397
     * @return SortableAssociationInterface|ActiveRecord|null
398
     */
399
    protected function lookupAssociation(
400
        SortableAssociationInterface $record
401
    ) {
402
        $model = $this->associationQuery($record)
1 ignored issue
show
Bug introduced by
The method andWhere does only exist in yii\db\ActiveQuery, but not in flipbox\craft\sortable\a...sociationQueryInterface.

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...
403
            ->andWhere([
404
                static::TARGET_ATTRIBUTE => $record->{static::TARGET_ATTRIBUTE},
405
            ])
406
            ->one();
407
408
        return $model instanceof SortableAssociationInterface ? $model : null;
409
    }
410
411
    /*******************************************
412
     * UTILITIES
413
     *******************************************/
414
415
    /**
416
     * @param SortableAssociationQueryInterface $query
417
     * @param string $attribute
418
     * @return null|string
419
     */
420
    protected function resolveStringAttribute(
421
        SortableAssociationQueryInterface $query,
422
        string $attribute
423
    ) {
424
        $value = $query->{$attribute};
425
426
        if ($value !== null && (is_string($value) || is_numeric($value))) {
427
            return (string)$value;
428
        }
429
430
        return null;
431
    }
432
433
    /**
434
     * @param array $sourceArray The source array which the target is to be inserted into.  The
435
     * key represents a unique identifier, while the value is the sort order.
436
     *
437
     * As an example if this is the $sourceArray
438
     *
439
     * ```
440
     * [
441
     *      111 => 1,
442
     *      343 => 2,
443
     *      545 => 3,
444
     *      'foo' => 4,
445
     *      'bar' => 5
446
     * ]
447
     * ```
448
     *
449
     * And your $targetKey is 'fooBar' with a $targetOrder of 4, the result would be
450
     *
451
     * ```
452
     * [
453
     *      111 => 1,
454
     *      343 => 2,
455
     *      545 => 3,
456
     *      'fooBar' => 4,
457
     *      'foo' => 5,
458
     *      'bar' => 6
459
     * ]
460
     * ```
461
     *
462
     * @param string|int $targetKey
463
     * @param int $targetOrder
464
     * @return array|bool
465
     */
466
    protected function insertSequential(array $sourceArray, $targetKey, int $targetOrder)
467
    {
468
        $this->ensureSequential($sourceArray);
469
470
        // Append exiting types after position
471
        if (false === ($indexPosition = array_search($targetKey, array_keys($sourceArray)))) {
472
            return false;
473
        }
474
475
        // Determine the furthest affected index
476
        $affectedIndex = $indexPosition >= $targetOrder ? ($targetOrder - 1) : $indexPosition;
477
478
        // All that are affected by re-ordering
479
        $affectedTypes = array_slice($sourceArray, $affectedIndex, null, true);
480
481
        // Remove the current (we're going to put it back in later)
482
        $currentPosition = (int)ArrayHelper::remove($affectedTypes, $targetKey);
483
484
        // Already in that position?
485
        if ($currentPosition === $targetOrder) {
486
            return true;
487
        }
488
489
        $startingSortOrder = $targetOrder;
490
        if ($affectedIndex++ < $targetOrder) {
491
            $startingSortOrder = $affectedIndex;
492
        }
493
494
        // Prepend current type
495
        $order = [$targetKey => $targetOrder];
496
497
        // Assemble order
498
        if (false !== ($position = array_search($targetOrder, array_values($affectedTypes)))) {
499
            if ($indexPosition < $targetOrder) {
500
                $position++;
501
            }
502
503
            if ($position > 0) {
504
                $order = array_slice($affectedTypes, 0, $position, true) + $order;
505
            }
506
507
            $order += array_slice($affectedTypes, $position, null, true);
508
        }
509
510
        return array_flip(array_combine(
511
            range($startingSortOrder, count($order)),
512
            array_keys($order)
513
        ));
514
    }
515
516
    /**
517
     * @param array $sourceArray
518
     */
519
    private function ensureSequential(array &$sourceArray)
520
    {
521
        $ct = 1;
522
        foreach($sourceArray as $key => &$sortOrder) {
523
            $sortOrder = (int) $sortOrder ?: $ct++;
524
525
            if($sortOrder > $ct) {
526
                $ct = $sortOrder + 1;
527
            }
528
        }
529
    }
530
}
531