mongoid_orderable 6.0.1 → 6.0.2

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: 517614edc30dd44ac15d5eafcfcc9103bced1dd7ddd4cb0f3624edfe6adb6af2
4
- data.tar.gz: 914eff039882840a25b888fe534a5adf3198c762a2944d141d425daff558baf5
3
+ metadata.gz: b3a80c8eb058adeffe1ead4c0fa20ce10660ea49a2df1405d46048419f2af965
4
+ data.tar.gz: d68f6f64f52f132295e588dfcd59ad27d22a68237e5b29ab7682a37e3c940ca5
5
5
  SHA512:
6
- metadata.gz: 67ef0079e9c442579b08d6043fd80215e7678f705fea24b4581131b39880d1b0b33cc9fee4d8eb9ec1905e0b8b8ad2b4c79706ff170e0d49ec5c91130b91737a
7
- data.tar.gz: c7c9befe3900fa468e6149fa0562de121525428b294981c6990d50e862f42912fa8a2b59a09e54e63328975b303af51de7c556a4b9a9632f0bbb9e4ed7d876b4
6
+ metadata.gz: 6c2d9da294dda836b17047aaf3ebc6329b3c382fb793c83fe57dd63a07512d2e1da65af3559b12e75ce3924b1edfaaefad8317f4acaa488cb94ad2392d84b474
7
+ data.tar.gz: 58e8c15554183674cef20639c2cb888cb6e793471ecf7f55ea76360f2ca7de351d218f417e34db220d2daa32c6ab5dc11da23bce7acec0f77a1f99718e713705
@@ -1,7 +1,13 @@
1
- ### 6.0.2 (Next)
1
+ ### 6.0.3 (Next)
2
2
 
3
3
  * Your contribution here.
4
4
 
