flow_machine 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,162 @@
1
+ require "active_support/core_ext/module/delegation"
2
+ require "active_support/inflector"
3
+
4
+ class FlowMachine::WorkflowState
5
+ attr_reader :workflow
6
+ attr_accessor :guard_errors
7
+
8
+ delegate :object, :options, to: :workflow
9
+
10
+ module ClassMethods
11
+ attr_accessor :state_callbacks
12
+ attr_accessor :expose_to_workflow_methods
13
+
14
+ def state_name
15
+ name.demodulize.sub(/State\z/, '').underscore.to_sym
16
+ end
17
+
18
+ # Maintains a list of methods that should be exposed to the workflow
19
+ # the workflow is responsible for reading this list
20
+ def expose_to_workflow(name)
21
+ self.expose_to_workflow_methods ||= []
22
+ self.expose_to_workflow_methods << name
23
+ end
24
+
25
+ def event(name, options = {}, &block)
26
+ define_may_event(name, options)
27
+ define_event(name, options, &block)
28
+ define_event_bang(name)
29
+ end
30
+
31
+ private
32
+
33
+ def define_event(name, options, &block)
34
+ define_method name do |*args|
35
+ return false unless self.send("may_#{name}?")
36
+ instance_exec *args, &block
37
+ end
38
+ expose_to_workflow name
39
+ end
40
+
41
+ def define_may_event(name, options)
42
+ define_method "may_#{name}?" do
43
+ run_guard_methods([*options[:guard]])
44
+ end
45
+ expose_to_workflow "may_#{name}?"
46
+ end
47
+
48
+ def define_event_bang(name)
49
+ define_method "#{name}!" do |*args|
50
+ workflow.persist if self.send(name, *args)
51
+ end
52
+ expose_to_workflow "#{name}!"
53
+ end
54
+ end
55
+ extend ClassMethods
56
+
57
+ # Callbacks may be a symbol method name on the state, workflow, or underlying object,
58
+ # and will look for that method on those objects in that order. You may also
59
+ # use a block.
60
+ # Callbacks will accept :if and :unless options, which also may be method name
61
+ # symbols or blocks. The option accepts an array meaning all methods must return
62
+ # true (for if) and false (for unless)
63
+ #
64
+ # class ExampleState < Workflow::State
65
+ # on_enter :some_method, if: :allowed?
66
+ # after_enter :after_enter_method, if: [:this_is_true?, :and_this_is_true?]
67
+ # before_change(:field_name) { do_something }
68
+ # end
69
+ #
70
+ module CallbackDsl
71
+ # Called when the workflow `transition`s to the state
72
+ def on_enter(*args, &block)
73
+ add_callback(:on_enter, FlowMachine::StateCallback.new(*args, &block))
74
+ end
75
+
76
+ # Called after `persist` when the workflow transitioned into this state
77
+ def after_enter(*args, &block)
78
+ add_callback(:after_enter, FlowMachine::StateCallback.new(*args, &block))
79
+ end
80
+
81
+ # Happens before persistence if the field on the object has changed
82
+ def before_change(field, *args, &block)
83
+ add_callback(:before_change, FlowMachine::ChangeCallback.new(field, *args, &block))
84
+ end
85
+
86
+ # Happens after persistence if the field on the object has changed
87
+ def after_change(field, *args, &block)
88
+ add_callback(:after_change, FlowMachine::ChangeCallback.new(field, *args, &block))
89
+ end
90
+
91
+ private
92
+
93
+ def add_callback(hook, callback)
94
+ self.state_callbacks ||= {}
95
+ state_callbacks[hook] ||= []
96
+ state_callbacks[hook] << callback
97
+ end
98
+ end
99
+ extend CallbackDsl
100
+
101
+ def initialize(workflow)
102
+ @workflow = workflow
103
+ @guard_errors = []
104
+ end
105
+
106
+ def fire_callback_list(callbacks, changes = {})
107
+ callbacks.each do |callback|
108
+ callback.call(self, changes)
109
+ end
110
+ end
111
+
112
+ def fire_callbacks(event, changes = {})
113
+ return unless self.class.state_callbacks.try(:[], event)
114
+ fire_callback_list self.class.state_callbacks[event], changes
115
+ end
116
+
117
+ # Allows method calls to fallback up the object chain so
118
+ # guards and other methods can be defined on the object or workflow
119
+ # as well as the state
120
+ def run_workflow_method(method_name, *args, &block)
121
+ if target = object_chain(method_name)
122
+ target.send(method_name, *args, &block)
123
+ else
124
+ raise NoMethodError.new("undefined method #{method_name}", method_name)
125
+ end
126
+ end
127
+
128
+ def transition(options = {})
129
+ workflow.transition(options).tap do |new_state|
130
+ new_state.fire_callbacks(:on_enter) if new_state
131
+ end
132
+ end
133
+
134
+ def name
135
+ self.class.state_name
136
+ end
137
+
138
+ def ==(other)
139
+ self.class == other.class
140
+ end
141
+
142
+ private
143
+
144
+ def run_guard_methods(guard_methods)
145
+ self.guard_errors = []
146
+ # Use inject to ensure that all guard methods are run.
147
+ # all? short circuits on first false value
148
+ guard_methods.inject(true) do |valid, guard_method|
149
+ if self.run_workflow_method(guard_method)
150
+ valid
151
+ else
152
+ self.guard_errors << guard_method
153
+ false
154
+ end
155
+ end
156
+ #
157
+ end
158
+
159
+ def object_chain(method_name)
160
+ [self, workflow, object].find { |o| o.respond_to?(method_name, true) }
161
+ end
162
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :workflow do
3
+ # # Task goes here
4
+ # end
@@ -0,0 +1,48 @@
1
+ RSpec.describe FlowMachine::Factory do
2
+ class TestClass; end
3
+
4
+ class TestClassWorkflow
5
+ include FlowMachine::Workflow
6
+ end
7
+
8
+ describe '.workflow_class_for' do
9
+ subject(:workflow_class) { described_class.workflow_class_for(target) }
10
+
11
+ describe 'with a class' do
12
+ let(:target) { TestClass }
13
+ it { should eq(TestClassWorkflow) }
14
+ end
15
+
16
+ describe 'with an object' do
17
+ let(:target) { TestClass.new }
18
+ it { should eq(TestClassWorkflow) }
19
+ end
20
+ end
21
+
22
+ describe '.workflow_for' do
23
+ subject(:workflow) { described_class.workflow_for(target) }
24
+ class SomeNewClass; end
25
+
26
+ describe 'not found' do
27
+ let(:target) { SomeNewClass.new }
28
+ it { should be_nil }
29
+ end
30
+
31
+ describe 'with an object' do
32
+ let(:target) { TestClass.new }
33
+ it { should be_an_instance_of(TestClassWorkflow) }
34
+ its(:object) { should eq(target) }
35
+ end
36
+
37
+ describe 'with an array of objects' do
38
+ let(:target) { [TestClass.new, TestClass.new] }
39
+ it { should match [an_instance_of(TestClassWorkflow), an_instance_of(TestClassWorkflow)] }
40
+ end
41
+ end
42
+
43
+ describe '.workflow_for_collection' do
44
+ subject(:result) { described_class.workflow_for_collection(target) }
45
+ let(:target) { [TestClass.new, TestClass.new] }
46
+ it { should match [an_instance_of(TestClassWorkflow), an_instance_of(TestClassWorkflow)] }
47
+ end
48
+ end
@@ -0,0 +1,37 @@
1
+ RSpec.describe FlowMachine::Workflow do
2
+ let(:state_class1) do
3
+ Class.new(FlowMachine::WorkflowState) do
4
+ def self.state_name; :state1; end
5
+ event :event1
6
+ end
7
+ end
8
+
9
+ let(:state_class2) do
10
+ Class.new(FlowMachine::WorkflowState) do
11
+ def self.state_name; :state2; end
12
+ event :event2
13
+ end
14
+ end
15
+
16
+ let(:workflow_class1) { Class.new }
17
+ let(:workflow_class2) { Class.new }
18
+
19
+ before do
20
+ workflow_class1.include described_class
21
+ workflow_class2.include described_class
22
+ workflow_class1.state state_class1
23
+ workflow_class2.state state_class2
24
+ end
25
+
26
+ context 'class1' do
27
+ subject { workflow_class1.new(double) }
28
+ it { should respond_to 'event1' }
29
+ it { should_not respond_to 'event2' }
30
+ end
31
+
32
+ context 'class2' do
33
+ subject { workflow_class2.new(double) }
34
+ it { should_not respond_to 'event1' }
35
+ it { should respond_to 'event2' }
36
+ end
37
+ end
@@ -0,0 +1,131 @@
1
+ RSpec.describe FlowMachine::Callback do
2
+ let(:object) { double }
3
+
4
+ describe 'callback with method' do
5
+ let(:callback) { described_class.new(:some_method) }
6
+
7
+ it 'calls the method' do
8
+ expect(object).to receive(:some_method)
9
+ callback.call(object)
10
+ end
11
+ end
12
+
13
+ describe 'callback with block' do
14
+ let(:callback) do
15
+ described_class.new { some_method }
16
+ end
17
+
18
+ it 'calls the method' do
19
+ expect(object).to receive(:some_method)
20
+ callback.call(object)
21
+ end
22
+
23
+ end
24
+
25
+ describe 'callback with if' do
26
+ context 'a single if' do
27
+ let(:callback) { described_class.new(:some_method, if: :if_method?) }
28
+
29
+ context 'if method returns true' do
30
+ before { allow(object).to receive(:if_method?).and_return true }
31
+ it 'calls the method' do
32
+ expect(object).to receive(:some_method)
33
+ callback.call(object)
34
+ end
35
+ end
36
+
37
+ context 'if method returns false' do
38
+ before { allow(object).to receive(:if_method?).and_return false }
39
+ it 'does not call the method' do
40
+ expect(object).not_to receive(:some_method)
41
+ callback.call(object)
42
+ end
43
+ end
44
+ end
45
+
46
+ context 'a lambda for if' do
47
+ let(:callback) do
48
+ described_class.new(:some_method, if: -> { if_method? })
49
+ end
50
+
51
+ it 'calls the method when true' do
52
+ allow(object).to receive(:if_method?).and_return true
53
+ expect(object).to receive(:some_method)
54
+ callback.call(object)
55
+ end
56
+
57
+ it 'does not call the method when false' do
58
+ allow(object).to receive(:if_method?).and_return false
59
+ expect(object).not_to receive(:some_method)
60
+ callback.call(object)
61
+ end
62
+ end
63
+
64
+ context 'an array of ifs' do
65
+ let(:callback) { described_class.new(:some_method, if: [:if_method?, :if2?]) }
66
+ context 'both return true' do
67
+ before :each do
68
+ allow(object).to receive(:if_method?).and_return true
69
+ allow(object).to receive(:if2?).and_return true
70
+ end
71
+ it 'calls the method' do
72
+ expect(object).to receive(:some_method)
73
+ callback.call(object)
74
+ end
75
+ end
76
+
77
+ context 'one returns false' do
78
+ before :each do
79
+ allow(object).to receive(:if_method?).and_return true
80
+ allow(object).to receive(:if2?).and_return false
81
+ end
82
+
83
+ it 'does not call the method' do
84
+ expect(object).to receive(:some_method).never
85
+ callback.call(object)
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ describe 'callback with unless' do
92
+ context 'a single if' do
93
+ let(:callback) { described_class.new(:some_method, unless: :unless_method?) }
94
+
95
+ context 'unless method returns false' do
96
+ before { allow(object).to receive(:unless_method?).and_return false }
97
+
98
+ it 'calls the method' do
99
+ expect(object).to receive(:some_method)
100
+ callback.call(object)
101
+ end
102
+ end
103
+
104
+ context 'unless method returns true' do
105
+ before { allow(object).to receive(:unless_method?).and_return true }
106
+ it 'does not call the method' do
107
+ expect(object).to receive(:some_method).never
108
+ callback.call(object)
109
+ end
110
+ end
111
+ end
112
+
113
+ context 'a lambda for unless' do
114
+ let(:callback) do
115
+ described_class.new(:some_method, unless: -> { unless_method? })
116
+ end
117
+
118
+ it 'calls the method when false' do
119
+ allow(object).to receive(:unless_method?).and_return false
120
+ expect(object).to receive(:some_method)
121
+ callback.call(object)
122
+ end
123
+
124
+ it 'does not call the method when true' do
125
+ allow(object).to receive(:unless_method?).and_return true
126
+ expect(object).not_to receive(:some_method)
127
+ callback.call(object)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,26 @@
1
+ RSpec.describe FlowMachine::ChangeCallback do
2
+ subject(:callback) { described_class.new(:field, :method, if: :condition?) }
3
+
4
+ specify { expect(callback.field).to eq(:field) }
5
+ specify { expect(callback.method).to eq(:method) }
6
+ specify { expect(callback.options).to eq({ if: :condition? }) }
7
+
8
+ let(:object) { double(condition?: true) }
9
+ before { allow(object).to receive(:run_workflow_method) { |m| object.send(m) } }
10
+
11
+ context 'the field changes' do
12
+ let(:changes) { { 'field' => [:old, :new] } }
13
+ it 'calls the method' do
14
+ expect(object).to receive(:method)
15
+ callback.call(object, changes)
16
+ end
17
+ end
18
+
19
+ context 'the field does not change' do
20
+ let(:changes) { { 'other_field' => [:old, :new] } }
21
+ it 'does not call the method' do
22
+ expect(object).to receive(:method).never
23
+ callback.call(object, changes)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,202 @@
1
+ require 'ostruct'
2
+
3
+ RSpec.describe FlowMachine::Workflow do
4
+ class Test1State < FlowMachine::WorkflowState
5
+ def state_method; end
6
+ end
7
+
8
+ class Test2State < FlowMachine::WorkflowState
9
+ def state_method; end
10
+ end
11
+
12
+ class TestWorkflow
13
+ include FlowMachine::Workflow
14
+ state Test1State
15
+ state Test2State
16
+ before_save :before_save_callback
17
+ after_save :after_save_callback
18
+ after_transition :after_transition_callback
19
+ def workflow_method; end
20
+ def before_save_callback; end
21
+ def after_save_callback; end
22
+ def after_transition_callback; end
23
+ end
24
+
25
+ describe 'state_names' do
26
+ it 'has the state names' do
27
+ expect(TestWorkflow.state_names).to eq(['test1', 'test2'])
28
+ end
29
+
30
+ it 'has the states in a hash keyed by names' do
31
+ expect(TestWorkflow.states).to eq({ test1: Test1State, test2: Test2State })
32
+ end
33
+ end
34
+
35
+ describe 'reading initial state' do
36
+ subject(:workflow) { TestWorkflow.new(object) }
37
+ context 'default state field' do
38
+ let(:object) { double(state: :test1) }
39
+ it 'has the state' do
40
+ expect(workflow.current_state_name).to eq(:test1)
41
+ end
42
+ end
43
+
44
+ context 'with a different state field on the object' do
45
+ before { TestWorkflow.send(:state_attribute, :status) }
46
+ # return to default
47
+ after { TestWorkflow.send(:state_attribute, :state) }
48
+
49
+ let(:object) { double(status: :test2) }
50
+ it 'has the state' do
51
+ expect(workflow.current_state_name).to eq(:test2)
52
+ end
53
+ end
54
+ end
55
+
56
+ describe '#transition' do
57
+ subject(:workflow) { TestWorkflow.new(object) }
58
+ let(:object) { double(state: :test1) }
59
+
60
+ it 'errors on an invalid state' do
61
+ expect { workflow.transition to: :invalid }.to raise_error(ArgumentError)
62
+ end
63
+
64
+ it 'changes the state of the object' do
65
+ expect(object).to receive(:state=).with('test2')
66
+ workflow.transition to: :test2
67
+ end
68
+
69
+ it 'leaves the state if transitions to itself' do
70
+ workflow.transition
71
+ expect(workflow.current_state_name).to eq(:test1)
72
+ expect(object.state).to eq(:test1)
73
+ end
74
+ end
75
+
76
+ describe 'transition :after hooks' do
77
+ subject(:workflow) { TestWorkflow.new(object) }
78
+ let(:object) { OpenStruct.new(state: :test1, changes: {}, save: true, object_method: true) }
79
+
80
+ it 'does not call the :after hook before saving' do
81
+ expect(workflow).not_to receive(:workflow_method)
82
+ workflow.transition to: :test2, after: :workflow_method
83
+ end
84
+
85
+ it 'does not calls the :after hook on failure' do
86
+ expect(workflow).not_to receive(:workflow_method)
87
+ workflow.transition to: :test1
88
+ workflow.save
89
+ end
90
+
91
+ it 'calls the :after hook on a state method' do
92
+ expect_any_instance_of(Test1State).to receive(:state_method)
93
+ expect_any_instance_of(Test2State).not_to receive(:state_method)
94
+ workflow.transition to: :test2, after: :state_method
95
+ workflow.save
96
+ end
97
+
98
+ it 'calls the :after hook on a workflow method' do
99
+ expect(workflow).to receive(:workflow_method)
100
+ workflow.transition to: :test2, after: :workflow_method
101
+ workflow.save
102
+ end
103
+
104
+ it 'calls the :after hook on an object method' do
105
+ expect(object).to receive(:object_method)
106
+ workflow.transition to: :test2, after: :object_method
107
+ workflow.save
108
+ end
109
+
110
+ it 'allows a lambda' do
111
+ expect_any_instance_of(Test1State).to receive(:state_method)
112
+ workflow.transition to: :test2, after: ->{ state_method }
113
+ workflow.save
114
+ end
115
+
116
+ describe 'after_transition hook' do
117
+ it 'calls the hook on transition' do
118
+ expect(workflow).to receive(:after_transition_callback)
119
+ workflow.transition to: :test2
120
+ end
121
+
122
+ it 'does not call for invalid states' do
123
+ expect(workflow).not_to receive(:after_transition_callback)
124
+ # Will throw an ArgumentError for invalid state
125
+ expect { workflow.transition to: :invalid_state }.to raise_error
126
+ end
127
+
128
+ it 'does not call for ending in the same state' do
129
+ expect(workflow).not_to receive(:after_transition_callback)
130
+ workflow.transition to: :test1
131
+ end
132
+ end
133
+ end
134
+
135
+ describe '#persist' do
136
+ subject(:workflow) { TestWorkflow.new(object) }
137
+ let(:object) { OpenStruct.new(state: :test1, changes: {}, save: true) }
138
+
139
+ context 'success' do
140
+ it 'saves the object' do
141
+ expect(workflow.persist).to be true
142
+ end
143
+
144
+ it 'runs before and after_change hooks' do
145
+ expect(workflow.current_state).to receive(:fire_callbacks).with(:before_change, {})
146
+ expect(workflow.current_state).to receive(:fire_callbacks).with(:after_change, {})
147
+ expect(workflow.persist).to be true
148
+ end
149
+
150
+ it "runs after_enter hooks if it's in a new state" do
151
+ allow(object).to receive(:changes).and_return({'state' => ['test1', 'test2']})
152
+ expect(workflow.current_state).to receive(:fire_callbacks).with(:before_change, { 'state' => ['test1', 'test2'] })
153
+ expect(workflow.current_state).to receive(:fire_callbacks).with(:after_change, { 'state' => ['test1', 'test2'] })
154
+ expect(workflow.current_state).to receive(:fire_callbacks).with(:after_enter, { 'state' => ['test1', 'test2'] })
155
+ expect(workflow.persist).to be true
156
+ end
157
+
158
+ it 'runs the before_save callback' do
159
+ expect(workflow).to receive(:before_save_callback)
160
+ workflow.persist
161
+ end
162
+
163
+ it 'runs the after_save callback' do
164
+ expect(workflow).to receive(:after_save_callback)
165
+ workflow.persist
166
+ end
167
+ end
168
+
169
+ context 'failure' do
170
+ before do
171
+ expect(object).to receive(:save).and_return false
172
+ end
173
+
174
+ it 'does not save the object' do
175
+ expect(workflow.persist).to be false
176
+ end
177
+
178
+ it 'runs the before_change callback' do
179
+ expect(workflow.current_state).to receive(:fire_callbacks).with(:before_change, {})
180
+ expect(workflow.persist).to be false
181
+ end
182
+
183
+ it 'runs the before_save callback' do
184
+ expect(workflow).to receive(:before_save_callback)
185
+ workflow.persist
186
+ end
187
+
188
+ it 'does not run the after_save callback' do
189
+ expect(workflow).to_not receive(:after_save_callback)
190
+ workflow.persist
191
+ end
192
+
193
+ it 'reverts to the old state' do
194
+ expect(workflow).to be_test1
195
+ workflow.transition to: :test2
196
+ expect(workflow).to be_test2
197
+ workflow.persist
198
+ expect(workflow).to be_test1
199
+ end
200
+ end
201
+ end
202
+ end