Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.37% covered (success)
94.37%
134 / 142
75.00% covered (warning)
75.00%
18 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Orderable
94.37% covered (success)
94.37%
134 / 142
75.00% covered (warning)
75.00%
18 / 24
50.45
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
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 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%
7 / 7
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
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 next
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 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->$orderColumn === 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        $orderColumn = $this->getOrderColumn();
212        $currentPosition = $this->getAttribute($orderColumn);
213        $newPosition = $currentPosition - $positions;
214        if (!$strict) {
215            $newPosition = max(1, $newPosition);
216            $newPosition = min($this->getMaxPosition(), $newPosition);
217        }
218        return $this->moveToPosition($newPosition);
219    }
220
221    /**
222     *
223     * @param int $positions
224     * @param boolean $strict
225     */
226    public function moveDown($positions = 1, $strict = true)
227    {
228        return $this->moveUp(-$positions, $strict);
229    }
230
231    /**
232     *
233     * @param static $entity
234     *
235     * @throws GroupException
236     */
237    public function swapWith($entity)
238    {
239        if (!$this->isInSameGroupAs($entity)) {
240            throw new GroupException('Both models must be in the same group!');
241        }
242
243        $this->getConnection()->transaction(function () use ($entity) {
244            $orderColumn = $this->getOrderColumn();
245
246            $oldPosition = $this->getAttribute($orderColumn);
247            $newPosition = $entity->getAttribute($orderColumn);
248
249            if ($oldPosition === $newPosition) {
250                return;
251            }
252
253            $this->setAttribute($orderColumn, $newPosition);
254            $entity->setAttribute($orderColumn, $oldPosition);
255
256            $this->save();
257            $entity->save();
258        });
259        return $this;
260    }
261
262    /**
263     * moves $this model after $entity model (and rearrange all entities).
264     *
265     * @param static $entity
266     *
267     * @throws GroupException
268     */
269    public function moveAfter($entity)
270    {
271        return $this->move('moveAfter', $entity);
272    }
273
274    /**
275     * moves $this model before $entity model (and rearrange all entities).
276     *
277     * @param static $entity
278     *
279     * @throws GroupException
280     */
281    public function moveBefore($entity)
282    {
283        return $this->move('moveBefore', $entity);
284    }
285
286    /**
287     * @param string $action moveAfter/moveBefore
288     * @param static $entity
289     *
290     * @throws GroupException
291     */
292    protected function move($action, $entity)
293    {
294        if (!$this->isInSameGroupAs($entity)) {
295            throw new GroupException('Both models must be in same group!');
296        }
297
298        $this->getConnection()->transaction(function () use ($entity, $action) {
299            $orderColumn = $this->getOrderColumn();
300
301            $oldPosition = $this->getAttribute($orderColumn);
302            $newPosition = $entity->getAttribute($orderColumn);
303
304            if ($oldPosition === $newPosition) {
305                return;
306            }
307
308            $isMoveBefore = $action === 'moveBefore'; // otherwise moveAfter
309            $isMoveForward = $oldPosition < $newPosition;
310
311            if ($isMoveForward) {
312                $this->newQueryBetween($oldPosition, $newPosition)->decrement($orderColumn);
313            } else {
314                $this->newQueryBetween($newPosition, $oldPosition)->increment($orderColumn);
315            }
316
317            $this->setAttribute($orderColumn, $this->getNewPosition($isMoveBefore, $isMoveForward, $newPosition));
318            $entity->setAttribute($orderColumn, $this->getNewPosition(!$isMoveBefore, $isMoveForward, $newPosition));
319
320            $this->save();
321            $entity->save();
322        });
323        return $this;
324    }
325
326    /**
327     * @param bool $isMoveBefore
328     * @param bool $isMoveForward
329     * @param      $position
330     *
331     * @return mixed
332     */
333    protected function getNewPosition($isMoveBefore, $isMoveForward, $position)
334    {
335        if (!$isMoveBefore) {
336            ++$position;
337        }
338
339        if ($isMoveForward) {
340            --$position;
341        }
342
343        return $position;
344    }
345
346    /**
347     * @param $leftPosition
348     * @param $rightPosition
349     *
350     * @return Builder
351     */
352    protected function newQueryBetween($leftPosition, $rightPosition)
353    {
354        $orderColumn = $this->getOrderColumn();
355        $query = $this->newQueryInSameGroup();
356
357        if (!is_null($leftPosition)) {
358            $query->where($orderColumn, '>', $leftPosition);
359        }
360        if (!is_null($rightPosition)) {
361            $query->where($orderColumn, '<', $rightPosition);
362        }
363        return $query;
364    }
365
366    /**
367     * @param int $limit
368     *
369     * @return Builder
370     */
371    public function previous($limit = null)
372    {
373        $orderColumn = $this->getOrderColumn();
374        $query = $this->newQueryBetween(null, $this->getAttribute($orderColumn))->ordered('desc');
375        if ($limit) {
376            $query->limit($limit);
377        }
378        return $query;
379    }
380
381    /**
382     * @param int $limit
383     *
384     * @return Builder
385     */
386    public function next($limit = null)
387    {
388        $orderColumn = $this->getOrderColumn();
389        $query = $this->newQueryBetween($this->getAttribute($orderColumn), null)->ordered();
390        if ($limit) {
391            $query->limit($limit);
392        }
393        return $query;
394    }
395
396    /**
397     * Reorders the elements based on their ids.
398     *
399     * @param Builder $query
400     * @param array $ids
401     * @return int
402     */
403    public function scopeSetOrder($query, $ids)
404    {
405        $query = clone $query;
406        $query->setQuery($query->getQuery()->cloneWithout(['orders'])->cloneWithoutBindings(['order']));
407
408        $instance = $query->getModel();
409        $pdo = $instance->getConnection()->getPdo();
410
411        // We're selecting only the necessary columns:
412        $orderColumn = $instance->getOrderColumn();
413        $groupColumn = $instance->getGroupColumn();
414        $columns = [
415            $instance->getKeyName(),
416            $orderColumn,
417        ];
418        if ($groupColumn) {
419            $columns = array_merge($columns, (array) $groupColumn);
420        }
421
422        $collection = $query->orderByValues($instance->getKeyName(), $ids)
423                ->ordered()
424                ->select($columns)
425                ->get();
426
427        $oldPositions = $collection->pluck($orderColumn);
428        $collection->saveOrder();
429        $newPositions = $collection->pluck($orderColumn);
430
431        return $oldPositions->combine($newPositions)->map(function ($new, $old) {
432            return (int) ($old != $new);
433        })->sum();
434    }
435}