1: <?php
2:
3: namespace Baril\Orderly\Concerns;
4:
5: use Baril\Orderly\GroupException;
6: use Baril\Orderly\OrderableCollection;
7: use Baril\Orderly\PositionException;
8: use Illuminate\Database\Eloquent\Model;
9: use Illuminate\Database\Eloquent\Builder;
10:
11: /**
12: * @property string $orderColumn
13: */
14: trait 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: }
433: