workflow 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,66 @@
1
+ module Workflow
2
+ module Adapter
3
+ module ActiveRecord
4
+ module InstanceMethods
5
+ def load_workflow_state
6
+ read_attribute(self.class.workflow_column)
7
+ end
8
+
9
+ # On transition the new workflow state is immediately saved in the
10
+ # database.
11
+ def persist_workflow_state(new_value)
12
+ if self.respond_to? :update_column
13
+ # Rails 3.1 or newer
14
+ update_column self.class.workflow_column, new_value
15
+ else
16
+ # older Rails; beware of side effect: other (pending) attribute changes will be persisted too
17
+ update_attribute self.class.workflow_column, new_value
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ # Motivation: even if NULL is stored in the workflow_state database column,
24
+ # the current_state is correctly recognized in the Ruby code. The problem
25
+ # arises when you want to SELECT records filtering by the value of initial
26
+ # state. That's why it is important to save the string with the name of the
27
+ # initial state in all the new records.
28
+ def write_initial_state
29
+ write_attribute self.class.workflow_column, current_state.to_s
30
+ end
31
+ end
32
+
33
+ # This module will automatically generate ActiveRecord scopes based on workflow states.
34
+ # The name of each generated scope will be something like `with_<state_name>_state`
35
+ #
36
+ # Examples:
37
+ #
38
+ # Article.with_pending_state # => ActiveRecord::Relation
39
+ #
40
+ # Example above just adds `where(:state_column_name => 'pending')` to AR query and returns
41
+ # ActiveRecord::Relation.
42
+ module Scopes
43
+ def self.extended(object)
44
+ class << object
45
+ alias_method :workflow_without_scopes, :workflow unless method_defined?(:workflow_without_scopes)
46
+ alias_method :workflow, :workflow_with_scopes
47
+ end
48
+ end
49
+
50
+ def workflow_with_scopes(&specification)
51
+ workflow_without_scopes(&specification)
52
+ states = workflow_spec.states.values
53
+ eigenclass = class << self; self; end
54
+
55
+ states.each do |state|
56
+ # Use eigenclass instead of `define_singleton_method`
57
+ # to be compatible with Ruby 1.8+
58
+ eigenclass.send(:define_method, "with_#{state}_state") do
59
+ where("#{table_name}.#{self.workflow_column.to_sym} = ?", state.to_s)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,15 @@
1
+ module Workflow
2
+ module Adapter
3
+ module Remodel
4
+ module InstanceMethods
5
+ def load_workflow_state
6
+ send(self.class.workflow_column)
7
+ end
8
+
9
+ def persist_workflow_state(new_value)
10
+ update(self.class.workflow_column => new_value)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,79 @@
1
+ begin
2
+ require 'rubygems'
3
+
4
+ gem 'ruby-graphviz', '>=1.0'
5
+ gem 'activesupport'
6
+
7
+ require 'graphviz'
8
+ require 'active_support/inflector'
9
+ rescue LoadError => e
10
+ $stderr.puts "Could not load the ruby-graphiz or active_support gems for rendering: #{e.message}"
11
+ end
12
+
13
+ module Workflow
14
+ module Draw
15
+
16
+ # Generates a `dot` graph of the workflow.
17
+ # Prerequisite: the `dot` binary. (Download from http://www.graphviz.org/)
18
+ # You can use this method in your own Rakefile like this:
19
+ #
20
+ # namespace :doc do
21
+ # desc "Generate a workflow graph for a model passed e.g. as 'MODEL=Order'."
22
+ # task :workflow => :environment do
23
+ # require 'workflow/draw'
24
+ # Workflow::Draw::workflow_diagram(ENV['MODEL'].constantize)
25
+ # end
26
+ # end
27
+ #
28
+ # You can influence the placement of nodes by specifying
29
+ # additional meta information in your states and transition descriptions.
30
+ # You can assign higher `weight` value to the typical transitions
31
+ # in your workflow. All other states and transitions will be arranged
32
+ # around that main line. See also `weight` in the graphviz documentation.
33
+ # Example:
34
+ #
35
+ # state :new do
36
+ # event :approve, :transitions_to => :approved, :meta => {:weight => 8}
37
+ # end
38
+ #
39
+ #
40
+ # @param klass A class with the Workflow mixin, for which you wish the graphical workflow representation
41
+ # @param [String] target_dir Directory, where to save the dot and the pdf files
42
+ # @param [String] graph_options You can change graph orientation, size etc. See graphviz documentation
43
+ def self.workflow_diagram(klass, options={})
44
+ options = {
45
+ :name => "#{klass.name.tableize}_workflow".gsub('/', '_'),
46
+ :path => '.',
47
+ :orientation => "landscape",
48
+ :ratio => "fill",
49
+ :format => 'png',
50
+ :font => 'Helvetica'
51
+ }.merge options
52
+
53
+ graph = ::GraphViz.new('G', :rankdir => options[:orientation] == 'landscape' ? 'LR' : 'TB', :ratio => options[:ratio])
54
+
55
+ # Add nodes
56
+ klass.workflow_spec.states.each do |_, state|
57
+ node = state.draw(graph)
58
+ node.fontname = options[:font]
59
+
60
+ state.events.each do |_, event|
61
+ edge = event.draw(graph, state)
62
+ edge.fontname = options[:font]
63
+ end
64
+ end
65
+
66
+ # Generate the graph
67
+ filename = File.join(options[:path], "#{options[:name]}.#{options[:format]}")
68
+
69
+ graph.output options[:format] => "'#{filename}'"
70
+
71
+ puts "
72
+ Please run the following to open the generated file:
73
+
74
+ open '#{filename}'
75
+ "
76
+ graph
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,18 @@
1
+ module Workflow
2
+ class TransitionHalted < Exception
3
+
4
+ attr_reader :halted_because
5
+
6
+ def initialize(msg = nil)
7
+ @halted_because = msg
8
+ super msg
9
+ end
10
+
11
+ end
12
+
13
+ class NoTransitionAllowed < Exception; end
14
+
15
+ class WorkflowError < Exception; end
16
+
17
+ class WorkflowDefinitionError < Exception; end
18
+ end
@@ -0,0 +1,18 @@
1
+ module Workflow
2
+ class Event
3
+
4
+ attr_accessor :name, :transitions_to, :meta, :action
5
+
6
+ def initialize(name, transitions_to, meta = {}, &action)
7
+ @name, @transitions_to, @meta, @action = name, transitions_to.to_sym, meta, action
8
+ end
9
+
10
+ def draw(graph, from_state)
11
+ graph.add_edges(from_state.name.to_s, transitions_to.to_s, meta.merge(:label => to_s))
12
+ end
13
+
14
+ def to_s
15
+ @name.to_s
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,64 @@
1
+ require 'workflow/state'
2
+ require 'workflow/event'
3
+ require 'workflow/errors'
4
+
5
+ module Workflow
6
+ class Specification
7
+ attr_accessor :states, :initial_state, :meta,
8
+ :on_transition_proc, :before_transition_proc, :after_transition_proc, :on_error_proc
9
+
10
+ def initialize(meta = {}, &specification)
11
+ @states = Hash.new
12
+ @meta = meta
13
+ instance_eval(&specification)
14
+ end
15
+
16
+ def state_names
17
+ states.keys
18
+ end
19
+
20
+ private
21
+
22
+ def state(name, meta = {:meta => {}}, &events_and_etc)
23
+ # meta[:meta] to keep the API consistent..., gah
24
+ new_state = Workflow::State.new(name, self, meta[:meta])
25
+ @initial_state = new_state if @states.empty?
26
+ @states[name.to_sym] = new_state
27
+ @scoped_state = new_state
28
+ instance_eval(&events_and_etc) if events_and_etc
29
+ end
30
+
31
+ def event(name, args = {}, &action)
32
+ target = args[:transitions_to] || args[:transition_to]
33
+ raise WorkflowDefinitionError.new(
34
+ "missing ':transitions_to' in workflow event definition for '#{name}'") \
35
+ if target.nil?
36
+ @scoped_state.events[name.to_sym] =
37
+ Workflow::Event.new(name, target, (args[:meta] or {}), &action)
38
+ end
39
+
40
+ def on_entry(&proc)
41
+ @scoped_state.on_entry = proc
42
+ end
43
+
44
+ def on_exit(&proc)
45
+ @scoped_state.on_exit = proc
46
+ end
47
+
48
+ def after_transition(&proc)
49
+ @after_transition_proc = proc
50
+ end
51
+
52
+ def before_transition(&proc)
53
+ @before_transition_proc = proc
54
+ end
55
+
56
+ def on_transition(&proc)
57
+ @on_transition_proc = proc
58
+ end
59
+
60
+ def on_error(&proc)
61
+ @on_error_proc = proc
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,44 @@
1
+ module Workflow
2
+ class State
3
+ attr_accessor :name, :events, :meta, :on_entry, :on_exit
4
+ attr_reader :spec
5
+
6
+ def initialize(name, spec, meta = {})
7
+ @name, @spec, @events, @meta = name, spec, Hash.new, meta
8
+ end
9
+
10
+ def draw(graph)
11
+ defaults = {
12
+ :label => to_s,
13
+ :width => '1',
14
+ :height => '1',
15
+ :shape => 'ellipse'
16
+ }
17
+
18
+ node = graph.add_nodes(to_s, defaults.merge(meta))
19
+
20
+ # Add open arrow for initial state
21
+ # graph.add_edge(graph.add_node('starting_state', :shape => 'point'), node) if initial?
22
+
23
+ node
24
+ end
25
+
26
+
27
+ if RUBY_VERSION >= '1.9'
28
+ include Comparable
29
+ def <=>(other_state)
30
+ states = spec.states.keys
31
+ raise ArgumentError, "state `#{other_state}' does not exist" unless states.include?(other_state.to_sym)
32
+ states.index(self.to_sym) <=> states.index(other_state.to_sym)
33
+ end
34
+ end
35
+
36
+ def to_s
37
+ "#{name}"
38
+ end
39
+
40
+ def to_sym
41
+ name.to_sym
42
+ end
43
+ end
44
+ end
@@ -1,3 +1,3 @@
1
1
  module Workflow
