mongoid_orderable 5.2.0 → 6.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +73 -58
  3. data/LICENSE.txt +20 -20
  4. data/README.md +256 -150
  5. data/Rakefile +24 -21
  6. data/lib/config/locales/en.yml +12 -9
  7. data/lib/mongoid/orderable.rb +29 -20
  8. data/lib/mongoid/orderable/configs/field_config.rb +79 -0
  9. data/lib/mongoid/orderable/configs/global_config.rb +26 -0
  10. data/lib/mongoid/orderable/engine.rb +204 -0
  11. data/lib/mongoid/orderable/errors/invalid_target_position.rb +19 -15
  12. data/lib/mongoid/orderable/errors/transaction_failed.rb +20 -0
  13. data/lib/mongoid/orderable/generators/base.rb +21 -0
  14. data/lib/mongoid/orderable/generators/helpers.rb +29 -0
  15. data/lib/mongoid/orderable/generators/listable.rb +41 -0
  16. data/lib/mongoid/orderable/generators/lock_collection.rb +37 -0
  17. data/lib/mongoid/orderable/generators/movable.rb +62 -0
  18. data/lib/mongoid/orderable/generators/position.rb +26 -0
  19. data/lib/mongoid/orderable/generators/scope.rb +26 -0
  20. data/lib/mongoid/orderable/installer.rb +63 -0
  21. data/lib/mongoid/orderable/mixins/callbacks.rb +29 -0
  22. data/lib/mongoid/orderable/mixins/helpers.rb +39 -0
  23. data/lib/mongoid/orderable/mixins/listable.rb +49 -0
  24. data/lib/mongoid/orderable/mixins/movable.rb +60 -0
  25. data/lib/mongoid/orderable/version.rb +7 -0
  26. data/lib/mongoid_orderable.rb +29 -56
  27. data/spec/mongoid/orderable_spec.rb +1486 -1380
  28. data/spec/spec_helper.rb +21 -21
  29. metadata +44 -41
  30. data/.gitignore +0 -4
  31. data/.rspec +0 -2
  32. data/.rubocop.yml +0 -6
  33. data/.rubocop_todo.yml +0 -88
  34. data/.rvmrc +0 -1
  35. data/.travis.yml +0 -48
  36. data/CONTRIBUTING.md +0 -118
  37. data/Dangerfile +0 -1
  38. data/Gemfile +0 -26
  39. data/RELEASING.md +0 -68
  40. data/lib/mongoid/orderable/callbacks.rb +0 -79
  41. data/lib/mongoid/orderable/configuration.rb +0 -58
  42. data/lib/mongoid/orderable/errors.rb +0 -2
  43. data/lib/mongoid/orderable/errors/mongoid_orderable_error.rb +0 -6
  44. data/lib/mongoid/orderable/generator.rb +0 -33
  45. data/lib/mongoid/orderable/generator/helpers.rb +0 -27
  46. data/lib/mongoid/orderable/generator/listable.rb +0 -39
  47. data/lib/mongoid/orderable/generator/movable.rb +0 -60
  48. data/lib/mongoid/orderable/generator/position.rb +0 -24
  49. data/lib/mongoid/orderable/generator/scope.rb +0 -17
  50. data/lib/mongoid/orderable/helpers.rb +0 -49
  51. data/lib/mongoid/orderable/listable.rb +0 -47
  52. data/lib/mongoid/orderable/movable.rb +0 -56
  53. data/lib/mongoid/orderable/orderable_class.rb +0 -47
  54. data/lib/mongoid_orderable/mongoid/contextual/memory.rb +0 -15
  55. data/lib/mongoid_orderable/version.rb +0 -3
  56. data/mongoid_orderable.gemspec +0 -26
