circulator 2.1.5 → 2.1.7

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: 4c511772e424334259794bfff3d3507e5740fc98f0a130c66bf1d77a41862f6a
4
- data.tar.gz: 7ee239ac23cbd6998a6d1689f8d6729983b42a6d6081e092465483ee19013107
3
+ metadata.gz: 63f6c8044ac3e21dffede73e1345d33d2e1e3f77350bcd6b332df8508d810f1f
4
+ data.tar.gz: 65ef2f903e7ec29993e1bbf7cf7b522c0d7bcba0cd424c75a7271ff0a89478db
5
5
  SHA512:
6
- metadata.gz: 63e6165e93a276d89ac6c469c3e8b5db41e6b184cde3290a9ff1332992e7f27a06cf1cd8f8b93c32100986c27be3761d0cfbc9ef7c69414b0cd379f32813c018
7
- data.tar.gz: 586cdd39db8b98b10465e84c02de948a44f39c556ae270ea6a68ea8f90ca8c0b61af37917419dfe599e39e882584101169f932671996edbeeb97d6e9debe806d
6
+ metadata.gz: c06f5b31e89fdd3832c8339b6884546b72b651395c2ca0a81cd37767f0a482dac2507ab45120bfe95fcaa950d6359080170859cb6ff1db61c485b165386df99d
7
+ data.tar.gz: 7a9f34b128d42a5c1ef9c45b806de26ac318bd822df6fbb6d35ffaa3df8384ac0dda2833057076cbb6715a30d98685a4eda03c73e6c2352a2d5682fa8a0c3260
data/CHANGELOG.md CHANGED
@@ -5,10 +5,12 @@ 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.5] - 2025-11-15
8
+ ## [2.1.7] - 2026-01-06
9
9
 
10
10
  ### Added
11
11
 
12
- - Circulator.extension to define changes to existing state machines. (0f0a50f)
13
- - Circulator.default_flow_proc to allow for custom storage objects. (7ddc442)
14
- - Test support for custom flows storage with libraries like Contours::BlendedHash. (c7f1f26)
12
+ - Use an array of mulitple symbols with allow_if guard clauses. (96b66e6)
13
+
14
+ ### Changed
15
+
16
+ - Documentation of lib/circulator.rb and explain extensions in README.md (7a04d58)
data/README.md CHANGED
@@ -173,6 +173,14 @@ end
173
173
 
174
174
  This is equivalent to the proc-based approach but cleaner when you have a dedicated method for the condition.
175
175
 
176
+ You can also use an array of symbols to represent multiple conditions:
177
+
178
+ ```ruby
179
+ action :publish, to: :published, allow_if: [:ready_to_publish?, :reviewed_by_present?]
180
+ ```
181
+
182
+ This is equivalent to the proc-based approach but cleaner when you have multiple conditions.
183
+
176
184
  **Hash-based guards** check the state of another attribute:
177
185
 
178
186
  You can make one state machine depend on another using hash-based `allow_if`:
@@ -285,10 +293,131 @@ class Payment
285
293
  end
