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.
- checksums.yaml +5 -5
- data/CHANGELOG.md +77 -17
- data/LICENSE.txt +20 -0
- data/README.md +256 -127
- data/Rakefile +24 -6
- data/lib/config/locales/en.yml +12 -9
- data/lib/mongoid/orderable.rb +29 -22
- data/lib/mongoid/orderable/configs/field_config.rb +79 -0
- data/lib/mongoid/orderable/configs/global_config.rb +26 -0
- data/lib/mongoid/orderable/engine.rb +206 -0
- data/lib/mongoid/orderable/errors/invalid_target_position.rb +19 -18
- data/lib/mongoid/orderable/errors/transaction_failed.rb +20 -0
- data/lib/mongoid/orderable/generators/base.rb +21 -0
- data/lib/mongoid/orderable/generators/helpers.rb +29 -0
- data/lib/mongoid/orderable/generators/listable.rb +41 -0
- data/lib/mongoid/orderable/generators/lock_collection.rb +37 -0
- data/lib/mongoid/orderable/generators/movable.rb +62 -0
- data/lib/mongoid/orderable/generators/position.rb +26 -0
- data/lib/mongoid/orderable/generators/scope.rb +26 -0
- data/lib/mongoid/orderable/installer.rb +63 -0
- data/lib/mongoid/orderable/mixins/callbacks.rb +29 -0
- data/lib/mongoid/orderable/mixins/helpers.rb +39 -0
- data/lib/mongoid/orderable/mixins/listable.rb +49 -0
- data/lib/mongoid/orderable/mixins/movable.rb +60 -0
- data/lib/mongoid/orderable/version.rb +7 -0
- data/lib/mongoid_orderable.rb +29 -56
- data/spec/mongoid/orderable_spec.rb +1486 -1408
- data/spec/spec_helper.rb +21 -37
- metadata +62 -42
- data/.gitignore +0 -4
- data/.rspec +0 -1
- data/.rvmrc +0 -1
- data/.travis.yml +0 -20
- data/Gemfile +0 -15
- data/lib/mongoid/orderable/callbacks.rb +0 -75
- data/lib/mongoid/orderable/configuration.rb +0 -60
- data/lib/mongoid/orderable/errors.rb +0 -2
- data/lib/mongoid/orderable/errors/mongoid_orderable_error.rb +0 -14
- data/lib/mongoid/orderable/generator.rb +0 -34
- data/lib/mongoid/orderable/generator/helpers.rb +0 -29
- data/lib/mongoid/orderable/generator/listable.rb +0 -41
- data/lib/mongoid/orderable/generator/movable.rb +0 -62
- data/lib/mongoid/orderable/generator/position.rb +0 -26
- data/lib/mongoid/orderable/generator/scope.rb +0 -17
- data/lib/mongoid/orderable/helpers.rb +0 -50
- data/lib/mongoid/orderable/listable.rb +0 -49
- data/lib/mongoid/orderable/movable.rb +0 -58
- data/lib/mongoid/orderable/orderable_class.rb +0 -51
- data/lib/mongoid_orderable/mongoid/contexts/enumerable.rb +0 -15
- data/lib/mongoid_orderable/mongoid/contexts/mongo.rb +0 -18
- data/lib/mongoid_orderable/mongoid/contextual/memory.rb +0 -15
- data/lib/mongoid_orderable/mongoid/criteria.rb +0 -4
- data/lib/mongoid_orderable/version.rb +0 -3
- data/mongoid_orderable.gemspec +0 -25
data/Rakefile
CHANGED
@@ -1,6 +1,24 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
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]
|
data/lib/config/locales/en.yml
CHANGED
@@ -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
|
-
|
9
|
-
|
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."
|
data/lib/mongoid/orderable.rb
CHANGED
@@ -1,22 +1,29 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
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
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
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
|