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 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
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/testdouble/standard
3
+ ruby_version: 2.6
data/.yardopts ADDED
@@ -0,0 +1,2 @@
1
+ -M redcarpet
2
+ -m markdown
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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in acts_as_ranked_list.gemspec
6
+ gemspec
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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[spec standard]
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ ::ActiveSupport.on_load :active_record do
4
+ extend ::ActsAsRankedList::ActiveRecord::Service
5
+ end
@@ -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
+ module ActsAsRankedList
4
+ module Errors
5
+ class Base < ::StandardError # Any gem-specific errors will be inheriting this base error.
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsRankedList
4
+ VERSION = "0.2.0"
5
+ 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"
@@ -0,0 +1,4 @@
1
+ module ActsAsRankedList
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
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: []