workflow 1.0.0 → 1.1.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.
@@ -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