ranked-model 0.4.8 → 0.4.9

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 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: "../"