rails-workflow 1.4.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.
- checksums.yaml +7 -0
- data/.gitignore +22 -0
- data/.travis.yml +27 -0
- data/.yardopts +2 -0
- data/Gemfile +3 -0
- data/MIT-LICENSE +20 -0
- data/README.markdown +426 -0
- data/Rakefile +30 -0
- data/asdf +18 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/gemfiles/Gemfile.rails-3.x +11 -0
- data/gemfiles/Gemfile.rails-4.0 +14 -0
- data/gemfiles/Gemfile.rails-5.0 +13 -0
- data/gemfiles/Gemfile.rails-edge +13 -0
- data/lib/workflow.rb +295 -0
- data/lib/workflow/adapters/active_record.rb +78 -0
- data/lib/workflow/adapters/active_record_validations.rb +110 -0
- data/lib/workflow/adapters/remodel.rb +15 -0
- data/lib/workflow/callbacks.rb +274 -0
- data/lib/workflow/configuration.rb +10 -0
- data/lib/workflow/draw.rb +79 -0
- data/lib/workflow/errors.rb +29 -0
- data/lib/workflow/event.rb +129 -0
- data/lib/workflow/specification.rb +137 -0
- data/lib/workflow/state.rb +88 -0
- data/lib/workflow/transition_context.rb +54 -0
- data/lib/workflow/version.rb +3 -0
- data/orders_workflow.png +0 -0
- data/rails-workflow.gemspec +51 -0
- metadata +258 -0
data/Rakefile
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake/testtask'
|
3
|
+
require 'rdoc/task'
|
4
|
+
|
5
|
+
require 'bundler'
|
6
|
+
Bundler.setup
|
7
|
+
|
8
|
+
task :default => [:test]
|
9
|
+
|
10
|
+
require 'rake'
|
11
|
+
Rake::TestTask.new do |t|
|
12
|
+
t.libs << 'test'
|
13
|
+
t.verbose = true
|
14
|
+
t.warning = true
|
15
|
+
t.test_files = FileList['test/*_test.rb'] + FileList['test/new_versions/*_test.rb']
|
16
|
+
end
|
17
|
+
|
18
|
+
Rake::TestTask.new do |t|
|
19
|
+
t.name = 'test_without_new_versions'
|
20
|
+
t.libs << 'test'
|
21
|
+
t.verbose = true
|
22
|
+
t.warning = true
|
23
|
+
t.pattern = 'test/*_test.rb'
|
24
|
+
end
|
25
|
+
|
26
|
+
Rake::RDocTask.new do |rdoc|
|
27
|
+
rdoc.rdoc_files.include("lib/**/*.rb")
|
28
|
+
rdoc.options << "-S"
|
29
|
+
end
|
30
|
+
|
data/asdf
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
|
2
|
+
[:before, :after, :around].each do |callback|
|
3
|
+
puts <<-EOF
|
4
|
+
##
|
5
|
+
# :method: #{callback}_transition
|
6
|
+
#
|
7
|
+
# :call-seq:
|
8
|
+
# #{callback}_transition(*instance_method_names, options={})
|
9
|
+
# #{callback}_transition(*instance_method_names)
|
10
|
+
# #{callback}_transition(*instance_method_names)
|
11
|
+
#
|
12
|
+
# Append a callback #{callback} transitions.
|
13
|
+
# Instance methods used for `before` and `after` transitions
|
14
|
+
# receive no
|
15
|
+
#
|
16
|
+
|
17
|
+
EOF
|
18
|
+
end
|
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "workflow"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
source "http://rubygems.org"
|
2
|
+
|
3
|
+
group :development do
|
4
|
+
gem "minitest", "< 5.0.0" # 5.0.0 introduced incompatible changes renaming all the classes
|
5
|
+
gem "rdoc", ">= 3.12"
|
6
|
+
gem "bundler", ">= 1.0.0"
|
7
|
+
gem "activerecord", "~>4.0"
|
8
|
+
gem 'protected_attributes'
|
9
|
+
gem "sqlite3"
|
10
|
+
gem "mocha"
|
11
|
+
gem "rake"
|
12
|
+
gem "test-unit"
|
13
|
+
gem "ruby-graphviz", "~> 1.0.0"
|
14
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
source "http://rubygems.org"
|
2
|
+
|
3
|
+
group :development do
|
4
|
+
gem "rdoc", ">= 3.12"
|
5
|
+
gem "bundler", ">= 1.0.0"
|
6
|
+
gem "activerecord", "~>5.0"
|
7
|
+
# gem 'protected_attributes' only supported until Rails 5.0
|
8
|
+
gem "sqlite3"
|
9
|
+
gem "mocha"
|
10
|
+
gem "rake"
|
11
|
+
gem "test-unit"
|
12
|
+
gem "ruby-graphviz", "~> 1.0.0"
|
13
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
source "http://rubygems.org"
|
2
|
+
|
3
|
+
group :development do
|
4
|
+
gem "rdoc", ">= 3.12"
|
5
|
+
gem "bundler", ">= 1.0.0"
|
6
|
+
gem "activerecord"
|
7
|
+
gem "sqlite3"
|
8
|
+
gem "mocha"
|
9
|
+
gem "rake"
|
10
|
+
gem "test-unit"
|
11
|
+
gem "ruby-graphviz", "~> 1.0.0"
|
12
|
+
gem 'protected_attributes'
|
13
|
+
end
|
data/lib/workflow.rb
ADDED
@@ -0,0 +1,295 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'active_support/concern'
|
3
|
+
require 'workflow/version'
|
4
|
+
require 'workflow/configuration'
|
5
|
+
require 'workflow/specification'
|
6
|
+
require 'workflow/callbacks'
|
7
|
+
require 'workflow/adapters/active_record'
|
8
|
+
require 'workflow/adapters/remodel'
|
9
|
+
require 'workflow/adapters/active_record_validations'
|
10
|
+
require 'workflow/transition_context'
|
11
|
+
|
12
|
+
# See also README.markdown for documentation
|
13
|
+
module Workflow
|
14
|
+
# @!parse include Callbacks
|
15
|
+
# @!parse extend Callbacks::ClassMethods
|
16
|
+
|
17
|
+
extend ActiveSupport::Concern
|
18
|
+
include Callbacks
|
19
|
+
include Errors
|
20
|
+
|
21
|
+
def self.configure(&block)
|
22
|
+
block.call(config) if block_given?
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.config
|
26
|
+
@@configuration ||= Configuration.new
|
27
|
+
end
|
28
|
+
|
29
|
+
included do
|
30
|
+
|
31
|
+
# Look for a hook; otherwise detect based on ancestor class.
|
32
|
+
if respond_to?(:workflow_adapter)
|
33
|
+
include self.workflow_adapter
|
34
|
+
else
|
35
|
+
if Object.const_defined?(:ActiveRecord) && self < ActiveRecord::Base
|
36
|
+
include Adapter::ActiveRecord
|
37
|
+
include Adapter::ActiveRecordValidations
|
38
|
+
end
|
39
|
+
if Object.const_defined?(:Remodel) && klass < Adapter::Remodel::Entity
|
40
|
+
include Adapter::Remodel::InstanceMethods
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Returns a state object representing the current workflow state.
|
46
|
+
#
|
47
|
+
# @return [State] Current workflow state
|
48
|
+
def current_state
|
49
|
+
loaded_state = load_workflow_state
|
50
|
+
res = workflow_spec.states.find{|t| t.name==loaded_state.to_sym} if loaded_state
|
51
|
+
res || workflow_spec.initial_state
|
52
|
+
end
|
53
|
+
|
54
|
+
# Deprecated. Check for false return value from {#process_event!}
|
55
|
+
# @return true if the last transition was halted by one of the transition callbacks.
|
56
|
+
def halted?
|
57
|
+
@halted
|
58
|
+
end
|
59
|
+
|
60
|
+
# Returns the reason given to a call to {#halt} or {#halt!}, if any.
|
61
|
+
# @return [String] The reason the transition was aborted.
|
62
|
+
attr_reader :halted_because
|
63
|
+
|
64
|
+
# Initiates state transition via the named event
|
65
|
+
#
|
66
|
+
# @param [Symbol] name name of event to initiate
|
67
|
+
# @param [Mixed] *args Arguments passed to state transition. Available also to callbacks
|
68
|
+
# @return [Type] description of returned object
|
69
|
+
def process_event!(name, *args, **attributes)
|
70
|
+
name = name.to_sym
|
71
|
+
event = current_state.find_event(name)
|
72
|
+
raise NoTransitionAllowed.new(
|
73
|
+
"There is no event #{name} defined for the #{current_state.name} state") \
|
74
|
+
if event.nil?
|
75
|
+
|
76
|
+
@halted_because = nil
|
77
|
+
@halted = false
|
78
|
+
|
79
|
+
target = event.evaluate(self)
|
80
|
+
unless target
|
81
|
+
raise NoMatchingTransitionError.new("No matching transition found on #{name} for target #{target}. Consider adding a catchall transition.")
|
82
|
+
end
|
83
|
+
|
84
|
+
from = current_state
|
85
|
+
return_value = false
|
86
|
+
begin
|
87
|
+
@transition_context = TransitionContext.new \
|
88
|
+
from: from.name,
|
89
|
+
to: target.name,
|
90
|
+
event: name,
|
91
|
+
event_args: args,
|
92
|
+
attributes: attributes,
|
93
|
+
named_arguments: workflow_spec.named_arguments
|
94
|
+
|
95
|
+
run_all_callbacks do
|
96
|
+
callback_value = run_action_callback name, *args
|
97
|
+
return_value = callback_value
|
98
|
+
return_value ||= persist_workflow_state(target.name) || true
|
99
|
+
end
|
100
|
+
ensure
|
101
|
+
@transition_context = nil
|
102
|
+
end
|
103
|
+
return_value
|
104
|
+
end
|
105
|
+
|
106
|
+
# Stop the current transition and set the reason for the abort.
|
107
|
+
#
|
108
|
+
# @param optional [String] reason Reason for halting transition.
|
109
|
+
# @return [void]
|
110
|
+
def halt(reason = nil)
|
111
|
+
@halted_because = reason
|
112
|
+
@halted = true
|
113
|
+
throw :abort
|
114
|
+
end
|
115
|
+
|
116
|
+
# Sets halt reason and raises [TransitionHaltedError] error.
|
117
|
+
#
|
118
|
+
# @param optional [String] reason Reason for halting
|
119
|
+
# @return [void]
|
120
|
+
def halt!(reason = nil)
|
121
|
+
@halted_because = reason
|
122
|
+
@halted = true
|
123
|
+
raise TransitionHaltedError.new(reason)
|
124
|
+
end
|
125
|
+
|
126
|
+
# The specification for this object.
|
127
|
+
# Could be set on a singleton for the object, on the object's class,
|
128
|
+
# Or else on a superclass of the object.
|
129
|
+
# @return [Specification] The Specification that applies to this object.
|
130
|
+
def workflow_spec
|
131
|
+
# check the singleton class first
|
132
|
+
class << self
|
133
|
+
return workflow_spec if workflow_spec
|
134
|
+
end
|
135
|
+
|
136
|
+
c = self.class
|
137
|
+
# using a simple loop instead of class_inheritable_accessor to avoid
|
138
|
+
# dependency on Rails' ActiveSupport
|
139
|
+
until c.workflow_spec || !(c.include? Workflow)
|
140
|
+
c = c.superclass
|
141
|
+
end
|
142
|
+
c.workflow_spec
|
143
|
+
end
|
144
|
+
|
145
|
+
private
|
146
|
+
|
147
|
+
def has_callback?(action)
|
148
|
+
# 1. public callback method or
|
149
|
+
# 2. protected method somewhere in the class hierarchy or
|
150
|
+
# 3. private in the immediate class (parent classes ignored)
|
151
|
+
action = action.to_sym
|
152
|
+
self.respond_to?(action) or
|
153
|
+
self.class.protected_method_defined?(action) or
|
154
|
+
self.private_methods(false).map(&:to_sym).include?(action)
|
155
|
+
end
|
156
|
+
|
157
|
+
def run_action_callback(action_name, *args)
|
158
|
+
action = action_name.to_sym
|
159
|
+
if has_callback?(action)
|
160
|
+
meth = method(action)
|
161
|
+
check_method_arity! meth, *args
|
162
|
+
meth.call *args
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def check_method_arity!(method, *args)
|
167
|
+
arity = method.arity
|
168
|
+
|
169
|
+
unless (arity >= 0 && args.length == arity) || (arity < 0 && (args.length + 1) >= arity.abs)
|
170
|
+
raise CallbackArityError.new("Method #{method.name} has arity #{arity} but was called with #{args.length} arguments.")
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
# load_workflow_state and persist_workflow_state
|
175
|
+
# can be overriden to handle the persistence of the workflow state.
|
176
|
+
#
|
177
|
+
# Default (non ActiveRecord) implementation stores the current state
|
178
|
+
# in a variable.
|
179
|
+
#
|
180
|
+
# Default ActiveRecord implementation uses a 'workflow_state' database column.
|
181
|
+
def load_workflow_state
|
182
|
+
@workflow_state if instance_variable_defined? :@workflow_state
|
183
|
+
end
|
184
|
+
|
185
|
+
def persist_workflow_state(new_value)
|
186
|
+
@workflow_state = new_value
|
187
|
+
end
|
188
|
+
|
189
|
+
module ClassMethods
|
190
|
+
attr_reader :workflow_spec
|
191
|
+
|
192
|
+
# Instructs Workflow which column to use to persist workflow state.
|
193
|
+
#
|
194
|
+
# @param optional [Symbol] column_name name of column on table
|
195
|
+
# @return [void]
|
196
|
+
def workflow_column(column_name=nil)
|
197
|
+
if column_name
|
198
|
+
@workflow_state_column_name = column_name.to_sym
|
199
|
+
end
|
200
|
+
if !instance_variable_defined?('@workflow_state_column_name') && superclass.respond_to?(:workflow_column)
|
201
|
+
@workflow_state_column_name = superclass.workflow_column
|
202
|
+
end
|
203
|
+
@workflow_state_column_name ||= :workflow_state
|
204
|
+
end
|
205
|
+
|
206
|
+
|
207
|
+
##
|
208
|
+
# Define workflow for the class.
|
209
|
+
#
|
210
|
+
# @yield [] Specification of workflow. Example below and in README.markdown
|
211
|
+
# @return [nil]
|
212
|
+
#
|
213
|
+
# Workflow definition takes place inside the yielded block.
|
214
|
+
# @see Specification::state
|
215
|
+
# @see Specification::event
|
216
|
+
#
|
217
|
+
# ~~~ruby
|
218
|
+
#
|
219
|
+
# class Article
|
220
|
+
# include Workflow
|
221
|
+
# workflow do
|
222
|
+
# state :new do
|
223
|
+
# event :submit, :transitions_to => :awaiting_review
|
224
|
+
# end
|
225
|
+
# state :awaiting_review do
|
226
|
+
# event :review, :transitions_to => :being_reviewed
|
227
|
+
# end
|
228
|
+
# state :being_reviewed do
|
229
|
+
# event :accept, :transitions_to => :accepted
|
230
|
+
# event :reject, :transitions_to => :rejected
|
231
|
+
# end
|
232
|
+
# state :accepted
|
233
|
+
# state :rejected
|
234
|
+
# end
|
235
|
+
# end
|
236
|
+
#
|
237
|
+
#~~~
|
238
|
+
#
|
239
|
+
def workflow(&specification)
|
240
|
+
assign_workflow Specification.new(Hash.new, &specification)
|
241
|
+
end
|
242
|
+
|
243
|
+
private
|
244
|
+
|
245
|
+
# Creates the convinience methods like `my_transition!`
|
246
|
+
def assign_workflow(specification_object)
|
247
|
+
# Merging two workflow specifications can **not** be done automically, so
|
248
|
+
# just make the latest specification win. Same for inheritance -
|
249
|
+
# definition in the subclass wins.
|
250
|
+
if self.superclass.respond_to?(:workflow_spec, true) && self.superclass.workflow_spec
|
251
|
+
undefine_methods_defined_by_workflow_spec superclass.workflow_spec
|
252
|
+
end
|
253
|
+
|
254
|
+
@workflow_spec = specification_object
|
255
|
+
@workflow_spec.states.each do |state|
|
256
|
+
state_name = state.name
|
257
|
+
module_eval do
|
258
|
+
define_method "#{state_name}?" do
|
259
|
+
state_name == current_state.name
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
state.events.each do |event|
|
264
|
+
event_name = event.name
|
265
|
+
module_eval do
|
266
|
+
define_method "#{event_name}!".to_sym do |*args|
|
267
|
+
process_event!(event_name, *args)
|
268
|
+
end
|
269
|
+
|
270
|
+
define_method "can_#{event_name}?" do
|
271
|
+
return !!current_state.find_event(event_name)&.evaluate(self)
|
272
|
+
end
|
273
|
+
end
|
274
|
+
end
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
def undefine_methods_defined_by_workflow_spec(inherited_workflow_spec)
|
279
|
+
inherited_workflow_spec.states.each do |state|
|
280
|
+
state_name = state.name
|
281
|
+
module_eval do
|
282
|
+
undef_method "#{state_name}?"
|
283
|
+
end
|
284
|
+
|
285
|
+
state.events.each do |event|
|
286
|
+
event_name = event.name
|
287
|
+
module_eval do
|
288
|
+
undef_method "#{event_name}!".to_sym
|
289
|
+
undef_method "can_#{event_name}?"
|
290
|
+
end
|
291
|
+
end
|
292
|
+
end
|
293
|
+
end
|
294
|
+
end
|
295
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
module Workflow
|
2
|
+
module Adapter
|
3
|
+
module ActiveRecord
|
4
|
+
def self.included(klass)
|
5
|
+
klass.send :include, Adapter::ActiveRecord::InstanceMethods
|
6
|
+
klass.send :extend, Adapter::ActiveRecord::Scopes
|
7
|
+
klass.before_validation :write_initial_state
|
8
|
+
end
|
9
|
+
|
10
|
+
module InstanceMethods
|
11
|
+
def load_workflow_state
|
12
|
+
read_attribute(self.class.workflow_column).to_sym
|
13
|
+
end
|
14
|
+
|
15
|
+
# On transition the new workflow state is immediately saved in the
|
16
|
+
# database, if configured to do so.
|
17
|
+
def persist_workflow_state(new_value)
|
18
|
+
# Rails 3.1 or newer
|
19
|
+
if persisted? && Workflow.config.persist_workflow_state_immediately
|
20
|
+
attrs = {self.class.workflow_column => new_value}
|
21
|
+
if Workflow.config.touch_on_update_column
|
22
|
+
attrs[:updated_at] = DateTime.now
|
23
|
+
end
|
24
|
+
update_columns attrs
|
25
|
+
else
|
26
|
+
self[self.class.workflow_column] = new_value
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
# Motivation: even if NULL is stored in the workflow_state database column,
|
33
|
+
# the current_state is correctly recognized in the Ruby code. The problem
|
34
|
+
# arises when you want to SELECT records filtering by the value of initial
|
35
|
+
# state. That's why it is important to save the string with the name of the
|
36
|
+
# initial state in all the new records.
|
37
|
+
def write_initial_state
|
38
|
+
write_attribute self.class.workflow_column, current_state.name
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# This module will automatically generate ActiveRecord scopes based on workflow states.
|
43
|
+
# The name of each generated scope will be something like `with_<state_name>_state`
|
44
|
+
#
|
45
|
+
# Examples:
|
46
|
+
#
|
47
|
+
# Article.with_pending_state # => ActiveRecord::Relation
|
48
|
+
# Payment.without_refunded_state # => ActiveRecord::Relation
|
49
|
+
#`
|
50
|
+
# Example above just adds `where(:state_column_name => 'pending')` or
|
51
|
+
# `where.not(:state_column_name => 'pending')` to AR query and returns
|
52
|
+
# ActiveRecord::Relation.
|
53
|
+
module Scopes
|
54
|
+
def self.extended(object)
|
55
|
+
class << object
|
56
|
+
alias_method :workflow_without_scopes, :workflow unless method_defined?(:workflow_without_scopes)
|
57
|
+
alias_method :workflow, :workflow_with_scopes
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def workflow_with_scopes(&specification)
|
62
|
+
workflow_without_scopes(&specification)
|
63
|
+
states = workflow_spec.states
|
64
|
+
|
65
|
+
states.map(&:name).each do |state|
|
66
|
+
define_singleton_method("with_#{state}_state") do
|
67
|
+
where(self.workflow_column.to_sym => state.to_s)
|
68
|
+
end
|
69
|
+
|
70
|
+
define_singleton_method("without_#{state}_state") do
|
71
|
+
where.not(self.workflow_column.to_sym => state.to_s)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|