286
294
  ```
287
295
 
296
+ #### Extending Flows
297
+
298
+ You can extend existing flows using `Circulator.extension`. This is useful for plugins, multi-tenant applications, or conditional feature enhancement. Extensions are registered globally and automatically applied when a class defines its flow.
299
+
300
+ **Basic Extension Example:**
301
+
302
+ ```ruby
303
+ class Document
304
+ extend Circulator
305
+
306
+ attr_accessor :status
307
+
308
+ flow :status do
309
+ state :draft do
310
+ action :submit, to: :review
311
+ end
312
+
313
+ state :review do
314
+ action :approve, to: :approved
315
+ end
316
+
317
+ state :approved
318
+ end
319
+ end
320
+
321
+ # Register extension - can be in a separate file or initializer
322
+ Circulator.extension(:Document, :status) do
323
+ state :review do
324
+ action :reject, to: :rejected
325
+ end
326
+
327
+ state :rejected do
328
+ action :revise, to: :draft
329
+ end
330
+ end
331
+
332
+ # Extensions are automatically applied when class is loaded
333
+ doc = Document.new
334
+ doc.status = :review
335
+ doc.status_reject # => :rejected (from extension)
336
+ doc.status_revise # => :draft (from extension)
337
+ ```
338
+
339
+ **How Extensions Work:**
340
+
341
+ Extensions are registered globally using `Circulator.extension(class_name, attribute)` and are automatically applied when the class defines its flow. Multiple extensions can be registered for the same class/attribute and are applied in registration order. Extensions must be registered before the class definition (typically in initializers).
342
+
343
+ By default, when an extension defines the same action from the same state as the base flow, the extension completely replaces the base definition (last-defined wins). To implement intelligent composition where extensions add their conditions/blocks additively, your application can configure a custom `flows_proc` that uses a Hash-like object with merge logic. Circulator remains dependency-free and supports any compatible Hash implementation.
344
+
345
+ **Plugin-Style Extensions:**
346
+
347
+ Extensions are perfect for gems that want to extend Circulator workflows without modifying the host application:
348
+
349
+ ```ruby
350
+ # gem_name/lib/gem_name.rb
351
+ Circulator.extension(:BlogPost, :status) do
352
+ state :draft do
353
+ action :generate_seo, to: :draft do
354
+ generate_meta_tags
355
+ end
356
+ end
357
+
358
+ state :published do
359
+ action :schedule_social, to: :published do
360
+ queue_social_sharing
361
+ end
362
+ end
363
+ end
364
+
365
+ # Host application doesn't need to know about the plugin's extensions
366
+ class BlogPost
367
+ extend Circulator
368
+
369
+ flow :status do
370
+ state :draft do
371
+ action :publish, to: :published
372
+ end
373
+ state :published
374
+ end
375
+ end
376
+
377
+ # Plugin actions are automatically available
378
+ post = BlogPost.new
379
+ post.status = :draft
380
+ post.status_generate_seo # From plugin extension
381
+ post.status_publish # From base flow
382
+ ```
383
+
384
+ **Conditional Extensions Based on Feature Flags:**
385
+
386
+ ```ruby
387
+ # config/initializers/circulator_extensions.rb
388
+ if ENV['ENABLE_APPROVAL_WORKFLOW']
389
+ Circulator.extension(:Document, :status) do
390
+ state :draft do
391
+ action :submit_for_approval, to: :approval
392
+ end
393
+
394
+ state :approval do
395
+ action :approve, to: :approved
396
+ action :reject, to: :draft
397
+ end
398
+
399
+ state :approved
400
+ end
401
+ end
402
+
403
+ # Base flow always available, additional workflow only when enabled
404
+ class Document
405
+ extend Circulator
406
+
407
+ flow :status do
408
+ state :draft do
409
+ action :save, to: :draft
410
+ end
411
+ end
412
+ end
413
+ ```
414
+
288
415
  ### Generating Diagrams
289
416
 
290
417
  You can generate diagrams for your Circulator models using the `circulator-diagram` executable. By default, it will generate a DOT file. You can also generate a PlantUML file by passing the `-f plantuml` option.
291
418
 
419
+ #### Generate a diagram for a specific model:
420
+
292
421
  ```bash
293
422
  bundle exec circulator-diagram MODEL_NAME
294
423
  ```
@@ -297,6 +426,30 @@ bundle exec circulator-diagram MODEL_NAME
297
426
  bundle exec circulator-diagram MODEL_NAME -f plantuml
298
427
  ```
299
428
 