2
- VERSION = "1.0.0"
2
+ VERSION = "1.1.0"
3
3
  end
Binary file
@@ -0,0 +1,49 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+
3
+ $VERBOSE = false
4
+ require 'active_record'
5
+ require 'sqlite3'
6
+ require 'workflow'
7
+
8
+ ActiveRecord::Migration.verbose = false
9
+
10
+ class Article < ActiveRecord::Base
11
+ include Workflow
12
+
13
+ workflow do
14
+ state :new
15
+ state :accepted
16
+ end
17
+ end
18
+
19
+ class ActiveRecordScopesTest < ActiveRecordTestCase
20
+
21
+ def setup
22
+ super
23
+
24
+ ActiveRecord::Schema.define do
25
+ create_table :articles do |t|
26
+ t.string :title
27
+ t.string :body
28
+ t.string :blame_reason
29
+ t.string :reject_reason
30
+ t.string :workflow_state
31
+ end
32
+ end
33
+ end
34
+
35
+ def assert_state(title, expected_state, klass = Order)
36
+ o = klass.find_by_title(title)
37
+ assert_equal expected_state, o.read_attribute(klass.workflow_column)
38
+ o
39
+ end
40
+
41
+ test 'have "with_new_state" scope' do
42
+ assert_respond_to Article, :with_new_state
43
+ end
44
+
45
+ test 'have "with_accepted_state" scope' do
46
+ assert_respond_to Article, :with_accepted_state
47
+ end
48
+ end
49
+
@@ -58,4 +58,25 @@ class AdvanceExamplesTest < ActiveRecordTestCase
58
58
  assert(a.new?, "should now be back in the 'new' state")
