Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.96% covered (success)
94.96%
132 / 139
79.17% covered (warning)
79.17%
19 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Orderable
94.96% covered (success)
94.96%
132 / 139
79.17% covered (warning)
79.17%
19 / 24
50.32
0.00% covered (danger)
0.00%
0 / 1
 bootOrderable
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
7
 newCollection
100.00% covered (success)
100.00%
1 / 1
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
2
 getPosition
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
 scopeOrdered
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 scopeUnordered
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 scopeForceOrderBy
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 moveToOffset
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
6.02
 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%
6 / 6
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
 swapWith
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
3.03
 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
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
4.02
 getNewPosition
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 newQueryBetween
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 previous
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 next
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 scopeSetOrder
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace Baril\Orderly\Concerns;
4
5use Baril\Orderly\GroupException;
6use Baril\Orderly\OrderableCollection;
7use Baril\Orderly\PositionException;
8use Illuminate\Database\Eloquent\Model;
9use Illuminate\Database\Eloquent\Builder;
10
11/**
12 * @property string $orderColumn
13 */
14trait Orderable
15{
16    use Groupable;
17
18    /**
19     * Adds position to model on creating event.
20     */
21    public static function bootOrderable()
22    {
23        static::creating(function ($model) {
24            /** @var Model $model */
25            $orderColumn = $model->getOrderColumn();
26
27            // only automatically calculate next position with max+1 when a position has not been set already
28            if ($model->$orderColumn === null) {
29                $model->setAttribute($orderColumn, $model->getNextPosition());
30            }
31        });
32
33        static::updating(function ($model) {
34            /** @var Model $model */
35            $groupColumn = $model->getGroupColumn();
36            $orderColumn = $model->getOrderColumn();
37            $query = $model->newQueryInSameGroup();
38
39            // only automatically calculate next position with max+1 when a position has not been set already,
40            // or if the group is changing
41            if ($model->getPosition() === null || ($groupColumn && $model->isDirty($groupColumn))) {
42                $model->setAttribute($orderColumn, $query->max($orderColumn) + 1);
43            }
44        });
45
46        static::updated(function ($model) {
47            $groupColumn = $model->getGroupColumn();
48            // If the group was changed, we need to refresh the position for the
49            // former group:
50            if ($groupColumn && $model->isDirty($groupColumn)) {
51                $group = $model->getGroup(true);
52                static::whereGroup($group)->ordered()->updateColumnWithRowNumber($model->getOrderColumn());
53            }
54        });
55
56        static::deleting(function ($model) {
57            $model->getConnection()->beginTransaction();
58        });
59
60        static::deleted(function ($model) {
61            /** @var Model $model */
62            $model->next()->decrement($model->getOrderColumn());
63            $model->getConnection()->commit();
64        });
65    }
66
67    public function newCollection(array $models = [])
68    {
69        return new OrderableCollection($models);
70    }
71
72    /**
73     * @return string
74     */
75    public function getOrderColumn()
76    {
77        return property_exists($this, 'orderColumn') ? $this->orderColumn : 'position';
78    }
79
80    public function getPosition()
81    {
82        return $this->{$this->getOrderColumn()};
83    }
84
85    /**
86     * Returns the maximum possible position for the current model.
87     *
88     * @return int
89     */
90    public function getMaxPosition()
91    {
92        return $this->newQueryInSameGroup()->max($this->getOrderColumn());
93    }
94
95    /**
96     * Returns the position for a newly inserted model.
97     *
98     * @return int
99     */
100    public function getNextPosition()
101    {
102        return $this->getMaxPosition() + 1;
103    }
104
105    /**
106     * @param Builder $query
107     * @param string $direction
108     *
109     * @return void
110     */
111    public function scopeOrdered($query, $direction = 'asc')
112    {
113        $this->scopeUnordered($query);
114        $query->orderBy($this->getOrderColumn(), $direction);
115    }
116
117    /**
118     * @param Builder $query
119     *
120     * @return void
121     */
122    public function scopeUnordered($query)
123    {
124        $query->getQuery()->orders = collect($query->getQuery()->orders)
125                ->reject(function ($order) {
126                    return isset($order['column'])
127                           ? $order['column'] === $this->getOrderColumn() : false;
128                })->values()->all();
129    }
130
131    /**
132     * @param Builder $query
133     * @param  mixed  $column
134     * @param  string  $direction
135     *
136     * @return void
137     */
138    public function scopeForceOrderBy($query, $column, $direction = 'asc')
139    {
140        $this->scopeUnordered($query);
141        $query->orderBy($column, $direction);
142    }
143
144    /**
145     *
146     * @param int $newOffset
147     *
148     * @throws PositionException
149     */
150    public function moveToOffset($newOffset)
151    {
152        $query = $this->newQueryInSameGroup();
153        $count = $query->count();
154
155        if ($newOffset < 0) {
156            $newOffset = $count + $newOffset;
157        }
158        if ($newOffset < 0 || $newOffset >= $count) {
159            throw new PositionException("Invalid offset $newOffset!");
160        }
161
162        $oldOffset = $this->previous()->count();
163
164        if ($oldOffset === $newOffset) {
165            return $this;
166        }
167
168        $entity = $query->ordered()->offset($newOffset)->first();
169        if ($oldOffset < $newOffset) {
170            return $this->moveAfter($entity);
171        } else {
172            return $this->moveBefore($entity);
173        }
174    }
175
176    /**
177     * Moves the current model to the first position.
178     *
179     * @return $this
180     */
181    public function moveToStart()
182    {
183        return $this->moveToOffset(0);
184    }
185
186    /**
187     * moves $this model to the last position.
188     *
189     */
190    public function moveToEnd()
191    {
192        return $this->moveToOffset(-1);
193    }
194
195    /**
196     *
197     * @param int $newPosition
198     */
199    public function moveToPosition($newPosition)
200    {
201        return $this->moveToOffset($newPosition - 1);
202    }
203
204    /**
205     *
206     * @param int $positions
207     * @param boolean $strict
208     */
209    public function moveUp($positions = 1, $strict = true)
210    {
211        $currentPosition = $this->getPosition();
212        $newPosition = $currentPosition - $positions;
213        if (!$strict) {
214            $newPosition = max(1, $newPosition);
215            $newPosition = min($this->getMaxPosition(), $newPosition);
216        }
217        return $this->moveToPosition($newPosition);
218    }
219
220    /**
221     *
222     * @param int $positions
223     * @param boolean $strict
224     */
225    public function moveDown($positions = 1, $strict = true)
226    {
227        return $this->moveUp(-$positions, $strict);
228    }
229
230    /**
231     *
232     * @param static $entity
233     *
234     * @throws GroupException
235     */
236    public function swapWith($entity)
237    {
238        if (!$this->isInSameGroupAs($entity)) {
239            throw new GroupException('Both models must be in the same group!');
240        }
241
242        $this->getConnection()->transaction(function () use ($entity) {
243            $orderColumn = $this->getOrderColumn();
244
245            $oldPosition = $this->getPosition();
246            $newPosition = $entity->getPosition();
247
248            if ($oldPosition === $newPosition) {
249                return;
250            }
251
252            $this->setAttribute($orderColumn, $newPosition);
253            $entity->setAttribute($orderColumn, $oldPosition);
254
255            $this->save();
256            $entity->save();
257        });
258        return $this;
259    }
260
261    /**
262     * moves $this model after $entity model (and rearrange all entities).
263     *
264     * @param static $entity
265     *
266     * @throws GroupException
267     */
268    public function moveAfter($entity)
269    {
270        return $this->move('moveAfter', $entity);
271    }
272
273    /**
274     * moves $this model before $entity model (and rearrange all entities).
275     *
276     * @param static $entity
277     *
278     * @throws GroupException
279     */
280    public function moveBefore($entity)
281    {
282        return $this->move('moveBefore', $entity);
283    }
284
285    /**
286     * @param string $action moveAfter/moveBefore
287     * @param static $entity
288     *
289     * @throws GroupException
290     */
291    protected function move($action, $entity)
292    {
293        if (!$this->isInSameGroupAs($entity)) {
294            throw new GroupException('Both models must be in same group!');
295        }
296
297        $this->getConnection()->transaction(function () use ($entity, $action) {
298            $orderColumn = $this->getOrderColumn();
299
300            $oldPosition = $this->getPosition();
301            $newPosition = $entity->getPosition();
302
303            if ($oldPosition === $newPosition) {
304                return;
305            }
306
307            $isMoveBefore = $action === 'moveBefore'; // otherwise moveAfter
308            $isMoveForward = $oldPosition < $newPosition;
309
310            if ($isMoveForward) {
311                $this->newQueryBetween($oldPosition, $newPosition)->decrement($orderColumn);
312            } else {
313                $this->newQueryBetween($newPosition, $oldPosition)->increment($orderColumn);
314            }
315
316            $this->setAttribute($orderColumn, $this->getNewPosition($isMoveBefore, $isMoveForward, $newPosition));
317            $entity->setAttribute($orderColumn, $this->getNewPosition(!$isMoveBefore, $isMoveForward, $newPosition));
318
319            $this->save();
320            $entity->save();
321        });
322        return $this;
323    }
324
325    /**
326     * @param bool $isMoveBefore
327     * @param bool $isMoveForward
328     * @param      $position
329     *
330     * @return mixed
331     */
332    protected function getNewPosition($isMoveBefore, $isMoveForward, $position)
333    {
334        if (!$isMoveBefore) {
335            ++$position;
336        }
337
338        if ($isMoveForward) {
339            --$position;
340        }
341
342        return $position;
343    }
344
345    /**
346     * @param $leftPosition
347     * @param $rightPosition
348     *
349     * @return Builder
350     */
351    protected function newQueryBetween($leftPosition, $rightPosition)
352    {
353        $orderColumn = $this->getOrderColumn();
354        $query = $this->newQueryInSameGroup();
355
356        if (!is_null($leftPosition)) {
357            $query->where($orderColumn, '>', $leftPosition);
358        }
359        if (!is_null($rightPosition)) {
360            $query->where($orderColumn, '<', $rightPosition);
361        }
362        return $query;
363    }
364
365    /**
366     * @param int $limit
367     *
368     * @return Builder
369     */
370    public function previous($limit = null)
371    {
372        $query = $this->newQueryBetween(null, $this->getPosition())->ordered('desc');
373        if ($limit) {
374            $query->limit($limit);
375        }
376        return $query;
377    }
378
379    /**
380     * @param int $limit
381     *
382     * @return Builder
383     */
384    public function next($limit = null)
385    {
386        $query = $this->newQueryBetween($this->getPosition(), null)->ordered();
387        if ($limit) {
388            $query->limit($limit);
389        }
390        return $query;
391    }
392
393    /**
394     * Reorders the elements based on their ids.
395     *
396     * @param Builder $query
397     * @param array $ids
398     * @return int
399     */
400    public function scopeSetOrder($query, $ids)
401    {
402        $query = clone $query;
403        $query->setQuery($query->getQuery()->cloneWithout(['orders'])->cloneWithoutBindings(['order']));
404
405        $instance = $query->getModel();
406        $pdo = $instance->getConnection()->getPdo();
407
408        // We're selecting only the necessary columns:
409        $orderColumn = $instance->getOrderColumn();
410        $groupColumn = $instance->getGroupColumn();
411        $columns = [
412            $instance->getKeyName(),
413            $orderColumn,
414        ];
415        if ($groupColumn) {
416            $columns = array_merge($columns, (array) $groupColumn);
417        }
418
419        $collection = $query->orderByValues($instance->getKeyName(), $ids)
420                ->ordered()
421                ->select($columns)
422                ->get();
423
424        $oldPositions = $collection->pluck($orderColumn);
425        $collection->saveOrder();
426        $newPositions = $collection->pluck($orderColumn);
427
428        return $oldPositions->combine($newPositions)->map(function ($new, $old) {
429            return (int) ($old != $new);
430        })->sum();
431    }
432}