1: <?php
2:
3: namespace Baril\Orderly\Relations\Concerns;
4:
5: use Baril\Orderly\GroupException;
6: use Baril\Orderly\PositionException;
7: use Illuminate\Database\Eloquent\Model;
8:
9: trait 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: }
485: