Completed
Push — develop ( 0dacb8...2bf9d6 )
by Nate
05:33
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
    use traits\SequentialOrderTrait;
26
27
    /**
28
     * The source attribute name
29
     * @return string
30
     */
31
    const SOURCE_ATTRIBUTE = '';
32
33
    /**
34
     * The source attribute name
35
     * @return string
36
     */
37
    const TARGET_ATTRIBUTE = '';
38
39
    /**
40
     * The sort order attribute name
41
     * @return string
42
     */
43
    const SORT_ORDER_ATTRIBUTE = 'sortOrder';
44
45
    /**
46
     * @return string
47
     */
48
    abstract protected static function tableAlias(): string;
49
50
    /**
51
     * @param array $config
52
     * @return SortableAssociationQueryInterface
53
     */
54
    abstract public function getQuery($config = []): SortableAssociationQueryInterface;
55
56
    /**
57
     * @param SortableAssociationInterface $record
58
     * @return SortableAssociationQueryInterface|ActiveQuery
59
     */
60
    abstract protected function associationQuery(
61
        SortableAssociationInterface $record
62
    ): SortableAssociationQueryInterface;
63
64
    /**
65
     * @param SortableAssociationQueryInterface $query
66
     * @return array
67
     */
68
    abstract protected function existingAssociations(
69
        SortableAssociationQueryInterface $query
70
    ): array;
71
72
    /**
73
     * @param SortableAssociationQueryInterface $query
74
     * @return bool
75
     * @throws \Exception
76
     */
77
    public function save(
78
        SortableAssociationQueryInterface $query
79
    ): bool {
80
        if (null === ($associations = $query->getCachedResult())) {
81
            return true;
82
        }
83
84
        $existingAssociations = $this->existingAssociations($query);
85
86
        $transaction = Craft::$app->getDb()->beginTransaction();
87
        try {
88
            $newOrder = [];
89
            if (!$this->associateAll($associations, $existingAssociations, $newOrder) ||
90
                !$this->dissociateAll($existingAssociations)
91
            ) {
92
                $transaction->rollBack();
93
                return false;
94
            }
95
        } catch (\Exception $e) {
96
            $transaction->rollBack();
97
            throw $e;
98
        }
99
100
        $transaction->commit();
101
        return true;
102
    }
103
104
    /*******************************************
105
     * ASSOCIATE / DISSOCIATE
106
     *******************************************/
107
108
    /**
109
     * @param SortableAssociationInterface $record
110
     * @param bool $reOrder
111
     * @return bool
112
     * @throws \Exception
113
     */
114
    public function associate(
115
        SortableAssociationInterface $record,
116
        bool $reOrder = true
117
    ): bool {
118
        if (true === $this->existingAssociation($record)) {
119
            $reOrder = true;
120
        }
121
122
        if ($record->save() === false) {
123
            return false;
124
        }
125
126
        if ($reOrder === true) {
127
            return $this->applySortOrder($record);
128
        }
129
130
        return true;
131
    }
132
133
    /**
134
     * @param SortableAssociationInterface $record
135
     * @param bool $reOrder
136
     * @return bool
137
     * @throws \yii\db\Exception
138
     */
139
    public function dissociate(
140
        SortableAssociationInterface $record,
141
        bool $reOrder = true
142
    ) {
143
        if (false === $this->existingAssociation($record, false)) {
144
            return true;
145
        }
146
147
        if ($record->delete() === false) {
148
            return false;
149
        }
150
151
        if ($reOrder === true) {
152
            $this->autoReOrder($record);
153
        }
154
155
        return true;
156
    }
157
158
159
    /*******************************************
160
     * ASSOCIATE / DISSOCIATE MANY
161
     *******************************************/
162
163
    /**
164
     * @param array $associations
165
     * @param array $currentModels
166
     * @param array $newOrder
167
     * @return bool
168
     * @throws \Exception
169
     */
170
    protected function associateAll(
171
        array $associations,
172
        array &$currentModels,
173
        array &$newOrder
174
    ): bool {
175
        /** @var SortableAssociationInterface $association */
176
        $ct = 1;
177
        foreach ($associations as $association) {
178
            $target = $association->{static::TARGET_ATTRIBUTE};
179
            $newOrder[$target] = $ct++;
180
181
            ArrayHelper::remove($currentModels, $target);
182
183
            if (!$this->associate($association, false)) {
184
                return false;
185
            }
186
        }
187
188
        return true;
189
    }
190
191
    /**
192
     * @param array $targets
193
     * @return bool
194
     * @throws \Exception
195
     */
196
    protected function dissociateAll(
197
        array $targets
198
    ): bool {
199
        /** @var SortableAssociationInterface $target */
200
        foreach ($targets as $target) {
201
            if (!$this->dissociate($target)) {
202
                return false;
203
            }
204
        }
205
206
        return true;
207
    }
208
209
210
    /*******************************************
211
     * RECORD SORT ORDER
212
     *******************************************/
213
214
    /**
215
     * @param SortableAssociationInterface $current
216
     * @param SortableAssociationInterface|null $existing
217
     */
218
    private function ensureSortOrder(
219
        SortableAssociationInterface $current,
220
        SortableAssociationInterface $existing = null
221
    ) {
222
        if ($current->{static::SORT_ORDER_ATTRIBUTE} !== null) {
223
            return;
224
        }
225
226
        $current->{static::SORT_ORDER_ATTRIBUTE} = $existing ?
227
            $existing->{static::SORT_ORDER_ATTRIBUTE} :
228
            $this->nextSortOrder($current);
229
    }
230
231
    /**
232
     * @param SortableAssociationInterface $record
233
     * @return bool
234
     * @throws \Exception
235
     */
