ranked-model 0.4.8 → 0.4.9

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '004680e8b292451d304375f3ae01d0100702db74867d93b2a713e7c908241d78'
4
- data.tar.gz: cc831e2837ba8ad07c21f6b890959e071005824a4f53bd51c135c8055fb1904f
3
+ metadata.gz: 61451e859060c7d8a3f889bd54327b103797d728312cedef66efa1b25d11767f
4
+ data.tar.gz: 2e1df0f514decf2e43667c2d0139e510202fc0ebc32aed758831ea20497aaf1a
5
5
  SHA512:
6
- metadata.gz: d4494e9eede725ef30ecbd05006a4d91deb89f62aced56b96fa3be2f0f5732f41336d5a3d1c450e3f5d2c0f1821a15de096a1ee68732d8cb5d10a300008270ca
7
- data.tar.gz: 04fa0d21ff8b2c6533ebc6705cc00c7258bd955c88a91636995d362136de9cf2a57c6e8ca49c43e112757e1209dc9a9fe3c2285b2bfde47639bcab90d2368fd4
6
+ metadata.gz: d046ed8e55533b8f453a67e0c705738e9353b22b20b01310381a56195803ee066cd647e1b8801484ca79518a8ea2394577841d4b9c4eb97d12f0b36246c1bfc7
7
+ data.tar.gz: ca05b38d74cbe11544cabb70890c64d26c9a94c4608d2b4d7da1253535efc03775ef3fed80fc9b93709f503f3f53a8cd725b7f2b8c43ad8565a72139f0ec53e5
@@ -0,0 +1,3 @@
1
+ # These are supported funding model platforms
2
+
3
+ github: [brendon]
@@ -0,0 +1,6 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "github-actions"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
@@ -0,0 +1,90 @@
1
+ ---
2
+ name: CI
3
+ on: [push, pull_request]
4
+
5
+ jobs:
6
+ tests:
7
+ name: Ruby ${{ matrix.ruby }}, ${{ matrix.gemfile }}, DB ${{ matrix.db }}
8
+ runs-on: ${{ matrix.os }}
9
+ strategy:
10
+ fail-fast: false
11
+ matrix:
12
+ ruby:
13
+ - 2.4
14
+ - 2.5
15
+ - 2.6
16
+ - 2.7
17
+ - '3.0'
18
+ - 3.1
19
+ gemfile:
20
+ - gemfiles/rails_5_2.gemfile
21
+ - gemfiles/rails_6_0.gemfile
22
+ - gemfiles/rails_6_1.gemfile
23
+ - gemfiles/rails_7_0.gemfile
24
+ db:
25
+ - sqlite
26
+ - mysql
27
+ - postgresql
28
+ exclude:
29
+ - ruby: 2.4
30
+ gemfile: gemfiles/rails_6_0.gemfile
31
+ - ruby: 2.4
32
+ gemfile: gemfiles/rails_6_1.gemfile
33
+ - ruby: 2.4
34
+ gemfile: gemfiles/rails_7_0.gemfile
35
+ - ruby: 2.5
36
+ gemfile: gemfiles/rails_7_0.gemfile
37
+ - ruby: 2.6
38
+ gemfile: gemfiles/rails_7_0.gemfile
39
+ - ruby: '3.0'
40
+ gemfile: gemfiles/rails_5_2.gemfile
41
+ - ruby: 3.1
42
+ gemfile: gemfiles/rails_5_2.gemfile
43
+ - ruby: 3.1
44
+ gemfile: gemfiles/rails_6_0.gemfile
45
+ os:
46
+ - ubuntu-latest
47
+ services:
48
+ mysql:
49
+ image: mysql:5.7
50
+ env:
51
+ MYSQL_ALLOW_EMPTY_PASSWORD: yes
52
+ ports:
53
+ - 3306:3306
54
+ options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
55
+
56
+ postgres:
57
+ # Docker Hub image
58
+ image: postgres
59
+ # Provide the password for postgres
60
+ env:
61
+ POSTGRES_USER: postgres
62
+ POSTGRES_HOST_AUTH_METHOD: trust
63
+ ports:
64
+ - 5432:5432
65
+ # Set health checks to wait until postgres has started
66
+ options: >-
67
+ --health-cmd pg_isready
68
+ --health-interval 10s
69
+ --health-timeout 5s
70
+ --health-retries 5
71
+ env:
72
+ BUNDLE_GEMFILE: ${{ matrix.gemfile }}
73
+ DB: ${{ matrix.db }}
74
+ steps:
75
+ - uses: actions/checkout@v3
76
+ - name: Set up Ruby
77
+ uses: ruby/setup-ruby@v1
78
+ with:
79
+ ruby-version: ${{ matrix.ruby }}
80
+ bundler-cache: true
81
+ - name: "Create MySQL database"
82
+ if: ${{ env.DB == 'mysql' }}
83
+ run: |
84
+ mysql -h 127.0.0.1 -u root -e 'create database ranked_model_test;'
85
+ - name: "Create PostgreSQL database"
86
+ if: ${{ env.DB == 'postgresql' }}
87
+ run: |
88
+ psql -c 'create database ranked_model_test;' -h localhost -U postgres
89
+ - name: Run tests
90
+ run: bundle exec rake
data/.gitignore CHANGED
@@ -2,6 +2,7 @@ pkg/*
2
2
  *.gem
3
3
  .bundle
4
4
  *.un~
5
+ *.swp
5
6
 
6
7
  Gemfile.lock
7
8
  *.gemfile.lock
data/Appraisals CHANGED
@@ -1,48 +1,3 @@
1
- appraise "rails-4-2" do
2
- group :sqlite do
3
- gem "activerecord-jdbcsqlite3-adapter", "~> 1.3.24", platform: :jruby
4
- end
5
- group :mysql do
6
- gem "mysql2", "~> 0.4.0", platform: :ruby
7
- gem "jdbc-mysql", "~> 5.1.47", platform: :jruby
8
- gem "activerecord-jdbcmysql-adapter", "~> 1.3.24", platform: :jruby
9
- end
10
- group :postgresql do
11
- gem "pg", "~> 0.18.4", platform: :ruby
12
- gem "activerecord-jdbcpostgresql-adapter", "~> 1.3.24", platform: :jruby
13
- end
14
-
15
- gem "activerecord", "~> 4.2.0"
16
- end
17
-
18
- appraise "rails-5-0" do
19
- group :sqlite do
20
- gem "activerecord-jdbcsqlite3-adapter", "~> 50.0", platform: :jruby
21
- end
22
- group :mysql do
23
- gem "activerecord-jdbcmysql-adapter", "~> 50.0", platform: :jruby
24
- end
25
- group :postgresql do
26
- gem "activerecord-jdbcpostgresql-adapter", "~> 50.0", platform: :jruby
27
- end
28
-
29
- gem "activerecord", "~> 5.0.0"
30
- end
31
-
32
- appraise "rails-5-1" do
33
- group :sqlite do
34
- gem "activerecord-jdbcsqlite3-adapter", "~> 51.0", platform: :jruby
35
- end
36
- group :mysql do
37
- gem "activerecord-jdbcmysql-adapter", "~> 51.0", platform: :jruby
38
- end
39
- group :postgresql do
40
- gem "activerecord-jdbcpostgresql-adapter", "~> 51.0", platform: :jruby
41
- end
42
-
43
- gem "activerecord", "~> 5.1.0"
44
- end
45
-
46
1
  appraise "rails-5-2" do
47
2
  group :sqlite do
48
3
  gem "activerecord-jdbcsqlite3-adapter", "~> 52.0", platform: :jruby
@@ -84,3 +39,17 @@ appraise "rails-6-1" do
84
39
  end
85
40
  gem "activerecord", "~> 6.1.0"
86
41
  end
42
+
43
+ appraise "rails-7-0" do
44
+ group :sqlite do
45
+ gem "sqlite3", "~> 1.4", platform: :ruby
46
+ gem "activerecord-jdbcsqlite3-adapter", "~> 61.0", platform: :jruby
47
+ end
48
+ group :mysql do
49
+ gem "activerecord-jdbcmysql-adapter", "~> 61.0", platform: :jruby
50
+ end
51
+ group :postgresql do
52
+ gem "activerecord-jdbcpostgresql-adapter", "~> 61.0", platform: :jruby
53
+ end
54
+ gem "activerecord", "~> 7.0.0"
55
+ end
data/Readme.mkd CHANGED
@@ -136,6 +136,37 @@ Pond.first.ducks.rank(:swimming_order)
136
136
  Duck.walking.rank(:walking)
137
137
  ```
138
138
 
139
+ Drawbacks
140
+ ---------
141
+
142
+ While ranked-model is performant when storing data, it might cause N+1s depending on how you write your code. Consider this snippet:
143
+
144
+ ```ruby
145
+ ducks = Duck.all
146
+ ducks.map do |duck|
147
+ {
148
+ id: duck.id,
149
+ position: duck.row_order_rank # This causes N+1!
150
+ }
151
+ end
152
+ ```
153
+
154
+ Every call to `duck.row_order_rank` will make a call to the DB to check the rank of that
155
+ particular element. If you have a long list of elements this might cause issues to your DB.
156
+
157
+ In order to avoid that, you can use the `rank(:your_rank)` scope and some Ruby code to get
158
+ the element's position:
159
+
160
+ ```ruby
161
+ ducks = Duck.rank(:row_order).all
162
+ ducks.map.with_index do |duck, index|
163
+ {
164
+ id: duck.id,
165
+ position: index
166
+ }
167
+ end
168
+ ```
169
+
139
170
  Single Table Inheritance (STI)
140
171
  ------------------------------
141
172
 
@@ -5,17 +5,17 @@ source "https://rubygems.org"
5
5
  gem "activerecord", "~> 5.2.0"
6
6
 
7
7
  group :sqlite do
8
- gem "sqlite3", "~> 1.3.13", platform: :ruby
8
+ gem "sqlite3", platform: :ruby
9
9
  gem "activerecord-jdbcsqlite3-adapter", "~> 52.0", platform: :jruby
10
10
  end
11
11
 
12
12
  group :postgresql do
13
- gem "pg", "~> 1.2.0", platform: :ruby
13
+ gem "pg", platform: :ruby
14
14
  gem "activerecord-jdbcpostgresql-adapter", "~> 52.0", platform: :jruby
15
15
  end
16
16
 
17
17
  group :mysql do
18
- gem "mysql2", "~> 0.5.0", platform: :ruby
18
+ gem "mysql2", platform: :ruby
19
19
  gem "activerecord-jdbcmysql-adapter", "~> 52.0", platform: :jruby
20
20
  end
21
21
 
@@ -10,12 +10,12 @@ group :sqlite do
10
10
  end
11
11
 
12
12
  group :postgresql do
13
- gem "pg", "~> 1.2.0", platform: :ruby
13
+ gem "pg", platform: :ruby
14
14
  gem "activerecord-jdbcpostgresql-adapter", "~> 60.0", platform: :jruby
15
15
  end
16
16
 
17
17
  group :mysql do
18
- gem "mysql2", "~> 0.5.0", platform: :ruby
18
+ gem "mysql2", platform: :ruby
19
19
  gem "activerecord-jdbcmysql-adapter", "~> 60.0", platform: :jruby
20
20
  end
21
21
 
@@ -10,12 +10,12 @@ group :sqlite do
10
10
  end
11
11
 
12
12
  group :postgresql do
13
- gem "pg", "~> 1.2.0", platform: :ruby
13
+ gem "pg", platform: :ruby
14
14
  gem "activerecord-jdbcpostgresql-adapter", "~> 61.0", platform: :jruby
15
15
  end
16
16
 
17
17
  group :mysql do
18
- gem "mysql2", "~> 0.5.0", platform: :ruby
18
+ gem "mysql2", platform: :ruby
19
19
  gem "activerecord-jdbcmysql-adapter", "~> 61.0", platform: :jruby
20
20
  end
21
21
 
@@ -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: "../"
@@ -1,356 +1,357 @@
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
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
+ instance.send "#{ranker.name}_position=", relative_rank unless position.is_a?(Integer)
118
+ end
119
+
120
+ def rank_changed?
121
+ instance.send "#{ranker.column}_changed?"
122
+ end
123
+
124
+ def new_record?
125
+ instance.new_record?
126
+ end
127
+
128
+ def update_index_from_position
129
+ case position
130
+ when :first, 'first'
131
+ if current_first && current_first.rank
132
+ rank_at_average current_first.rank, RankedModel::MIN_RANK_VALUE
133
+ else
134
+ position_at :middle
135
+ end
136
+ when :last, 'last'
137
+ if current_last && current_last.rank
138
+ rank_at_average current_last.rank, RankedModel::MAX_RANK_VALUE
139
+ else
140
+ position_at :middle
141
+ end
142
+ when :middle, 'middle'
143
+ rank_at_average RankedModel::MIN_RANK_VALUE, RankedModel::MAX_RANK_VALUE
144
+ when :down, 'down'
145
+ neighbors = find_next_two(rank)
146
+ if neighbors[:lower]
147
+ min = neighbors[:lower].rank
148
+ max = neighbors[:upper] ? neighbors[:upper].rank : RankedModel::MAX_RANK_VALUE
149
+ rank_at_average min, max
150
+ end
151
+ when :up, 'up'
152
+ neighbors = find_previous_two(rank)
153
+ if neighbors[:upper]
154
+ max = neighbors[:upper].rank
155
+ min = neighbors[:lower] ? neighbors[:lower].rank : RankedModel::MIN_RANK_VALUE
156
+ rank_at_average min, max
157
+ end
158
+ when String
159
+ position_at position.to_i
160
+ when 0
161
+ position_at :first
162
+ when Integer
163
+ neighbors = neighbors_at_position(position)
164
+ min = ((neighbors[:lower] && neighbors[:lower].has_rank?) ? neighbors[:lower].rank : RankedModel::MIN_RANK_VALUE)
165
+ max = ((neighbors[:upper] && neighbors[:upper].has_rank?) ? neighbors[:upper].rank : RankedModel::MAX_RANK_VALUE)
166
+ rank_at_average min, max
167
+ when NilClass
168
+ if !rank
169
+ position_at :last
170
+ end
171
+ end
172
+ end
173
+
174
+ def rank_at_average(min, max)
175
+ if (max - min).between?(-1, 1) # No room at the inn...
176
+ rebalance_ranks
177
+ position_at position
178
+ else
179
+ rank_at( ( ( max - min ).to_f / 2 ).ceil + min )
180
+ end
181
+ end
182
+
183
+ def assure_unique_position
184
+ if ( new_record? || rank_changed? )
185
+ if (rank > RankedModel::MAX_RANK_VALUE) || rank_taken?
186
+ rearrange_ranks
187
+ end
188
+ end
189
+ end
190
+
191
+ def rearrange_ranks
192
+ _scope = finder
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...
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
197
+ _scope.
198
+ where( instance_class.arel_table[ranker.column].lteq(rank) ).
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...
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
203
+ _scope.
204
+ where( instance_class.arel_table[ranker.column].gteq(rank) ).
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...
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
209
+ _scope.
210
+ where( instance_class.arel_table[ranker.column].lt(rank) ).
211
+ update_all( "#{escaped_column} = #{escaped_column} - 1" )
212
+ rank_at( rank - 1 )
213
+ else
214
+ rebalance_ranks
215
+ end
216
+ end
217
+
218
+ def rebalance_ranks
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
243
+ end
244
+ end
245
+
246
+ reset_cache
247
+ end
248
+ end
249
+
250
+ def finder(order = :asc)
251
+ @finder ||= {}
252
+ @finder[order] ||= begin
253
+ _finder = instance_class
254
+ columns = [instance_class.primary_key.to_sym, ranker.column]
255
+
256
+ if ranker.scope
257
+ _finder = _finder.send ranker.scope
258
+ end
259
+
260
+ case ranker.with_same
261
+ when Symbol
262
+ columns << ranker.with_same
263
+ _finder = _finder.where \
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
274
+ end
275
+
276
+ _finder.reorder(ranker.column.to_sym => order).select(columns)
277
+ end
278
+ end
279
+
280
+ def current_order
281
+ @current_order ||= begin
282
+ finder.unscope(where: instance_class.primary_key.to_sym).collect { |ordered_instance|
283
+ RankedModel::Ranker::Mapper.new ranker, ordered_instance
284
+ }
285
+ end
286
+ end
287
+
288
+ def current_first
289
+ @current_first ||= begin
290
+ if (ordered_instance = finder.first)
291
+ RankedModel::Ranker::Mapper.new ranker, ordered_instance
292
+ end
293
+ end
294
+ end
295
+
296
+ def current_last
297
+ @current_last ||= begin
298
+ if (ordered_instance = finder.last)
299
+ RankedModel::Ranker::Mapper.new ranker, ordered_instance
300
+ end
301
+ end
302
+ end
303
+
304
+ def rank_taken?
305
+ finder.except(:order).where(ranker.column => rank).exists?
306
+ end
307
+
308
+ def neighbors_at_position _pos
309
+ if _pos > 0
310
+ if (ordered_instances = finder.offset(_pos-1).limit(2).to_a)
311
+ if ordered_instances[1]
312
+ { :lower => RankedModel::Ranker::Mapper.new( ranker, ordered_instances[0] ),
313
+ :upper => RankedModel::Ranker::Mapper.new( ranker, ordered_instances[1] ) }
314
+ elsif ordered_instances[0]
315
+ { :lower => RankedModel::Ranker::Mapper.new( ranker, ordered_instances[0] ) }
316
+ else
317
+ { :lower => current_last }
318
+ end
319
+ end
320
+ else
321
+ if (ordered_instance = finder.first)
322
+ { :upper => RankedModel::Ranker::Mapper.new( ranker, ordered_instance ) }
323
+ else
324
+ {}
325
+ end
326
+ end
327
+ end
328
+
329
+ def find_next_two _rank
330
+ ordered_instances = finder.where(instance_class.arel_table[ranker.column].gt _rank).limit(2)
331
+ if ordered_instances[1]
332
+ { :lower => RankedModel::Ranker::Mapper.new( ranker, ordered_instances[0] ),
333
+ :upper => RankedModel::Ranker::Mapper.new( ranker, ordered_instances[1] ) }
334
+ elsif ordered_instances[0]
335
+ { :lower => RankedModel::Ranker::Mapper.new( ranker, ordered_instances[0] ) }
336
+ else
337
+ {}
338
+ end
339
+ end
340
+
341
+ def find_previous_two _rank
342
+ ordered_instances = finder(:desc).where(instance_class.arel_table[ranker.column].lt _rank).limit(2)
343
+ if ordered_instances[1]
344
+ { :upper => RankedModel::Ranker::Mapper.new( ranker, ordered_instances[0] ),
345
+ :lower => RankedModel::Ranker::Mapper.new( ranker, ordered_instances[1] ) }
346
+ elsif ordered_instances[0]
347
+ { :upper => RankedModel::Ranker::Mapper.new( ranker, ordered_instances[0] ) }
348
+ else
349
+ {}
350
+ end
351
+ end
352
+
353
+ end
354
+
355
+ end
356
+
357
+ end
@@ -1,3 +1,3 @@
1
1
  module RankedModel
2
- VERSION = "0.4.8"
2
+ VERSION = "0.4.9"
3
3
  end
data/ranked-model.gemspec CHANGED
@@ -13,7 +13,7 @@ Gem::Specification.new do |s|
13
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", ">= 4.2"
16
+ s.add_dependency "activerecord", ">= 5.2"
17
17
  s.add_development_dependency "rspec", "~> 3"
18
18
  s.add_development_dependency "rspec-its"
19
19
  s.add_development_dependency "mocha"
@@ -0,0 +1,71 @@
1
+ require "spec_helper"
2
+
3
+ describe Duck do
4
+ before do
5
+ 5.times do |i|
6
+ Duck.create(name: "Duck #{i + 1}")
7
+ end
8
+ end
9
+
10
+ describe "updating a duck order with last" do
11
+ it "should maintain the order after creating a new duck" do
12
+ duck = Duck.first
13
+ duck.update(row_position: :last)
14
+ expect(duck.row_rank).to eq(4)
15
+
16
+ Duck.create(name: "Wacky")
17
+
18
+ expect(duck.row_rank).to eq(4)
19
+
20
+ duck.update(pond: 'Shin')
21
+ expect(duck.row_rank).to eq(4)
22
+ end
23
+ end
24
+
25
+ describe "updating a duck order with first" do
26
+ it "should maintain the order after creating a new duck" do
27
+ duck = Duck.last
28
+ duck.update(row_position: :first)
29
+ expect(duck.row_rank).to eq(0)
30
+
31
+ Duck.create(name: "Wacky")
32
+
33
+ expect(duck.row_rank).to eq(0)
34
+
35
+ duck.update(pond: 'Shin')
36
+ expect(duck.row_rank).to eq(0)
37
+ end
38
+ end
39
+
40
+ describe "updating a duck order with up" do
41
+ it "should maintain the order after creating a new duck" do
42
+ duck_id = Duck.ranker(:row).with(Duck.new).current_at_position(2).instance.id
43
+ duck = Duck.find(duck_id)
44
+ duck.update(row_position: :up)
45
+ expect(duck.row_rank).to eq(1)
46
+
47
+ Duck.create(name: "Wacky")
48
+
49
+ expect(duck.row_rank).to eq(1)
50
+
51
+ duck.update(pond: 'Shin')
52
+ expect(duck.row_rank).to eq(1)
53
+ end
54
+ end
55
+
56
+ describe "updating a duck order with down" do
57
+ it "should maintain the order after creating a new duck" do
58
+ duck_id = Duck.ranker(:row).with(Duck.new).current_at_position(2).instance.id
59
+ duck = Duck.find(duck_id)
60
+ duck.update(row_position: :down)
61
+ expect(duck.row_rank).to eq(3)
62
+
63
+ Duck.create(name: "Wacky")
64
+
65
+ expect(duck.row_rank).to eq(3)
66
+
67
+ duck.update(pond: 'Shin')
68
+ expect(duck.row_rank).to eq(3)
69
+ end
70
+ end
71
+ end
@@ -6,6 +6,7 @@ sqlite:
6
6
 
7
7
  mysql:
8
8
  adapter: mysql2
9
+ host: 127.0.0.1
9
10
  database: ranked_model_test
10
11
  pool: 5
11
12
  timeout: 5000
@@ -14,6 +15,7 @@ mysql:
14
15
 
15
16
  postgresql:
16
17
  adapter: postgresql
18
+ host: 127.0.0.1
17
19
  database: ranked_model_test
18
20
  pool: 5
19
21
  timeout: 5000
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.8
4
+ version: 0.4.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthew Beale
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-02-07 00:00:00.000000000 Z
11
+ date: 2023-08-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '4.2'
19
+ version: '5.2'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '4.2'
26
+ version: '5.2'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: rspec
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -130,6 +130,9 @@ executables: []
130
130
  extensions: []
131
131
  extra_rdoc_files: []
132
132
  files:
133
+ - ".github/FUNDING.yml"
134
+ - ".github/dependabot.yml"
135
+ - ".github/workflows/ci.yml"
133
136
  - ".gitignore"
134
137
  - ".rspec"
135
138
  - ".travis.yml"
@@ -138,12 +141,10 @@ files:
138
141
  - LICENSE
139
142
  - Rakefile
140
143
  - Readme.mkd
141
- - gemfiles/rails_4_2.gemfile
142
- - gemfiles/rails_5_0.gemfile
143
- - gemfiles/rails_5_1.gemfile
144
144
  - gemfiles/rails_5_2.gemfile
145
145
  - gemfiles/rails_6_0.gemfile
146
146
  - gemfiles/rails_6_1.gemfile
147
+ - gemfiles/rails_7_0.gemfile
147
148
  - lib/ranked-model.rb
148
149
  - lib/ranked-model/railtie.rb
149
150
  - lib/ranked-model/ranker.rb
@@ -152,6 +153,7 @@ files:
152
153
  - ranked-model.gemspec
153
154
  - spec/duck-model/column_default_ducks_spec.rb
154
155
  - spec/duck-model/duck_spec.rb
156
+ - spec/duck-model/inferred_ducks_spec.rb
155
157
  - spec/duck-model/lots_of_ducks_spec.rb
156
158
  - spec/duck-model/wrong_ducks_spec.rb
157
159
  - spec/ego-model/ego_spec.rb
@@ -191,6 +193,7 @@ summary: An acts_as_sortable replacement built for Rails 4.2+
191
193
  test_files:
192
194
  - spec/duck-model/column_default_ducks_spec.rb
193
195
  - spec/duck-model/duck_spec.rb
196
+ - spec/duck-model/inferred_ducks_spec.rb
194
197
  - spec/duck-model/lots_of_ducks_spec.rb
195
198
  - spec/duck-model/wrong_ducks_spec.rb
196
199
  - spec/ego-model/ego_spec.rb
@@ -1,23 +0,0 @@
1
- # This file was generated by Appraisal
2
-
3
- source "https://rubygems.org"
4
-
5
- gem "activerecord", "~> 4.2.0"
6
-
7
- group :sqlite do
8
- gem "sqlite3", "~> 1.3.13", platform: :ruby
9
- gem "activerecord-jdbcsqlite3-adapter", "~> 1.3.24", platform: :jruby
10
- end
11
-
12
- group :postgresql do
13
- gem "pg", "~> 0.18.4", platform: :ruby
14
- gem "activerecord-jdbcpostgresql-adapter", "~> 1.3.24", platform: :jruby
15
- end
16
-
17
- group :mysql do
18
- gem "mysql2", "~> 0.4.0", platform: :ruby
19
- gem "jdbc-mysql", "~> 5.1.47", platform: :jruby
20
- gem "activerecord-jdbcmysql-adapter", "~> 1.3.24", platform: :jruby
21
- end
22
-
23
- gemspec path: "../"
@@ -1,22 +0,0 @@
1
- # This file was generated by Appraisal
2
-
3
- source "https://rubygems.org"
4
-
5
- gem "activerecord", "~> 5.0.0"
6
-
7
- group :sqlite do
8
- gem "sqlite3", "~> 1.3.13", platform: :ruby
9
- gem "activerecord-jdbcsqlite3-adapter", "~> 50.0", platform: :jruby
10
- end
11
-
12
- group :postgresql do
13
- gem "pg", "~> 1.2.0", platform: :ruby
14
- gem "activerecord-jdbcpostgresql-adapter", "~> 50.0", platform: :jruby
15
- end
16
-
17
- group :mysql do
18
- gem "mysql2", "~> 0.5.0", platform: :ruby
19
- gem "activerecord-jdbcmysql-adapter", "~> 50.0", platform: :jruby
20
- end
21
-
22
- gemspec path: "../"
@@ -1,22 +0,0 @@
1
- # This file was generated by Appraisal
2
-
3
- source "https://rubygems.org"
4
-
5
- gem "activerecord", "~> 5.1.0"
6
-
7
- group :sqlite do
8
- gem "sqlite3", "~> 1.3.13", platform: :ruby
9
- gem "activerecord-jdbcsqlite3-adapter", "~> 51.0", platform: :jruby
10
- end
11
-
12
- group :postgresql do
13
- gem "pg", "~> 1.2.0", platform: :ruby
14
- gem "activerecord-jdbcpostgresql-adapter", "~> 51.0", platform: :jruby
15
- end
16
-
17
- group :mysql do
18
- gem "mysql2", "~> 0.5.0", platform: :ruby
19
- gem "activerecord-jdbcmysql-adapter", "~> 51.0", platform: :jruby
20
- end
21
-
22
- gemspec path: "../"