Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
96.58% |
141 / 146 |
|
86.21% |
25 / 29 |
CRAP | |
0.00% |
0 / 1 |
InteractsWithOrderablePivotTable | |
96.58% |
141 / 146 |
|
86.21% |
25 / 29 |
55 | |
0.00% |
0 / 1 |
setOrderColumn | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getOrderColumn | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getQualifiedOrderColumn | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getMaxPosition | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getNextPosition | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
ordered | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
unordered | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
forceOrderBy | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
newPivotQuery | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
newPivotQueryBetween | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
parsePivot | |
75.00% |
6 / 8 |
|
0.00% |
0 / 1 |
5.39 | |||
moveToOffset | |
94.44% |
17 / 18 |
|
0.00% |
0 / 1 |
6.01 | |||
moveToStart | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
moveToEnd | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
moveToPosition | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
moveUp | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
moveDown | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
swap | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
2.00 | |||
moveAfter | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
moveBefore | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
move | |
95.83% |
23 / 24 |
|
0.00% |
0 / 1 |
3 | |||
getNewPosition | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
attachNew | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
attach | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
detach | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
setOrder | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
2 | |||
refreshPositions | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
before | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
after | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 |
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 | } |