workflow-orchestrator 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +20 -0
  3. data/.travis.yml +36 -0
  4. data/CHANGELOG.md +133 -0
  5. data/Gemfile +3 -0
  6. data/MIT-LICENSE +22 -0
  7. data/README.md +707 -0
  8. data/Rakefile +30 -0
  9. data/gemfiles/Gemfile.rails-3.x +12 -0
  10. data/gemfiles/Gemfile.rails-4.0 +14 -0
  11. data/gemfiles/Gemfile.rails-4.1 +14 -0
  12. data/gemfiles/Gemfile.rails-4.2 +14 -0
  13. data/gemfiles/Gemfile.rails-edge +14 -0
  14. data/lib/workflow/adapters/active_record.rb +75 -0
  15. data/lib/workflow/adapters/remodel.rb +15 -0
  16. data/lib/workflow/draw.rb +79 -0
  17. data/lib/workflow/errors.rb +20 -0
  18. data/lib/workflow/event.rb +38 -0
  19. data/lib/workflow/event_collection.rb +36 -0
  20. data/lib/workflow/specification.rb +83 -0
  21. data/lib/workflow/state.rb +44 -0
  22. data/lib/workflow/version.rb +3 -0
  23. data/lib/workflow.rb +307 -0
  24. data/orders_workflow.png +0 -0
  25. data/test/active_record_scopes_test.rb +56 -0
  26. data/test/active_record_scopes_with_values_test.rb +79 -0
  27. data/test/adapter_hook_test.rb +52 -0
  28. data/test/advanced_examples_test.rb +84 -0
  29. data/test/advanced_hooks_and_validation_test.rb +119 -0
  30. data/test/attr_protected_test.rb +107 -0
  31. data/test/before_transition_test.rb +36 -0
  32. data/test/couchtiny_example.rb +46 -0
  33. data/test/enum_values_in_memory_test.rb +23 -0
  34. data/test/enum_values_test.rb +30 -0
  35. data/test/incline_column_test.rb +54 -0
  36. data/test/inheritance_test.rb +56 -0
  37. data/test/main_test.rb +588 -0
  38. data/test/multiple_workflows_test.rb +84 -0
  39. data/test/new_versions/compare_states_test.rb +32 -0
  40. data/test/new_versions/persistence_test.rb +62 -0
  41. data/test/on_error_test.rb +52 -0
  42. data/test/on_unavailable_transition_test.rb +85 -0
  43. data/test/readme_example.rb +37 -0
  44. data/test/test_helper.rb +39 -0
  45. data/test/without_active_record_test.rb +54 -0
  46. data/workflow-orchestrator.gemspec +42 -0
  47. metadata +267 -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
