acts_as_list 0.9.17 → 1.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.github/FUNDING.yml +3 -0
- data/.travis.yml +11 -40
- data/Appraisals +15 -31
- data/CHANGELOG.md +411 -348
- data/Gemfile +6 -5
- data/README.md +105 -7
- data/Rakefile +1 -1
- data/acts_as_list.gemspec +15 -10
- data/gemfiles/rails_4_2.gemfile +5 -6
- data/gemfiles/rails_5_0.gemfile +4 -6
- data/gemfiles/rails_5_1.gemfile +4 -6
- data/gemfiles/rails_5_2.gemfile +4 -6
- data/gemfiles/{rails_4_1.gemfile → rails_6_0.gemfile} +4 -7
- data/lib/acts_as_list/active_record/acts/list.rb +54 -60
- data/lib/acts_as_list/active_record/acts/no_update.rb +14 -4
- data/lib/acts_as_list/active_record/acts/position_column_method_definer.rb +18 -5
- data/lib/acts_as_list/active_record/acts/scope_method_definer.rb +7 -5
- data/lib/acts_as_list/active_record/acts/sequential_updates_method_definer.rb +1 -1
- data/lib/acts_as_list/version.rb +1 -1
- data/test/helper.rb +1 -10
- data/test/shared_list.rb +8 -4
- data/test/shared_list_sub.rb +1 -1
- data/test/test_list.rb +192 -57
- data/test/test_no_update_for_extra_classes.rb +30 -5
- data/test/test_no_update_for_scope_destruction.rb +2 -7
- data/test/test_no_update_for_subclasses.rb +3 -3
- data/test/test_scope_with_user_defined_foreign_key.rb +42 -0
- metadata +17 -13
- data/gemfiles/rails_3_2.gemfile +0 -34
data/Gemfile
CHANGED
@@ -1,7 +1,5 @@
|
|
1
1
|
source "http://rubygems.org"
|
2
2
|
|
3
|
-
gem "rack", "~> 1", platforms: [:ruby_19, :ruby_20, :ruby_21]
|
4
|
-
|
5
3
|
gemspec
|
6
4
|
|
7
5
|
gem "rake"
|
@@ -13,15 +11,18 @@ end
|
|
13
11
|
|
14
12
|
group :test do
|
15
13
|
gem "minitest", "~> 5.0"
|
16
|
-
gem "test_after_commit", "~> 0.4.2"
|
17
14
|
gem "timecop"
|
18
15
|
gem "mocha"
|
19
16
|
end
|
20
17
|
|
21
18
|
group :sqlite do
|
22
|
-
gem "sqlite3",
|
19
|
+
gem "sqlite3", "~> 1.3.13"
|
23
20
|
end
|
24
21
|
|
25
22
|
group :postgresql do
|
26
|
-
gem "pg", "~>
|
23
|
+
gem "pg", "~> 1.1.4"
|
24
|
+
end
|
25
|
+
|
26
|
+
group :mysql do
|
27
|
+
gem "mysql2", "~> 0.5.0"
|
27
28
|
end
|
data/README.md
CHANGED
@@ -1,7 +1,8 @@
|
|
1
|
-
#
|
1
|
+
# Acts As List
|
2
2
|
|
3
3
|
## Build Status
|
4
|
-
[![Build Status](https://
|
4
|
+
[![Build Status](https://travis-ci.org/brendon/acts_as_list.svg?branch=master)](https://travis-ci.org/brendon/acts_as_list)
|
5
|
+
[![Gem Version](https://badge.fury.io/rb/acts_as_list.svg)](https://badge.fury.io/rb/acts_as_list)
|
5
6
|
|
6
7
|
## Description
|
7
8
|
|
@@ -11,8 +12,8 @@ This `acts_as` extension provides the capabilities for sorting and reordering a
|
|
11
12
|
|
12
13
|
There are a couple of changes of behaviour from `0.8.0` onwards:
|
13
14
|
|
14
|
-
- If you specify `add_new_at: :top`, new items will be added to the top of the list like always. But now, if you specify a position at insert time: `.create(position: 3)`, the position will be respected. In this example, the item will end up at position `3` and will move other items further down the list. Before `0.8.0` the position would be ignored and the item would still be added to the top of the list. [#220](https://github.com/
|
15
|
-
- `acts_as_list` now copes with disparate position integers (i.e. gaps between the numbers). There has been a change in behaviour for the `higher_items` method. It now returns items with the first item in the collection being the closest item to the reference item, and the last item in the collection being the furthest from the reference item (a.k.a. the first item in the list). [#223](https://github.com/
|
15
|
+
- If you specify `add_new_at: :top`, new items will be added to the top of the list like always. But now, if you specify a position at insert time: `.create(position: 3)`, the position will be respected. In this example, the item will end up at position `3` and will move other items further down the list. Before `0.8.0` the position would be ignored and the item would still be added to the top of the list. [#220](https://github.com/brendon/acts_as_list/pull/220)
|
16
|
+
- `acts_as_list` now copes with disparate position integers (i.e. gaps between the numbers). There has been a change in behaviour for the `higher_items` method. It now returns items with the first item in the collection being the closest item to the reference item, and the last item in the collection being the furthest from the reference item (a.k.a. the first item in the list). [#223](https://github.com/brendon/acts_as_list/pull/223)
|
16
17
|
|
17
18
|
## Installation
|
18
19
|
|
@@ -104,6 +105,25 @@ TodoList.all.each do |todo_list|
|
|
104
105
|
end
|
105
106
|
```
|
106
107
|
|
108
|
+
When using PostgreSQL, it is also possible to leave this migration up to the database layer. Inside of the `change` block you could write:
|
109
|
+
|
110
|
+
```ruby
|
111
|
+
execute <<~SQL.squish
|
112
|
+
UPDATE todo_items
|
113
|
+
SET position = mapping.new_position
|
114
|
+
FROM (
|
115
|
+
SELECT
|
116
|
+
id,
|
117
|
+
ROW_NUMBER() OVER (
|
118
|
+
PARTITION BY todo_list_id
|
119
|
+
ORDER BY updated_at
|
120
|
+
) as new_position
|
121
|
+
FROM todo_items
|
122
|
+
) AS mapping
|
123
|
+
WHERE todo_items.id = mapping.id;
|
124
|
+
SQL
|
125
|
+
```
|
126
|
+
|
107
127
|
## Notes
|
108
128
|
All `position` queries (select, update, etc.) inside gem methods are executed without the default scope (i.e. `Model.unscoped`), this will prevent nasty issues when the default scope is different from `acts_as_list` scope.
|
109
129
|
|
@@ -141,6 +161,10 @@ default: `position`. Use this option if the column name in your database is diff
|
|
141
161
|
default: `1`. Use this option to define the top of the list. Use 0 to make the collection act more like an array in its indexing.
|
142
162
|
- `add_new_at`
|
143
163
|
default: `:bottom`. Use this option to specify whether objects get added to the `:top` or `:bottom` of the list. `nil` will result in new items not being added to the list on create, i.e, position will be kept nil after create.
|
164
|
+
- `touch_on_update`
|
165
|
+
default: `true`. Use `touch_on_update: false` if you don't want to update the timestamps of the associated records.
|
166
|
+
- `sequential_updates`
|
167
|
+
Specifies whether insert_at should update objects positions during shuffling one by one to respect position column unique not null constraint. Defaults to true if position column has unique index, otherwise false. If constraint is `deferrable initially deferred` (PostgreSQL), overriding it with false will speed up insert_at.
|
144
168
|
|
145
169
|
## Disabling temporarily
|
146
170
|
|
@@ -180,15 +204,89 @@ TodoItem.acts_as_list_no_update([TodoAttachment]) do
|
|
180
204
|
end
|
181
205
|
```
|
182
206
|
|
207
|
+
## Troubleshooting Database Deadlock Errors
|
208
|
+
When using this gem in an app with a high amount of concurrency, you may see "deadlock" errors raised by your database server.
|
209
|
+
It's difficult for the gem to provide a solution that fits every app.
|
210
|
+
Here are some steps you can take to mitigate and handle these kinds of errors.
|
211
|
+
|
212
|
+
### 1) Use the Most Concise API
|
213
|
+
One easy way to reduce deadlocks is to use the most concise gem API available for what you want to accomplish.
|
214
|
+
In this specific example, the more concise API for creating a list item at a position results in one transaction instead of two,
|
215
|
+
and it issues fewer SQL statements. Issuing fewer statements tends to lead to faster transactions.
|
216
|
+
Faster transactions are less likely to deadlock.
|
217
|
+
|
218
|
+
Example:
|
219
|
+
```ruby
|
220
|
+
# Good
|
221
|
+
TodoItem.create(todo_list: todo_list, position: 1)
|
222
|
+
|
223
|
+
# Bad
|
224
|
+
item = TodoItem.create(todo_list: todo_list)
|
225
|
+
item.insert_at(1)
|
226
|
+
```
|
227
|
+
|
228
|
+
### 2) Rescue then Retry
|
229
|
+
Deadlocks are always a possibility when updating tables rows concurrently.
|
230
|
+
The general advice from MySQL documentation is to catch these errors and simply retry the transaction; it will probably succeed on another attempt. (see [How to Minimize and Handle Deadlocks](https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlocks-handling.html))
|
231
|
+
Retrying transactions sounds simple, but there are many details that need to be chosen on a per-app basis:
|
232
|
+
How many retry attempts should be made?
|
233
|
+
Should there be a wait time between attempts?
|
234
|
+
What _other_ statements were in the transaction that got rolled back?
|
235
|
+
|
236
|
+
Here a simple example of rescuing from deadlock and retrying the operation:
|
237
|
+
* `ActiveRecord::Deadlocked` is available in Rails >= 5.1.0.
|
238
|
+
* If you have Rails < 5.1.0, you will need to rescue `ActiveRecord::StatementInvalid` and check `#cause`.
|
239
|
+
```ruby
|
240
|
+
attempts_left = 2
|
241
|
+
while attempts_left > 0
|
242
|
+
attempts_left -= 1
|
243
|
+
begin
|
244
|
+
TodoItem.transaction do
|
245
|
+
TodoItem.create(todo_list: todo_list, position: 1)
|
246
|
+
end
|
247
|
+
attempts_left = 0
|
248
|
+
rescue ActiveRecord::Deadlocked
|
249
|
+
raise unless attempts_left > 0
|
250
|
+
end
|
251
|
+
end
|
252
|
+
```
|
253
|
+
|
254
|
+
You can also use the approach suggested in this StackOverflow post:
|
255
|
+
https://stackoverflow.com/questions/4027659/activerecord3-deadlock-retry
|
256
|
+
|
257
|
+
### 3) Lock Parent Record
|
258
|
+
In addition to reacting to deadlocks, it is possible to reduce their frequency with more pessimistic locking.
|
259
|
+
This approach uses the parent record as a mutex for the entire list.
|
260
|
+
This kind of locking is very effective at reducing the frequency of deadlocks while updating list items.
|
261
|
+
However, there are some things to keep in mind:
|
262
|
+
* This locking pattern needs to be used around *every* call that modifies the list; even if it does not reorder list items.
|
263
|
+
* This locking pattern effectively serializes operations on the list. The throughput of operations on the list will decrease.
|
264
|
+
* Locking the parent record may lead to deadlock elsewhere if some other code also locks the parent table.
|
265
|
+
|
266
|
+
Example:
|
267
|
+
```ruby
|
268
|
+
todo_list = TodoList.create(name: "The List")
|
269
|
+
todo_list.with_lock do
|
270
|
+
item = TodoItem.create(description: "Buy Groceries", todo_list: todo_list, position: 1)
|
271
|
+
end
|
272
|
+
```
|
273
|
+
|
183
274
|
## Versions
|
184
|
-
Version `0.9.0` adds `acts_as_list_no_update` (https://github.com/
|
275
|
+
Version `0.9.0` adds `acts_as_list_no_update` (https://github.com/brendon/acts_as_list/pull/244) and compatibility with not-null and uniqueness constraints on the database (https://github.com/brendon/acts_as_list/pull/246). These additions shouldn't break compatibility with existing implementations.
|
185
276
|
|
186
277
|
As of version `0.7.5` Rails 5 is supported.
|
187
278
|
|
188
279
|
All versions `0.1.5` onwards require Rails 3.0.x and higher.
|
189
280
|
|
190
|
-
##
|
191
|
-
|
281
|
+
## A note about data integrity
|
282
|
+
|
283
|
+
We often hear complaints that `position` values are repeated, incorrect etc. For example, #254. To ensure data integrity, you should rely on your database. There are two things you can do:
|
284
|
+
|
285
|
+
1. Use constraints. If you model `Item` that `belongs_to` an `Order`, and it has a `position` column, then add a unique constraint on `items` with `[:order_id, :position]`. Think of it as a list invariant. What are the properties of your list that don't change no matter how many items you have in it? One such propery is that each item has a distinct position. Another _could be_ that position is always greater than 0. It is strongly recommended that you rely on your database to enforce these invariants or constraints. Here are the docs for [PostgreSQL](https://www.postgresql.org/docs/9.5/static/ddl-constraints.html) and [MySQL](https://dev.mysql.com/doc/refman/8.0/en/alter-table.html).
|
286
|
+
2. Use mutexes or row level locks. At its heart the duplicate problem is that of handling concurrency. Adding a contention resolution mechanism like locks will solve it to some extent. But it is not a solution or replacement for constraints. Locks are also prone to deadlocks.
|
287
|
+
|
288
|
+
As a library, `acts_as_list` may not always have all the context needed to apply these tools. They are much better suited at the application level.
|
289
|
+
|
192
290
|
|
193
291
|
## Roadmap
|
194
292
|
|
data/Rakefile
CHANGED
data/acts_as_list.gemspec
CHANGED
@@ -7,23 +7,28 @@ Gem::Specification.new do |s|
|
|
7
7
|
s.name = "acts_as_list"
|
8
8
|
s.version = ActiveRecord::Acts::List::VERSION
|
9
9
|
s.platform = Gem::Platform::RUBY
|
10
|
-
s.authors = ["
|
11
|
-
s.email =
|
12
|
-
s.homepage = "http://github.com/
|
10
|
+
s.authors = ["Swanand Pagnis", "Brendon Muir"]
|
11
|
+
s.email = %w(swanand.pagnis@gmail.com brendon@spikeatschool.co.nz)
|
12
|
+
s.homepage = "http://github.com/brendon/acts_as_list"
|
13
13
|
s.summary = "A gem adding sorting, reordering capabilities to an active_record model, allowing it to act as a list"
|
14
14
|
s.description = 'This "acts_as" extension provides the capabilities for sorting and reordering a number of objects in a list. The class that has this specified needs to have a "position" column defined as an integer on the mapped database table.'
|
15
15
|
s.license = "MIT"
|
16
|
-
s.
|
17
|
-
|
16
|
+
s.required_ruby_version = ">= 2.4.7"
|
17
|
+
|
18
|
+
if s.respond_to?(:metadata)
|
19
|
+
s.metadata['changelog_uri'] = 'https://github.com/brendon/acts_as_list/blob/master/CHANGELOG.md'
|
20
|
+
s.metadata['source_code_uri'] = 'https://github.com/brendon/acts_as_list'
|
21
|
+
s.metadata['bug_tracker_uri'] = 'https://github.com/brendon/acts_as_list/issues'
|
22
|
+
end
|
18
23
|
|
19
24
|
# Load Paths...
|
20
|
-
s.files
|
21
|
-
s.test_files
|
22
|
-
s.executables
|
23
|
-
s.require_paths
|
25
|
+
s.files = `git ls-files`.split("\n")
|
26
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
27
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map {|f| File.basename(f)}
|
28
|
+
s.require_paths = ["lib"]
|
24
29
|
|
25
30
|
|
26
31
|
# Dependencies (installed via "bundle install")
|
27
|
-
s.add_dependency "activerecord", ">=
|
32
|
+
s.add_dependency "activerecord", ">= 4.2"
|
28
33
|
s.add_development_dependency "bundler", ">= 1.0.0"
|
29
34
|
end
|
data/gemfiles/rails_4_2.gemfile
CHANGED
@@ -2,10 +2,9 @@
|
|
2
2
|
|
3
3
|
source "http://rubygems.org"
|
4
4
|
|
5
|
-
gem "rack", "~> 1", platforms: [:ruby_19, :ruby_20, :ruby_21]
|
6
5
|
gem "rake"
|
7
6
|
gem "appraisal"
|
8
|
-
gem "activerecord", "~> 4.2.
|
7
|
+
gem "activerecord", "~> 4.2.0"
|
9
8
|
|
10
9
|
group :development do
|
11
10
|
gem "github_changelog_generator", "1.9.0"
|
@@ -13,21 +12,21 @@ end
|
|
13
12
|
|
14
13
|
group :test do
|
15
14
|
gem "minitest", "~> 5.0"
|
16
|
-
gem "test_after_commit", "~> 0.4.2"
|
17
15
|
gem "timecop"
|
18
16
|
gem "mocha"
|
17
|
+
gem "test_after_commit", "~> 0.4.2"
|
19
18
|
end
|
20
19
|
|
21
20
|
group :sqlite do
|
22
|
-
gem "sqlite3",
|
21
|
+
gem "sqlite3", "~> 1.3.13"
|
23
22
|
end
|
24
23
|
|
25
24
|
group :postgresql do
|
26
|
-
gem "pg", "~> 0.18.
|
25
|
+
gem "pg", "~> 0.18.4"
|
27
26
|
end
|
28
27
|
|
29
28
|
group :mysql do
|
30
|
-
gem "mysql2", "~> 0.4.
|
29
|
+
gem "mysql2", "~> 0.4.0"
|
31
30
|
end
|
32
31
|
|
33
32
|
gemspec path: "../"
|
data/gemfiles/rails_5_0.gemfile
CHANGED
@@ -2,10 +2,9 @@
|
|
2
2
|
|
3
3
|
source "http://rubygems.org"
|
4
4
|
|
5
|
-
gem "rack", "~> 1", platforms: [:ruby_19, :ruby_20, :ruby_21]
|
6
5
|
gem "rake"
|
7
6
|
gem "appraisal"
|
8
|
-
gem "activerecord", "~> 5.0.
|
7
|
+
gem "activerecord", "~> 5.0.0"
|
9
8
|
|
10
9
|
group :development do
|
11
10
|
gem "github_changelog_generator", "1.9.0"
|
@@ -13,21 +12,20 @@ end
|
|
13
12
|
|
14
13
|
group :test do
|
15
14
|
gem "minitest", "~> 5.0"
|
16
|
-
gem "test_after_commit", "~> 0.4.2"
|
17
15
|
gem "timecop"
|
18
16
|
gem "mocha"
|
19
17
|
end
|
20
18
|
|
21
19
|
group :sqlite do
|
22
|
-
gem "sqlite3",
|
20
|
+
gem "sqlite3", "~> 1.3.13"
|
23
21
|
end
|
24
22
|
|
25
23
|
group :postgresql do
|
26
|
-
gem "pg", "~>
|
24
|
+
gem "pg", "~> 1.1.4"
|
27
25
|
end
|
28
26
|
|
29
27
|
group :mysql do
|
30
|
-
gem "mysql2", "~> 0.
|
28
|
+
gem "mysql2", "~> 0.5.0"
|
31
29
|
end
|
32
30
|
|
33
31
|
gemspec path: "../"
|
data/gemfiles/rails_5_1.gemfile
CHANGED
@@ -2,10 +2,9 @@
|
|
2
2
|
|
3
3
|
source "http://rubygems.org"
|
4
4
|
|
5
|
-
gem "rack", "~> 1", platforms: [:ruby_19, :ruby_20, :ruby_21]
|
6
5
|
gem "rake"
|
7
6
|
gem "appraisal"
|
8
|
-
gem "activerecord", "~> 5.1.
|
7
|
+
gem "activerecord", "~> 5.1.0"
|
9
8
|
|
10
9
|
group :development do
|
11
10
|
gem "github_changelog_generator", "1.9.0"
|
@@ -13,21 +12,20 @@ end
|
|
13
12
|
|
14
13
|
group :test do
|
15
14
|
gem "minitest", "~> 5.0"
|
16
|
-
gem "test_after_commit", "~> 0.4.2"
|
17
15
|
gem "timecop"
|
18
16
|
gem "mocha"
|
19
17
|
end
|
20
18
|
|
21
19
|
group :sqlite do
|
22
|
-
gem "sqlite3",
|
20
|
+
gem "sqlite3", "~> 1.3.13"
|
23
21
|
end
|
24
22
|
|
25
23
|
group :postgresql do
|
26
|
-
gem "pg", "~>
|
24
|
+
gem "pg", "~> 1.1.4"
|
27
25
|
end
|
28
26
|
|
29
27
|
group :mysql do
|
30
|
-
gem "mysql2", "~> 0.
|
28
|
+
gem "mysql2", "~> 0.5.0"
|
31
29
|
end
|
32
30
|
|
33
31
|
gemspec path: "../"
|
data/gemfiles/rails_5_2.gemfile
CHANGED
@@ -2,10 +2,9 @@
|
|
2
2
|
|
3
3
|
source "http://rubygems.org"
|
4
4
|
|
5
|
-
gem "rack", "~> 1", platforms: [:ruby_19, :ruby_20, :ruby_21]
|
6
5
|
gem "rake"
|
7
6
|
gem "appraisal"
|
8
|
-
gem "activerecord", "~> 5.2.0
|
7
|
+
gem "activerecord", "~> 5.2.0"
|
9
8
|
|
10
9
|
group :development do
|
11
10
|
gem "github_changelog_generator", "1.9.0"
|
@@ -13,21 +12,20 @@ end
|
|
13
12
|
|
14
13
|
group :test do
|
15
14
|
gem "minitest", "~> 5.0"
|
16
|
-
gem "test_after_commit", "~> 0.4.2"
|
17
15
|
gem "timecop"
|
18
16
|
gem "mocha"
|
19
17
|
end
|
20
18
|
|
21
19
|
group :sqlite do
|
22
|
-
gem "sqlite3",
|
20
|
+
gem "sqlite3", "~> 1.3.13"
|
23
21
|
end
|
24
22
|
|
25
23
|
group :postgresql do
|
26
|
-
gem "pg", "~>
|
24
|
+
gem "pg", "~> 1.1.4"
|
27
25
|
end
|
28
26
|
|
29
27
|
group :mysql do
|
30
|
-
gem "mysql2", "~> 0.
|
28
|
+
gem "mysql2", "~> 0.5.0"
|
31
29
|
end
|
32
30
|
|
33
31
|
gemspec path: "../"
|
@@ -2,10 +2,9 @@
|
|
2
2
|
|
3
3
|
source "http://rubygems.org"
|
4
4
|
|
5
|
-
gem "rack", "~> 1", platforms: [:ruby_19, :ruby_20, :ruby_21]
|
6
5
|
gem "rake"
|
7
6
|
gem "appraisal"
|
8
|
-
gem "activerecord", "~>
|
7
|
+
gem "activerecord", "~> 6.0.0"
|
9
8
|
|
10
9
|
group :development do
|
11
10
|
gem "github_changelog_generator", "1.9.0"
|
@@ -13,22 +12,20 @@ end
|
|
13
12
|
|
14
13
|
group :test do
|
15
14
|
gem "minitest", "~> 5.0"
|
16
|
-
gem "test_after_commit", "~> 0.4.2"
|
17
15
|
gem "timecop"
|
18
16
|
gem "mocha"
|
19
|
-
gem "after_commit_exception_notification"
|
20
17
|
end
|
21
18
|
|
22
19
|
group :sqlite do
|
23
|
-
gem "sqlite3",
|
20
|
+
gem "sqlite3", "~> 1.4"
|
24
21
|
end
|
25
22
|
|
26
23
|
group :postgresql do
|
27
|
-
gem "pg", "~>
|
24
|
+
gem "pg", "~> 1.1.4"
|
28
25
|
end
|
29
26
|
|
30
27
|
group :mysql do
|
31
|
-
gem "mysql2", "~> 0.
|
28
|
+
gem "mysql2", "~> 0.5.0"
|
32
29
|
end
|
33
30
|
|
34
31
|
gemspec path: "../"
|
@@ -20,13 +20,14 @@ module ActiveRecord
|
|
20
20
|
# one by one to respect position column unique not null constraint.
|
21
21
|
# Defaults to true if position column has unique index, otherwise false.
|
22
22
|
# If constraint is <tt>deferrable initially deferred<tt>, overriding it with false will speed up insert_at.
|
23
|
+
# * +touch_on_update+ - configuration to disable the update of the model timestamps when the positions are updated.
|
23
24
|
def acts_as_list(options = {})
|
24
|
-
configuration = { column: "position", scope: "1 = 1", top_of_list: 1, add_new_at: :bottom }
|
25
|
+
configuration = { column: "position", scope: "1 = 1", top_of_list: 1, add_new_at: :bottom, touch_on_update: true }
|
25
26
|
configuration.update(options) if options.is_a?(Hash)
|
26
27
|
|
27
28
|
caller_class = self
|
28
29
|
|
29
|
-
ActiveRecord::Acts::List::PositionColumnMethodDefiner.call(caller_class, configuration[:column])
|
30
|
+
ActiveRecord::Acts::List::PositionColumnMethodDefiner.call(caller_class, configuration[:column], configuration[:touch_on_update])
|
30
31
|
ActiveRecord::Acts::List::ScopeMethodDefiner.call(caller_class, configuration[:scope])
|
31
32
|
ActiveRecord::Acts::List::TopOfListMethodDefiner.call(caller_class, configuration[:top_of_list])
|
32
33
|
ActiveRecord::Acts::List::AddNewAtMethodDefiner.call(caller_class, configuration[:add_new_at])
|
@@ -64,6 +65,12 @@ module ActiveRecord
|
|
64
65
|
end
|
65
66
|
|
66
67
|
module InstanceMethods
|
68
|
+
# Get the current position of the item in the list
|
69
|
+
def current_position
|
70
|
+
position = send(position_column)
|
71
|
+
position ? position.to_i : nil
|
72
|
+
end
|
73
|
+
|
67
74
|
# Insert the item at the given position (defaults to the top position of 1).
|
68
75
|
def insert_at(position = acts_as_list_top)
|
69
76
|
insert_at_position(position)
|
@@ -78,8 +85,8 @@ module ActiveRecord
|
|
78
85
|
return unless lower_item
|
79
86
|
|
80
87
|
acts_as_list_class.transaction do
|
81
|
-
if lower_item.
|
82
|
-
|
88
|
+
if lower_item.current_position != current_position
|
89
|
+
swap_positions_with(lower_item)
|
83
90
|
else
|
84
91
|
lower_item.decrement_position
|
85
92
|
increment_position
|
@@ -92,8 +99,8 @@ module ActiveRecord
|
|
92
99
|
return unless higher_item
|
93
100
|
|
94
101
|
acts_as_list_class.transaction do
|
95
|
-
if higher_item.
|
96
|
-
|
102
|
+
if higher_item.current_position != current_position
|
103
|
+
swap_positions_with(higher_item)
|
97
104
|
else
|
98
105
|
higher_item.increment_position
|
99
106
|
decrement_position
|
@@ -133,13 +140,13 @@ module ActiveRecord
|
|
133
140
|
# Increase the position of this item without adjusting the rest of the list.
|
134
141
|
def increment_position
|
135
142
|
return unless in_list?
|
136
|
-
set_list_position(
|
143
|
+
set_list_position(current_position + 1)
|
137
144
|
end
|
138
145
|
|
139
146
|
# Decrease the position of this item without adjusting the rest of the list.
|
140
147
|
def decrement_position
|
141
148
|
return unless in_list?
|
142
|
-
set_list_position(
|
149
|
+
set_list_position(current_position - 1)
|
143
150
|
end
|
144
151
|
|
145
152
|
def first?
|
@@ -162,9 +169,8 @@ module ActiveRecord
|
|
162
169
|
# selects all higher items by default
|
163
170
|
def higher_items(limit=nil)
|
164
171
|
limit ||= acts_as_list_list.count
|
165
|
-
position_value = send(position_column)
|
166
172
|
acts_as_list_list.
|
167
|
-
where("#{quoted_position_column_with_table_name} <= ?",
|
173
|
+
where("#{quoted_position_column_with_table_name} <= ?", current_position).
|
168
174
|
where("#{quoted_table_name}.#{self.class.primary_key} != ?", self.send(self.class.primary_key)).
|
169
175
|
reorder(acts_as_list_order_argument(:desc)).
|
170
176
|
limit(limit)
|
@@ -180,9 +186,8 @@ module ActiveRecord
|
|
180
186
|
# selects all lower items by default
|
181
187
|
def lower_items(limit=nil)
|
182
188
|
limit ||= acts_as_list_list.count
|
183
|
-
position_value = send(position_column)
|
184
189
|
acts_as_list_list.
|
185
|
-
where("#{quoted_position_column_with_table_name} >= ?",
|
190
|
+
where("#{quoted_position_column_with_table_name} >= ?", current_position).
|
186
191
|
where("#{quoted_table_name}.#{self.class.primary_key} != ?", self.send(self.class.primary_key)).
|
187
192
|
reorder(acts_as_list_order_argument(:asc)).
|
188
193
|
limit(limit)
|
@@ -194,40 +199,34 @@ module ActiveRecord
|
|
194
199
|
end
|
195
200
|
|
196
201
|
def not_in_list?
|
197
|
-
|
202
|
+
current_position.nil?
|
198
203
|
end
|
199
204
|
|
200
205
|
def default_position
|
201
|
-
acts_as_list_class.
|
206
|
+
acts_as_list_class.column_defaults[position_column.to_s]
|
202
207
|
end
|
203
208
|
|
204
209
|
def default_position?
|
205
|
-
default_position && default_position
|
210
|
+
default_position && default_position == current_position
|
206
211
|
end
|
207
212
|
|
208
213
|
# Sets the new position and saves it
|
209
214
|
def set_list_position(new_position, raise_exception_if_save_fails=false)
|
210
|
-
|
215
|
+
self[position_column] = new_position
|
211
216
|
raise_exception_if_save_fails ? save! : save
|
212
217
|
end
|
213
218
|
|
214
219
|
private
|
215
220
|
|
216
|
-
def
|
217
|
-
|
221
|
+
def swap_positions_with(item)
|
222
|
+
item_position = item.current_position
|
218
223
|
|
219
|
-
|
220
|
-
|
224
|
+
item.set_list_position(current_position)
|
225
|
+
set_list_position(item_position)
|
221
226
|
end
|
222
227
|
|
223
228
|
def acts_as_list_list
|
224
|
-
|
225
|
-
acts_as_list_class.unscoped do
|
226
|
-
acts_as_list_class.where(scope_condition)
|
227
|
-
end
|
228
|
-
else
|
229
|
-
acts_as_list_class.unscope(:select, :where).where(scope_condition)
|
230
|
-
end
|
229
|
+
acts_as_list_class.unscope(:select, :where).where(scope_condition)
|
231
230
|
end
|
232
231
|
|
233
232
|
# Poorly named methods. They will insert the item at the desired position if the position
|
@@ -275,7 +274,7 @@ module ActiveRecord
|
|
275
274
|
# bottom_position_in_list # => 2
|
276
275
|
def bottom_position_in_list(except = nil)
|
277
276
|
item = bottom_item(except)
|
278
|
-
item ? item.
|
277
|
+
item ? item.current_position : acts_as_list_top - 1
|
279
278
|
end
|
280
279
|
|
281
280
|
# Returns the bottom item
|
@@ -302,7 +301,7 @@ module ActiveRecord
|
|
302
301
|
# This has the effect of moving all the higher items down one.
|
303
302
|
def increment_positions_on_higher_items
|
304
303
|
return unless in_list?
|
305
|
-
acts_as_list_list.where("#{quoted_position_column_with_table_name} < ?",
|
304
|
+
acts_as_list_list.where("#{quoted_position_column_with_table_name} < ?", current_position).increment_all
|
306
305
|
end
|
307
306
|
|
308
307
|
# This has the effect of moving all the lower items down one.
|
@@ -313,7 +312,11 @@ module ActiveRecord
|
|
313
312
|
scope = scope.where("#{quoted_table_name}.#{self.class.primary_key} != ?", avoid_id)
|
314
313
|
end
|
315
314
|
|
316
|
-
|
315
|
+
if sequential_updates?
|
316
|
+
scope.where("#{quoted_position_column_with_table_name} >= ?", position).reorder(acts_as_list_order_argument(:desc)).increment_sequentially
|
317
|
+
else
|
318
|
+
scope.where("#{quoted_position_column_with_table_name} >= ?", position).increment_all
|
319
|
+
end
|
317
320
|
end
|
318
321
|
|
319
322
|
# This has the effect of moving all the higher items up one.
|
@@ -322,14 +325,11 @@ module ActiveRecord
|
|
322
325
|
end
|
323
326
|
|
324
327
|
# This has the effect of moving all the lower items up one.
|
325
|
-
def decrement_positions_on_lower_items(position=
|
328
|
+
def decrement_positions_on_lower_items(position=current_position)
|
326
329
|
return unless in_list?
|
327
|
-
position ||= send(position_column).to_i
|
328
330
|
|
329
331
|
if sequential_updates?
|
330
|
-
acts_as_list_list.where("#{quoted_position_column_with_table_name} > ?", position).reorder(acts_as_list_order_argument(:asc)).
|
331
|
-
item.decrement!(position_column)
|
332
|
-
end
|
332
|
+
acts_as_list_list.where("#{quoted_position_column_with_table_name} > ?", position).reorder(acts_as_list_order_argument(:asc)).decrement_sequentially
|
333
333
|
else
|
334
334
|
acts_as_list_list.where("#{quoted_position_column_with_table_name} > ?", position).decrement_all
|
335
335
|
end
|
@@ -365,9 +365,7 @@ module ActiveRecord
|
|
365
365
|
)
|
366
366
|
|
367
367
|
if sequential_updates?
|
368
|
-
items.reorder(acts_as_list_order_argument(:asc)).
|
369
|
-
item.decrement!(position_column)
|
370
|
-
end
|
368
|
+
items.reorder(acts_as_list_order_argument(:asc)).decrement_sequentially
|
371
369
|
else
|
372
370
|
items.decrement_all
|
373
371
|
end
|
@@ -383,9 +381,7 @@ module ActiveRecord
|
|
383
381
|
)
|
384
382
|
|
385
383
|
if sequential_updates?
|
386
|
-
items.reorder(acts_as_list_order_argument(:desc)).
|
387
|
-
item.increment!(position_column)
|
388
|
-
end
|
384
|
+
items.reorder(acts_as_list_order_argument(:desc)).increment_sequentially
|
389
385
|
else
|
390
386
|
items.increment_all
|
391
387
|
end
|
@@ -397,7 +393,7 @@ module ActiveRecord
|
|
397
393
|
return set_list_position(position, raise_exception_if_save_fails) if new_record?
|
398
394
|
with_lock do
|
399
395
|
if in_list?
|
400
|
-
old_position =
|
396
|
+
old_position = current_position
|
401
397
|
return if position == old_position
|
402
398
|
# temporary move after bottom with gap, avoiding duplicate values
|
403
399
|
# gap is required to leave room for position increments
|
@@ -416,31 +412,27 @@ module ActiveRecord
|
|
416
412
|
return unless position_before_save_changed?
|
417
413
|
|
418
414
|
old_position = position_before_save || bottom_position_in_list + 1
|
419
|
-
new_position = send(position_column).to_i
|
420
415
|
|
421
|
-
return unless acts_as_list_list.where(
|
422
|
-
"#{quoted_position_column_with_table_name} = #{
|
416
|
+
return unless current_position && acts_as_list_list.where(
|
417
|
+
"#{quoted_position_column_with_table_name} = #{current_position}"
|
423
418
|
).count > 1
|
424
|
-
|
419
|
+
|
420
|
+
shuffle_positions_on_intermediate_items old_position, current_position, id
|
425
421
|
end
|
426
422
|
|
427
423
|
def position_before_save_changed?
|
428
|
-
if
|
429
|
-
ActiveRecord::VERSION::MAJOR > 5
|
430
|
-
|
424
|
+
if active_record_version_is?('>= 5.1')
|
431
425
|
saved_change_to_attribute? position_column
|
432
426
|
else
|
433
|
-
|
427
|
+
attribute_changed? position_column
|
434
428
|
end
|
435
429
|
end
|
436
430
|
|
437
431
|
def position_before_save
|
438
|
-
if
|
439
|
-
ActiveRecord::VERSION::MAJOR > 5
|
440
|
-
|
432
|
+
if active_record_version_is?('>= 5.1')
|
441
433
|
attribute_before_last_save position_column
|
442
434
|
else
|
443
|
-
|
435
|
+
attribute_was position_column
|
444
436
|
end
|
445
437
|
end
|
446
438
|
|
@@ -469,7 +461,7 @@ module ActiveRecord
|
|
469
461
|
# This check is skipped if the position is currently the default position from the table
|
470
462
|
# as modifying the default position on creation is handled elsewhere
|
471
463
|
def check_top_position
|
472
|
-
if
|
464
|
+
if current_position && !default_position? && current_position < acts_as_list_top
|
473
465
|
self[position_column] = acts_as_list_top
|
474
466
|
end
|
475
467
|
end
|
@@ -489,11 +481,13 @@ module ActiveRecord
|
|
489
481
|
end
|
490
482
|
|
491
483
|
def acts_as_list_order_argument(direction = :asc)
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
484
|
+
{ position_column => direction }
|
485
|
+
end
|
486
|
+
|
487
|
+
def active_record_version_is?(version_requirement)
|
488
|
+
requirement = Gem::Requirement.new(version_requirement)
|
489
|
+
version = Gem.loaded_specs['activerecord'].version
|
490
|
+
requirement.satisfied_by?(version)
|
497
491
|
end
|
498
492
|
end
|
499
493
|
|