mongoid_orderable 4.1.1 → 6.0.1

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.
Files changed (54) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +77 -17
  3. data/LICENSE.txt +20 -0
  4. data/README.md +256 -127
  5. data/Rakefile +24 -6
  6. data/lib/config/locales/en.yml +12 -9
  7. data/lib/mongoid/orderable.rb +29 -22
  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 +206 -0
  11. data/lib/mongoid/orderable/errors/invalid_target_position.rb +19 -18
  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 -1408
  28. data/spec/spec_helper.rb +21 -37
  29. metadata +62 -42
  30. data/.gitignore +0 -4
  31. data/.rspec +0 -1
  32. data/.rvmrc +0 -1
  33. data/.travis.yml +0 -20
  34. data/Gemfile +0 -15
  35. data/lib/mongoid/orderable/callbacks.rb +0 -75
  36. data/lib/mongoid/orderable/configuration.rb +0 -60
  37. data/lib/mongoid/orderable/errors.rb +0 -2
  38. data/lib/mongoid/orderable/errors/mongoid_orderable_error.rb +0 -14
  39. data/lib/mongoid/orderable/generator.rb +0 -34
  40. data/lib/mongoid/orderable/generator/helpers.rb +0 -29
  41. data/lib/mongoid/orderable/generator/listable.rb +0 -41
  42. data/lib/mongoid/orderable/generator/movable.rb +0 -62
  43. data/lib/mongoid/orderable/generator/position.rb +0 -26
  44. data/lib/mongoid/orderable/generator/scope.rb +0 -17
  45. data/lib/mongoid/orderable/helpers.rb +0 -50
  46. data/lib/mongoid/orderable/listable.rb +0 -49
  47. data/lib/mongoid/orderable/movable.rb +0 -58
  48. data/lib/mongoid/orderable/orderable_class.rb +0 -51
  49. data/lib/mongoid_orderable/mongoid/contexts/enumerable.rb +0 -15
  50. data/lib/mongoid_orderable/mongoid/contexts/mongo.rb +0 -18
  51. data/lib/mongoid_orderable/mongoid/contextual/memory.rb +0 -15
  52. data/lib/mongoid_orderable/mongoid/criteria.rb +0 -4
  53. data/lib/mongoid_orderable/version.rb +0 -3
  54. data/mongoid_orderable.gemspec +0 -25
data/Rakefile CHANGED
@@ -1,6 +1,24 @@
1
- require "bundler/gem_tasks"
2
- require "rspec/core/rake_task"
3
-
4
- RSpec::Core::RakeTask.new(:spec)
5
-
6
- task :default => [ :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,22 +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
-
15
- def orderable options = {}
16
- configuration = Mongoid::Orderable::Configuration.build(self, options)
17
-
18
- Mongoid::Orderable::OrderableClass.setup(self, configuration)
19
- end
20
-
21
- end
22
- 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,206 @@
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
+ 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
+ end
205
+ end
206
+ end
@@ -1,18 +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
- def _compose_message value
10
- if MongoidOrderable.mongoid2?
11
- translate 'invalid_target_position', { :value => value.inspect }
12
- else
13
- compose_message 'invalid_target_position', { :value => value.inspect }
14
- end
15
- end
16
- end
17
- end
18
- 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