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