workflow-orchestrator 1.3.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.
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