Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.58% covered (success)
96.58%
141 / 146
86.21% covered (warning)
86.21%
25 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
InteractsWithOrderablePivotTable
96.58% covered (success)
96.58%
141 / 146
86.21% covered (warning)
86.21%
25 / 29
55
0.00% covered (danger)
0.00%
0 / 1
 setOrderColumn
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getOrderColumn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getQualifiedOrderColumn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMaxPosition
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNextPosition
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ordered
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 unordered
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 forceOrderBy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newPivotQuery
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 newPivotQueryBetween
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 parsePivot
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 moveToOffset
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
6.01
 moveToStart
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 moveToEnd
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 moveToPosition
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 moveUp
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 moveDown
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 swap
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 moveAfter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 moveBefore
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 move
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
3
 getNewPosition
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 attachNew
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 attach
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 detach
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setOrder
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 refreshPositions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 before
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 after
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace Baril\Orderly\Relations\Concerns;
4
5use Baril\Orderly\GroupException;
6use Baril\Orderly\PositionException;
7use Illuminate\Database\Eloquent\Model;
8
9trait InteractsWithOrderablePivotTable
10{
11    protected $orderColumn;
12
13    /**
14     * @param string $orderColumn
15     * @return $this
16     */
17    protected function setOrderColumn($orderColumn)
18    {
19        $this->orderColumn = $orderColumn;
20        return $this->withPivot($orderColumn);
21    }
22
23    /**
24     * @return string
25     */
26    public function getOrderColumn()
27    {
28        return $this->orderColumn;
29    }
30
31    /**
32     * @return string
33     */
34    public function getQualifiedOrderColumn()
35    {
36        return $this->table . '.' . $this->orderColumn;
37    }
38
39    public function getMaxPosition()
40    {
41        return $this->newPivotQuery(false)->max($this->getOrderColumn());
42    }
43
44    public function getNextPosition()
45    {
46        return $this->getMaxPosition() + 1;
47    }
48
49    /**
50     * @param string $direction
51     * @return $this
52     */
53    public function ordered($direction = 'asc')
54    {
55        return $this->unordered()->orderBy($this->getQualifiedOrderColumn(), $direction);
56    }
57
58    /**
59     * @return $this
60     */
61    public function unordered()
62    {
63        $query = $this->query->getQuery();
64        $query->orders = collect($query->orders)
65                ->reject(function ($order) {
66                    return isset($order['column'])
67                           ? $order['column'] === $this->getQualifiedOrderColumn() : false;
68                })->values()->all();
69        return $this;
70    }
71
72    /**
73     * @param  mixed  $column
74     * @param  string  $direction
75     *
76     * @return $this
77     */
78    public function forceOrderBy($column, $direction = 'asc')
79    {
80        return $this->unordered()->orderBy($column, $direction);
81    }
82
83    /**
84     * Create a new query builder for the pivot table.
85     *
86     * @return \Illuminate\Database\Query\Builder
87     */
88    public function newPivotQuery($ordered = true)
89    {
90        $query = parent::newPivotQuery();
91        return $ordered ? $query->orderBy($this->getQualifiedOrderColumn()) : $query;
92    }
93
94    /**
95     * @return \Illuminate\Database\Query\Builder
96     */
97    protected function newPivotQueryBetween($leftPosition, $rightPosition)
98    {
99        $query = $this->newPivotQuery();
100
101        if (!is_null($leftPosition)) {
102            $query->where($this->getQualifiedOrderColumn(), '>', $leftPosition);
103        }
104        if (!is_null($rightPosition)) {
105            $query->where($this->getQualifiedOrderColumn(), '<', $rightPosition);
106        }
107        return $query;
108    }
109
110    /**
111     * Extract the pivot (with the order column) from a model, or fetch it
112     * from the database.
113     *
114     * @param \Illuminate\Database\Eloquent\Model $entity
115     * @return \Illuminate\Database\Eloquent\Pivot
116     *
117     * @throws GroupException
118     */
119    protected function parsePivot($entity)
120    {
121        if ($entity->{$this->accessor} && $entity->{$this->accessor}->{$this->orderColumn}) {
122            if ($entity->{$this->accessor}->{$this->foreignPivotKey} != $this->parent->{$this->parentKey}) {
123                throw new GroupException('The provided model doesn\'t belong to this relationship!');
124            }
125            return $entity->{$this->accessor};
126        }
127
128        $pivot = $this->newPivotStatementForId($entity->getKey())->first();
129        if ($pivot === null) {
130            throw new GroupException('The provided model doesn\'t belong to this relationship!');
131        }
132        return $pivot;
133    }
134
135    /**
136     * Moves the provided model to the specific offset.
137     *
138     * @param Model $entity
139     * @param int $newOffset
140     * @return $this
141     *
142     * @throws GroupException
143     * @throws PositionException
144     */
145    public function moveToOffset($entity, $newOffset)
146    {
147        $pivot = $this->parsePivot($entity);
148        $count = $this->newPivotQuery()->count();
149
150        if ($newOffset < 0) {
151            $newOffset = $count + $newOffset;
152        }
153        if ($newOffset < 0 || $newOffset >= $count) {
154            throw new PositionException("Invalid offset $newOffset!");
155        }
156
157        $oldOffset = $this->newPivotQueryBetween(null, $pivot->{$this->orderColumn})->count();
158
159        if ($oldOffset === $newOffset) {
160            return $this;
161        }
162
163        $entity = (object) [
164            $this->accessor => $pivot,
165        ];
166        $positionEntity = (object) [
167            $this->accessor => $this->newPivotQuery()->offset($newOffset)->first(),
168        ];
169        if ($oldOffset < $newOffset) {
170            return $this->moveAfter($entity, $positionEntity);
171        } else {
172            return $this->moveBefore($entity, $positionEntity);
173        }
174    }
175
176    /**
177     * Moves the provided model to the first position.
178     *
179     * @param Model $entity
180     * @return $this
181     *
182     * @throws GroupException
183     */
184    public function moveToStart($entity)
185    {
186        return $this->moveToOffset($entity, 0);
187    }
188
189    /**
190     * Moves the provided model to the first position.
191     *
192     * @param Model $entity
193     * @return $this
194     *
195     * @throws GroupException
196     */
197    public function moveToEnd($entity)
198    {
199        return $this->moveToOffset($entity, -1);
200    }
201
202    /**
203     * Moves the provided model to the specified position.
204     *
205     * @param Model $entity
206     * @param int $newPosition
207     * @return $this
208     *
209     * @throws GroupException
210     * @throws PositionException
211     */
212    public function moveToPosition($entity, $newPosition)
213    {
214        return $this->moveToOffset($entity, $newPosition - 1);
215    }
216
217    /**
218     *
219     * @param Model $entity
220     * @param int $positions
221     * @param boolean $strict
222     */
223    public function moveUp($entity, $positions = 1, $strict = true)
224    {
225        $pivot = $this->parsePivot($entity);
226        $orderColumn = $this->getOrderColumn();
227        $currentPosition = $pivot->$orderColumn;
228        $newPosition = $currentPosition - $positions;
229        if (!$strict) {
230            $newPosition = max(1, $newPosition);
231            $newPosition = min($this->getMaxPosition(), $newPosition);
232        }
233        return $this->moveToPosition($entity, $newPosition);
234    }
235
236    /**
237     *
238     * @param Model $entity
239     * @param int $positions
240     * @param boolean $strict
241     */
242    public function moveDown($entity, $positions = 1, $strict = true)
243    {
244        return $this->moveUp($entity, -$positions, $strict);
245    }
246
247    /**
248     * Swaps the provided models.
249     *
250     * @param Model $entity1
251     * @param Model $entity2
252     * @return $this
253     *
254     * @throws GroupException
255     */
256    public function swap($entity1, $entity2)
257    {
258        $pivot1 = $this->parsePivot($entity1);
259        $pivot2 = $this->parsePivot($entity2);
260
261        $this->getConnection()->transaction(function () use ($pivot1, $pivot2) {
262            $relatedPivotKey = $this->relatedPivotKey;
263            $orderColumn = $this->orderColumn;
264
265            if ($pivot1->$orderColumn === $pivot2->$orderColumn) {
266                return;
267            }
268
269            $this->updateExistingPivot($pivot1->$relatedPivotKey, [$orderColumn => $pivot2->$orderColumn]);
270            $this->updateExistingPivot($pivot2->$relatedPivotKey, [$orderColumn => $pivot1->$orderColumn]);
271        });
272        return $this;
273    }
274
275    /**
276     * Moves $entity after $positionEntity.
277     *
278     * @param Model $entity
279     * @param Model $positionEntity
280     * @return $this
281     *
282     * @throws GroupException
283     */
284    public function moveAfter($entity, $positionEntity)
285    {
286        return $this->move('moveAfter', $entity, $positionEntity);
287    }
288
289    /**
290     * Moves $entity before $positionEntity.
291     *
292     * @param Model $entity
293     * @param Model $positionEntity
294     * @return $this
295     *
296     * @throws GroupException
297     */
298    public function moveBefore($entity, $positionEntity)
299    {
300        return $this->move('moveBefore', $entity, $positionEntity);
301    }
302
303    /**
304     * @param string $action moveAfter/moveBefore
305     * @param Model  $entity
306     * @param Model  $positionEntity
307     *
308     * @throws GroupException
309     */
310    protected function move($action, $entity, $positionEntity)
311    {
312        $pivot = $this->parsePivot($entity);
313        $positionPivot = $this->parsePivot($positionEntity);
314
315        $this->getConnection()->transaction(function () use ($pivot, $positionPivot, $action) {
316            $relatedPivotKey = $this->relatedPivotKey;
317            $orderColumn = $this->orderColumn;
318
319            $oldPosition = $pivot->$orderColumn;
320            $newPosition = $positionPivot->$orderColumn;
321
322            if ($oldPosition === $newPosition) {
323                return;
324            }
325
326            $isMoveBefore = $action === 'moveBefore'; // otherwise moveAfter
327            $isMoveForward = $oldPosition < $newPosition;
328
329            if ($isMoveForward) {
330                $this->newPivotQueryBetween($oldPosition, $newPosition)->decrement($orderColumn);
331            } else {
332                $this->newPivotQueryBetween($newPosition, $oldPosition)->increment($orderColumn);
333            }
334
335            $this->updateExistingPivot(
336                $pivot->$relatedPivotKey,
337                [$orderColumn => $this->getNewPosition($isMoveBefore, $isMoveForward, $newPosition)]
338            );
339            $this->updateExistingPivot(
340                $positionPivot->$relatedPivotKey,
341                [$orderColumn => $this->getNewPosition(!$isMoveBefore, $isMoveForward, $newPosition)]
342            );
343        });
344        return $this;
345    }
346
347    /**
348     * @param bool $isMoveBefore
349     * @param bool $isMoveForward
350     * @param      $position
351     *
352     * @return mixed
353     */
354    protected function getNewPosition($isMoveBefore, $isMoveForward, $position)
355    {
356        if (!$isMoveBefore) {
357            ++$position;
358        }
359
360        if ($isMoveForward) {
361            --$position;
362        }
363
364        return $position;
365    }
366
367    /**
368     * Attach all of the records that aren't in the given current records.
369     *
370     * @param  array  $records
371     * @param  array  $current
372     * @param  bool   $touch
373     * @return array
374     */
375    protected function attachNew(array $records, array $current, $touch = true)
376    {
377        $i = 0;
378        $changes = ['attached' => [], 'updated' => []];
379
380        foreach ($records as $id => $attributes) {
381            $attributes[$this->orderColumn] = ++$i;
382
383            if (! in_array($id, $current)) {
384                // If the ID is not in the list of existing pivot IDs, we will insert a new pivot
385                // record, otherwise, we will just update this existing record on this joining
386                // table, so that the developers will easily update these records pain free.
387                parent::attach($id, $attributes, $touch);
388                $changes['attached'][] = $this->castKey($id);
389            } elseif (
390                count($attributes) > 0 &&
391                $this->updateExistingPivot($id, $attributes, $touch)
392            ) {
393                // Now we'll try to update an existing pivot record with the attributes that were
394                // given to the method. If the model is actually updated we will add it to the
395                // list of updated pivot records so we return them back out to the consumer.
396                $changes['updated'][] = $this->castKey($id);
397            }
398        }
399
400        return $changes;
401    }
402
403    /**
404     * Attach a model to the parent.
405     *
406     * @param mixed $id
407     * @param array $attributes
408     * @param bool  $touch
409     */
410    public function attach($id, array $attributes = [], $touch = true)
411    {
412        $ids = $this->parseIds($id);
413        $nextPosition = $this->getNextPosition();
414        foreach ($ids as $id) {
415            $attributes[$this->orderColumn] = $nextPosition++;
416            parent::attach($id, $attributes, $touch);
417        }
418    }
419
420    /**
421     * Detach models from the relationship.
422     *
423     * @param  mixed  $ids
424     * @param  bool  $touch
425     * @return int
426     */
427    public function detach($ids = null, $touch = true)
428    {
429        $results = parent::detach($ids, $touch);
430        if ($results) {
431            $this->refreshPositions();
432        }
433        return $results;
434    }
435
436    /**
437     *
438     * @param array|\Illuminate\Database\Eloquent\Collection $ids
439     * @return $this
440     */
441    public function setOrder($ids)
442    {
443        $ids = array_flip($ids);
444        $models = $this->ordered()->get();
445        $i = $models->count();
446        $models = $models->sortBy(function ($model) use ($ids, &$i) {
447            return $ids[$model->getKey()] ?? ++$i;
448        });
449
450
451        $ids = $models->modelKeys();
452        $positions = $models->pluck($this->accessor . '.' . $this->getOrderColumn())->sort()->values()->all();
453        $newOrder = array_combine($ids, $positions);
454
455        $this->getConnection()->transaction(function () use ($newOrder) {
456            foreach ($newOrder as $id => $position) {
457                $this->newPivotStatementForId($id)->update([$this->getOrderColumn() => $position]);
458            }
459        });
460
461        return $this;
462    }
463
464    public function refreshPositions()
465    {
466        $this->newPivotQuery()->orderBy($this->orderColumn)->updateColumnWithRowNumber($this->orderColumn);
467    }
468
469    public function before($entity)
470    {
471        $pivot = $this->parsePivot($entity);
472        return $this->query->cloneWithout(['orders'])
473                ->orderBy($this->getQualifiedOrderColumn(), 'desc')
474                ->where($this->getQualifiedOrderColumn(), '<', $pivot->{$this->orderColumn});
475    }
476
477    public function after($entity)
478    {
479        $pivot = $this->parsePivot($entity);
480        return $this->query->cloneWithout(['orders'])
481                ->orderBy($this->getQualifiedOrderColumn())
482                ->where($this->getQualifiedOrderColumn(), '>', $pivot->{$this->orderColumn});
483    }
484}