rails-workflow 1.4.5.4 → 1.4.6.4

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 (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