236
    private function applySortOrder(
237
        SortableAssociationInterface $record
238
    ): bool {
239
        $sortOrder = $this->sortOrder($record);
240
241
        if (count($sortOrder) < $record->{static::SORT_ORDER_ATTRIBUTE}) {
242
            $record->{static::SORT_ORDER_ATTRIBUTE} = count($sortOrder);
243
        }
244
245
        $order = $this->insertIntoOrder($record, $sortOrder);
246
247
        if ($order === true || $order === false) {
248
            return $order;
249
        }
250
251
        return $this->reOrder(
252
            $record->getPrimaryKey(true),
253
            (array)$order
254
        );
255
    }
256
257
    /**
258
     * @param SortableAssociationInterface $record
259
     * @param array $sortOrder
260
     * @return array|bool
261
     * @throws \Exception
262
     */
263
    private function insertIntoOrder(
264
        SortableAssociationInterface $record,
265
        array $sortOrder
266
    ) {
267
268
        if($record->{static::SORT_ORDER_ATTRIBUTE} !== null) {
269
            return true;
270
        }
271
272
        $order = $this->insertSequential(
273
            $sortOrder,
274
            $record->{static::TARGET_ATTRIBUTE},
275
            $record->{static::SORT_ORDER_ATTRIBUTE} ?: 1
276
        );
277
278
        if ($order === false) {
279
            return $this->associate($record);
280
        }
281
282
        if ($order === true) {
283
            return true;
284
        }
285
286
        return $order;
287
    }
288
289
    /**
290
     * @param SortableAssociationInterface $record
291
     * @return bool
292
     * @throws \yii\db\Exception
293
     */
294
    protected function autoReOrder(
295
        SortableAssociationInterface $record
296
    ): bool {
297
        $sortOrder = $this->sortOrder($record);
298
299
        if (empty($sortOrder)) {
300
            return true;
301
        }
302
303
        return $this->reOrder(
304
            $record->getPrimaryKey(true),
305
            array_flip(array_combine(
306
                range(1, count($sortOrder)),
307
                array_keys($sortOrder)
308
            ))
309
        );
310
    }
311
312
    /**
313
     * @param SortableAssociationInterface $record
314
     * @return array
315
     */
316
    protected function sortOrder(
317
        SortableAssociationInterface $record
318
    ): array {
319
        return $this->associationQuery($record)
320
            ->indexBy(static::TARGET_ATTRIBUTE)
321
            ->select([static::SORT_ORDER_ATTRIBUTE])
322
            ->column();
323
    }
324
325
    /**
326
     * @param SortableAssociationInterface $record
327
     * @return int
328
     */
329
    private function nextSortOrder(
330
        SortableAssociationInterface $record
331
    ): int {
332
        $maxSortOrder = $this->associationQuery($record)
333
            ->max(static::SORT_ORDER_ATTRIBUTE);
334
335
        return ++$maxSortOrder;
336
    }
337
338
    /*******************************************
339
     * RAW SORT ORDER
340
     *******************************************/
341
342
    /**
343
     * @param array $condition
344
     * @param array $sortOrder
345
     * @return bool
346
     * @throws \yii\db\Exception
347
     */
348
    protected function reOrder(
349
        array $condition,
350
        array $sortOrder
351
    ): bool {
352
        foreach ($sortOrder as $target => $order) {
353
            Craft::$app->getDb()->createCommand()
354
                ->update(
355
                    '{{%' . $this->tableAlias() . '}}',
356
                    [static::SORT_ORDER_ATTRIBUTE => $order],
357
                    array_merge(
358
                        $condition,
359
                        [
360
                            static::TARGET_ATTRIBUTE => $target
361
                        ]
362
                    )
363
                )
364
                ->execute();
365
        }
366
367
        return true;
368
    }
369
370
371
    /*******************************************
372
     * EXISTING / SYNC
373
     *******************************************/
374
375
    /**
376
     * @param SortableAssociationInterface|ActiveRecord $record
377
     * @param bool $ensureSortOrder
378
     * @return bool
379
     */
380
    protected function existingAssociation(
381
        SortableAssociationInterface $record,
382
        bool $ensureSortOrder = true
383
    ): bool {
384
        if (null !== ($existing = $this->lookupAssociation($record))) {
385
            $record->setOldAttributes(
386
                $existing->getOldAttributes()
387
            );
388
        }
389
390
        if (true === $ensureSortOrder) {
391
            $this->ensureSortOrder($record, $existing);
392
        }
393
394
        return $existing !== null;
395
    }
396
397
    /**
398
     * @param SortableAssociationInterface $record
399
     * @return SortableAssociationInterface|ActiveRecord|null
400
     */
401
    protected function lookupAssociation(
402
        SortableAssociationInterface $record
403
    ) {
404
        $model = $this->associationQuery($record)
405
            ->andWhere([
406
                static::TARGET_ATTRIBUTE => $record->{static::TARGET_ATTRIBUTE},
407
            ])
408
            ->one();
409
410
        return $model instanceof SortableAssociationInterface ? $model : null;
411
    }
412
413
    /*******************************************
414
     * UTILITIES
415
     *******************************************/
416
417
    /**
418
     * @param SortableAssociationQueryInterface $query
419
     * @param string $attribute
420
     * @return null|string
421
     */
422
    protected function resolveStringAttribute(
423
        SortableAssociationQueryInterface $query,
424
        string $attribute
425
    ) {
426
        $value = $query->{$attribute};
427
428
        if ($value !== null && (is_string($value) || is_numeric($value))) {
429
            return (string)$value;
430
        }
431
432
        return null;
433
    }
434
}
435