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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7dbf52b54ebf9536c2d5cac336376e6fa1fadbdd614794bd7322349c714bbc0
4
- data.tar.gz: c81382ec182f2d2b2b4f13d73c8228675efed7f5f5ee80886a58d8b77820db76
3
+ metadata.gz: 9968947252ce58a57128dede22347aba081ed39b515de427868d240e963307f3
4
+ data.tar.gz: 8b216356c83d0bfc169b94cba44ab23adc153dbc70bef8b4a5dca16e8faf22e0
5
5
  SHA512:
6
- metadata.gz: c996bdd7a378930fbf14cdfabad9b83b099db386932ea6fab946c38bcd5d649dce25bbc2730b87747b5f46937b55727a06964e8930154b4da23745cf27cdb757
7
- data.tar.gz: 24cd2abfe4e710269d4a3a39ed9606bb158b395fa66fa83c5892e9cc060850131c9d9704693a214762eed2efe97ec39a64e4b97378f2e6bda98a1f52ef5adcdf
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.12] - 2026-02-17
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
- - Flow attribute writers are private after flow definition Version: minor (35ea18f)
12
+ - Update reissue dependency to 0.4.15 (d11ba8c)
13
+ - Update reissue dependency to 0.4.16 (25c4be7)
@@ -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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Circulator
4
- VERSION = "2.1.12"
4
+ VERSION = "3.0.0"
5
5
  end
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, extension will be applied when flow is defined
128
+ return # Class doesn't exist yet
88
129
  end
89
130
 
90
- # Check if the class has flows and the specific attribute flow
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.flows.dig(model_key, attribute_name.to_sym)
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 existing flow
137
+ # Merge the extension into the (possibly copied) flow
98
138
  existing_flow.merge(&block)
99
139
 
100
- # Re-define flow methods for any new actions/states
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
- transitions[current_value.to_sym]
452
+ current_transitions[current_value.to_sym]
356
453
  else
357
- transitions[current_value]
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
- transition = transitions[current_state]
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
- base.singleton_class.attr_reader :flows
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)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: circulator
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.12
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay