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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9232620710fbfe075e26c0ae3bcf1dea0794cdca188b0a9a0ff8529362573ede
4
- data.tar.gz: c45d7ac1bdf89fe21107905811d36d0ef4dca37370ea89686d5433cef1aeaaf5
3
+ metadata.gz: 8004841370c863fdd0910117bf16212a16b2584b9862fb7521637e817006ac9a
4
+ data.tar.gz: 49c4c8368037c91f0fd2eeb9f1c69654e0db9334d1b6e2cb8c7f34f4e6aa79c3
5
5
  SHA512:
6
- metadata.gz: bfbc391566c29b7dd18005c56c37c21c9d976eeebc2705f92ea726074b93807daf47528f34e07bb449be3a38bca797067742abc64e1d862162cc18924049b47d
7
- data.tar.gz: 9edd59a4985b266ad4c590d3e4d05bbe41f381e85a149b84de08adeabf9e1ff141d0a2b5d5dee759e1e1ba8d4dd740c8e33036331694614951994fddab6d4448
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.11] - 2026-02-09
8
+ ## [2.1.13] - 2026-02-24
9
9
 
10
10
  ### Added
11
11
 
12
- - action_missing alias for no_action to clarify intent (c576a7c)
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
- - Improved inline documentation for no_action and around methods (c576a7c)
17
- - Added method documentation for Flow DSL methods (2546989)
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)
@@ -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.11"
4
+ VERSION = "2.1.13"
5
5
  end
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, extension will be applied when flow is defined
128
+ return # Class doesn't exist yet
86
129
  end
87
130
 
88
- # Check if the class has flows and the specific attribute flow
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.flows.dig(model_key, attribute_name.to_sym)
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 existing flow
137
+ # Merge the extension into the (possibly copied) flow
96
138
  existing_flow.merge(&block)
97
139
 
98
- # Re-define flow methods for any new actions/states
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
- method_name = [object, attribute_name, action].compact.join("_")
170
+ base_name = [object, attribute_name, action].compact.join("_")
113
171
 
114
- # Remove existing method so it can be redefined with updated transitions
115
- if flow_module.method_defined?(method_name)
116
- flow_module.remove_method(method_name)
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
- klass.send(:define_flow_method, attribute_name:, action:, transitions:, object:, owner: flow_module)
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
- define_flow_method(attribute_name:, action:, transitions:, object:, owner: flow_module)
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
- transitions[current_value.to_sym]
452
+ current_transitions[current_value.to_sym]
334
453
  else
335
- transitions[current_value]
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
- 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
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
- def flow(action, attribute, *args, flow_target: self, **kwargs, &block)
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
- method_name = "#{external_attribute_name}_#{action}"
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)
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.11
4
+ version: 2.1.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay