nxt_state_machine 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/.rspec +3 -0
  4. data/.ruby-version +1 -0
  5. data/.travis.yml +7 -0
  6. data/Gemfile +4 -0
  7. data/Gemfile.lock +67 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +348 -0
  10. data/Rakefile +6 -0
  11. data/bin/console +14 -0
  12. data/bin/setup +8 -0
  13. data/lib/nxt_state_machine/callable.rb +63 -0
  14. data/lib/nxt_state_machine/callback_registry.rb +35 -0
  15. data/lib/nxt_state_machine/error_callback_registry.rb +38 -0
  16. data/lib/nxt_state_machine/errors/error.rb +1 -0
  17. data/lib/nxt_state_machine/errors/event_already_registered.rb +5 -0
  18. data/lib/nxt_state_machine/errors/event_without_transitions.rb +5 -0
  19. data/lib/nxt_state_machine/errors/initial_state_already_defined.rb +7 -0
  20. data/lib/nxt_state_machine/errors/invalid_callback_option.rb +5 -0
  21. data/lib/nxt_state_machine/errors/missing_configuration.rb +5 -0
  22. data/lib/nxt_state_machine/errors/state_already_registered.rb +5 -0
  23. data/lib/nxt_state_machine/errors/transition_already_registered.rb +5 -0
  24. data/lib/nxt_state_machine/errors/transition_halted.rb +12 -0
  25. data/lib/nxt_state_machine/errors/transition_not_defined.rb +5 -0
  26. data/lib/nxt_state_machine/errors/unknown_state_error.rb +5 -0
  27. data/lib/nxt_state_machine/event.rb +49 -0
  28. data/lib/nxt_state_machine/event_registry.rb +11 -0
  29. data/lib/nxt_state_machine/integrations/active_record.rb +77 -0
  30. data/lib/nxt_state_machine/integrations/attr_accessor.rb +69 -0
  31. data/lib/nxt_state_machine/integrations/hash.rb +67 -0
  32. data/lib/nxt_state_machine/state.rb +17 -0
  33. data/lib/nxt_state_machine/state_machine.rb +179 -0
  34. data/lib/nxt_state_machine/state_registry.rb +12 -0
  35. data/lib/nxt_state_machine/transition/around_callback_chain.rb +26 -0
  36. data/lib/nxt_state_machine/transition/proxy.rb +31 -0
  37. data/lib/nxt_state_machine/transition/store.rb +19 -0
  38. data/lib/nxt_state_machine/transition.rb +87 -0
  39. data/lib/nxt_state_machine/version.rb +3 -0
  40. data/lib/nxt_state_machine.rb +96 -0
  41. data/nxt_state_machine.gemspec +46 -0
  42. metadata +202 -0
