statecraft 0.1.0
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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +354 -0
- data/lib/generators/statecraft/machine/machine_generator.rb +105 -0
- data/lib/statecraft/errors.rb +120 -0
- data/lib/statecraft/instrumentation.rb +47 -0
- data/lib/statecraft/introspection.rb +78 -0
- data/lib/statecraft/machine.rb +315 -0
- data/lib/statecraft/metadata.rb +51 -0
- data/lib/statecraft/mounting.rb +184 -0
- data/lib/statecraft/pipeline.rb +326 -0
- data/lib/statecraft/version.rb +5 -0
- data/lib/statecraft/warnings.rb +30 -0
- data/lib/statecraft.rb +18 -0
- metadata +96 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
# The record-facing questions: "would the guards pass if I fired this
|
|
5
|
+
# transition with these metadata right now". Normalization and freeze are
|
|
6
|
+
# identical to the pipeline's, so a guard that mutates metadata fails the
|
|
7
|
+
# same way in a check as in a transition, and the answer never diverges from
|
|
8
|
+
# what fire! would actually do. Every answer is a snapshot: CAS may still
|
|
9
|
+
# reject the transition later.
|
|
10
|
+
module Introspection
|
|
11
|
+
Availability = Struct.new(:to, :via, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
def can_fire?(event_name, metadata: {})
|
|
14
|
+
graph = statecraft_graph
|
|
15
|
+
branches = graph.events[event_name.to_sym]
|
|
16
|
+
return false unless branches
|
|
17
|
+
|
|
18
|
+
edge = branches[statecraft_current_state]
|
|
19
|
+
return false unless edge
|
|
20
|
+
|
|
21
|
+
statecraft_guards_pass?(edge, event_name.to_sym, Metadata.normalize(metadata))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def available_events(metadata: {})
|
|
25
|
+
normalized = Metadata.normalize(metadata)
|
|
26
|
+
statecraft_graph.events.filter_map do |event_name, branches|
|
|
27
|
+
edge = branches[statecraft_current_state]
|
|
28
|
+
next unless edge
|
|
29
|
+
|
|
30
|
+
event_name if statecraft_guards_pass?(edge, event_name, normalized)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def available_transitions(metadata: {})
|
|
35
|
+
normalized = Metadata.normalize(metadata)
|
|
36
|
+
statecraft_graph.edges.filter_map do |(from, _to), edge|
|
|
37
|
+
next unless from == statecraft_current_state
|
|
38
|
+
|
|
39
|
+
via = statecraft_passable_via(edge, normalized)
|
|
40
|
+
Availability.new(to: edge.to, via: via) unless via.empty?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def transitioned_to?(state_name)
|
|
45
|
+
history.where(to_state: state_name.to_s).exists?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def statecraft_passable_via(edge, normalized_metadata)
|
|
51
|
+
via = edge.event_names.select do |event_name|
|
|
52
|
+
statecraft_guards_pass?(edge, event_name, normalized_metadata)
|
|
53
|
+
end
|
|
54
|
+
direct = statecraft_direct_legal?(edge) && statecraft_guards_pass?(edge, nil, normalized_metadata)
|
|
55
|
+
direct ? via + [:direct] : via
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def statecraft_direct_legal?(edge)
|
|
59
|
+
edge.event_guards.values.all?(&:empty?)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def statecraft_guards_pass?(edge, event_name, normalized_metadata)
|
|
63
|
+
machine_instance = self.class.statecraft_mounting.machine_class.new
|
|
64
|
+
guards = edge.edge_guards + (event_name ? edge.event_guards.fetch(event_name, []) : [])
|
|
65
|
+
guards.all? do |guard|
|
|
66
|
+
Machine::Handlers.invoke(machine_instance, guard, self, normalized_metadata)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def statecraft_graph
|
|
71
|
+
self.class.statecraft_mounting.machine_class.finalize!
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def statecraft_current_state
|
|
75
|
+
self[self.class.statecraft_mounting.column].to_s.to_sym
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
# The machine DSL and its compiler. A machine is declared in a dedicated
|
|
5
|
+
# class (`include Statecraft::Machine`), collects states, edges, events and
|
|
6
|
+
# callbacks, and compiles them into a deep-frozen graph on finalization.
|
|
7
|
+
# Guard and callback symbols resolve to instance methods of the machine
|
|
8
|
+
# class; callables are honored with a plain `call` and arity dispatch.
|
|
9
|
+
module Machine
|
|
10
|
+
Edge = Struct.new(:from, :to, :lock, :edge_guards, :event_names, :event_guards, keyword_init: true)
|
|
11
|
+
Callback = Struct.new(:handler, :from, :to, :event, keyword_init: true)
|
|
12
|
+
CompiledGraph = Struct.new(
|
|
13
|
+
:states, :initial_state, :edges, :events, :callbacks,
|
|
14
|
+
keyword_init: true
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
CALLBACK_PHASES = %i[before_transition after_transition after_commit].freeze
|
|
18
|
+
|
|
19
|
+
def self.included(machine_class)
|
|
20
|
+
machine_class.extend(ClassMethods)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Invokes a guard or callback handler with the honest-call convention:
|
|
24
|
+
# a Symbol resolves to a (possibly private) instance method of the machine,
|
|
25
|
+
# a callable is called as-is with `self` untouched. Arity 1 receives the
|
|
26
|
+
# record alone, everything else receives (record, payload) — the same
|
|
27
|
+
# dispatch Active Record validators use.
|
|
28
|
+
module Handlers
|
|
29
|
+
def self.invoke(machine_instance, handler, record, payload)
|
|
30
|
+
callable = resolve(machine_instance, handler)
|
|
31
|
+
if unary?(callable)
|
|
32
|
+
callable.call(record)
|
|
33
|
+
else
|
|
34
|
+
callable.call(record, payload)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.resolve(machine_instance, handler)
|
|
39
|
+
handler.is_a?(Symbol) ? machine_instance.method(handler) : handler
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.unary?(callable)
|
|
43
|
+
callable.arity == 1
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Class-level DSL collected declaratively and compiled by finalize!.
|
|
48
|
+
module ClassMethods
|
|
49
|
+
def state(name, initial: false)
|
|
50
|
+
declared_states << { name: name, initial: initial }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def transition(from:, to:, guard: nil, lock: false)
|
|
54
|
+
declared_edges << {
|
|
55
|
+
from: from, to: to, guards: Array(guard), lock: lock || current_event_lock,
|
|
56
|
+
event: current_event_name
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def event(name, from: nil, to: nil, guard: nil, lock: false, &declarations)
|
|
61
|
+
declared_event_names << name
|
|
62
|
+
if declarations
|
|
63
|
+
if from || to
|
|
64
|
+
raise CompilationError, "event #{name.inspect} takes either inline from:/to: or a block, not both"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
@statecraft_current_event = { name: name, lock: lock }
|
|
69
|
+
yield
|
|
70
|
+
ensure
|
|
71
|
+
@statecraft_current_event = nil
|
|
72
|
+
end
|
|
73
|
+
else
|
|
74
|
+
raise CompilationError, "event #{name.inspect} needs from:/to: or a block" if from.nil? || to.nil?
|
|
75
|
+
|
|
76
|
+
@statecraft_current_event = { name: name, lock: false }
|
|
77
|
+
begin
|
|
78
|
+
transition(from: from, to: to, guard: guard, lock: lock)
|
|
79
|
+
ensure
|
|
80
|
+
@statecraft_current_event = nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
CALLBACK_PHASES.each do |phase|
|
|
86
|
+
define_method(phase) do |handler = nil, from: nil, to: nil, event: nil, &block|
|
|
87
|
+
callback_handler = handler || block
|
|
88
|
+
raise CompilationError, "#{phase} needs a method name or a callable" if callback_handler.nil?
|
|
89
|
+
|
|
90
|
+
declared_callbacks[phase] << Callback.new(
|
|
91
|
+
handler: callback_handler,
|
|
92
|
+
from: from && Array(from), to: to && Array(to), event: event && Array(event)
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def states
|
|
98
|
+
compiled_graph.states
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def events
|
|
102
|
+
compiled_graph.events.keys
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def initial_state
|
|
106
|
+
compiled_graph.initial_state
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def compiled_graph
|
|
110
|
+
finalize!
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def finalized?
|
|
114
|
+
!@statecraft_compiled_graph.nil?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def finalize!
|
|
118
|
+
@statecraft_compiled_graph ||= Compiler.new(self).compile
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def declared_states
|
|
122
|
+
@statecraft_declared_states ||= []
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def declared_edges
|
|
126
|
+
@statecraft_declared_edges ||= []
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def declared_event_names
|
|
130
|
+
@statecraft_declared_event_names ||= []
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def declared_callbacks
|
|
134
|
+
@statecraft_declared_callbacks ||= CALLBACK_PHASES.to_h { |phase| [phase, []] }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def current_event_name
|
|
140
|
+
@statecraft_current_event && @statecraft_current_event[:name]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def current_event_lock
|
|
144
|
+
@statecraft_current_event ? @statecraft_current_event[:lock] : false
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Validates the declarations and produces the deep-frozen graph. All five
|
|
149
|
+
# compilation errors live here, plus symbol resolution: every Symbol guard
|
|
150
|
+
# or callback must exist as an instance method of the machine class at
|
|
151
|
+
# finalization time (never checked at the declaration line, so a guard may
|
|
152
|
+
# be declared above its def).
|
|
153
|
+
class Compiler
|
|
154
|
+
def initialize(machine_class)
|
|
155
|
+
@machine_class = machine_class
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def compile
|
|
159
|
+
states = compile_states
|
|
160
|
+
initial = compile_initial(states)
|
|
161
|
+
edges = compile_edges(states)
|
|
162
|
+
events = compile_events(edges)
|
|
163
|
+
resolve_symbols(edges)
|
|
164
|
+
CompiledGraph.new(
|
|
165
|
+
states: states.freeze,
|
|
166
|
+
initial_state: initial,
|
|
167
|
+
edges: deep_freeze_edges(edges),
|
|
168
|
+
events: freeze_events(events),
|
|
169
|
+
callbacks: freeze_callbacks
|
|
170
|
+
).freeze
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
attr_reader :machine_class
|
|
176
|
+
|
|
177
|
+
def compile_states
|
|
178
|
+
names = machine_class.declared_states.map { |declaration| declaration[:name] }
|
|
179
|
+
duplicate = names.tally.find { |_name, count| count > 1 }
|
|
180
|
+
raise CompilationError, "state #{duplicate.first.inspect} is declared twice" if duplicate
|
|
181
|
+
|
|
182
|
+
names
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def compile_initial(states)
|
|
186
|
+
initials = machine_class.declared_states.select { |declaration| declaration[:initial] }.map { |d| d[:name] }
|
|
187
|
+
raise CompilationError, "exactly one initial state is required, got none" if initials.empty?
|
|
188
|
+
if initials.length > 1
|
|
189
|
+
raise CompilationError, "exactly one initial state is required, got #{initials.join(", ")}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
initials.first.tap { |initial| assert_known_state(initial, states) }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def compile_edges(states)
|
|
196
|
+
edges = {}
|
|
197
|
+
machine_class.declared_edges.each do |declaration|
|
|
198
|
+
assert_known_state(declaration[:from], states)
|
|
199
|
+
assert_known_state(declaration[:to], states)
|
|
200
|
+
pair = [declaration[:from], declaration[:to]]
|
|
201
|
+
edge = edges[pair]
|
|
202
|
+
if declaration[:event].nil?
|
|
203
|
+
raise CompilationError, "duplicate edge #{pair_name(pair)}" if edge
|
|
204
|
+
|
|
205
|
+
edges[pair] = build_edge(declaration)
|
|
206
|
+
else
|
|
207
|
+
edges[pair] = attach_event(edge, declaration, pair)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
edges
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def build_edge(declaration)
|
|
214
|
+
Edge.new(
|
|
215
|
+
from: declaration[:from], to: declaration[:to], lock: declaration[:lock],
|
|
216
|
+
edge_guards: declaration[:guards], event_names: [], event_guards: {}
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def attach_event(edge, declaration, pair)
|
|
221
|
+
event_name = declaration[:event]
|
|
222
|
+
edge ||= Edge.new(
|
|
223
|
+
from: declaration[:from], to: declaration[:to], lock: false,
|
|
224
|
+
edge_guards: [], event_names: [], event_guards: {}
|
|
225
|
+
)
|
|
226
|
+
if edge.event_names.include?(event_name)
|
|
227
|
+
raise CompilationError, "event #{event_name.inspect} declares edge #{pair_name(pair)} twice"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
edge.event_names << event_name
|
|
231
|
+
edge.event_guards[event_name] = declaration[:guards]
|
|
232
|
+
edge.lock ||= declaration[:lock]
|
|
233
|
+
edge
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def compile_events(edges)
|
|
237
|
+
events = machine_class.declared_event_names.to_h { |name| [name, {}] }
|
|
238
|
+
edges.each_value do |edge|
|
|
239
|
+
edge.event_names.each do |event_name|
|
|
240
|
+
branches = events[event_name]
|
|
241
|
+
if branches.key?(edge.from)
|
|
242
|
+
raise CompilationError,
|
|
243
|
+
"event #{event_name.inspect} has two edges from #{edge.from.inspect}: " \
|
|
244
|
+
"within one event, from must be unique"
|
|
245
|
+
end
|
|
246
|
+
branches[edge.from] = edge
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
events.each do |name, branches|
|
|
250
|
+
raise CompilationError, "event #{name.inspect} has no edges" if branches.empty?
|
|
251
|
+
end
|
|
252
|
+
events
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def resolve_symbols(edges)
|
|
256
|
+
symbol_handlers(edges).each do |symbol|
|
|
257
|
+
next if machine_class.method_defined?(symbol) || machine_class.private_method_defined?(symbol)
|
|
258
|
+
|
|
259
|
+
raise CompilationError,
|
|
260
|
+
"guard or callback #{symbol.inspect} is not defined on #{machine_class.name || "the machine class"}"
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def symbol_handlers(edges)
|
|
265
|
+
from_edges = edges.each_value.flat_map do |edge|
|
|
266
|
+
edge.edge_guards + edge.event_guards.values.flatten
|
|
267
|
+
end
|
|
268
|
+
from_callbacks = machine_class.declared_callbacks.each_value.flat_map do |callbacks|
|
|
269
|
+
callbacks.map(&:handler)
|
|
270
|
+
end
|
|
271
|
+
(from_edges + from_callbacks).select { |handler| handler.is_a?(Symbol) }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def deep_freeze_edges(edges)
|
|
275
|
+
edges.each_value do |edge|
|
|
276
|
+
edge.edge_guards.freeze
|
|
277
|
+
edge.event_names.freeze
|
|
278
|
+
edge.event_guards.each_value(&:freeze)
|
|
279
|
+
edge.event_guards.freeze
|
|
280
|
+
edge.freeze
|
|
281
|
+
end
|
|
282
|
+
edges.freeze
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def freeze_events(events)
|
|
286
|
+
events.each_value(&:freeze)
|
|
287
|
+
events.freeze
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def freeze_callbacks
|
|
291
|
+
callbacks = machine_class.declared_callbacks
|
|
292
|
+
callbacks.each_value do |list|
|
|
293
|
+
list.each do |callback|
|
|
294
|
+
callback.from&.freeze
|
|
295
|
+
callback.to&.freeze
|
|
296
|
+
callback.event&.freeze
|
|
297
|
+
callback.freeze
|
|
298
|
+
end
|
|
299
|
+
list.freeze
|
|
300
|
+
end
|
|
301
|
+
callbacks.freeze
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def assert_known_state(name, states)
|
|
305
|
+
return if states.include?(name)
|
|
306
|
+
|
|
307
|
+
raise CompilationError, "unknown state #{name.inspect}: declare it with state #{name.inspect}"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def pair_name(pair)
|
|
311
|
+
"#{pair.first} -> #{pair.last}"
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
# Normalizes transition metadata to exactly what will survive the jsonb
|
|
5
|
+
# round-trip, then deep-freezes it: what the guards checked is what the log
|
|
6
|
+
# stored. Symbols become strings (keys and values), times become ISO-8601
|
|
7
|
+
# strings, and anything that JSON cannot represent fails instantly at the
|
|
8
|
+
# pipeline entrance instead of inside the transaction.
|
|
9
|
+
module Metadata
|
|
10
|
+
def self.normalize(raw_metadata)
|
|
11
|
+
deep_freeze(round_trip(raw_metadata))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.round_trip(value)
|
|
15
|
+
case value
|
|
16
|
+
when Hash
|
|
17
|
+
value.to_h { |key, nested| [round_trip_key(key), round_trip(nested)] }
|
|
18
|
+
when Array
|
|
19
|
+
value.map { |element| round_trip(element) }
|
|
20
|
+
when String then value.dup
|
|
21
|
+
when Symbol then value.to_s
|
|
22
|
+
when Integer, Float, true, false, nil then value
|
|
23
|
+
when Time, Date, DateTime then value.iso8601
|
|
24
|
+
else
|
|
25
|
+
raise ArgumentError,
|
|
26
|
+
"metadata value #{value.inspect} (#{value.class}) is not JSON-serializable; " \
|
|
27
|
+
"metadata must round-trip through jsonb"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.round_trip_key(key)
|
|
32
|
+
case key
|
|
33
|
+
when String then key.dup
|
|
34
|
+
when Symbol, Integer then key.to_s
|
|
35
|
+
else
|
|
36
|
+
raise ArgumentError,
|
|
37
|
+
"metadata key #{key.inspect} (#{key.class}) is not a JSON object key"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.deep_freeze(value)
|
|
42
|
+
case value
|
|
43
|
+
when Hash
|
|
44
|
+
value.each { |key, nested| key.freeze && deep_freeze(nested) }
|
|
45
|
+
when Array
|
|
46
|
+
value.each { |element| deep_freeze(element) }
|
|
47
|
+
end
|
|
48
|
+
value.freeze
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
# The model-side macro. `state_machine OrderFlow, ...` mounts a compiled
|
|
5
|
+
# machine onto an ActiveRecord model: resolves the log class (convention
|
|
6
|
+
# `<base_class>Transition`, overridable via log:), runs the mounting-time
|
|
7
|
+
# checks (AlreadyMounted, composite primary keys, connection-class identity),
|
|
8
|
+
# finalizes the machine, verifies helper and scope name conflicts, generates
|
|
9
|
+
# scopes, and defines the reading surface (history, last_transition,
|
|
10
|
+
# in_state?). Everything internal goes through base_class.
|
|
11
|
+
module Mounting
|
|
12
|
+
Configuration = Struct.new(
|
|
13
|
+
:machine_class, :log_class, :column, :changed_at_column, :touch, :helpers, :scopes,
|
|
14
|
+
:log_foreign_key,
|
|
15
|
+
keyword_init: true
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def state_machine(machine_class, log: nil, column: :state, changed_at: false, touch: true,
|
|
19
|
+
helpers: false, scopes: false)
|
|
20
|
+
Builder.new(
|
|
21
|
+
model: self, machine_class: machine_class, log: log, column: column,
|
|
22
|
+
changed_at: changed_at, touch: touch, helpers: helpers, scopes: scopes
|
|
23
|
+
).mount
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Performs the mounting steps in threshold order. Every check here works on
|
|
27
|
+
# class hierarchies only — never on the schema and never on a live
|
|
28
|
+
# connection, so mounting is safe at load time.
|
|
29
|
+
class Builder
|
|
30
|
+
def initialize(model:, machine_class:, log:, column:, changed_at:, touch:, helpers:, scopes:)
|
|
31
|
+
@model = model
|
|
32
|
+
@machine_class = machine_class
|
|
33
|
+
@log_option = log
|
|
34
|
+
@column = column
|
|
35
|
+
@changed_at = changed_at
|
|
36
|
+
@touch = touch
|
|
37
|
+
@helpers = helpers
|
|
38
|
+
@scopes = scopes
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def mount
|
|
42
|
+
assert_not_mounted
|
|
43
|
+
assert_single_column_primary_key
|
|
44
|
+
log_class = resolve_log_class
|
|
45
|
+
assert_shared_connection_class(log_class)
|
|
46
|
+
graph = machine_class.finalize!
|
|
47
|
+
assert_no_verb_conflicts(graph)
|
|
48
|
+
assert_no_scope_conflicts(graph)
|
|
49
|
+
configuration = build_configuration(log_class)
|
|
50
|
+
store_configuration(configuration)
|
|
51
|
+
define_scopes(graph, configuration)
|
|
52
|
+
define_reading_surface
|
|
53
|
+
model.include(Pipeline::Surface)
|
|
54
|
+
model.include(Introspection)
|
|
55
|
+
define_verbs(graph) if @helpers
|
|
56
|
+
configuration
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
attr_reader :model, :machine_class
|
|
62
|
+
|
|
63
|
+
def assert_not_mounted
|
|
64
|
+
raise AlreadyMounted.new(model: model) if model.respond_to?(:statecraft_mounting)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def assert_single_column_primary_key
|
|
68
|
+
raise CompositePrimaryKeyUnsupported.new(model: model) if model.primary_key.is_a?(Array)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def resolve_log_class
|
|
72
|
+
return @log_option if @log_option
|
|
73
|
+
|
|
74
|
+
conventional_name = "#{model.base_class.name}Transition"
|
|
75
|
+
begin
|
|
76
|
+
Object.const_get(conventional_name)
|
|
77
|
+
rescue NameError
|
|
78
|
+
raise CompilationError,
|
|
79
|
+
"log class #{conventional_name} not found for #{model.name}: " \
|
|
80
|
+
"create it or pass log: explicitly"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def assert_shared_connection_class(log_class)
|
|
85
|
+
return if model.connection_specification_name == log_class.connection_specification_name
|
|
86
|
+
|
|
87
|
+
raise ConnectionMismatch.new(model: model, log_class: log_class)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def assert_no_verb_conflicts(graph)
|
|
91
|
+
return unless @helpers
|
|
92
|
+
|
|
93
|
+
graph.events.each_key do |event_name|
|
|
94
|
+
verb_names(event_name).each do |verb|
|
|
95
|
+
next unless model.method_defined?(verb) || model.private_method_defined?(verb)
|
|
96
|
+
|
|
97
|
+
raise CompilationError,
|
|
98
|
+
"helper #{verb} for event #{event_name.inspect} conflicts with an existing " \
|
|
99
|
+
"instance method on #{model.name}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def assert_no_scope_conflicts(graph)
|
|
105
|
+
return unless @scopes
|
|
106
|
+
|
|
107
|
+
graph.states.each do |state_name|
|
|
108
|
+
next unless model.respond_to?(state_name, true)
|
|
109
|
+
|
|
110
|
+
raise CompilationError,
|
|
111
|
+
"scope #{state_name} conflicts with an existing class method on #{model.name}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def verb_names(event_name)
|
|
116
|
+
["#{event_name}!", event_name.to_s, "may_#{event_name}?"]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_configuration(log_class)
|
|
120
|
+
Configuration.new(
|
|
121
|
+
machine_class: machine_class,
|
|
122
|
+
log_class: log_class,
|
|
123
|
+
column: @column.to_sym,
|
|
124
|
+
changed_at_column: resolve_changed_at_column,
|
|
125
|
+
touch: @touch,
|
|
126
|
+
helpers: @helpers,
|
|
127
|
+
scopes: @scopes,
|
|
128
|
+
log_foreign_key: model.base_class.name.foreign_key.to_sym
|
|
129
|
+
).freeze
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def resolve_changed_at_column
|
|
133
|
+
case @changed_at
|
|
134
|
+
when false then nil
|
|
135
|
+
when true then :"#{@column}_changed_at"
|
|
136
|
+
else @changed_at.to_sym
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def store_configuration(configuration)
|
|
141
|
+
model.define_singleton_method(:statecraft_mounting) { configuration }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def define_scopes(graph, configuration)
|
|
145
|
+
return unless configuration.scopes
|
|
146
|
+
|
|
147
|
+
column = configuration.column
|
|
148
|
+
graph.states.each do |state_name|
|
|
149
|
+
model.scope state_name, -> { where(column => state_name.to_s) }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def define_verbs(graph)
|
|
154
|
+
verbs = Module.new do
|
|
155
|
+
graph.events.each_key do |event_name|
|
|
156
|
+
define_method("#{event_name}!") { |metadata: {}| fire!(event_name, metadata: metadata) }
|
|
157
|
+
define_method(event_name) { |metadata: {}| fire(event_name, metadata: metadata) }
|
|
158
|
+
define_method("may_#{event_name}?") { |metadata: {}| can_fire?(event_name, metadata: metadata) }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
model.include(verbs)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def define_reading_surface
|
|
165
|
+
reading_surface = Module.new do
|
|
166
|
+
define_method(:history) do
|
|
167
|
+
config = self.class.statecraft_mounting
|
|
168
|
+
config.log_class.where(config.log_foreign_key => id).order(:id)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
define_method(:last_transition) do
|
|
172
|
+
history.last
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
define_method(:in_state?) do |state_name|
|
|
176
|
+
config = self.class.statecraft_mounting
|
|
177
|
+
self[config.column].to_s == state_name.to_s
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
model.include(reading_surface)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|