flow_core 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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