circulator 1.0.0 → 2.0.1
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 +7 -3
- data/README.md +17 -5
- data/Rakefile +9 -2
- data/exe/circulator-diagram +118 -0
- data/lib/circulator/dot.rb +92 -0
- data/lib/circulator/flow.rb +1 -3
- data/lib/circulator/plantuml.rb +88 -0
- data/lib/circulator/version.rb +1 -1
- data/lib/circulator.rb +239 -1
- metadata +6 -3
- data/lib/circulator/diverter.rb +0 -242
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 42195ffd537b18be888f0bbab239d61d9ff19a2ff91c50adf0d6e51bea4119b0
|
4
|
+
data.tar.gz: ec684979c91e8a5bba4538e6a0c0546d1b23c2746d83b815cc9f43f5cb01fd83
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: eb5064f15bb2d4756139ae66b8a229d35afd9e3df5dd09ae0f8875c522661ffa82c7d0773d6dd088becca25c42f17da350fee347723357d21bd6b67037aa256d
|
7
|
+
data.tar.gz: 1d800dc29d73a0e5f8e88821abba4ec9b3e4d48e109362b5bbd15550a0af4a7e40e2c7a00346c64ac8eebe6d8caf327c57129ea1ae3d09de2129922c235adc0a
|
data/CHANGELOG.md
CHANGED
@@ -5,8 +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
|
-
## [
|
8
|
+
## [2.0.1] - 2025-10-01
|
9
9
|
|
10
|
-
###
|
10
|
+
### Changed
|
11
11
|
|
12
|
-
-
|
12
|
+
- Switched to a rake test task to set the test order
|
13
|
+
|
14
|
+
### Added
|
15
|
+
|
16
|
+
- Ability to generate diagrams from declared flows in Dot or PlantUML
|
data/README.md
CHANGED
@@ -39,7 +39,7 @@ gem install circulator
|
|
39
39
|
|
40
40
|
```ruby
|
41
41
|
class Order
|
42
|
-
extend Circulator
|
42
|
+
extend Circulator
|
43
43
|
|
44
44
|
attr_accessor :status
|
45
45
|
|
@@ -77,7 +77,7 @@ order.status_deliver # => :delivered
|
|
77
77
|
|
78
78
|
```ruby
|
79
79
|
class Document
|
80
|
-
extend Circulator
|
80
|
+
extend Circulator
|
81
81
|
|
82
82
|
attr_accessor :state, :reviewed_by
|
83
83
|
|
@@ -97,7 +97,7 @@ end
|
|
97
97
|
|
98
98
|
```ruby
|
99
99
|
class Task
|
100
|
-
extend Circulator
|
100
|
+
extend Circulator
|
101
101
|
|
102
102
|
attr_accessor :priority, :urgency_level
|
103
103
|
|
@@ -114,7 +114,7 @@ end
|
|
114
114
|
|
115
115
|
```ruby
|
116
116
|
class Server
|
117
|
-
extend Circulator
|
117
|
+
extend Circulator
|
118
118
|
|
119
119
|
attr_accessor :power_state, :network_state
|
120
120
|
|
@@ -147,7 +147,7 @@ end
|
|
147
147
|
|
148
148
|
```ruby
|
149
149
|
class Payment
|
150
|
-
extend Circulator
|
150
|
+
extend Circulator
|
151
151
|
|
152
152
|
attr_accessor :status, :processed_at
|
153
153
|
|
@@ -168,6 +168,18 @@ class Payment
|
|
168
168
|
end
|
169
169
|
```
|
170
170
|
|
171
|
+
### Generating Diagrams
|
172
|
+
|
173
|
+
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.
|
174
|
+
|
175
|
+
```bash
|
176
|
+
bundle exec circulator-diagram MODEL_NAME
|
177
|
+
```
|
178
|
+
|
179
|
+
```bash
|
180
|
+
bundle exec circulator-diagram MODEL_NAME -f plantuml
|
181
|
+
```
|
182
|
+
|
171
183
|
## Why Circulator?
|
172
184
|
|
173
185
|
Circulator distinguishes itself from other Ruby state machine libraries through its simplicity and flexibility:
|
data/Rakefile
CHANGED
@@ -1,9 +1,15 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "bundler/gem_tasks"
|
4
|
-
require "
|
4
|
+
require "rake/testtask"
|
5
5
|
|
6
|
-
|
6
|
+
Rake::TestTask.new(:test) do |t|
|
7
|
+
t.libs << "test"
|
8
|
+
t.libs << "lib"
|
9
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
10
|
+
t.verbose = true
|
11
|
+
t.warning = true
|
12
|
+
end
|
7
13
|
|
8
14
|
require "standard/rake"
|
9
15
|
|
@@ -15,4 +21,5 @@ Reissue::Task.create :reissue do |task|
|
|
15
21
|
task.version_file = "lib/circulator/version.rb"
|
16
22
|
task.changelog_file = "CHANGELOG.md"
|
17
23
|
task.version_limit = 1
|
24
|
+
task.fragment = :git
|
18
25
|
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "optparse"
|
5
|
+
require "fileutils"
|
6
|
+
require_relative "../lib/circulator"
|
7
|
+
require_relative "../lib/circulator/dot"
|
8
|
+
require_relative "../lib/circulator/plantuml"
|
9
|
+
|
10
|
+
# Parse command-line options
|
11
|
+
options = {format: "dot"}
|
12
|
+
parser = OptionParser.new do |opts|
|
13
|
+
opts.banner = "Usage: circulator-diagram MODEL_NAME [options]"
|
14
|
+
opts.separator ""
|
15
|
+
opts.separator "Generate diagram files for Circulator state machine flows"
|
16
|
+
opts.separator ""
|
17
|
+
opts.separator "Arguments:"
|
18
|
+
opts.separator " MODEL_NAME Name of the model class with Circulator flows"
|
19
|
+
opts.separator ""
|
20
|
+
opts.separator "Options:"
|
21
|
+
|
22
|
+
opts.on("-f", "--format FORMAT", ["dot", "plantuml"], "Output format (dot, plantuml). Default: dot") do |format|
|
23
|
+
options[:format] = format
|
24
|
+
end
|
25
|
+
|
26
|
+
opts.on("-h", "--help", "Show this help message") do
|
27
|
+
puts opts
|
28
|
+
exit 0
|
29
|
+
end
|
30
|
+
|
31
|
+
opts.on("-v", "--version", "Show version") do
|
32
|
+
puts "circulator-diagram #{Circulator::VERSION}"
|
33
|
+
exit 0
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
begin
|
38
|
+
parser.parse!
|
39
|
+
rescue OptionParser::InvalidOption => e
|
40
|
+
warn "Error: #{e.message}"
|
41
|
+
warn parser
|
42
|
+
exit 1
|
43
|
+
end
|
44
|
+
|
45
|
+
# Check for required model name argument
|
46
|
+
if ARGV.empty?
|
47
|
+
warn "Error: MODEL_NAME is required"
|
48
|
+
warn ""
|
49
|
+
warn parser
|
50
|
+
exit 1
|
51
|
+
end
|
52
|
+
|
53
|
+
model_name = ARGV[0]
|
54
|
+
|
55
|
+
# Try to constantize the model name
|
56
|
+
begin
|
57
|
+
model_class = Object.const_get(model_name)
|
58
|
+
rescue NameError
|
59
|
+
warn "Error: Model '#{model_name}' not found"
|
60
|
+
warn "Make sure the model is loaded in your environment"
|
61
|
+
exit 1
|
62
|
+
end
|
63
|
+
|
64
|
+
# Generate diagram file
|
65
|
+
begin
|
66
|
+
generator = case options[:format]
|
67
|
+
when "plantuml"
|
68
|
+
Circulator::PlantUml.new(model_class)
|
69
|
+
else
|
70
|
+
Circulator::Dot.new(model_class)
|
71
|
+
end
|
72
|
+
|
73
|
+
content = generator.generate
|
74
|
+
|
75
|
+
# Determine output filename and extension
|
76
|
+
# Convert namespaced class names to directory paths
|
77
|
+
# Something::Other becomes something/other
|
78
|
+
class_name = model_class.name || "diagram"
|
79
|
+
path_parts = class_name.split("::").map { |part|
|
80
|
+
part.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
|
81
|
+
}
|
82
|
+
base_name = path_parts.join("/")
|
83
|
+
|
84
|
+
if options[:format] == "plantuml"
|
85
|
+
# Use model class name for PlantUML files
|
86
|
+
output_file = "#{base_name}.puml"
|
87
|
+
|
88
|
+
# Create directory if needed
|
89
|
+
dir = File.dirname(output_file)
|
90
|
+
FileUtils.mkdir_p(dir) unless dir == "." || File.exist?(dir)
|
91
|
+
|
92
|
+
File.write(output_file, content)
|
93
|
+
puts "Generated PlantUML file: #{output_file}"
|
94
|
+
puts "To create an image, run:"
|
95
|
+
puts " plantuml #{output_file}"
|
96
|
+
else
|
97
|
+
# Use model class name for DOT files
|
98
|
+
output_file = "#{base_name}.dot"
|
99
|
+
|
100
|
+
# Create directory if needed
|
101
|
+
dir = File.dirname(output_file)
|
102
|
+
FileUtils.mkdir_p(dir) unless dir == "." || File.exist?(dir)
|
103
|
+
|
104
|
+
File.write(output_file, content)
|
105
|
+
puts "Generated DOT file: #{output_file}"
|
106
|
+
puts "To create an image, run:"
|
107
|
+
puts " dot -Tpng #{output_file} -o #{base_name}.png"
|
108
|
+
end
|
109
|
+
|
110
|
+
exit 0
|
111
|
+
rescue ArgumentError => e
|
112
|
+
warn "Error: #{e.message}"
|
113
|
+
exit 1
|
114
|
+
rescue => e
|
115
|
+
warn "Error: #{e.class} - #{e.message}"
|
116
|
+
warn e.backtrace.first(5).join("\n") if ENV["DEBUG"]
|
117
|
+
exit 1
|
118
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Circulator
|
4
|
+
class Dot
|
5
|
+
def initialize(model_class)
|
6
|
+
unless model_class.respond_to?(:flows)
|
7
|
+
raise ArgumentError, "Model class must extend Circulator"
|
8
|
+
end
|
9
|
+
|
10
|
+
flows = model_class.flows
|
11
|
+
if flows.nil? || flows.empty?
|
12
|
+
raise ArgumentError, "Model class has no flows defined"
|
13
|
+
end
|
14
|
+
|
15
|
+
@model_class = model_class
|
16
|
+
@flows = flows
|
17
|
+
end
|
18
|
+
|
19
|
+
def generate
|
20
|
+
output = []
|
21
|
+
output << "digraph #{graph_name} {"
|
22
|
+
output << " rankdir=LR;"
|
23
|
+
output << ""
|
24
|
+
|
25
|
+
# Collect all states and transitions
|
26
|
+
states = Set.new
|
27
|
+
transitions = []
|
28
|
+
|
29
|
+
@flows.each do |model_key, attribute_flows|
|
30
|
+
attribute_flows.each do |attribute_name, flow|
|
31
|
+
# Extract states and transitions from the flow
|
32
|
+
flow.transition_map.each do |action, state_transitions|
|
33
|
+
state_transitions.each do |from_state, transition_info|
|
34
|
+
states.add(from_state)
|
35
|
+
|
36
|
+
to_state = transition_info[:to]
|
37
|
+
if to_state.respond_to?(:call)
|
38
|
+
# Dynamic state - use ? as placeholder
|
39
|
+
states.add(:"?")
|
40
|
+
label = "#{action} (dynamic)"
|
41
|
+
transitions << {from: from_state, to: :"?", label: label}
|
42
|
+
else
|
43
|
+
states.add(to_state)
|
44
|
+
label = action.to_s
|
45
|
+
label += " (conditional)" if transition_info[:allow_if]
|
46
|
+
transitions << {from: from_state, to: to_state, label: label}
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Output state nodes
|
54
|
+
output << " // States"
|
55
|
+
states.sort_by { |s| s.to_s }.each do |state|
|
56
|
+
state_label = state.nil? ? "nil" : state.to_s
|
57
|
+
output << " #{state_label} [shape=circle];"
|
58
|
+
end
|
59
|
+
|
60
|
+
output << ""
|
61
|
+
output << " // Transitions"
|
62
|
+
|
63
|
+
# Output transition edges
|
64
|
+
transitions.sort_by { |t| [t[:from].to_s, t[:to].to_s, t[:label]] }.each do |transition|
|
65
|
+
from_label = transition[:from].nil? ? "nil" : transition[:from].to_s
|
66
|
+
to_label = transition[:to].nil? ? "nil" : transition[:to].to_s
|
67
|
+
output << " #{from_label} -> #{to_label} [label=\"#{transition[:label]}\"];"
|
68
|
+
end
|
69
|
+
|
70
|
+
output << "}"
|
71
|
+
output.join("\n") + "\n"
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def graph_name
|
77
|
+
# Use the model class name if available, otherwise use the model key
|
78
|
+
class_name = @model_class.name
|
79
|
+
model_key = @flows.keys.first
|
80
|
+
|
81
|
+
# If class has no name, use the model_key which will be like "anonymous_XXX"
|
82
|
+
# If model_key differs from class_name, it's a model-based flow, use model_key
|
83
|
+
if class_name.nil?
|
84
|
+
"#{model_key}_flows"
|
85
|
+
elsif model_key && model_key != class_name
|
86
|
+
"#{model_key}_flows"
|
87
|
+
else
|
88
|
+
"#{class_name}_flows"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
data/lib/circulator/flow.rb
CHANGED
@@ -0,0 +1,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Circulator
|
4
|
+
class PlantUml
|
5
|
+
def initialize(model_class)
|
6
|
+
unless model_class.respond_to?(:flows)
|
7
|
+
raise ArgumentError, "Model class must extend Circulator"
|
8
|
+
end
|
9
|
+
|
10
|
+
flows = model_class.flows
|
11
|
+
if flows.nil? || flows.empty?
|
12
|
+
raise ArgumentError, "Model class has no flows defined"
|
13
|
+
end
|
14
|
+
|
15
|
+
@model_class = model_class
|
16
|
+
@flows = flows
|
17
|
+
end
|
18
|
+
|
19
|
+
def generate
|
20
|
+
output = []
|
21
|
+
output << "@startuml"
|
22
|
+
output << ""
|
23
|
+
|
24
|
+
# Collect all states and transitions
|
25
|
+
states = Set.new
|
26
|
+
transitions = []
|
27
|
+
|
28
|
+
@flows.each do |model_key, attribute_flows|
|
29
|
+
attribute_flows.each do |attribute_name, flow|
|
30
|
+
# Extract states and transitions from the flow
|
31
|
+
flow.transition_map.each do |action, state_transitions|
|
32
|
+
state_transitions.each do |from_state, transition_info|
|
33
|
+
states.add(from_state)
|
34
|
+
|
35
|
+
to_state = transition_info[:to]
|
36
|
+
if to_state.respond_to?(:call)
|
37
|
+
# Dynamic state - use [*] as placeholder (end state)
|
38
|
+
label = action.to_s
|
39
|
+
transitions << {
|
40
|
+
from: from_state,
|
41
|
+
to: nil,
|
42
|
+
label: label,
|
43
|
+
note: "dynamic target state"
|
44
|
+
}
|
45
|
+
else
|
46
|
+
states.add(to_state)
|
47
|
+
label = action.to_s
|
48
|
+
note = nil
|
49
|
+
note = "conditional transition" if transition_info[:allow_if]
|
50
|
+
transitions << {
|
51
|
+
from: from_state,
|
52
|
+
to: to_state,
|
53
|
+
label: label,
|
54
|
+
note: note
|
55
|
+
}
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Output state declarations (except nil which is represented as [*])
|
63
|
+
states.reject(&:nil?).sort_by(&:to_s).each do |state|
|
64
|
+
output << "state #{state}"
|
65
|
+
end
|
66
|
+
|
67
|
+
output << ""
|
68
|
+
|
69
|
+
# Output transitions
|
70
|
+
transitions.sort_by { |t| [t[:from].to_s, t[:to].to_s, t[:label]] }.each do |transition|
|
71
|
+
from_label = transition[:from].nil? ? "[*]" : transition[:from].to_s
|
72
|
+
to_label = transition[:to].nil? ? "[*]" : transition[:to].to_s
|
73
|
+
output << "#{from_label} --> #{to_label} : #{transition[:label]}"
|
74
|
+
|
75
|
+
# Add note if present
|
76
|
+
if transition[:note]
|
77
|
+
output << "note on link"
|
78
|
+
output << " #{transition[:note]}"
|
79
|
+
output << "end note"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
output << ""
|
84
|
+
output << "@enduml"
|
85
|
+
output.join("\n") + "\n"
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
data/lib/circulator/version.rb
CHANGED
data/lib/circulator.rb
CHANGED
@@ -1,3 +1,241 @@
|
|
1
1
|
require "circulator/version"
|
2
|
-
require "circulator/diverter"
|
3
2
|
require "circulator/flow"
|
3
|
+
|
4
|
+
module Circulator
|
5
|
+
# Declare a flow for an attribute.
|
6
|
+
#
|
7
|
+
# Specify the attribute to be used for states and actions.
|
8
|
+
#
|
9
|
+
# Example:
|
10
|
+
#
|
11
|
+
# flow(:status) do
|
12
|
+
# state :pending do
|
13
|
+
# action :approve, to: :approved
|
14
|
+
# end
|
15
|
+
# end
|
16
|
+
#
|
17
|
+
# The above declares a flow for the `status` attribute. When in the `pending`
|
18
|
+
# state, the `approve` action will transition the `status` to `approved`.
|
19
|
+
#
|
20
|
+
# This creates a `status_approve` method which will change the state in memory.
|
21
|
+
#
|
22
|
+
# You will also have a instance method `flow` which will allow you to specify
|
23
|
+
# the action to take on the attribute.
|
24
|
+
#
|
25
|
+
# Example:
|
26
|
+
#
|
27
|
+
# test_object.status_approve
|
28
|
+
# # OR
|
29
|
+
# test_object.flow(:approve, :status)
|
30
|
+
#
|
31
|
+
# You can also provide a block to receive arguments
|
32
|
+
#
|
33
|
+
# Example:
|
34
|
+
#
|
35
|
+
# flow(:status) do
|
36
|
+
# state :pending do
|
37
|
+
# action :approve, to: :approved do |*args, **kwargs|
|
38
|
+
# @args_received = {args: args, kwargs: kwargs}
|
39
|
+
# end
|
40
|
+
# action_allowed(:approve) { true } # Optional. Check some value on self
|
41
|
+
# end
|
42
|
+
# end
|
43
|
+
#
|
44
|
+
# The block will be evalutaed on the instance of the class declaring the flow.
|
45
|
+
# So `self` inside that action block will be the instance of the class.
|
46
|
+
#
|
47
|
+
# Example:
|
48
|
+
#
|
49
|
+
# test_object.status_approve("arg1", "arg2", key: "value")
|
50
|
+
# # @args_received will be {args: ["arg1", "arg2"], kwargs: {key: "value"}}
|
51
|
+
#
|
52
|
+
# If the action is not allowed, the transition will not be executed.
|
53
|
+
#
|
54
|
+
# Example:
|
55
|
+
#
|
56
|
+
# flow(:status) do
|
57
|
+
# state :pending do
|
58
|
+
# action :approve, to: :approved do
|
59
|
+
# @args_received = {args: args, kwargs: kwargs}
|
60
|
+
# end
|
61
|
+
# action_allowed(:approve) { false }
|
62
|
+
# end
|
63
|
+
# end
|
64
|
+
#
|
65
|
+
# test_object.status_approve
|
66
|
+
# # Will not transition because the action is not allowed
|
67
|
+
#
|
68
|
+
# You may also specify the `allow_if` option to check a condition before
|
69
|
+
# the action is allowed. The callable will be evaluated on the instance of
|
70
|
+
# the class declaring the flow. So `self` inside that block will be the
|
71
|
+
# instance of the class.
|
72
|
+
#
|
73
|
+
# Example:
|
74
|
+
#
|
75
|
+
# flow(:status) do
|
76
|
+
# state :pending do
|
77
|
+
# action :approve, to: :approved, allow_if: -> { true }
|
78
|
+
# end
|
79
|
+
# end
|
80
|
+
#
|
81
|
+
# test_object.status_approve
|
82
|
+
# # Will transition to :approved if the condition is true
|
83
|
+
#
|
84
|
+
# If you declare states separately, for example in an enum, you can use the
|
85
|
+
# `action` method to declare the action on the attribute.
|
86
|
+
#
|
87
|
+
# Example:
|
88
|
+
#
|
89
|
+
# enum :status, {pending: 0, approved: 1, rejected: 2}
|
90
|
+
# flow(:status) do
|
91
|
+
# action :approve, to: :approved, from: :pending
|
92
|
+
# action :reject, to: :rejected, from: :approved do |rejected_at|
|
93
|
+
# self.rejected_at = rejected_at
|
94
|
+
# end
|
95
|
+
# end
|
96
|
+
#
|
97
|
+
# test_object.status_approve
|
98
|
+
# # Will transition to :approved
|
99
|
+
# test_object.status_reject
|
100
|
+
# # Will transition to :rejected and set the rejected_at attribute
|
101
|
+
#
|
102
|
+
# By default, if there is no transition for the current state, the flow will
|
103
|
+
# raise an error. You can specify a no_action block to handle this case.
|
104
|
+
#
|
105
|
+
# Example:
|
106
|
+
#
|
107
|
+
# flow(:status) do
|
108
|
+
# no_action { |attribute_name, action| raise "Nope!" }
|
109
|
+
# end
|
110
|
+
#
|
111
|
+
# test_object.status_approve
|
112
|
+
# # Will raise an error
|
113
|
+
#
|
114
|
+
# You can also provide a custom action for other behavior for a set of states and
|
115
|
+
# use the `to` option as a callable to set the attribute.
|
116
|
+
#
|
117
|
+
# Example:
|
118
|
+
#
|
119
|
+
# flow(:status) do
|
120
|
+
# action :unknown, to: -> { status }, from: [:enforcing, :monitoring, :ignoring] do |signal|
|
121
|
+
# raise UnhandledSignalError, signal
|
122
|
+
# end
|
123
|
+
# end
|
124
|
+
#
|
125
|
+
# test_object.flow(:unknown, :status, "signal")
|
126
|
+
# # Will raise an UnhandledSignalError
|
127
|
+
#
|
128
|
+
def flow(attribute_name, model: to_s, &block)
|
129
|
+
@flows ||= {}
|
130
|
+
model_key = Circulator.model_key(model)
|
131
|
+
@flows[model_key] ||= {}
|
132
|
+
@flows[model_key][attribute_name] = Flow.new(model, attribute_name, &block)
|
133
|
+
|
134
|
+
flow_module = ancestors.find { |ancestor|
|
135
|
+
ancestor.name.to_s =~ /FlowMethods/
|
136
|
+
} || Module.new.tap do |mod|
|
137
|
+
include mod
|
138
|
+
|
139
|
+
const_set(:FlowMethods, mod)
|
140
|
+
end
|
141
|
+
|
142
|
+
object = if model == to_s
|
143
|
+
nil
|
144
|
+
else
|
145
|
+
Circulator.methodize_name(model)
|
146
|
+
end
|
147
|
+
|
148
|
+
@flows.dig(model_key, attribute_name).transition_map.each do |action, transitions|
|
149
|
+
define_flow_method(attribute_name:, action:, transitions:, object:, owner: flow_module)
|
150
|
+
end
|
151
|
+
end
|
152
|
+
alias_method :circulator, :flow
|
153
|
+
|
154
|
+
def define_flow_method(attribute_name:, action:, transitions:, object:, owner:)
|
155
|
+
object_attribute_method = [object, attribute_name, action].compact.join("_")
|
156
|
+
raise ArgumentError, "Method already defined: #{object_attribute_method}" if owner.method_defined?(object_attribute_method)
|
157
|
+
|
158
|
+
owner.define_method(object_attribute_method) do |*args, flow_target: self, **kwargs, &block|
|
159
|
+
current_value = flow_target.send(attribute_name)
|
160
|
+
|
161
|
+
transition = if current_value.respond_to?(:to_sym)
|
162
|
+
transitions[current_value.to_sym]
|
163
|
+
else
|
164
|
+
transitions[current_value]
|
165
|
+
end
|
166
|
+
|
167
|
+
unless transition
|
168
|
+
flow_target.instance_exec(attribute_name, action, &flows.dig(Circulator.model_key(flow_target), attribute_name).no_action)
|
169
|
+
return
|
170
|
+
end
|
171
|
+
|
172
|
+
if transition[:allow_if]
|
173
|
+
return unless flow_target.instance_exec(*args, **kwargs, &transition[:allow_if])
|
174
|
+
end
|
175
|
+
|
176
|
+
if transition[:block]
|
177
|
+
flow_target.instance_exec(*args, **kwargs, &transition[:block])
|
178
|
+
end
|
179
|
+
|
180
|
+
if transition[:to].respond_to?(:call)
|
181
|
+
flow_target.send("#{attribute_name}=", flow_target.instance_exec(*args, **kwargs, &transition[:to]))
|
182
|
+
else
|
183
|
+
flow_target.send("#{attribute_name}=", transition[:to])
|
184
|
+
end.tap do
|
185
|
+
if block
|
186
|
+
flow_target.instance_exec(*args, **kwargs, &block)
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
module_function def model_key(object)
|
193
|
+
if object.is_a?(String)
|
194
|
+
if object.start_with?("#<Class:")
|
195
|
+
"anonymous_#{object.split("0x")[1]}".sub(">", "")
|
196
|
+
else
|
197
|
+
object
|
198
|
+
end
|
199
|
+
else
|
200
|
+
model_key(object.class.name || object.class.to_s)
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
module_function def methodize_name(name)
|
205
|
+
name.split("::").map { |part| part.gsub(/([a-z])([A-Z])/, '\1_\2') }.join("_").downcase
|
206
|
+
end
|
207
|
+
|
208
|
+
def self.extended(base)
|
209
|
+
base.include(InstanceMethods)
|
210
|
+
base.singleton_class.attr_reader :flows
|
211
|
+
end
|
212
|
+
|
213
|
+
module InstanceMethods
|
214
|
+
# Use this method to call an action on the attribute.
|
215
|
+
#
|
216
|
+
# Example:
|
217
|
+
#
|
218
|
+
# test_object.flow(:approve, :status)
|
219
|
+
# test_object.flow(:approve, :status, "arg1", "arg2", key: "value")
|
220
|
+
def flow(action, attribute, *args, flow_target: self, **kwargs, &block)
|
221
|
+
target_name = if flow_target != self
|
222
|
+
Circulator.methodize_name(Circulator.model_key(flow_target))
|
223
|
+
end
|
224
|
+
external_attribute_name = [target_name, attribute].compact.join("_")
|
225
|
+
method_name = "#{external_attribute_name}_#{action}"
|
226
|
+
if respond_to?(method_name)
|
227
|
+
send(method_name, *args, flow_target:, **kwargs, &block)
|
228
|
+
elsif flow_target.respond_to?(method_name)
|
229
|
+
flow_target.send(method_name, *args, **kwargs, &block)
|
230
|
+
else
|
231
|
+
raise "Invalid action for the current state of #{attribute} (#{flow_target.send(attribute).inspect}): #{action}"
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
private
|
236
|
+
|
237
|
+
def flows
|
238
|
+
self.class.flows
|
239
|
+
end
|
240
|
+
end
|
241
|
+
end
|
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:
|
4
|
+
version: 2.0.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jim Gay
|
@@ -12,16 +12,19 @@ dependencies: []
|
|
12
12
|
description: Simple declarative state machine
|
13
13
|
email:
|
14
14
|
- jim@saturnflyer.com
|
15
|
-
executables:
|
15
|
+
executables:
|
16
|
+
- circulator-diagram
|
16
17
|
extensions: []
|
17
18
|
extra_rdoc_files: []
|
18
19
|
files:
|
19
20
|
- CHANGELOG.md
|
20
21
|
- README.md
|
21
22
|
- Rakefile
|
23
|
+
- exe/circulator-diagram
|
22
24
|
- lib/circulator.rb
|
23
|
-
- lib/circulator/
|
25
|
+
- lib/circulator/dot.rb
|
24
26
|
- lib/circulator/flow.rb
|
27
|
+
- lib/circulator/plantuml.rb
|
25
28
|
- lib/circulator/version.rb
|
26
29
|
homepage: https://github.com/SOFware/circulator
|
27
30
|
licenses: []
|
data/lib/circulator/diverter.rb
DELETED
@@ -1,242 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Circulator
|
4
|
-
module Diverter
|
5
|
-
# Declare a flow for an attribute.
|
6
|
-
#
|
7
|
-
# Specify the attribute to be used for states and actions.
|
8
|
-
#
|
9
|
-
# Example:
|
10
|
-
#
|
11
|
-
# flow(:status) do
|
12
|
-
# state :pending do
|
13
|
-
# action :approve, to: :approved
|
14
|
-
# end
|
15
|
-
# end
|
16
|
-
#
|
17
|
-
# The above declares a flow for the `status` attribute. When in the `pending`
|
18
|
-
# state, the `approve` action will transition the `status` to `approved`.
|
19
|
-
#
|
20
|
-
# This creates a `status_approve` method which will change the state in memory.
|
21
|
-
#
|
22
|
-
# You will also have a instance method `flow` which will allow you to specify
|
23
|
-
# the action to take on the attribute.
|
24
|
-
#
|
25
|
-
# Example:
|
26
|
-
#
|
27
|
-
# test_object.status_approve
|
28
|
-
# # OR
|
29
|
-
# test_object.flow(:approve, :status)
|
30
|
-
#
|
31
|
-
# You can also provide a block to receive arguments
|
32
|
-
#
|
33
|
-
# Example:
|
34
|
-
#
|
35
|
-
# flow(:status) do
|
36
|
-
# state :pending do
|
37
|
-
# action :approve, to: :approved do |*args, **kwargs|
|
38
|
-
# @args_received = {args: args, kwargs: kwargs}
|
39
|
-
# end
|
40
|
-
# action_allowed(:approve) { true } # Optional. Check some value on self
|
41
|
-
# end
|
42
|
-
# end
|
43
|
-
#
|
44
|
-
# The block will be evalutaed on the instance of the class declaring the flow.
|
45
|
-
# So `self` inside that action block will be the instance of the class.
|
46
|
-
#
|
47
|
-
# Example:
|
48
|
-
#
|
49
|
-
# test_object.status_approve("arg1", "arg2", key: "value")
|
50
|
-
# # @args_received will be {args: ["arg1", "arg2"], kwargs: {key: "value"}}
|
51
|
-
#
|
52
|
-
# If the action is not allowed, the transition will not be executed.
|
53
|
-
#
|
54
|
-
# Example:
|
55
|
-
#
|
56
|
-
# flow(:status) do
|
57
|
-
# state :pending do
|
58
|
-
# action :approve, to: :approved do
|
59
|
-
# @args_received = {args: args, kwargs: kwargs}
|
60
|
-
# end
|
61
|
-
# action_allowed(:approve) { false }
|
62
|
-
# end
|
63
|
-
# end
|
64
|
-
#
|
65
|
-
# test_object.status_approve
|
66
|
-
# # Will not transition because the action is not allowed
|
67
|
-
#
|
68
|
-
# You may also specify the `allow_if` option to check a condition before
|
69
|
-
# the action is allowed. The callable will be evaluated on the instance of
|
70
|
-
# the class declaring the flow. So `self` inside that block will be the
|
71
|
-
# instance of the class.
|
72
|
-
#
|
73
|
-
# Example:
|
74
|
-
#
|
75
|
-
# flow(:status) do
|
76
|
-
# state :pending do
|
77
|
-
# action :approve, to: :approved, allow_if: -> { true }
|
78
|
-
# end
|
79
|
-
# end
|
80
|
-
#
|
81
|
-
# test_object.status_approve
|
82
|
-
# # Will transition to :approved if the condition is true
|
83
|
-
#
|
84
|
-
# If you declare states separately, for example in an enum, you can use the
|
85
|
-
# `action` method to declare the action on the attribute.
|
86
|
-
#
|
87
|
-
# Example:
|
88
|
-
#
|
89
|
-
# enum :status, {pending: 0, approved: 1, rejected: 2}
|
90
|
-
# flow(:status) do
|
91
|
-
# action :approve, to: :approved, from: :pending
|
92
|
-
# action :reject, to: :rejected, from: :approved do |rejected_at|
|
93
|
-
# self.rejected_at = rejected_at
|
94
|
-
# end
|
95
|
-
# end
|
96
|
-
#
|
97
|
-
# test_object.status_approve
|
98
|
-
# # Will transition to :approved
|
99
|
-
# test_object.status_reject
|
100
|
-
# # Will transition to :rejected and set the rejected_at attribute
|
101
|
-
#
|
102
|
-
# By default, if there is no transition for the current state, the flow will
|
103
|
-
# raise an error. You can specify a no_action block to handle this case.
|
104
|
-
#
|
105
|
-
# Example:
|
106
|
-
#
|
107
|
-
# flow(:status) do
|
108
|
-
# no_action { |attribute_name, action| raise "Nope!" }
|
109
|
-
# end
|
110
|
-
#
|
111
|
-
# test_object.status_approve
|
112
|
-
# # Will raise an error
|
113
|
-
#
|
114
|
-
# You can also provide a custom action for other behavior for a set of states and
|
115
|
-
# use the `to` option as a callable to set the attribute.
|
116
|
-
#
|
117
|
-
# Example:
|
118
|
-
#
|
119
|
-
# flow(:status) do
|
120
|
-
# action :unknown, to: -> { status }, from: [:enforcing, :monitoring, :ignoring] do |signal|
|
121
|
-
# raise UnhandledSignalError, signal
|
122
|
-
# end
|
123
|
-
# end
|
124
|
-
#
|
125
|
-
# test_object.flow(:unknown, :status, "signal")
|
126
|
-
# # Will raise an UnhandledSignalError
|
127
|
-
#
|
128
|
-
def flow(attribute_name, model: to_s, &block)
|
129
|
-
@flows ||= {}
|
130
|
-
model_key = Circulator::Diverter.model_key(model)
|
131
|
-
@flows[model_key] ||= {}
|
132
|
-
@flows[model_key][attribute_name] = Circulator::Flow.new(model, attribute_name, &block)
|
133
|
-
|
134
|
-
flow_module = ancestors.find { |ancestor|
|
135
|
-
ancestor.name.to_s =~ /FlowMethods/
|
136
|
-
} || Module.new.tap do |mod|
|
137
|
-
include mod
|
138
|
-
|
139
|
-
const_set(:FlowMethods, mod)
|
140
|
-
end
|
141
|
-
|
142
|
-
object = if model == to_s
|
143
|
-
nil
|
144
|
-
else
|
145
|
-
Circulator::Diverter.methodize_name(model)
|
146
|
-
end
|
147
|
-
|
148
|
-
@flows.dig(model_key, attribute_name).transition_map.each do |action, transitions|
|
149
|
-
define_flow_method(attribute_name:, action:, transitions:, object:, owner: flow_module)
|
150
|
-
end
|
151
|
-
end
|
152
|
-
alias_method :circulator, :flow
|
153
|
-
|
154
|
-
def define_flow_method(attribute_name:, action:, transitions:, object:, owner:)
|
155
|
-
object_attribute_method = [object, attribute_name, action].compact.join("_")
|
156
|
-
raise ArgumentError, "Method already defined: #{object_attribute_method}" if owner.method_defined?(object_attribute_method)
|
157
|
-
|
158
|
-
owner.define_method(object_attribute_method) do |*args, flow_target: self, **kwargs, &block|
|
159
|
-
current_value = flow_target.send(attribute_name)
|
160
|
-
|
161
|
-
transition = if current_value.respond_to?(:to_sym)
|
162
|
-
transitions[current_value.to_sym]
|
163
|
-
else
|
164
|
-
transitions[current_value]
|
165
|
-
end
|
166
|
-
|
167
|
-
unless transition
|
168
|
-
flow_target.instance_exec(attribute_name, action, &flows.dig(Circulator::Diverter.model_key(flow_target), attribute_name).no_action)
|
169
|
-
return
|
170
|
-
end
|
171
|
-
|
172
|
-
if transition[:allow_if]
|
173
|
-
return unless flow_target.instance_exec(*args, **kwargs, &transition[:allow_if])
|
174
|
-
end
|
175
|
-
|
176
|
-
if transition[:block]
|
177
|
-
flow_target.instance_exec(*args, **kwargs, &transition[:block])
|
178
|
-
end
|
179
|
-
|
180
|
-
if transition[:to].respond_to?(:call)
|
181
|
-
flow_target.send("#{attribute_name}=", flow_target.instance_exec(*args, **kwargs, &transition[:to]))
|
182
|
-
else
|
183
|
-
flow_target.send("#{attribute_name}=", transition[:to])
|
184
|
-
end.tap do
|
185
|
-
if block
|
186
|
-
flow_target.instance_exec(*args, **kwargs, &block)
|
187
|
-
end
|
188
|
-
end
|
189
|
-
end
|
190
|
-
end
|
191
|
-
|
192
|
-
module_function def model_key(object)
|
193
|
-
if object.is_a?(String)
|
194
|
-
if object.start_with?("#<Class:")
|
195
|
-
"anonymous_#{object.split("0x")[1]}".sub(">", "")
|
196
|
-
else
|
197
|
-
object
|
198
|
-
end
|
199
|
-
else
|
200
|
-
model_key(object.class.name || object.class.to_s)
|
201
|
-
end
|
202
|
-
end
|
203
|
-
|
204
|
-
module_function def methodize_name(name)
|
205
|
-
name.split("::").map { |part| part.gsub(/([a-z])([A-Z])/, '\1_\2') }.join("_").downcase
|
206
|
-
end
|
207
|
-
|
208
|
-
def self.extended(base)
|
209
|
-
base.include(InstanceMethods)
|
210
|
-
base.singleton_class.attr_reader :flows
|
211
|
-
end
|
212
|
-
|
213
|
-
module InstanceMethods
|
214
|
-
# Use this method to call an action on the attribute.
|
215
|
-
#
|
216
|
-
# Example:
|
217
|
-
#
|
218
|
-
# test_object.flow(:approve, :status)
|
219
|
-
# test_object.flow(:approve, :status, "arg1", "arg2", key: "value")
|
220
|
-
def flow(action, attribute, *args, flow_target: self, **kwargs, &block)
|
221
|
-
target_name = if flow_target != self
|
222
|
-
Circulator::Diverter.methodize_name(Circulator::Diverter.model_key(flow_target))
|
223
|
-
end
|
224
|
-
external_attribute_name = [target_name, attribute].compact.join("_")
|
225
|
-
method_name = "#{external_attribute_name}_#{action}"
|
226
|
-
if respond_to?(method_name)
|
227
|
-
send(method_name, *args, flow_target:, **kwargs, &block)
|
228
|
-
elsif flow_target.respond_to?(method_name)
|
229
|
-
flow_target.send(method_name, *args, **kwargs, &block)
|
230
|
-
else
|
231
|
-
raise "Invalid action for the current state of #{attribute} (#{flow_target.send(attribute).inspect}): #{action}"
|
232
|
-
end
|
233
|
-
end
|
234
|
-
|
235
|
-
private
|
236
|
-
|
237
|
-
def flows
|
238
|
-
self.class.flows
|
239
|
-
end
|
240
|
-
end
|
241
|
-
end
|
242
|
-
end
|