+
@@ -0,0 +1,12 @@
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
+ gem "test-unit"
12
+ end
@@ -0,0 +1,14 @@
1
+ source "http://rubygems.org"
2
+
3
+ group :development do
4
+ gem "minitest", "~> 5.1.0"
5
+ gem "rdoc", ">= 3.12"
6
+ gem "bundler", ">= 1.0.0"
7
+ gem "activerecord", "~>4.1"
8
+ gem 'protected_attributes'
9
+ gem "sqlite3"
10
+ gem "mocha"
11
+ gem "rake"
12
+ gem "ruby-graphviz", "~> 1.0.0"
13
+ gem "test-unit"
14
+ end
@@ -0,0 +1,14 @@
1
+ source "http://rubygems.org"
2
+
3
+ group :development do
4
+ gem "minitest", "~> 5.1.0"
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 "ruby-graphviz", "~> 1.0.0"
13
+ gem "test-unit"
14
+ end
@@ -0,0 +1,14 @@
1
+ source "http://rubygems.org"
2
+
3
+ group :development do
4
+ gem "minitest", "~> 5.1.0"
5
+ gem "rdoc", ">= 3.12"
6
+ gem "bundler", ">= 1.0.0"
7
+ gem "activerecord", "~>4.2"
8
+ gem "protected_attributes"
9
+ gem "sqlite3"
10
+ gem "mocha"
11
+ gem "rake"
12
+ gem "ruby-graphviz", "~> 1.0.0"
13
+ gem "test-unit"
14
+ end
@@ -0,0 +1,14 @@
1
+ source "http://rubygems.org"
2
+
3
+ group :development do
4
+ gem "minitest", "~> 5.1.0"
5
+ gem "rdoc", ">= 3.12"
6
+ gem "bundler", ">= 1.0.0"
7
+ gem "activerecord"
8
+ gem "sqlite3"
9
+ gem "mocha"
10
+ gem "rake"
11
+ gem "ruby-graphviz", "~> 1.0.0"
12
+ gem "protected_attributes"
13
+ gem "test-unit"
14
+ end
@@ -0,0 +1,75 @@
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)
13
+ end
14
+
15
+ # On transition the new workflow state is immediately saved in the
16
+ # database.
17
+ def persist_workflow_state(new_value)
18
+ # Rails 3.1 or newer
19
+ update_column self.class.workflow_column, new_value
20
+ end
21
+
22
+ private
23
+
24
+ # Motivation: even if NULL is stored in the workflow_state database column,
25
+ # the current_state is correctly recognized in the Ruby code. The problem
26
+ # arises when you want to SELECT records filtering by the value of initial
27
+ # state. That's why it is important to save the string with the value of the
28
+ # initial state in all the new records.
29
+ def write_initial_state
30
+ write_attribute self.class.workflow_column, current_state.value
31
+ end
32
+ end
33
+
34
+ # This module will automatically generate ActiveRecord scopes based on workflow states.
35
+ # The name of each generated scope will be something like `with_<state_name>_state`
36
+ #
37
+ # Examples:
38
+ #
39
+ # Article.with_pending_state # => ActiveRecord::Relation
40
+ # Payment.without_refunded_state # => ActiveRecord::Relation
41
+ #`
42
+ # Example above just adds `where(:state_column_name => 'pending')` or
43
+ # `where.not(:state_column_name => 'pending')` to AR query and returns
44
+ # ActiveRecord::Relation.
45
+ module Scopes
46
+ def self.extended(object)
47
+ class << object
48
+ alias_method :workflow_without_scopes, :workflow unless method_defined?(:workflow_without_scopes)
49
+ alias_method :workflow, :workflow_with_scopes
50
+ end
51
+ end
52
+
53
+ def workflow_with_scopes(column=nil, &specification)
54
+ workflow_without_scopes(column, &specification)
55
+ states = workflow_spec.states.values
56
+
57
+ states.each do |state|
58
+ define_singleton_method("with_#{state}_state") do
59
+ where("#{table_name}.#{self.workflow_column.to_sym} = ?", state.value)
60
+ end
61
+
62
+ define_singleton_method("without_#{state}_state") do
63
+ where("#{table_name}.#{self.workflow_column.to_sym} != ?", state.value)
64
+ end
65
+
66
+ define_method("without_#{state}_state") do
67
+ where("#{table_name}.#{self.workflow_column.to_sym} <> ?", state.to_s)
68
+ end
69
+ end
70
+ end
71
+
72
+ end
73
+ end
74
+ end
75
+ 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.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.flat.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,20 @@
1
+ module Workflow
2
+ class Error < StandardError; end
3
+
4
+ class TransitionHalted < Error
5
+
6
+ attr_reader :halted_because
7
+
8
+ def initialize(msg = nil)
9
+ @halted_because = msg
10
+ super msg
11
+ end
12
+
13
+ end
14
+
15
+ class NoTransitionAllowed < Error; end
16
+
17
+ class WorkflowError < Error; end
18
+
19
+ class WorkflowDefinitionError < Error; end
20
+ end
@@ -0,0 +1,38 @@
1
+ module Workflow
2
+ class Event
3
+
4
+ attr_accessor :name, :transitions_to, :meta, :action, :condition
5
+
6
+ def initialize(name, transitions_to, condition = nil, meta = {}, &action)
7
+ @name = name
8
+ @transitions_to = transitions_to.to_sym
9
+ @meta = meta
10
+ @action = action
11
+ @condition = if condition.nil? || condition.is_a?(Symbol) || condition.respond_to?(:call)
12
+ condition
13
+ else
14
+ raise TypeError, 'condition must be nil, an instance method name symbol or a callable (eg. a proc or lambda)'
15
+ end
16
+ end
17
+
18
+ def condition_applicable?(object)
19
+ if condition
20
+ if condition.is_a?(Symbol)
21
+ object.send(condition)
22
+ else
23
+ condition.call(object)
24
+ end
25
+ else
26
+ true
27
+ end
28
+ end
29
+
30
+ def draw(graph, from_state)
31
+ graph.add_edges(from_state.name.to_s, transitions_to.to_s, meta.merge(:label => to_s))
32
+ end
33
+
34
+ def to_s
35
+ @name.to_s
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,36 @@
1
+ module Workflow
2
+ class EventCollection < Hash
3
+
4
+ def [](name)
5
+ super name.to_sym # Normalize to symbol
6
+ end
7
+
8
+ def push(name, event)
9
+ key = name.to_sym
10
+ self[key] ||= []
11
+ self[key] << event
12
+ end
13
+
14
+ def flat
15
+ self.values.flatten.uniq do |event|
16
+ [:name, :transitions_to, :meta, :action].map { |m| event.send(m) }
17
+ end
18
+ end
19
+
20
+ def include?(name_or_obj)
21
+ case name_or_obj
22
+ when Event
23
+ flat.include? name_or_obj
24
+ else
25
+ !(self[name_or_obj].nil?)
26
+ end
27
+ end
28
+
29
+ def first_applicable(name, object_context)
30
+ (self[name] || []).detect do |event|
31
+ event.condition_applicable?(object_context) && event
32
+ end
33
+ end
34
+
35
+ end
36
+ end
@@ -0,0 +1,83 @@
1
+ require 'workflow/state'
2
+ require 'workflow/event'
3
+ require 'workflow/event_collection'
4
+ require 'workflow/errors'
5
+
6
+ module Workflow
7
+ class Specification
8
+
9
+ attr_accessor :states, :initial_state, :meta,
10
+ :on_transition_proc, :before_transition_proc,
11
+ :after_transition_proc, :on_error_proc, :on_unavailable_transition_proc
12
+
13
+ def initialize(meta = {}, &specification)
14
+ @states = Hash.new
15
+ @meta = meta
16
+ instance_eval(&specification)
17
+ end
18
+
19
+ def state_names
20
+ states.keys
21
+ end
22
+
23
+ private
24
+
25
+ def state(name, value=nil, options=nil, &events_and_etc)
26
+
27
+ if value.is_a? Hash
28
+ value = nil
29
+ options = value
30
+ end
31
+
32
+ value ||= name
33
+ options ||= {}
34
+ meta.reverse_merge! meta: {}
35
+
36
+ new_state = Workflow::State.new(name, value, self, options[:meta])
37
+ @initial_state = new_state if @states.empty?
38
+ @states[name.to_sym] = new_state
39
+ @scoped_state = new_state
40
+ instance_eval(&events_and_etc) if events_and_etc
41
+ end
42
+
43
+ def event(name, args = {}, &action)
44
+ target = args[:transitions_to] || args[:transition_to]
45
+ condition = args[:if]
46
+ raise WorkflowDefinitionError.new(
47
+ "missing ':transitions_to' in workflow event definition for '#{name}'") \
48
+ if target.nil?
49
+ @scoped_state.events.push(
50
+ name, Workflow::Event.new(name, target, condition, (args[:meta] or {}), &action)
51
+ )
52
+ end
53
+
54
+ def on_entry(&proc)
55
+ @scoped_state.on_entry = proc
56
+ end
57
+
58
+ def on_exit(&proc)
59
+ @scoped_state.on_exit = proc
60
+ end
61
+
62
+ def after_transition(&proc)
63
+ @after_transition_proc = proc
64
+ end
65
+
66
+ def before_transition(&proc)
67
+ @before_transition_proc = proc
68
+ end
69
+
70
+ def on_transition(&proc)
71
+ @on_transition_proc = proc
72
+ end
73
+
74
+ def on_error(&proc)
75
+ @on_error_proc = proc
76
+ end
77
+
78
+ def on_unavailable_transition(&proc)
79
+ @on_unavailable_transition_proc = proc
80
+ end
81
+
82
+ end
83
+ end
@@ -0,0 +1,44 @@
1
+ module Workflow
2
+ class State
3
+ attr_accessor :name, :value, :events, :meta, :on_entry, :on_exit
4
+ attr_reader :spec
5
+
6
+ def initialize(name, value, spec, meta = {})
7
+ @name, @value, @spec, @events, @meta = name, value, spec, EventCollection.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)
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
@@ -0,0 +1,3 @@
1
+ module Workflow
2
+ VERSION = "1.3.0"
3
+ end