Class: Signoff::Definition

Inherits:
Object
  • Object
show all
Defined in:
lib/signoff/definition.rb

Overview

Immutable-once-validated description of a model’s workflow: its states, the allowed forward transitions, the reject state, authorization guards and lifecycle callbacks. Built by Signoff::DSL and stored on the model class as signoff_definition.

Constant Summary collapse

RESERVED_ACTIONS =

Built-in verbs that own their own transition methods on the model; a custom action may not reuse these names.

%i[submit approve reject].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(state_column: :approval_state) ⇒ Definition

Returns a new instance of Definition.



17
18
19
20
21
22
23
24
25
26
27
# File 'lib/signoff/definition.rb', line 17

def initialize(state_column: :approval_state)
  @states           = []
  @transitions      = {} # from(Symbol) => [to(Symbol), ...]
  @authorizations   = {} # from(Symbol) => callable guard
  @actions          = {} # name(Symbol) => { to: Symbol, from: [Symbol]|nil }
  @before_callbacks = []
  @after_callbacks  = []
  @initial_state    = nil
  @reject_state     = nil
  @state_column     = state_column.to_sym
end

Instance Attribute Details

#actionsObject (readonly)

Returns the value of attribute actions.



13
14
15
# File 'lib/signoff/definition.rb', line 13

def actions
  @actions
end

#after_callbacksObject (readonly)

Returns the value of attribute after_callbacks.



13
14
15
# File 'lib/signoff/definition.rb', line 13

def after_callbacks
  @after_callbacks
end

#authorizationsObject (readonly)

Returns the value of attribute authorizations.



13
14
15
# File 'lib/signoff/definition.rb', line 13

def authorizations
  @authorizations
end

#before_callbacksObject (readonly)

Returns the value of attribute before_callbacks.



13
14
15
# File 'lib/signoff/definition.rb', line 13

def before_callbacks
  @before_callbacks
end

#initial_stateObject

— queries ———————————————————



77
78
79
# File 'lib/signoff/definition.rb', line 77

def initial_state
  @initial_state || @states.first
end

#reject_stateObject

Returns the value of attribute reject_state.



73
74
75
# File 'lib/signoff/definition.rb', line 73

def reject_state
  @reject_state
end

#state_columnObject (readonly)

Returns the value of attribute state_column.



13
14
15
# File 'lib/signoff/definition.rb', line 13

def state_column
  @state_column
end

#statesObject (readonly)

Returns the value of attribute states.



13
14
15
# File 'lib/signoff/definition.rb', line 13

def states
  @states
end

#transitionsObject (readonly)

Returns the value of attribute transitions.



13
14
15
# File 'lib/signoff/definition.rb', line 13

def transitions
  @transitions
end

Instance Method Details

#action_allowed_from(name) ⇒ Object

States a custom action may be invoked from. An explicit from: wins; otherwise any non-finalized state except the action’s own target.



122
123
124
125
126
127
# File 'lib/signoff/definition.rb', line 122

def action_allowed_from(name)
  spec = @actions.fetch(name.to_sym)
  return spec[:from] if spec[:from]

  states - finalized_states - [spec[:to]]
end

#action_for(name) ⇒ Object



116
117
118
# File 'lib/signoff/definition.rb', line 116

def action_for(name)
  @actions[name.to_sym]
end

#action_namesObject



112
113
114
# File 'lib/signoff/definition.rb', line 112

def action_names
  @actions.keys
end

#action_target_statesObject

The distinct set of states reachable via custom actions.



108
109
110
# File 'lib/signoff/definition.rb', line 108

def action_target_states
  @actions.values.map { |spec| spec[:to] }.uniq
end

#add_action(name, to:, from: nil) ⇒ Object

Register a named custom action: a guarded “side transition” that moves a record to to and records an event whose action is the name verbatim. Like reject, the target is NOT a forward transition, so it never makes approve! ambiguous. from limits the source states (defaults to every non-finalized state except the target itself).

Raises:



