circulator 2.1.12 → 3.0.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 +4 -4
- data/CHANGELOG.md +3 -11
- data/lib/circulator/flow.rb +28 -0
- data/lib/circulator/version.rb +1 -1
- data/lib/circulator.rb +167 -13
- 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: 9968947252ce58a57128dede22347aba081ed39b515de427868d240e963307f3
|
|
4
|
+
data.tar.gz: 8b216356c83d0bfc169b94cba44ab23adc153dbc70bef8b4a5dca16e8faf22e0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 706b8085ed7233b1b3f3cf5c1acca0b2f8d1be9c4b61c35188731b22fd5e099bc90efe3bb8fcfa10de4c5ec286410a03efa108c3f6bc2a1609f0d4970dff553a
|
|
7
|
+
data.tar.gz: 0d35d33444128844ee07ed61acb86ffe94dbf442513af698d600ac566b2ab39de742a05615c69910704faa425e02fdf2993a8b7d4bfbcb47ff1e6639e9a70d92
|
data/CHANGELOG.md
CHANGED
|
@@ -5,17 +5,9 @@ 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.
|
|
9
|
-
|
|
10
|
-
### Added
|
|
11
|
-
|
|
12
|
-
- Named test sampler classes for reuse across test files (b6bcb4e)
|
|
13
|
-
- bang method variants for flow actions (673d8a0)
|
|
14
|
-
- Circulator::InvalidTransition exception class (673d8a0)
|
|
15
|
-
- host-namespaced InvalidTransition subclass per class with flows (673d8a0)
|
|
16
|
-
- flow! instance method and variant: keyword on flow (673d8a0)
|
|
17
|
-
- FLOW_VARIANTS registry that drives method definition (673d8a0)
|
|
8
|
+
## [2.1.14] - 2026-02-24
|
|
18
9
|
|
|
19
10
|
### Changed
|
|
20
11
|
|
|
21
|
-
-
|
|
12
|
+
- Update reissue dependency to 0.4.15 (d11ba8c)
|
|
13
|
+
- Update reissue dependency to 0.4.16 (25c4be7)
|
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
|
@@ -79,34 +79,90 @@ module Circulator
|
|
|
79
79
|
|
|
80
80
|
private
|
|
81
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
|
+
|
|
82
124
|
def apply_extension_to_existing_flow(class_name, attribute_name, block)
|
|
83
|
-
# Try to get the class constant
|
|
84
125
|
klass = begin
|
|
85
126
|
Object.const_get(class_name.to_s)
|
|
86
127
|
rescue NameError
|
|
87
|
-
return # Class doesn't exist yet
|
|
128
|
+
return # Class doesn't exist yet
|
|
88
129
|
end
|
|
89
130
|
|
|
90
|
-
|
|
91
|
-
return unless klass.respond_to?(:flows) && klass.flows
|
|
131
|
+
return unless klass.respond_to?(:flows)
|
|
92
132
|
|
|
93
133
|
model_key = Circulator.model_key(klass.to_s)
|
|
94
|
-
existing_flow = klass
|
|
134
|
+
existing_flow = ensure_local_flow(klass, model_key, attribute_name)
|
|
95
135
|
return unless existing_flow
|
|
96
136
|
|
|
97
|
-
# Merge the extension into the
|
|
137
|
+
# Merge the extension into the (possibly copied) flow
|
|
98
138
|
existing_flow.merge(&block)
|
|
99
139
|
|
|
100
|
-
# Re-define flow methods for
|
|
140
|
+
# Re-define flow methods for new/changed actions
|
|
101
141
|
redefine_flow_methods(klass, attribute_name, existing_flow)
|
|
102
142
|
end
|
|
103
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.
|
|
104
149
|
def redefine_flow_methods(klass, attribute_name, flow)
|
|
150
|
+
# Find an existing FlowMethods module in the ancestor chain
|
|
105
151
|
flow_module = klass.ancestors.find { |ancestor|
|
|
106
152
|
ancestor.name.to_s =~ /#{FLOW_MODULE_NAME}/o
|
|
107
153
|
}
|
|
108
154
|
return unless flow_module
|
|
109
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
|
+
|
|
110
166
|
object = nil # Extensions only work on the same class model
|
|
111
167
|
|
|
112
168
|
# Define or redefine methods for actions (need to redefine if transitions changed)
|
|
@@ -131,6 +187,30 @@ module Circulator
|
|
|
131
187
|
klass.send(:define_state_method, attribute_name:, state:, object:, owner: flow_module)
|
|
132
188
|
end
|
|
133
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
|
|
134
214
|
end
|
|
135
215
|
|
|
136
216
|
FLOW_MODULE_NAME = "FlowMethods"
|
|
@@ -275,8 +355,18 @@ module Circulator
|
|
|
275
355
|
# # Will log the flow logic according to the with_logging block behavior
|
|
276
356
|
#
|
|
277
357
|
def flow(attribute_name, model: to_s, flows_proc: Circulator.default_flow_proc, &block)
|
|
278
|
-
@flows ||= flows_proc.call
|
|
279
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
|
|
280
370
|
@flows[model_key] ||= flows_proc.call
|
|
281
371
|
# Pass the flows_proc to Flow so it can create transition_maps of the same type
|
|
282
372
|
@flows[model_key][attribute_name] = Flow.new(self, attribute_name, flows_proc:, &block)
|
|
@@ -351,10 +441,17 @@ module Circulator
|
|
|
351
441
|
flow_logic = -> {
|
|
352
442
|
current_value = flow_target.send(attribute_name)
|
|
353
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
|
+
|
|
354
451
|
transition = if current_value.respond_to?(:to_sym)
|
|
355
|
-
|
|
452
|
+
current_transitions[current_value.to_sym]
|
|
356
453
|
else
|
|
357
|
-
|
|
454
|
+
current_transitions[current_value]
|
|
358
455
|
end
|
|
359
456
|
|
|
360
457
|
unless transition
|
|
@@ -394,7 +491,11 @@ module Circulator
|
|
|
394
491
|
current_value = flow_target.send(attribute_name)
|
|
395
492
|
current_state = current_value.respond_to?(:to_sym) ? current_value.to_sym : current_value
|
|
396
493
|
|
|
397
|
-
|
|
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]
|
|
398
499
|
unless transition
|
|
399
500
|
raise self.class.const_get(:InvalidTransition),
|
|
400
501
|
"No transition #{action} on #{attribute_name} from #{current_state.inspect}"
|
|
@@ -449,7 +550,37 @@ module Circulator
|
|
|
449
550
|
|
|
450
551
|
def self.extended(base)
|
|
451
552
|
base.include(InstanceMethods)
|
|
452
|
-
|
|
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
|
|
453
584
|
end
|
|
454
585
|
|
|
455
586
|
module InstanceMethods
|
|
@@ -568,8 +699,31 @@ module Circulator
|
|
|
568
699
|
|
|
569
700
|
private
|
|
570
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.
|
|
571
713
|
def flows
|
|
572
|
-
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
|
|
573
727
|
end
|
|
574
728
|
|
|
575
729
|
def check_allow_if(allow_if, *args, **kwargs)
|