igniter 0.2.0 → 0.3.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +224 -1
  4. data/docs/API_V2.md +296 -1
  5. data/docs/BACKLOG.md +166 -0
  6. data/docs/BRANCHES_V1.md +213 -0
  7. data/docs/COLLECTIONS_V1.md +303 -0
  8. data/docs/EXECUTION_MODEL_V2.md +79 -0
  9. data/docs/PATTERNS.md +222 -0
  10. data/docs/STORE_ADAPTERS.md +126 -0
  11. data/examples/README.md +127 -0
  12. data/examples/async_store.rb +47 -0
  13. data/examples/collection.rb +43 -0
  14. data/examples/collection_partial_failure.rb +50 -0
  15. data/examples/marketing_ergonomics.rb +57 -0
  16. data/examples/ringcentral_routing.rb +269 -0
  17. data/lib/igniter/compiler/compiled_graph.rb +90 -0
  18. data/lib/igniter/compiler/graph_compiler.rb +12 -2
  19. data/lib/igniter/compiler/type_resolver.rb +54 -0
  20. data/lib/igniter/compiler/validation_context.rb +61 -0
  21. data/lib/igniter/compiler/validation_pipeline.rb +30 -0
  22. data/lib/igniter/compiler/validator.rb +1 -187
  23. data/lib/igniter/compiler/validators/callable_validator.rb +107 -0
  24. data/lib/igniter/compiler/validators/dependencies_validator.rb +153 -0
  25. data/lib/igniter/compiler/validators/outputs_validator.rb +66 -0
  26. data/lib/igniter/compiler/validators/type_compatibility_validator.rb +84 -0
  27. data/lib/igniter/compiler/validators/uniqueness_validator.rb +60 -0
  28. data/lib/igniter/compiler.rb +8 -0
  29. data/lib/igniter/contract.rb +152 -4
  30. data/lib/igniter/diagnostics/auditing/report/console_formatter.rb +80 -0
  31. data/lib/igniter/diagnostics/auditing/report/markdown_formatter.rb +22 -0
  32. data/lib/igniter/diagnostics/introspection/formatters/mermaid_formatter.rb +58 -0
  33. data/lib/igniter/diagnostics/introspection/formatters/text_tree_formatter.rb +44 -0
  34. data/lib/igniter/diagnostics/report.rb +186 -11
  35. data/lib/igniter/dsl/contract_builder.rb +271 -5
  36. data/lib/igniter/dsl/schema_builder.rb +73 -0
  37. data/lib/igniter/dsl.rb +1 -0
  38. data/lib/igniter/errors.rb +11 -0
  39. data/lib/igniter/events/bus.rb +5 -0
  40. data/lib/igniter/events/event.rb +29 -0
  41. data/lib/igniter/executor.rb +74 -0
  42. data/lib/igniter/executor_registry.rb +44 -0
  43. data/lib/igniter/extensions/auditing/timeline.rb +4 -0
  44. data/lib/igniter/extensions/introspection/graph_formatter.rb +33 -3
  45. data/lib/igniter/extensions/introspection/plan_formatter.rb +55 -0
  46. data/lib/igniter/extensions/introspection/runtime_formatter.rb +18 -3
  47. data/lib/igniter/extensions/introspection.rb +1 -0
  48. data/lib/igniter/extensions/reactive/engine.rb +49 -2
  49. data/lib/igniter/extensions/reactive/reaction.rb +3 -2
  50. data/lib/igniter/model/branch_node.rb +46 -0
  51. data/lib/igniter/model/collection_node.rb +31 -0
  52. data/lib/igniter/model/composition_node.rb +2 -2
  53. data/lib/igniter/model/compute_node.rb +58 -2
  54. data/lib/igniter/model/input_node.rb +2 -2
  55. data/lib/igniter/model/output_node.rb +24 -4
  56. data/lib/igniter/model.rb +2 -0
  57. data/lib/igniter/runtime/cache.rb +64 -25
  58. data/lib/igniter/runtime/collection_result.rb +111 -0
  59. data/lib/igniter/runtime/deferred_result.rb +40 -0
  60. data/lib/igniter/runtime/execution.rb +261 -11
  61. data/lib/igniter/runtime/input_validator.rb +2 -24
  62. data/lib/igniter/runtime/invalidator.rb +1 -1
  63. data/lib/igniter/runtime/job_worker.rb +18 -0
  64. data/lib/igniter/runtime/node_state.rb +20 -0
  65. data/lib/igniter/runtime/planner.rb +126 -0
  66. data/lib/igniter/runtime/resolver.rb +310 -15
  67. data/lib/igniter/runtime/result.rb +14 -2
  68. data/lib/igniter/runtime/runner_factory.rb +20 -0
  69. data/lib/igniter/runtime/runners/inline_runner.rb +21 -0
  70. data/lib/igniter/runtime/runners/store_runner.rb +29 -0
  71. data/lib/igniter/runtime/runners/thread_pool_runner.rb +37 -0
  72. data/lib/igniter/runtime/stores/active_record_store.rb +41 -0
  73. data/lib/igniter/runtime/stores/file_store.rb +43 -0
  74. data/lib/igniter/runtime/stores/memory_store.rb +40 -0
  75. data/lib/igniter/runtime/stores/redis_store.rb +44 -0
  76. data/lib/igniter/runtime.rb +12 -0
  77. data/lib/igniter/type_system.rb +44 -0
  78. data/lib/igniter/version.rb +1 -1
  79. data/lib/igniter.rb +23 -0
  80. metadata +43 -2
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "igniter"
5
+
6
+ class TechnicianAvailabilityContract < Igniter::Contract
7
+ define do
8
+ input :technician_id
9
+ input :active
10
+
11
+ guard :active_technician, with: :active, eq: true, message: "Technician inactive"
12
+
13
+ compute :summary, with: %i[technician_id active_technician] do |technician_id:, active_technician:|
14
+ active_technician
15
+ { id: technician_id, status: "available" }
16
+ end
17
+
18
+ output :summary
19
+ end
20
+ end
21
+
22
+ class TechnicianAvailabilityBatchContract < Igniter::Contract
23
+ define do
24
+ input :technician_inputs, type: :array
25
+
26
+ collection :technicians,
27
+ with: :technician_inputs,
28
+ each: TechnicianAvailabilityContract,
29
+ key: :technician_id,
30
+ mode: :collect
31
+
32
+ output :technicians
33
+ end
34
+ end
35
+
36
+ contract = TechnicianAvailabilityBatchContract.new(
37
+ technician_inputs: [
38
+ { technician_id: 1, active: true },
39
+ { technician_id: 2, active: false },
40
+ { technician_id: 3, active: true }
41
+ ]
42
+ )
43
+
44
+ result = contract.result.technicians
45
+
46
+ puts "summary=#{result.summary.inspect}"
47
+ puts "items_summary=#{result.items_summary.inspect}"
48
+ puts "failed_items=#{result.failed_items.inspect}"
49
+ puts "---"
50
+ puts contract.diagnostics_text
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "igniter"
5
+
6
+ OUTBOX = []
7
+
8
+ class MarketingQuoteContract < Igniter::Contract
9
+ define do
10
+ input :service, type: :string
11
+ input :zip_code, type: :string
12
+
13
+ const :vendor_id, "eLocal"
14
+
15
+ scope :routing do
16
+ map :trade_name, from: :service do |service:|
17
+ %w[heating cooling ventilation air_conditioning].include?(service.downcase) ? "HVAC" : service
18
+ end
19
+ end
20
+
21
+ scope :pricing do
22
+ lookup :trade, with: :trade_name do |trade_name:|
23
+ { name: trade_name, base_bid: trade_name == "HVAC" ? 45.0 : 25.0 }
24
+ end
25
+ end
26
+
27
+ namespace :validation do
28
+ guard :zip_supported, with: :zip_code, in: %w[60601 10001], message: "Unsupported zip"
29
+ end
30
+
31
+ compute :quote, with: %i[vendor_id trade zip_supported zip_code] do |vendor_id:, trade:, zip_supported:, zip_code:|
32
+ zip_supported
33
+ {
34
+ vendor_id: vendor_id,
35
+ trade: trade[:name],
36
+ zip_code: zip_code,
37
+ bid: trade[:base_bid]
38
+ }
39
+ end
40
+
41
+ expose :quote, as: :response
42
+ end
43
+
44
+ on_success :response do |value:, **|
45
+ OUTBOX << {
46
+ vendor_id: value[:vendor_id],
47
+ zip_code: value[:zip_code]
48
+ }
49
+ end
50
+ end
51
+
52
+ contract = MarketingQuoteContract.new(service: "heating", zip_code: "60601")
53
+
54
+ puts contract.explain_plan
55
+ puts "---"
56
+ puts "response=#{contract.result.response.inspect}"
57
+ puts "outbox=#{OUTBOX.inspect}"
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "igniter"
5
+ require "pp"
6
+
7
+ class InboundCallContract < Igniter::Contract
8
+ define do
9
+ input :session_id, type: :string
10
+ input :direction, type: :string
11
+ input :from, type: :string
12
+ input :to, type: :string
13
+ input :start_time, type: :string
14
+
15
+ compute :summary, with: %i[session_id from to start_time] do |session_id:, from:, to:, start_time:|
16
+ {
17
+ session_id: session_id,
18
+ direction: "Inbound",
19
+ customer_phone: from,
20
+ lookup_phone: from,
21
+ dialed_phone: to,
22
+ started_at: start_time
23
+ }
24
+ end
25
+
26
+ output :summary
27
+ end
28
+ end
29
+
30
+ class OutboundCallContract < Igniter::Contract
31
+ define do
32
+ input :session_id, type: :string
33
+ input :direction, type: :string
34
+ input :from, type: :string
35
+ input :to, type: :string
36
+ input :start_time, type: :string
37
+
38
+ compute :summary, with: %i[session_id from to start_time] do |session_id:, from:, to:, start_time:|
39
+ {
40
+ session_id: session_id,
41
+ direction: "Outbound",
42
+ customer_phone: to,
43
+ lookup_phone: to,
44
+ operator_phone: from,
45
+ started_at: start_time
46
+ }
47
+ end
48
+
49
+ output :summary
50
+ end
51
+ end
52
+
53
+ class UnknownDirectionContract < Igniter::Contract
54
+ define do
55
+ input :session_id, type: :string
56
+ input :direction, type: :string
57
+ input :from, type: :string
58
+ input :to, type: :string
59
+ input :start_time, type: :string
60
+
61
+ compute :summary, with: %i[session_id direction] do |session_id:, direction:|
62
+ {
63
+ session_id: session_id,
64
+ direction: direction,
65
+ ignored: true
66
+ }
67
+ end
68
+
69
+ output :summary
70
+ end
71
+ end
72
+
73
+ class CallEventContract < Igniter::Contract
74
+ define do
75
+ input :session_id, type: :string
76
+ input :direction, type: :string
77
+ input :from, type: :string
78
+ input :to, type: :string
79
+ input :start_time, type: :string
80
+
81
+ branch :call_context, with: :direction, inputs: {
82
+ session_id: :session_id,
83
+ direction: :direction,
84
+ from: :from,
85
+ to: :to,
86
+ start_time: :start_time
87
+ } do
88
+ on "Inbound", contract: InboundCallContract
89
+ on "Outbound", contract: OutboundCallContract
90
+ default contract: UnknownDirectionContract
91
+ end
92
+
93
+ export :summary, from: :call_context
94
+ end
95
+ end
96
+
97
+ class CallConnectedContract < Igniter::Contract
98
+ define do
99
+ input :extension_id, type: :integer
100
+ input :active_calls, type: :array
101
+ input :telephony_status, type: :string
102
+
103
+ guard :has_calls, with: :active_calls, message: "No active calls" do |active_calls:|
104
+ active_calls.any?
105
+ end
106
+
107
+ collection :calls,
108
+ with: :active_calls,
109
+ each: CallEventContract,
110
+ key: :session_id,
111
+ mode: :collect,
112
+ map_inputs: lambda { |item:|
113
+ {
114
+ session_id: item.fetch("telephonySessionId"),
115
+ direction: item.fetch("direction"),
116
+ from: item.fetch("from"),
117
+ to: item.fetch("to"),
118
+ start_time: item.fetch("startTime")
119
+ }
120
+ }
121
+
122
+ compute :call_summaries, with: :calls do |calls:|
123
+ calls.successes.values.map { |item| item.result.summary }
124
+ end
125
+
126
+ aggregate :routing_summary, with: %i[calls call_summaries extension_id telephony_status has_calls] do |calls:, call_summaries:, extension_id:, telephony_status:, has_calls:|
127
+ has_calls
128
+ {
129
+ extension_id: extension_id,
130
+ telephony_status: telephony_status,
131
+ total_calls: calls.summary[:total],
132
+ succeeded_calls: calls.summary[:succeeded],
133
+ failed_calls: calls.summary[:failed],
134
+ inbound_calls: call_summaries.count { |item| item[:direction] == "Inbound" },
135
+ outbound_calls: call_summaries.count { |item| item[:direction] == "Outbound" },
136
+ ignored_calls: call_summaries.count { |item| item[:ignored] }
137
+ }
138
+ end
139
+
140
+ output :calls
141
+ output :routing_summary
142
+ end
143
+ end
144
+
145
+ class NoCallContract < Igniter::Contract
146
+ define do
147
+ input :extension_id, type: :integer
148
+ input :telephony_status, type: :string
149
+ input :active_calls, type: :array
150
+
151
+ compute :routing_summary, with: %i[extension_id telephony_status] do |extension_id:, telephony_status:|
152
+ {
153
+ extension_id: extension_id,
154
+ telephony_status: telephony_status,
155
+ clear_operator: true
156
+ }
157
+ end
158
+
159
+ output :routing_summary
160
+ end
161
+ end
162
+
163
+ class RingingContract < Igniter::Contract
164
+ define do
165
+ input :extension_id, type: :integer
166
+ input :telephony_status, type: :string
167
+ input :active_calls, type: :array
168
+
169
+ compute :routing_summary, with: %i[extension_id telephony_status] do |extension_id:, telephony_status:|
170
+ {
171
+ extension_id: extension_id,
172
+ telephony_status: telephony_status,
173
+ ringing: true
174
+ }
175
+ end
176
+
177
+ output :routing_summary
178
+ end
179
+ end
180
+
181
+ class UnknownStatusContract < Igniter::Contract
182
+ define do
183
+ input :extension_id, type: :integer
184
+ input :telephony_status, type: :string
185
+ input :active_calls, type: :array
186
+
187
+ compute :routing_summary, with: %i[extension_id telephony_status] do |extension_id:, telephony_status:|
188
+ {
189
+ extension_id: extension_id,
190
+ telephony_status: telephony_status,
191
+ ignored_status: true
192
+ }
193
+ end
194
+
195
+ output :routing_summary
196
+ end
197
+ end
198
+
199
+ class RingcentralWebhookContract < Igniter::Contract
200
+ define do
201
+ input :payload
202
+
203
+ scope :parse do
204
+ project :body, from: :payload, key: :body, default: {}
205
+ project :telephony_status, from: :body, key: "telephonyStatus"
206
+ project :extension_id, from: :body, key: "extensionId"
207
+ project :active_calls, from: :body, key: "activeCalls", default: []
208
+ end
209
+
210
+ branch :status_route,
211
+ with: :telephony_status,
212
+ depends_on: %i[extension_id active_calls],
213
+ map_inputs: lambda { |selector:, extension_id:, active_calls:|
214
+ {
215
+ extension_id: extension_id,
216
+ telephony_status: selector,
217
+ active_calls: active_calls
218
+ }
219
+ } do
220
+ on "CallConnected", contract: CallConnectedContract
221
+ on "NoCall", contract: NoCallContract
222
+ on "Ringing", contract: RingingContract
223
+ default contract: UnknownStatusContract
224
+ end
225
+
226
+ export :routing_summary, from: :status_route
227
+ output :status_route
228
+ end
229
+ end
230
+
231
+ payload = {
232
+ "body" => {
233
+ "extensionId" => 62872332031,
234
+ "telephonyStatus" => "CallConnected",
235
+ "activeCalls" => [
236
+ {
237
+ "from" => "+18009066027",
238
+ "to" => "+16199627154",
239
+ "direction" => "Outbound",
240
+ "startTime" => "2024-04-01T16:18:13.553Z",
241
+ "telephonySessionId" => "s-outbound-1"
242
+ },
243
+ {
244
+ "from" => "+13125550100",
245
+ "to" => "+18009066027",
246
+ "direction" => "Inbound",
247
+ "startTime" => "2024-04-01T16:19:10.000Z",
248
+ "telephonySessionId" => "s-inbound-2"
249
+ },
250
+ {
251
+ "from" => "+13125550199",
252
+ "to" => "+18009066027",
253
+ "direction" => "Parked",
254
+ "startTime" => "2024-04-01T16:20:00.000Z",
255
+ "telephonySessionId" => "s-unknown-3"
256
+ }
257
+ ]
258
+ }
259
+ }
260
+
261
+ contract = RingcentralWebhookContract.new(payload: payload)
262
+
263
+ puts contract.explain_plan
264
+ puts "---"
265
+ puts "routing_summary=#{contract.result.routing_summary.inspect}"
266
+ puts "status_route_branch=#{contract.events.find { |event| event.type == :branch_selected }.payload[:matched_case]}"
267
+ puts "child_collection_summary=#{contract.result.status_route.calls.summary.inspect}"
268
+ puts "child_diagnostics_status=#{contract.result.status_route.execution.diagnostics.to_h[:status]}"
269
+ pp contract.result.status_route.calls.as_json
@@ -38,6 +38,17 @@ module Igniter
38
38
  @outputs_by_name.fetch(name.to_sym)