58
59
60
61
62
63
64
65
66
67
# File 'lib/signoff/definition.rb', line 58

def add_action(name, to:, from: nil)
  name = name.to_sym
  raise DefinitionError, "duplicate action #{name.inspect}" if @actions.key?(name)
  raise DefinitionError, "action #{name.inspect} requires a :to state" if to.nil?

  @actions[name] = {
    to: to.to_sym,
    from: from.nil? ? nil : Array(from).map(&:to_sym)
  }
end

#add_authorization(from, guard) ⇒ Object



49
50
51
# File 'lib/signoff/definition.rb', line 49

def add_authorization(from, guard)
  @authorizations[from.to_sym] = guard
end

#add_state(name) ⇒ Object

— builders (called by the DSL) ————————————

Raises:



31
32
33
34
35
36
# File 'lib/signoff/definition.rb', line 31

def add_state(name)
  name = name.to_sym
  raise DefinitionError, "duplicate state #{name.inspect}" if @states.include?(name)

  @states << name
end

#add_transition(from, to) ⇒ Object



38
39
40
41
42
43
44
45
46
47
# File 'lib/signoff/definition.rb', line 38

def add_transition(from, to)
  Array(from).each do |source|
    source = source.to_sym
    Array(to).each do |target|
      target = target.to_sym
      @transitions[source] ||= []
      @transitions[source] << target unless @transitions[source].include?(target)
    end
  end
end

#approval_terminal_statesObject

Terminal states that represent successful completion: terminal via the forward flow and not reachable only as an off-ramp (reject or a custom action target). This keeps approved? true for genuine end states only.



94
95
96
# File 'lib/signoff/definition.rb', line 94

def approval_terminal_states
  terminal_states - [reject_state].compact - action_target_states
end

#finalized_statesObject

Every state that ends the workflow: successful terminals, the reject state, and any custom-action target that has no forward transition out (e.g. a cancel action’s cancelled state). Off-ramp targets that loop back (e.g. changes_requested -> re-submit) stay in flight, not finalized.



102
103
104
105
# File 'lib/signoff/definition.rb', line 102

def finalized_states
  terminal_action_targets = action_target_states.select { |s| forward_targets(s).empty? }
  (approval_terminal_states + [reject_state].compact + terminal_action_targets).uniq
end

#forward_targets(state) ⇒ Object

Targets reachable from state via a declared forward transition.



82
83
84
# File 'lib/signoff/definition.rb', line 82

def forward_targets(state)
  @transitions[state.to_sym] || []
end

#guard_for(state) ⇒ Object



129
130
131
# File 'lib/signoff/definition.rb', line 129

def guard_for(state)
  @authorizations[state.to_sym]
end

#next_state(from, to: nil) ⇒ Object

Resolve the next state when advancing forward from from. to may be supplied to disambiguate when several forward transitions exist.



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/signoff/definition.rb', line 135

def next_state(from, to: nil)
  from = from.to_sym
  targets = forward_targets(from)

  if to
    to = to.to_sym
    return to if targets.include?(to)

    raise InvalidTransitionError,
          "no transition declared from #{from.inspect} to #{to.inspect}"
  end

  case targets.size
  when 0
    raise InvalidTransitionError,
          "no forward transition declared from #{from.inspect}"
  when 1
    targets.first
  else
    raise InvalidTransitionError,
          "ambiguous transition from #{from.inspect}; pass to: " \
          "(one of #{targets.inspect})"
  end
end

#terminal_statesObject

States with no outgoing forward transition.



87
88
89
# File 'lib/signoff/definition.rb', line 87

def terminal_states
  @states.reject { |s| forward_targets(s).any? }
end

#validate!Object

— validation ——————————————————

Raises:



162
163
164
165
166
167
168
169
170
171
# File 'lib/signoff/definition.rb', line 162

def validate!
  raise DefinitionError, "no states have been defined" if @states.empty?

  validate_initial_state!
  validate_transitions!
  validate_reject_state!
  validate_authorizations!
  validate_actions!
  self
end