mongoid_orderable 6.0.1 → 6.0.2

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: 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