data/Rakefile CHANGED
@@ -1,21 +1,24 @@
1
- require 'bundler'
2
- begin
3
- Bundler.setup(:default, :development)
4
- rescue Bundler::BundlerError => e
5
- $stderr.puts e.message
6
- $stderr.puts 'Run `bundle install` to install missing gems'
7
- exit e.status_code
8
- end
9
-
10
- Bundler::GemHelper.install_tasks
11
-
12
- require 'rspec/core'
13
- require 'rspec/core/rake_task'
14
- RSpec::Core::RakeTask.new(:spec) do |spec|
15
- spec.pattern = FileList['spec/**/*_spec.rb']
16
- end
17
-
18
- require 'rubocop/rake_task'
19
- RuboCop::RakeTask.new(:rubocop)
20
-
21
- task default: [:rubocop, :spec]
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler'
4
+
5
+ begin
6
+ Bundler.setup(:default, :development)
7
+ rescue Bundler::BundlerError => e
8
+ warn e.message
9
+ warn 'Run `bundle install` to install missing gems'
10
+ exit e.status_code
11
+ end
12
+
13
+ Bundler::GemHelper.install_tasks
14
+
15
+ require 'rspec/core'
16
+ require 'rspec/core/rake_task'
17
+ RSpec::Core::RakeTask.new(:spec) do |spec|
18
+ spec.pattern = FileList['spec/**/*_spec.rb']
19
+ end
20
+
21
+ require 'rubocop/rake_task'
22
+ RuboCop::RakeTask.new(:rubocop)
23
+
24
+ task default: %i[rubocop spec]
@@ -1,9 +1,12 @@
1
- en:
2
- mongoid:
3
- errors:
4
- messages:
5
- invalid_target_position:
6
- message: "`%{value}` is not an acceptable value for target position."
7
- summary: "Mongoid::Orderable accepts: a Numeric, a numeric string,
8
- :top, :bottom, or nil."
9
- resolution: "You must give an acceptable value for target position."
1
+ en:
2
+ mongoid:
3
+ errors:
4
+ messages:
5
+ invalid_target_position:
6
+ message: "`%{value}` is not an acceptable value for target position."
7
+ summary: "Mongoid::Orderable accepts: a Numeric, a numeric string, :top, :bottom, or nil."
8
+ resolution: "You must give an acceptable value for target position."
9
+ transaction_failed:
10
+ message: "Transaction failed due to a database-level conflict."
11
+ summary: ""
12
+ resolution: "Please retry the transaction."
@@ -1,20 +1,29 @@
1
- module Mongoid::Orderable
2
- extend ActiveSupport::Concern
3
-
4
- included do
5
- include Mongoid::Orderable::Helpers
6
- include Mongoid::Orderable::Callbacks
7
- include Mongoid::Orderable::Movable
8
- include Mongoid::Orderable::Listable
9
-
10
- class_attribute :orderable_configurations
11
- end
12
-
13
- module ClassMethods
14
- def orderable(options = {})
15
- configuration = Mongoid::Orderable::Configuration.build(self, options)
16
-
17
- Mongoid::Orderable::OrderableClass.setup(self, configuration)
18
- end
19
- end
20
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Orderable
5
+ MUTEX = Mutex.new
6
+
7
+ class << self
8
+ def configure
9
+ yield(config) if block_given?
10
+ end
11
+
12
+ def config
13
+ @config || MUTEX.synchronize { @config = ::Mongoid::Orderable::Configs::GlobalConfig.new }
14
+ end
15
+ end
16
+
17
+ extend ActiveSupport::Concern
18
+
19
+ included do
20
+ class_attribute :orderable_configs
21
+ end
22
+
23
+ class_methods do
24
+ def orderable(options = {})
25
+ Mongoid::Orderable::Installer.new(self, options).setup
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Orderable
5
+ module Configs
6
+ class FieldConfig
7
+ CONFIG_OPTIONS = %i[field
8
+ scope
9
+ foreign_key
10
+ inherited
11
+ base
12
+ index
13
+ default
14
+ use_transactions
15
+ transaction_max_retries
16
+ lock_collection].freeze
17
+ ALIASES = { column: :field }.freeze
18
+ FIELD_OPTIONS = %i[as].freeze
19
+ VALID_OPTIONS = (CONFIG_OPTIONS | FIELD_OPTIONS).freeze
20
+
21
+ attr_reader :orderable_class,
22
+ :options
23
+
24
+ def initialize(parent, options = {})
25
+ @orderable_class = parent
26
+ assign_options(options)
27
+ set_field_options
28
+ set_orderable_scope
29
+ end
30
+
31
+ def global_config
32
+ cfg = Mongoid::Orderable.config
33
+ { field: cfg.field,
34
+ as: cfg.as,
35
+ index: cfg.index,
36
+ base: cfg.base,
37
+ use_transactions: cfg.use_transactions,
38
+ transaction_max_retries: cfg.transaction_max_retries,
39
+ lock_collection: cfg.lock_collection }
40
+ end
41
+
42
+ protected
43
+
44
+ def assign_options(options)
45
+ @options = global_config
46
+ return unless options.is_a?(Hash)
47
+ @options = @options.merge(options.symbolize_keys.transform_keys {|k| ALIASES[k] || k }).slice(*VALID_OPTIONS)
48
+ end
49
+
50
+ def set_field_options
51
+ @options[:field_options] = {}
52
+ FIELD_OPTIONS.each do |key|
53
+ next unless @options.key?(key)
54
+ @options[:field_options][key] = @options.delete(key)
55
+ end
56
+ end
57
+
58
+ def set_orderable_scope
59
+ return unless @options[:scope].class.in?([Array, Symbol, String])
60
+
61
+ scope = Array(@options[:scope])
62
+ scope.map! do |value|
63
+ case value
64
+ when Symbol
65
+ relation = @orderable_class.relations[@options[:scope].to_s]&.key&.to_sym
66
+ relation || value
67
+ when String
68
+ value.to_sym
69
+ else
70
+ raise ArgumentError.new("Orderable :scope invalid: #{@options[:scope]}")
71
+ end
72
+ end
73
+
74
+ @options[:scope] = scope
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Orderable
5
+ module Configs
6
+ class GlobalConfig
7
+ attr_accessor :field,
8
+ :as,
9
+ :base,
10
+ :index,
11
+ :use_transactions,
12
+ :transaction_max_retries,
13
+ :lock_collection
14
+
15
+ def initialize
16
+ self.field = :position
17
+ self.index = true
18
+ self.base = 1
19
+ self.use_transactions = false
20
+ self.transaction_max_retries = 10
21
+ self.lock_collection = :mongoid_orderable_locks
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Orderable
5
+ class Engine
6
+ ORDERABLE_TRANSACTION_KEY = :__mongoid_orderable_in_txn
7
+
8
+ attr_accessor :doc
9
+
10
+ def initialize(doc)
11
+ @doc = doc
12
+ end
13
+
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) }
18
+
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
24
+
25
+ yield unless new_record
26
+ end
27
+
28
+ def remove_positions
29
+ orderable_keys.each do |field|
30
+ remove_one_position(field)
31
+ end
32
+ end
33
+
34
+ def apply_one_position(field, target_position)
35
+ return unless changed?(field)
36
+
37
+ set_lock(field) if use_transactions && !embedded?
38
+
39
+ f = orderable_field(field)
40
+ scope = orderable_scope(field)
41
+ scope_changed = orderable_scope_changed?(field)
42
+
43
+ # Set scope-level lock if scope changed
44
+ if use_transactions && persisted? && !embedded? && scope_changed
45
+ set_lock(field, true)
46
+ scope_changed = orderable_scope_changed?(field)
47
+ end
48
+
49
+ # Get the current position as exists in the database
50
+ current = if !persisted? || scope_changed
51
+ nil
52
+ elsif persisted? && !embedded?
53
+ scope.where(_id: _id).pluck(f).first
54
+ else
55
+ orderable_position(field)
56
+ end
57
+
58
+ # If scope changed, remove the position from the old scope
59
+ if persisted? && !embedded? && scope_changed
60
+ existing_doc = doc.class.unscoped.find(_id)
61
+ self.class.new(existing_doc).remove_one_position(field)
62
+ end
63
+
64
+ # Return if there is no instruction to change the position
65
+ in_list = persisted? && current
66
+ return if in_list && !target_position
67
+
68
+ # Use $inc operator to shift the position of the other documents
69
+ target = resolve_target_position(field, target_position, in_list)
70
+ if !in_list
71
+ scope.gte(f => target).inc(f => 1)
72
+ elsif target < current
73
+ scope.where(f => { '$gte' => target, '$lt' => current }).inc(f => 1)
74
+ elsif target > current
75
+ scope.where(f => { '$gt' => current, '$lte' => target }).inc(f => -1)
76
+ end
77
+
78
+ # If persisted, update the field in the database atomically
79
+ doc.set({ f => target }.merge(changed_scope_hash(field))) if use_transactions && persisted? && !embedded?
80
+ doc.send("orderable_#{field}_position=", target)
81
+ end
82
+
83
+ def remove_one_position(field)
84
+ f = orderable_field(field)
85
+ current = orderable_position(field)
86
+ set_lock(field) if use_transactions && !embedded?
87
+ orderable_scope(field).gt(f => current).inc(f => -1)
88
+ end
89
+
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
+ def move_all
107
+ doc.send(:move_all)
108
+ end
109
+
110
+ def resolve_target_position(field, target_position, in_list)
111
+ target_position ||= 'bottom'
112
+
113
+ unless target_position.is_a? Numeric
114
+ target_position = case target_position.to_s
115
+ when 'top' then (min ||= orderable_top(field))
116
+ when 'bottom' then (max ||= orderable_bottom(field, in_list))
117
+ when 'higher' then orderable_position(field).pred
118
+ when 'lower' then orderable_position(field).next
119
+ when /\A\d+\Z/ then target_position.to_i
120
+ else raise Mongoid::Orderable::Errors::InvalidTargetPosition.new(target_position)
121
+ end
122
+ end
123
+
124
+ if target_position <= (min ||= orderable_top(field))
125
+ target_position = min
126
+ elsif target_position > (max ||= orderable_bottom(field, in_list))
127
+ target_position = max
128
+ end
129
+
130
+ target_position
131
+ end
132
+
133
+ def changed?(field)
134
+ return true if new_record? || !doc.send(orderable_field(field)) || move_all[field]
135
+ changeable_keys(field).any? {|f| doc.send("#{f}_changed?") }
136
+ end
137
+
138
+ def changeable_keys(field)
139
+ [orderable_field(field)] | scope_keys(field)
140
+ end
141
+
142
+ def scope_keys(field)
143
+ orderable_scope(field).selector.keys.map do |f|
144
+ doc.fields[f]&.options&.[](:as) || f
145
+ end
146
+ end
147
+
148
+ def changed_scope_hash(field)
149
+ scope_keys(field).each_with_object({}) do |f, hash|
150
+ hash[f] = doc.send(f) if doc.send("#{f}_changed?")
151
+ end
152
+ end
153
+
154
+ def set_lock(field, scope_changed = false)
155
+ return unless use_transactions && !embedded?
156
+ model_name = doc.class.orderable_configs[field][:lock_collection].to_s.singularize.classify
157
+ model = Mongoid::Orderable::Models.const_get(model_name)
158
+ attrs = lock_scope(field, scope_changed)
159
+ model.where(attrs).find_one_and_update(attrs, { upsert: true })
160
+ end
161
+
162
+ def lock_scope(field, scope_changed = false)
163
+ sel = orderable_scope(field).selector
164
+ scope = ([collection_name] + (scope_changed ? sel.keys : sel.to_a.flatten)).map(&:to_s).join('|')
165
+ { scope: scope }
166
+ 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
+ session.start_transaction(read: { mode: :primary },
184
+ read_concern: { level: 'majority' },
185
+ write_concern: { w: 'majority' })
186
+ yield
187
+ session.commit_transaction
188
+ end
189
+ rescue Mongo::Error::OperationFailure => e
190
+ sleep(0.001)
191
+ retries -= 1
192
+ retry if retries >= 0
193
+ raise Mongoid::Orderable::Errors::TransactionFailed.new(e)
194
+ ensure
195
+ Thread.current[ORDERABLE_TRANSACTION_KEY] = nil
196
+ end
197
+ else
198
+ yield
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -1,15 +1,19 @@
1
- module Mongoid::Orderable
2
- module Errors
3
- class InvalidTargetPosition < Mongoid::Orderable::Errors::MongoidOrderableError
4
- def initialize(value)
5
- super _compose_message(value)
6
- end
7
-
8
- private
9
-
10
- def _compose_message(value)
11
- compose_message 'invalid_target_position', value: value.inspect
12
- end
13
- end
14
- end
15
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Orderable
5
+ module Errors
6
+ class InvalidTargetPosition < ::Mongoid::Errors::MongoidError
7
+ def initialize(value)
8
+ super _compose_message(value)
9
+ end
10
+
11
+ private
12
+
13
+ def _compose_message(value)
14
+ compose_message 'invalid_target_position', value: value.inspect
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end