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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34d38ad852dfc0dea3071103c368fa9d77224c7a390b7fc10c6a39fde2e2b507
4
- data.tar.gz: c67068423ece22963cdf64391f0424251a8c23caebf08280a6bb977b1a18bd4b
3
+ metadata.gz: 42195ffd537b18be888f0bbab239d61d9ff19a2ff91c50adf0d6e51bea4119b0
4
+ data.tar.gz: ec684979c91e8a5bba4538e6a0c0546d1b23c2746d83b815cc9f43f5cb01fd83
5
5
  SHA512:
6
- metadata.gz: dc4264165cdb3fdf72ec246dcabc02c0bacff9d20f24a167984e6521517550cc131fd0711f289892c8263fea6ac6c07dcc1d39c0ed77165dbf74d2a3d044f331
7
- data.tar.gz: d85440b2e40cc2aa2ff1f7848f5b4dc63b94c2217ebdd7918488967489badb439edcb1c5f39c0b762634f1739ce38405ad19e841a7f4b84c2bb8671262418ec3
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
- ## [1.0.0] - 2025-09-11
8
+ ## [2.0.1] - 2025-10-01
9
9
 
10
- ###
10
+ ### Changed
11
11
 
12
- - Initial release.
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::Diverter
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::Diverter
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::Diverter
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::Diverter
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::Diverter
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 "minitest/test_task"
4
+ require "rake/testtask"
5
5
 
6
- Minitest::TestTask.create
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
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "diverter"
4
-
5
3
  module Circulator
6
4
  class Flow
7
5
  def initialize(klass, attribute_name, states = Set.new, &block)
@@ -70,4 +68,4 @@ module Circulator
70
68
  end
71
69
  end
72
70
  end
73
- end
71
+ end
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Circulator
4
- VERSION = "1.0.0"
4
+ VERSION = "2.0.1"
5
5
  end
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: 1.0.0
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/diverter.rb
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: []
@@ -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