59
59
  end
60
60
 
61
+ test '#92 Load workflow specification' do
62
+ c = Class.new
63
+ c.class_eval do
64
+ include Workflow
65
+ end
66
+
67
+ # build a Specification (you can load it from yaml file too)
68
+ myspec = Workflow::Specification.new do
69
+ state :one do
70
+ event :dynamic_transition, :transitions_to => :one_a
71
+ end
72
+ state :one_a
73
+ end
74
+
75
+ c.send :assign_workflow, myspec
76
+
77
+ a = c.new
78
+ a.dynamic_transition!(1)
79
+ assert a.one_a?, 'Expected successful transition to a new state'
80
+ end
81
+
61
82
  end
@@ -7,6 +7,7 @@ require 'sqlite3'
7
7
  require 'workflow'
8
8
  require 'mocha/setup'
9
9
  require 'stringio'
10
+ require 'protected_attributes' if ActiveRecord::VERSION::MAJOR >= 4
10
11
 
11
12
  ActiveRecord::Migration.verbose = false
12
13
 
@@ -60,7 +61,9 @@ class AttrProtectedTest < ActiveRecordTestCase
60
61
  test 'cannot mass-assign workflow_state if attr_protected' do
61
62
  o = AttrProtectedTestOrder.find_by_title('order1')
