circulator 2.1.11 → 2.1.13
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 +4 -4
- data/CHANGELOG.md +7 -8
- data/lib/circulator/flow.rb +28 -0
- data/lib/circulator/version.rb +1 -1
- data/lib/circulator.rb +236 -20
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8004841370c863fdd0910117bf16212a16b2584b9862fb7521637e817006ac9a
|
|
4
|
+
data.tar.gz: 49c4c8368037c91f0fd2eeb9f1c69654e0db9334d1b6e2cb8c7f34f4e6aa79c3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 34ec6da8f78e953350d3a24dbe609cbb15e7a90a0c204fe7ecb023b98e9b6adcc4edcbb700640f7af157f623a2e020d2cf55b7d3b329b028f942e0208d313f0d
|
|
7
|
+
data.tar.gz: b31bbb831011ca82e47cbd6c1272f561eaecd41d49e4ddde7abce65d212d2bdb4476274d7e1029c5c03946a54daf8ce3540877aeed1b9fb2e51fca1472c8ba71
|
data/CHANGELOG.md
CHANGED
|
@@ -5,17 +5,16 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [2.1.
|
|
8
|
+
## [2.1.13] - 2026-02-24
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
11
|
|
|
12
|
-
-
|
|
12
|
+
- Flow#dup_for method for deep-copying flows to subclasses (4f75b79)
|
|
13
|
+
- Error on redeclaring inherited flow attributes in subclasses (98ca6a7)
|
|
14
|
+
- Extension support for subclasses that inherit parent flows (d73f231)
|
|
15
|
+
- Lazy application of pending extensions via inherited hook (2ed2787)
|
|
13
16
|
|
|
14
17
|
### Changed
|
|
15
18
|
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
### Removed
|
|
20
|
-
|
|
21
|
-
- @no_action internal instance_variable and switched to @action_missing Version: minor (c576a7c)
|
|
19
|
+
- Subclasses of Circulator classes inherit parent flows Version: major (2a63539)
|
|
20
|
+
- Flow methods use dynamic transition lookup for inherited flows (2ed2787)
|
data/lib/circulator/flow.rb
CHANGED
|
@@ -219,6 +219,34 @@ module Circulator
|
|
|
219
219
|
self
|
|
220
220
|
end
|
|
221
221
|
|
|
222
|
+
# Create a deep copy of this flow for a different owning class.
|
|
223
|
+
# Used when a subclass needs its own copy of a parent's flow
|
|
224
|
+
# (e.g., when applying extensions to an inherited flow).
|
|
225
|
+
def dup_for(new_klass)
|
|
226
|
+
copy = self.class.allocate
|
|
227
|
+
copy.instance_variable_set(:@klass, new_klass)
|
|
228
|
+
copy.instance_variable_set(:@attribute_name, @attribute_name)
|
|
229
|
+
copy.instance_variable_set(:@states, @states.dup)
|
|
230
|
+
# Procs (@action_missing, @flows_proc, @around) are intentionally shared,
|
|
231
|
+
# not copied. They are stateless closures that work identically across
|
|
232
|
+
# parent and child classes.
|
|
233
|
+
copy.instance_variable_set(:@action_missing, @action_missing)
|
|
234
|
+
copy.instance_variable_set(:@flows_proc, @flows_proc)
|
|
235
|
+
copy.instance_variable_set(:@around, @around)
|
|
236
|
+
|
|
237
|
+
# Deep copy transition map: action => {from_state => {to:, block:, ...}}
|
|
238
|
+
new_map = @flows_proc.call
|
|
239
|
+
@transition_map.each do |action, transitions|
|
|
240
|
+
new_map[action] = @flows_proc.call
|
|
241
|
+
transitions.each do |from_state, data|
|
|
242
|
+
new_map[action][from_state] = data.dup
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
copy.instance_variable_set(:@transition_map, new_map)
|
|
246
|
+
|
|
247
|
+
copy
|
|
248
|
+
end
|
|
249
|
+
|
|
222
250
|
private
|
|
223
251
|
|
|
224
252
|
def validate_allow_if(allow_if)
|
data/lib/circulator/version.rb
CHANGED
data/lib/circulator.rb
CHANGED
|
@@ -2,6 +2,8 @@ require "circulator/version"
|
|
|
2
2
|
require "circulator/flow"
|
|
3
3
|
|
|
4
4
|
module Circulator
|
|
5
|
+
class InvalidTransition < StandardError; end
|
|
6
|
+
|
|
5
7
|
# Global registry for extensions
|
|
6
8
|
@extensions = Hash.new { |h, k| h[k] = [] }
|
|
7
9
|
|
|
@@ -77,46 +79,105 @@ module Circulator
|
|
|
77
79
|
|
|
78
80
|
private
|
|
79
81
|
|
|
82
|
+
# Walk the ancestor chain of +klass+ to find a Flow for +attribute_name+.
|
|
83
|
+
# Each ancestor is checked under its own model key, since flows are stored
|
|
84
|
+
# under the declaring class's name.
|
|
85
|
+
#
|
|
86
|
+
# Returns the first matching Flow, or nil.
|
|
87
|
+
def find_inherited_flow(klass, attribute_name)
|
|
88
|
+
klass.ancestors.drop(1).lazy.filter_map { |a|
|
|
89
|
+
next unless a.respond_to?(:flows)
|
|
90
|
+
a_flows = a.instance_variable_get(:@flows)
|
|
91
|
+
next unless a_flows
|
|
92
|
+
a_key = Circulator.model_key(a.to_s)
|
|
93
|
+
a_flows.dig(a_key, attribute_name.to_sym)
|
|
94
|
+
}.first
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Ensure +klass+ has its own local copy of the flow for +attribute_name+.
|
|
98
|
+
#
|
|
99
|
+
# If the class already owns a local flow, returns it. Otherwise, looks up
|
|
100
|
+
# the ancestor chain, deep-copies the inherited flow, stores it on the
|
|
101
|
+
# child class, and returns the copy.
|
|
102
|
+
#
|
|
103
|
+
# Returns the local Flow, or nil if no flow exists anywhere in the chain.
|
|
104
|
+
def ensure_local_flow(klass, model_key, attribute_name)
|
|
105
|
+
local_flows = klass.instance_variable_get(:@flows)
|
|
106
|
+
existing = local_flows&.dig(model_key, attribute_name.to_sym)
|
|
107
|
+
return existing if existing
|
|
108
|
+
|
|
109
|
+
parent_flow = find_inherited_flow(klass, attribute_name)
|
|
110
|
+
return unless parent_flow
|
|
111
|
+
|
|
112
|
+
# Deep-copy parent flow for this subclass
|
|
113
|
+
copy = parent_flow.dup_for(klass)
|
|
114
|
+
|
|
115
|
+
# Store on the child class
|
|
116
|
+
flows_proc = copy.instance_variable_get(:@flows_proc) || Circulator.default_flow_proc
|
|
117
|
+
klass.instance_variable_set(:@flows, klass.instance_variable_get(:@flows) || flows_proc.call)
|
|
118
|
+
klass.instance_variable_get(:@flows)[model_key] ||= flows_proc.call
|
|
119
|
+
klass.instance_variable_get(:@flows)[model_key][attribute_name.to_sym] = copy
|
|
120
|
+
|
|
121
|
+
copy
|
|
122
|
+
end
|
|
123
|
+
|
|
80
124
|
def apply_extension_to_existing_flow(class_name, attribute_name, block)
|
|
81
|
-
# Try to get the class constant
|
|
82
125
|
klass = begin
|
|
83
126
|
Object.const_get(class_name.to_s)
|
|
84
127
|
rescue NameError
|
|
85
|
-
return # Class doesn't exist yet
|
|
128
|
+
return # Class doesn't exist yet
|
|
86
129
|
end
|
|
87
130
|
|
|
88
|
-
|
|
89
|
-
return unless klass.respond_to?(:flows) && klass.flows
|
|
131
|
+
return unless klass.respond_to?(:flows)
|
|
90
132
|
|
|
91
133
|
model_key = Circulator.model_key(klass.to_s)
|
|
92
|
-
existing_flow = klass
|
|
134
|
+
existing_flow = ensure_local_flow(klass, model_key, attribute_name)
|
|
93
135
|
return unless existing_flow
|
|
94
136
|
|
|
95
|
-
# Merge the extension into the
|
|
137
|
+
# Merge the extension into the (possibly copied) flow
|
|
96
138
|
existing_flow.merge(&block)
|
|
97
139
|
|
|
98
|
-
# Re-define flow methods for
|
|
140
|
+
# Re-define flow methods for new/changed actions
|
|
99
141
|
redefine_flow_methods(klass, attribute_name, existing_flow)
|
|
100
142
|
end
|
|
101
143
|
|
|
144
|
+
# Re-define flow action and state methods after an extension is merged.
|
|
145
|
+
#
|
|
146
|
+
# Side effect: if the FlowMethods module found in the ancestor chain
|
|
147
|
+
# belongs to a parent class, this method creates a new FlowMethods
|
|
148
|
+
# module on +klass+ so that the parent's methods are not mutated.
|
|
102
149
|
def redefine_flow_methods(klass, attribute_name, flow)
|
|
150
|
+
# Find an existing FlowMethods module in the ancestor chain
|
|
103
151
|
flow_module = klass.ancestors.find { |ancestor|
|
|
104
152
|
ancestor.name.to_s =~ /#{FLOW_MODULE_NAME}/o
|
|
105
153
|
}
|
|
106
154
|
return unless flow_module
|
|
107
155
|
|
|
156
|
+
# If the FlowMethods module belongs to a parent class, create a new
|
|
157
|
+
# one on the child so we don't mutate the parent's methods.
|
|
158
|
+
if klass.const_defined?(FLOW_MODULE_NAME.to_sym, false)
|
|
159
|
+
flow_module = klass.const_get(FLOW_MODULE_NAME.to_sym, false)
|
|
160
|
+
else
|
|
161
|
+
flow_module = Module.new
|
|
162
|
+
klass.include flow_module
|
|
163
|
+
klass.const_set(FLOW_MODULE_NAME.to_sym, flow_module)
|
|
164
|
+
end
|
|
165
|
+
|
|
108
166
|
object = nil # Extensions only work on the same class model
|
|
109
167
|
|
|
110
168
|
# Define or redefine methods for actions (need to redefine if transitions changed)
|
|
111
169
|
flow.transition_map.each do |action, transitions|
|
|
112
|
-
|
|
170
|
+
base_name = [object, attribute_name, action].compact.join("_")
|
|
113
171
|
|
|
114
|
-
# Remove existing
|
|
115
|
-
|
|
116
|
-
|
|
172
|
+
# Remove existing methods so they can be redefined with updated transitions
|
|
173
|
+
FLOW_VARIANTS.each_value do |suffix|
|
|
174
|
+
full_name = "#{base_name}#{suffix}"
|
|
175
|
+
flow_module.remove_method(full_name) if flow_module.method_defined?(full_name)
|
|
117
176
|
end
|
|
118
177
|
|
|
119
|
-
|
|
178
|
+
FLOW_VARIANTS.each_key do |variant|
|
|
179
|
+
klass.send(["define", variant, "flow_method"].compact.join("_"), attribute_name:, action:, transitions:, object:, owner: flow_module)
|
|
180
|
+
end
|
|
120
181
|
end
|
|
121
182
|
|
|
122
183
|
# Define predicate methods for any new states
|
|
@@ -126,9 +187,34 @@ module Circulator
|
|
|
126
187
|
klass.send(:define_state_method, attribute_name:, state:, object:, owner: flow_module)
|
|
127
188
|
end
|
|
128
189
|
end
|
|
190
|
+
|
|
191
|
+
# Apply any pending extensions that were registered before the class existed.
|
|
192
|
+
#
|
|
193
|
+
# When Circulator.extension is called for a class that doesn't exist yet,
|
|
194
|
+
# the extension block is stored in the global registry. This method checks
|
|
195
|
+
# the registry for extensions matching the given class name and applies them.
|
|
196
|
+
#
|
|
197
|
+
# Called from the inherited hook when a subclass is defined, and also
|
|
198
|
+
# lazily from the flows accessor when the class name becomes available
|
|
199
|
+
# after class creation (e.g., after Object.const_set).
|
|
200
|
+
def apply_pending_extensions(klass)
|
|
201
|
+
class_name = klass.name
|
|
202
|
+
return unless class_name
|
|
203
|
+
|
|
204
|
+
Circulator.extensions.each_key do |key|
|
|
205
|
+
ext_class_name, ext_attribute = key.split(":", 2)
|
|
206
|
+
next unless ext_class_name == class_name
|
|
207
|
+
|
|
208
|
+
attribute_name = ext_attribute.to_sym
|
|
209
|
+
Circulator.extensions[key].each do |ext_block|
|
|
210
|
+
apply_extension_to_existing_flow(class_name, attribute_name, ext_block)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
129
214
|
end
|
|
130
215
|
|
|
131
216
|
FLOW_MODULE_NAME = "FlowMethods"
|
|
217
|
+
FLOW_VARIANTS = {:bang => "!", nil => ""}.freeze
|
|
132
218
|
|
|
133
219
|
# Declare a flow for an attribute.
|
|
134
220
|
#
|
|
@@ -269,12 +355,27 @@ module Circulator
|
|
|
269
355
|
# # Will log the flow logic according to the with_logging block behavior
|
|
270
356
|
#
|
|
271
357
|
def flow(attribute_name, model: to_s, flows_proc: Circulator.default_flow_proc, &block)
|
|
272
|
-
@flows ||= flows_proc.call
|
|
273
358
|
model_key = Circulator.model_key(model)
|
|
359
|
+
|
|
360
|
+
# Check if a parent class already defines this flow
|
|
361
|
+
parent_flow = Circulator.send(:find_inherited_flow, self, attribute_name)
|
|
362
|
+
|
|
363
|
+
if parent_flow
|
|
364
|
+
raise ArgumentError,
|
|
365
|
+
"#{self} inherits a :#{attribute_name} flow from a parent class. " \
|
|
366
|
+
"Use Circulator.extension(#{self}, :#{attribute_name}) to customize it."
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
@flows ||= flows_proc.call
|
|
274
370
|
@flows[model_key] ||= flows_proc.call
|
|
275
371
|
# Pass the flows_proc to Flow so it can create transition_maps of the same type
|
|
276
372
|
@flows[model_key][attribute_name] = Flow.new(self, attribute_name, flows_proc:, &block)
|
|
277
373
|
|
|
374
|
+
# Define InvalidTransition exception on the host class if not already defined
|
|
375
|
+
unless const_defined?(:InvalidTransition, false)
|
|
376
|
+
const_set(:InvalidTransition, Class.new(Circulator::InvalidTransition))
|
|
377
|
+
end
|
|
378
|
+
|
|
278
379
|
flow_module = ancestors.find { |ancestor|
|
|
279
380
|
ancestor.name.to_s =~ /#{FLOW_MODULE_NAME}/o
|
|
280
381
|
} || Module.new.tap do |mod|
|
|
@@ -298,7 +399,9 @@ module Circulator
|
|
|
298
399
|
states.add(transition_data[:to])
|
|
299
400
|
end
|
|
300
401
|
end
|
|
301
|
-
|
|
402
|
+
FLOW_VARIANTS.each_key do |variant|
|
|
403
|
+
send(["define", variant, "flow_method"].compact.join("_"), attribute_name:, action:, transitions:, object:, owner: flow_module)
|
|
404
|
+
end
|
|
302
405
|
end
|
|
303
406
|
|
|
304
407
|
# Define predicate methods for each state (skip nil)
|
|
@@ -306,6 +409,15 @@ module Circulator
|
|
|
306
409
|
next if state.nil?
|
|
307
410
|
define_state_method(attribute_name:, state:, object:, owner: flow_module)
|
|
308
411
|
end
|
|
412
|
+
|
|
413
|
+
# Make the attribute writer private so that state changes must go through
|
|
414
|
+
# the generated flow methods. This prevents bypassing guards and transition
|
|
415
|
+
# logic by directly assigning the attribute. The writer is only made private
|
|
416
|
+
# for self-managed flows (not model-based flows where the attribute belongs
|
|
417
|
+
# to a different class).
|
|
418
|
+
if object.nil? && method_defined?("#{attribute_name}=")
|
|
419
|
+
private "#{attribute_name}="
|
|
420
|
+
end
|
|
309
421
|
end
|
|
310
422
|
alias_method :circulator, :flow
|
|
311
423
|
|
|
@@ -329,10 +441,17 @@ module Circulator
|
|
|
329
441
|
flow_logic = -> {
|
|
330
442
|
current_value = flow_target.send(attribute_name)
|
|
331
443
|
|
|
444
|
+
# Look up transitions dynamically to support inherited+extended flows.
|
|
445
|
+
# The closure-captured `transitions` may be stale if an extension was
|
|
446
|
+
# applied after this method was defined (e.g., early extensions on subclasses).
|
|
447
|
+
# Fall back to the closure-captured transitions if the flow lookup fails.
|
|
448
|
+
live_flow = flows&.dig(Circulator.model_key(flow_target), attribute_name)
|
|
449
|
+
current_transitions = live_flow&.transition_map&.[](action) || transitions
|
|
450
|
+
|
|
332
451
|
transition = if current_value.respond_to?(:to_sym)
|
|
333
|
-
|
|
452
|
+
current_transitions[current_value.to_sym]
|
|
334
453
|
else
|
|
335
|
-
|
|
454
|
+
current_transitions[current_value]
|
|
336
455
|
end
|
|
337
456
|
|
|
338
457
|
unless transition
|
|
@@ -363,6 +482,34 @@ module Circulator
|
|
|
363
482
|
end
|
|
364
483
|
end
|
|
365
484
|
|
|
485
|
+
def define_bang_flow_method(attribute_name:, action:, transitions:, object:, owner:)
|
|
486
|
+
bang_method = [object, attribute_name, action].compact.join("_") << "!"
|
|
487
|
+
return if owner.method_defined?(bang_method)
|
|
488
|
+
|
|
489
|
+
non_bang_method = [object, attribute_name, action].compact.join("_")
|
|
490
|
+
owner.define_method(bang_method) do |*args, flow_target: self, **kwargs, &block|
|
|
491
|
+
current_value = flow_target.send(attribute_name)
|
|
492
|
+
current_state = current_value.respond_to?(:to_sym) ? current_value.to_sym : current_value
|
|
493
|
+
|
|
494
|
+
# Look up transitions dynamically (same rationale as define_flow_method)
|
|
495
|
+
live_flow = flows&.dig(Circulator.model_key(flow_target), attribute_name)
|
|
496
|
+
current_transitions = live_flow&.transition_map&.[](action) || transitions
|
|
497
|
+
|
|
498
|
+
transition = current_transitions[current_state]
|
|
499
|
+
unless transition
|
|
500
|
+
raise self.class.const_get(:InvalidTransition),
|
|
501
|
+
"No transition #{action} on #{attribute_name} from #{current_state.inspect}"
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
if transition[:allow_if] && !Circulator.evaluate_guard(flow_target, transition[:allow_if], *args, **kwargs)
|
|
505
|
+
raise self.class.const_get(:InvalidTransition),
|
|
506
|
+
"Guard prevented #{action} on #{attribute_name} from #{current_state.inspect}"
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
send(non_bang_method, *args, flow_target:, **kwargs, &block)
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
366
513
|
module_function def evaluate_guard(target, allow_if, *args, **kwargs)
|
|
367
514
|
case allow_if
|
|
368
515
|
when Array
|
|
@@ -403,22 +550,57 @@ module Circulator
|
|
|
403
550
|
|
|
404
551
|
def self.extended(base)
|
|
405
552
|
base.include(InstanceMethods)
|
|
406
|
-
|
|
553
|
+
|
|
554
|
+
# Define flows method that walks ancestor chain
|
|
555
|
+
base.define_singleton_method(:flows) do
|
|
556
|
+
@flows || ancestors.drop(1).lazy.filter_map { |a|
|
|
557
|
+
a.instance_variable_get(:@flows) if a.respond_to?(:flows)
|
|
558
|
+
}.first
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
base.define_singleton_method(:inherited) do |subclass|
|
|
562
|
+
super(subclass)
|
|
563
|
+
|
|
564
|
+
# Track whether pending extensions have been applied for this subclass.
|
|
565
|
+
# Since inherited fires before the class body block runs (and before
|
|
566
|
+
# Object.const_set assigns a name), we use lazy application: the first
|
|
567
|
+
# time flows is accessed on a named subclass, pending extensions are applied.
|
|
568
|
+
pending_extensions_applied = false
|
|
569
|
+
|
|
570
|
+
subclass.define_singleton_method(:flows) do
|
|
571
|
+
# Lazily apply pending extensions once the class has a name
|
|
572
|
+
unless pending_extensions_applied
|
|
573
|
+
if name
|
|
574
|
+
pending_extensions_applied = true
|
|
575
|
+
Circulator.send(:apply_pending_extensions, self)
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
@flows || ancestors.drop(1).lazy.filter_map { |a|
|
|
580
|
+
a.instance_variable_get(:@flows) if a.respond_to?(:flows)
|
|
581
|
+
}.first
|
|
582
|
+
end
|
|
583
|
+
end
|
|
407
584
|
end
|
|
408
585
|
|
|
409
586
|
module InstanceMethods
|
|
410
587
|
# Use this method to call an action on the attribute.
|
|
411
588
|
#
|
|
589
|
+
# Accepts an optional +variant:+ keyword to select a method variant.
|
|
590
|
+
# See +FLOW_VARIANTS+ for the available variants and their suffixes.
|
|
591
|
+
#
|
|
412
592
|
# Example:
|
|
413
593
|
#
|
|
414
594
|
# test_object.flow(:approve, :status)
|
|
415
595
|
# test_object.flow(:approve, :status, "arg1", "arg2", key: "value")
|
|
416
|
-
|
|
596
|
+
# test_object.flow(:approve, :status, variant: :bang)
|
|
597
|
+
def flow(action, attribute, *args, flow_target: self, variant: nil, **kwargs, &block)
|
|
417
598
|
target_name = if flow_target != self
|
|
418
599
|
Circulator.methodize_name(Circulator.model_key(flow_target))
|
|
419
600
|
end
|
|
420
601
|
external_attribute_name = [target_name, attribute].compact.join("_")
|
|
421
|
-
|
|
602
|
+
suffix = Circulator::FLOW_VARIANTS.fetch(variant)
|
|
603
|
+
method_name = "#{external_attribute_name}_#{action}#{suffix}"
|
|
422
604
|
if respond_to?(method_name)
|
|
423
605
|
send(method_name, *args, flow_target:, **kwargs, &block)
|
|
424
606
|
elsif flow_target.respond_to?(method_name)
|
|
@@ -426,6 +608,17 @@ module Circulator
|
|
|
426
608
|
else
|
|
427
609
|
raise "Invalid action for the current state of #{attribute} (#{flow_target.send(attribute).inspect}): #{action}"
|
|
428
610
|
end
|
|
611
|
+
rescue KeyError
|
|
612
|
+
raise ArgumentError, "Invalid variant: #{variant.inspect}"
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# Bang variant of flow that raises InvalidTransition on failure.
|
|
616
|
+
#
|
|
617
|
+
# Example:
|
|
618
|
+
#
|
|
619
|
+
# test_object.flow!(:approve, :status)
|
|
620
|
+
def flow!(action, attribute, *args, **kwargs, &block)
|
|
621
|
+
flow(action, attribute, *args, variant: :bang, **kwargs, &block)
|
|
429
622
|
end
|
|
430
623
|
|
|
431
624
|
# Get available actions for an attribute based on current state
|
|
@@ -506,8 +699,31 @@ module Circulator
|
|
|
506
699
|
|
|
507
700
|
private
|
|
508
701
|
|
|
702
|
+
# Returns the class-level flows hash, aliasing the current instance's
|
|
703
|
+
# model_key to the parent's flow data when needed.
|
|
704
|
+
#
|
|
705
|
+
# Why aliasing is needed: flows are stored under the declaring class's
|
|
706
|
+
# model_key (e.g. "Parent"), but instance lookups use the instance's own
|
|
707
|
+
# class key (e.g. "Child"). When a subclass inherits flows without
|
|
708
|
+
# overriding them, there is no entry for the child's key. We add an alias
|
|
709
|
+
# entry so that dig(child_key, attribute) resolves correctly.
|
|
710
|
+
#
|
|
711
|
+
# The mutation is safe because the alias is idempotent (same value each
|
|
712
|
+
# time) and just adds a pointer to existing data.
|
|
509
713
|
def flows
|
|
510
|
-
self.class.flows
|
|
714
|
+
raw_flows = self.class.flows
|
|
715
|
+
return nil unless raw_flows
|
|
716
|
+
|
|
717
|
+
my_key = Circulator.model_key(self)
|
|
718
|
+
return raw_flows if raw_flows.key?(my_key)
|
|
719
|
+
|
|
720
|
+
# Find the ancestor whose model_key matches a key in the flows hash
|
|
721
|
+
parent_key = raw_flows.keys.find { |k| raw_flows[k].is_a?(Hash) || raw_flows[k].respond_to?(:dig) }
|
|
722
|
+
return raw_flows unless parent_key
|
|
723
|
+
|
|
724
|
+
# Alias in place — avoids allocating a new Hash on every call.
|
|
725
|
+
raw_flows[my_key] = raw_flows[parent_key]
|
|
726
|
+
raw_flows
|
|
511
727
|
end
|
|
512
728
|
|
|
513
729
|
def check_allow_if(allow_if, *args, **kwargs)
|