statesman 3.5.0 → 7.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.circleci/config.yml +49 -250
- data/.rubocop.yml +1 -1
- data/.rubocop_todo.yml +26 -6
- data/CHANGELOG.md +106 -0
- data/Gemfile +10 -4
- data/Guardfile +2 -0
- data/README.md +78 -48
- data/Rakefile +2 -4
- data/lib/generators/statesman/active_record_transition_generator.rb +2 -0
- data/lib/generators/statesman/generator_helpers.rb +2 -0
- data/lib/generators/statesman/migration_generator.rb +2 -0
- data/lib/statesman.rb +14 -4
- data/lib/statesman/adapters/active_record.rb +259 -37
- data/lib/statesman/adapters/active_record_queries.rb +100 -36
- data/lib/statesman/adapters/active_record_transition.rb +2 -0
- data/lib/statesman/adapters/memory.rb +2 -0
- data/lib/statesman/adapters/memory_transition.rb +2 -0
- data/lib/statesman/callback.rb +2 -0
- data/lib/statesman/config.rb +28 -0
- data/lib/statesman/exceptions.rb +34 -2
- data/lib/statesman/guard.rb +3 -4
- data/lib/statesman/machine.rb +29 -7
- data/lib/statesman/railtie.rb +2 -0
- data/lib/statesman/utils.rb +2 -0
- data/lib/statesman/version.rb +3 -1
- data/lib/tasks/statesman.rake +3 -1
- data/spec/fixtures/add_constraints_to_most_recent_for_bacon_transitions_with_partial_index.rb +2 -0
- data/spec/fixtures/add_constraints_to_most_recent_for_bacon_transitions_without_partial_index.rb +2 -0
- data/spec/fixtures/add_most_recent_to_bacon_transitions.rb +2 -0
- data/spec/generators/statesman/active_record_transition_generator_spec.rb +2 -0
- data/spec/generators/statesman/migration_generator_spec.rb +2 -0
- data/spec/spec_helper.rb +3 -30
- data/spec/statesman/adapters/active_record_queries_spec.rb +167 -91
- data/spec/statesman/adapters/active_record_spec.rb +15 -1
- data/spec/statesman/adapters/active_record_transition_spec.rb +2 -0
- data/spec/statesman/adapters/memory_spec.rb +2 -0
- data/spec/statesman/adapters/memory_transition_spec.rb +2 -0
- data/spec/statesman/adapters/shared_examples.rb +2 -0
- data/spec/statesman/callback_spec.rb +2 -0
- data/spec/statesman/config_spec.rb +2 -0
- data/spec/statesman/exceptions_spec.rb +88 -0
- data/spec/statesman/guard_spec.rb +2 -0
- data/spec/statesman/machine_spec.rb +79 -4
- data/spec/statesman/utils_spec.rb +2 -0
- data/spec/support/active_record.rb +9 -12
- data/spec/support/generators_shared_examples.rb +2 -0
- data/statesman.gemspec +19 -7
- metadata +40 -32
- data/lib/generators/statesman/mongoid_transition_generator.rb +0 -25
- data/lib/generators/statesman/templates/mongoid_transition_model.rb.erb +0 -14
- data/lib/statesman/adapters/mongoid.rb +0 -66
- data/lib/statesman/adapters/mongoid_transition.rb +0 -10
- data/spec/generators/statesman/mongoid_transition_generator_spec.rb +0 -23
- data/spec/statesman/adapters/mongoid_spec.rb +0 -86
- data/spec/support/mongoid.rb +0 -28
@@ -1,51 +1,124 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Statesman
|
2
4
|
module Adapters
|
3
5
|
module ActiveRecordQueries
|
6
|
+
def self.check_missing_methods!(base)
|
7
|
+
missing_methods = %i[transition_class initial_state].
|
8
|
+
reject { |m| base.respond_to?(m) }
|
9
|
+
return if missing_methods.none?
|
10
|
+
|
11
|
+
raise NotImplementedError,
|
12
|
+
"#{missing_methods.join(', ')} method(s) should be defined on " \
|
13
|
+
"the model. Alternatively, use the new form of `include " \
|
14
|
+
"Statesman::Adapters::ActiveRecordQueries[" \
|
15
|
+
"transition_class: MyTransition, " \
|
16
|
+
"initial_state: :some_state]`"
|
17
|
+
end
|
18
|
+
|
4
19
|
def self.included(base)
|
5
|
-
base
|
20
|
+
check_missing_methods!(base)
|
21
|
+
|
22
|
+
base.include(
|
23
|
+
ClassMethods.new(
|
24
|
+
transition_class: base.transition_class,
|
25
|
+
initial_state: base.initial_state,
|
26
|
+
most_recent_transition_alias: base.try(:most_recent_transition_alias),
|
27
|
+
transition_name: base.try(:transition_name),
|
28
|
+
),
|
29
|
+
)
|
6
30
|
end
|
7
31
|
|
8
|
-
|
9
|
-
|
10
|
-
|
32
|
+
def self.[](**args)
|
33
|
+
ClassMethods.new(**args)
|
34
|
+
end
|
11
35
|
|
12
|
-
|
13
|
-
|
36
|
+
class ClassMethods < Module
|
37
|
+
def initialize(**args)
|
38
|
+
@args = args
|
14
39
|
end
|
15
40
|
|
16
|
-
def
|
17
|
-
|
41
|
+
def included(base)
|
42
|
+
ensure_inheritance(base)
|
18
43
|
|
19
|
-
|
20
|
-
where("NOT (#{states_where(most_recent_transition_alias, states)})",
|
21
|
-
states)
|
22
|
-
end
|
44
|
+
query_builder = QueryBuilder.new(base, **@args)
|
23
45
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
46
|
+
base.define_singleton_method(:most_recent_transition_join) do
|
47
|
+
query_builder.most_recent_transition_join
|
48
|
+
end
|
49
|
+
|
50
|
+
define_in_state(base, query_builder)
|
51
|
+
define_not_in_state(base, query_builder)
|
29
52
|
end
|
30
53
|
|
31
54
|
private
|
32
55
|
|
33
|
-
def
|
34
|
-
|
35
|
-
|
56
|
+
def ensure_inheritance(base)
|
57
|
+
klass = self
|
58
|
+
existing_inherited = base.method(:inherited)
|
59
|
+
base.define_singleton_method(:inherited) do |subclass|
|
60
|
+
existing_inherited.call(subclass)
|
61
|
+
subclass.send(:include, klass)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def define_in_state(base, query_builder)
|
66
|
+
base.define_singleton_method(:in_state) do |*states|
|
67
|
+
states = states.flatten
|
68
|
+
|
69
|
+
joins(most_recent_transition_join).
|
70
|
+
where(query_builder.states_where(states), states)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def define_not_in_state(base, query_builder)
|
75
|
+
base.define_singleton_method(:not_in_state) do |*states|
|
76
|
+
states = states.flatten
|
77
|
+
|
78
|
+
joins(most_recent_transition_join).
|
79
|
+
where("NOT (#{query_builder.states_where(states)})", states)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
class QueryBuilder
|
85
|
+
def initialize(model, transition_class:, initial_state:,
|
86
|
+
most_recent_transition_alias: nil,
|
87
|
+
transition_name: nil)
|
88
|
+
@model = model
|
89
|
+
@transition_class = transition_class
|
90
|
+
@initial_state = initial_state
|
91
|
+
@most_recent_transition_alias = most_recent_transition_alias
|
92
|
+
@transition_name = transition_name
|
36
93
|
end
|
37
94
|
|
38
|
-
def
|
39
|
-
|
40
|
-
|
95
|
+
def states_where(states)
|
96
|
+
if initial_state.to_s.in?(states.map(&:to_s))
|
97
|
+
"#{most_recent_transition_alias}.to_state IN (?) OR " \
|
98
|
+
"#{most_recent_transition_alias}.to_state IS NULL"
|
99
|
+
else
|
100
|
+
"#{most_recent_transition_alias}.to_state IN (?) AND " \
|
101
|
+
"#{most_recent_transition_alias}.to_state IS NOT NULL"
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def most_recent_transition_join
|
106
|
+
"LEFT OUTER JOIN #{model_table} AS #{most_recent_transition_alias} " \
|
107
|
+
"ON #{model.table_name}.id = " \
|
108
|
+
"#{most_recent_transition_alias}.#{model_foreign_key} " \
|
109
|
+
"AND #{most_recent_transition_alias}.most_recent = #{db_true}"
|
41
110
|
end
|
42
111
|
|
112
|
+
private
|
113
|
+
|
114
|
+
attr_reader :model, :transition_class, :initial_state
|
115
|
+
|
43
116
|
def transition_name
|
44
|
-
transition_class.table_name.to_sym
|
117
|
+
@transition_name || transition_class.table_name.to_sym
|
45
118
|
end
|
46
119
|
|
47
120
|
def transition_reflection
|
48
|
-
reflect_on_all_associations(:has_many).each do |value|
|
121
|
+
model.reflect_on_all_associations(:has_many).each do |value|
|
49
122
|
return value if value.klass == transition_class
|
50
123
|
end
|
51
124
|
|
@@ -62,18 +135,9 @@ module Statesman
|
|
62
135
|
transition_reflection.table_name
|
63
136
|
end
|
64
137
|
|
65
|
-
def states_where(temporary_table_name, states)
|
66
|
-
if initial_state.to_s.in?(states.map(&:to_s))
|
67
|
-
"#{temporary_table_name}.to_state IN (?) OR " \
|
68
|
-
"#{temporary_table_name}.to_state IS NULL"
|
69
|
-
else
|
70
|
-
"#{temporary_table_name}.to_state IN (?) AND " \
|
71
|
-
"#{temporary_table_name}.to_state IS NOT NULL"
|
72
|
-
end
|
73
|
-
end
|
74
|
-
|
75
138
|
def most_recent_transition_alias
|
76
|
-
|
139
|
+
@most_recent_transition_alias ||
|
140
|
+
"most_recent_#{transition_name.to_s.singularize}"
|
77
141
|
end
|
78
142
|
|
79
143
|
def db_true
|
data/lib/statesman/callback.rb
CHANGED
data/lib/statesman/config.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "json"
|
2
4
|
require_relative "exceptions"
|
3
5
|
|
@@ -12,5 +14,31 @@ module Statesman
|
|
12
14
|
def storage_adapter(adapter_class)
|
13
15
|
@adapter_class = adapter_class
|
14
16
|
end
|
17
|
+
|
18
|
+
def mysql_gaplock_protection?
|
19
|
+
return @mysql_gaplock_protection unless @mysql_gaplock_protection.nil?
|
20
|
+
|
21
|
+
# If our adapter class suggests we're using mysql, enable gaplock protection by
|
22
|
+
# default.
|
23
|
+
enable_mysql_gaplock_protection if mysql_adapter?(adapter_class)
|
24
|
+
@mysql_gaplock_protection
|
25
|
+
end
|
26
|
+
|
27
|
+
def enable_mysql_gaplock_protection
|
28
|
+
@mysql_gaplock_protection = true
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def mysql_adapter?(adapter_class)
|
34
|
+
adapter_name = adapter_name(adapter_class)
|
35
|
+
return false unless adapter_name
|
36
|
+
|
37
|
+
adapter_name.start_with?("mysql")
|
38
|
+
end
|
39
|
+
|
40
|
+
def adapter_name(adapter_class)
|
41
|
+
adapter_class.respond_to?(:adapter_name) && adapter_class&.adapter_name
|
42
|
+
end
|
15
43
|
end
|
16
44
|
end
|
data/lib/statesman/exceptions.rb
CHANGED
@@ -1,12 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Statesman
|
2
4
|
class InvalidStateError < StandardError; end
|
3
5
|
class InvalidTransitionError < StandardError; end
|
4
6
|
class InvalidCallbackError < StandardError; end
|
5
|
-
class GuardFailedError < StandardError; end
|
6
|
-
class TransitionFailedError < StandardError; end
|
7
7
|
class TransitionConflictError < StandardError; end
|
8
8
|
class MissingTransitionAssociation < StandardError; end
|
9
9
|
|
10
|
+
class TransitionFailedError < StandardError
|
11
|
+
def initialize(from, to)
|
12
|
+
@from = from
|
13
|
+
@to = to
|
14
|
+
super(_message)
|
15
|
+
end
|
16
|
+
|
17
|
+
attr_reader :from, :to
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def _message
|
22
|
+
"Cannot transition from '#{from}' to '#{to}'"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
class GuardFailedError < StandardError
|
27
|
+
def initialize(from, to)
|
28
|
+
@from = from
|
29
|
+
@to = to
|
30
|
+
super(_message)
|
31
|
+
end
|
32
|
+
|
33
|
+
attr_reader :from, :to
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def _message
|
38
|
+
"Guard on transition from: '#{from}' to '#{to}' returned false"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
10
42
|
class UnserializedMetadataError < StandardError
|
11
43
|
def initialize(transition_class_name)
|
12
44
|
super(_message(transition_class_name))
|
data/lib/statesman/guard.rb
CHANGED
@@ -1,13 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require_relative "callback"
|
2
4
|
require_relative "exceptions"
|
3
5
|
|
4
6
|
module Statesman
|
5
7
|
class Guard < Callback
|
6
8
|
def call(*args)
|
7
|
-
unless super(*args)
|
8
|
-
raise GuardFailedError,
|
9
|
-
"Guard on transition from: '#{from}' to '#{to}' returned false"
|
10
|
-
end
|
9
|
+
raise GuardFailedError.new(from, to) unless super(*args)
|
11
10
|
end
|
12
11
|
end
|
13
12
|
end
|
data/lib/statesman/machine.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require_relative "version"
|
2
4
|
require_relative "exceptions"
|
3
5
|
require_relative "guard"
|
@@ -46,10 +48,12 @@ module Statesman
|
|
46
48
|
|
47
49
|
def callbacks
|
48
50
|
@callbacks ||= {
|
49
|
-
before:
|
50
|
-
after:
|
51
|
+
before: [],
|
52
|
+
after: [],
|
53
|
+
after_transition_failure: [],
|
54
|
+
after_guard_failure: [],
|
51
55
|
after_commit: [],
|
52
|
-
guards:
|
56
|
+
guards: [],
|
53
57
|
}
|
54
58
|
end
|
55
59
|
|
@@ -83,6 +87,16 @@ module Statesman
|
|
83
87
|
from: options[:from], to: options[:to], &block)
|
84
88
|
end
|
85
89
|
|
90
|
+
def after_transition_failure(options = {}, &block)
|
91
|
+
add_callback(callback_type: :after_transition_failure, callback_class: Callback,
|
92
|
+
from: options[:from], to: options[:to], &block)
|
93
|
+
end
|
94
|
+
|
95
|
+
def after_guard_failure(options = {}, &block)
|
96
|
+
add_callback(callback_type: :after_guard_failure, callback_class: Callback,
|
97
|
+
from: options[:from], to: options[:to], &block)
|
98
|
+
end
|
99
|
+
|
86
100
|
def validate_callback_condition(options = { from: nil, to: nil })
|
87
101
|
from = to_s_or_nil(options[:from])
|
88
102
|
to = array_to_s_or_nil(options[:to])
|
@@ -219,6 +233,17 @@ module Statesman
|
|
219
233
|
@storage_adapter.create(initial_state, new_state, metadata)
|
220
234
|
|
221
235
|
true
|
236
|
+
rescue TransitionFailedError => e
|
237
|
+
execute_on_failure(:after_transition_failure, initial_state, new_state, e)
|
238
|
+
raise
|
239
|
+
rescue GuardFailedError => e
|
240
|
+
execute_on_failure(:after_guard_failure, initial_state, new_state, e)
|
241
|
+
raise
|
242
|
+
end
|
243
|
+
|
244
|
+
def execute_on_failure(phase, initial_state, new_state, exception)
|
245
|
+
callbacks = callbacks_for(phase, from: initial_state, to: new_state)
|
246
|
+
callbacks.each { |cb| cb.call(@object, exception) }
|
222
247
|
end
|
223
248
|
|
224
249
|
def execute(phase, initial_state, new_state, transition)
|
@@ -265,10 +290,7 @@ module Statesman
|
|
265
290
|
to = to_s_or_nil(options[:to])
|
266
291
|
|
267
292
|
successors = self.class.successors[from] || []
|
268
|
-
unless successors.include?(to)
|
269
|
-
raise TransitionFailedError,
|
270
|
-
"Cannot transition from '#{from}' to '#{to}'"
|
271
|
-
end
|
293
|
+
raise TransitionFailedError.new(from, to) unless successors.include?(to)
|
272
294
|
|
273
295
|
# Call all guards, they raise exceptions if they fail
|
274
296
|
guards_for(from: from, to: to).each do |guard|
|
data/lib/statesman/railtie.rb
CHANGED
data/lib/statesman/utils.rb
CHANGED
data/lib/statesman/version.rb
CHANGED
data/lib/tasks/statesman.rake
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
namespace :statesman do
|
2
4
|
desc "Set most_recent to false for old transitions and to true for the "\
|
3
5
|
"latest one. Safe to re-run"
|
@@ -19,7 +21,7 @@ namespace :statesman do
|
|
19
21
|
batch_size = 500
|
20
22
|
|
21
23
|
parent_class.find_in_batches(batch_size: batch_size) do |models|
|
22
|
-
ActiveRecord::Base.transaction do
|
24
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
23
25
|
if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?
|
24
26
|
# Set all transitions' most_recent to FALSE
|
25
27
|
transition_class.where(parent_fk => models.map(&:id)).
|
data/spec/spec_helper.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "statesman"
|
2
4
|
require "sqlite3"
|
3
5
|
require "mysql2"
|
@@ -20,36 +22,7 @@ RSpec.configure do |config|
|
|
20
22
|
config.order = "random"
|
21
23
|
|
22
24
|
def connection_failure
|
23
|
-
if defined?(Moped)
|
24
|
-
Moped::Errors::ConnectionFailure
|
25
|
-
else
|
26
|
-
Mongo::Error::NoServerAvailable
|
27
|
-
end
|
28
|
-
end
|
29
|
-
|
30
|
-
if config.exclusion_filter[:mongo]
|
31
|
-
puts "Skipping Mongo tests"
|
32
|
-
else
|
33
|
-
require "mongoid"
|
34
|
-
|
35
|
-
# Try a mongo connection at the start of the suite and raise if it fails
|
36
|
-
begin
|
37
|
-
Mongoid.configure do |mongo_config|
|
38
|
-
if defined?(Moped)
|
39
|
-
mongo_config.connect_to("statesman_test")
|
40
|
-
mongo_config.sessions["default"]["options"]["max_retries"] = 2
|
41
|
-
else
|
42
|
-
mongo_config.connect_to("statesman_test", server_selection_timeout: 2)
|
43
|
-
end
|
44
|
-
end
|
45
|
-
# Attempting a mongo operation will trigger 2 retries then throw an
|
46
|
-
# exception if mongo is not running.
|
47
|
-
Mongoid.purge!
|
48
|
-
rescue connection_failure => error
|
49
|
-
puts "The spec suite requires MongoDB to be installed and running locally"
|
50
|
-
puts "Mongo dependent specs can be filtered with rspec --tag '~mongo'"
|
51
|
-
raise(error)
|
52
|
-
end
|
25
|
+
Moped::Errors::ConnectionFailure if defined?(Moped)
|
53
26
|
end
|
54
27
|
|
55
28
|
if config.exclusion_filter[:active_record]
|