429
+ #### Generate diagrams for all models with Circulator flows:
430
+
431
+ Use the `--all` option to automatically find and generate diagrams for all classes that have Circulator flows defined:
432
+
433
+ ```bash
434
+ bundle exec circulator-diagram --all
435
+ ```
436
+
437
+ ```bash
438
+ bundle exec circulator-diagram --all -f plantuml
439
+ ```
440
+
441
+ The `--all` option will:
442
+ - Automatically discover all classes with Circulator flows (including classes that inherit from a parent that extends Circulator)
443
+ - Eager load Rails application classes if running in a Rails environment
444
+ - Generate diagrams for each class found
445
+ - Use the same output directory and format options as single-model generation
446
+
447
+ #### Other options:
448
+
449
+ - `-d, --directory DIRECTORY` - Specify output directory (default: `docs`)
450
+ - `-s, --separate` - Generate separate diagram files for each flow attribute
451
+ - `-r, --require FILE` - Require a file before loading models (e.g., `config/environment`)
452
+
300
453
  ## Why Circulator?
301
454
 
302
455
  Circulator distinguishes itself from other Ruby state machine libraries through its simplicity and flexibility:
@@ -8,17 +8,21 @@ require_relative "../lib/circulator/dot"
8
8
  require_relative "../lib/circulator/plantuml"
9
9
 
10
10
  # Parse command-line options
11
- options = {format: "dot", require: nil, directory: "docs", separate: false}
11
+ options = {format: "dot", require: nil, directory: "docs", separate: false, all: false}
12
12
  parser = OptionParser.new do |opts|
13
- opts.banner = "Usage: circulator-diagram MODEL_NAME [options]"
13
+ opts.banner = "Usage: circulator-diagram [MODEL_NAME | --all] [options]"
14
14
  opts.separator ""
15
15
  opts.separator "Generate diagram files for Circulator state machine flows"
16
16
  opts.separator ""
17
17
  opts.separator "Arguments:"
18
- opts.separator " MODEL_NAME Name of the model class with Circulator flows"
18
+ opts.separator " MODEL_NAME Name of the model class with Circulator flows (required unless --all is used)"
19
19
  opts.separator ""
20
20
  opts.separator "Options:"
21
21
 
22
+ opts.on("-a", "--all", "Generate diagrams for all classes with Circulator in their ancestry") do
23
+ options[:all] = true
24
+ end
25
+
22
26
  opts.on("-f", "--format FORMAT", ["dot", "plantuml"], "Output format (dot, plantuml). Default: dot") do |format|
23
27
  options[:format] = format
24
28
  end
@@ -54,15 +58,15 @@ rescue OptionParser::InvalidOption => e
54
58
  exit 1
55
59
  end
56
60
 
57
- # Check for required model name argument
58
- if ARGV.empty?
59
- warn "Error: MODEL_NAME is required"
61
+ # Check for required model name argument or --all flag
62
+ if ARGV.empty? && !options[:all]
63
+ warn "Error: Either MODEL_NAME or --all is required"
60
64
  warn ""
61
65
  warn parser
62
66
  exit 1
63
67
  end
64
68
 
65
- model_name = ARGV[0]
69
+ model_name = ARGV[0] unless options[:all]
66
70
 
67
71
  # Load the application environment
68
72
  # Priority: -r option > config/environment.rb > nothing
@@ -79,22 +83,63 @@ elsif File.exist?("config/environment.rb")
79
83
  require File.expand_path("config/environment.rb")
80
84
  end
81
85
 
82
- # Try to constantize the model name
83
- begin
84
- model_class = Object.const_get(model_name)
85
- rescue NameError
86
- warn "Error: Model '#{model_name}' not found"
87
- warn "Make sure the model is loaded in your environment"
88
- exit 1
86
+ # Find all classes with Circulator in their ancestry
87
+ def find_circulator_classes
88
+ classes = []
89
+
90
+ # Try to force-load classes that might not be loaded yet
91
+ # This is especially important for Rails apps with lazy loading
92
+ if defined?(Rails) && Rails.application
93
+ begin
94
+ Rails.application.eager_load!
95
+ rescue => e
96
+ warn "Warning: Could not eager load Rails application: #{e.message}"
97
+ end
98
+ end
99
+
100
+ ObjectSpace.each_object(Class) do |klass|
101
+ # Skip anonymous classes and classes without names
102
+ next if klass.name.nil? || klass.name.empty?
103
+
104
+ # Check if the class responds to :flows (set up when Circulator is extended)
105
+ next unless klass.respond_to?(:flows)
106
+
107
+ begin
108
+ flows = klass.flows
109
+ next if flows.nil? || flows.empty?
110
+ rescue => e
111
+ # Skip classes where flows method raises an error
112
+ warn "Warning: Could not get flows for #{klass.name}: #{e.class} - #{e.message}"
113
+ next
114
+ end
115
+
116
+ classes << klass
117
+ rescue => e
118
+ # Skip any class that causes an error during inspection
119
+ if ENV["DEBUG"]
120
+ warn "Warning: Error inspecting class #{begin
121
+ klass.name
122
+ rescue
123
+ "unknown"
124
+ end}: #{e.class} - #{e.message}"
125
+ end
126
+ next
127
+ end
128
+
129
+ classes.sort_by(&:name)
89
130
  end
90
131
 
91
- # Generate diagram file(s)
92
- begin
93
- generator = case options[:format]
94
- when "plantuml"
95
- Circulator::PlantUml.new(model_class)
96
- else
97
- Circulator::Dot.new(model_class)
132
+ # Generate diagram for a single model class
133
+ def generate_diagram_for_class(model_class, options)
134
+ begin
135
+ generator = case options[:format]
136
+ when "plantuml"
137
+ Circulator::PlantUml.new(model_class)
138
+ else
139
+ Circulator::Dot.new(model_class)
140
+ end
141
+ rescue => e
142
+ raise "Failed to initialize diagram generator for #{model_class.name}: #{e.class} - #{e.message}"
98
143
  end
99
144
 
100
145
  # Determine base output filename and extension
@@ -163,6 +208,59 @@ begin
163
208
  puts " dot -Tpng #{output_file} -o #{File.join(options[:directory], base_name)}.png"
164
209
  end
165
210
  end
211
+ end
212
+
213
+ # Collect classes to generate diagrams for
214
+ begin
215
+ if options[:all]
216
+ # Find all classes with Circulator in their ancestry
217
+ begin
218
+ classes_to_process = find_circulator_classes
219
+ rescue => e
220
+ warn "Error finding Circulator classes: #{e.class} - #{e.message}"
221
+ warn e.backtrace.first(10).join("\n")
222
+ exit 1
223
+ end
224
+
225
+ if classes_to_process.empty?
226
+ warn "No classes with Circulator flows found"
227
+ exit 1
228
+ end
229
+
230
+ puts "Found #{classes_to_process.size} class(es) with Circulator flows:"
231
+ classes_to_process.each do |klass|
232
+ puts " - #{klass.name}"
233
+ end
234
+ puts ""
235
+ else
236
+ # Try to constantize the model name
237
+ begin
238
+ model_class = Object.const_get(model_name)
239
+ rescue NameError
240
+ warn "Error: Model '#{model_name}' not found"
241
+ warn "Make sure the model is loaded in your environment"
242
+ exit 1
243
+ end
244
+
245
+ classes_to_process = [model_class]
246
+ end
247
+
248
+ # Generate diagrams for all classes in the array
249
+ classes_to_process.each do |model_class|
250
+ if classes_to_process.size > 1
251
+ puts "Generating diagram for #{model_class.name}..."
252
+ end
253
+
254
+ begin
255
+ generate_diagram_for_class(model_class, options)
256
+ rescue => e
257
+ warn "Error generating diagram for #{model_class.name}: #{e.class} - #{e.message}"
258
+ warn e.backtrace.first(5).join("\n") if ENV["DEBUG"]
259
+ next
260
+ end
261
+
262
+ puts "" if classes_to_process.size > 1
263
+ end
166
264
 
167
265
  exit 0
168
266
  rescue ArgumentError => e
@@ -98,8 +98,10 @@ module Circulator
98
98
  validate_symbol_allow_if(allow_if)
