flow_machine 0.1.0

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,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