1 | <?php |
||
2 | /** |
||
3 | * @link https://github.com/paulzi/yii2-sortable |
||
4 | * @copyright Copyright (c) 2015 PaulZi <[email protected]> |
||
5 | * @license MIT (https://github.com/paulzi/yii2-sortable/blob/master/LICENSE) |
||
6 | */ |
||
7 | |||
8 | namespace paulzi\sortable; |
||
9 | |||
10 | use yii\base\Behavior; |
||
11 | use yii\db\ActiveQuery; |
||
12 | use yii\db\ActiveRecord; |
||
13 | use yii\db\Expression; |
||
14 | |||
15 | |||
16 | /** |
||
17 | * Sortable Behavior for Yii2 |
||
18 | * @author PaulZi <[email protected]> |
||
19 | * |
||
20 | * @property ActiveRecord $owner |
||
21 | */ |
||
22 | class SortableBehavior extends Behavior |
||
23 | { |
||
24 | const OPERATION_FIRST = 1; |
||
25 | const OPERATION_LAST = 2; |
||
26 | const OPERATION_POSITION_BACKWARD = 3; |
||
27 | const OPERATION_POSITION_FORWARD = 4; |
||
28 | |||
29 | /** |
||
30 | * List of attributes, callable or ActiveQuery |
||
31 | * The list of attributes - a simple way to scope elements with the same content fields, the aliases do not need. |
||
32 | * Warning! You MUST use tableName() alias in ActiveQuery, when you are using joinMode: |
||
33 | * For example, |
||
34 | * |
||
35 | * ~~~ |
||
36 | * public function behaviors() |
||
37 | * { |
||
38 | * return [ |
||
39 | * [ |
||
40 | * 'class' => SortableBehavior::className(), |
||
41 | * 'query' => ['parent_id'], |
||
42 | * ] |
||
43 | * ]; |
||
44 | * } |
||
45 | * ~~~ |
||
46 | * |
||
47 | * This is equivalent to: |
||
48 | * |
||
49 | * ~~~ |
||
50 | * public function behaviors() |
||
51 | * { |
||
52 | * return [ |
||
53 | * [ |
||
54 | * 'class' => SortableBehavior::className(), |
||
55 | * 'query' => function ($model) { |
||
56 | * $tableName = $model->tableName(); |
||
57 | * return $model->find()->andWhere(["{$tableName}.[[parent_id]]" => $model->parent_id]); |
||
58 | * }, |
||
59 | * ] |
||
60 | * ]; |
||
61 | * } |
||
62 | * ~~~ |
||
63 | * |
||
64 | * @var array|callable|ActiveQuery |
||
65 | */ |
||
66 | public $query; |
||
67 | |||
68 | /** |
||
69 | * @var string |
||
70 | */ |
||
71 | public $sortAttribute = 'sort'; |
||
72 | |||
73 | /** |
||
74 | * @var int |
||
75 | */ |
||
76 | public $step = 100; |
||
77 | |||
78 | /** |
||
79 | * Search method of unallocated value. |
||
80 | * When joinMode is true, using join table with self. Otherwise, use the search in the window. Window size defined by $windowSize property. |
||
81 | * @var bool |
||
82 | */ |
||
83 | public $joinMode = true; |
||
84 | |||
85 | /** |
||
86 | * Defines the size of the search window, when joinMode is false. |
||
87 | * @var int |
||
88 | */ |
||
89 | public $windowSize = 1000; |
||
90 | |||
91 | /** |
||
92 | * @var integer|null |
||
93 | */ |
||
94 | protected $operation; |
||
95 | |||
96 | /** |
||
97 | * @var integer |
||
98 | */ |
||
99 | protected $position; |
||
100 | |||
101 | |||
102 | /** |
||
103 | * @inheritdoc |
||
104 | */ |
||
105 | 63 | public function events() |
|
106 | { |
||
107 | return [ |
||
108 | 63 | ActiveRecord::EVENT_BEFORE_INSERT => 'beforeSave', |
|
109 | 63 | ActiveRecord::EVENT_AFTER_INSERT => 'afterSave', |
|
110 | 63 | ActiveRecord::EVENT_BEFORE_UPDATE => 'beforeSave', |
|
111 | 63 | ActiveRecord::EVENT_AFTER_UPDATE => 'afterSave', |
|
112 | 63 | ]; |
|
113 | } |
||
114 | |||
115 | /** |
||
116 | * @return integer |
||
117 | */ |
||
118 | 3 | public function getSortablePosition() |
|
119 | { |
||
120 | 3 | return $this->owner->getAttribute($this->sortAttribute); |
|
121 | } |
||
122 | |||
123 | /** |
||
124 | * @return ActiveRecord |
||
125 | */ |
||
126 | 15 | public function moveFirst() |
|
127 | { |
||
128 | 15 | $this->operation = self::OPERATION_FIRST; |
|
129 | 15 | return $this->owner; |
|
130 | } |
||
131 | |||
132 | /** |
||
133 | * @return ActiveRecord |
||
134 | */ |
||
135 | 15 | public function moveLast() |
|
136 | { |
||
137 | 15 | $this->operation = self::OPERATION_LAST; |
|
138 | 15 | return $this->owner; |
|
139 | } |
||
140 | |||
141 | /** |
||
142 | * @param integer $position |
||
143 | * @param bool $forward Move existing items to forward or backward |
||
144 | * @return ActiveRecord |
||
145 | */ |
||
146 | 21 | public function moveTo($position, $forward = true) |
|
147 | { |
||
148 | 21 | $this->operation = $forward ? self::OPERATION_POSITION_FORWARD : self::OPERATION_POSITION_BACKWARD; |
|
149 | 21 | $this->position = (int)$position; |
|
150 | 21 | return $this->owner; |
|
151 | } |
||
152 | |||
153 | /** |
||
154 | * @param ActiveRecord $model |
||
155 | * @return ActiveRecord |
||
156 | */ |
||
157 | 3 | public function moveBefore($model) |
|
158 | { |
||
159 | 3 | return $this->moveTo($model->getAttribute($this->sortAttribute) - 1, false); |
|
160 | } |
||
161 | |||
162 | /** |
||
163 | * @param ActiveRecord $model |
||
164 | * @return ActiveRecord |
||
165 | */ |
||
166 | 3 | public function moveAfter($model) |
|
167 | { |
||
168 | 3 | return $this->moveTo($model->getAttribute($this->sortAttribute) + 1, true); |
|
169 | } |
||
170 | |||
171 | /** |
||
172 | * Reorders items with values of sortAttribute begin from zero. |
||
173 | * @param bool $middle |
||
174 | * @return integer |
||
175 | * @throws \Exception |
||
176 | */ |
||
177 | 3 | public function reorder($middle = true) |
|
178 | { |
||
179 | 3 | $result = 0; |
|
180 | \Yii::$app->getDb()->transaction(function () use (&$result, $middle) { |
||
181 | 3 | $list = $this->getQueryInternal() |
|
182 | 3 | ->select($this->owner->primaryKey()) |
|
183 | 3 | ->orderBy([$this->sortAttribute => SORT_ASC]) |
|
184 | 3 | ->asArray() |
|
185 | 3 | ->all(); |
|
186 | 3 | $from = $middle ? count($list) >> 1 : 0; |
|
187 | 3 | foreach ($list as $i => $item) { |
|
188 | 3 | $result += $this->owner->updateAll([$this->sortAttribute => ($i - $from) * $this->step], $item); |
|
189 | 3 | } |
|
190 | 3 | }); |
|
191 | |||
192 | 3 | return $result; |
|
193 | } |
||
194 | |||
195 | /** |
||
196 | * |
||
197 | */ |
||
198 | 57 | public function beforeSave() |
|
199 | { |
||
200 | 57 | if ($this->owner->getIsNewRecord() && $this->operation === null) { |
|
201 | 3 | $this->operation = self::OPERATION_LAST; |
|
202 | 3 | } |
|
203 | |||
204 | 57 | switch ($this->operation) { |
|
205 | 57 | case self::OPERATION_FIRST: |
|
206 | 57 | case self::OPERATION_LAST: |
|
207 | 33 | $query = $this->getQueryInternal(); |
|
208 | 33 | $query->orderBy(null); |
|
209 | 33 | $position = $this->operation === self::OPERATION_LAST ? $query->max($this->sortAttribute) : $query->min($this->sortAttribute); |
|
210 | |||
211 | 33 | $isSelf = false; |
|
212 | 33 | if ($position !== null && !$this->owner->getIsNewRecord() && (int)$position === $this->owner->getAttribute($this->sortAttribute)) { |
|
213 | 6 | if ($this->query instanceof ActiveQuery || is_callable($this->query)) { |
|
214 | 6 | $isSelf = $this->getQueryInternal() |
|
215 | 6 | ->andWhere([$this->sortAttribute => $position]) |
|
216 | 6 | ->andWhere($this->selfCondition()) |
|
217 | 6 | ->exists(); |
|
218 | |||
219 | 6 | } else { |
|
220 | 6 | $isSelf = count($this->owner->getDirtyAttributes($this->query)) === 0; |
|
221 | } |
||
222 | 6 | } |
|
223 | |||
224 | 33 | if ($position === null) { |
|
225 | 6 | $this->owner->setAttribute($this->sortAttribute, 0); |
|
226 | 33 | } elseif (!$isSelf) { |
|
227 | 21 | if ($this->operation === self::OPERATION_LAST) { |
|
228 | 12 | $this->owner->setAttribute($this->sortAttribute, $position + $this->step); |
|
229 | 12 | } else { |
|
230 | 9 | $this->owner->setAttribute($this->sortAttribute, $position - $this->step); |
|
231 | } |
||
232 | 21 | } |
|
233 | 33 | break; |
|
234 | |||
235 | 24 | case self::OPERATION_POSITION_BACKWARD: |
|
236 | 24 | case self::OPERATION_POSITION_FORWARD: |
|
237 | 21 | $this->moveToInternal($this->position, $this->operation === self::OPERATION_POSITION_FORWARD); |
|
238 | 21 | break; |
|
239 | 57 | } |
|
240 | 57 | } |
|
241 | |||
242 | /** |
||
243 | * |
||
244 | */ |
||
245 | 57 | public function afterSave() |
|
246 | { |
||
247 | 57 | $this->operation = null; |
|
248 | 57 | } |
|
249 | |||
250 | /** |
||
251 | * @return ActiveQuery |
||
252 | */ |
||
253 | 57 | protected function getQueryInternal() |
|
254 | { |
||
255 | 57 | if ($this->query instanceof ActiveQuery) { |
|
256 | $query = clone $this->query; |
||
257 | return $query; |
||
258 | 57 | } elseif (is_callable($this->query)) { |
|
259 | 57 | return call_user_func($this->query, $this->owner); |
|
260 | } else { |
||
261 | 57 | $tableName = $this->owner->tableName(); |
|
262 | 57 | $attributes = $this->owner->getAttributes($this->query); |
|
263 | 57 | $attributes = array_combine( |
|
264 | array_map(function ($value) use ($tableName) { return "{$tableName}.[[{$value}]]"; }, array_keys($attributes)), |
||
265 | 57 | array_values($attributes) |
|
266 | 57 | ); |
|
267 | 57 | return $this->owner->find()->andWhere($attributes); |
|
268 | } |
||
269 | } |
||
270 | |||
271 | /** |
||
272 | * @param string $tableName |
||
273 | * @param string $string |
||
274 | * @return string |
||
275 | */ |
||
276 | 21 | protected static function getJoinConditionReplace($tableName, $string) |
|
277 | { |
||
278 | 21 | return str_replace($tableName . '.', 'n.', $string); |
|
279 | } |
||
280 | |||
281 | /** |
||
282 | * @param string $tableName |
||
283 | * @param array|string $condition |
||
284 | * @return array|string |
||
285 | */ |
||
286 | 21 | protected static function getJoinCondition($tableName, $condition) |
|
287 | { |
||
288 | 21 | if (is_string($condition)) { |
|
289 | return static::getJoinConditionReplace($tableName, $condition); |
||
290 | 21 | } elseif (is_array($condition)) { |
|
291 | 21 | $joinCondition = []; |
|
292 | 21 | array_walk($condition, function ($value, $key) use ($tableName, &$joinCondition) { |
|
293 | 21 | $joinCondition[static::getJoinConditionReplace($tableName, $key)] = static::getJoinCondition($tableName, $value); |
|
294 | 21 | }); |
|
295 | 21 | return $joinCondition; |
|
296 | 21 | } elseif ($condition instanceof Expression) { |
|
297 | $condition->expression = static::getJoinConditionReplace($tableName, $condition->expression); |
||
298 | } |
||
299 | 21 | return $condition; |
|
300 | } |
||
301 | |||
302 | /** |
||
303 | * @return array |
||
304 | */ |
||
305 | 27 | protected function selfCondition() |
|
306 | { |
||
307 | 27 | $tableName = $this->owner->tableName(); |
|
308 | 27 | $result = []; |
|
309 | 27 | foreach ($this->owner->getPrimaryKey(true) as $field => $value) { |
|
310 | 27 | $result["{$tableName}.[[{$field}]]"] = $value; |
|
311 | 27 | } |
|
312 | 27 | return $result; |
|
313 | } |
||
314 | |||
315 | /** |
||
316 | * @param integer $from |
||
317 | * @param integer|null $to |
||
318 | * @param bool $forward |
||
319 | */ |
||
320 | 21 | protected function shift($from, $to, $forward) |
|
321 | { |
||
322 | 21 | $query = $this->getQueryInternal(); |
|
323 | 21 | if ($to === null) { |
|
324 | 6 | $condition = [$forward ? '>=' : '<=', $this->sortAttribute, $from]; |
|
325 | 6 | } else { |
|
326 | 18 | $condition = ['between', $this->sortAttribute, $forward ? $from : $to, $forward ? $to : $from]; |
|
327 | } |
||
328 | 21 | $this->owner->updateAll( |
|
329 | 21 | [$this->sortAttribute => new Expression("[[{$this->sortAttribute}]] " . ($forward ? '+ 1' : '- 1'))], |
|
330 | [ |
||
331 | 21 | 'and', |
|
332 | 21 | $query->where, |
|
333 | 21 | $condition, |
|
334 | ] |
||
335 | 21 | ); |
|
336 | 21 | } |
|
337 | |||
338 | /** |
||
339 | * @param integer $position |
||
340 | * @param bool $forward |
||
341 | */ |
||
342 | 21 | protected function moveToInternal($position, $forward) |
|
343 | { |
||
344 | 21 | if ($this->joinMode) { |
|
345 | 21 | $this->moveToInternalJoinMode($position, $forward); |
|
346 | 21 | } else { |
|
347 | 21 | $this->moveToInternalWindowMode($position, $forward); |
|
348 | } |
||
349 | 21 | } |
|
350 | |||
351 | /** |
||
352 | * @param integer $position |
||
353 | * @param bool $forward |
||
354 | */ |
||
355 | 21 | protected function moveToInternalJoinMode($position, $forward) |
|
356 | { |
||
357 | 21 | $this->owner->setAttribute($this->sortAttribute, $position); |
|
358 | |||
359 | 21 | $tableName = $this->owner->tableName(); |
|
360 | 21 | $query = $this->getQueryInternal(); |
|
361 | $joinCondition = [ |
||
362 | 21 | 'and', |
|
363 | 21 | static::getJoinCondition($tableName, $query->where), |
|
364 | 21 | ["n.[[{$this->sortAttribute}]]" => new Expression("{$tableName}.[[{$this->sortAttribute}]] " . ($forward ? '+ 1' : '- 1'))], |
|
365 | 21 | ]; |
|
366 | 21 | if (!$this->owner->getIsNewRecord()) { |
|
367 | 15 | $joinCondition[] = ['not', static::getJoinCondition($tableName, $this->selfCondition())]; |
|
368 | 15 | } |
|
369 | |||
370 | $exists = $query |
||
371 | 21 | ->andWhere(["{$tableName}.[[{$this->sortAttribute}]]" => $position]) |
|
372 | 21 | ->andWhere(['not', $this->selfCondition()]) |
|
373 | 21 | ->exists(); |
|
374 | 21 | if ($exists) { |
|
375 | 12 | $unallocated = $this->getQueryInternal() |
|
376 | 12 | ->select("{$tableName}.[[{$this->sortAttribute}]]") |
|
377 | 12 | ->leftJoin("{$tableName} n", $joinCondition) |
|
378 | 12 | ->andWhere([ |
|
379 | 12 | 'and', |
|
380 | 12 | [$forward ? '>=' : '<=', "{$tableName}.[[{$this->sortAttribute}]]", $position - ($forward ? 1 : -1)], |
|
381 | 12 | ["n.[[{$this->sortAttribute}]]" => null], |
|
382 | 12 | ]) |
|
383 | 12 | ->orderBy(["{$tableName}.[[{$this->sortAttribute}]]" => $forward ? SORT_ASC : SORT_DESC]) |
|
384 | 12 | ->limit(1) |
|
385 | 12 | ->scalar(); |
|
386 | 12 | $this->shift($position, $unallocated, $forward); |
|
387 | 12 | } |
|
388 | 21 | } |
|
389 | |||
390 | /** |
||
391 | * @param integer $position |
||
392 | * @param bool $forward |
||
393 | */ |
||
394 | 21 | protected function moveToInternalWindowMode($position, $forward) |
|
395 | { |
||
396 | 21 | $this->owner->setAttribute($this->sortAttribute, $position); |
|
397 | |||
398 | 21 | $tableName = $this->owner->tableName(); |
|
399 | 21 | $query = $this->getQueryInternal(); |
|
400 | 21 | if (!$this->owner->getIsNewRecord()) { |
|
401 | 15 | $query->andWhere(['not', $this->selfCondition()]); |
|
402 | 15 | } |
|
403 | |||
404 | $list = $query |
||
405 | 21 | ->select("{$tableName}.[[{$this->sortAttribute}]]") |
|
406 | 21 | ->andWhere([$forward ? '>=' : '<=', "{$tableName}.[[{$this->sortAttribute}]]", $position]) |
|
407 | 21 | ->orderBy(["{$tableName}.[[{$this->sortAttribute}]]" => $forward ? SORT_ASC : SORT_DESC]) |
|
408 | 21 | ->limit($this->windowSize) |
|
409 | 21 | ->column(); |
|
410 | 21 | $unallocated = null; |
|
411 | 21 | $prev = $position - ($forward ? 1 : -1); |
|
412 | 21 | foreach ($list as $item) { |
|
413 | 21 | if (abs($item - $prev) > 1) { |
|
414 | 15 | $unallocated = $prev; |
|
415 | 15 | break; |
|
416 | } |
||
417 | 9 | $prev = $item; |
|
418 | 21 | } |
|
419 | |||
420 | 21 | $this->shift($position, $unallocated, $forward); |
|
421 | 21 | } |
|
422 | } |
||
423 |