acts_as_ranked_list 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/.yardopts +2 -0
- data/CHANGELOG.md +27 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +292 -0
- data/Rakefile +10 -0
- data/lib/acts_as_ranked_list/active_record/active_record.rb +5 -0
- data/lib/acts_as_ranked_list/active_record/avoid_collisions.rb +59 -0
- data/lib/acts_as_ranked_list/active_record/persistence_callback.rb +62 -0
- data/lib/acts_as_ranked_list/active_record/rank_column.rb +257 -0
- data/lib/acts_as_ranked_list/active_record/service.rb +107 -0
- data/lib/acts_as_ranked_list/active_record/skip_persistence.rb +80 -0
- data/lib/acts_as_ranked_list/errors/base.rb +8 -0
- data/lib/acts_as_ranked_list/version.rb +5 -0
- data/lib/acts_as_ranked_list.rb +8 -0
- data/sig/acts_as_ranked_list.rbs +4 -0
- metadata +199 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: c52097c9da44e29678359a1ea99ff7907e3fdb549cb98997b10a9aaed3819a09
|
4
|
+
data.tar.gz: cc599ce31fa0547848440e7ba55f233d8324016d864d5688368dbbdbc863af93
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 146bf7c4e0f9d1ba563e90f577cdd2b2c136be125fed1513f08a7d77cd250089a5d135f42dedb1bbf50ad4a40cd5385cb24b7d53c7d9cbc6071eaee8aceec3e9
|
7
|
+
data.tar.gz: 274ad10fc900278dc84a5ba39ffaee17fe7412b8ea16b2f227d07abeea1c095b911315d8544be2ddcf558ea039fbdc51e3dc380ef782e493184493745b73b26b
|
data/.rspec
ADDED
data/.standard.yml
ADDED
data/.yardopts
ADDED
data/CHANGELOG.md
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# Changelog
|
2
|
+
|
3
|
+
All notable changes to this project will be documented in this file.
|
4
|
+
|
5
|
+
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
6
|
+
and this project adheres to [Semantic Versioning](http://semver.org/).
|
7
|
+
|
8
|
+
## [Unreleased]
|
9
|
+
|
10
|
+
- Allows unranked items in list by having `nil` values. New items can also be added as unranked.
|
11
|
+
- Allows different scopes on the items by referencing another column.
|
12
|
+
|
13
|
+
## [0.2.0] - 2023-04-21
|
14
|
+
|
15
|
+
- Adds gem documentation
|
16
|
+
- Adds gem tests
|
17
|
+
- Adds gem functionality to rank `::ActiveRecord` objects.
|
18
|
+
- Adds AvoidsCollisions
|
19
|
+
- Adds SkipPersistence
|
20
|
+
- Adds PersistenceCallback
|
21
|
+
- Adds RankColumn
|
22
|
+
- Adds Service
|
23
|
+
- Adds Base error
|
24
|
+
|
25
|
+
## [0.1.0] - 2023-04-11
|
26
|
+
|
27
|
+
- Initial release
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2023 YehyaRasayel
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,292 @@
|
|
1
|
+
# ActsAsRankedList
|
2
|
+
|
3
|
+
This gem is based off of the [ActsAsList](https://github.com/brendon/acts_as_list) gem. It rewrites the gem using floating point position (or rank) for items. The benefit of using floating point ranks is the ability to insert an item inbetween items without updating the other items' positions.
|
4
|
+
|
5
|
+
Also supports having a(n) (float/integer) step between ranks that achieves the same thing. Can also use a step <1.0 to rank more items than is allowed by a database's max float/integer column restrictions.
|
6
|
+
|
7
|
+
## Installation
|
8
|
+
|
9
|
+
Install the gem and add to the application's Gemfile by executing:
|
10
|
+
|
11
|
+
$ bundle add acts_as_ranked_list
|
12
|
+
|
13
|
+
or by adding `gem "acts_as_ranked_list"` to the Gemfile and running `bundle install`.
|
14
|
+
|
15
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
16
|
+
|
17
|
+
$ gem install acts_as_ranked_list
|
18
|
+
|
19
|
+
## Usage
|
20
|
+
|
21
|
+
### Basic usage
|
22
|
+
|
23
|
+
This gem allows you to easily rank `::ActiveRecord` items without worrying about the underlying logic. After installing the gem, add the following `acts_as_ranked_list` to the `::ActiveRecord` model, for example:
|
24
|
+
|
25
|
+
```
|
26
|
+
class MyModelName << ::ActiveRecord::Base
|
27
|
+
acts_as_ranked_list
|
28
|
+
end
|
29
|
+
```
|
30
|
+
|
31
|
+
When you create a new `MyModelName` item, it will be ranked among the list of existing `MyModelName` items. You can increase/decrease the item's rank by the following methods:
|
32
|
+
|
33
|
+
```
|
34
|
+
item_a = MyModelName.create!
|
35
|
+
item_a.increase_rank
|
36
|
+
item_a.decrease_rank
|
37
|
+
```
|
38
|
+
|
39
|
+
You can get the current rank of the item by the following:
|
40
|
+
|
41
|
+
```
|
42
|
+
item_a = MyModelName.create! # is at the bottom of the list, highest rank item
|
43
|
+
item_b = MyModelName.create! # is at the bottom of the list, lowest rank item
|
44
|
+
item_a.current_rank # 1
|
45
|
+
item_b.current_rank # 2
|
46
|
+
```
|
47
|
+
|
48
|
+
Note that the list is viewed as ascending order of `rank, last updated at, id`. So `item_b` with rank 2 is lower in the list than `item_a` with rank 1.
|
49
|
+
|
50
|
+
You may get the highest items in the list sorted by rank by the following methods:
|
51
|
+
|
52
|
+
```
|
53
|
+
my_existing_item = MyModelName.create! # placed top in the list
|
54
|
+
my_new_item = MyModelName.create! # placed bottom in the list
|
55
|
+
MyModelName.get_highest_items # ActiveRecord::Relation with results [my_existing_item, my_new_item]
|
56
|
+
```
|
57
|
+
|
58
|
+
You may get the highest/lowest item by specifying a number as the first argument to the `get_highest_items`/`get_lowest_items` methods, such as:
|
59
|
+
|
60
|
+
```
|
61
|
+
my_existing_item = MyModelName.create! # placed top in the list
|
62
|
+
my_new_item = MyModelName.create! # placed bottom in the list
|
63
|
+
MyModelName.get_highest_items(1) # ActiveRecord::Relation with results [my_existing_item] # Note the result is an array
|
64
|
+
MyModelName.get_lowest_items(1) # ActiveRecord::Relation with results [my_new_item]
|
65
|
+
MyModelName.get_lowest_items(2) # ActiveRecord::Relation with results [my_new_item, my_existing_item] # Note the order of the returned results
|
66
|
+
MyModelName.get_highest_items(50000) # ActiveRecord::Relation with results [my_existing_item, my_new_item] # Note the number of requested results
|
67
|
+
```
|
68
|
+
|
69
|
+
### Advanced Usage
|
70
|
+
|
71
|
+
For the next examples, each will be initialized to the following:
|
72
|
+
|
73
|
+
```
|
74
|
+
class TodoItem << ::ApplicationRecord
|
75
|
+
# the rank column is named "priority" (without quotation marks) for this table
|
76
|
+
# new items are added as highest priority
|
77
|
+
acts_as_ranked_list column: "priority", adds_new_at: :highest, step_increment: 1.0
|
78
|
+
end
|
79
|
+
|
80
|
+
design_reusable_plastic_bag_graphic = TodoItem.create!(title: "Design the front and back graphic on the reusable plastic bag")
|
81
|
+
exercise = TodoItem.create!(title: "Run for 8 miles")
|
82
|
+
health_check = TodoItem.create!(title: "Drink lemon water")
|
83
|
+
print_on_shirt = TodoItem.create!(title: "Print a prototype design on the shrit to check quality")
|
84
|
+
# items and their priorities in ascending order: (the actual result is an array of the items, but the variable name and rank are shown here for simplicity)
|
85
|
+
# [["print_on_shirt" ... , 0.125], ["health_check" ... , 0.25], ["exercise" ... , 0.5], ["design_reusable_plastic_bag_graphic" ... , 1.0]]
|
86
|
+
# the highest prioritised item is "print_on_shirt"
|
87
|
+
# the lowest prioritised item is "design_reusable_plastic_bag_graphic"
|
88
|
+
```
|
89
|
+
|
90
|
+
### Query position of current item
|
91
|
+
|
92
|
+
You can check if an item is the highest item or the lowest item in the list by using the `highest_item?` or `lowest_item?` instance methods.
|
93
|
+
|
94
|
+
```
|
95
|
+
design_reusable_plastic_bag_graphic.lowest_item? # true
|
96
|
+
design_reusable_plastic_bag_graphic.highest_item? # false
|
97
|
+
print_on_shirt.highest_item? # true
|
98
|
+
```
|
99
|
+
|
100
|
+
#### Get higher or lower items
|
101
|
+
|
102
|
+
You can get the higher/lower items by using the instance methods `get_higher_items` or `get_lower_items`:
|
103
|
+
|
104
|
+
```
|
105
|
+
exercise.get_higher_items # items and their priorities (note the order): [["health_check", 0.25], ["print_on_shirt", 0.125]]
|
106
|
+
```
|
107
|
+
|
108
|
+
You may pass in optional arguments to control how the results are returned. If the first argument is `0`, it will return all higher/lower items.
|
109
|
+
|
110
|
+
```
|
111
|
+
design_reusable_plastic_bag_graphic.get_higher_items(2, "ASC") # [["health_check", 0.25], ["exercise", 0.5]]
|
112
|
+
```
|
113
|
+
|
114
|
+
#### Check if the current item is ranked
|
115
|
+
|
116
|
+
If the value of the rank column for the instance is `nil` then the item is not ranked. This item will still interact with the list when running queries such as `highest_item?` and so on. This item will be given a rank when [spreading ranks](spread-ranks).
|
117
|
+
|
118
|
+
```
|
119
|
+
design_reusable_plastic_bag_graphic.is_ranked? # true
|
120
|
+
```
|
121
|
+
|
122
|
+
You may create a new item with nil rank as follows:
|
123
|
+
|
124
|
+
```
|
125
|
+
## this persists the record, but skips callbacks
|
126
|
+
::TodoItem.with_skip_persistence { ::TodoItem.create!(rank: nil) }
|
127
|
+
```
|
128
|
+
|
129
|
+
#### Move rank relative to another item
|
130
|
+
|
131
|
+
Instead of updating rank one or down one position at a time, you can move above/below another item, using `set_rank_above` or `set_rank_below` instance methods:
|
132
|
+
|
133
|
+
```
|
134
|
+
design_reusable_plastic_bag_graphic.set_rank_above(health_check)
|
135
|
+
```
|
136
|
+
|
137
|
+
This can be used together with the `get_highest_items(1)` class method to move item to the top of the list.
|
138
|
+
|
139
|
+
```
|
140
|
+
design_reusable_plastic_bag_graphic.set_rank_above(TodoItem.get_highest_items(1).first)
|
141
|
+
```
|
142
|
+
|
143
|
+
#### Persistence and persistence callbacks
|
144
|
+
|
145
|
+
Each model with the `acts_as_ranked_list` has class methods to skip persistence to the database, and persistence callbacks.
|
146
|
+
|
147
|
+
Skipping persistence is useful if you want to mass update items, and persist once at the end. Persistence callbacks is useful for hooking into the life cycle of the updated item with regards to its rank, for example to send a webhook to all subscribers notifying them of an updated rank.
|
148
|
+
|
149
|
+
You can use the class method `with_skip_persistence` as follows:
|
150
|
+
|
151
|
+
```
|
152
|
+
TodoItem.with_skip_persistence do
|
153
|
+
design_reusable_plastic_bag_graphic.update(rank: 20.3)
|
154
|
+
exercise.update(rank: 5.2)
|
155
|
+
health_check.update(rank: 7.1)
|
156
|
+
print_on_shirt.update(rank: 92.1)
|
157
|
+
end
|
158
|
+
::TodoItem.bulk_import!(
|
159
|
+
[design_reusable_plastic_bag_graphic, exercise, health_check, print_on_shirt],
|
160
|
+
on_duplicate_key_update: {
|
161
|
+
conflict_target: [:id],
|
162
|
+
columns: [:priority, :updated_at]
|
163
|
+
}
|
164
|
+
) # uses the `activerecord_import` gem, or any other bulk update method to mass save changes to the database in 1 query
|
165
|
+
```
|
166
|
+
|
167
|
+
You may also pass in an array of classes to the `with_skip_persistence` method to skip persistence for these `::ActiveRecord` models which use the `acts_as_ranked_list` concern.
|
168
|
+
|
169
|
+
```
|
170
|
+
TodoItem.with_skip_persistence([FootballTeam]) do # the calling class is added by default, in this case: `TodoItem`
|
171
|
+
design_reusable_plastic_bag_graphic.increase_rank
|
172
|
+
exercise.increase_rank
|
173
|
+
health_check.set_rank_below(print_on_shirt)
|
174
|
+
print_on_shirt.set_rank_below(design_reusable_plastic_bag_graphic)
|
175
|
+
|
176
|
+
instance_of_other_model = FootballTeam.create(name: "MineerPul")
|
177
|
+
instance_of_other_model.decrease_rank
|
178
|
+
end
|
179
|
+
::TodoItem.bulk_import!(
|
180
|
+
[design_reusable_plastic_bag_graphic, exercise, health_check, print_on_shirt],
|
181
|
+
on_duplicate_key_update: {
|
182
|
+
conflict_target: [:id],
|
183
|
+
columns: [:priority, :updated_at]
|
184
|
+
}
|
185
|
+
) # uses the `activerecord_import` gem, or any other bulk update method to mass save changes to the database in 1 query
|
186
|
+
|
187
|
+
::FootballTeam.bulk_import!(
|
188
|
+
[instance_of_other_model],
|
189
|
+
on_duplicate_key_update: {
|
190
|
+
conflict_target: [:id],
|
191
|
+
columns: [:rank, :updated_at]
|
192
|
+
}
|
193
|
+
)
|
194
|
+
```
|
195
|
+
|
196
|
+
#### Avoiding collisions
|
197
|
+
|
198
|
+
You can control whether to spread ranks or not on collisions by using the option `avoid_collisions: true` (by default) on using the concern in your `::ActiveRecord` model. You can change this setting on a per-block per-class basis by using the following class method:
|
199
|
+
|
200
|
+
```
|
201
|
+
# disallows collisions
|
202
|
+
TodoItem.with_avoid_collisions(true) do # do spread ranks on collisions
|
203
|
+
TodoItem.find(1).update(rank: 1)
|
204
|
+
... # you may update more than one record too
|
205
|
+
end # items with their new ranks [["health_check", 1.0], ["exercise", 2.0], ["print_on_shirt", 3.0], ["design_reusable_plastic_bag_graphic", 4.0]]
|
206
|
+
```
|
207
|
+
|
208
|
+
```
|
209
|
+
# allows collisions
|
210
|
+
TodoItem.with_avoid_collisions(false) do # do not spread ranks on collisions
|
211
|
+
TodoItem.find(1).update(rank: 1)
|
212
|
+
end # items with their new ranks [["health_check", 0.25], ["exercise", 0.5], ["print_on_shirt", 1.0], ["design_reusable_plastic_bag_graphic", 1.0]]
|
213
|
+
```
|
214
|
+
|
215
|
+
#### Spread ranks
|
216
|
+
|
217
|
+
You can spread ranks so that the difference between each rank and the next is set to the `step_increment`. This is useful for:
|
218
|
+
|
219
|
+
- Being able to rerank items again without overflowing column's max precision.
|
220
|
+
- Human-readable viewing purposes. This is not recommended. The rank should be human-readable (or not viewable) at the view (presentation) layer.
|
221
|
+
|
222
|
+
If `avoid_collisions = true (by default)`. Then you do not have to use spread ranks manually. If the database raises an overflow error when mutating a rank, then it could be time to recalibrate the ranks in the table. You should handle this case in your code, and spread ranks. This is very infrequent. For example, in postgres this error will be raised when using the default precision of `16_383` digits after the decimal of a `decimal` column type in postgres versions `9.1+`:
|
223
|
+
|
224
|
+
```
|
225
|
+
ActiveRecord::RangeError: PG::NumericValueOutOfRange: ERROR: value overflows numeric format
|
226
|
+
```
|
227
|
+
|
228
|
+
Items are spread in, ascending order of each, by:
|
229
|
+
|
230
|
+
1. rank
|
231
|
+
2. time updated columns (`updated_at` for example)
|
232
|
+
3. primary key (`id` for example)
|
233
|
+
|
234
|
+
To spread ranks:
|
235
|
+
|
236
|
+
```
|
237
|
+
TodoItem.spread_ranks
|
238
|
+
TodoItem.get_highest_items # items with their new ranks [["print_on_shirt", 1.0], ["health_check", 2.0], ["exercise", 3.0], ["design_reusable_plastic_bag_graphic", 4.0]]
|
239
|
+
```
|
240
|
+
|
241
|
+
## Things to beware of
|
242
|
+
|
243
|
+
### Ranking more items in a databse than the max integer column
|
244
|
+
|
245
|
+
Imagine the scenario where you have 199 records to rank, and you must rank them in a `decision/numeric` column that allows 2 digits before the decimal point, and 2 digits after the decimal point. I.e the max number that can be stored is `99.99`.
|
246
|
+
|
247
|
+
If you use a `step_increment` of 1 you will be faced with a max float/integer overflow error by the database, as some of the ranks are more than 2 digits before the decimal point. You may use a decimal `step_increment` that is less than 1, and suitable for this scenario, and ranking will work as expected, without errors.
|
248
|
+
|
249
|
+
```
|
250
|
+
class CrammedTodoItem << ::ActiveRecord::Base
|
251
|
+
acts_as_ranked_list step_increment: 0.5
|
252
|
+
end
|
253
|
+
|
254
|
+
crammed_todo_items = []
|
255
|
+
CrammedTodoItem.with_skip_persistence do
|
256
|
+
199.times { crammed_todo_items << CrammedTodoItem.create }
|
257
|
+
end
|
258
|
+
|
259
|
+
CrammedTodoItem.bulk_import!(crammed_todo_items) # persist 199 records in a mass insert method using gem `activerecord-import` or other method
|
260
|
+
|
261
|
+
CrammedTodoItem.spread_ranks # ensures every step is 0.5
|
262
|
+
|
263
|
+
CrammedTodoItem.get_highest_items.pluck(:rank) # [0.50 .. 99.50]
|
264
|
+
```
|
265
|
+
|
266
|
+
### Floating point precision and rounding, and max float/integer column overflow
|
267
|
+
|
268
|
+
Not all software can handle the precision handled by the database. The weakest link will cause issues to the entire flow of ranking items using floating-point precision. You may wish to convert to string and back to float if you need to. This **must** be done by the database layer as you'd have already lost precision if you let any other layer cast the values.
|
269
|
+
|
270
|
+
An alternative solution is to use a high `step_increment` to not get into rounding errors. This also brings its own problems of max integer overflow, but that is usually easier to plan in advance for, and does not fail silently. If you do this, it is best to use a large number that is a power of 2. This helps reduce the number of collisions as much as possible.
|
271
|
+
|
272
|
+
Some database services do not fail loudly and will persist a rounded or incorrect value altogether. You may set up an `::ActiveRecord` validation on the model to check for the precision before saving, and raise your own validation error, and handle recalibrating ranks.
|
273
|
+
|
274
|
+
### Performance
|
275
|
+
|
276
|
+
For best results, it is recommended to benchmark and compare with real life scenarios. Depending on your use case, different strategies may be faster. Using a large `step_increment` that's a power of 2 in an integer-based column (i.e no decimals) may be faster. The gem will continue to work as expected, but there may be more frequent collisions, which would be handled automatically but may slow down performance. So experiment and kindly share your learnings :pray:. You should also check other ruby gems that rank and sort `::ActiveRecord` models.
|
277
|
+
|
278
|
+
## Development
|
279
|
+
|
280
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec ./spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
281
|
+
|
282
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
283
|
+
|
284
|
+
You may also run `yardoc` to build the docs locally. And `yard server` to serve the built docs.
|
285
|
+
|
286
|
+
## Contributing
|
287
|
+
|
288
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/Farbafe/acts_as_ranked_list.
|
289
|
+
|
290
|
+
## License
|
291
|
+
|
292
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActsAsRankedList #:nodoc:
|
4
|
+
module ActiveRecord #:nodoc:
|
5
|
+
module AvoidCollisions # Refer to {AvoidCollisions::ClassMethods} for docs.
|
6
|
+
|
7
|
+
def self.included(base)
|
8
|
+
base.extend(ClassMethods)
|
9
|
+
end
|
10
|
+
|
11
|
+
module ClassMethods
|
12
|
+
# Pass a block to this method to avoid collisions to an `::ActiveRecord`
|
13
|
+
# model's table. You may pass a boolean argument to change the behaviour.
|
14
|
+
#
|
15
|
+
# @example with an `::ActiveRecord` model of the name `TodoItem`:
|
16
|
+
# TodoItem.with_avoid_collisions do
|
17
|
+
# TodoItem.find(4).update(rank: 200)
|
18
|
+
# TodoItem.find(5).update(rank: 200)
|
19
|
+
# end
|
20
|
+
# @since 0.2.0
|
21
|
+
# @scope class
|
22
|
+
# @param [Array<Class>] avoid_collisions argument to avoid or allow collisions
|
23
|
+
# @yield the block to execute with changes to the instances
|
24
|
+
# @return [void]
|
25
|
+
def with_avoid_collisions(avoid_collisions = true, &blk)
|
26
|
+
AvoidCollisions.with_applied_klasses(self, avoid_collisions) do
|
27
|
+
yield
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def active_record_objects?(klasses)
|
34
|
+
klasses.all? { |klass| klass.ancestors.include?(::ActiveRecord::Base) }
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
class << self
|
39
|
+
def with_applied_klasses(caller_class, avoid_collisions, &blk)
|
40
|
+
original_avoid_collisions = caller_class.avoid_collisions
|
41
|
+
caller_class.avoid_collisions = avoid_collisions
|
42
|
+
yield
|
43
|
+
ensure
|
44
|
+
caller_class.avoid_collisions = original_avoid_collisions
|
45
|
+
end
|
46
|
+
|
47
|
+
def applied_to?(klass)
|
48
|
+
klass.avoid_collisions
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def avoid_collisions?
|
55
|
+
AvoidCollisions.applied_to?(self.class)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActsAsRankedList #:nodoc:
|
4
|
+
module ActiveRecord #:nodoc:
|
5
|
+
module PersistenceCallback
|
6
|
+
# @!method ranked_list_before_validation_callback
|
7
|
+
# Callback on `::ActiveRecord` before validation. Skipped with {SkipPersistence}
|
8
|
+
# @!scope class
|
9
|
+
# @since 0.2.0
|
10
|
+
|
11
|
+
# @!method ranked_list_before_destroy_callback
|
12
|
+
# Callback on `::ActiveRecord` before destroy. Skipped with {SkipPersistence}
|
13
|
+
# @!scope class
|
14
|
+
# @since 0.2.0
|
15
|
+
|
16
|
+
# @!method ranked_list_after_destroy_callback
|
17
|
+
# Callback on `::ActiveRecord` after destroy. Skipped with {SkipPersistence}
|
18
|
+
# @!scope class
|
19
|
+
# @since 0.2.0
|
20
|
+
|
21
|
+
# @!method ranked_list_before_update_callback
|
22
|
+
# Callback on `::ActiveRecord` before update. Skipped with {SkipPersistence}
|
23
|
+
# @!scope class
|
24
|
+
# @since 0.2.0
|
25
|
+
|
26
|
+
# @!method ranked_list_after_update_callback
|
27
|
+
# Callback on `::ActiveRecord` after update. Skipped with {SkipPersistence}
|
28
|
+
# @!scope class
|
29
|
+
# @since 0.2.0
|
30
|
+
|
31
|
+
# @!method ranked_list_after_save_callback
|
32
|
+
# Callback on `::ActiveRecord` after save.
|
33
|
+
# @!scope class
|
34
|
+
# @since 0.2.0
|
35
|
+
|
36
|
+
# @!method ranked_list_before_create_callback
|
37
|
+
# Callback on `::ActiveRecord` before create. Skipped with {SkipPersistence}.
|
38
|
+
# @!scope class
|
39
|
+
# @since 0.2.0
|
40
|
+
|
41
|
+
# Sets the callback handlers for {ActsAsRankedList}. Used internally.
|
42
|
+
# You may use the callback methods if you want to add your handlers.
|
43
|
+
# Call super at the end of your callback handlers. Do **not** update records
|
44
|
+
# within an update callback.
|
45
|
+
def self.call(caller_class)
|
46
|
+
caller_class.class_eval do
|
47
|
+
before_validation :ranked_list_before_validation_callback, unless: :skip_persistence?
|
48
|
+
|
49
|
+
before_destroy :ranked_list_before_destroy_callback, unless: :skip_persistence?
|
50
|
+
after_destroy :ranked_list_after_destroy_callback, unless: :skip_persistence?
|
51
|
+
|
52
|
+
before_update :ranked_list_before_update_callback, unless: :skip_persistence?
|
53
|
+
after_update :ranked_list_after_update_callback, unless: :skip_persistence?
|
54
|
+
|
55
|
+
after_save :ranked_list_after_save_callback
|
56
|
+
|
57
|
+
before_create :ranked_list_before_create_callback, unless: :skip_persistence?
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,257 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActsAsRankedList #:nodoc:
|
4
|
+
module ActiveRecord #:nodoc:
|
5
|
+
module RankColumn
|
6
|
+
# Sets the methods to rank `::ActiveRecord` objects. Please refer to
|
7
|
+
# the {file:README.md} file for usage and examples.
|
8
|
+
def self.call(caller_class, rank_column, touch_on_update, step_increment, avoid_collisions, new_item_at)
|
9
|
+
caller_class.class_eval do
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
define_singleton_method :new_item_at do
|
14
|
+
new_item_at
|
15
|
+
end
|
16
|
+
|
17
|
+
define_singleton_method :step_increment do
|
18
|
+
step_increment
|
19
|
+
end
|
20
|
+
|
21
|
+
define_singleton_method :avoid_collisions= do |avoid_collisions_input|
|
22
|
+
@avoid_collisions = avoid_collisions_input
|
23
|
+
end
|
24
|
+
@avoid_collisions = avoid_collisions
|
25
|
+
|
26
|
+
define_singleton_method :avoid_collisions do
|
27
|
+
@avoid_collisions
|
28
|
+
end
|
29
|
+
|
30
|
+
define_singleton_method :acts_as_ranked_list_query do
|
31
|
+
default_scoped.unscope(:select, :where)
|
32
|
+
end
|
33
|
+
|
34
|
+
define_singleton_method :quoted_rank_column do
|
35
|
+
@_quoted_rank_column ||= connection.quote_column_name(rank_column)
|
36
|
+
end
|
37
|
+
|
38
|
+
define_singleton_method :quoted_rank_column_with_table_name do
|
39
|
+
@_quoted_rank_column_with_table_name ||= "#{caller_class.quoted_table_name}.#{quoted_rank_column}"
|
40
|
+
end
|
41
|
+
|
42
|
+
define_singleton_method :order_by_columns do |order = "ASC"|
|
43
|
+
@order_by_columns ||= {}
|
44
|
+
@order_by_columns[order] ||= <<~ORDER_BY_COLUMNS.squish
|
45
|
+
#{
|
46
|
+
[ quoted_rank_column,
|
47
|
+
*quoted_timestamp_attributes_for_update_in_model,
|
48
|
+
primary_key
|
49
|
+
].join(" #{order}, ")
|
50
|
+
} #{order}
|
51
|
+
ORDER_BY_COLUMNS
|
52
|
+
end
|
53
|
+
|
54
|
+
define_singleton_method :spread_ranks do
|
55
|
+
sql = <<~SQL.squish
|
56
|
+
WITH ORDERED_ROW_NUMBER_CTE AS (
|
57
|
+
SELECT #{caller_class.primary_key},
|
58
|
+
ROW_NUMBER() OVER (ORDER BY #{order_by_columns("ASC")}) AS rn
|
59
|
+
FROM #{caller_class.quoted_table_name}
|
60
|
+
)
|
61
|
+
UPDATE #{caller_class.quoted_table_name}
|
62
|
+
SET #{quoted_rank_column} = ORDERED_ROW_NUMBER_CTE.rn * #{step_increment} #{with_touch}
|
63
|
+
FROM ORDERED_ROW_NUMBER_CTE
|
64
|
+
WHERE #{caller_class.quoted_table_name}.#{caller_class.primary_key} = ORDERED_ROW_NUMBER_CTE.#{caller_class.primary_key}
|
65
|
+
SQL
|
66
|
+
|
67
|
+
connection.execute(sql)
|
68
|
+
end
|
69
|
+
|
70
|
+
define_singleton_method :with_touch do
|
71
|
+
touch_record if touch_on_update
|
72
|
+
end
|
73
|
+
|
74
|
+
define_singleton_method :quoted_timestamp_attributes_for_update_in_model do
|
75
|
+
@_quoted_timestamp_attributes_for_update_in_model ||= timestamp_attributes_for_update_in_model.map do |attribute|
|
76
|
+
connection.quote_column_name(attribute)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
define_singleton_method :touch_record do
|
81
|
+
cached_quoted_now = quoted_current_time_from_proper_timezone
|
82
|
+
|
83
|
+
quoted_timestamp_attributes_for_update_in_model.map do |attribute|
|
84
|
+
", #{attribute} = #{cached_quoted_now}"
|
85
|
+
end.join
|
86
|
+
end
|
87
|
+
|
88
|
+
define_singleton_method :quoted_current_time_from_proper_timezone do
|
89
|
+
connection.quote(connection.quoted_date(current_time_from_proper_timezone))
|
90
|
+
end
|
91
|
+
|
92
|
+
define_singleton_method :get_highest_items do |limit = 0|
|
93
|
+
query = acts_as_ranked_list_query.order(order_by_columns)
|
94
|
+
|
95
|
+
return query if limit == 0
|
96
|
+
|
97
|
+
query.limit(limit)
|
98
|
+
end
|
99
|
+
|
100
|
+
define_singleton_method :get_lowest_items do |limit = 0|
|
101
|
+
query = acts_as_ranked_list_query.order(order_by_columns("DESC"))
|
102
|
+
|
103
|
+
return query if limit == 0
|
104
|
+
|
105
|
+
query.limit(limit)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
caller_class.class_eval do
|
110
|
+
define_method :rank_column do
|
111
|
+
rank_column
|
112
|
+
end
|
113
|
+
|
114
|
+
define_method :"#{rank_column}=" do |rank|
|
115
|
+
self[rank_column] = rank
|
116
|
+
@rank_changed = true
|
117
|
+
end
|
118
|
+
|
119
|
+
define_method :current_rank do
|
120
|
+
self[rank_column]
|
121
|
+
end
|
122
|
+
|
123
|
+
define_method :rank_changed? do
|
124
|
+
@rank_changed
|
125
|
+
end
|
126
|
+
|
127
|
+
define_method :is_ranked? do
|
128
|
+
self[rank_column].present?
|
129
|
+
end
|
130
|
+
|
131
|
+
define_method :swap_rank_with do |item|
|
132
|
+
temp_rank = current_rank
|
133
|
+
with_persistence([self, item]) do
|
134
|
+
self[rank_column] = item.current_rank
|
135
|
+
item[rank_column] = temp_rank
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
define_method :set_rank_between do |item_a, item_b|
|
140
|
+
set_rank_below(item_a)
|
141
|
+
end
|
142
|
+
|
143
|
+
define_method :set_rank_above do |item|
|
144
|
+
higher_items = get_higher_items(2, "DESC", item.current_rank, true, true)
|
145
|
+
padded_array = pad_array(higher_items.pluck(rank_column), 0)
|
146
|
+
new_rank = padded_array.sum / 2
|
147
|
+
|
148
|
+
with_persistence do
|
149
|
+
self[rank_column] = new_rank
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
define_method :set_rank_below do |item|
|
154
|
+
# effectively the same as, without the padding of arrays
|
155
|
+
# sql = <<~SQL.squish
|
156
|
+
# with CTE AS (
|
157
|
+
# SELECT DISTINCT #{self.class.quoted_rank_column_with_table_name} AS dis
|
158
|
+
# FROM #{caller_class.quoted_table_name}
|
159
|
+
# WHERE (#{self.class.quoted_rank_column_with_table_name} >= #{item.current_rank}::numeric)
|
160
|
+
# GROUP BY #{self.class.quoted_rank_column_with_table_name}
|
161
|
+
# ORDER BY #{self.class.quoted_rank_column_with_table_name}
|
162
|
+
# LIMIT 2
|
163
|
+
# )
|
164
|
+
# SELECT AVG(CTE.dis)
|
165
|
+
# FROM CTE
|
166
|
+
# SQL
|
167
|
+
# new_rank = ::ActiveRecord::Base.connection.execute(sql).to_a.first.values.first
|
168
|
+
|
169
|
+
lower_items = get_lower_items(2, "ASC", item.current_rank, true, true)
|
170
|
+
padded_array = pad_array(lower_items.pluck(rank_column))
|
171
|
+
new_rank = padded_array.sum / 2
|
172
|
+
|
173
|
+
with_persistence do
|
174
|
+
self[rank_column] = new_rank
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
define_method :increase_rank do |count = 1|
|
179
|
+
higher_items = get_higher_items(count + 1, "DESC")
|
180
|
+
return if higher_items.blank?
|
181
|
+
|
182
|
+
padded_array = pad_array(higher_items.pluck(rank_column), 0)
|
183
|
+
new_rank = padded_array.sum / 2
|
184
|
+
|
185
|
+
with_persistence do
|
186
|
+
self[rank_column] = new_rank
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
define_method :decrease_rank do |count = 1|
|
191
|
+
lower_items = get_lower_items(count + 1, "ASC")
|
192
|
+
return if lower_items.blank?
|
193
|
+
|
194
|
+
padded_array = pad_array(lower_items.pluck(rank_column))
|
195
|
+
new_rank = padded_array.sum / 2
|
196
|
+
|
197
|
+
with_persistence do
|
198
|
+
self[rank_column] = new_rank
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
define_method :get_higher_items do |limit = 0, order = "DESC", rank = nil, distinct = false, include_self_rank = false|
|
203
|
+
operator = include_self_rank ? "<=" : "<"
|
204
|
+
rank ||= current_rank
|
205
|
+
|
206
|
+
query = self.class.acts_as_ranked_list_query
|
207
|
+
.where("#{self.class.quoted_rank_column} #{operator} #{rank}")
|
208
|
+
.order(self.class.order_by_columns(order))
|
209
|
+
|
210
|
+
query = query.distinct(self.class.quoted_rank_column) if distinct
|
211
|
+
|
212
|
+
return query if limit == 0
|
213
|
+
|
214
|
+
query.limit(limit)
|
215
|
+
end
|
216
|
+
|
217
|
+
define_method :get_lower_items do |limit = 0, order = "ASC", rank = nil, distinct = false, include_self_rank = false|
|
218
|
+
operator = include_self_rank ? ">=" : ">"
|
219
|
+
rank ||= current_rank
|
220
|
+
|
221
|
+
query = self.class.acts_as_ranked_list_query
|
222
|
+
.where("#{self.class.quoted_rank_column} #{operator} #{rank}")
|
223
|
+
.order(self.class.order_by_columns(order))
|
224
|
+
|
225
|
+
query = query.distinct(self.class.quoted_rank_column) if distinct
|
226
|
+
|
227
|
+
return query if limit == 0
|
228
|
+
|
229
|
+
query.limit(limit)
|
230
|
+
end
|
231
|
+
|
232
|
+
define_method :highest_item? do
|
233
|
+
self.class.get_highest_items(1).first == self
|
234
|
+
end
|
235
|
+
|
236
|
+
define_method :lowest_item? do
|
237
|
+
self.class.get_lowest_items(1).first == self
|
238
|
+
end
|
239
|
+
|
240
|
+
private
|
241
|
+
|
242
|
+
define_method :with_persistence do |items = [self], &blk|
|
243
|
+
blk.call
|
244
|
+
|
245
|
+
items.each(&:save!) unless skip_persistence?
|
246
|
+
end
|
247
|
+
|
248
|
+
define_method :pad_array do |array, value = nil, size = 2|
|
249
|
+
current_array_value = array[0] || self.class.step_increment
|
250
|
+
value ||= current_array_value + self.class.step_increment
|
251
|
+
array + ::Array.new(size - array.size, value)
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActsAsRankedList
|
4
|
+
module ActiveRecord #:nodoc:
|
5
|
+
module Service
|
6
|
+
|
7
|
+
# Add `acts_as_ranked_list` to an `::ActiveRecord` model to use this gem.
|
8
|
+
# Please refer to the {file:README.md} for complete usage and examples.
|
9
|
+
#
|
10
|
+
# @example with an `::ActiveRecord` model of the name `TodoItem`. Works when inheriting from `::ApplicationRecord`.
|
11
|
+
# class TodoItem << ::ActiveRecord::Base
|
12
|
+
# acts_as_ranked_list
|
13
|
+
# end
|
14
|
+
#
|
15
|
+
# @example when changing default options
|
16
|
+
# class TodoItem << ::ActiveRecord::Base
|
17
|
+
# acts_as_ranked_list column: "position", touch_on_update: false, step_increment: 0.5, avoid_collisions: false, new_item_at: :highest
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
# @since 0.2.0
|
21
|
+
# @scope class
|
22
|
+
# @param [Hash] user_options options
|
23
|
+
# @option user_options [String] :column The column name to use for ranking
|
24
|
+
# @option user_options [Boolean] :touch_on_update Controls updating table's columns
|
25
|
+
# updated fields on any write operation
|
26
|
+
# @option user_options [Float/Integer] :step_increment The value to use for spreading ranks
|
27
|
+
# @option user_options [Boolean] :avoid_collisions Controls avoiding rank collisions
|
28
|
+
# @option user_options [Symbol] :new_item_at Controls where to add new items
|
29
|
+
# @return [void]
|
30
|
+
def acts_as_ranked_list(user_options = {})
|
31
|
+
options = {
|
32
|
+
column: "rank",
|
33
|
+
touch_on_update: true,
|
34
|
+
step_increment: 1024,
|
35
|
+
avoid_collisions: true,
|
36
|
+
new_item_at: :lowest
|
37
|
+
}
|
38
|
+
options.update(user_options)
|
39
|
+
|
40
|
+
::ActsAsRankedList::ActiveRecord::PersistenceCallback.call(self)
|
41
|
+
::ActsAsRankedList::ActiveRecord::RankColumn.call(self, options[:column], options[:touch_on_update], options[:step_increment], options[:avoid_collisions], options[:new_item_at])
|
42
|
+
|
43
|
+
include ::ActsAsRankedList::ActiveRecord::Service::InstanceMethods
|
44
|
+
include ::ActsAsRankedList::ActiveRecord::SkipPersistence
|
45
|
+
include ::ActsAsRankedList::ActiveRecord::AvoidCollisions
|
46
|
+
end
|
47
|
+
|
48
|
+
module InstanceMethods
|
49
|
+
def ranked_list_before_validation_callback
|
50
|
+
nil
|
51
|
+
end
|
52
|
+
|
53
|
+
def ranked_list_before_destroy_callback
|
54
|
+
nil
|
55
|
+
end
|
56
|
+
|
57
|
+
def ranked_list_after_destroy_callback
|
58
|
+
nil
|
59
|
+
end
|
60
|
+
|
61
|
+
def ranked_list_before_update_callback
|
62
|
+
update_ranks
|
63
|
+
end
|
64
|
+
|
65
|
+
def ranked_list_after_update_callback
|
66
|
+
nil
|
67
|
+
end
|
68
|
+
|
69
|
+
def ranked_list_after_save_callback
|
70
|
+
@rank_changed = nil
|
71
|
+
end
|
72
|
+
|
73
|
+
def ranked_list_before_create_callback
|
74
|
+
set_new_item_rank
|
75
|
+
update_ranks
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def update_ranks
|
81
|
+
return unless avoid_collisions?
|
82
|
+
|
83
|
+
return if rank_changed? == false
|
84
|
+
|
85
|
+
return unless current_rank && self.class.acts_as_ranked_list_query.where(
|
86
|
+
"#{self.class.quoted_rank_column_with_table_name} = #{current_rank}"
|
87
|
+
).count >= 1
|
88
|
+
|
89
|
+
self.class.spread_ranks
|
90
|
+
end
|
91
|
+
|
92
|
+
def set_new_item_rank
|
93
|
+
return if current_rank.present?
|
94
|
+
|
95
|
+
case self.class.new_item_at
|
96
|
+
when :highest
|
97
|
+
highest_item_rank = self.class.get_highest_items(1).first&.current_rank || self.class.step_increment
|
98
|
+
self[rank_column] = [highest_item_rank, 0].sum / 2
|
99
|
+
when :lowest
|
100
|
+
lowest_item_rank = self.class.get_lowest_items(1).first&.current_rank || self.class.step_increment
|
101
|
+
self[rank_column] = [lowest_item_rank, lowest_item_rank + self.class.step_increment].sum / 2
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActsAsRankedList #:nodoc:
|
4
|
+
module ActiveRecord #:nodoc:
|
5
|
+
module SkipPersistence # Refer to {SkipPersistence::ClassMethods} for docs.
|
6
|
+
|
7
|
+
def self.included(base)
|
8
|
+
base.extend(ClassMethods)
|
9
|
+
end
|
10
|
+
|
11
|
+
module ClassMethods
|
12
|
+
# Pass a block to this method to prevent persisting `::ActiveRecord` updates and
|
13
|
+
# callbacks. You may pass an array of classes to prevent persisting. These
|
14
|
+
# `::ActiveRecord` models _must_ include the `acts_as_ranked_list` concern.
|
15
|
+
#
|
16
|
+
# @example with an `::ActiveRecord` model of the name `TodoItem`:
|
17
|
+
# TodoItem.with_skip_persistence { TodoItem.find(4).increase_rank }
|
18
|
+
# @since 0.2.0
|
19
|
+
# @scope class
|
20
|
+
# @param [Array<Class>] klasses array of klasses to prevent persisting
|
21
|
+
# @yield the block to execute with changes to the instances
|
22
|
+
# @raise [ArgumentError] raised if optional klasses argument is not an array of
|
23
|
+
# `::ActiveRecord` objects
|
24
|
+
# @return [void]
|
25
|
+
def with_skip_persistence(klasses = [], &blk)
|
26
|
+
raise ::ArgumentError unless klasses.is_a?(Array)
|
27
|
+
|
28
|
+
klasses << self
|
29
|
+
|
30
|
+
raise ::ArgumentError unless active_record_objects?(klasses)
|
31
|
+
|
32
|
+
SkipPersistence.with_applied_klasses(klasses) do
|
33
|
+
yield
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def active_record_objects?(klasses)
|
40
|
+
klasses.all? { |klass| klass.ancestors.include?(::ActiveRecord::Base) }
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
class << self
|
45
|
+
def with_applied_klasses(klasses, &blk)
|
46
|
+
klasses.map {|klass| add_klass(klass)}
|
47
|
+
yield
|
48
|
+
ensure
|
49
|
+
klasses.map {|klass| remove_klass(klass)}
|
50
|
+
end
|
51
|
+
|
52
|
+
def applied_to?(klass)
|
53
|
+
!(klass.ancestors & extracted_klasses.keys).empty?
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def extracted_klasses
|
59
|
+
::Thread.current[:acts_as_ranked_list_skip_persistence] ||= {}
|
60
|
+
end
|
61
|
+
|
62
|
+
def add_klass(klass)
|
63
|
+
extracted_klasses[klass] = 0 unless extracted_klasses.key?(klass)
|
64
|
+
extracted_klasses[klass] += 1
|
65
|
+
end
|
66
|
+
|
67
|
+
def remove_klass(klass)
|
68
|
+
extracted_klasses[klass] -= 1
|
69
|
+
extracted_klasses.delete(klass) if extracted_klasses[klass] <= 0
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
def skip_persistence?
|
76
|
+
SkipPersistence.applied_to?(self.class)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,8 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "acts_as_ranked_list/active_record/active_record"
|
4
|
+
require "acts_as_ranked_list/active_record/service"
|
5
|
+
require "acts_as_ranked_list/active_record/skip_persistence"
|
6
|
+
require "acts_as_ranked_list/active_record/persistence_callback"
|
7
|
+
require "acts_as_ranked_list/active_record/rank_column"
|
8
|
+
require "acts_as_ranked_list/active_record/avoid_collisions"
|
metadata
ADDED
@@ -0,0 +1,199 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: acts_as_ranked_list
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.2.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Farbafe
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2023-04-21 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 6.0.3
|
20
|
+
- - ">="
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: 6.0.3.6
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - "~>"
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: 6.0.3
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: 6.0.3.6
|
33
|
+
- !ruby/object:Gem::Dependency
|
34
|
+
name: rake
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '13.0'
|
40
|
+
type: :runtime
|
41
|
+
prerelease: false
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - "~>"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '13.0'
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: rspec
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - "~>"
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '3.0'
|
54
|
+
type: :runtime
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - "~>"
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '3.0'
|
61
|
+
- !ruby/object:Gem::Dependency
|
62
|
+
name: standard
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - "~>"
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: '1.3'
|
68
|
+
type: :runtime
|
69
|
+
prerelease: false
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
71
|
+
requirements:
|
72
|
+
- - "~>"
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: '1.3'
|
75
|
+
- !ruby/object:Gem::Dependency
|
76
|
+
name: rspec-rails
|
77
|
+
requirement: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - '='
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: 5.1.2
|
82
|
+
type: :runtime
|
83
|
+
prerelease: false
|
84
|
+
version_requirements: !ruby/object:Gem::Requirement
|
85
|
+
requirements:
|
86
|
+
- - '='
|
87
|
+
- !ruby/object:Gem::Version
|
88
|
+
version: 5.1.2
|
89
|
+
- !ruby/object:Gem::Dependency
|
90
|
+
name: byebug
|
91
|
+
requirement: !ruby/object:Gem::Requirement
|
92
|
+
requirements:
|
93
|
+
- - '='
|
94
|
+
- !ruby/object:Gem::Version
|
95
|
+
version: 11.1.3
|
96
|
+
type: :development
|
97
|
+
prerelease: false
|
98
|
+
version_requirements: !ruby/object:Gem::Requirement
|
99
|
+
requirements:
|
100
|
+
- - '='
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: 11.1.3
|
103
|
+
- !ruby/object:Gem::Dependency
|
104
|
+
name: yard
|
105
|
+
requirement: !ruby/object:Gem::Requirement
|
106
|
+
requirements:
|
107
|
+
- - '='
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: 0.9.34
|
110
|
+
type: :development
|
111
|
+
prerelease: false
|
112
|
+
version_requirements: !ruby/object:Gem::Requirement
|
113
|
+
requirements:
|
114
|
+
- - '='
|
115
|
+
- !ruby/object:Gem::Version
|
116
|
+
version: 0.9.34
|
117
|
+
- !ruby/object:Gem::Dependency
|
118
|
+
name: redcarpet
|
119
|
+
requirement: !ruby/object:Gem::Requirement
|
120
|
+
requirements:
|
121
|
+
- - '='
|
122
|
+
- !ruby/object:Gem::Version
|
123
|
+
version: 3.6.0
|
124
|
+
type: :development
|
125
|
+
prerelease: false
|
126
|
+
version_requirements: !ruby/object:Gem::Requirement
|
127
|
+
requirements:
|
128
|
+
- - '='
|
129
|
+
- !ruby/object:Gem::Version
|
130
|
+
version: 3.6.0
|
131
|
+
- !ruby/object:Gem::Dependency
|
132
|
+
name: sqlite3
|
133
|
+
requirement: !ruby/object:Gem::Requirement
|
134
|
+
requirements:
|
135
|
+
- - '='
|
136
|
+
- !ruby/object:Gem::Version
|
137
|
+
version: 1.6.2
|
138
|
+
type: :development
|
139
|
+
prerelease: false
|
140
|
+
version_requirements: !ruby/object:Gem::Requirement
|
141
|
+
requirements:
|
142
|
+
- - '='
|
143
|
+
- !ruby/object:Gem::Version
|
144
|
+
version: 1.6.2
|
145
|
+
description: Orders ActiveRecord items by floating ranks for spaces in-between items.
|
146
|
+
Influenced by gem ActsAsList. The floating rank allows inserting items at arbitrary
|
147
|
+
positions without reordering items. Thus, reducing the number of WRITE queries.
|
148
|
+
email:
|
149
|
+
- yehyapal@gmail.com
|
150
|
+
executables: []
|
151
|
+
extensions: []
|
152
|
+
extra_rdoc_files: []
|
153
|
+
files:
|
154
|
+
- ".rspec"
|
155
|
+
- ".standard.yml"
|
156
|
+
- ".yardopts"
|
157
|
+
- CHANGELOG.md
|
158
|
+
- Gemfile
|
159
|
+
- LICENSE.txt
|
160
|
+
- README.md
|
161
|
+
- Rakefile
|
162
|
+
- lib/acts_as_ranked_list.rb
|
163
|
+
- lib/acts_as_ranked_list/active_record/active_record.rb
|
164
|
+
- lib/acts_as_ranked_list/active_record/avoid_collisions.rb
|
165
|
+
- lib/acts_as_ranked_list/active_record/persistence_callback.rb
|
166
|
+
- lib/acts_as_ranked_list/active_record/rank_column.rb
|
167
|
+
- lib/acts_as_ranked_list/active_record/service.rb
|
168
|
+
- lib/acts_as_ranked_list/active_record/skip_persistence.rb
|
169
|
+
- lib/acts_as_ranked_list/errors/base.rb
|
170
|
+
- lib/acts_as_ranked_list/version.rb
|
171
|
+
- sig/acts_as_ranked_list.rbs
|
172
|
+
homepage: https://github.com/Farbafe/acts_as_ranked_list
|
173
|
+
licenses:
|
174
|
+
- MIT
|
175
|
+
metadata:
|
176
|
+
allowed_push_host: https://rubygems.org
|
177
|
+
homepage_uri: https://github.com/Farbafe/acts_as_ranked_list
|
178
|
+
source_code_uri: https://github.com/Farbafe/acts_as_ranked_list/blob/master
|
179
|
+
changelog_uri: https://github.com/Farbafe/acts_as_ranked_list/blob/master/CHANGELOG.md
|
180
|
+
post_install_message:
|
181
|
+
rdoc_options: []
|
182
|
+
require_paths:
|
183
|
+
- lib
|
184
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
185
|
+
requirements:
|
186
|
+
- - ">="
|
187
|
+
- !ruby/object:Gem::Version
|
188
|
+
version: 2.6.0
|
189
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
190
|
+
requirements:
|
191
|
+
- - ">="
|
192
|
+
- !ruby/object:Gem::Version
|
193
|
+
version: '0'
|
194
|
+
requirements: []
|
195
|
+
rubygems_version: 3.1.6
|
196
|
+
signing_key:
|
197
|
+
specification_version: 4
|
198
|
+
summary: Orders ActiveRecord items by ranks. Influenced by gem ActsAsList.
|
199
|
+
test_files: []
|