flow_core 0.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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +255 -0
  4. data/Rakefile +29 -0
  5. data/app/models/flow_core/application_record.rb +7 -0
  6. data/app/models/flow_core/arc.rb +43 -0
  7. data/app/models/flow_core/arc_guard.rb +18 -0
  8. data/app/models/flow_core/end_place.rb +17 -0
  9. data/app/models/flow_core/instance.rb +115 -0
  10. data/app/models/flow_core/place.rb +63 -0
  11. data/app/models/flow_core/start_place.rb +17 -0
  12. data/app/models/flow_core/task.rb +197 -0
  13. data/app/models/flow_core/token.rb +105 -0
  14. data/app/models/flow_core/transition.rb +143 -0
  15. data/app/models/flow_core/transition_callback.rb +24 -0
  16. data/app/models/flow_core/transition_trigger.rb +24 -0
  17. data/app/models/flow_core/workflow.rb +131 -0
  18. data/config/routes.rb +4 -0
  19. data/db/migrate/20200130200532_create_flow_core_tables.rb +151 -0
  20. data/lib/flow_core.rb +25 -0
  21. data/lib/flow_core/arc_guardable.rb +15 -0
  22. data/lib/flow_core/definition.rb +17 -0
  23. data/lib/flow_core/definition/callback.rb +33 -0
  24. data/lib/flow_core/definition/guard.rb +33 -0
  25. data/lib/flow_core/definition/net.rb +111 -0
  26. data/lib/flow_core/definition/place.rb +33 -0
  27. data/lib/flow_core/definition/transition.rb +107 -0
  28. data/lib/flow_core/definition/trigger.rb +33 -0
  29. data/lib/flow_core/engine.rb +6 -0
  30. data/lib/flow_core/errors.rb +14 -0
  31. data/lib/flow_core/locale/en.yml +26 -0
  32. data/lib/flow_core/task_executable.rb +34 -0
  33. data/lib/flow_core/transition_callbackable.rb +33 -0
  34. data/lib/flow_core/transition_triggerable.rb +27 -0
  35. data/lib/flow_core/version.rb +5 -0
  36. data/lib/flow_core/violations.rb +253 -0
  37. data/lib/tasks/flow_core_tasks.rake +6 -0
  38. metadata +123 -0
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowCore::Definition
4
+ class Net
5
+ attr_reader :attributes, :places, :transitions, :start_tag, :end_tag
6
+
7
+ def initialize(attributes = {})
8
+ @attributes = attributes.with_indifferent_access.except(FlowCore::Workflow::FORBIDDEN_ATTRIBUTES)
9
+ @places = []
10
+ @transitions = []
11
+ @start_tag = nil
12
+ @end_tag = nil
13
+
14
+ yield(self)
15
+ end
16
+
17
+ def add_place(tag_or_place, attributes = {})
18
+ entity =
19
+ if tag_or_place.is_a? FlowCore::Definition::Place
20
+ tag_or_place
21
+ else
22
+ attributes[:name] ||= tag_or_place.to_s
23
+ FlowCore::Definition::Place.new(tag_or_place, attributes)
24
+ end
25
+
26
+ @places << entity unless @places.include?(entity)
27
+ entity
28
+ end
29
+
30
+ def start_place(tag, attributes = {})
31
+ raise "`start_place` can only call once" if @start_tag
32
+
33
+ place = FlowCore::Definition::Place.new(tag, attributes.merge(type: FlowCore::StartPlace.to_s))
34
+ @places << place
35
+ @start_tag = place.tag
36
+ place
37
+ end
38
+
39
+ def end_place(tag, attributes = {})
40
+ raise "`end_place` can only call once" if @end_tag
41
+
42
+ place = FlowCore::Definition::Place.new(tag, attributes.merge(type: FlowCore::EndPlace.to_s))
43
+ @places << place
44
+ @end_tag = place.tag
45
+ place
46
+ end
47
+
48
+ def transition(tag, options = {}, &block)
49
+ raise TypeError unless tag.is_a? Symbol
50
+ raise ArgumentError if @transitions.include? tag
51
+
52
+ @transitions << FlowCore::Definition::Transition.new(self, tag, options, &block)
53
+ end
54
+
55
+ def compile
56
+ {
57
+ attributes: @attributes,
58
+ start_tag: @start_tag,
59
+ end_tag: @end_tag,
60
+ places: @places.map(&:compile),
61
+ transitions: @transitions.map(&:compile)
62
+ }
63
+ end
64
+
65
+ def deploy!
66
+ # TODO: Simple validation
67
+
68
+ workflow = nil
69
+ FlowCore::ApplicationRecord.transaction do
70
+ workflow = FlowCore::Workflow.create! attributes
71
+
72
+ # Places
73
+ place_records = {}
74
+ places.each do |pd|
75
+ place_records[pd.tag] =
76
+ workflow.places.create! pd.attributes
77
+ end
78
+
79
+ # Transitions
80
+ transition_records = {}
81
+ transitions.each do |td|
82
+ transition_records[td.tag] =
83
+ workflow.transitions.create! td.attributes
84
+
85
+ if td.trigger
86
+ transition_records[td.tag].create_trigger! td.trigger.compile
87
+ end
88
+
89
+ td.callbacks.each do |cb|
90
+ transition_records[td.tag].callbacks.create! cb.compile
91
+ end
92
+
93
+ td.input_tags.each do |place_tag|
94
+ workflow.arcs.in.create! transition: transition_records[td.tag],
95
+ place: place_records[place_tag]
96
+ end
97
+
98
+ td.output_tags.each do |output|
99
+ arc = workflow.arcs.out.create! transition: transition_records[td.tag],
100
+ place: place_records[output[:tag]]
101
+ if output[:guard]
102
+ arc.guards.create! output[:guard].compile
103
+ end
104
+ end
105
+ end
106
+ end
107
+ workflow&.verify!
108
+ workflow
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowCore::Definition
4
+ class Place
5
+ attr_reader :tag, :attributes
6
+
7
+ def initialize(tag, attributes = {})
8
+ raise TypeError unless tag.is_a? Symbol
9
+
10
+ @tag = tag
11
+ @attributes = attributes.with_indifferent_access.except(FlowCore::Place::FORBIDDEN_ATTRIBUTES)
12
+ @attributes[:name] ||= tag.to_s
13
+ @attributes[:tag] ||= tag.to_s
14
+ end
15
+
16
+ def compile
17
+ {
18
+ tag: @tag,
19
+ attributes: @attributes
20
+ }
21
+ end
22
+
23
+ def eql?(other)
24
+ if other.is_a? FlowCore::Definition::Place
25
+ @tag == other.tag
26
+ else
27
+ false
28
+ end
29
+ end
30
+
31
+ alias == eql?
32
+ end
33
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowCore::Definition
4
+ class Transition
5
+ attr_reader :net, :tag, :attributes, :trigger, :callbacks, :input_tags, :output_tags
6
+ private :net
7
+
8
+ def initialize(net, tag, attributes = {}, &block)
9
+ raise TypeError unless net.is_a? FlowCore::Definition::Net
10
+ raise TypeError unless tag.is_a? Symbol
11
+
12
+ @net = net
13
+ @tag = tag
14
+ @input_tags = []
15
+ @output_tags = []
16
+ @callbacks = []
17
+
18
+ input = attributes.delete(:input)
19
+ output = attributes.delete(:output)
20
+ raise ArgumentError, "Require `input`" unless input
21
+
22
+ self.input input
23
+ self.output output if output
24
+
25
+ trigger = attributes.delete :with_trigger
26
+ @trigger =
27
+ if trigger
28
+ FlowCore::Definition::Trigger.new trigger
29
+ end
30
+
31
+ callbacks = []
32
+ callbacks.concat Array.wrap(attributes.delete(:with_callbacks))
33
+ callbacks.concat Array.wrap(attributes.delete(:with_callback))
34
+ @callbacks = callbacks.map { |cb| FlowCore::Definition::Callback.new cb }
35
+
36
+ @attributes = attributes.with_indifferent_access.except(FlowCore::Transition::FORBIDDEN_ATTRIBUTES)
37
+ @attributes[:name] ||= tag.to_s
38
+ @attributes[:tag] ||= tag.to_s
39
+
40
+ block&.call(self)
41
+ end
42
+
43
+ def with_trigger(type, attributes = {})
44
+ @trigger = FlowCore::Definition::Trigger.new attributes.merge(type: type)
45
+ end
46
+
47
+ def with_callback(type, attributes = {})
48
+ @callbacks << FlowCore::Definition::Callback.new(attributes.merge(type: type))
49
+ end
50
+
51
+ def with_callbacks(*callbacks)
52
+ callbacks.each do |cb|
53
+ with_callback(*cb)
54
+ end
55
+ end
56
+
57
+ def input(tag)
58
+ places = Array.wrap(tag)
59
+ places.each do |place|
60
+ case place
61
+ when Symbol
62
+ net.add_place(place)
63
+ tag = place
64
+ when Array # Expect `[:p1, {name: "Place 1"}]`
65
+ net.add_place(*place)
66
+ tag = place.first
67
+ else
68
+ raise TypeError, "Unknown pattern - #{place}"
69
+ end
70
+
71
+ @input_tags << tag
72
+ end
73
+ end
74
+
75
+ def output(tag, attributes = {})
76
+ guard = attributes.delete :with_guard
77
+ guard = FlowCore::Definition::Guard.new(guard) if guard&.is_a?(Hash)
78
+
79
+ places = Array.wrap(tag)
80
+ places.each do |place|
81
+ case place
82
+ when Symbol
83
+ net.add_place(place)
84
+ tag = place
85
+ when Array # Expect `[:p1, {name: "Place 1"}]`
86
+ net.add_place(*place)
87
+ tag = place.first
88
+ else
89
+ raise TypeError, "Unknown pattern - #{place}"
90
+ end
91
+
92
+ @output_tags << { tag: tag, guard: guard }
93
+ end
94
+ end
95
+
96
+ def compile
97
+ {
98
+ tag: @tag,
99
+ attributes: @attributes,
100
+ trigger: @trigger&.compile,
101
+ callbacks: @callbacks.map(&:compile),
102
+ input_tags: @input_tags,
103
+ output_tags: @output_tags.map { |output| { tag: output[:tag], guard: output[:guard]&.compile } }
104
+ }
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowCore::Definition
4
+ class Trigger
5
+ def initialize(attributes)
6
+ constant_or_klass = attributes.is_a?(Hash) ? attributes.delete(:type) : attributes
7
+ @klass =
8
+ if constant_or_klass.is_a? String
9
+ constant_or_klass.safe_constantize
10
+ else
11
+ constant_or_klass
12
+ end
13
+ unless @klass && @klass < FlowCore::TransitionTrigger
14
+ raise TypeError, "First argument expect `FlowCore::TransitionTrigger` subclass or its constant name - #{constant_or_klass}"
15
+ end
16
+
17
+ @configuration = attributes.is_a?(Hash) ? attributes : nil
18
+ end
19
+
20
+ def compile
21
+ if @configuration&.any?
22
+ {
23
+ type: @klass.to_s,
24
+ configuration: @configuration
25
+ }
26
+ else
27
+ {
28
+ type: @klass.to_s
29
+ }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowCore
4
+ class Engine < ::Rails::Engine
5
+ end
6
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowCore
4
+ # Generic base class for all Workflow Core exceptions.
5
+ class Error < StandardError; end
6
+
7
+ class UnverifiedWorkflow < Error; end
8
+
9
+ class InvalidTransition < Error; end
10
+
11
+ class NoNewTokenCreated < Error; end
12
+
13
+ class ForbiddenOperation < Error; end
14
+ end
@@ -0,0 +1,26 @@
1
+ en:
2
+ activerecord:
3
+ models:
4
+ flow_core/workflow: Workflow
5
+ flow_core/place: Place
6
+ flow_core/start_place: Start Place
7
+ flow_core/end_place: End Place
8
+ flow_core/transition: Workflow
9
+ flow_core/transition_trigger: Transition Trigger
10
+ flow_core/transition_callback: Trasition Callback
11
+ flow_core/arc: Arc
12
+ flow_core/arc_guard: Arc Guard
13
+ flow_core/instance: Instance
14
+ flow_core/token: Token
15
+ flow_core/task: Task
16
+ flow_core:
17
+ verify_status:
18
+ unverify: Unverify
19
+ verified: Verified
20
+ invalid: Invalid
21
+ violations:
22
+ format: "%{model} %{name} %{message}"
23
+ unreachable: "Unreachable from start place"
24
+ impassable: "Impassable to end place"
25
+ trigger_unconfigure: "Trigger not configured yet"
26
+ callback_unconfigure: "Callback not configured yet"
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowCore
4
+ module TaskExecutable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ has_one :task, as: :executable, required: true, class_name: "FlowCore::Task"
9
+ has_one :instance, through: :task, class_name: "FlowCore::Instance"
10
+ has_one :transition, -> { readonly }, through: :task, class_name: "FlowCore::Transition"
11
+
12
+ after_save :notify_workflow_task_finished!, if: :implicit_notify_workflow_task_finished
13
+ end
14
+
15
+ # For automatic task
16
+ def run!
17
+ raise NotImplementedError
18
+ end
19
+
20
+ def finished?
21
+ raise NotImplementedError
22
+ end
23
+
24
+ def implicit_notify_workflow_task_finished
25
+ true
26
+ end
27
+
28
+ protected
29
+
30
+ def notify_workflow_task_finished!
31
+ task.finish! if finished?
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowCore
4
+ module TransitionCallbackable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ private :_call
9
+ end
10
+
11
+ # Should in
12
+ # :created, :enabled, :finished, :terminated,
13
+ # :errored, :rescued :suspended, :resumed
14
+ def on
15
+ []
16
+ end
17
+
18
+ def callable?(_task)
19
+ true
20
+ end
21
+
22
+ def _call(_task)
23
+ raise NotImplementedError
24
+ end
25
+
26
+ def call(task)
27
+ return unless on.include? task.stage.to_sym
28
+ return unless callable? task
29
+
30
+ _call task
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowCore
4
+ module TransitionTriggerable
5
+ extend ActiveSupport::Concern
6
+
7
+ def on_verify(_transition, _violations); end
8
+
9
+ def on_task_created(_task); end
10
+
11
+ def on_task_enabled(_task); end
12
+
13
+ def on_task_finished(_task); end
14
+
15
+ def on_task_terminated(_task); end
16
+
17
+ def on_task_suspended(_task); end
18
+
19
+ def on_task_resumed(_task); end
20
+
21
+ def on_task_errored(_task, error)
22
+ raise error
23
+ end
24
+
25
+ def on_task_rescued(_task); end
26
+ end
27
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowCore
4
+ VERSION = "0.0.1"
5
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/array/conversions"
4
+ require "active_support/core_ext/string/inflections"
5
+ require "active_support/core_ext/object/deep_dup"
6
+ require "active_support/core_ext/string/filters"
7
+
8
+ module FlowCore
9
+ class Violations
10
+ include Enumerable
11
+
12
+ MESSAGE_OPTIONS = %i[message].freeze
13
+
14
+ attr_reader :messages, :details, :records
15
+
16
+ def initialize
17
+ @messages = apply_default_array({})
18
+ @details = apply_default_array({})
19
+ @records = apply_default_array({})
20
+ end
21
+
22
+ def initialize_dup(other) # :nodoc:
23
+ @messages = other.messages.dup
24
+ @details = other.details.deep_dup
25
+ @records = other.records.deep_dup
26
+ super
27
+ end
28
+
29
+ def copy!(other) # :nodoc:
30
+ @messages = other.messages.dup
31
+ @details = other.details.dup
32
+ @records = other.records.dup
33
+ end
34
+
35
+ def merge!(other)
36
+ @messages.merge!(other.messages) { |_, ary1, ary2| ary1 + ary2 }
37
+ @details.merge!(other.details) { |_, ary1, ary2| ary1 + ary2 }
38
+ @records.merge!(other.records) { |_, ary1, ary2| ary1 + ary2 }
39
+ end
40
+
41
+ def slice!(*keys)
42
+ keys = keys.map(&:to_sym)
43
+ @details.slice!(*keys)
44
+ @messages.slice!(*keys)
45
+ @records.slice!(*keys)
46
+ end
47
+
48
+ def clear
49
+ messages.clear
50
+ details.clear
51
+ records.clear
52
+ end
53
+
54
+ def include?(record)
55
+ record = "#{record.class}/#{record.id}"
56
+ messages.key?(record) && messages[record].present?
57
+ end
58
+ alias has_key? include?
59
+ alias key? include?
60
+
61
+ def delete(record)
62
+ record = "#{record.class}/#{record.id}"
63
+ details.delete(record)
64
+ messages.delete(record)
65
+ records.delete(record)
66
+ end
67
+
68
+ def [](record)
69
+ record = "#{record.class}/#{record.id}"
70
+ messages[record]
71
+ end
72
+
73
+ def each
74
+ messages.each_key do |record_key|
75
+ model = records[record_key][:model]
76
+ id = records[record_key][:id]
77
+ name = records[record_key][:name]
78
+ messages[record_key].each { |error| yield record_key, model, id, name, error }
79
+ end
80
+ end
81
+
82
+ def size
83
+ values.flatten.size
84
+ end
85
+ alias count size
86
+
87
+ def values
88
+ messages.reject do |_key, value|
89
+ value.empty?
90
+ end.values
91
+ end
92
+
93
+ def keys
94
+ messages.reject do |_key, value|
95
+ value.empty?
96
+ end.keys
97
+ end
98
+
99
+ def empty?
100
+ size.zero?
101
+ end
102
+ alias blank? empty?
103
+
104
+ def to_xml(options = {})
105
+ to_a.to_xml({ root: "errors", skip_types: true }.merge!(options))
106
+ end
107
+
108
+ def as_json(options = nil)
109
+ to_hash(options && options[:full_messages])
110
+ end
111
+
112
+ def to_hash(full_messages = false)
113
+ if full_messages
114
+ messages.each_with_object({}) do |(record_key, array), messages|
115
+ messages[record_key] = array.map { |message| full_message(record_key, message) }
116
+ end
117
+ else
118
+ without_default_proc(messages)
119
+ end
120
+ end
121
+
122
+ def add(record, message = :invalid, options = {})
123
+ message = message.call if message.respond_to?(:call)
124
+ detail = normalize_detail(message, options)
125
+ message = normalize_message(record, message, options)
126
+ n_record = normalize_record(record)
127
+
128
+ record_key = "#{record.class}/#{record.id}"
129
+ details[record_key] << detail
130
+ messages[record_key] << message
131
+ records[record_key] = n_record
132
+
133
+ if exception = options[:strict]
134
+ exception = FlowCore::StrictViolationFailed if exception == true
135
+ raise exception, full_message(record_key, message)
136
+ end
137
+ end
138
+
139
+ def added?(record, message = :invalid, options = {})
140
+ message = message.call if message.respond_to?(:call)
141
+
142
+ record_key = "#{record.class}/#{record.id}"
143
+ if message.is_a? Symbol
144
+ details[record_key].include? normalize_detail(message, options)
145
+ else
146
+ self[record_key].include? message
147
+ end
148
+ end
149
+
150
+ def of_kind?(record, message = :invalid)
151
+ message = message.call if message.respond_to?(:call)
152
+
153
+ record_key = "#{record.class}/#{record.id}"
154
+ if message.is_a? Symbol
155
+ details[record_key].map { |e| e[:error] }.include? message
156
+ else
157
+ self[record_key].include? message
158
+ end
159
+ end
160
+
161
+ def full_messages
162
+ map do |record_key, _, _, _, message|
163
+ full_message(record_key, message)
164
+ end
165
+ end
166
+ alias to_a full_messages
167
+
168
+ def full_messages_for(record)
169
+ record_key = "#{record.class}/#{record.id}"
170
+ messages[record].map { |message| full_message(record_key, message) }
171
+ end
172
+
173
+ def full_message(record_key, message)
174
+ model = records[record_key][:model]
175
+ model_name = records[record_key][:model].model_name.human
176
+ id = records[record_key][:id]
177
+ name = records[record_key][:name]
178
+
179
+ defaults = [:"flow_core.violations.format"]
180
+ defaults << "%{name} %{message}"
181
+
182
+ I18n.t(defaults.shift,
183
+ default: defaults, model: model, model_name: model_name, id: id, name: name, message: message)
184
+ end
185
+
186
+ def generate_message(record, type = :invalid, options = {})
187
+ type = options.delete(:message) if options[:message].is_a?(Symbol)
188
+
189
+ options = {
190
+ model: record.class,
191
+ model_name: record.model_name.human,
192
+ id: record.id,
193
+ name: record.name
194
+ }.merge!(options)
195
+
196
+ options[:default] = options.delete(:message) if options[:message]
197
+
198
+ I18n.translate("flow_core.violations.#{type}", options)
199
+ end
200
+
201
+ def marshal_dump # :nodoc:
202
+ [without_default_proc(@messages), without_default_proc(@details), without_default_proc(@records)]
203
+ end
204
+
205
+ def marshal_load(array) # :nodoc:
206
+ @messages, @details, @records = array
207
+ apply_default_array(@messages)
208
+ apply_default_array(@details)
209
+ apply_default_array(@records)
210
+ end
211
+
212
+ def init_with(coder) # :nodoc:
213
+ coder.map.each { |k, v| instance_variable_set(:"@#{k}", v) }
214
+ @details ||= {}
215
+ apply_default_array(@messages)
216
+ apply_default_array(@details)
217
+ apply_default_array(@records)
218
+ end
219
+
220
+ private
221
+
222
+ def normalize_message(record, message, options)
223
+ case message
224
+ when Symbol
225
+ generate_message(record, message, options)
226
+ else
227
+ message
228
+ end
229
+ end
230
+
231
+ def normalize_detail(message, options)
232
+ { error: message }.merge(options.except(*MESSAGE_OPTIONS))
233
+ end
234
+
235
+ def normalize_record(record)
236
+ { model: record.class, id: record.id, name: record.name }
237
+ end
238
+
239
+ def without_default_proc(hash)
240
+ hash.dup.tap do |new_h|
241
+ new_h.default_proc = nil
242
+ end
243
+ end
244
+
245
+ def apply_default_array(hash)
246
+ hash.default_proc = proc { |h, key| h[key] = [] }
247
+ hash
248
+ end
249
+ end
250
+
251
+ class StrictViolationFailed < StandardError
252
+ end
253
+ end