flow_machine 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 0e9a4fd3c30a8e7920333c34c4cb23b075648f91
4
+ data.tar.gz: 6df24793c54c72d1498ab49b40da25b3aa29820f
5
+ SHA512:
6
+ metadata.gz: 4d287bcf3788aeec09232337a905d8c851065c0e2067455480f95308dd764d8c8567e5b11beadf316919e64ee94c3921ec9d2035afb6c014bc43205820433ae6
7
+ data.tar.gz: cfc7ec61704bb070cf216704edd496f5d1719f3f4c52c026c6e92dd1de013ea4ab9ce4bdcc72e4bd18eb85a019e1ad2c5ff3536ed76d8a4d65d3c5be2e6b5a7d
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2015 Table XI. http://www.tablexi.com
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,177 @@
1
+ # FlowMachine
2
+
3
+ Build finite state machines in a backend-agnostic, class-centric way.
4
+
5
+ The basic features will work with any PORO, and more features and callbacks are available when used with an ORM like `ActiveRecord` and/or `ActiveModel::Dirty`.
6
+
7
+ ## *Raison d'être*
8
+
9
+ After exploring several of the existing Ruby state machine options, they all seem too tightly coupled to an ORM models and tend to pollute the object model's code far too much. The goal of this gem is to provide a clean, testable interface for working with a state machine that decouples as much as possible from the model object itself.
10
+
11
+ ## Simple Usage
12
+
13
+ ```ruby
14
+ class BlogPost
15
+ attr_accessor :state, :title, :body, :author
16
+
17
+ def initialize
18
+ self.state = :draft
19
+ end
20
+ end
21
+
22
+ class PublishingWorkflow
23
+ include FlowMachine::Workflow
24
+
25
+ state DraftState
26
+ state PublishedState
27
+ end
28
+
29
+ class DraftState < FlowMachine::WorkflowState
30
+ event :publish do
31
+ transition to: :published
32
+ end
33
+ end
34
+
35
+ class PublishedState < FlowMachine::WorkflowState
36
+ on_enter :notify_email_author
37
+
38
+ def notify_email_author
39
+ # Send an email
40
+ end
41
+ end
42
+ ```
43
+
44
+ ```ruby
45
+ blog_post = BlogPost.new
46
+ blog_post.author = "author@example.org"
47
+ workflow = PublishingWorkflow.new(blog_post)
48
+ workflow.publish # notify_email_author is called (returns true if successful)
49
+ workflow.published? # => true
50
+ blog_post.state # => :published
51
+ ```
52
+
53
+
54
+ ## `transition!`
55
+
56
+ If you are using the workflow around an ORM model like ActiveRecord, calling the bang version of the transition will perform the transition and call `save` on the object. This method will return the value returned by `save` (`true`/`false` for ActiveRecord) or `false` if any of the guards fail.
57
+
58
+ E.g. `workflow.publish!` will transition the object to `published` and call `save` on the object.
59
+
60
+ ## Guards
61
+
62
+ Guards are used to allow or prevent `event`s from being called. `:guard` accepts a single symbol or an array of symbols representing methods. The method may be on the state, the workflow, or the object itself, and the method will be searched for in that order.
63
+
64
+ **Best practice** Use predicate methods that return a simple true/false. All guard methods are called, so avoid side affects in these methods.
65
+
66
+ Calling the transition with a failing guard will result in the object not being transitioned and returning `false`. If using the bang version, `save` will not be called.
67
+
68
+ #### may_xxx?
69
+
70
+ `workflow.may_publish?` will call all the guard methods and return `false` if any of the guard methods return `false`. It will also return `false` if you are not in a state that has a defined event (e.g. `published_workflow.may_publish?` will always return `false`)
71
+
72
+ #### guard_errors
73
+
74
+ After calling `may_xxx?`, the workflow will have an array of the guard methods that failed. to avoid additional dependencies, the developer is responsible for converting these to human readable messages (using I18n or the like).
75
+
76
+ ```ruby
77
+ class DraftState < FlowMachine::WorkflowState
78
+ event :publish, guard: [:content_present?, :can_publish?]
79
+ transition to: :published
80
+ end
81
+
82
+ def can_publish?
83
+ false
84
+ end
85
+ end
86
+
87
+ class BlogPost
88
+ def content_present?
89
+ content.present? # assuming you have ActiveSupport loaded
90
+ end
91
+ end
92
+ ```
93
+
94
+ ```ruby
95
+ workflow.may_publish? # => false
96
+ workfow.guard_errors # => [:can_publish?]
97
+ workflow.publish # => false
98
+ ```
99
+
100
+ ## Callbacks
101
+
102
+ State and Workflow callbacks accept `if` and `unless` options. They may be a symbol or array of symbols (looking for the method in the state, workflow, and object in that order) or a Proc.
103
+
104
+ ### State callbacks
105
+
106
+ Declared in the `WorkflowState` class.
107
+
108
+ * `on_enter` Called after the object has transitioned into the state
109
+
110
+ The following are available when `Workflow#save` is used (`workflow.save` or `workflow.transition!`) *Not called if you call `save` directly on the decorated model*.
111
+
112
+ * `after_enter` Called when the object has transitioned into the state and the object has been saved either `workflow.save` or `workflow.transition!` has been called.
113
+ * `before_change` Useful when watching for changes to a model, but only when in a certain state. Will be called if anything exists in the `object#changes` hash (if it exists), often provided by `ActiveModel#dirty`.
114
+ * `after_change` Useful when watching for changes to a model in a certain state, but you only want to trigger when the save is successful (e.g. the model is `valid?`)
115
+
116
+ ### Workflow callbacks
117
+
118
+ Declared in the `Workflow` class.
119
+
120
+ * `after_transition` Called anytime a transition takes place
121
+
122
+ The following are available when `Workflow#save` is used:
123
+
124
+ * `before_save` Called when `Workflow#save` is called, but before `object#save` is called
125
+ * `after_save` Called after `object#save` has returned `true`
126
+
127
+ ### Transition callbacks
128
+
129
+ Declared as an option to the `transition` method inside an `event` block.
130
+
131
+ * `after` Will be called after the transition has happened successfully. Useful when you only want something to trigger when moving from a specific state to another.
132
+
133
+ `transition to: :published, after: :send_mailing_list_email`
134
+
135
+ ## Scopes and Predicate methods
136
+
137
+ If you want scopes and predicate methods defined on your model, use the following:
138
+
139
+ `PublishingWorkflow.create_scopes_on(self)` within the model.
140
+
141
+ Assuming BlogPost is an ActiveRecord model, this will create `BlogPost.draft` and `BlogPost.published` scopes as well as the `BlogPost#draft?` and `BlogPost#published?` methods.
142
+
143
+ ## Other useful features
144
+
145
+ ### Use a different attribute for the state
146
+
147
+ If you don't want to use `state` as your field for storing state, simply declare `state_attribute :status` in the Workflow class.
148
+
149
+ ### List of state names
150
+
151
+ ```ruby
152
+ PublishingWorkflow.state_names` # => ['draft', 'published']
153
+ ```
154
+
155
+ Especially useful in an ActiveModel validation:
156
+
157
+ ```ruby
158
+ validates :state, presence: true, inclusion: { in: PublishingWorkflow.state_names }
159
+ ```
160
+
161
+ ### Options Hash
162
+
163
+ You can pass an options hash into the workflow which is available at any time while using the workflow. A prime example is tracking the user who performed an action.
164
+
165
+ ```ruby
166
+ class PublishedState < FlowMachine::WorkflowState
167
+ on_enter :update_published_by
168
+
169
+ def update_published_by
170
+ object.published_by = options[:current_user]
171
+ end
172
+ end
173
+
174
+ workflow = PublishingWorkflow.new(blog_post, current_user: User.find(123))
175
+ workflow.published
176
+ blog_post.published_by # => User #123
177
+ ```
data/Rakefile ADDED
@@ -0,0 +1,38 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'Workflow'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.rdoc')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+
18
+
19
+
20
+ Bundler::GemHelper.install_tasks
21
+
22
+ require 'rake/testtask'
23
+
24
+ Rake::TestTask.new(:test) do |t|
25
+ t.libs << 'lib'
26
+ t.libs << 'test'
27
+ t.pattern = 'test/**/*_test.rb'
28
+ t.verbose = false
29
+ end
30
+
31
+
32
+ task default: :test
33
+
34
+ begin
35
+ require 'rspec/core/rake_task'
36
+ RSpec::Core::RakeTask.new(:spec)
37
+ rescue LoadError
38
+ end
@@ -0,0 +1,12 @@
1
+ $:.unshift(File.dirname(__FILE__))
2
+
3
+ require "flow_machine/workflow"
4
+ require "flow_machine/factory"
5
+ require "flow_machine/workflow_state"
6
+ require "flow_machine/callback"
7
+ require "flow_machine/state_callback"
8
+ require "flow_machine/change_callback"
9
+
10
+ module FlowMachine
11
+ end
12
+
@@ -0,0 +1,43 @@
1
+ require 'active_support/core_ext/object/blank'
2
+ require 'active_support/core_ext/array/extract_options'
3
+
4
+ class FlowMachine::Callback
5
+ attr_accessor :method, :options
6
+ def initialize(*args, &block)
7
+ @options = args.extract_options!
8
+ @method = args.shift unless block
9
+ @block = block
10
+ end
11
+
12
+ def call(target, changes = {})
13
+ return unless will_run? target, changes
14
+ call!(target)
15
+ end
16
+
17
+ # Runs the callback without any validations
18
+ def call!(target)
19
+ run_method_or_lambda(target, method.presence || @block)
20
+ end
21
+
22
+ def run_method_or_lambda(target, method)
23
+ if method.respond_to? :call # is it a lambda
24
+ target.instance_exec &method
25
+ else
26
+ run_method(target, method)
27
+ end
28
+ end
29
+
30
+ def run_method(target, method)
31
+ target.send(method)
32
+ end
33
+
34
+ def will_run?(target, changes = {})
35
+ if options[:if]
36
+ [*options[:if]].all? { |m| run_method_or_lambda(target, m) }
37
+ elsif options[:unless]
38
+ [*options[:unless]].none? { |m| run_method_or_lambda(target, m) }
39
+ else
40
+ true
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,11 @@
1
+ class FlowMachine::ChangeCallback < FlowMachine::StateCallback
2
+ attr_accessor :field
3
+ def initialize(field, *args, &block)
4
+ @field = field
5
+ super(*args, &block)
6
+ end
7
+
8
+ def will_run?(object, changes = {})
9
+ changes.keys.include?(field.to_s) && super
10
+ end
11
+ end
@@ -0,0 +1,27 @@
1
+ module FlowMachine
2
+ class Factory
3
+ def self.workflow_for(object, options = {})
4
+ # If the object is enumerable, delegate. This allows workflow_for
5
+ # as shorthand
6
+ return workflow_for_collection(object, options) if object.respond_to?(:map)
7
+
8
+ klazz = workflow_class_for(object)
9
+ return nil unless klazz
10
+ klazz.new(object, options)
11
+ end
12
+
13
+ def self.workflow_for_collection(collection, options = {})
14
+ collection.map { |item| workflow_for(item, options) }
15
+ end
16
+
17
+ def self.workflow_class_for(object_or_class)
18
+ if object_or_class.is_a? Class
19
+ "#{object_or_class.name}Workflow".constantize
20
+ else
21
+ workflow_class_for(object_or_class.class)
22
+ end
23
+ rescue NameError # if the workflow class doesn't exist
24
+ nil
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,5 @@
1
+ class FlowMachine::StateCallback < FlowMachine::Callback
2
+ def run_method(target, method)
3
+ target.run_workflow_method(method)
4
+ end
5
+ end
@@ -0,0 +1,3 @@
1
+ module FlowMachine
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,206 @@
1
+ require "active_support/core_ext/module/delegation"
2
+ require "active_support/core_ext/object/try"
3
+
4
+ module FlowMachine
5
+ module Workflow
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ base.send(:attr_reader, :object)
9
+ end
10
+
11
+ module ClassMethods
12
+ # resolves to
13
+ # class Model
14
+ # def self.published
15
+ # where(status: 'published')
16
+ # end
17
+ #
18
+ # def published?
19
+ # self.status == 'published'
20
+ # end
21
+ # end
22
+ def create_scopes_on(content_class)
23
+ self.state_names.each do |status|
24
+ content_class.singleton_class.send(:define_method, status) do
25
+ where(status: status)
26
+ end
27
+
28
+ content_class.send(:define_method, "#{status}?") do
29
+ self.status == status
30
+ end
31
+ end
32
+ end
33
+
34
+ attr_accessor :callbacks
35
+
36
+ def state_names
37
+ states.keys.map &:to_s
38
+ end
39
+
40
+ def states
41
+ @states ||= {}
42
+ end
43
+
44
+ # Mainly to be used in testing, call this method to ensure that
45
+ # any dynamically created state methods get exposed to the workflow
46
+ def refresh_state_methods!
47
+ states.values.each do |state_class|
48
+ add_state_methods_from(state_class)
49
+ end
50
+ end
51
+
52
+ def state(state_class)
53
+ name = get_state_name(state_class)
54
+ states[name] = state_class
55
+ add_state_methods_from(state_class)
56
+
57
+ define_method "#{name}?" do
58
+ current_state_name.to_s == name.to_s
59
+ end
60
+ end
61
+
62
+ def state_attribute(method)
63
+ @state_attribute = method
64
+ end
65
+
66
+ def state_method
67
+ @state_attribute || :state
68
+ end
69
+
70
+ def before_save(*args, &block)
71
+ add_callback(:before_save, FlowMachine::Callback.new(*args, &block))
72
+ end
73
+
74
+ def after_save(*args, &block)
75
+ add_callback(:after_save, FlowMachine::Callback.new(*args, &block))
76
+ end
77
+
78
+ def after_transition(*args, &block)
79
+ add_callback(:after_transition, FlowMachine::Callback.new(*args, &block))
80
+ end
81
+
82
+ private
83
+
84
+ def add_callback(hook, callback)
85
+ self.callbacks ||= {}
86
+ callbacks[hook] ||= []
87
+ callbacks[hook] << callback
88
+ end
89
+
90
+ # Defines an instance method on Workflow that delegates to the
91
+ # current state, but only if the current_state implements the method
92
+ def define_state_method(method_name)
93
+ define_method method_name do |*args|
94
+ current_state.respond_to?(method_name) && current_state.send(method_name, *args)
95
+ end
96
+ end
97
+
98
+ def add_state_methods_from(state_class)
99
+ state_class.expose_to_workflow_methods.try(:each) do |method_name|
100
+ define_state_method(method_name) unless method_defined?(method_name)
101
+ end
102
+ end
103
+
104
+ def get_state_name(state_class)
105
+ state_class.state_name
106
+ end
107
+ end
108
+
109
+ attr_accessor :options, :previous_state_persistence_callbacks, :previous_state
110
+ attr_accessor :changes
111
+
112
+ # extend Forwardable
113
+ # def_delegators :current_state, :guard_errors
114
+ delegate :guard_errors, to: :current_state
115
+
116
+ def initialize(object, options = {})
117
+ @object = object
118
+ @options = options
119
+ @previous_state_persistence_callbacks = []
120
+ end
121
+
122
+ def current_state_name
123
+ object.send(self.class.state_method)
124
+ end
125
+ alias_method :state, :current_state_name
126
+
127
+ def previous_state_name
128
+ @previous_state.try(:name)
129
+ end
130
+
131
+ def current_state
132
+ @current_state ||= self.class.states[current_state_name.to_sym].new(self)
133
+ end
134
+
135
+ def transition(options = {})
136
+ @previous_state = current_state
137
+ @current_state = nil
138
+ self.current_state_name = options[:to].to_s if options[:to]
139
+ @previous_state_persistence_callbacks << FlowMachine::StateCallback.new(options[:after]) if options[:after]
140
+ fire_callbacks(:after_transition) unless previous_state == current_state
141
+ current_state
142
+ end
143
+
144
+ def save
145
+ persist
146
+ end
147
+
148
+ def persist
149
+ self.changes = object.changes
150
+ # If the model has a default state from the database, then it doesn't get
151
+ # included in `changes` when you're first saving it.
152
+ self.changes[state_method.to_s] ||= [nil, current_state_name] if object.new_record?
153
+
154
+ fire_callbacks(:before_save)
155
+ current_state.fire_callbacks(:before_change, changes)
156
+
157
+ if persist_object
158
+ fire_state_callbacks
159
+ fire_callbacks(:after_save)
160
+ true
161
+ else
162
+ self.current_state_name = @previous_state.name.to_s if @previous_state
163
+ false
164
+ end
165
+ end
166
+
167
+ # Useful for using in if/unless on state and after_save callbacks so you can
168
+ # run the callback only on the initial persistence
169
+ def create?
170
+ self.changes[state_method.to_s].try(:first).blank?
171
+ end
172
+
173
+ def persist_object
174
+ object.save
175
+ end
176
+
177
+ def current_state_name=(new_state)
178
+ raise ArgumentError.new("invalid state: #{new_state}") unless self.class.state_names.include?(new_state.to_s)
179
+ object.send("#{self.class.state_method}=", new_state)
180
+ end
181
+
182
+ def current_user
183
+ options[:current_user]
184
+ end
185
+
186
+ private
187
+
188
+ def fire_callbacks(event)
189
+ self.class.callbacks ||= {}
190
+ self.class.callbacks[event].try(:each) do |callback|
191
+ callback.call(self, changes)
192
+ end
193
+ end
194
+
195
+ def fire_state_callbacks
196
+ previous_state.fire_callback_list(previous_state_persistence_callbacks) if previous_state
197
+ @previous_state_persistence_callbacks = []
198
+ current_state.fire_callbacks(:after_enter, changes) if changes.include? state_method.to_s
199
+ current_state.fire_callbacks(:after_change, changes)
200
+ end
201
+
202
+ def state_method
203
+ self.class.state_method
204
+ end
205
+ end
206
+ end