circulator 2.1.4 → 2.1.6
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 +6 -16
- data/README.md +66 -0
- data/exe/circulator-diagram +119 -21
- data/lib/circulator/flow.rb +42 -6
- data/lib/circulator/version.rb +1 -1
- data/lib/circulator.rb +31 -4
- 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: 128d49edd784a3a0cc8ebc72ee4ca9be581bbea4ab57ff795a7c7435a52e3ddb
|
|
4
|
+
data.tar.gz: ae4a7ca3dd48197e944c95516f862e9f08cbc082ad098d0ef3d589fe351d59c7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5792c5c3fc86dc3a7d75fd619229426a3a8ae8198bee46fe28d7ec54f3d78ca374732c71281177fb53a8e54d5f1ed256af8163cd70420739b4450fb0f5b0589d
|
|
7
|
+
data.tar.gz: f7fc9e7bf3e10331306a3161c7feff64e09b0dd1933f52f054f98076256c2287e40c6ddd7ac7b6c7eee39bb55442ca3f3045179cc889378d87b9c59eb74dc971
|
data/CHANGELOG.md
CHANGED
|
@@ -5,24 +5,14 @@ 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
|
-
- Symbol-based allow_if support (a96b794)
|
|
13
|
-
- Documentation for symbol-based allow_if (d3befc1)
|
|
14
|
-
- available_flows method with guard and argument support (122e2ab)
|
|
15
|
-
- available_flow? predicate method (122e2ab)
|
|
16
|
-
- Documentation for query methods (a75507e)
|
|
8
|
+
## [2.1.6] - 2025-12-08
|
|
17
9
|
|
|
18
10
|
### Changed
|
|
19
11
|
|
|
20
|
-
-
|
|
12
|
+
- Dependabot config to follow a 14 day cooldown. (6aabc0a)
|
|
21
13
|
|
|
22
|
-
###
|
|
23
|
-
|
|
24
|
-
- Redundant respond_to? check (40ff669)
|
|
25
|
-
|
|
26
|
-
### Fixed
|
|
14
|
+
### Added
|
|
27
15
|
|
|
28
|
-
-
|
|
16
|
+
- Documentation for extension system and Circulator.extension API (be7c6fc)
|
|
17
|
+
- --all flag to automatically discover and generate diagrams for all classes with Circulator flows (f4dbbfc)
|
|
18
|
+
- Automatic eager loading of Rails application classes when using --all (f4dbbfc)
|
data/README.md
CHANGED
|
@@ -285,10 +285,52 @@ class Payment
|
|
|
285
285
|
end
|
|
286
286
|
```
|
|
287
287
|
|
|
288
|
+
#### Extending Flows
|
|
289
|
+
|
|
290
|
+
You can extend existing flows using `Circulator.extension`:
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
class Document
|
|
294
|
+
extend Circulator
|
|
295
|
+
|
|
296
|
+
attr_accessor :status
|
|
297
|
+
|
|
298
|
+
flow :status do
|
|
299
|
+
state :draft do
|
|
300
|
+
action :submit, to: :review
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
state :review do
|
|
304
|
+
action :approve, to: :approved
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
state :approved
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Add additional states and transitions
|
|
312
|
+
Circulator.extension(:Document, :status) do
|
|
313
|
+
state :review do
|
|
314
|
+
action :reject, to: :rejected
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
state :rejected do
|
|
318
|
+
action :revise, to: :draft
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
doc = Document.new
|
|
323
|
+
doc.status = :review
|
|
324
|
+
doc.status_reject # => :rejected (from extension)
|
|
325
|
+
doc.status_revise # => :draft (from extension)
|
|
326
|
+
```
|
|
327
|
+
|
|
288
328
|
### Generating Diagrams
|
|
289
329
|
|
|
290
330
|
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
331
|
|
|
332
|
+
#### Generate a diagram for a specific model:
|
|
333
|
+
|
|
292
334
|
```bash
|
|
293
335
|
bundle exec circulator-diagram MODEL_NAME
|
|
294
336
|
```
|
|
@@ -297,6 +339,30 @@ bundle exec circulator-diagram MODEL_NAME
|
|
|
297
339
|
bundle exec circulator-diagram MODEL_NAME -f plantuml
|
|
298
340
|
```
|
|
299
341
|
|
|
342
|
+
#### Generate diagrams for all models with Circulator flows:
|
|
343
|
+
|
|
344
|
+
Use the `--all` option to automatically find and generate diagrams for all classes that have Circulator flows defined:
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
bundle exec circulator-diagram --all
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
```bash
|
|
351
|
+
bundle exec circulator-diagram --all -f plantuml
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
The `--all` option will:
|
|
355
|
+
- Automatically discover all classes with Circulator flows (including classes that inherit from a parent that extends Circulator)
|
|
356
|
+
- Eager load Rails application classes if running in a Rails environment
|
|
357
|
+
- Generate diagrams for each class found
|
|
358
|
+
- Use the same output directory and format options as single-model generation
|
|
359
|
+
|
|
360
|
+
#### Other options:
|
|
361
|
+
|
|
362
|
+
- `-d, --directory DIRECTORY` - Specify output directory (default: `docs`)
|
|
363
|
+
- `-s, --separate` - Generate separate diagram files for each flow attribute
|
|
364
|
+
- `-r, --require FILE` - Require a file before loading models (e.g., `config/environment`)
|
|
365
|
+
|
|
300
366
|
## Why Circulator?
|
|
301
367
|
|
|
302
368
|
Circulator distinguishes itself from other Ruby state machine libraries through its simplicity and flexibility:
|
data/exe/circulator-diagram
CHANGED
|
@@ -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
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
data/lib/circulator/flow.rb
CHANGED
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
module Circulator
|
|
4
4
|
class Flow
|
|
5
|
-
def initialize(klass, attribute_name, states = Set.new, &block)
|
|
5
|
+
def initialize(klass, attribute_name, states = Set.new, extension: false, flows_proc: Circulator.default_flow_proc, &block)
|
|
6
6
|
@klass = klass
|
|
7
7
|
@attribute_name = attribute_name
|
|
8
8
|
@states = states
|
|
9
9
|
@no_action = ->(attribute_name, action) { raise "No action found for the current state of #{attribute_name} (#{send(attribute_name)}): #{action}" }
|
|
10
|
-
@
|
|
11
|
-
|
|
10
|
+
@flows_proc = flows_proc
|
|
11
|
+
@transition_map = flows_proc.call
|
|
12
|
+
|
|
13
|
+
# Execute the main flow block
|
|
14
|
+
instance_eval(&block) if block
|
|
15
|
+
|
|
16
|
+
# Apply any registered extensions (unless explicitly disabled)
|
|
17
|
+
apply_extensions unless extension
|
|
12
18
|
end
|
|
13
19
|
attr_reader :transition_map
|
|
14
20
|
|
|
@@ -28,7 +34,7 @@ module Circulator
|
|
|
28
34
|
validate_allow_if(allow_if)
|
|
29
35
|
end
|
|
30
36
|
|
|
31
|
-
@transition_map[name] ||=
|
|
37
|
+
@transition_map[name] ||= @flows_proc.call
|
|
32
38
|
selected_state = (from == :__not_specified__) ? @current_state : from
|
|
33
39
|
|
|
34
40
|
# Handle nil case specially - convert to [nil] instead of []
|
|
@@ -48,8 +54,10 @@ module Circulator
|
|
|
48
54
|
@states.add(to_state)
|
|
49
55
|
end
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
# Build transition data hash with all keys at once
|
|
58
|
+
transition_data = {to:, block:}
|
|
59
|
+
transition_data[:allow_if] = allow_if if allow_if
|
|
60
|
+
@transition_map[name][from_state] = transition_data
|
|
53
61
|
end
|
|
54
62
|
end
|
|
55
63
|
|
|
@@ -132,5 +140,33 @@ module Circulator
|
|
|
132
140
|
raise ArgumentError, "allow_if references invalid states #{invalid_states.inspect} for :#{attribute_name}. Valid states: #{referenced_states.to_a.inspect}"
|
|
133
141
|
end
|
|
134
142
|
end
|
|
143
|
+
|
|
144
|
+
def apply_extensions
|
|
145
|
+
# Look up extensions for this class and attribute
|
|
146
|
+
class_name = if @klass.is_a?(Class)
|
|
147
|
+
@klass.name || @klass.to_s
|
|
148
|
+
else
|
|
149
|
+
Circulator.model_key(@klass)
|
|
150
|
+
end
|
|
151
|
+
key = "#{class_name}:#{@attribute_name}"
|
|
152
|
+
extensions = Circulator.extensions[key]
|
|
153
|
+
|
|
154
|
+
# Apply each extension by creating a new Flow and merging its transition_map
|
|
155
|
+
extensions.each do |extension_block|
|
|
156
|
+
extension_flow = Flow.new(@klass, @attribute_name, @states, extension: true, flows_proc: @flows_proc, &extension_block)
|
|
157
|
+
extension_flow.transition_map.each do |action, transitions|
|
|
158
|
+
@transition_map[action] = if @transition_map[action]
|
|
159
|
+
@transition_map[action].merge(transitions)
|
|
160
|
+
else
|
|
161
|
+
transitions
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Merge states from extension
|
|
166
|
+
extension_flow.instance_variable_get(:@states).each do |state|
|
|
167
|
+
@states.add(state)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
135
171
|
end
|
|
136
172
|
end
|
data/lib/circulator/version.rb
CHANGED
data/lib/circulator.rb
CHANGED
|
@@ -2,6 +2,32 @@ require "circulator/version"
|
|
|
2
2
|
require "circulator/flow"
|
|
3
3
|
|
|
4
4
|
module Circulator
|
|
5
|
+
# Global registry for extensions
|
|
6
|
+
@extensions = Hash.new { |h, k| h[k] = [] }
|
|
7
|
+
|
|
8
|
+
@default_flow_proc = ::Hash.method(:new)
|
|
9
|
+
class << self
|
|
10
|
+
attr_reader :extensions
|
|
11
|
+
attr_reader :default_flow_proc
|
|
12
|
+
|
|
13
|
+
# Register an extension for a specific class and attribute
|
|
14
|
+
#
|
|
15
|
+
# Example:
|
|
16
|
+
#
|
|
17
|
+
# Circulator.extension(:Document, :status) do
|
|
18
|
+
# state :pending do
|
|
19
|
+
# action :send_to_legal, to: :legal_review
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# Extensions are automatically applied when the class defines its flow
|
|
24
|
+
def extension(class_name, attribute_name, &block)
|
|
25
|
+
raise ArgumentError, "Block required for extension" unless block_given?
|
|
26
|
+
|
|
27
|
+
key = "#{class_name}:#{attribute_name}"
|
|
28
|
+
@extensions[key] << block
|
|
29
|
+
end
|
|
30
|
+
end
|
|
5
31
|
# Declare a flow for an attribute.
|
|
6
32
|
#
|
|
7
33
|
# Specify the attribute to be used for states and actions.
|
|
@@ -125,11 +151,12 @@ module Circulator
|
|
|
125
151
|
# test_object.flow(:unknown, :status, "signal")
|
|
126
152
|
# # Will raise an UnhandledSignalError
|
|
127
153
|
#
|
|
128
|
-
def flow(attribute_name, model: to_s, &block)
|
|
129
|
-
@flows ||=
|
|
154
|
+
def flow(attribute_name, model: to_s, flows_proc: Circulator.default_flow_proc, &block)
|
|
155
|
+
@flows ||= flows_proc.call
|
|
130
156
|
model_key = Circulator.model_key(model)
|
|
131
|
-
@flows[model_key] ||=
|
|
132
|
-
|
|
157
|
+
@flows[model_key] ||= flows_proc.call
|
|
158
|
+
# Pass the flows_proc to Flow so it can create transition_maps of the same type
|
|
159
|
+
@flows[model_key][attribute_name] = Flow.new(self, attribute_name, flows_proc:, &block)
|
|
133
160
|
|
|
134
161
|
flow_module = ancestors.find { |ancestor|
|
|
135
162
|
ancestor.name.to_s =~ /FlowMethods/
|