statesman 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,39 @@
1
+ require "json"
2
+
3
+ module Statesman
4
+ module Adapters
5
+ class Memory
6
+ attr_reader :transition_class
7
+ attr_reader :history
8
+ attr_reader :parent_model
9
+
10
+ # We only accept mode as a parameter to maintain a consistent interface
11
+ # with other adapters which require it.
12
+ def initialize(transition_class, parent_model)
13
+ @history = []
14
+ @transition_class = transition_class
15
+ @parent_model = parent_model
16
+ end
17
+
18
+ def create(to, before_cbs, after_cbs, metadata = {})
19
+ transition = transition_class.new(to, next_sort_key, metadata)
20
+
21
+ before_cbs.each { |cb| cb.call(@parent_model, transition) }
22
+ @history << transition
23
+ after_cbs.each { |cb| cb.call(@parent_model, transition) }
24
+
25
+ transition
26
+ end
27
+
28
+ def last
29
+ @history.sort_by(&:sort_key).last
30
+ end
31
+
32
+ private
33
+
34
+ def next_sort_key
35
+ (last && last.sort_key + 10) || 0
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,34 @@
1
+ require "statesman/exceptions"
2
+
3
+ module Statesman
4
+ class Callback
5
+ attr_reader :from
6
+ attr_reader :to
7
+ attr_reader :callback
8
+
9
+ def initialize(from: nil, to: nil, callback: nil)
10
+ unless callback.respond_to?(:call)
11
+ raise InvalidCallbackError, "No callback passed"
12
+ end
13
+
14
+ @from = from
15
+ @to = to
16
+ @callback = callback
17
+ end
18
+
19
+ def call(*args)
20
+ callback.call(*args)
21
+ end
22
+
23
+ def applies_to?(from: nil, to: nil)
24
+ # rubocop:disable RedundantSelf
25
+ (self.from.nil? && self.to.nil?) ||
26
+ (from.nil? && to == self.to) ||
27
+ (self.from.nil? && to == self.to) ||
28
+ (from == self.from && to.nil?) ||
29
+ (from == self.from && self.to.nil?) ||
30
+ (from == self.from && to == self.to)
31
+ # rubocop:enable RedundantSelf
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,23 @@
1
+ require "json"
2
+ require "statesman/exceptions"
3
+
4
+ module Statesman
5
+ class Config
6
+ attr_reader :adapter_class
7
+
8
+ def initialize(block = nil)
9
+ instance_eval(&block) unless block.nil?
10
+ end
11
+
12
+ # rubocop:disable TrivialAccessors
13
+ def storage_adapter(adapter_class)
14
+ @adapter_class = adapter_class
15
+ end
16
+ # rubocop:enable TrivialAccessors
17
+
18
+ def transition_class(*args)
19
+ args.each { |klass| klass.serialize(:metadata, JSON) }
20
+ end
21
+
22
+ end
23
+ end
@@ -0,0 +1,8 @@
1
+ module Statesman
2
+ class InvalidStateError < StandardError; end
3
+ class InvalidTransitionError < StandardError; end
4
+ class InvalidCallbackError < StandardError; end
5
+ class GuardFailedError < StandardError; end
6
+ class TransitionFailedError < StandardError; end
7
+ class UnserializedMetadataError < StandardError; end
8
+ end
@@ -0,0 +1,15 @@
1
+ require "statesman/callback"
2
+ require "statesman/exceptions"
3
+
4
+ module Statesman
5
+ class Guard < Callback
6
+
7
+ def call(*args)
8
+ unless super(*args)
9
+ raise GuardFailedError,
10
+ "Guard on transition from: '#{from}' to '#{to}' returned false"
11
+ end
12
+ end
13
+
14
+ end
15
+ end
@@ -0,0 +1,231 @@
1
+ require "statesman/version"
2
+ require "statesman/exceptions"
3
+ require "statesman/guard"
4
+ require "statesman/callback"
5
+ require "statesman/transition"
6
+
7
+ module Statesman
8
+ # The main module, that should be `extend`ed in to state machine classes.
9
+ module Machine
10
+ def self.included(base)
11
+ base.extend(ClassMethods)
12
+ base.send(:attr_reader, :object)
13
+ end
14
+
15
+ module ClassMethods
16
+ attr_reader :initial_state
17
+
18
+ def states
19
+ @states ||= []
20
+ end
21
+
22
+ def state(name, initial: false)
23
+ name = name.to_s
24
+ if initial
25
+ validate_initial_state(name)
26
+ @initial_state = name
27
+ end
28
+ states << name
29
+ end
30
+
31
+ def successors
32
+ @successors ||= {}
33
+ end
34
+
35
+ def before_callbacks
36
+ @before_callbacks ||= []
37
+ end
38
+
39
+ def after_callbacks
40
+ @after_callbacks ||= []
41
+ end
42
+
43
+ def guards
44
+ @guards ||= []
45
+ end
46
+
47
+ def transition(from: nil, to: nil)
48
+ from = to_s_or_nil(from)
49
+ to = Array(to).map { |item| to_s_or_nil(item) }
50
+
51
+ successors[from] ||= []
52
+
53
+ ([from] + to).each { |state| validate_state(state) }
54
+
55
+ successors[from] += to
56
+ end
57
+
58
+ def before_transition(from: nil, to: nil, &block)
59
+ from = to_s_or_nil(from)
60
+ to = to_s_or_nil(to)
61
+
62
+ validate_callback_condition(from: from, to: to)
63
+ before_callbacks << Callback.new(from: from, to: to, callback: block)
64
+ end
65
+
66
+ def after_transition(from: nil, to: nil, &block)
67
+ from = to_s_or_nil(from)
68
+ to = to_s_or_nil(to)
69
+
70
+ validate_callback_condition(from: from, to: to)
71
+ after_callbacks << Callback.new(from: from, to: to, callback: block)
72
+ end
73
+
74
+ def guard_transition(from: nil, to: nil, &block)
75
+ from = to_s_or_nil(from)
76
+ to = to_s_or_nil(to)
77
+
78
+ validate_callback_condition(from: from, to: to)
79
+ guards << Guard.new(from: from, to: to, callback: block)
80
+ end
81
+
82
+ def validate_callback_condition(from: nil, to: nil)
83
+ from = to_s_or_nil(from)
84
+ to = to_s_or_nil(to)
85
+
86
+ [from, to].compact.each { |state| validate_state(state) }
87
+ return if from.nil? && to.nil?
88
+
89
+ validate_not_from_terminal_state(from)
90
+ validate_not_to_initial_state(to)
91
+
92
+ return if from.nil? || to.nil?
93
+
94
+ validate_from_and_to_state(from, to)
95
+ end
96
+
97
+ # Check that the 'from' state is not terminal
98
+ def validate_not_from_terminal_state(from)
99
+ unless from.nil? || successors.keys.include?(from)
100
+ raise InvalidTransitionError,
101
+ "Cannont transition away from terminal state '#{from}'"
102
+ end
103
+ end
104
+
105
+ # Check that the 'to' state is not initial
106
+ def validate_not_to_initial_state(to)
107
+ unless to.nil? || successors.values.flatten.include?(to)
108
+ raise InvalidTransitionError,
109
+ "Cannont transition to initial state '#{to}'"
110
+ end
111
+ end
112
+
113
+ # Check that the transition is valid when 'from' and 'to' are given
114
+ def validate_from_and_to_state(from, to)
115
+ unless successors.fetch(from, []).include?(to)
116
+ raise InvalidTransitionError,
117
+ "Cannot transition from '#{from}' to '#{to}'"
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ def validate_state(state)
124
+ unless states.include?(state.to_s)
125
+ raise InvalidStateError, "Invalid state '#{state}'"
126
+ end
127
+ end
128
+
129
+ def validate_initial_state(state)
130
+ unless initial_state.nil?
131
+ raise InvalidStateError, "Cannot set initial state to '#{state}', " +
132
+ "already defined as #{initial_state}."
133
+ end
134
+ end
135
+
136
+ def to_s_or_nil(input)
137
+ input.nil? ? input : input.to_s
138
+ end
139
+ end
140
+
141
+ def initialize(object, transition_class: Statesman::Transition)
142
+ @object = object
143
+ @storage_adapter = Statesman.storage_adapter.new(transition_class,
144
+ object)
145
+ end
146
+
147
+ def current_state
148
+ last_action = last_transition
149
+ last_action ? last_action.to_state : self.class.initial_state
150
+ end
151
+
152
+ def last_transition
153
+ @storage_adapter.last
154
+ end
155
+
156
+ def can_transition_to?(new_state, metadata = nil)
157
+ validate_transition(from: current_state,
158
+ to: new_state,
159
+ metadata: metadata)
160
+ true
161
+ rescue TransitionFailedError, GuardFailedError
162
+ false
163
+ end
164
+
165
+ def history
166
+ @storage_adapter.history
167
+ end
168
+
169
+ def transition_to!(new_state, metadata = nil)
170
+ initial_state = current_state
171
+ new_state = new_state.to_s
172
+
173
+ validate_transition(from: initial_state,
174
+ to: new_state,
175
+ metadata: metadata)
176
+
177
+ before_cbs = before_callbacks_for(from: initial_state, to: new_state)
178
+ after_cbs = after_callbacks_for(from: initial_state, to: new_state)
179
+
180
+ @storage_adapter.create(new_state, before_cbs, after_cbs, metadata)
181
+
182
+ true
183
+ end
184
+
185
+ def transition_to(new_state, metadata = nil)
186
+ self.transition_to!(new_state, metadata)
187
+ rescue
188
+ false
189
+ end
190
+
191
+ private
192
+
193
+ def guards_for(from: nil, to: nil)
194
+ select_callbacks_for(self.class.guards, from: from, to: to)
195
+ end
196
+
197
+ def before_callbacks_for(from: nil, to: nil)
198
+ select_callbacks_for(self.class.before_callbacks, from: from, to: to)
199
+ end
200
+
201
+ def after_callbacks_for(from: nil, to: nil)
202
+ select_callbacks_for(self.class.after_callbacks, from: from, to: to)
203
+ end
204
+
205
+ def select_callbacks_for(callbacks, from: nil, to: nil)
206
+ from = to_s_or_nil(from)
207
+ to = to_s_or_nil(to)
208
+ callbacks.select { |callback| callback.applies_to?(from: from, to: to) }
209
+ end
210
+
211
+ def validate_transition(from: nil, to: nil, metadata: nil)
212
+ from = to_s_or_nil(from)
213
+ to = to_s_or_nil(to)
214
+
215
+ # Call all guards, they raise exceptions if they fail
216
+ guards_for(from: from, to: to).each do |guard|
217
+ guard.call(@object, last_transition, metadata)
218
+ end
219
+
220
+ successors = self.class.successors[from] || []
221
+ unless successors.include?(to)
222
+ raise TransitionFailedError,
223
+ "Cannot transition from '#{from}' to '#{to}'"
224
+ end
225
+ end
226
+
227
+ def to_s_or_nil(input)
228
+ input.nil? ? input : input.to_s
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,15 @@
1
+ module Statesman
2
+ class Transition
3
+ attr_accessor :created_at
4
+ attr_accessor :to_state
5
+ attr_accessor :sort_key
6
+ attr_accessor :metadata
7
+
8
+ def initialize(to, sort_key, metadata = nil)
9
+ @created_at = Time.now
10
+ @to_state = to
11
+ @sort_key = sort_key
12
+ @metadata = metadata
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,3 @@
1
+ module Statesman
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,33 @@
1
+ require "statesman"
2
+ require "sqlite3"
3
+ require "active_record"
4
+ require "support/active_record"
5
+
6
+ RSpec.configure do |config|
7
+ config.expect_with :rspec do |c|
8
+ c.syntax = :expect
9
+ end
10
+
11
+ config.order = 'random'
12
+
13
+ config.before(:each) do
14
+ # Connect to & cleanup test database
15
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3",
16
+ database: DB.to_s)
17
+
18
+ %w(my_models my_model_transitions).each do |table_name|
19
+ sql = "DROP TABLE IF EXISTS #{table_name};"
20
+ ActiveRecord::Base.connection.execute(sql)
21
+ end
22
+
23
+ def prepare_model_table
24
+ silence_stream(STDOUT) { CreateMyModelMigration.migrate(:up) }
25
+ end
26
+
27
+ def prepare_transitions_table
28
+ silence_stream(STDOUT) { CreateMyModelTransitionMigration.migrate(:up) }
29
+ end
30
+ end
31
+
32
+ config.after(:each) { DB.delete if DB.exist? }
33
+ end
@@ -0,0 +1,50 @@
1
+ require "spec_helper"
2
+ require "statesman/adapters/shared_examples"
3
+ require "statesman/exceptions"
4
+
5
+ describe Statesman::Adapters::ActiveRecord do
6
+ before do
7
+ prepare_model_table
8
+ prepare_transitions_table
9
+ end
10
+
11
+ before { MyModelTransition.serialize(:metadata, JSON) }
12
+
13
+ let(:model) { MyModel.create(current_state: :pending) }
14
+ it_behaves_like "an adapter", described_class, MyModelTransition
15
+
16
+ describe "#initialize" do
17
+ context "with unserialized metadata" do
18
+ before { MyModelTransition.stub(serialized_attributes: {}) }
19
+
20
+ it "raises an exception if metadata is not serialized" do
21
+ expect do
22
+ described_class.new(MyModelTransition, MyModel)
23
+ end.to raise_exception(Statesman::UnserializedMetadataError)
24
+ end
25
+ end
26
+ end
27
+
28
+ describe "#last" do
29
+ let(:adapter) { described_class.new(MyModelTransition, model) }
30
+
31
+ context "with a previously looked up transition" do
32
+ before do
33
+ adapter.create(:y, [], [])
34
+ adapter.last
35
+ end
36
+
37
+ it "caches the transition" do
38
+ MyModel.any_instance.should_receive(:my_model_transitions).never
39
+ adapter.last
40
+ end
41
+
42
+ context "and a new transition" do
43
+ before { adapter.create(:z, [], []) }
44
+ it "retrieves the new transition from the database" do
45
+ expect(adapter.last.to_state).to eq("z")
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end