mongoid_orderable 5.0.0 → 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.
Files changed (71) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +83 -22
  3. data/LICENSE.txt +20 -0
  4. data/README.md +256 -149
  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/errors/invalid_target_position.rb +19 -18
  11. data/lib/mongoid/orderable/errors/transaction_failed.rb +20 -0
  12. data/lib/mongoid/orderable/generators/base.rb +21 -0
  13. data/lib/mongoid/orderable/generators/helpers.rb +29 -0
  14. data/lib/mongoid/orderable/generators/listable.rb +41 -0
  15. data/lib/mongoid/orderable/generators/lock_collection.rb +37 -0
  16. data/lib/mongoid/orderable/generators/movable.rb +62 -0
  17. data/lib/mongoid/orderable/generators/position.rb +26 -0
  18. data/lib/mongoid/orderable/generators/scope.rb +26 -0
  19. data/lib/mongoid/orderable/handlers/base.rb +167 -0
  20. data/lib/mongoid/orderable/handlers/document.rb +24 -0
  21. data/lib/mongoid/orderable/handlers/document_embedded.rb +14 -0
  22. data/lib/mongoid/orderable/handlers/document_transactional.rb +35 -0
  23. data/lib/mongoid/orderable/handlers/transaction.rb +71 -0
  24. data/lib/mongoid/orderable/installer.rb +63 -0
  25. data/lib/mongoid/orderable/mixins/callbacks.rb +43 -0
  26. data/lib/mongoid/orderable/mixins/helpers.rb +39 -0
  27. data/lib/mongoid/orderable/mixins/listable.rb +49 -0
  28. data/lib/mongoid/orderable/mixins/movable.rb +60 -0
  29. data/lib/mongoid/orderable/version.rb +7 -0
  30. data/lib/mongoid_orderable.rb +33 -54
  31. data/spec/integration/concurrency_spec.rb +232 -0
  32. data/spec/integration/customized_spec.rb +31 -0
  33. data/spec/integration/embedded_spec.rb +41 -0
  34. data/spec/integration/foreign_key_spec.rb +33 -0
  35. data/spec/integration/inherited_spec.rb +54 -0
  36. data/spec/integration/multiple_fields_spec.rb +554 -0
  37. data/spec/integration/multiple_scoped_spec.rb +63 -0
  38. data/spec/integration/no_indexed_spec.rb +23 -0
  39. data/spec/integration/scoped_spec.rb +151 -0
  40. data/spec/integration/simple_spec.rb +184 -0
  41. data/spec/integration/string_scoped_spec.rb +28 -0
  42. data/spec/integration/zero_based_spec.rb +161 -0
  43. data/spec/spec_helper.rb +42 -30
  44. data/spec/support/models.rb +122 -0
  45. metadata +75 -41
  46. data/.gitignore +0 -4
  47. data/.rspec +0 -2
  48. data/.rvmrc +0 -1
  49. data/.travis.yml +0 -14
  50. data/Gemfile +0 -18
  51. data/lib/mongoid/orderable/callbacks.rb +0 -75
  52. data/lib/mongoid/orderable/configuration.rb +0 -60
  53. data/lib/mongoid/orderable/errors.rb +0 -2
  54. data/lib/mongoid/orderable/errors/mongoid_orderable_error.rb +0 -14
  55. data/lib/mongoid/orderable/generator.rb +0 -34
  56. data/lib/mongoid/orderable/generator/helpers.rb +0 -29
  57. data/lib/mongoid/orderable/generator/listable.rb +0 -41
  58. data/lib/mongoid/orderable/generator/movable.rb +0 -62
  59. data/lib/mongoid/orderable/generator/position.rb +0 -26
  60. data/lib/mongoid/orderable/generator/scope.rb +0 -17
  61. data/lib/mongoid/orderable/helpers.rb +0 -50
  62. data/lib/mongoid/orderable/listable.rb +0 -49
  63. data/lib/mongoid/orderable/movable.rb +0 -58
  64. data/lib/mongoid/orderable/orderable_class.rb +0 -51
  65. data/lib/mongoid_orderable/mongoid/contexts/enumerable.rb +0 -15
  66. data/lib/mongoid_orderable/mongoid/contexts/mongo.rb +0 -18
  67. data/lib/mongoid_orderable/mongoid/contextual/memory.rb +0 -15
  68. data/lib/mongoid_orderable/mongoid/criteria.rb +0 -4
  69. data/lib/mongoid_orderable/version.rb +0 -3
  70. data/mongoid_orderable.gemspec +0 -26
  71. data/spec/mongoid/orderable_spec.rb +0 -1413
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Orderable
5
+ module Generators
6
+ class Position < Base
7
+ def generate(field_name)
8
+ klass.class_eval <<~KLASS, __FILE__, __LINE__ + 1
9
+ def orderable_position(field = nil)
10
+ field ||= default_orderable_field
11
+ send "orderable_\#{field}_position"
12
+ end
13
+ KLASS
14
+
15
+ generate_method("orderable_#{field_name}_position") do
16
+ send field_name
17
+ end
18
+
19
+ generate_method("orderable_#{field_name}_position=") do |value|
20
+ send "#{field_name}=", value
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Orderable
5
+ module Generators
6
+ class Scope < Base
7
+ def generate(field_name, order_scope)
8
+ criteria = criteria(order_scope)
9
+ klass.class_eval do
10
+ scope "orderable_#{field_name}_scope", criteria
11
+ end
12
+ end
13
+
14
+ protected
15
+
16
+ def criteria(order_scope)
17
+ case order_scope
18
+ when Proc then order_scope
19
+ when Array then ->(doc) { where(order_scope.each_with_object({}) {|f, h| h[f] = doc.send(f) }) }
20
+ else ->(_doc) { where({}) }
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Orderable
5
+ module Handlers
6
+ class Base
7
+ attr_reader :doc
8
+
9
+ def initialize(doc)
10
+ @doc = doc
11
+ end
12
+
13
+ protected
14
+
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
28
+
29
+ def use_transactions
30
+ false
31
+ end
32
+
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]) }
39
+ end
40
+
41
+ def apply_one_position(field, target_position)
42
+ return unless changed?(field)
43
+
44
+ set_lock(field) if use_transactions
45
+
46
+ f = orderable_field(field)
47
+ scope = orderable_scope(field)
48
+ scope_changed = orderable_scope_changed?(field)
49
+
50
+ # Set scope-level lock if scope changed
51
+ if use_transactions && persisted? && scope_changed
52
+ set_lock(field, true)
53
+ scope_changed = orderable_scope_changed?(field)
54
+ end
55
+
56
+ # Get the current position as exists in the database
57
+ current = if !persisted? || scope_changed
58
+ nil
59
+ elsif persisted? && !embedded?
60
+ scope.where(_id: _id).pluck(f).first
61
+ else
62
+ orderable_position(field)
63
+ end
64
+
65
+ # If scope changed, remove the position from the old scope
66
+ if persisted? && !embedded? && scope_changed
67
+ existing_doc = doc.class.unscoped.find(_id)
68
+ self.class.new(existing_doc).send(:remove_one_position, field)
69
+ end
70
+
71
+ # Return if there is no instruction to change the position
72
+ in_list = persisted? && current
73
+ return if in_list && !target_position
74
+
75
+ # Use $inc operator to shift the position of the other documents
76
+ target = resolve_target_position(field, target_position, in_list)
77
+ if !in_list
78
+ scope.gte(f => target).inc(f => 1)
79
+ elsif target < current
80
+ scope.where(f => { '$gte' => target, '$lt' => current }).inc(f => 1)
81
+ elsif target > current
82
+ scope.where(f => { '$gt' => current, '$lte' => target }).inc(f => -1)
83
+ end
84
+
85
+ # If persisted, update the field in the database atomically
86
+ doc.set({ f => target }.merge(changed_scope_hash(field))) if use_transactions && persisted?
87
+ doc.send("orderable_#{field}_position=", target)
88
+ end
89
+
90
+ def remove_all_positions
91
+ orderable_keys.each do |field|
92
+ remove_one_position(field)
93
+ end
94
+ end
95
+
96
+ def remove_one_position(field)
97
+ f = orderable_field(field)
98
+ current = orderable_position(field)
99
+ set_lock(field) if use_transactions
100
+ orderable_scope(field).gt(f => current).inc(f => -1)
101
+ end
102
+
103
+ def move_all
104
+ doc.send(:move_all)
105
+ end
106
+
107
+ def resolve_target_position(field, target_position, in_list)
108
+ target_position ||= 'bottom'
109
+
110
+ unless target_position.is_a? Numeric
111
+ target_position = case target_position.to_s
112
+ when 'top' then (min ||= orderable_top(field))
113
+ when 'bottom' then (max ||= orderable_bottom(field, in_list))
114
+ when 'higher' then orderable_position(field).pred
115
+ when 'lower' then orderable_position(field).next
116
+ when /\A\d+\Z/ then target_position.to_i
117
+ else raise Mongoid::Orderable::Errors::InvalidTargetPosition.new(target_position)
118
+ end
119
+ end
120
+
121
+ if target_position <= (min ||= orderable_top(field))
122
+ target_position = min
123
+ elsif target_position > (max ||= orderable_bottom(field, in_list))
124
+ target_position = max
125
+ end
126
+
127
+ target_position
128
+ end
129
+
130
+ def changed?(field)
131
+ return true if new_record? || !doc.send(orderable_field(field)) || move_all[field]
132
+ changeable_keys(field).any? {|f| doc.send("#{f}_changed?") }
133
+ end
134
+
135
+ def changeable_keys(field)
136
+ [orderable_field(field)] | scope_keys(field)
137
+ end
138
+
139
+ def scope_keys(field)
140
+ orderable_scope(field).selector.keys.map do |f|
141
+ doc.fields[f]&.options&.[](:as) || f
142
+ end
143
+ end
144
+
145
+ def changed_scope_hash(field)
146
+ scope_keys(field).each_with_object({}) do |f, hash|
147
+ hash[f] = doc.send(f) if doc.send("#{f}_changed?")
148
+ end
149
+ end
150
+
151
+ def set_lock(field, generic = false)
152
+ return unless use_transactions
153
+ model_name = doc.class.orderable_configs[field][:lock_collection].to_s.singularize.classify
154
+ model = Mongoid::Orderable::Models.const_get(model_name)
155
+ attrs = lock_scope(field, generic)
156
+ model.where(attrs).find_one_and_update(attrs, { upsert: true })
157
+ end
158
+
159
+ def lock_scope(field, generic = false)
160
+ sel = orderable_scope(field).selector
161
+ scope = ([collection_name] + (generic ? [field] : sel.to_a.flatten)).map(&:to_s).join('|')
162
+ { scope: scope }
163
+ end
164
+ end
165
+ end
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
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Orderable
5
+ class Installer
6
+ attr_reader :klass, :config
7
+
8
+ def initialize(klass, options = {})
9
+ @klass = klass
10
+ @config = Mongoid::Orderable::Configs::FieldConfig.new(klass, options).options
11
+ end
12
+
13
+ def setup
14
+ add_db_field
15
+ add_db_index if config[:index]
16
+ save_config
17
+ include_mixins
18
+ generate_all_helpers
19
+ end
20
+
21
+ protected
22
+
23
+ def field_name
24
+ config[:field_options][:as] || config[:field]
25
+ end
26
+
27
+ def order_scope
28
+ config[:scope]
29
+ end
30
+
31
+ def add_db_field
32
+ klass.field(config[:field], { type: Integer }.reverse_merge(config[:field_options]))
33
+ end
34
+
35
+ def add_db_index
36
+ spec = [[config[:field], 1]]
37
+ config[:scope].each {|field| spec.unshift([field, 1]) } if config[:scope].is_a?(Array)
38
+ klass.index(Hash[spec])
39
+ end
40
+
41
+ def save_config
42
+ klass.orderable_configs ||= {}
43
+ klass.orderable_configs = klass.orderable_configs.merge(field_name => config)
44
+ end
45
+
46
+ def include_mixins
47
+ klass.send :include, Mixins::Helpers
48
+ klass.send :include, Mixins::Callbacks
49
+ klass.send :include, Mixins::Movable
50
+ klass.send :include, Mixins::Listable
51
+ end
52
+
53
+ def generate_all_helpers
54
+ Generators::Scope.new(klass).generate(field_name, order_scope)
55
+ Generators::Position.new(klass).generate(field_name)
56
+ Generators::Movable.new(klass).generate(field_name)
57
+ Generators::Listable.new(klass).generate(field_name)
58
+ Generators::Helpers.new(klass).generate
59
+ Generators::LockCollection.new.generate(config[:lock_collection])
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Orderable
5
+ module Mixins
6
+ module Callbacks
7
+ extend ActiveSupport::Concern
8
+
9
+ ORDERABLE_TRANSACTION_KEY = :__mongoid_orderable_in_txn
10
+
11
+ included do
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
16
+
17
+ delegate :before_create,
18
+ :after_create,
19
+ :before_update,
20
+ :after_destroy,
21
+ to: :orderable_handler,
22
+ prefix: :orderable
23
+
24
+ protected
25
+
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
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end