mongoid_orderable 5.0.0 → 6.0.2

Sign up to get free protection for your applications and to get access to all the features.
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