@@ -0,0 +1,5 @@
1
+ module NxtStateMachine
2
+ module Errors
3
+ StateAlreadyRegistered = Class.new(Error)
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module NxtStateMachine
2
+ module Errors
3
+ TransitionAlreadyRegistered = Class.new(Error)
4
+ end
5
+ end
@@ -0,0 +1,12 @@
1
+ module NxtStateMachine
2
+ module Errors
3
+ class TransitionHalted < Error
4
+ def initialize(*args, **opts)
5
+ super(*args)
6
+ @options = opts
7
+ end
8
+
9
+ attr_reader :options
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ module NxtStateMachine
2
+ module Errors
3
+ TransitionNotDefined = Class.new(Error)
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module NxtStateMachine
2
+ module Errors
3
+ UnknownStateError = Class.new(Error)
4
+ end
5
+ end
@@ -0,0 +1,49 @@
1
+ module NxtStateMachine
2
+ class Event
3
+ include NxtRegistry
4
+
5
+ def initialize(name, state_machine:, &block)
6
+ @state_machine = state_machine
7
+ @name = name
8
+ @event_transitions = registry("#{name} event transitions")
9
+
10
+ configure(&block)
11
+
12
+ ensure_event_has_transitions
13
+ end
14
+
15
+ attr_reader :name, :state_machine, :event_transitions
16
+
17
+ delegate :before_transition,
18
+ :after_transition,
19
+ :around_transition,
20
+ :on_error,
21
+ :on_error!,
22
+ :any_state,
23
+ :all_states,
24
+ :all_states_except,
25
+ to: :state_machine
26
+
27
+ def transitions(from:, to:, &block)
28
+ Array(from).each do |from_state|
29
+ transition = Transition.new(name, from: from_state, to: to, state_machine: state_machine, &block)
30
+ state_machine.transitions << transition
31
+ event_transitions.register(from_state, transition)
32
+ end
33
+ end
34
+
35
+ alias_method :transition, :transitions
36
+
37
+ private
38
+
39
+ def configure(&block)
40
+ instance_exec(&block)
41
+ end
42
+
43
+ def ensure_event_has_transitions
44
+ return if event_transitions.size > 0
45
+
46
+ raise NxtStateMachine::Errors::EventWithoutTransitions, "No transitions for event :#{name} defined"
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,11 @@
1
+ module NxtStateMachine
2
+ class EventRegistry < NxtRegistry::Registry
3
+ def initialize
4
+ super :events do
5
+ on_key_already_registered do |key|
6
+ raise NxtStateMachine::Errors::EventAlreadyRegistered, "An event with the name '#{key}' was already registered!"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,77 @@
1
+ module NxtStateMachine
2
+ module ActiveRecord
3
+ module ClassMethods
4
+ def state_machine(name = :default, state_attr: :state, target: nil, &config)
5
+ machine = super(
6
+ name,
7
+ state_attr: state_attr,
8
+ target: target,
9
+ &config
10
+ )
11
+
12
+ machine.get_state_with do |target|
13
+ if target
14
+ if target.send(state_attr).nil? && target.new_record?
15
+ target.assign_attributes(state_attr => machine.initial_state.to_s)
16
+ end
17
+
18
+ current_state = target.send(state_attr)
19
+ current_state&.to_sym
20
+ end
21
+ end
22
+
23
+ machine.set_state_with do |target, transition|
24
+ target.transaction do
25
+ transition.run_before_callbacks
26
+ result = set_state(target, transition, state_attr, :save)
27
+ transition.run_after_callbacks
28
+
29
+ result
30
+ end
31
+ rescue StandardError => error
32
+ target.assign_attributes(state_attr => transition.from.to_s)
33
+
34
+ if error.is_a?(NxtStateMachine::Errors::TransitionHalted)
35
+ false
36
+ else
37
+ raise
38
+ end
39
+ end
40
+
41
+ machine.set_state_with! do |target, transition|
42
+ target.transaction do
43
+ transition.run_before_callbacks
44
+ result = set_state(target, transition, state_attr, :save!)
45
+ transition.run_after_callbacks
46
+
47
+ result
48
+ end
49
+ rescue StandardError
50
+ target.assign_attributes(state_attr => transition.from.to_s)
51
+ raise
52
+ end
53
+
54
+ machine
55
+ end
56
+ end
57
+
58
+ module InstanceMethods
59
+ private
60
+
61
+ def set_state(target, transition, state_attr, method)
62
+ transition.execute do |block|
63
+ result = block ? block.call : nil
64
+ target.assign_attributes(state_attr => transition.to.to_s)
65
+ set_state_result = target.send(method) || halt_transition
66
+ block ? result : set_state_result
67
+ end
68
+ end
69
+ end
70
+
71
+ def self.included(base)
72
+ base.include(NxtStateMachine)
73
+ base.include(InstanceMethods)
74
+ base.extend(ClassMethods)
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,69 @@
1
+ module NxtStateMachine
2
+ module AttrAccessor
3
+ module ClassMethods
4
+ def state_machine(name = :default, state_attr: :state, target: nil, &config)
5
+ machine = super(
6
+ name,
7
+ state_attr: state_attr,
8
+ target: target,
9
+ &config
10
+ )
11
+
12
+ machine.get_state_with do |target|
13
+ if target.send(state_attr).nil?
14
+ target.send("#{state_attr}=", initial_state.enum)
15
+ end
16
+
17
+ target.send(state_attr)
18
+ end
19
+
20
+ machine.set_state_with do |target, transition|
21
+ transition.run_before_callbacks
22
+ result = set_state(target, transition, state_attr)
23
+ transition.run_after_callbacks
24
+
25
+ result
26
+ rescue StandardError => error
27
+ target.send("#{state_attr}=", transition.from.enum)
28
+
29
+ if error.is_a?(NxtStateMachine::Errors::TransitionHalted)
30
+ false
31
+ else
32
+ raise
33
+ end
34
+ end
35
+
36
+ machine.set_state_with! do |target, transition|
37
+ transition.run_before_callbacks
38
+ result = set_state(target, transition, state_attr)
39
+ transition.run_after_callbacks
40
+
41
+ result
42
+ rescue StandardError
43
+ target.send("#{state_attr}=", transition.from.enum)
44
+ raise
45
+ end
46
+
47
+ machine
48
+ end
49
+ end
50
+
51
+ module InstanceMethods
52
+ private
53
+
54
+ def set_state(target, transition, state_attr)
55
+ transition.execute do |block|
56
+ result = block ? block.call : nil
57
+ set_state_result = target.send("#{state_attr}=", transition.to.enum)
58
+ block ? result : set_state_result
59
+ end
60
+ end
61
+ end
62
+
63
+ def self.included(base)
64
+ base.include(NxtStateMachine)
65
+ base.include(InstanceMethods)
66
+ base.extend(ClassMethods)
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,67 @@
1
+ module NxtStateMachine
2
+ module Hash
3
+ module ClassMethods
4
+ def state_machine(name = :default, state_attr: :state, target: nil, &config)
5
+ machine = super(
6
+ name,
7
+ state_attr: state_attr,
8
+ target: target,
9
+ &config
10
+ )
11
+
12
+ machine.get_state_with do |target|
13
+ if target[state_attr].nil?
14
+ target[state_attr] = initial_state.enum
15
+ end
16
+
17
+ target[state_attr]
18
+ end
19
+
20
+ machine.set_state_with do |target, transition|
21
+ transition.run_before_callbacks
22
+ result = set_state(target, transition, state_attr)
23
+ transition.run_after_callbacks
24
+ result
25
+ rescue StandardError => error
26
+ target[state_attr] = transition.from.enum
27
+
28
+ if error.is_a?(NxtStateMachine::Errors::TransitionHalted)
29
+ false
30
+ else
31
+ raise
32
+ end
33
+ end
34
+
35
+ machine.set_state_with! do |target, transition|
36
+ transition.run_before_callbacks
37
+ result = set_state(target, transition, state_attr)
38
+ transition.run_after_callbacks
39
+
40
+ result
41
+ rescue StandardError
42
+ target[state_attr] = transition.from.enum
43
+ raise
44
+ end
45
+
46
+ machine
47
+ end
48
+ end
49
+
50
+ module InstanceMethods
51
+ private
52
+
53
+ def set_state(target, transition, state_attr)
54
+ transition.execute do |block|
55
+ result = block ? block.call : nil
56
+ set_state_result = target[state_attr] = transition.to.enum || halt_transition
57
+ block ? result : set_state_result
58
+ end
59
+ end
60
+ end
61
+
62
+ def self.included(base)
63
+ base.include(NxtStateMachine)
64
+ base.extend(ClassMethods)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,17 @@
1
+ module NxtStateMachine
2
+ class State
3
+ def initialize(enum, machine, **opts)
4
+ @enum = enum
5
+ @machine = machine
6
+ @initial = opts.delete(:initial)
7
+ @transitions = []
8
+ @options = opts.with_indifferent_access
9
+ end
10
+
11
+ attr_accessor :enum, :initial, :transitions, :machine, :options
12
+
13
+ def to_s
14
+ enum.to_s
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,179 @@
1
+ module NxtStateMachine
2
+ class StateMachine
3
+ def initialize(name, class_context, event_registry, **opts)
4
+ @name = name
5
+ @class_context = class_context
6
+ @options = opts
7
+
8
+ @states = NxtStateMachine::StateRegistry.new
9
+ @transitions = Transition::Store.new
10
+ @events = event_registry
11
+ @callbacks = CallbackRegistry.new
12
+ @error_callback_registry = ErrorCallbackRegistry.new
13
+
14
+ @initial_state = nil
15
+ end
16
+
17
+ attr_reader :class_context, :states, :transitions, :events, :options, :callbacks, :name, :error_callback_registry
18
+ attr_accessor :initial_state
19
+
20
+ def get_state_with(method = nil, &block)
21
+ method_or_block = (method || block)
22
+ @get_state_with ||= method_or_block && Callable.new(method_or_block) ||
23
+ raise_missing_configuration_error(:get_state_with)
24
+ end
25
+
26
+ def set_state_with(method = nil, &block)
27
+ method_or_block = (method || block)
28
+ @set_state_with ||= method_or_block && Callable.new(method_or_block) ||
29
+ raise_missing_configuration_error(:set_state_with)
30
+ end
31
+
32
+ def set_state_with!(method = nil, &block)
33
+ method_or_block = (method || block)
34
+ @set_state_with_bang ||= method_or_block && Callable.new(method_or_block) ||
35
+ raise_missing_configuration_error(:set_state_with!)
36
+ end
37
+
38
+ def state(*names, **opts, &block)
39
+ defaults = { initial: false }
40
+ opts.reverse_merge!(defaults)
41
+ machine = self
42
+
43
+ Array(names).map do |name|
44
+ if opts.fetch(:initial) && initial_state.present?
45
+ raise NxtStateMachine::Errors::InitialStateAlreadyDefined, ":#{initial_state.enum} was already defined as the initial state"
46
+ else
47
+ state = new_state_class(&block).new(name, self, opts)
48
+ states.register(name, state)
49
+ self.initial_state = state if opts.fetch(:initial)
50
+
51
+ class_context.define_method "#{name}?" do
52
+ # States internally are always strings
53
+ machine.current_state_name(self) == name
54
+ end
55
+
56
+ state
57
+ end
58
+ end
59
+ end
60
+
61
+ def transitions
62
+ @transitions ||= events.values.flat_map(&:event_transitions)
63
+ end
64
+
65
+ def all_transitions_from_to(from: all_states, to: all_states)
66
+ transitions.select { |transition| transition.transitions_from_to?(from, to) }
67
+ end
68
+
69
+ def any_state
70
+ states.values.map(&:enum)
71
+ end
72
+
73
+ alias_method :all_states, :any_state
74
+
75
+ def all_states_except(*excluded)
76
+ all_states - excluded
77
+ end
78
+
79
+ def event(name, &block)
80
+ event = Event.new(name, state_machine: self, &block)
81
+ events.register(name, event)
82
+
83
+ class_context.define_method name do |*args, **opts|
84
+ event.state_machine.can_transition!(name, event.state_machine.current_state_name(self))
85
+ transition = event.event_transitions.resolve(event.state_machine.current_state_name(self))
86
+ transition.prepare(name, self, :set_state_with, *args, **opts)
87
+ end
88
+
89
+ class_context.define_method "#{name}!" do |*args, **opts|
90
+ event.state_machine.can_transition!(name, event.state_machine.current_state_name(self))
91
+ transition = event.event_transitions.resolve(event.state_machine.current_state_name(self))
92
+ transition.prepare("#{name}!", self, :set_state_with!, *args, **opts)
93
+ end
94
+
95
+ class_context.define_method "can_#{name}?" do
96
+ event.state_machine.can_transition?(name, event.state_machine.current_state_name(self))
97
+ end
98
+ end
99
+
100
+ def can_transition?(event_name, from)
101
+ normalized_event_name = event_name
102
+ event = events.resolve(normalized_event_name)
103
+ event && event.event_transitions.key?(from)
104
+ end
105
+
106
+ def can_transition!(event, from)
107
+ return true if can_transition?(event, from)
108
+ raise NxtStateMachine::Errors::TransitionNotDefined, "No transition :#{event} for state :#{from} defined"
109
+ end
110
+
111
+ def before_transition(from:, to:, run: nil, &block)
112
+ callbacks.register(from, to, :before, run, block)
113
+ end
114
+
115
+ def after_transition(from:, to:, run: nil, &block)
116
+ callbacks.register(from, to, :after, run, block)
117
+ end
118
+
119
+ def on_error(error = StandardError, from:, to:, run: nil, &block)
120
+ error_callback_registry.register(from, to, error, run, block)
121
+ end
122
+
123
+ def on_error!(error = StandardError, from:, to:, run: nil, &block)
124
+ error_callback_registry.register!(from, to, error, run, block)
125
+ end
126
+
127
+ def around_transition(from:, to:, run: nil, &block)
128
+ callbacks.register(from, to, :around, run, block)
129
+ end
130
+
131
+ def configure(&block)
132
+ instance_exec(&block)
133
+ self
134
+ end
135
+
136
+ def run_before_callbacks(transition, context)
137
+ run_callbacks(transition, :before, context)
138
+ end
139
+
140
+ def run_after_callbacks(transition, context)
141
+ run_callbacks(transition, :after, context)
142
+ end
143
+
144
+ def find_error_callback(error, transition)
145
+ error_callback_registry.resolve(error, transition)
146
+ end
147
+
148
+ def run_callbacks(transition, kind, context)
149
+ current_callbacks = callbacks.resolve(transition, kind)
150
+
151
+ current_callbacks.each do |callback|
152
+ Callable.new(callback).with_context(context).call(transition)
153
+ end
154
+ end
155
+
156
+ def current_state_name(context)
157
+ get_state_with.with_context(context).call(target(context))
158
+ end
159
+
160
+ def target(context)
161
+ @target_method ||= options[:target] || :itself
162
+ context.send(@target_method)
163
+ end
164
+
165
+ private
166
+
167
+ def raise_missing_configuration_error(method)
168
+ raise NxtStateMachine::Errors::MissingConfiguration, "Configuration method :#{method} was not defined"
169
+ end
170
+
171
+ def new_state_class(&block)
172
+ if block
173
+ Class.new(NxtStateMachine::State, &block)
174
+ else
175
+ NxtStateMachine::State
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,12 @@
1
+ module NxtStateMachine
2
+ class StateRegistry < NxtRegistry::Registry
3
+ def initialize
4
+ super :states do
5
+ on_key_already_registered do |key|
6
+ raise NxtStateMachine::Errors::StateAlreadyRegistered,
7
+ "A state with the name '#{key}' was already registered!"
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,26 @@
1
+ module NxtStateMachine
2
+ class Transition::AroundCallbackChain
3
+
4
+ def initialize(transition, context, state_machine)
5
+ @transition = transition
6
+ @context = context
7
+ @state_machine = state_machine
8
+ end
9
+
10
+ def build(proxy)
11
+ return proxy unless callbacks.any?
12
+
13
+ callbacks.map { |c| Callable.new(c).with_context(context) }.reverse.inject(proxy) do |previous, callback|
14
+ -> { callback.call(previous, transition) }
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def callbacks
21
+ @callbacks ||= state_machine.callbacks.resolve(transition).kind(:around)
22
+ end
23
+
24
+ attr_reader :transition, :context, :state_machine
25
+ end
26
+ end
@@ -0,0 +1,31 @@
1
+ module NxtStateMachine
2
+ class Transition::Proxy
3
+ def initialize(event, state_machine, transition, context)
4
+ @event = event
5
+ @transition = transition
6
+ @state_machine = state_machine
7
+ @context = context
8
+ end
9
+
10
+ def call(&block)
11
+ proxy = if block.arity == 1
12
+ Proc.new do
13
+ block.call(transition.block_proxy)
14
+ end
15
+ else
16
+ block
17
+ end
18
+
19
+ around_callback_chain(proxy).call
20
+ end
21
+
22
+ private
23
+
24
+ def around_callback_chain(proxy)
25
+ @around_callback_chain ||= Transition::AroundCallbackChain.new(transition, context, state_machine).build(proxy)
26
+ end
27
+
28
+ attr_reader :proxy, :transition, :state_machine, :context, :event
29
+ end
30
+ end
31
+
@@ -0,0 +1,19 @@
1
+ module NxtStateMachine
2
+ class Transition::Store < Array
3
+ def <<(transition)
4
+ ensure_transition_unique(transition)
5
+ super
6
+ end
7
+
8
+ alias_method :add, :<<
9
+
10
+ private
11
+
12
+ def ensure_transition_unique(transition)
13
+ return unless find { |other| other.from.enum == transition.from.enum && other.to.enum == transition.to.enum }
14
+
15
+ raise NxtStateMachine::Errors::TransitionAlreadyRegistered,
16
+ "A transition from :#{transition.from.enum} to :#{transition.to.enum} was already registered"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,87 @@
1
+ module NxtStateMachine
2
+ class Transition
3
+ def initialize(name, from:, to:, state_machine:, &block)
4
+ @name = name
5
+ @from = state_machine.states.resolve(from)
6
+ @to = state_machine.states.resolve(to)
7
+ @state_machine = state_machine
8
+ @block = block
9
+
10
+ # TODO: Write a spec that verifies that transitions are unique
11
+ ensure_states_exist
12
+ end
13
+
14
+ attr_reader :name, :from, :to
15
+
16
+ # TODO: Probably would make sense if we could also define the event name to be passed in
17
+ # => This way we could differentiate what event triggered the callback!!!
18
+
19
+ def prepare(event, context, set_state_with_method, *args, **opts)
20
+ # This exposes the transition block on the transition_to_execute so it can be executed later in :set_state_with
21
+ current_transition = clone
22
+ current_transition.send(:context=, context)
23
+ current_transition.send(:event=, event)
24
+ current_transition.send(:block_proxy=, nil)
25
+
26
+ # block_proxy only is set when the transition accepts a block!
27
+ if block
28
+ proxy = Proc.new do
29
+ # if the block takes arguments we always pass the transition as the first one
30
+ args.prepend(current_transition) if block.arity > 0
31
+ context.instance_exec(*args, **opts, &block)
32
+ end
33
+
34
+ current_transition.send(:block_proxy=, proxy)
35
+ end
36
+
37
+ state_machine.send(
38
+ set_state_with_method
39
+ ).with_context(
40
+ context
41
+ ).call(state_machine.target(context), current_transition)
42
+ end
43
+
44
+ def execute(&block)
45
+ # This is called on the cloned transition from above!
46
+ Transition::Proxy.new(event, state_machine,self, context).call(&block)
47
+ rescue StandardError => error
48
+ callback = state_machine.find_error_callback(error, self)
49
+ raise unless callback
50
+
51
+ Callable.new(callback).with_context(context).call(error, self)
52
+ end
53
+
54
+ alias_method :with_around_callbacks, :execute
55
+
56
+ def run_before_callbacks
57
+ state_machine.run_before_callbacks(self, context)
58
+ end
59
+
60
+ def run_after_callbacks
61
+ state_machine.run_after_callbacks(self, context)
62
+ end
63
+
64
+ def transitions_from_to?(from_state, to_state)
65
+ from.enum.in?(Array(from_state)) && to.enum.in?(Array(to_state))
66
+ end
67
+
68
+ def id
69
+ @id ||= "#{from.to_s}_#{to.to_s}"
70
+ end
71
+
72
+ attr_reader :block_proxy, :event
73
+
74
+ private
75
+
76
+ delegate :all_states, to: :state_machine
77
+
78
+ attr_reader :block, :state_machine
79
+ attr_accessor :context
80
+ attr_writer :block_proxy, :event
81
+
82
+ def ensure_states_exist
83
+ raise NxtStateMachine::Errors::UnknownStateError, "No state with :#{from} registered" unless state_machine.states.key?(from.enum)
84
+ raise NxtStateMachine::Errors::UnknownStateError, "No state with :#{to} registered" unless state_machine.states.key?(to.enum)
85
+ end
86
+ end
87
+ end