5
+ ### 6.0.2 (2021/01/26)
6
+
7
+ * [#70](https://github.com/mongoid/mongoid_orderable/pull/70): Fix: Transactions should not use around callbacks - [@johnnyshields](https://github.com/johnnyshields).
8
+ * [#70](https://github.com/mongoid/mongoid_orderable/pull/70): Refactor: Partially reduce code complexity of handler classes - [@johnnyshields](https://github.com/johnnyshields).
9
+ * [#70](https://github.com/mongoid/mongoid_orderable/pull/70): Tests: Run all tests both with and without transactions - [@johnnyshields](https://github.com/johnnyshields).
10
+
5
11
  ### 6.0.1 (2021/01/26)
6
12
 
7
13
  * [#69](https://github.com/mongoid/mongoid_orderable/pull/69): Fix: Transactions should force read from primary - [@johnnyshields](https://github.com/johnnyshields).
@@ -2,46 +2,53 @@
2
2
 
3
3
  module Mongoid
4
4
  module Orderable
5
- class Engine
6
- ORDERABLE_TRANSACTION_KEY = :__mongoid_orderable_in_txn
7
-
8
- attr_accessor :doc
5
+ module Handlers
6
+ class Base
7
+ attr_reader :doc
9
8
 
10
9
  def initialize(doc)
11
10
  @doc = doc
12
11
  end
13
12
 
14
- # For new records, or if the orderable scope changes,
15
- # we must yield the save action inside the transaction.
16
- def update_positions(&_block)
17
- yield and return unless orderable_keys.any? {|field| changed?(field) }
13
+ protected
18
14
 
19
- new_record = new_record?
20
- with_transaction do
21
- orderable_keys.map {|field| apply_one_position(field, move_all[field]) }
22
- yield if new_record
23
- end
15
+ delegate :orderable_keys,
16
+ :orderable_field,
17
+ :orderable_position,
18
+ :orderable_scope,
19
+ :orderable_scope_changed?,
20
+ :orderable_top,
21
+ :orderable_bottom,
22
+ :_id,
23
+ :new_record?,
24
+ :persisted?,
25
+ :embedded?,
26
+ :collection_name,
27
+ to: :doc
24
28
 
25
- yield unless new_record
29
+ def use_transactions
30
+ false
26
31
  end
27
32
 
28
- def remove_positions
29
- orderable_keys.each do |field|
30
- remove_one_position(field)
31
- end
33
+ def any_field_changed?
34
+ orderable_keys.any? {|field| changed?(field) }
35
+ end
36
+
37
+ def apply_all_positions
38
+ orderable_keys.map {|field| apply_one_position(field, move_all[field]) }
32
39
  end
33
40
 
34
41
  def apply_one_position(field, target_position)
35
42
  return unless changed?(field)
36
43
 
37
- set_lock(field) if use_transactions && !embedded?
44
+ set_lock(field) if use_transactions
38
45
 
39
46
  f = orderable_field(field)
40
47
  scope = orderable_scope(field)
41
48
  scope_changed = orderable_scope_changed?(field)
42
49
 
43
50
  # Set scope-level lock if scope changed
44
- if use_transactions && persisted? && !embedded? && scope_changed
51
+ if use_transactions && persisted? && scope_changed
45
52
  set_lock(field, true)
46
53
  scope_changed = orderable_scope_changed?(field)
47
54
  end
@@ -58,7 +65,7 @@ module Orderable
58
65
  # If scope changed, remove the position from the old scope
59
66
  if persisted? && !embedded? && scope_changed
60
67
  existing_doc = doc.class.unscoped.find(_id)
61
- self.class.new(existing_doc).remove_one_position(field)
68
+ self.class.new(existing_doc).send(:remove_one_position, field)
62
69
  end
63
70
 
64
71
  # Return if there is no instruction to change the position
@@ -76,33 +83,23 @@ module Orderable
76
83
  end
77
84
 
78
85
  # If persisted, update the field in the database atomically
79
- doc.set({ f => target }.merge(changed_scope_hash(field))) if use_transactions && persisted? && !embedded?
86
+ doc.set({ f => target }.merge(changed_scope_hash(field))) if use_transactions && persisted?
80
87
  doc.send("orderable_#{field}_position=", target)
81
88
  end
82
89
 
90
+ def remove_all_positions
91
+ orderable_keys.each do |field|
92
+ remove_one_position(field)
93
+ end
94
+ end
95
+
83
96
  def remove_one_position(field)
84
97
  f = orderable_field(field)
85
98
  current = orderable_position(field)
86
- set_lock(field) if use_transactions && !embedded?
99
+ set_lock(field) if use_transactions
87
100
  orderable_scope(field).gt(f => current).inc(f => -1)
88
101
  end
89
102
 
90
- protected
91
-
92
- delegate :orderable_keys,
93
- :orderable_field,
94
- :orderable_position,
95
- :orderable_scope,
96
- :orderable_scope_changed?,
97
- :orderable_top,
98
- :orderable_bottom,
99
- :_id,
100
- :new_record?,
101
- :persisted?,
102
- :embedded?,
103
- :collection_name,
104
- to: :doc
105
-
106
103
  def move_all
107
104
  doc.send(:move_all)
108
105
  end
@@ -151,56 +148,20 @@ module Orderable
151
148
  end
152
149
  end
153
150
 
154
- def set_lock(field, scope_changed = false)
155
- return unless use_transactions && !embedded?
151
+ def set_lock(field, generic = false)
152
+ return unless use_transactions
156
153
  model_name = doc.class.orderable_configs[field][:lock_collection].to_s.singularize.classify
157
154
  model = Mongoid::Orderable::Models.const_get(model_name)
158
- attrs = lock_scope(field, scope_changed)
155
+ attrs = lock_scope(field, generic)
159
156
  model.where(attrs).find_one_and_update(attrs, { upsert: true })
160
157
  end
161
158
 
162
- def lock_scope(field, scope_changed = false)
159
+ def lock_scope(field, generic = false)
163
160
  sel = orderable_scope(field).selector
164
- scope = ([collection_name] + (scope_changed ? sel.keys : sel.to_a.flatten)).map(&:to_s).join('|')
161
+ scope = ([collection_name] + (generic ? [field] : sel.to_a.flatten)).map(&:to_s).join('|')
165
162
  { scope: scope }
166
163
  end
167
-
168
- def use_transactions
169
- orderable_keys.any? {|k| doc.class.orderable_configs[k][:use_transactions] }
170
- end
171
-
172
- def transaction_max_retries
173
- orderable_keys.map {|k| doc.class.orderable_configs[k][:transaction_max_retries] }.compact.max
174
- end
175
-
176
- def with_transaction(&_block)
177
- Mongoid::QueryCache.uncached do
178
- if use_transactions && !embedded? && !Thread.current[ORDERABLE_TRANSACTION_KEY]
179
- Thread.current[ORDERABLE_TRANSACTION_KEY] = true
180
- retries = transaction_max_retries
181
- begin
182
- doc.class.with_session(causal_consistency: true) do |session|
183
- doc.class.with(read: { mode: :primary }) do
184
- session.start_transaction(read: { mode: :primary },
185
- read_concern: { level: 'majority' },
186
- write_concern: { w: 'majority' })
187
- yield
188
- session.commit_transaction
189
- end
190
- end
191
- rescue Mongo::Error::OperationFailure => e
192
- sleep(0.001)
193
- retries -= 1
194
- retry if retries >= 0
195
- raise Mongoid::Orderable::Errors::TransactionFailed.new(e)
196
- ensure
197
- Thread.current[ORDERABLE_TRANSACTION_KEY] = nil
198
- end
199
- else
200
- yield
201
- end
202
- end
203
- end
204
164
  end
205
165
  end
206
166
  end
167
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Orderable
5
+ module Handlers
6
+ class Document < Base
7
+ def before_create
8
+ apply_all_positions
9
+ end
10
+
11
+ def after_create; end
12
+
13
+ def before_update
14
+ return unless any_field_changed?
15
+ apply_all_positions
16
+ end
17
+
18
+ def after_destroy
19
+ remove_all_positions
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Orderable
5
+ module Handlers
6
+ class DocumentEmbedded < Document
7
+ def after_destroy
8
+ return if doc._root.destroyed?
9
+ super
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Orderable
5
+ module Handlers
6
+ class DocumentTransactional < Document
7
+ def before_create
8
+ clear_all_positions
9
+ end
10
+
11
+ def after_create
12
+ apply_all_positions
13
+ end
14
+
15
+ protected
16
+
17
+ def apply_all_positions
18
+ with_transaction { super }
19
+ end
20
+
21
+ def clear_all_positions
22
+ orderable_keys.each {|field| doc.send("orderable_#{field}_position=", nil) }
23
+ end
24
+
25
+ def use_transactions
26
+ true
27
+ end
28
+
29
+ def with_transaction(&block)
30
+ Mongoid::Orderable::Handlers::Transaction.new(doc).with_transaction(&block)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Orderable
5
+ module Handlers
6
+ # Executes a block within the context of a MongoDB transaction.
7
+ class Transaction
8
+ THREAD_KEY = :__mongoid_orderable_in_txn
9
+ RETRY_SLEEP = 0.001
10
+
11
+ attr_reader :doc
12
+
13
+ def initialize(doc)
14
+ @doc = doc
15
+ end
16
+
17
+ def with_transaction(&block)
18
+ Mongoid::QueryCache.uncached do
19
+ if Thread.current[THREAD_KEY]
20
+ yield
21
+ else
22
+ Thread.current[THREAD_KEY] = true
23
+ retries = transaction_max_retries
24
+ begin
25
+ do_transaction(&block)
26
+ rescue Mongo::Error::OperationFailure => e
27
+ sleep(RETRY_SLEEP)
28
+ retries -= 1
29
+ retry if retries >= 0
30
+ raise Mongoid::Orderable::Errors::TransactionFailed.new(e)
31
+ ensure
32
+ Thread.current[THREAD_KEY] = nil
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ protected
39
+
40
+ def do_transaction(&_block)
41
+ doc.class.with_session(session_opts) do |session|
42
+ doc.class.with(persistence_opts) do
43
+ session.start_transaction(transaction_opts)
44
+ yield
45
+ session.commit_transaction
46
+ end
47
+ end
48
+ end
49
+
50
+ def session_opts
51
+ { read: { mode: :primary },
52
+ causal_consistency: true }
53
+ end
54
+
55
+ def persistence_opts
56
+ { read: { mode: :primary } }
57
+ end
58
+
59
+ def transaction_opts
60
+ { read: { mode: :primary },
61
+ read_concern: { level: 'majority' },
62
+ write_concern: { w: 'majority' } }
63
+ end
64
+
65
+ def transaction_max_retries
66
+ doc.orderable_keys.map {|k| doc.class.orderable_configs.dig(k, :transaction_max_retries) }.compact.max
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -9,18 +9,32 @@ module Mixins
9
9
  ORDERABLE_TRANSACTION_KEY = :__mongoid_orderable_in_txn
10
10
 
11
11
  included do
12
- around_save :orderable_update_positions
13
- after_destroy :orderable_remove_positions, unless: -> { embedded? && _root.destroyed? }
12
+ before_create :orderable_before_create
13
+ after_create :orderable_after_create, prepend: true
14
+ before_update :orderable_before_update
15
+ after_destroy :orderable_after_destroy, prepend: true
14
16
 
15
- delegate :update_positions,
16
- :remove_positions,
17
- to: :orderable_engine,
17
+ delegate :before_create,
18
+ :after_create,
19
+ :before_update,
20
+ :after_destroy,
21
+ to: :orderable_handler,
18
22
  prefix: :orderable
19
23
 
20
24
  protected
21
25
 
22
- def orderable_engine
23
- @orderable_engine ||= Mongoid::Orderable::Engine.new(self)
26
+ def orderable_handler
27
+ @orderable_handler ||= self.class.orderable_handler_class.new(self)
28
+ end
29
+
30
+ def self.orderable_handler_class
31
+ if embedded?
32
+ Mongoid::Orderable::Handlers::DocumentEmbedded
33
+ elsif orderable_configs.values.any? {|c| c[:use_transactions] }
34
+ Mongoid::Orderable::Handlers::DocumentTransactional
35
+ else
36
+ Mongoid::Orderable::Handlers::Document
37
+ end
24
38
  end
25
39
  end
26
40
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Mongoid
4
4
  module Orderable
5
- VERSION = '6.0.1'
5
+ VERSION = '6.0.2'
6
6
  end
7
7
  end
@@ -25,5 +25,9 @@ require 'mongoid/orderable/generators/movable'
25
25
  require 'mongoid/orderable/generators/position'
26
26
  require 'mongoid/orderable/generators/scope'
27
27
  require 'mongoid/orderable/generators/helpers'
28
- require 'mongoid/orderable/engine'
28
+ require 'mongoid/orderable/handlers/base'
29
+ require 'mongoid/orderable/handlers/document'
30
+ require 'mongoid/orderable/handlers/document_embedded'
31
+ require 'mongoid/orderable/handlers/document_transactional'
32
+ require 'mongoid/orderable/handlers/transaction'
29
33
  require 'mongoid/orderable/installer'
@@ -0,0 +1,232 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'concurrency' do
4
+ enable_transactions!
5
+
6
+ describe 'simple create' do
7
+
8
+ it 'should correctly insert at the top' do
9
+ 20.times.map do
10
+ Thread.new do
11
+ SimpleOrderable.create!(move_to: :top)
12
+ end
13
+ end.each(&:join)
14
+
15
+ expect(SimpleOrderable.pluck(:position).sort).to eq((1..20).to_a)
16
+ end
17
+
18
+ it 'should correctly insert at the bottom' do
19
+ 20.times.map do
20
+ Thread.new do
21
+ SimpleOrderable.create!
22
+ end
23
+ end.each(&:join)
24
+
25
+ expect(SimpleOrderable.pluck(:position).sort).to eq((1..20).to_a)
26
+ end
27
+
28
+ it 'should correctly insert at a random position' do
29
+ 20.times.map do
30
+ Thread.new do
31
+ SimpleOrderable.create!(move_to: (1..10).to_a.sample)
32
+ end
33
+ end.each(&:join)
34
+
35
+ expect(SimpleOrderable.pluck(:position).sort).to eq((1..20).to_a)
36
+ end
37
+ end
38
+
39
+ describe 'simple update' do
40
+ before :each do
41
+ 5.times { SimpleOrderable.create! }
42
+ end
43
+
44
+ it 'should correctly move items to top' do
45
+ 20.times.map do
46
+ Thread.new do
47
+ record = SimpleOrderable.all.sample
48
+ record.update_attributes move_to: :top
49
+ end
50
+ end.each(&:join)
51
+
52
+ expect(SimpleOrderable.pluck(:position).sort).to eq([1, 2, 3, 4, 5])
53
+ end
54
+
55
+ it 'should correctly move items to bottom' do
56
+ 20.times.map do
57
+ Thread.new do
58
+ record = SimpleOrderable.all.sample
59
+ record.update_attributes move_to: :bottom
60
+ end
61
+ end.each(&:join)
62
+
63
+ expect(SimpleOrderable.pluck(:position).sort).to eq([1, 2, 3, 4, 5])
64
+ end
65
+
66
+ it 'should correctly move items higher' do
67
+ 20.times.map do
68
+ Thread.new do
69
+ record = SimpleOrderable.all.sample
70
+ record.update_attributes move_to: :higher
71
+ end
72
+ end.each(&:join)
73
+
74
+ expect(SimpleOrderable.pluck(:position).sort).to eq([1, 2, 3, 4, 5])
75
+ end
76
+
77
+ it 'should correctly move items lower' do
78
+ 20.times.map do
79
+ Thread.new do
80
+ record = SimpleOrderable.all.sample
81
+ record.update_attributes move_to: :lower
82
+ end
83
+ end.each(&:join)
84
+
85
+ expect(SimpleOrderable.pluck(:position).sort).to eq([1, 2, 3, 4, 5])
86
+ end
87
+
88
+ it 'should correctly insert at the top' do
89
+ 20.times.map do
90
+ Thread.new do
91
+ SimpleOrderable.create!(move_to: :top)
92
+ end
93
+ end.each(&:join)
94
+
95
+ expect(SimpleOrderable.pluck(:position).sort).to eq((1..25).to_a)
96
+ end
97
+
98
+ it 'should correctly insert at the bottom' do
99
+ 20.times.map do
100
+ Thread.new do
101
+ SimpleOrderable.create!
102
+ end
103
+ end.each(&:join)
104
+
105
+ expect(SimpleOrderable.pluck(:position).sort).to eq((1..25).to_a)
106
+ end
107
+
108
+ it 'should correctly insert at a random position' do
109
+ 20.times.map do
110
+ Thread.new do
111
+ SimpleOrderable.create!(move_to: (1..10).to_a.sample)
112
+ end
113
+ end.each(&:join)
114
+
115
+ expect(SimpleOrderable.pluck(:position).sort).to eq((1..25).to_a)
116
+ end
117
+
118
+ it 'should correctly move items to a random position' do
119
+ 20.times.map do
120
+ Thread.new do
121
+ record = SimpleOrderable.all.sample
122
+ record.update_attributes move_to: (1..5).to_a.sample
123
+ end
124
+ end.each(&:join)
125
+
126
+ expect(SimpleOrderable.pluck(:position).sort).to eq([1, 2, 3, 4, 5])
127
+ end
128
+ end
129
+
130
+ describe 'scoped update' do
131
+
132
+ before :each do
133
+ 2.times { ScopedOrderable.create! group_id: 1 }
134
+ 3.times { ScopedOrderable.create! group_id: 2 }
135
+ end
136
+
137
+ it 'should correctly move items to top' do
138
+ 20.times.map do
139
+ Thread.new do
140
+ record = ScopedOrderable.all.sample
141
+ record.update_attributes move_to: :top
142
+ end
143
+ end.each(&:join)
144
+
145
+ expect(ScopedOrderable.pluck(:position).sort).to eq([1, 1, 2, 2, 3])
146
+ end
147
+
148
+ it 'should correctly move items to bottom' do
149
+ 20.times.map do
150
+ Thread.new do
151
+ record = ScopedOrderable.all.sample
152
+ record.update_attributes move_to: :bottom
153
+ end
154
+ end.each(&:join)
155
+
156
+ expect(ScopedOrderable.pluck(:position).sort).to eq([1, 1, 2, 2, 3])
157
+ end
158
+
159
+ it 'should correctly move items higher' do
160
+ 20.times.map do
161
+ Thread.new do
162
+ record = ScopedOrderable.all.sample
163
+ record.update_attributes move_to: :higher
164
+ end
165
+ end.each(&:join)
166
+
167
+ expect(ScopedOrderable.pluck(:position).sort).to eq([1, 1, 2, 2, 3])
168
+ end
169
+
170
+ it 'should correctly move items lower' do
171
+ 20.times.map do
172
+ Thread.new do
173
+ record = ScopedOrderable.all.sample
174
+ record.update_attributes move_to: :lower
175
+ end
176
+ end.each(&:join)
177
+
178
+ expect(ScopedOrderable.pluck(:position).sort).to eq([1, 1, 2, 2, 3])
179
+ end
180
+
181
+ it 'should correctly move items to a random position' do
182
+ 20.times.map do
183
+ Thread.new do
184
+ record = ScopedOrderable.all.sample
185
+ record.update_attributes move_to: (1..5).to_a.sample
186
+ end
187
+ end.each(&:join)
188
+
189
+ expect(ScopedOrderable.pluck(:position).sort).to eq([1, 1, 2, 2, 3])
190
+ end
191
+
192
+ # This spec fails randomly
193
+ it 'should correctly move items to a random scope', retry: 5 do
194
+ 20.times.map do
195
+ Thread.new do
196
+ record = ScopedOrderable.all.sample
197
+ group_id = ([1, 2, 3] - [record.group_id]).sample
198
+ record.update_attributes group_id: group_id
199
+ end
200
+ end.each(&:join)
201
+
202
+ result = ScopedOrderable.all.to_a.each_with_object({}) do |obj, hash|
203
+ hash[obj.group_id] ||= []
204
+ hash[obj.group_id] << obj.position
205
+ end
206
+
207
+ result.values.each do |ary|
208
+ expect(ary.sort).to eq((1..(ary.size)).to_a)
209
+ end
210
+ end
211
+
212
+ it 'should correctly move items to a random position and scope' do
213
+ 20.times.map do
214
+ Thread.new do
215
+ record = ScopedOrderable.all.sample
216
+ group_id = ([1, 2, 3] - [record.group_id]).sample
217
+ position = (1..5).to_a.sample
218
+ record.update_attributes group_id: group_id, move_to: position
219
+ end
220
+ end.each(&:join)
221
+
222
+ result = ScopedOrderable.all.to_a.each_with_object({}) do |obj, hash|
223
+ hash[obj.group_id] ||= []
224
+ hash[obj.group_id] << obj.position
225
+ end
226
+
227
+ result.values.each do |ary|
228
+ expect(ary.sort).to eq((1..(ary.size)).to_a)
229
+ end
230
+ end
231
+ end
232
+ end