99
99
  in Hash
100
100
  validate_hash_allow_if(allow_if)
101
+ in Array
102
+ validate_array_allow_if(allow_if)
101
103
  else
102
- raise ArgumentError, "allow_if must be a Proc, Hash, or Symbol, got: #{allow_if.class}"
104
+ raise ArgumentError, "allow_if must be a Proc, Hash, Symbol, or Array, got: #{allow_if.class}"
103
105
  end
104
106
  end
105
107
 
@@ -141,6 +143,25 @@ module Circulator
141
143
  end
142
144
  end
143
145
 
146
+ def validate_array_allow_if(allow_if_array)
147
+ # Array must not be empty
148
+ if allow_if_array.empty?
149
+ raise ArgumentError, "allow_if array must not be empty"
150
+ end
151
+
152
+ # First, validate all element types
153
+ allow_if_array.each do |element|
154
+ unless element.is_a?(Symbol) || element.is_a?(Proc)
155
+ raise ArgumentError, "allow_if array elements must be Symbols or Procs, got: #{element.class}"
156
+ end
157
+ end
158
+
159
+ # Then, validate that Symbol methods exist
160
+ allow_if_array.each do |element|
161
+ validate_symbol_allow_if(element) if element.is_a?(Symbol)
162
+ end
163
+ end
164
+
144
165
  def apply_extensions
145
166
  # Look up extensions for this class and attribute
146
167
  class_name = if @klass.is_a?(Class)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Circulator
4
- VERSION = "2.1.5"
4
+ VERSION = "2.1.7"
5
5
  end
data/lib/circulator.rb CHANGED
@@ -7,20 +7,64 @@ module Circulator
7
7
 
8
8
  @default_flow_proc = ::Hash.method(:new)
9
9
  class << self
10
+ # Returns the global registry of registered extensions
11
+ #
12
+ # The registry is a Hash where keys are "ClassName:attribute_name" strings
13
+ # and values are Arrays of extension blocks.
14
+ #
15
+ # Example:
16
+ #
17
+ # Circulator.extensions["Document:status"]
18
+ # # => [<Proc>, <Proc>] # Array of extension blocks
10
19
  attr_reader :extensions
20
+
21
+ # The default Proc used to create transition_maps for flows
22
+ #
23
+ # By default, this returns Hash.method(:new), which creates regular Hashes.
24
+ # Can be overridden to use custom storage implementations (e.g., any Hash-like object
25
+ # with custom merge behavior) by setting @default_flow_proc before defining flows.
11
26
  attr_reader :default_flow_proc
12
27
 
13
28
  # Register an extension for a specific class and attribute
14
29
  #
30
+ # Extensions allow you to add additional states and transitions to existing flows
31
+ # without modifying the original class definition. This is useful for:
32
+ # - Plugin gems extending host application workflows
33
+ # - Multi-tenant applications with customer-specific flows
34
+ # - Conditional feature enhancement based on configuration
35
+ #
36
+ # Extensions are registered globally and automatically applied when the class
37
+ # defines its flow. Multiple extensions can be registered for the same class/attribute.
38
+ #
15
39
  # Example:
16
40
  #
17
41
  # Circulator.extension(:Document, :status) do
18
42
  # state :pending do
19
43
  # action :send_to_legal, to: :legal_review
20
44
  # end
45
+ #
46
+ # state :legal_review do
47
+ # action :approve, to: :approved
48
+ # end
21
49
  # end
22
50
  #
23
- # Extensions are automatically applied when the class defines its flow
51
+ # Merging behavior (default):
52
+ # When an extension defines the same action from the same state as the base flow,
53
+ # the extension completely replaces the base definition (last-defined wins).
54
+ #
55
+ # Custom merging:
56
+ # To implement intelligent composition where extensions add conditions/blocks additively,
57
+ # pass a custom flows_proc parameter to your flow() definition that creates a Hash-like
58
+ # object with custom merge logic.
59
+ #
60
+ # Extension registration must happen before class definition (typically in initializers).
61
+ #
62
+ # Arguments:
63
+ # - class_name: Symbol or String - Name of class being extended
64
+ # - attribute_name: Symbol or String - Name of the flow attribute
65
+ # - block: Required block containing state and action definitions
66
+ #
67
+ # Raises ArgumentError if no block provided.
24
68
  def extension(class_name, attribute_name, &block)