39
39
  end
40
40
 
41
+ def output?(name)
42
+ @outputs_by_name.key?(name.to_sym)
43
+ end
44
+
45
+ def fetch_dependency(name)
46
+ return fetch_node(name) if node?(name)
47
+ return fetch_output(name) if output?(name)
48
+
49
+ raise KeyError, "Unknown dependency '#{name}'"
50
+ end
51
+
41
52
  def to_h
42
53
  {
43
54
  name: name,
@@ -53,6 +64,22 @@ module Igniter
53
64
  base[:contract] = node.contract_class.name
54
65
  base[:inputs] = node.input_mapping
55
66
  end
67
+ if node.kind == :branch
68
+ base[:selector] = node.selector_dependency
69
+ base[:depends_on] = node.context_dependencies if node.context_dependencies.any?
70
+ base[:cases] = node.cases.map { |entry| { match: entry[:match], contract: entry[:contract].name } }
71
+ base[:default_contract] = node.default_contract.name
72
+ base[:inputs] = node.input_mapping
73
+ base[:mapper] = node.input_mapper.to_s if node.input_mapper?
74
+ end
75
+ if node.kind == :collection
76
+ base[:with] = node.source_dependency
77
+ base[:depends_on] = node.context_dependencies if node.context_dependencies.any?
78
+ base[:each] = node.contract_class.name
79
+ base[:key] = node.key_name
80
+ base[:mode] = node.mode
81
+ base[:mapper] = node.input_mapper.to_s if node.input_mapper?
82
+ end
56
83
  base
57
84
  end,
58
85
  outputs: outputs.map do |output|
@@ -70,6 +97,69 @@ module Igniter
70
97
  Extensions::Introspection::GraphFormatter.to_text(self)
71
98
  end
72
99
 
100
+ def to_schema
101
+ {
102
+ name: name,
103
+ inputs: nodes.select { |node| node.kind == :input }.map do |node|
104
+ {
105
+ name: node.name,
106
+ type: node.type,
107
+ required: node.required?,
108
+ default: (node.default if node.default?),
109
+ metadata: node.metadata.reject { |key, _| key == :source_location || key == :type || key == :required || key == :default }
110
+ }.compact
111
+ end,
112
+ compositions: nodes.select { |node| node.kind == :composition }.map do |node|
113
+ {
114
+ name: node.name,
115
+ contract: node.contract_class,
116
+ inputs: node.input_mapping,
117
+ metadata: node.metadata.reject { |key, _| key == :source_location }
118
+ }
119
+ end,
120
+ branches: nodes.select { |node| node.kind == :branch }.map do |node|
121
+ {
122
+ name: node.name,
123
+ with: node.selector_dependency,
124
+ depends_on: node.context_dependencies,
125
+ inputs: node.input_mapping,
126
+ map_inputs: (node.input_mapper if node.input_mapper?),
127
+ cases: node.cases.map { |entry| { on: entry[:match], contract: entry[:contract] } },
128
+ default: node.default_contract,
129
+ metadata: node.metadata.reject { |key, _| key == :source_location }
130
+ }
131
+ end,
132
+ collections: nodes.select { |node| node.kind == :collection }.map do |node|
133
+ {
134
+ name: node.name,
135
+ with: node.source_dependency,
136
+ depends_on: node.context_dependencies,
137
+ each: node.contract_class,
138
+ key: node.key_name,
139
+ mode: node.mode,
140
+ map_inputs: (node.input_mapper if node.input_mapper?),
141
+ metadata: node.metadata.reject { |key, _| key == :source_location }
142
+ }
143
+ end,
144
+ computes: nodes.select { |node| node.kind == :compute }.map do |node|
145
+ {
146
+ name: node.name,
147
+ depends_on: node.dependencies,
148
+ executor: node.executor_key,
149
+ call: (node.callable unless node.executor_key),
150
+ metadata: node.metadata.reject { |key, _| %i[source_location executor_key].include?(key) }
151
+ }.compact
152
+ end,
153
+ outputs: outputs.map do |output|
154
+ {
155
+ name: output.name,
156
+ from: output.source,
157
+ metadata: output.metadata.reject { |key, _| %i[source_location type].include?(key) }
158
+ }.compact
159
+ end
160
+ }
161
+ end
162
+
73
163
  def to_mermaid
74
164
  Extensions::Introspection::GraphFormatter.to_mermaid(self)
75
165
  end
@@ -18,6 +18,7 @@ module Igniter
18
18
  def call
19
19
  validator = Validator.call(@graph)
20
20
  @nodes_by_name = validator.runtime_nodes_by_name
21
+ @outputs_by_name = validator.outputs.each_with_object({}) { |output, memo| memo[output.name] = output }
21
22
 
22
23
  CompiledGraph.new(
23
24
  name: @graph.name,
@@ -36,7 +37,9 @@ module Igniter
36
37
 
37
38
  def build_dependents
38
39
  runtime_nodes.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |node, memo|
39
- node.dependencies.each { |dependency_name| memo[dependency_name] << node.name }
40
+ node.dependencies.each do |dependency_name|
41
+ memo[dependency_source_name(dependency_name)] << node.name
42
+ end
40
43
  end
41
44
  end
42
45
 
@@ -46,10 +49,17 @@ module Igniter
46
49
 
47
50
  def tsort_each_child(node, &block)
48
51
  node.dependencies.each do |dependency_name|
49
- block.call(@nodes_by_name.fetch(dependency_name))
52
+ block.call(@nodes_by_name.fetch(dependency_source_name(dependency_name)))
50
53
  end
51
54
  end
52
55
 
56
+ def dependency_source_name(dependency_name)
57
+ output = @outputs_by_name[dependency_name.to_sym]
58
+ return output.source_root if output
59
+
60
+ dependency_name
61
+ end
62
+
53
63
  def tsort
54
64
  super
55
65
  rescue TSort::Cyclic => e
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Compiler
5
+ class TypeResolver
6
+ def self.call(graph, name)
7
+ new(graph).call(name)
8
+ end
9
+
10
+ def initialize(graph)
11
+ @graph = graph
12
+ end
13
+
14
+ def call(name)
15
+ if @graph.output?(name)
16
+ resolve_output(@graph.fetch_output(name))
17
+ else
18
+ resolve_node(@graph.fetch_node(name))
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def resolve_output(output)
25
+ return output.type if output.type
26
+
27
+ if output.composition_output?
28
+ composition = @graph.fetch_node(output.source_root)
29
+ child_graph = composition.contract_class.compiled_graph
30
+ self.class.call(child_graph, output.child_output_name)
31
+ else
32
+ resolve_node(@graph.fetch_node(output.source))
33
+ end
34
+ end
35
+
36
+ def resolve_node(node)
37
+ case node.kind
38
+ when :input
39
+ node.type
40
+ when :compute
41
+ node.type
42
+ when :composition
43
+ :result
44
+ when :branch
45
+ :result
46
+ when :collection
47
+ :array
48
+ else
49
+ nil
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Compiler
5
+ class ValidationContext
6
+ attr_reader :graph, :runtime_nodes_by_name, :outputs_by_name
7
+
8
+ def initialize(graph)
9
+ @graph = graph
10
+ @runtime_nodes_by_name = {}
11
+ @outputs_by_name = {}
12
+ end
13
+
14
+ def runtime_nodes
15
+ @runtime_nodes ||= graph.nodes.reject { |node| node.kind == :output }
16
+ end
17
+
18
+ def outputs
19
+ @outputs ||= graph.nodes.select { |node| node.kind == :output }
20
+ end
21
+
22
+ def build_indexes!
23
+ runtime_nodes.each { |node| @runtime_nodes_by_name[node.name] = node }
24
+ outputs.each { |output| @outputs_by_name[output.name] = output }
25
+ end
26
+
27
+ def dependency_resolvable?(dependency_name)
28
+ runtime_nodes_by_name.key?(dependency_name.to_sym) || outputs_by_name.key?(dependency_name.to_sym)
29
+ end
30
+
31
+ def node?(name)
32
+ runtime_nodes_by_name.key?(name.to_sym)
33
+ end
34
+
35
+ def output?(name)
36
+ outputs_by_name.key?(name.to_sym)
37
+ end
38
+
39
+ def fetch_node(name)
40
+ runtime_nodes_by_name.fetch(name.to_sym)
41
+ end
42
+
43
+ def fetch_output(name)
44
+ outputs_by_name.fetch(name.to_sym)
45
+ end
46
+
47
+ def validation_error(node, message)
48
+ ValidationError.new(
49
+ message,
50
+ context: {
51
+ graph: graph.name,
52
+ node_id: node.id,
53
+ node_name: node.name,
54
+ node_path: node.path,
55
+ source_location: node.source_location
56
+ }
57
+ )
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Compiler
5
+ class ValidationPipeline
6
+ DEFAULT_VALIDATORS = [
7
+ Validators::UniquenessValidator,
8
+ Validators::OutputsValidator,
9
+ Validators::DependenciesValidator,
10
+ Validators::TypeCompatibilityValidator,
11
+ Validators::CallableValidator
12
+ ].freeze
13
+
14
+ def self.call(context, validators: DEFAULT_VALIDATORS)
15
+ new(context, validators: validators).call
16
+ end
17
+
18
+ def initialize(context, validators:)
19
+ @context = context
20
+ @validators = validators
21
+ end
22
+
23
+ def call
24
+ @context.build_indexes!
25
+ @validators.each { |validator| validator.call(@context) }
26
+ @context
27
+ end
28
+ end
29
+ end
30
+ end