62
63
  assert_equal 'submitted', o.read_attribute(:workflow_state)
64
+ AttrProtectedTestOrder.logger.level = Logger::ERROR # ignore warnings
63
65
  o.update_attributes :workflow_state => 'some_bad_value'
66
+ AttrProtectedTestOrder.logger.level = Logger::WARN
64
67
  assert_equal 'submitted', o.read_attribute(:workflow_state)
65
68
  o.update_attribute :workflow_state, 'some_overridden_value'
66
69
  assert_equal 'some_overridden_value', o.read_attribute(:workflow_state)
@@ -0,0 +1,60 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+ require 'workflow'
3
+ class InheritanceTest < ActiveRecordTestCase
4
+
5
+ test '#69 inheritance' do
6
+ class Animal
7
+ include Workflow
8
+
9
+ workflow do
10
+
11
+ state :conceived do
12
+ event :birth, :transition_to => :born
13
+ end
14
+
15
+ state :born do
16
+
17
+ end
18
+ end
19
+ end
20
+
21
+ class Cat < Animal
22
+ include Workflow
23
+ workflow do
24
+
25
+ state :upset do
26
+ event :scratch, :transition_to => :hiding
27
+ end
28
+
29
+ state :hiding do
30
+
31
+ end
32
+ end
33
+ end
34
+
35
+ assert_equal [:born, :conceived] , sort_sym_array(Animal.workflow_spec.states.keys)
36
+ assert_equal [:hiding, :upset], sort_sym_array(Cat.workflow_spec.states.keys), "Workflow definitions are not inherited"
37
+
38
+ animal = Animal.new
39
+ cat = Cat.new
40
+
41
+ animal.birth!
42
+
43
+ assert_raise NoMethodError, 'Methods defined by the old workflow spec should have be gone away' do
44
+ cat.birth!
45
+ end
46
+
47
+ assert_equal [:birth!, :halt!, :process_event!], bang_methods(animal)
48
+ assert_equal [:halt!, :process_event!, :scratch!], bang_methods(cat)
49
+ end
50
+
51
+ def sort_sym_array(a)
52
+ a.sort { |a, b| a.to_s <=> b.to_s } # workaround for Ruby 1.8.7
53
+ end
54
+
55
+ def bang_methods(obj)
56
+ non_trivial_methods = obj.public_methods-Object.public_methods
57
+ methods_with_bang = non_trivial_methods.select {|m| m =~ /!$/}
58
+ sort_sym_array(methods_with_bang).map {|m| m.to_sym}
59
+ end
60
+ end