rails-workflow 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,29 @@
1
+ module Workflow
2
+ module Errors
3
+ class TransitionHaltedError < StandardError
4
+
5
+ attr_reader :halted_because
6
+
7
+ def initialize(msg = nil)
8
+ @halted_because = msg
9
+ super msg
10
+ end
11
+
12
+ end
13
+
14
+ class NoMatchingTransitionError < StandardError
15
+ end
16
+
17
+ class NoTransitionAllowed < StandardError
18
+ end
19
+
20
+ class WorkflowError < StandardError
21
+ end
22
+
23
+ class CallbackArityError < StandardError
24
+ end
25
+
26
+ class WorkflowDefinitionError < StandardError
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,129 @@
1
+ module Workflow
2
+ class Event
3
+ attr_reader :name, :transitions, :meta
4
+
5
+ def initialize(name, meta)
6
+ @name = name.to_sym
7
+ @transitions = []
8
+ @meta = meta || {}
9
+ end
10
+
11
+ def inspect
12
+ "<Event name=#{name.inspect} transitions(#{transitions.length})=#{transitions.inspect}>"
13
+ end
14
+
15
+ def evaluate(target)
16
+ transition = transitions.find{|t| t.apply? target}
17
+ if transition
18
+ return transition.target_state
19
+ else
20
+ nil
21
+ end
22
+ end
23
+
24
+ def to(target_state, **conditions_def, &block)
25
+ conditions = Conditions.new &&conditions_def, block
26
+ self.transitions << Transition.new(target_state, conditions_def, &block)
27
+ end
28
+
29
+ private
30
+ class Transition
31
+ attr_accessor :target_state, :conditions
32
+ def apply?(target)
33
+ conditions.apply?(target)
34
+ end
35
+ # delegate :apply?, to: :conditions
36
+ def initialize(target_state, conditions_def, &block)
37
+ @target_state = target_state
38
+ @conditions = Conditions.new conditions_def, &block
39
+ end
40
+
41
+ def inspect
42
+ "<to=#{target_state.inspect} conditions=#{conditions.inspect}"
43
+ end
44
+ end
45
+
46
+ class Conditions #:nodoc:#
47
+
48
+ def initialize(**options, &block)
49
+ @if = Array(options[:if])
50
+ @unless = Array(options[:unless])
51
+ @if << block if block_given?
52
+ @conditions_lambdas = conditions_lambdas
53
+ end
54
+
55
+ def inspect
56
+ "if: #{@if}, unless: #{@unless}"
57
+ end
58
+
59
+ def apply?(target)
60
+ # TODO: Remove the second parameter from the conditions below.
61
+ @conditions_lambdas.all?{|l| l.call(target, ->(){})}
62
+ end
63
+
64
+ private
65
+
66
+ # Copied from https://github.com/rails/rails/blob/bca2e69b785fa3cdbe148b0d2dd5d3b58f6daf53/activesupport/lib/active_support/callbacks.rb#L366
67
+ def invert_lambda(l)
68
+ lambda { |*args, &blk| !l.call(*args, &blk) }
69
+ end
70
+
71
+ # Filters support:
72
+ #
73
+ # Symbols:: A method to call.
74
+ # Strings:: Some content to evaluate.
75
+ # Procs:: A proc to call with the object.
76
+ # Objects:: An object with a <tt>before_foo</tt> method on it to call.
77
+ #
78
+ # All of these objects are converted into a lambda and handled
79
+ # the same after this point.
80
+ # Copied from https://github.com/rails/rails/blob/bca2e69b785fa3cdbe148b0d2dd5d3b58f6daf53/activesupport/lib/active_support/callbacks.rb#L379
81
+ def make_lambda(filter)
82
+ case filter
83
+ when Symbol
84
+ lambda { |target, _, &blk| target.send filter, &blk }
85
+ when String
86
+ l = eval "lambda { |value| #{filter} }"
87
+ lambda { |target, value| target.instance_exec(value, &l) }
88
+ # when Conditionals::Value then filter
89
+ when ::Proc
90
+ if filter.arity > 1
91
+ return lambda { |target, _, &block|
92
+ raise ArgumentError unless block
93
+ target.instance_exec(target, block, &filter)
94
+ }
95
+ end
96
+
97
+ if filter.arity <= 0
98
+ lambda { |target, _| target.instance_exec(&filter) }
99
+ else
100
+ lambda { |target, _| target.instance_exec(target, &filter) }
101
+ end
102
+ else
103
+ scopes = Array(chain_config[:scope])
104
+ method_to_call = scopes.map{ |s| public_send(s) }.join("_")
105
+
106
+ lambda { |target, _, &blk|
107
+ filter.public_send method_to_call, target, &blk
108
+ }
109
+ end
110
+ end
111
+
112
+ # From https://github.com/rails/rails/blob/bca2e69b785fa3cdbe148b0d2dd5d3b58f6daf53/activesupport/lib/active_support/callbacks.rb#L410
113
+ def compute_identifier(filter)
114
+ case filter
115
+ when String, ::Proc
116
+ filter.object_id
117
+ else
118
+ filter
119
+ end
120
+ end
121
+
122
+ # From https://github.com/rails/rails/blob/bca2e69b785fa3cdbe148b0d2dd5d3b58f6daf53/activesupport/lib/active_support/callbacks.rb#L419
123
+ def conditions_lambdas
124
+ @if.map { |c| make_lambda c } +
125
+ @unless.map { |c| invert_lambda make_lambda c }
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,137 @@
1
+ require 'workflow/state'
2
+ require 'workflow/event'
3
+ require 'workflow/errors'
4
+ require 'active_support/callbacks'
5
+
6
+ module Workflow
7
+ # Metadata object describing available states and state transitions.
8
+ class Specification
9
+ include ActiveSupport::Callbacks
10
+
11
+ # The state objects defined for this specification, keyed by name
12
+ # @return [Hash]
13
+ attr_reader :states
14
+
15
+ # State object to be given to newly created objects under this workflow.
16
+ # @return [State]
17
+ attr_reader :initial_state
18
+
19
+ # Optional metadata stored with this workflow specification
20
+ # @return [Hash]
21
+ attr_reader :meta
22
+
23
+ # List of symbols, for attribute accessors to be added to {TransitionContext} object
24
+ # @return [Array]
25
+ attr_reader :named_arguments
26
+
27
+ define_callbacks :spec_definition
28
+ set_callback(:spec_definition, :after, if: :define_revert_events?) do |spec|
29
+ spec.states.each do |state|
30
+ state.events.reject{ |e|
31
+ e.name.to_s =~ /^revert_/
32
+ }.select{|e| e.transitions.length == 1}.each do |event|
33
+ revert_event_name = "revert_#{event.name}".to_sym
34
+ from_state_for_revert = event.transitions.first.target_state
35
+ from_state_for_revert.on revert_event_name, to: state
36
+ end
37
+ end
38
+ end
39
+
40
+ set_callback(:spec_definition, :after) do |spec|
41
+ spec.states.each do |state|
42
+ state.events.each do |event|
43
+ event.transitions.each do |transition|
44
+ target_state = spec.find_state(transition.target_state)
45
+ unless target_state.present?
46
+ raise Workflow::Errors::WorkflowDefinitionError.new("Event #{event.name} transitions to #{transition.target_state} but there is no such state.")
47
+ end
48
+ transition.target_state = target_state
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ def find_state(name)
55
+ states.find{|t| t.name == name.to_sym}
56
+ end
57
+
58
+
59
+
60
+
61
+ # @api private
62
+ #
63
+ # @param [Hash] meta Metadata
64
+ # @yield [] Block for workflow definition
65
+ # @return [Specification]
66
+ def initialize(meta = {}, &specification)
67
+ @states = []
68
+ @meta = meta
69
+ run_callbacks :spec_definition do
70
+ instance_eval(&specification)
71
+ end
72
+ end
73
+
74
+ # Define a new state named [name]
75
+ #
76
+ # @param [Symbol] name name of state
77
+ # @param [Hash] meta Metadata to be stored with the state within the {Specification} object
78
+ # @yield [] block defining events for this state.
79
+ # @return [nil]
80
+ def state(name, meta: {}, &events)
81
+ name = name.to_sym
82
+ new_state = Workflow::State.new(name, self, meta)
83
+ @initial_state ||= new_state
84
+ @states << new_state
85
+ new_state.instance_eval(&events) if block_given?
86
+ end
87
+
88
+
89
+
90
+ # Specify attributes to make available on the {TransitionContext} object
91
+ # during transitions taking place in this specification.
92
+ # The attributes' values will be taken in order from the arguments passed to
93
+ # the event transit method call.
94
+ #
95
+ # @param [Array] names A list of symbols
96
+ # @return [nil]
97
+ def event_args(*names)
98
+ @named_arguments = names
99
+ end
100
+
101
+
102
+ # Also create additional event transitions that will move each configured transition
103
+ # in the reverse direction.
104
+ #
105
+ # @return [nil]
106
+ #
107
+ #```ruby
108
+ # class Article
109
+ # include Workflow
110
+ # workflow do
111
+ # define_revert_events!
112
+ # state :foo do
113
+ # event :bar, transitions_to: :bax
114
+ # end
115
+ # state :bax
116
+ # end
117
+ # end
118
+ #
119
+ # a = Article.new
120
+ # a.process_event! :foo
121
+ # a.current_state.name # => :bax
122
+ # a.process_event! :revert_bar
123
+ # a.current_state.name # => :foo
124
+ #```
125
+ def define_revert_events!
126
+ @define_revert_events = true
127
+ end
128
+
129
+ private
130
+
131
+
132
+ def define_revert_events?
133
+ !!@define_revert_events
134
+ end
135
+
136
+ end
137
+ end
@@ -0,0 +1,88 @@
1
+ module Workflow
2
+ class State
3
+ include Comparable
4
+
5
+ attr_accessor :name, :events, :meta, :on_entry, :on_exit
6
+ attr_reader :sequence
7
+
8
+ def initialize(name, sequence, **meta)
9
+ @name, @sequence, @events, @meta = name.to_sym, sequence, [], meta
10
+ end
11
+
12
+ def find_event(name)
13
+ events.find{|t| t.name == name}
14
+ end
15
+
16
+ # Define an event on this specification.
17
+ # Must be called within the scope of the block within a call to {#state}.
18
+ #
19
+ # @param [Symbol] name The name of the event
20
+ # @param [Hash] args
21
+ # @option args [Symbol] :transitions_to The state this event transitions to.
22
+ # @option args [Symbol] :if optional instance method name or [Proc] that will receive the object when called.
23
+ # @option args [Hash] :meta Optional metadata to be stored on the event object
24
+ # @return [nil]
25
+ #
26
+ #```ruby
27
+ #workflow do
28
+ # state :new do
29
+ # on :review, to: :being_reviewed
30
+ #
31
+ # on :submit do
32
+ # to :submitted,
33
+ # if: [ "name == 'The Dude'", :abides?, -> (rug) {rug.tied_the_room_together?}],
34
+ # unless: :nihilist?
35
+ #
36
+ # to :trash, unless: :body?
37
+ # to :another_place do |article|
38
+ # article.foo?
39
+ # end
40
+ # end
41
+ # end
42
+ #
43
+ # state :kitchen
44
+ # state :the_bar
45
+ # state :the_diner
46
+ #end
47
+ #```
48
+ def on(name, to: nil, meta: nil, &transitions)
49
+ if to && block_given?
50
+ raise Errors::WorkflowDefinitionError.new("Event target can only be received in the method call or the block, not both.")
51
+ end
52
+
53
+ unless to || block_given?
54
+ raise Errors::WorkflowDefinitionError.new("No event target given for event #{name}")
55
+ end
56
+
57
+ if find_event(name)
58
+ raise Errors::WorkflowDefinitionError.new("Already defined an event [#{name}] for state[#{self.name}]")
59
+ end
60
+
61
+ event = Workflow::Event.new(name, meta)
62
+
63
+ if to
64
+ event.to to
65
+ else
66
+ event.instance_eval(&transitions)
67
+ end
68
+
69
+ if event.transitions.empty?
70
+ raise Errors::WorkflowDefinitionError.new("No transitions defined for event [#{name}] on state [#{self.name}]")
71
+ end
72
+
73
+ events << event
74
+ nil
75
+ end
76
+
77
+ def inspect
78
+ "<State name=#{name.inspect} events(#{events.length})=#{events.inspect}>"
79
+ end
80
+
81
+ def <=>(other_state)
82
+ unless other_state.is_a?(State)
83
+ raise StandardError.new "Other State #{other_state} is a #{other_state.class}. I can only be compared with a Workflow::State."
84
+ end
85
+ self.sequence <=> other_state.sequence
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,54 @@
1
+ module Workflow
2
+ # During transitions, an instance of this class can be found
3
+ # on the object as `transition_context`.
4
+ # Contains metadata related to the current transition underway.
5
+ #
6
+ # == To name parameters:
7
+ #
8
+ # During workflow definition, do the following:
9
+ #
10
+ # before_transition :transition_handler
11
+ #
12
+ # def transition_handler
13
+ # transition_context.name1 # will equal 1
14
+ # transition_context.name2 # will equal 2
15
+ # transition_context.name3 # will equal 3
16
+ # end
17
+ #
18
+ # workflow do
19
+ # event_args :name1, :name2, :name3
20
+ # state :foo do
21
+ # event :bar, transitions_to: :bax
22
+ # end
23
+ # end
24
+ #
25
+ # Then later call:
26
+ #
27
+ # my_obj.submit! 1, 2, 3
28
+ #
29
+ # The entire list of passed parameters will still be available on +event_args+.
30
+ # If you pass fewer parameters, the later ones will simply be nil.
31
+ class TransitionContext
32
+ attr_reader :from, :to, :event, :event_args, :attributes, :named_arguments
33
+ def initialize(from:, to:, event:, event_args:, attributes:, named_arguments: [])
34
+ @from = from
35
+ @to = to
36
+ @event = event
37
+ @event_args = event_args
38
+ @attributes = attributes
39
+ @named_arguments = (named_arguments || []).zip(event_args).to_h
40
+ end
41
+
42
+ def values
43
+ [from, to, event, event_args]
44
+ end
45
+
46
+ def method_missing(method, *args)
47
+ if named_arguments.key?(method)
48
+ named_arguments[method]
49
+ else
50
+ super
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,3 @@
1
+ module Workflow
2
+ VERSION = "1.4.0"
3
+ end