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.
@@ -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: "../"
@@ -66,13 +66,23 @@ module RankedModel
66
66
  #
67
67
  instance_class.
68
68
  where(instance_class.primary_key => instance.id).
69
- update_all([%Q{#{ranker.column} = ?}, value])
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
- rank_at( ( ( RankedModel::MIN_RANK_VALUE - current_first.rank ).to_f / 2 ).ceil + current_first.rank)
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
- rank_at( ( ( RankedModel::MAX_RANK_VALUE - current_last.rank ).to_f / 2 ).ceil + current_last.rank )
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
- rank_at( ( ( RankedModel::MAX_RANK_VALUE - RankedModel::MIN_RANK_VALUE ).to_f / 2 ).ceil + RankedModel::MIN_RANK_VALUE )
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
- rank_at( ( ( max - min ).to_f / 2 ).ceil + min )
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
- rank_at( ( ( max - min ).to_f / 2 ).ceil + min )
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
- rank_at( ( ( max - min ).to_f / 2 ).ceil + min )
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
- unless rank
162
- rank_at( RankedModel::MAX_RANK_VALUE )
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
- unless instance.id.nil?
174
- # Never update ourself, shift others around us.
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( %Q{#{ranker.column} = #{ranker.column} - 1} )
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( %Q{#{ranker.column} = #{ranker.column} + 1} )
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( %Q{#{ranker.column} = #{ranker.column} - 1} )
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
- total = current_order.size + 2
197
- has_set_self = false
198
- total.times do |index|
199
- next if index == 0 || index == total
200
- rank_value = ((((RankedModel::MAX_RANK_VALUE - RankedModel::MIN_RANK_VALUE).to_f / total) * index ).ceil + RankedModel::MIN_RANK_VALUE)
201
- index = index - 1
202
- if has_set_self
203
- index = index - 1
204
- else
205
- if !current_order[index] ||
206
- ( !current_order[index].rank.nil? &&
207
- current_order[index].rank >= rank )
208
- rank_at rank_value
209
- has_set_self = true
210
- next
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
- current_order[index].update_rank! rank_value
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.arel_table[instance_class.primary_key], instance_class.arel_table[ranker.column]]
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
- when Symbol
227
- columns << instance_class.arel_table[ranker.with_same]
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
- instance_class.arel_table[instance_class.primary_key].not_eq(instance.id)
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
- _finder.order(instance_class.arel_table[ranker.column].send(order)).select(columns)
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 current_at_rank _rank
281
- if (ordered_instance = finder.
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
@@ -1,3 +1,3 @@
1
1
  module RankedModel
2
- VERSION = "0.4.1"
2
+ VERSION = "0.4.11"
3
3
  end
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
- order ranker(name.to_sym).column
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
- private
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 3 & 4}
13
- s.description = %q{ranked-model is a modern row sorting library built for Rails 3 & 4. It uses ARel aggressively and is better optimized than most other libraries.}
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.add_dependency "activerecord", ">= 3.1.12"
17
- s.add_development_dependency "rspec", "~> 2.13.0"
18
- s.add_development_dependency "sqlite3", "~> 1.3.7"
19
- s.add_development_dependency "genspec", "~> 0.2.8"
20
- s.add_development_dependency "mocha", "~> 0.14.0"
21
- s.add_development_dependency "database_cleaner", "~> 1.2.0"
22
- s.add_development_dependency "rake", "~> 10.1.0"
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