ranked-model 0.4.7 → 0.4.8
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +5 -0
- data/Appraisals +14 -0
- data/Gemfile +3 -3
- data/Readme.mkd +2 -2
- data/gemfiles/rails_6_1.gemfile +22 -0
- data/lib/ranked-model/ranker.rb +356 -358
- data/lib/ranked-model/version.rb +1 -1
- data/ranked-model.gemspec +1 -1
- metadata +11 -10
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '004680e8b292451d304375f3ae01d0100702db74867d93b2a713e7c908241d78'
|
4
|
+
data.tar.gz: cc831e2837ba8ad07c21f6b890959e071005824a4f53bd51c135c8055fb1904f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d4494e9eede725ef30ecbd05006a4d91deb89f62aced56b96fa3be2f0f5732f41336d5a3d1c450e3f5d2c0f1821a15de096a1ee68732d8cb5d10a300008270ca
|
7
|
+
data.tar.gz: 04fa0d21ff8b2c6533ebc6705cc00c7258bd955c88a91636995d362136de9cf2a57c6e8ca49c43e112757e1209dc9a9fe3c2285b2bfde47639bcab90d2368fd4
|
data/.travis.yml
CHANGED
@@ -26,6 +26,7 @@ gemfile:
|
|
26
26
|
- gemfiles/rails_5_1.gemfile
|
27
27
|
- gemfiles/rails_5_2.gemfile
|
28
28
|
- gemfiles/rails_6_0.gemfile
|
29
|
+
- gemfiles/rails_6_1.gemfile
|
29
30
|
matrix:
|
30
31
|
exclude:
|
31
32
|
# Rails <6 does not support Ruby 3, see:
|
@@ -49,3 +50,7 @@ matrix:
|
|
49
50
|
gemfile: gemfiles/rails_6_0.gemfile
|
50
51
|
- rvm: jruby-9.1.17.0
|
51
52
|
gemfile: gemfiles/rails_6_0.gemfile
|
53
|
+
- rvm: 2.4
|
54
|
+
gemfile: gemfiles/rails_6_1.gemfile
|
55
|
+
- rvm: jruby-9.1.17.0
|
56
|
+
gemfile: gemfiles/rails_6_1.gemfile
|
data/Appraisals
CHANGED
@@ -70,3 +70,17 @@ appraise "rails-6-0" do
|
|
70
70
|
end
|
71
71
|
gem "activerecord", "~> 6.0.0"
|
72
72
|
end
|
73
|
+
|
74
|
+
appraise "rails-6-1" do
|
75
|
+
group :sqlite do
|
76
|
+
gem "sqlite3", "~> 1.4", platform: :ruby
|
77
|
+
gem "activerecord-jdbcsqlite3-adapter", "~> 61.0", platform: :jruby
|
78
|
+
end
|
79
|
+
group :mysql do
|
80
|
+
gem "activerecord-jdbcmysql-adapter", "~> 61.0", platform: :jruby
|
81
|
+
end
|
82
|
+
group :postgresql do
|
83
|
+
gem "activerecord-jdbcpostgresql-adapter", "~> 61.0", platform: :jruby
|
84
|
+
end
|
85
|
+
gem "activerecord", "~> 6.1.0"
|
86
|
+
end
|
data/Gemfile
CHANGED
@@ -4,13 +4,13 @@ source "https://rubygems.org"
|
|
4
4
|
gemspec
|
5
5
|
|
6
6
|
group :sqlite do
|
7
|
-
gem "sqlite3",
|
7
|
+
gem "sqlite3", platform: :ruby
|
8
8
|
end
|
9
9
|
|
10
10
|
group :postgresql do
|
11
|
-
gem "pg",
|
11
|
+
gem "pg", platform: :ruby
|
12
12
|
end
|
13
13
|
|
14
14
|
group :mysql do
|
15
|
-
gem "mysql2",
|
15
|
+
gem "mysql2", platform: :ruby
|
16
16
|
end
|
data/Readme.mkd
CHANGED
@@ -5,7 +5,7 @@
|
|
5
5
|
Installation
|
6
6
|
------------
|
7
7
|
|
8
|
-
ranked-model passes specs with Rails 4.2, 5.0, 5.1, 5.2 and 6.
|
8
|
+
ranked-model passes specs with Rails 4.2, 5.0, 5.1, 5.2, 6.0 and 6.1 for MySQL, Postgres, and SQLite on Ruby 2.4 through 3.0 (with exceptions, please check the CI setup for supported combinations), and jruby-9.1.17.0 where Rails supports the platform.
|
9
9
|
|
10
10
|
To install ranked-model, just add it to your `Gemfile`:
|
11
11
|
|
@@ -212,7 +212,7 @@ class AddRowOrderToDucks < ActiveRecord::Migration[6.0]
|
|
212
212
|
Duck.update_all('row_order = EXTRACT(EPOCH FROM created_at)')
|
213
213
|
|
214
214
|
# Alternatively, implement any other sorting default
|
215
|
-
# Duck.order(created_at: :desc).
|
215
|
+
# Duck.order(created_at: :desc).each do |duck|
|
216
216
|
# duck.update!(row_order: duck.created_at.to_i + duck.age / 2)
|
217
217
|
# end
|
218
218
|
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# This file was generated by Appraisal
|
2
|
+
|
3
|
+
source "https://rubygems.org"
|
4
|
+
|
5
|
+
gem "activerecord", "~> 6.1.0"
|
6
|
+
|
7
|
+
group :sqlite do
|
8
|
+
gem "sqlite3", "~> 1.4", platform: :ruby
|
9
|
+
gem "activerecord-jdbcsqlite3-adapter", "~> 61.0", platform: :jruby
|
10
|
+
end
|
11
|
+
|
12
|
+
group :postgresql do
|
13
|
+
gem "pg", "~> 1.2.0", platform: :ruby
|
14
|
+
gem "activerecord-jdbcpostgresql-adapter", "~> 61.0", platform: :jruby
|
15
|
+
end
|
16
|
+
|
17
|
+
group :mysql do
|
18
|
+
gem "mysql2", "~> 0.5.0", platform: :ruby
|
19
|
+
gem "activerecord-jdbcmysql-adapter", "~> 61.0", platform: :jruby
|
20
|
+
end
|
21
|
+
|
22
|
+
gemspec path: "../"
|
data/lib/ranked-model/ranker.rb
CHANGED
@@ -1,358 +1,356 @@
|
|
1
|
-
module RankedModel
|
2
|
-
|
3
|
-
class InvalidScope < StandardError; end
|
4
|
-
class InvalidField < StandardError; end
|
5
|
-
|
6
|
-
class Ranker
|
7
|
-
attr_accessor :name, :column, :scope, :with_same, :class_name, :unless
|
8
|
-
|
9
|
-
def initialize name, options={}
|
10
|
-
self.name = name.to_sym
|
11
|
-
self.column = options[:column] || name
|
12
|
-
self.class_name = options[:class_name]
|
13
|
-
|
14
|
-
[ :scope, :with_same, :unless ].each do |key|
|
15
|
-
self.send "#{key}=", options[key]
|
16
|
-
end
|
17
|
-
end
|
18
|
-
|
19
|
-
def with instance
|
20
|
-
Mapper.new self, instance
|
21
|
-
end
|
22
|
-
|
23
|
-
class Mapper
|
24
|
-
attr_accessor :ranker, :instance
|
25
|
-
|
26
|
-
def initialize ranker, instance
|
27
|
-
self.ranker = ranker
|
28
|
-
self.instance = instance
|
29
|
-
|
30
|
-
validate_ranker_for_instance!
|
31
|
-
end
|
32
|
-
|
33
|
-
def validate_ranker_for_instance!
|
34
|
-
if ranker.scope && !instance_class.respond_to?(ranker.scope)
|
35
|
-
raise RankedModel::InvalidScope, %Q{No scope called "#{ranker.scope}" found in model}
|
36
|
-
end
|
37
|
-
|
38
|
-
if ranker.with_same
|
39
|
-
if (case ranker.with_same
|
40
|
-
when Symbol
|
41
|
-
!instance.respond_to?(ranker.with_same)
|
42
|
-
when Array
|
43
|
-
array_element = ranker.with_same.detect {|attr| !instance.respond_to?(attr) }
|
44
|
-
else
|
45
|
-
false
|
46
|
-
end)
|
47
|
-
raise RankedModel::InvalidField, %Q{No field called "#{array_element || ranker.with_same}" found in model}
|
48
|
-
end
|
49
|
-
end
|
50
|
-
end
|
51
|
-
|
52
|
-
def handle_ranking
|
53
|
-
case ranker.unless
|
54
|
-
when Proc
|
55
|
-
return if ranker.unless.call(instance)
|
56
|
-
when Symbol
|
57
|
-
return if instance.send(ranker.unless)
|
58
|
-
end
|
59
|
-
|
60
|
-
update_index_from_position
|
61
|
-
assure_unique_position
|
62
|
-
end
|
63
|
-
|
64
|
-
def update_rank! value
|
65
|
-
# Bypass callbacks
|
66
|
-
#
|
67
|
-
instance_class.
|
68
|
-
where(instance_class.primary_key => instance.id).
|
69
|
-
update_all(ranker.column => value)
|
70
|
-
end
|
71
|
-
|
72
|
-
def reset_ranks!
|
73
|
-
finder.update_all(ranker.column => nil)
|
74
|
-
end
|
75
|
-
|
76
|
-
def position
|
77
|
-
instance.send "#{ranker.name}_position"
|
78
|
-
end
|
79
|
-
|
80
|
-
def relative_rank
|
81
|
-
escaped_column = instance_class.connection.quote_column_name ranker.column
|
82
|
-
|
83
|
-
finder.where("#{escaped_column} < #{rank}").count(:all)
|
84
|
-
end
|
85
|
-
|
86
|
-
def rank
|
87
|
-
instance.send "#{ranker.column}"
|
88
|
-
end
|
89
|
-
|
90
|
-
def current_at_position _pos
|
91
|
-
if (ordered_instance = finder.offset(_pos).first)
|
92
|
-
RankedModel::Ranker::Mapper.new ranker, ordered_instance
|
93
|
-
end
|
94
|
-
end
|
95
|
-
|
96
|
-
def has_rank?
|
97
|
-
!rank.nil?
|
98
|
-
end
|
99
|
-
|
100
|
-
private
|
101
|
-
|
102
|
-
def reset_cache
|
103
|
-
@finder, @current_order, @current_first, @current_last = nil
|
104
|
-
end
|
105
|
-
|
106
|
-
def instance_class
|
107
|
-
ranker.class_name.nil? ? instance.class : ranker.class_name.constantize
|
108
|
-
end
|
109
|
-
|
110
|
-
def position_at value
|
111
|
-
instance.send "#{ranker.name}_position=", value
|
112
|
-
update_index_from_position
|
113
|
-
end
|
114
|
-
|
115
|
-
def rank_at value
|
116
|
-
instance.send "#{ranker.column}=", value
|
117
|
-
end
|
118
|
-
|
119
|
-
def rank_changed?
|
120
|
-
instance.send "#{ranker.column}_changed?"
|
121
|
-
end
|
122
|
-
|
123
|
-
def new_record?
|
124
|
-
instance.new_record?
|
125
|
-
end
|
126
|
-
|
127
|
-
def update_index_from_position
|
128
|
-
case position
|
129
|
-
when :first, 'first'
|
130
|
-
if current_first && current_first.rank
|
131
|
-
rank_at_average current_first.rank, RankedModel::MIN_RANK_VALUE
|
132
|
-
else
|
133
|
-
position_at :middle
|
134
|
-
end
|
135
|
-
when :last, 'last'
|
136
|
-
if current_last && current_last.rank
|
137
|
-
rank_at_average current_last.rank, RankedModel::MAX_RANK_VALUE
|
138
|
-
else
|
139
|
-
position_at :middle
|
140
|
-
end
|
141
|
-
when :middle, 'middle'
|
142
|
-
rank_at_average RankedModel::MIN_RANK_VALUE, RankedModel::MAX_RANK_VALUE
|
143
|
-
when :down, 'down'
|
144
|
-
neighbors = find_next_two(rank)
|
145
|
-
if neighbors[:lower]
|
146
|
-
min = neighbors[:lower].rank
|
147
|
-
max = neighbors[:upper] ? neighbors[:upper].rank : RankedModel::MAX_RANK_VALUE
|
148
|
-
rank_at_average min, max
|
149
|
-
end
|
150
|
-
when :up, 'up'
|
151
|
-
neighbors = find_previous_two(rank)
|
152
|
-
if neighbors[:upper]
|
153
|
-
max = neighbors[:upper].rank
|
154
|
-
min = neighbors[:lower] ? neighbors[:lower].rank : RankedModel::MIN_RANK_VALUE
|
155
|
-
rank_at_average min, max
|
156
|
-
end
|
157
|
-
when String
|
158
|
-
position_at position.to_i
|
159
|
-
when 0
|
160
|
-
position_at :first
|
161
|
-
when Integer
|
162
|
-
neighbors = neighbors_at_position(position)
|
163
|
-
min = ((neighbors[:lower] && neighbors[:lower].has_rank?) ? neighbors[:lower].rank : RankedModel::MIN_RANK_VALUE)
|
164
|
-
max = ((neighbors[:upper] && neighbors[:upper].has_rank?) ? neighbors[:upper].rank : RankedModel::MAX_RANK_VALUE)
|
165
|
-
rank_at_average min, max
|
166
|
-
when NilClass
|
167
|
-
if !rank
|
168
|
-
position_at :last
|
169
|
-
end
|
170
|
-
end
|
171
|
-
end
|
172
|
-
|
173
|
-
def rank_at_average(min, max)
|
174
|
-
if (max - min).between?(-1, 1) # No room at the inn...
|
175
|
-
rebalance_ranks
|
176
|
-
position_at position
|
177
|
-
else
|
178
|
-
rank_at( ( ( max - min ).to_f / 2 ).ceil + min )
|
179
|
-
end
|
180
|
-
end
|
181
|
-
|
182
|
-
def assure_unique_position
|
183
|
-
if ( new_record? || rank_changed? )
|
184
|
-
if (rank > RankedModel::MAX_RANK_VALUE) || rank_taken?
|
185
|
-
rearrange_ranks
|
186
|
-
end
|
187
|
-
end
|
188
|
-
end
|
189
|
-
|
190
|
-
def rearrange_ranks
|
191
|
-
_scope = finder
|
192
|
-
escaped_column = instance_class.connection.quote_column_name ranker.column
|
193
|
-
# If there is room at the bottom of the list and we're added to the very top of the list...
|
194
|
-
if current_first.rank && current_first.rank > RankedModel::MIN_RANK_VALUE && rank == RankedModel::MAX_RANK_VALUE
|
195
|
-
# ...then move everyone else down 1 to make room for us at the end
|
196
|
-
_scope.
|
197
|
-
where( instance_class.arel_table[ranker.column].lteq(rank) ).
|
198
|
-
update_all( "#{escaped_column} = #{escaped_column} - 1" )
|
199
|
-
# If there is room at the top of the list and we're added below the last value in the list...
|
200
|
-
elsif current_last.rank && current_last.rank < (RankedModel::MAX_RANK_VALUE - 1) && rank < current_last.rank
|
201
|
-
# ...then move everyone else at or above our desired rank up 1 to make room for us
|
202
|
-
_scope.
|
203
|
-
where( instance_class.arel_table[ranker.column].gteq(rank) ).
|
204
|
-
update_all( "#{escaped_column} = #{escaped_column} + 1" )
|
205
|
-
# If there is room at the bottom of the list and we're added above the lowest value in the list...
|
206
|
-
elsif current_first.rank && current_first.rank > RankedModel::MIN_RANK_VALUE && rank > current_first.rank
|
207
|
-
# ...then move everyone else below us down 1 and change our rank down 1 to avoid the collission
|
208
|
-
_scope.
|
209
|
-
where( instance_class.arel_table[ranker.column].lt(rank) ).
|
210
|
-
update_all( "#{escaped_column} = #{escaped_column} - 1" )
|
211
|
-
rank_at( rank - 1 )
|
212
|
-
else
|
213
|
-
rebalance_ranks
|
214
|
-
end
|
215
|
-
end
|
216
|
-
|
217
|
-
def rebalance_ranks
|
218
|
-
ActiveRecord::Base.transaction do
|
219
|
-
if rank && instance.persisted?
|
220
|
-
origin = current_order.index { |item| item.instance.id == instance.id }
|
221
|
-
if origin
|
222
|
-
destination = current_order.index { |item| rank <= item.rank }
|
223
|
-
destination -= 1 if origin < destination
|
224
|
-
|
225
|
-
current_order.insert destination, current_order.delete_at(origin)
|
226
|
-
end
|
227
|
-
end
|
228
|
-
|
229
|
-
gaps = current_order.size + 1
|
230
|
-
range = (RankedModel::MAX_RANK_VALUE - RankedModel::MIN_RANK_VALUE).to_f
|
231
|
-
gap_size = (range / gaps).ceil
|
232
|
-
|
233
|
-
reset_ranks!
|
234
|
-
|
235
|
-
current_order.each.with_index(1) do |item, position|
|
236
|
-
new_rank = (gap_size * position) + RankedModel::MIN_RANK_VALUE
|
237
|
-
|
238
|
-
if item.instance.id == instance.id
|
239
|
-
rank_at new_rank
|
240
|
-
else
|
241
|
-
item.update_rank! new_rank
|
242
|
-
end
|
243
|
-
end
|
244
|
-
|
245
|
-
reset_cache
|
246
|
-
end
|
247
|
-
end
|
248
|
-
|
249
|
-
def finder(order = :asc)
|
250
|
-
@finder ||= {}
|
251
|
-
@finder[order] ||= begin
|
252
|
-
_finder = instance_class
|
253
|
-
columns = [instance_class.primary_key.to_sym, ranker.column]
|
254
|
-
|
255
|
-
if ranker.scope
|
256
|
-
_finder = _finder.send ranker.scope
|
257
|
-
end
|
258
|
-
|
259
|
-
case ranker.with_same
|
260
|
-
when Symbol
|
261
|
-
columns << ranker.with_same
|
262
|
-
_finder = _finder.where \
|
263
|
-
ranker.with_same => instance.attributes[ranker.with_same.to_s]
|
264
|
-
when Array
|
265
|
-
ranker.with_same.each do |column|
|
266
|
-
columns << column
|
267
|
-
_finder = _finder.where column => instance.attributes[column.to_s]
|
268
|
-
end
|
269
|
-
end
|
270
|
-
|
271
|
-
unless new_record?
|
272
|
-
_finder = _finder.where.not instance_class.primary_key.to_sym => instance.id
|
273
|
-
end
|
274
|
-
|
275
|
-
_finder.reorder(ranker.column.to_sym => order).select(columns)
|
276
|
-
end
|
277
|
-
end
|
278
|
-
|
279
|
-
def current_order
|
280
|
-
@current_order ||= begin
|
281
|
-
finder.unscope(where: instance_class.primary_key.to_sym).collect { |ordered_instance|
|
282
|
-
RankedModel::Ranker::Mapper.new ranker, ordered_instance
|
283
|
-
}
|
284
|
-
end
|
285
|
-
end
|
286
|
-
|
287
|
-
def current_first
|
288
|
-
@current_first ||= begin
|
289
|
-
if (ordered_instance = finder.first)
|
290
|
-
RankedModel::Ranker::Mapper.new ranker, ordered_instance
|
291
|
-
end
|
292
|
-
end
|
293
|
-
end
|
294
|
-
|
295
|
-
def current_last
|
296
|
-
@current_last ||= begin
|
297
|
-
if (ordered_instance = finder.
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
{ :lower =>
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
{
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
{
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
{
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
end
|
1
|
+
module RankedModel
|
2
|
+
|
3
|
+
class InvalidScope < StandardError; end
|
4
|
+
class InvalidField < StandardError; end
|
5
|
+
|
6
|
+
class Ranker
|
7
|
+
attr_accessor :name, :column, :scope, :with_same, :class_name, :unless
|
8
|
+
|
9
|
+
def initialize name, options={}
|
10
|
+
self.name = name.to_sym
|
11
|
+
self.column = options[:column] || name
|
12
|
+
self.class_name = options[:class_name]
|
13
|
+
|
14
|
+
[ :scope, :with_same, :unless ].each do |key|
|
15
|
+
self.send "#{key}=", options[key]
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def with instance
|
20
|
+
Mapper.new self, instance
|
21
|
+
end
|
22
|
+
|
23
|
+
class Mapper
|
24
|
+
attr_accessor :ranker, :instance
|
25
|
+
|
26
|
+
def initialize ranker, instance
|
27
|
+
self.ranker = ranker
|
28
|
+
self.instance = instance
|
29
|
+
|
30
|
+
validate_ranker_for_instance!
|
31
|
+
end
|
32
|
+
|
33
|
+
def validate_ranker_for_instance!
|
34
|
+
if ranker.scope && !instance_class.respond_to?(ranker.scope)
|
35
|
+
raise RankedModel::InvalidScope, %Q{No scope called "#{ranker.scope}" found in model}
|
36
|
+
end
|
37
|
+
|
38
|
+
if ranker.with_same
|
39
|
+
if (case ranker.with_same
|
40
|
+
when Symbol
|
41
|
+
!instance.respond_to?(ranker.with_same)
|
42
|
+
when Array
|
43
|
+
array_element = ranker.with_same.detect {|attr| !instance.respond_to?(attr) }
|
44
|
+
else
|
45
|
+
false
|
46
|
+
end)
|
47
|
+
raise RankedModel::InvalidField, %Q{No field called "#{array_element || ranker.with_same}" found in model}
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def handle_ranking
|
53
|
+
case ranker.unless
|
54
|
+
when Proc
|
55
|
+
return if ranker.unless.call(instance)
|
56
|
+
when Symbol
|
57
|
+
return if instance.send(ranker.unless)
|
58
|
+
end
|
59
|
+
|
60
|
+
update_index_from_position
|
61
|
+
assure_unique_position
|
62
|
+
end
|
63
|
+
|
64
|
+
def update_rank! value
|
65
|
+
# Bypass callbacks
|
66
|
+
#
|
67
|
+
instance_class.
|
68
|
+
where(instance_class.primary_key => instance.id).
|
69
|
+
update_all(ranker.column => value)
|
70
|
+
end
|
71
|
+
|
72
|
+
def reset_ranks!
|
73
|
+
finder.update_all(ranker.column => nil)
|
74
|
+
end
|
75
|
+
|
76
|
+
def position
|
77
|
+
instance.send "#{ranker.name}_position"
|
78
|
+
end
|
79
|
+
|
80
|
+
def relative_rank
|
81
|
+
escaped_column = instance_class.connection.quote_column_name ranker.column
|
82
|
+
|
83
|
+
finder.where("#{escaped_column} < #{rank}").count(:all)
|
84
|
+
end
|
85
|
+
|
86
|
+
def rank
|
87
|
+
instance.send "#{ranker.column}"
|
88
|
+
end
|
89
|
+
|
90
|
+
def current_at_position _pos
|
91
|
+
if (ordered_instance = finder.offset(_pos).first)
|
92
|
+
RankedModel::Ranker::Mapper.new ranker, ordered_instance
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def has_rank?
|
97
|
+
!rank.nil?
|
98
|
+
end
|
99
|
+
|
100
|
+
private
|
101
|
+
|
102
|
+
def reset_cache
|
103
|
+
@finder, @current_order, @current_first, @current_last = nil
|
104
|
+
end
|
105
|
+
|
106
|
+
def instance_class
|
107
|
+
ranker.class_name.nil? ? instance.class : ranker.class_name.constantize
|
108
|
+
end
|
109
|
+
|
110
|
+
def position_at value
|
111
|
+
instance.send "#{ranker.name}_position=", value
|
112
|
+
update_index_from_position
|
113
|
+
end
|
114
|
+
|
115
|
+
def rank_at value
|
116
|
+
instance.send "#{ranker.column}=", value
|
117
|
+
end
|
118
|
+
|
119
|
+
def rank_changed?
|
120
|
+
instance.send "#{ranker.column}_changed?"
|
121
|
+
end
|
122
|
+
|
123
|
+
def new_record?
|
124
|
+
instance.new_record?
|
125
|
+
end
|
126
|
+
|
127
|
+
def update_index_from_position
|
128
|
+
case position
|
129
|
+
when :first, 'first'
|
130
|
+
if current_first && current_first.rank
|
131
|
+
rank_at_average current_first.rank, RankedModel::MIN_RANK_VALUE
|
132
|
+
else
|
133
|
+
position_at :middle
|
134
|
+
end
|
135
|
+
when :last, 'last'
|
136
|
+
if current_last && current_last.rank
|
137
|
+
rank_at_average current_last.rank, RankedModel::MAX_RANK_VALUE
|
138
|
+
else
|
139
|
+
position_at :middle
|
140
|
+
end
|
141
|
+
when :middle, 'middle'
|
142
|
+
rank_at_average RankedModel::MIN_RANK_VALUE, RankedModel::MAX_RANK_VALUE
|
143
|
+
when :down, 'down'
|
144
|
+
neighbors = find_next_two(rank)
|
145
|
+
if neighbors[:lower]
|
146
|
+
min = neighbors[:lower].rank
|
147
|
+
max = neighbors[:upper] ? neighbors[:upper].rank : RankedModel::MAX_RANK_VALUE
|
148
|
+
rank_at_average min, max
|
149
|
+
end
|
150
|
+
when :up, 'up'
|
151
|
+
neighbors = find_previous_two(rank)
|
152
|
+
if neighbors[:upper]
|
153
|
+
max = neighbors[:upper].rank
|
154
|
+
min = neighbors[:lower] ? neighbors[:lower].rank : RankedModel::MIN_RANK_VALUE
|
155
|
+
rank_at_average min, max
|
156
|
+
end
|
157
|
+
when String
|
158
|
+
position_at position.to_i
|
159
|
+
when 0
|
160
|
+
position_at :first
|
161
|
+
when Integer
|
162
|
+
neighbors = neighbors_at_position(position)
|
163
|
+
min = ((neighbors[:lower] && neighbors[:lower].has_rank?) ? neighbors[:lower].rank : RankedModel::MIN_RANK_VALUE)
|
164
|
+
max = ((neighbors[:upper] && neighbors[:upper].has_rank?) ? neighbors[:upper].rank : RankedModel::MAX_RANK_VALUE)
|
165
|
+
rank_at_average min, max
|
166
|
+
when NilClass
|
167
|
+
if !rank
|
168
|
+
position_at :last
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
def rank_at_average(min, max)
|
174
|
+
if (max - min).between?(-1, 1) # No room at the inn...
|
175
|
+
rebalance_ranks
|
176
|
+
position_at position
|
177
|
+
else
|
178
|
+
rank_at( ( ( max - min ).to_f / 2 ).ceil + min )
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
def assure_unique_position
|
183
|
+
if ( new_record? || rank_changed? )
|
184
|
+
if (rank > RankedModel::MAX_RANK_VALUE) || rank_taken?
|
185
|
+
rearrange_ranks
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
def rearrange_ranks
|
191
|
+
_scope = finder
|
192
|
+
escaped_column = instance_class.connection.quote_column_name ranker.column
|
193
|
+
# If there is room at the bottom of the list and we're added to the very top of the list...
|
194
|
+
if current_first.rank && current_first.rank > RankedModel::MIN_RANK_VALUE && rank == RankedModel::MAX_RANK_VALUE
|
195
|
+
# ...then move everyone else down 1 to make room for us at the end
|
196
|
+
_scope.
|
197
|
+
where( instance_class.arel_table[ranker.column].lteq(rank) ).
|
198
|
+
update_all( "#{escaped_column} = #{escaped_column} - 1" )
|
199
|
+
# If there is room at the top of the list and we're added below the last value in the list...
|
200
|
+
elsif current_last.rank && current_last.rank < (RankedModel::MAX_RANK_VALUE - 1) && rank < current_last.rank
|
201
|
+
# ...then move everyone else at or above our desired rank up 1 to make room for us
|
202
|
+
_scope.
|
203
|
+
where( instance_class.arel_table[ranker.column].gteq(rank) ).
|
204
|
+
update_all( "#{escaped_column} = #{escaped_column} + 1" )
|
205
|
+
# If there is room at the bottom of the list and we're added above the lowest value in the list...
|
206
|
+
elsif current_first.rank && current_first.rank > RankedModel::MIN_RANK_VALUE && rank > current_first.rank
|
207
|
+
# ...then move everyone else below us down 1 and change our rank down 1 to avoid the collission
|
208
|
+
_scope.
|
209
|
+
where( instance_class.arel_table[ranker.column].lt(rank) ).
|
210
|
+
update_all( "#{escaped_column} = #{escaped_column} - 1" )
|
211
|
+
rank_at( rank - 1 )
|
212
|
+
else
|
213
|
+
rebalance_ranks
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
def rebalance_ranks
|
218
|
+
ActiveRecord::Base.transaction do
|
219
|
+
if rank && instance.persisted?
|
220
|
+
origin = current_order.index { |item| item.instance.id == instance.id }
|
221
|
+
if origin
|
222
|
+
destination = current_order.index { |item| rank <= item.rank }
|
223
|
+
destination -= 1 if origin < destination
|
224
|
+
|
225
|
+
current_order.insert destination, current_order.delete_at(origin)
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
gaps = current_order.size + 1
|
230
|
+
range = (RankedModel::MAX_RANK_VALUE - RankedModel::MIN_RANK_VALUE).to_f
|
231
|
+
gap_size = (range / gaps).ceil
|
232
|
+
|
233
|
+
reset_ranks!
|
234
|
+
|
235
|
+
current_order.each.with_index(1) do |item, position|
|
236
|
+
new_rank = (gap_size * position) + RankedModel::MIN_RANK_VALUE
|
237
|
+
|
238
|
+
if item.instance.id == instance.id
|
239
|
+
rank_at new_rank
|
240
|
+
else
|
241
|
+
item.update_rank! new_rank
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
reset_cache
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
def finder(order = :asc)
|
250
|
+
@finder ||= {}
|
251
|
+
@finder[order] ||= begin
|
252
|
+
_finder = instance_class
|
253
|
+
columns = [instance_class.primary_key.to_sym, ranker.column]
|
254
|
+
|
255
|
+
if ranker.scope
|
256
|
+
_finder = _finder.send ranker.scope
|
257
|
+
end
|
258
|
+
|
259
|
+
case ranker.with_same
|
260
|
+
when Symbol
|
261
|
+
columns << ranker.with_same
|
262
|
+
_finder = _finder.where \
|
263
|
+
ranker.with_same => instance.attributes[ranker.with_same.to_s]
|
264
|
+
when Array
|
265
|
+
ranker.with_same.each do |column|
|
266
|
+
columns << column
|
267
|
+
_finder = _finder.where column => instance.attributes[column.to_s]
|
268
|
+
end
|
269
|
+
end
|
270
|
+
|
271
|
+
unless new_record?
|
272
|
+
_finder = _finder.where.not instance_class.primary_key.to_sym => instance.id
|
273
|
+
end
|
274
|
+
|
275
|
+
_finder.reorder(ranker.column.to_sym => order).select(columns)
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
def current_order
|
280
|
+
@current_order ||= begin
|
281
|
+
finder.unscope(where: instance_class.primary_key.to_sym).collect { |ordered_instance|
|
282
|
+
RankedModel::Ranker::Mapper.new ranker, ordered_instance
|
283
|
+
}
|
284
|
+
end
|
285
|
+
end
|
286
|
+
|
287
|
+
def current_first
|
288
|
+
@current_first ||= begin
|
289
|
+
if (ordered_instance = finder.first)
|
290
|
+
RankedModel::Ranker::Mapper.new ranker, ordered_instance
|
291
|
+
end
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
def current_last
|
296
|
+
@current_last ||= begin
|
297
|
+
if (ordered_instance = finder.last)
|
298
|
+
RankedModel::Ranker::Mapper.new ranker, ordered_instance
|
299
|
+
end
|
300
|
+
end
|
301
|
+
end
|
302
|
+
|
303
|
+
def rank_taken?
|
304
|
+
finder.except(:order).where(ranker.column => rank).exists?
|
305
|
+
end
|
306
|
+
|
307
|
+
def neighbors_at_position _pos
|
308
|
+
if _pos > 0
|
309
|
+
if (ordered_instances = finder.offset(_pos-1).limit(2).to_a)
|
310
|
+
if ordered_instances[1]
|
311
|
+
{ :lower => RankedModel::Ranker::Mapper.new( ranker, ordered_instances[0] ),
|
312
|
+
:upper => RankedModel::Ranker::Mapper.new( ranker, ordered_instances[1] ) }
|
313
|
+
elsif ordered_instances[0]
|
314
|
+
{ :lower => RankedModel::Ranker::Mapper.new( ranker, ordered_instances[0] ) }
|
315
|
+
else
|
316
|
+
{ :lower => current_last }
|
317
|
+
end
|
318
|
+
end
|
319
|
+
else
|
320
|
+
if (ordered_instance = finder.first)
|
321
|
+
{ :upper => RankedModel::Ranker::Mapper.new( ranker, ordered_instance ) }
|
322
|
+
else
|
323
|
+
{}
|
324
|
+
end
|
325
|
+
end
|
326
|
+
end
|
327
|
+
|
328
|
+
def find_next_two _rank
|
329
|
+
ordered_instances = finder.where(instance_class.arel_table[ranker.column].gt _rank).limit(2)
|
330
|
+
if ordered_instances[1]
|
331
|
+
{ :lower => RankedModel::Ranker::Mapper.new( ranker, ordered_instances[0] ),
|
332
|
+
:upper => RankedModel::Ranker::Mapper.new( ranker, ordered_instances[1] ) }
|
333
|
+
elsif ordered_instances[0]
|
334
|
+
{ :lower => RankedModel::Ranker::Mapper.new( ranker, ordered_instances[0] ) }
|
335
|
+
else
|
336
|
+
{}
|
337
|
+
end
|
338
|
+
end
|
339
|
+
|
340
|
+
def find_previous_two _rank
|
341
|
+
ordered_instances = finder(:desc).where(instance_class.arel_table[ranker.column].lt _rank).limit(2)
|
342
|
+
if ordered_instances[1]
|
343
|
+
{ :upper => RankedModel::Ranker::Mapper.new( ranker, ordered_instances[0] ),
|
344
|
+
:lower => RankedModel::Ranker::Mapper.new( ranker, ordered_instances[1] ) }
|
345
|
+
elsif ordered_instances[0]
|
346
|
+
{ :upper => RankedModel::Ranker::Mapper.new( ranker, ordered_instances[0] ) }
|
347
|
+
else
|
348
|
+
{}
|
349
|
+
end
|
350
|
+
end
|
351
|
+
|
352
|
+
end
|
353
|
+
|
354
|
+
end
|
355
|
+
|
356
|
+
end
|
data/lib/ranked-model/version.rb
CHANGED
data/ranked-model.gemspec
CHANGED
@@ -18,7 +18,7 @@ Gem::Specification.new do |s|
|
|
18
18
|
s.add_development_dependency "rspec-its"
|
19
19
|
s.add_development_dependency "mocha"
|
20
20
|
s.add_development_dependency "database_cleaner", "~> 1.7.0"
|
21
|
-
s.add_development_dependency "rake", "
|
21
|
+
s.add_development_dependency "rake", ">= 12.3.3"
|
22
22
|
s.add_development_dependency "appraisal"
|
23
23
|
s.add_development_dependency "pry"
|
24
24
|
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ranked-model
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.4.
|
4
|
+
version: 0.4.8
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Matthew Beale
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2022-02-07 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -84,16 +84,16 @@ dependencies:
|
|
84
84
|
name: rake
|
85
85
|
requirement: !ruby/object:Gem::Requirement
|
86
86
|
requirements:
|
87
|
-
- - "
|
87
|
+
- - ">="
|
88
88
|
- !ruby/object:Gem::Version
|
89
|
-
version:
|
89
|
+
version: 12.3.3
|
90
90
|
type: :development
|
91
91
|
prerelease: false
|
92
92
|
version_requirements: !ruby/object:Gem::Requirement
|
93
93
|
requirements:
|
94
|
-
- - "
|
94
|
+
- - ">="
|
95
95
|
- !ruby/object:Gem::Version
|
96
|
-
version:
|
96
|
+
version: 12.3.3
|
97
97
|
- !ruby/object:Gem::Dependency
|
98
98
|
name: appraisal
|
99
99
|
requirement: !ruby/object:Gem::Requirement
|
@@ -143,6 +143,7 @@ files:
|
|
143
143
|
- gemfiles/rails_5_1.gemfile
|
144
144
|
- gemfiles/rails_5_2.gemfile
|
145
145
|
- gemfiles/rails_6_0.gemfile
|
146
|
+
- gemfiles/rails_6_1.gemfile
|
146
147
|
- lib/ranked-model.rb
|
147
148
|
- lib/ranked-model/railtie.rb
|
148
149
|
- lib/ranked-model/ranker.rb
|
@@ -168,7 +169,7 @@ homepage: https://github.com/mixonic/ranked-model
|
|
168
169
|
licenses:
|
169
170
|
- MIT
|
170
171
|
metadata: {}
|
171
|
-
post_install_message:
|
172
|
+
post_install_message:
|
172
173
|
rdoc_options: []
|
173
174
|
require_paths:
|
174
175
|
- lib
|
@@ -183,8 +184,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
183
184
|
- !ruby/object:Gem::Version
|
184
185
|
version: '0'
|
185
186
|
requirements: []
|
186
|
-
rubygems_version: 3.
|
187
|
-
signing_key:
|
187
|
+
rubygems_version: 3.2.32
|
188
|
+
signing_key:
|
188
189
|
specification_version: 4
|
189
190
|
summary: An acts_as_sortable replacement built for Rails 4.2+
|
190
191
|
test_files:
|