ranked-model 0.4.1 → 0.4.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +5 -5
- data/.github/FUNDING.yml +3 -0
- data/.github/dependabot.yml +6 -0
- data/.github/workflows/ci.yml +107 -0
- data/.gitignore +2 -0
- data/.rspec +1 -0
- data/.travis.yml +40 -22
- data/Appraisals +30 -46
- data/CHANGELOG.md +9 -0
- data/Gemfile +11 -2
- data/Readme.mkd +136 -17
- data/gemfiles/rails_5_2.gemfile +22 -0
- data/gemfiles/rails_6_0.gemfile +22 -0
- data/gemfiles/rails_6_1.gemfile +22 -0
- data/gemfiles/rails_7_0.gemfile +22 -0
- data/lib/ranked-model/ranker.rb +100 -71
- data/lib/ranked-model/version.rb +1 -1
- data/lib/ranked-model.rb +17 -2
- data/ranked-model.gemspec +11 -9
- data/spec/duck-model/column_default_ducks_spec.rb +29 -0
- data/spec/duck-model/duck_spec.rb +179 -79
- data/spec/duck-model/inferred_ducks_spec.rb +71 -0
- data/spec/duck-model/lots_of_ducks_spec.rb +48 -36
- data/spec/ego-model/ego_spec.rb +3 -3
- data/spec/notifications_spec.rb +89 -0
- data/spec/number-model/number_spec.rb +39 -0
- data/spec/player-model/records_already_exist_spec.rb +1 -1
- data/spec/ranked-model/ranker_spec.rb +18 -0
- data/spec/ranked-model/version_spec.rb +1 -1
- data/spec/spec_helper.rb +7 -0
- data/spec/sti-model/element_spec.rb +24 -24
- data/spec/sti-model/vehicle_spec.rb +6 -6
- data/spec/support/active_record.rb +23 -5
- data/spec/support/database.yml +2 -0
- metadata +54 -41
- data/gemfiles/rails_3_2.gemfile +0 -24
- data/gemfiles/rails_4_1.gemfile +0 -24
- data/gemfiles/rails_4_2.gemfile +0 -24
@@ -0,0 +1,22 @@
|
|
1
|
+
# This file was generated by Appraisal
|
2
|
+
|
3
|
+
source "https://rubygems.org"
|
4
|
+
|
5
|
+
gem "activerecord", "~> 7.0.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", platform: :ruby
|
14
|
+
gem "activerecord-jdbcpostgresql-adapter", "~> 61.0", platform: :jruby
|
15
|
+
end
|
16
|
+
|
17
|
+
group :mysql do
|
18
|
+
gem "mysql2", platform: :ruby
|
19
|
+
gem "activerecord-jdbcmysql-adapter", "~> 61.0", platform: :jruby
|
20
|
+
end
|
21
|
+
|
22
|
+
gemspec path: "../"
|
data/lib/ranked-model/ranker.rb
CHANGED
@@ -66,13 +66,23 @@ module RankedModel
|
|
66
66
|
#
|
67
67
|
instance_class.
|
68
68
|
where(instance_class.primary_key => instance.id).
|
69
|
-
update_all(
|
69
|
+
update_all(ranker.column => value)
|
70
|
+
end
|
71
|
+
|
72
|
+
def reset_ranks!
|
73
|
+
finder.update_all(ranker.column => nil)
|
70
74
|
end
|
71
75
|
|
72
76
|
def position
|
73
77
|
instance.send "#{ranker.name}_position"
|
74
78
|
end
|
75
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
|
+
|
76
86
|
def rank
|
77
87
|
instance.send "#{ranker.column}"
|
78
88
|
end
|
@@ -89,6 +99,10 @@ module RankedModel
|
|
89
99
|
|
90
100
|
private
|
91
101
|
|
102
|
+
def reset_cache
|
103
|
+
@finder, @current_order, @current_first, @current_last = nil
|
104
|
+
end
|
105
|
+
|
92
106
|
def instance_class
|
93
107
|
ranker.class_name.nil? ? instance.class : ranker.class_name.constantize
|
94
108
|
end
|
@@ -100,6 +114,7 @@ module RankedModel
|
|
100
114
|
|
101
115
|
def rank_at value
|
102
116
|
instance.send "#{ranker.column}=", value
|
117
|
+
instance.send "#{ranker.name}_position=", relative_rank unless position.is_a?(Integer)
|
103
118
|
end
|
104
119
|
|
105
120
|
def rank_changed?
|
@@ -114,31 +129,31 @@ module RankedModel
|
|
114
129
|
case position
|
115
130
|
when :first, 'first'
|
116
131
|
if current_first && current_first.rank
|
117
|
-
|
132
|
+
rank_at_average current_first.rank, RankedModel::MIN_RANK_VALUE
|
118
133
|
else
|
119
134
|
position_at :middle
|
120
135
|
end
|
121
136
|
when :last, 'last'
|
122
137
|
if current_last && current_last.rank
|
123
|
-
|
138
|
+
rank_at_average current_last.rank, RankedModel::MAX_RANK_VALUE
|
124
139
|
else
|
125
140
|
position_at :middle
|
126
141
|
end
|
127
142
|
when :middle, 'middle'
|
128
|
-
|
143
|
+
rank_at_average RankedModel::MIN_RANK_VALUE, RankedModel::MAX_RANK_VALUE
|
129
144
|
when :down, 'down'
|
130
145
|
neighbors = find_next_two(rank)
|
131
146
|
if neighbors[:lower]
|
132
147
|
min = neighbors[:lower].rank
|
133
148
|
max = neighbors[:upper] ? neighbors[:upper].rank : RankedModel::MAX_RANK_VALUE
|
134
|
-
|
149
|
+
rank_at_average min, max
|
135
150
|
end
|
136
151
|
when :up, 'up'
|
137
152
|
neighbors = find_previous_two(rank)
|
138
153
|
if neighbors[:upper]
|
139
154
|
max = neighbors[:upper].rank
|
140
155
|
min = neighbors[:lower] ? neighbors[:lower].rank : RankedModel::MIN_RANK_VALUE
|
141
|
-
|
156
|
+
rank_at_average min, max
|
142
157
|
end
|
143
158
|
when String
|
144
159
|
position_at position.to_i
|
@@ -148,7 +163,7 @@ module RankedModel
|
|
148
163
|
neighbors = neighbors_at_position(position)
|
149
164
|
min = ((neighbors[:lower] && neighbors[:lower].has_rank?) ? neighbors[:lower].rank : RankedModel::MIN_RANK_VALUE)
|
150
165
|
max = ((neighbors[:upper] && neighbors[:upper].has_rank?) ? neighbors[:upper].rank : RankedModel::MAX_RANK_VALUE)
|
151
|
-
|
166
|
+
rank_at_average min, max
|
152
167
|
when NilClass
|
153
168
|
if !rank
|
154
169
|
position_at :last
|
@@ -156,36 +171,44 @@ module RankedModel
|
|
156
171
|
end
|
157
172
|
end
|
158
173
|
|
174
|
+
def rank_at_average(min, max)
|
175
|
+
if (max - min).between?(-1, 1) # No room at the inn...
|
176
|
+
notify_ranks_updated { rebalance_ranks }
|
177
|
+
position_at position
|
178
|
+
else
|
179
|
+
rank_at( ( ( max - min ).to_f / 2 ).ceil + min )
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
159
183
|
def assure_unique_position
|
160
184
|
if ( new_record? || rank_changed? )
|
161
|
-
|
162
|
-
|
163
|
-
end
|
164
|
-
|
165
|
-
if (rank > RankedModel::MAX_RANK_VALUE) || current_at_rank(rank)
|
166
|
-
rearrange_ranks
|
185
|
+
if (rank > RankedModel::MAX_RANK_VALUE) || rank_taken?
|
186
|
+
notify_ranks_updated { rearrange_ranks }
|
167
187
|
end
|
168
188
|
end
|
169
189
|
end
|
170
190
|
|
171
191
|
def rearrange_ranks
|
172
192
|
_scope = finder
|
173
|
-
|
174
|
-
|
175
|
-
_scope = _scope.where( instance_class.arel_table[instance_class.primary_key].not_eq(instance.id) )
|
176
|
-
end
|
193
|
+
escaped_column = instance_class.connection.quote_column_name ranker.column
|
194
|
+
# If there is room at the bottom of the list and we're added to the very top of the list...
|
177
195
|
if current_first.rank && current_first.rank > RankedModel::MIN_RANK_VALUE && rank == RankedModel::MAX_RANK_VALUE
|
196
|
+
# ...then move everyone else down 1 to make room for us at the end
|
178
197
|
_scope.
|
179
198
|
where( instance_class.arel_table[ranker.column].lteq(rank) ).
|
180
|
-
update_all(
|
199
|
+
update_all( "#{escaped_column} = #{escaped_column} - 1" )
|
200
|
+
# If there is room at the top of the list and we're added below the last value in the list...
|
181
201
|
elsif current_last.rank && current_last.rank < (RankedModel::MAX_RANK_VALUE - 1) && rank < current_last.rank
|
202
|
+
# ...then move everyone else at or above our desired rank up 1 to make room for us
|
182
203
|
_scope.
|
183
204
|
where( instance_class.arel_table[ranker.column].gteq(rank) ).
|
184
|
-
update_all(
|
205
|
+
update_all( "#{escaped_column} = #{escaped_column} + 1" )
|
206
|
+
# If there is room at the bottom of the list and we're added above the lowest value in the list...
|
185
207
|
elsif current_first.rank && current_first.rank > RankedModel::MIN_RANK_VALUE && rank > current_first.rank
|
208
|
+
# ...then move everyone else below us down 1 and change our rank down 1 to avoid the collission
|
186
209
|
_scope.
|
187
210
|
where( instance_class.arel_table[ranker.column].lt(rank) ).
|
188
|
-
update_all(
|
211
|
+
update_all( "#{escaped_column} = #{escaped_column} - 1" )
|
189
212
|
rank_at( rank - 1 )
|
190
213
|
else
|
191
214
|
rebalance_ranks
|
@@ -193,24 +216,34 @@ module RankedModel
|
|
193
216
|
end
|
194
217
|
|
195
218
|
def rebalance_ranks
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
219
|
+
ActiveRecord::Base.transaction do
|
220
|
+
if rank && instance.persisted?
|
221
|
+
origin = current_order.index { |item| item.instance.id == instance.id }
|
222
|
+
if origin
|
223
|
+
destination = current_order.index { |item| rank <= item.rank }
|
224
|
+
destination -= 1 if origin < destination
|
225
|
+
|
226
|
+
current_order.insert destination, current_order.delete_at(origin)
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
gaps = current_order.size + 1
|
231
|
+
range = (RankedModel::MAX_RANK_VALUE - RankedModel::MIN_RANK_VALUE).to_f
|
232
|
+
gap_size = (range / gaps).ceil
|
233
|
+
|
234
|
+
reset_ranks!
|
235
|
+
|
236
|
+
current_order.each.with_index(1) do |item, position|
|
237
|
+
new_rank = (gap_size * position) + RankedModel::MIN_RANK_VALUE
|
238
|
+
|
239
|
+
if item.instance.id == instance.id
|
240
|
+
instance.send "#{ranker.column}=", new_rank
|
241
|
+
else
|
242
|
+
item.update_rank! new_rank
|
211
243
|
end
|
212
244
|
end
|
213
|
-
|
245
|
+
|
246
|
+
reset_cache
|
214
247
|
end
|
215
248
|
end
|
216
249
|
|
@@ -218,42 +251,35 @@ module RankedModel
|
|
218
251
|
@finder ||= {}
|
219
252
|
@finder[order] ||= begin
|
220
253
|
_finder = instance_class
|
221
|
-
columns = [instance_class.
|
254
|
+
columns = [instance_class.primary_key.to_sym, ranker.column]
|
255
|
+
|
222
256
|
if ranker.scope
|
223
257
|
_finder = _finder.send ranker.scope
|
224
258
|
end
|
259
|
+
|
225
260
|
case ranker.with_same
|
226
|
-
|
227
|
-
|
228
|
-
_finder = _finder.where \
|
229
|
-
instance_class.arel_table[ranker.with_same].eq(instance.attributes["#{ranker.with_same}"])
|
230
|
-
when Array
|
231
|
-
ranker.with_same.each {|c| columns.push instance_class.arel_table[c] }
|
232
|
-
_finder = _finder.where(
|
233
|
-
ranker.with_same[1..-1].inject(
|
234
|
-
instance_class.arel_table[ranker.with_same.first].eq(
|
235
|
-
instance.attributes["#{ranker.with_same.first}"]
|
236
|
-
)
|
237
|
-
) {|scoper, attr|
|
238
|
-
scoper.and(
|
239
|
-
instance_class.arel_table[attr].eq(
|
240
|
-
instance.attributes["#{attr}"]
|
241
|
-
)
|
242
|
-
)
|
243
|
-
}
|
244
|
-
)
|
245
|
-
end
|
246
|
-
if !new_record?
|
261
|
+
when Symbol
|
262
|
+
columns << ranker.with_same
|
247
263
|
_finder = _finder.where \
|
248
|
-
|
264
|
+
ranker.with_same => instance.attributes[ranker.with_same.to_s]
|
265
|
+
when Array
|
266
|
+
ranker.with_same.each do |column|
|
267
|
+
columns << column
|
268
|
+
_finder = _finder.where column => instance.attributes[column.to_s]
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
unless new_record?
|
273
|
+
_finder = _finder.where.not instance_class.primary_key.to_sym => instance.id
|
249
274
|
end
|
250
|
-
|
275
|
+
|
276
|
+
_finder.reorder(ranker.column.to_sym => order).select(columns)
|
251
277
|
end
|
252
278
|
end
|
253
279
|
|
254
280
|
def current_order
|
255
281
|
@current_order ||= begin
|
256
|
-
finder.collect { |ordered_instance|
|
282
|
+
finder.unscope(where: instance_class.primary_key.to_sym).collect { |ordered_instance|
|
257
283
|
RankedModel::Ranker::Mapper.new ranker, ordered_instance
|
258
284
|
}
|
259
285
|
end
|
@@ -269,21 +295,14 @@ module RankedModel
|
|
269
295
|
|
270
296
|
def current_last
|
271
297
|
@current_last ||= begin
|
272
|
-
if (ordered_instance = finder.
|
273
|
-
reverse.
|
274
|
-
first)
|
298
|
+
if (ordered_instance = finder.last)
|
275
299
|
RankedModel::Ranker::Mapper.new ranker, ordered_instance
|
276
300
|
end
|
277
301
|
end
|
278
302
|
end
|
279
303
|
|
280
|
-
def
|
281
|
-
|
282
|
-
except( :order ).
|
283
|
-
where( ranker.column => _rank ).
|
284
|
-
first)
|
285
|
-
RankedModel::Ranker::Mapper.new ranker, ordered_instance
|
286
|
-
end
|
304
|
+
def rank_taken?
|
305
|
+
finder.except(:order).where(ranker.column => rank).exists?
|
287
306
|
end
|
288
307
|
|
289
308
|
def neighbors_at_position _pos
|
@@ -331,6 +350,16 @@ module RankedModel
|
|
331
350
|
end
|
332
351
|
end
|
333
352
|
|
353
|
+
def notify_ranks_updated(&block)
|
354
|
+
ActiveSupport::Notifications.instrument(
|
355
|
+
"ranked_model.ranks_updated",
|
356
|
+
instance: instance,
|
357
|
+
scope: ranker.scope,
|
358
|
+
with_same: ranker.with_same
|
359
|
+
) do
|
360
|
+
block.call
|
361
|
+
end
|
362
|
+
end
|
334
363
|
end
|
335
364
|
|
336
365
|
end
|
data/lib/ranked-model/version.rb
CHANGED
data/lib/ranked-model.rb
CHANGED
@@ -3,6 +3,8 @@ require File.dirname(__FILE__)+'/ranked-model/railtie' if defined?(Rails::Railti
|
|
3
3
|
|
4
4
|
module RankedModel
|
5
5
|
|
6
|
+
class NonNilColumnDefault < StandardError; end
|
7
|
+
|
6
8
|
# Signed INT in MySQL
|
7
9
|
#
|
8
10
|
MAX_RANK_VALUE = 2147483647
|
@@ -18,7 +20,7 @@ module RankedModel
|
|
18
20
|
before_save :handle_ranking
|
19
21
|
|
20
22
|
scope :rank, lambda { |name|
|
21
|
-
|
23
|
+
reorder ranker(name.to_sym).column
|
22
24
|
}
|
23
25
|
end
|
24
26
|
|
@@ -40,11 +42,16 @@ module RankedModel
|
|
40
42
|
end
|
41
43
|
end
|
42
44
|
|
43
|
-
|
45
|
+
private
|
44
46
|
|
45
47
|
def ranks *args
|
46
48
|
self.rankers ||= []
|
47
49
|
ranker = RankedModel::Ranker.new(*args)
|
50
|
+
|
51
|
+
if column_default(ranker)
|
52
|
+
raise NonNilColumnDefault, %Q{Your ranked model column "#{ranker.name}" must not have a default value in the database.}
|
53
|
+
end
|
54
|
+
|
48
55
|
self.rankers << ranker
|
49
56
|
attr_reader "#{ranker.name}_position"
|
50
57
|
define_method "#{ranker.name}_position=" do |position|
|
@@ -54,9 +61,17 @@ module RankedModel
|
|
54
61
|
end
|
55
62
|
end
|
56
63
|
|
64
|
+
define_method "#{ranker.name}_rank" do
|
65
|
+
ranker.with(self).relative_rank
|
66
|
+
end
|
67
|
+
|
57
68
|
public "#{ranker.name}_position", "#{ranker.name}_position="
|
58
69
|
end
|
59
70
|
|
71
|
+
def column_default ranker
|
72
|
+
column_defaults[ranker.name.to_s] if ActiveRecord::Base.connected? && table_exists?
|
73
|
+
end
|
74
|
+
|
60
75
|
end
|
61
76
|
|
62
77
|
end
|
data/ranked-model.gemspec
CHANGED
@@ -9,18 +9,20 @@ Gem::Specification.new do |s|
|
|
9
9
|
s.authors = ["Matthew Beale"]
|
10
10
|
s.email = ["matt.beale@madhatted.com"]
|
11
11
|
s.homepage = "https://github.com/mixonic/ranked-model"
|
12
|
-
s.summary = %q{An acts_as_sortable replacement built for Rails
|
13
|
-
s.description = %q{ranked-model is a modern row sorting library built for Rails
|
12
|
+
s.summary = %q{An acts_as_sortable replacement built for Rails 4.2+}
|
13
|
+
s.description = %q{ranked-model is a modern row sorting library built for Rails 4.2+. It uses ARel aggressively and is better optimized than most other libraries.}
|
14
14
|
s.license = 'MIT'
|
15
15
|
|
16
|
-
s.
|
17
|
-
|
18
|
-
s.
|
19
|
-
s.add_development_dependency "
|
20
|
-
s.add_development_dependency "
|
21
|
-
s.add_development_dependency "
|
22
|
-
s.add_development_dependency "
|
16
|
+
s.metadata["funding_uri"] = "https://github.com/sponsors/brendon"
|
17
|
+
|
18
|
+
s.add_dependency "activerecord", ">= 5.2"
|
19
|
+
s.add_development_dependency "rspec", "~> 3"
|
20
|
+
s.add_development_dependency "rspec-its"
|
21
|
+
s.add_development_dependency "mocha"
|
22
|
+
s.add_development_dependency "database_cleaner", "~> 1.7.0"
|
23
|
+
s.add_development_dependency "rake", ">= 12.3.3"
|
23
24
|
s.add_development_dependency "appraisal"
|
25
|
+
s.add_development_dependency "pry"
|
24
26
|
|
25
27
|
s.files = `git ls-files`.split("\n")
|
26
28
|
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe 'ColumnDefaultDuck' do
|
4
|
+
|
5
|
+
it "should raise an error if we try to initialise ranked_model on a column with a default value" do
|
6
|
+
expect {
|
7
|
+
class ColumnDefaultDuck < ActiveRecord::Base
|
8
|
+
include RankedModel
|
9
|
+
ranks :size, :with_same => :pond
|
10
|
+
end
|
11
|
+
}.to raise_error(RankedModel::NonNilColumnDefault, 'Your ranked model column "size" must not have a default value in the database.')
|
12
|
+
end
|
13
|
+
|
14
|
+
it "should not raise an error if we don't have a database connection when checking for default value" do
|
15
|
+
begin
|
16
|
+
ActiveRecord::Base.remove_connection
|
17
|
+
|
18
|
+
expect {
|
19
|
+
class ColumnDefaultDuck < ActiveRecord::Base
|
20
|
+
include RankedModel
|
21
|
+
ranks :size, :with_same => :pond
|
22
|
+
end
|
23
|
+
}.not_to raise_error
|
24
|
+
ensure
|
25
|
+
ActiveRecord::Base.establish_connection(ENV['DB'].to_sym)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|