rails-workflow 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
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,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,11 @@
1
+ source "http://rubygems.org"
2
+
3
+ group :development do
4
+ gem "rdoc", ">= 3.12"
5
+ gem "bundler", ">= 1.0.0"
6
+ gem "activerecord", "~>3.2"
7
+ gem "sqlite3"
8
+ gem "mocha"
9
+ gem "rake"
10
+ gem "ruby-graphviz", "~> 1.0.0"
11
+ end
@@ -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