25
69
  raise ArgumentError, "Block required for extension" unless block_given?
26
70
 
@@ -223,8 +267,18 @@ module Circulator
223
267
  end
224
268
 
225
269
  if transition[:allow_if]
270
+ # Handle array-based allow_if (array of symbols and/or procs)
271
+ if transition[:allow_if].is_a?(Array)
272
+ return unless transition[:allow_if].all? do |guard|
273
+ case guard
274
+ when Symbol
275
+ flow_target.send(guard, *args, **kwargs)
276
+ when Proc
277
+ flow_target.instance_exec(*args, **kwargs, &guard)
278
+ end
279
+ end
226
280
  # Handle hash-based allow_if (checking other attribute states)
227
- if transition[:allow_if].is_a?(Hash)
281
+ elsif transition[:allow_if].is_a?(Hash)
228
282
  attribute_name_to_check, valid_states = transition[:allow_if].first
229
283
  current_state = flow_target.send(attribute_name_to_check)
230
284
 
@@ -341,6 +395,45 @@ module Circulator
341
395
  available_flows(attribute, *args, **kwargs).include?(action)
342
396
  end
343
397
 
398
+ # Get the guard methods for a specific transition
399
+ #
400
+ # Returns an array of Symbol method names if the guard is an array of symbols,
401
+ # or nil if no guard or guard is not an array.
402
+ #
403
+ # Example:
404
+ #
405
+ # class Order
406
+ # extend Circulator
407
+ # flow(:status) do
408
+ # state :pending do
409
+ # action :approve, to: :approved, allow_if: [:approved?, :in_budget?]
410
+ # end
411
+ # end
412
+ # end
413
+ #
414
+ # order = Order.new
415
+ # order.guards_for(:status, :approve)
416
+ # # => [:approved?, :in_budget?]
417
+ def guards_for(attribute, action)
418
+ model_key = Circulator.model_key(self)
419
+ flow = flows.dig(model_key, attribute)
420
+ return nil unless flow
421
+
422
+ current_value = send(attribute)
423
+ current_state = current_value.respond_to?(:to_sym) ? current_value.to_sym : current_value
424
+
425
+ transition = flow.transition_map.dig(action, current_state)
426
+ return nil unless transition
427
+
428
+ guard = transition[:allow_if]
429
+ return nil unless guard
430
+
431
+ # If guard is an array, return only the Symbol elements
432
+ if guard.is_a?(Array)
433
+ guard.select { |g| g.is_a?(Symbol) }
434
+ end
435
+ end
436
+
344
437
  private
345
438
 
346
439
  def flows
@@ -349,6 +442,16 @@ module Circulator
349
442
 
350
443
  def check_allow_if(allow_if, *args, **kwargs)
351
444
  case allow_if
445
+ when Array
446
+ # All guards in array must be true (AND logic)
447
+ allow_if.all? do |guard|
448
+ case guard
449
+ when Symbol
450
+ send(guard, *args, **kwargs)
451
+ when Proc
452
+ instance_exec(*args, **kwargs, &guard)
453
+ end
454
+ end
352
455
  when Hash
353
456
  attribute_name, valid_states = allow_if.first
354
457
  current_state = send(attribute_name)
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.5
4
+ version: 2.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay
@@ -46,7 +46,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
46
46
  - !ruby/object:Gem::Version
47
47
  version: '0'
48
48
  requirements: []
49
- rubygems_version: 3.7.2
49
+ rubygems_version: 4.0.3
50
50
  specification_version: 4
51
51
  summary: Simple state machine
52
52
  test_files: []