rails-workflow 1.4.5.4 → 1.4.6.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/.rubocop.yml +23 -0
  4. data/Gemfile +2 -1
  5. data/Rakefile +4 -4
  6. data/bin/console +3 -3
  7. data/lib/active_support/overloads.rb +13 -6
  8. data/lib/workflow.rb +12 -279
  9. data/lib/workflow/adapters/active_record.rb +57 -50
  10. data/lib/workflow/adapters/active_record_validations.rb +25 -19
  11. data/lib/workflow/adapters/adapter.rb +23 -0
  12. data/lib/workflow/adapters/remodel.rb +8 -9
  13. data/lib/workflow/callbacks.rb +60 -45
  14. data/lib/workflow/callbacks/callback.rb +23 -37
  15. data/lib/workflow/callbacks/method_callback.rb +12 -0
  16. data/lib/workflow/callbacks/proc_callback.rb +23 -0
  17. data/lib/workflow/callbacks/string_callback.rb +12 -0
  18. data/lib/workflow/callbacks/transition_callback.rb +88 -78
  19. data/lib/workflow/callbacks/transition_callbacks/method_caller.rb +53 -0
  20. data/lib/workflow/callbacks/transition_callbacks/proc_caller.rb +60 -0
  21. data/lib/workflow/configuration.rb +1 -0
  22. data/lib/workflow/definition.rb +73 -0
  23. data/lib/workflow/errors.rb +37 -6
  24. data/lib/workflow/event.rb +30 -15
  25. data/lib/workflow/helper_method_configurator.rb +100 -0
  26. data/lib/workflow/specification.rb +45 -22
  27. data/lib/workflow/state.rb +45 -36
  28. data/lib/workflow/transition_context.rb +5 -4
  29. data/lib/workflow/transitions.rb +94 -0
  30. data/lib/workflow/version.rb +2 -1
  31. data/rails-workflow.gemspec +18 -18
  32. data/tags.markdown +31 -0
  33. metadata +13 -5
  34. data/lib/workflow/callbacks/transition_callbacks/method_wrapper.rb +0 -102
  35. data/lib/workflow/callbacks/transition_callbacks/proc_wrapper.rb +0 -48
  36. data/lib/workflow/draw.rb +0 -79
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Workflow
2
3
  class Configuration
3
4
  attr_accessor :persist_workflow_state_immediately, :touch_on_update_column
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+ module Workflow
3
+ module Definition
4
+ extend ActiveSupport::Concern
5
+
6
+ # Returns a state object representing the current workflow state.
7
+ #
8
+ # @return [State] Current workflow state
9
+ def current_state
10
+ loaded_state = load_workflow_state
11
+ res = workflow_spec.states.find { |t| t.name == loaded_state.to_sym } if loaded_state
12
+ res || workflow_spec.initial_state
13
+ end
14
+
15
+ # The specification for this object.
16
+ # Could be set on a singleton for the object, on the object's class,
17
+ # Or else on a superclass of the object.
18
+ # @return [Specification] The Specification that applies to this object.
19
+ def workflow_spec
20
+ # check the singleton class first
21
+ class << self
22
+ return workflow_spec if workflow_spec
23
+ end
24
+
25
+ c = self.class
26
+ # using a simple loop instead of class_inheritable_accessor to avoid
27
+ # dependency on Rails' ActiveSupport
28
+ c = c.superclass until c.workflow_spec || !(c.include? Workflow)
29
+ c.workflow_spec
30
+ end
31
+
32
+ module ClassMethods
33
+ attr_reader :workflow_spec
34
+
35
+ ##
36
+ # Define workflow for the class.
37
+ #
38
+ # @yield [] Specification of workflow. Example below and in README.markdown
39
+ # @return [nil]
40
+ #
41
+ # Workflow definition takes place inside the yielded block.
42
+ # @see Specification::state
43
+ # @see Specification::event
44
+ #
45
+ # ~~~ruby
46
+ #
47
+ # class Article
48
+ # include Workflow
49
+ # workflow do
50
+ # state :new do
51
+ # event :submit, :transitions_to => :awaiting_review
52
+ # end
53
+ # state :awaiting_review do
54
+ # event :review, :transitions_to => :being_reviewed
55
+ # end
56
+ # state :being_reviewed do
57
+ # event :accept, :transitions_to => :accepted
58
+ # event :reject, :transitions_to => :rejected
59
+ # end
60
+ # state :accepted
61
+ # state :rejected
62
+ # end
63
+ # end
64
+ #
65
+ # ~~~
66
+ #
67
+ def workflow(&specification)
68
+ @workflow_spec = Specification.new({}, &specification)
69
+ HelperMethodConfigurator.new(@workflow_spec, self).configure!
70
+ end
71
+ end
72
+ end
73
+ end
@@ -1,29 +1,60 @@
1
+ # frozen_string_literal: true
1
2
  module Workflow
2
3
  module Errors
3
4
  class TransitionHaltedError < StandardError
4
-
5
5
  attr_reader :halted_because
6
6
 
7
7
  def initialize(msg = nil)
8
8
  @halted_because = msg
9
9
  super msg
10
10
  end
11
-
12
11
  end
13
12
 
14
13
  class NoMatchingTransitionError < StandardError
15
14
  end
16
15
 
17
- class NoTransitionAllowed < StandardError
16
+ class WorkflowDefinitionError < StandardError
18
17
  end
19
18
 
20
- class WorkflowError < StandardError
19
+ class NoTransitionsDefinedError < WorkflowDefinitionError
20
+ def initialize(state, event)
21
+ super("No transitions defined for event [#{event.name}] on state [#{state.name}]")
22
+ end
21
23
  end
22
24
 
23
- class CallbackArityError < StandardError
25
+ class DualEventDefinitionError < WorkflowDefinitionError
26
+ def initialize
27
+ super('Event target can only be received in the method call or the block, not both.')
28
+ end
24
29
  end
25
30
 
26
- class WorkflowDefinitionError < StandardError
31
+ class EventNameCollisionError < WorkflowDefinitionError
32
+ def initialize(state, event_name)
33
+ super("Already defined an event [#{event_name}] for state[#{state.name}]")
34
+ end
35
+ end
36
+
37
+ class StateComparisonError < StandardError
38
+ def initialize(state)
39
+ super("Other State #{state} is a #{state.class}.
40
+ I can only be compared with a Workflow::State.".squish)
41
+ end
42
+ end
43
+
44
+ class NoSuchStateError < WorkflowDefinitionError
45
+ def initialize(event, transition)
46
+ super("Event #{event.name} transitions to
47
+ #{transition.target_state} but there is no such state.".squish)
48
+ end
49
+ end
50
+
51
+ class NoTransitionAllowed < StandardError
52
+ def initialize(state, event_name)
53
+ super("There is no event #{event_name} defined for the #{state.name} state")
54
+ end
55
+ end
56
+
57
+ class WorkflowError < StandardError
27
58
  end
28
59
  end
29
60
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Workflow
2
3
  class Event
3
4
  # @!attribute [r] name
@@ -11,13 +12,17 @@ module Workflow
11
12
  # @api private
12
13
  # See {Workflow::State#on} for creating objects of this class.
13
14
  # @param [Symbol] name The name of the event to create.
14
- # @param [Hash] meta: Optional Metadata for this object.
15
+ # @param [Hash] meta Optional Metadata for this object.
15
16
  def initialize(name, meta: {})
16
17
  @name = name.to_sym
17
18
  @transitions = []
18
19
  @meta = meta || {}
19
20
  end
20
21
 
22
+ def valid?
23
+ transitions.any?
24
+ end
25
+
21
26
  def inspect
22
27
  "<Event name=#{name.inspect} transitions(#{transitions.length})=#{transitions.inspect}>"
23
28
  end
@@ -28,28 +33,37 @@ module Workflow
28
33
  # @param [Object] target An object of the class that this event was defined on.
29
34
  # @return [Workflow::State] The first applicable destination state, or nil if none.
30
35
  def evaluate(target)
31
- transitions.find{|transition|
36
+ transitions.find do |transition|
32
37
  transition.matches? target
33
- }&.target_state
38
+ end&.target_state
39
+ end
40
+
41
+ def evaluate!(target)
42
+ state_name = evaluate(target)
43
+ unless state_name
44
+ raise NoMatchingTransitionError, "No matching transition found on #{name}
45
+ for target #{target}. Consider adding a catchall transition.".squish
46
+ end
47
+ state_name
34
48
  end
35
49
 
36
50
  # Add a {Workflow::Transition} to the possible {#transitions} for this event.
37
51
  #
38
52
  # @param [Symbol] target_state the name of the state target state if this transition matches.
39
53
  # @option conditions_def [Symbol] :if Name of instance method to evaluate. e.g. `:valid?`
40
- # @option conditions_def [Array] :if Mixed array of Symbol, String or Proc conditions. All must match for the transition to apply.
41
- # @option conditions_def [String] :if A string to evaluate on the target. e.g. `"self.foo == :bar"`
42
- # @option conditions_def [Proc] :if A proc which will be evaluated on the object e.g. `->{self.foo == :bar}`
43
- # @option conditions_def [Symbol] :unless Same as `:if` except all conditions must **not** match.
54
+ # @option conditions_def [Array] :if Mixed array of Symbol, String or Proc conditions.
55
+ # All must match for the transition to apply.
56
+ # @option conditions_def [String] :if A string to evaluate on the target.
57
+ # e.g. `"self.foo == :bar"`
58
+ # @option conditions_def [Proc] :if A proc which will be evaluated on the object
59
+ # e.g. `->{self.foo == :bar}`
60
+ # @option conditions_def [Symbol] :unless Same Like `:if` but all conditions must **not** match
44
61
  # @yield [] Optional block which, if provided, becomes an `:if` condition for the transition.
45
62
  # @return [nil]
46
63
  def to(target_state, **conditions_def, &block)
47
- conditions = Conditions.new &&conditions_def, block
48
- self.transitions << Transition.new(target_state, conditions_def, &block)
64
+ transitions << Transition.new(target_state, conditions_def, &block)
49
65
  end
50
66
 
51
- private
52
-
53
67
  # @api private
54
68
  # Represents a possible transition via the event on which it is defined.
55
69
  class Transition
@@ -77,6 +91,7 @@ module Workflow
77
91
  end
78
92
 
79
93
  private
94
+
80
95
  # @!attribute [r] conditions
81
96
  # @return [Workflow::Event::Conditions] Conditions for this transition.
82
97
  attr_reader :conditions
@@ -91,7 +106,7 @@ module Workflow
91
106
  def initialize(**options, &block)
92
107
  @if = Array(options[:if])
93
108
  @unless = Array(options[:unless])
94
- @if << block if block_given?
109
+ @if << block if block_given?
95
110
  @conditions_lambdas = conditions_lambdas
96
111
  end
97
112
 
@@ -100,14 +115,14 @@ module Workflow
100
115
  end
101
116
 
102
117
  def apply?(target)
103
- @conditions_lambdas.all?{|l| l.call(target)}
118
+ @conditions_lambdas.all? { |l| l.call(target) }
104
119
  end
105
120
 
106
121
  private
107
122
 
108
123
  def conditions_lambdas
109
- @if.map { |c| Callbacks::Callback.new c } +
110
- @unless.map { |c| Callbacks::Callback.new c, true }
124
+ @if.map { |c| Callbacks::Callback.build c } +
125
+ @unless.map { |c| Callbacks::Callback.inverted c }
111
126
  end
112
127
  end
113
128
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+ module Workflow
3
+ class HelperMethodConfigurator
4
+ attr_reader :workflow_spec, :workflow_class
5
+
6
+ def initialize(workflow_spec, workflow_class)
7
+ @workflow_spec = workflow_spec
8
+ @workflow_class = workflow_class
9
+ end
10
+
11
+ def configure!
12
+ undefine_methods_defined_by_workflow_spec if inherited_workflow_spec?
13
+ define_revert_events if workflow_spec.define_revert_events?
14
+ create_instance_methods
15
+ end
16
+
17
+ def create_instance_methods
18
+ workflow_spec.states.each do |state|
19
+ state_name = state.name
20
+ workflow_class.module_eval do
21
+ define_method "#{state_name}?" do
22
+ state_name == current_state.name
23
+ end
24
+ end
25
+
26
+ state.events.each do |event|
27
+ define_method_for_event(event) unless event_method?(event)
28
+ end
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def define_revert_events
35
+ workflow_spec.states.each do |state|
36
+ reversible_events(state).each do |event|
37
+ revert_event_name = "revert_#{event.name}".to_sym
38
+ from_state_for_revert = event.transitions.first.target_state
39
+ from_state_for_revert.on revert_event_name, to: state
40
+ end
41
+ end
42
+ end
43
+
44
+ def reversible_events(state)
45
+ state.events.select do |e|
46
+ e.name !~ /^revert_/ && e.transitions.length == 1
47
+ end
48
+ end
49
+
50
+ def event_method?(event)
51
+ workflow_class.instance_methods.include?(event_method_name(event))
52
+ end
53
+
54
+ def event_method_name(event)
55
+ "#{event.name}!".to_sym
56
+ end
57
+
58
+ def define_method_for_event(event)
59
+ workflow_class.module_eval do
60
+ define_method "#{event.name}!".to_sym do |*args|
61
+ transition!(event.name, *args)
62
+ end
63
+
64
+ define_method "can_#{event.name}?" do
65
+ current_state.find_event(event.name)&.evaluate(self)
66
+ end
67
+ end
68
+ end
69
+
70
+ def inherited_workflow_spec?
71
+ workflow_class.superclass.respond_to?(:workflow_spec, true) &&
72
+ workflow_class.superclass.workflow_spec
73
+ end
74
+
75
+ def undefine_methods_defined_by_workflow_spec
76
+ superclass_workflow_spec.states.each do |state|
77
+ workflow_class.class_exec(state.name, &undef_state_method_proc)
78
+
79
+ state.events.each do |event|
80
+ workflow_class.class_exec(event.name, &undef_event_method_procs)
81
+ end
82
+ end
83
+ end
84
+
85
+ def superclass_workflow_spec
86
+ workflow_class.superclass.workflow_spec
87
+ end
88
+
89
+ def undef_state_method_proc
90
+ -> (state) { undef_method "#{state}?" }
91
+ end
92
+
93
+ def undef_event_method_procs
94
+ lambda do |event_name|
95
+ undef_method "#{event_name}!"
96
+ undef_method "can_#{event_name}?"
97
+ end
98
+ end
99
+ end
100
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'workflow/state'
2
3
  require 'workflow/event'
3
4
  require 'workflow/errors'
@@ -24,17 +25,6 @@ module Workflow
24
25
  attr_reader :named_arguments
25
26
 
26
27
  define_callbacks :spec_definition
27
- set_callback(:spec_definition, :after, if: :define_revert_events?) do |spec|
28
- spec.states.each do |state|
29
- state.events.reject{ |e|
30
- e.name.to_s =~ /^revert_/
31
- }.select{|e| e.transitions.length == 1}.each do |event|
32
- revert_event_name = "revert_#{event.name}".to_sym
33
- from_state_for_revert = event.transitions.first.target_state
34
- from_state_for_revert.on revert_event_name, to: state
35
- end
36
- end
37
- end
38
28
 
39
29
  set_callback(:spec_definition, :after) do |spec|
40
30
  spec.states.each do |state|
@@ -42,7 +32,7 @@ module Workflow
42
32
  event.transitions.each do |transition|
43
33
  target_state = spec.find_state(transition.target_state)
44
34
  if target_state.nil?
45
- raise Workflow::Errors::WorkflowDefinitionError.new("Event #{event.name} transitions to #{transition.target_state} but there is no such state.")
35
+ raise Errors::NoSuchStateError.new(event, transition)
46
36
  end
47
37
  transition.target_state = target_state
48
38
  end
@@ -50,12 +40,14 @@ module Workflow
50
40
  end
51
41
  end
52
42
 
43
+ set_callback(:spec_definition, :after, :define_tag_methods)
44
+
53
45
  # Find the state with the given name.
54
46
  #
55
47
  # @param [Symbol] name Name of state to find.
56
48
  # @return [Workflow::State] The state with the given name.
57
49
  def find_state(name)
58
- states.find{|t| t.name == name.to_sym}
50
+ states.find { |t| t.name == name.to_sym }
59
51
  end
60
52
 
61
53
  # @api private
@@ -75,18 +67,17 @@ module Workflow
75
67
  #
76
68
  # @param [Symbol] name name of state
77
69
  # @param [Hash] meta Metadata to be stored with the state within the {Specification} object
70
+ # @param [Array] tags Tags to apply to the {Workflow::State} object
78
71
  # @yield [] block defining events for this state.
79
72
  # @return [nil]
80
- def state(name, meta: {}, &events)
73
+ def state(name, tags: [], meta: {}, &events)
81
74
  name = name.to_sym
82
- new_state = Workflow::State.new(name, @states.length, meta: meta)
75
+ new_state = Workflow::State.new(name, @states.length, tags: tags, meta: meta)
83
76
  @initial_state ||= new_state
84
77
  @states << new_state
85
78
  new_state.instance_eval(&events) if block_given?
86
79
  end
87
80
 
88
-
89
-
90
81
  # Specify attributes to make available on the {TransitionContext} object
91
82
  # during transitions taking place in this specification.
92
83
  # The attributes' values will be taken in order from the arguments passed to
@@ -98,13 +89,12 @@ module Workflow
98
89
  @named_arguments = names
99
90
  end
100
91
 
101
-
102
92
  # Also create additional event transitions that will move each configured transition
103
93
  # in the reverse direction.
104
94
  #
105
95
  # @return [nil]
106
96
  #
107
- #```ruby
97
+ # ```ruby
108
98
  # class Article
109
99
  # include Workflow
110
100
  # workflow do
@@ -121,17 +111,50 @@ module Workflow
121
111
  # a.current_state.name # => :bax
122
112
  # a.transition! :revert_bar
123
113
  # a.current_state.name # => :foo
124
- #```
114
+ # ```
125
115
  def define_revert_events!
126
116
  @define_revert_events = true
127
117
  end
128
118
 
119
+ def unique_event_names
120
+ states.collect(&:events).flatten.collect(&:name).flatten.uniq
121
+ end
122
+
123
+ def define_revert_events?
124
+ @define_revert_events
125
+ end
126
+
129
127
  private
130
128
 
129
+ module TagHelpers
130
+ def initial?
131
+ sequence.zero?
132
+ end
131
133
 
132
- def define_revert_events?
133
- !!@define_revert_events
134
+ def terminal?
135
+ events.empty?
136
+ end
134
137
  end
135
138
 
139
+ def define_tag_methods
140
+ tags = states.map(&:tags).flatten.uniq
141
+ tag_method_module = build_tag_method_module(tags)
142
+ states.each do |state|
143
+ state.send :extend, tag_method_module
144
+ end
145
+ end
146
+
147
+ def build_tag_method_module(tags)
148
+ tag_method_module = Module.new
149
+ tag_method_module.send :include, TagHelpers
150
+ tag_method_module.class_eval do
151
+ tags.each do |tag|
152
+ define_method "#{tag}?" do
153
+ self.tags.include?(tag)
154
+ end
155
+ end
156
+ end
157
+ tag_method_module
158
+ end
136
159
  end
137
160
  end