statesman 0.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.
@@ -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