lazy_graph 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LazyGraph
4
+ class Builder
5
+ # This module defines the DSL for building Lazy Graph JSON schemas.
6
+ # Supported helpers
7
+ # * object :name, **opts, &blk
8
+ # * object_conditional :name, **opts, &blk
9
+ # ⌙ matches &blk
10
+ # * array :name, **opts, &blk
11
+ # ⌙ items &blk
12
+ # * <primitive> :name, **opts, &blk
13
+ # * date :name, **opts, &blk
14
+ # * decimal :name, **opts, &blk
15
+ # * timestamp :name, **opts, &blk
16
+ # * time :name, **opts, &blk
17
+ #
18
+ module DSL
19
+ def additional_properties(value)
20
+ schema[:additionalProperties] = value
21
+ self
22
+ end
23
+
24
+ def required(*keys)
25
+ (schema[:required] ||= []).concat(keys.map(&:to_s)).uniq!
26
+ self
27
+ end
28
+
29
+ def default(value)
30
+ schema[:default] = \
31
+ if value.is_a?(Hash)
32
+ HashUtils.deep_merge(schema.fetch(:default, {}), value)
33
+ elsif value.is_a?(Array)
34
+ schema.fetch(:default, []).concat(value).uniq!
35
+ else
36
+ value
37
+ end
38
+ self
39
+ end
40
+
41
+ def set_pattern_property(pattern, value)
42
+ pattern = pattern.to_s
43
+ properties = schema[:patternProperties] ||= {}
44
+ properties[pattern] = \
45
+ if properties.key?(pattern) && %i[object array].include?(properties[pattern][:type])
46
+ HashUtils.deep_merge(properties[pattern], value, key)
47
+ else
48
+ value
49
+ end
50
+ self
51
+ end
52
+
53
+ def set_property(key, value)
54
+ key = key.to_sym
55
+ properties = schema[:properties] ||= {}
56
+ properties[key] = \
57
+ if properties.key?(key) && %i[object array].include?(properties[key][:type])
58
+ HashUtils.deep_merge(properties[key], value, key)
59
+ else
60
+ value
61
+ end
62
+ self
63
+ end
64
+
65
+ def object_conditional(
66
+ name = nil, required: false, pattern_property: false, rule: nil,
67
+ default: nil, description: nil, **opts, &blk
68
+ )
69
+ new_object = {
70
+ type: :object,
71
+ properties: {},
72
+ additionalProperties: false,
73
+ **(!default.nil? ? { default: default } : {}),
74
+ **(description ? { description: description } : {}),
75
+ **opts
76
+ }
77
+ @prev_match_cases = @match_cases
78
+ @match_cases = []
79
+
80
+ yields(new_object, &blk)
81
+
82
+ object_names = @match_cases.map do |match_case|
83
+ rule = rule_from_when(match_case[:when_clause])
84
+ set_property(match_case[:name], { type: :object, rule: rule, **match_case[:schema] })
85
+ match_case[:name]
86
+ end
87
+
88
+ new_object[:rule] = rule_from_first_of(object_names)
89
+ @match_cases = @prev_match_cases
90
+ required(name) if required && default.nil? && rule.nil?
91
+ pattern_property ? set_pattern_property(name, new_object) : set_property(name, new_object)
92
+ end
93
+
94
+ def matches(name, invisible: true, **when_clause, &blk)
95
+ @match_cases << { name: name, when_clause: when_clause, schema: { invisible: invisible } }
96
+ yields(@match_cases.last[:schema], &blk)
97
+ end
98
+
99
+ def object(
100
+ name = nil, required: false, pattern_property: false, rule: nil,
101
+ default: nil, description: nil, **opts, &blk
102
+ )
103
+ rule ||= rule_from_when(opts.delete(:when)) if opts[:when]
104
+ rule ||= rule_from_first_of(opts.delete(:first_of)) if opts[:first_of]
105
+ new_object = {
106
+ type: :object,
107
+ properties: {},
108
+ additionalProperties: false,
109
+
110
+ **(!default.nil? ? { default: default } : {}),
111
+ **(description ? { description: description } : {}),
112
+ **(rule ? { rule: rule } : {}),
113
+ **opts
114
+ }
115
+ yields(new_object, &blk)
116
+ required(name) if required && default.nil? && rule.nil?
117
+ pattern_property ? set_pattern_property(name, new_object) : set_property(name, new_object)
118
+ end
119
+
120
+ def primitive(
121
+ name = nil, type, required: false, pattern_property: false,
122
+ default: nil, description: nil, enum: nil,
123
+ rule: nil, **additional_options, &blk
124
+ )
125
+ new_primitive = {
126
+ type: type,
127
+ **(enum ? { enum: enum } : {}),
128
+ **(!default.nil? ? { default: default } : {}),
129
+ **(description ? { description: description } : {}),
130
+ **(rule ? { rule: rule } : {}),
131
+ **additional_options
132
+ }
133
+ yields(new_primitive, &blk)
134
+ required(name) if required && default.nil? && rule.nil?
135
+ pattern_property ? set_pattern_property(name, new_primitive) : set_property(name, new_primitive)
136
+ end
137
+
138
+ def decimal(name = nil, required: false, pattern_property: false, default: nil, description: nil, rule: nil,
139
+ **opts, &blk)
140
+ # Define the decimal schema supporting multiple formats
141
+ new_decimal = {
142
+ anyOf: [
143
+ {
144
+ type: :string,
145
+ # Matches valid decimals with optional exponentials
146
+ pattern: '^-?(\\d+\\.\\d+|\\d+|\\d+e[+-]?\\d+|\\d+\\.\\d+e[+-]?\\d+)$'
147
+
148
+ },
149
+ # Allows both float and int
150
+ {
151
+ type: :number #
152
+ },
153
+ {
154
+ type: :integer
155
+ }
156
+ ],
157
+ type: :decimal,
158
+ **(!default.nil? ? { default: default } : {}),
159
+ **(description ? { description: description } : {}),
160
+ **(rule ? { rule: rule } : {}),
161
+ **opts
162
+ }
163
+ yields(new_decimal, &blk)
164
+ required(name) if required && default.nil? && rule.nil?
165
+ pattern_property ? set_pattern_property(name, new_decimal) : set_property(name, new_decimal)
166
+ end
167
+
168
+ def timestamp(name = nil, required: false, pattern_property: false, default: nil, description: nil, rule: nil,
169
+ **opts, &blk)
170
+ new_timestamp = {
171
+ anyOf: [
172
+ {
173
+ type: :string,
174
+ # Matches ISO 8601 timestamp without timezone
175
+ pattern: '^\d{4}-\d{2}-\d{2}(T\d{2}(:\d{2}(:\d{2}(\.\d{1,3})?)?)?(Z|[+-]\d{2}(:\d{2})?)?)?$'
176
+ },
177
+ {
178
+ type: :number # Allows numeric epoch timestamps
179
+ },
180
+ {
181
+ type: :integer # Allows both float and int
182
+ }
183
+ ],
184
+ type: :timestamp, # Custom extended type
185
+ **(!default.nil? ? { default: default } : {}),
186
+ **(description ? { description: description } : {}),
187
+ **(rule ? { rule: rule } : {}),
188
+ **opts
189
+ }
190
+ yields(new_timestamp, &blk)
191
+ required(name) if required && default.nil? && rule.nil?
192
+ pattern_property ? set_pattern_property(name, new_timestamp) : set_property(name, new_timestamp)
193
+ end
194
+
195
+ def time(name = nil, required: false, pattern_property: false, default: nil, description: nil, rule: nil,
196
+ **opts, &blk)
197
+ new_time = {
198
+ type: :time, # Custom extended type
199
+ # Matches HH:mm[:ss[.SSS]]
200
+ pattern: '^\\d{2}:\\d{2}(:\\d{2}(\\.\\d{1,3})?)?$',
201
+ **(!default.nil? ? { default: default } : {}),
202
+ **(description ? { description: description } : {}),
203
+ **(rule ? { rule: rule } : {}),
204
+ **opts
205
+ }
206
+ yields(new_time, &blk)
207
+ required(name) if required && default.nil? && rule.nil?
208
+ pattern_property ? set_pattern_property(name, new_time) : set_property(name, new_time)
209
+ end
210
+
211
+ def date(name = nil, required: false, pattern_property: false, default: nil, description: nil, rule: nil,
212
+ **opts, &blk)
213
+ new_date = {
214
+ anyOf: [
215
+ {
216
+ type: :string,
217
+ # Matches ISO 8601 date format
218
+ pattern: '^\\d{4}-\\d{2}-\\d{2}$'
219
+ }
220
+ ],
221
+ type: :date, # Custom extended type
222
+ **(!default.nil? ? { default: default } : {}),
223
+ **(description ? { description: description } : {}),
224
+ **(rule ? { rule: rule } : {}),
225
+ **opts
226
+ }
227
+ yields(new_date, &blk)
228
+ required(name) if required && default.nil? && rule.nil?
229
+ pattern_property ? set_pattern_property(name, new_date) : set_property(name, new_date)
230
+ end
231
+
232
+ %i[boolean string const integer number null].each do |type|
233
+ define_method(type) do |
234
+ name = nil, required: false, pattern_property: false,
235
+ default: nil, description: nil, enum: nil, rule: nil, **additional_options, &blk|
236
+ primitive(
237
+ name, type, required: required, pattern_property: pattern_property, enum: enum,
238
+ default: default, rule: rule, description: description,
239
+ **additional_options, &blk
240
+ )
241
+ end
242
+ end
243
+
244
+ def dependencies(dependencies)
245
+ schema[:dependencies] = HashUtils.deep_merge(schema[:dependencies] || {}, dependencies)
246
+ end
247
+
248
+ def one_of(one_of)
249
+ schema[:oneOf] = (schema[:one_of] || []).concat(one_of).uniq
250
+ end
251
+
252
+ def any_of(any_of)
253
+ schema[:anyOf] = (schema[:any_of] || []).concat(any_of).uniq
254
+ end
255
+
256
+ def array(name = nil, required: false, pattern_property: false, default: nil, description: nil, rule: nil,
257
+ type: :object, **opts, &blk)
258
+ new_array = {
259
+ type: :array,
260
+ **(!default.nil? ? { default: default } : {}),
261
+ **(description ? { description: description } : {}),
262
+ **(rule ? { rule: rule } : {}),
263
+ **opts,
264
+ items: {
265
+ type: type,
266
+ properties: {},
267
+ additionalProperties: false
268
+ }
269
+ }
270
+ yields(new_array, &blk)
271
+ required(name) if required && default.nil? && rule.nil?
272
+ pattern_property ? set_pattern_property(name, new_array) : set_property(name, new_array)
273
+ end
274
+
275
+ def items(&blk)
276
+ yields(schema[:items], &blk)
277
+ end
278
+
279
+ def yields(other)
280
+ return unless block_given?
281
+
282
+ prev_schema = schema
283
+ self.schema = other
284
+ yield
285
+ self.schema = prev_schema
286
+ end
287
+
288
+ def rule_from_when(when_clause)
289
+ inputs = when_clause.keys
290
+ conditions = when_clause
291
+ rule = "{#{when_clause.keys.map { |k| "#{k}: #{k}}" }.join(', ')}"
292
+ {
293
+ inputs: inputs,
294
+ conditions: conditions,
295
+ rule: rule
296
+ }
297
+ end
298
+
299
+ def rule_from_first_of(prop_list)
300
+ {
301
+ inputs: prop_list,
302
+ rule: "itself.get_first_of(:#{prop_list.join(', :')})"
303
+ }
304
+ end
305
+
306
+ def depends_on(*dependencies)
307
+ @resolved_dependencies ||= Hash.new do |h, k|
308
+ send(k) # Load dependency once
309
+ h[k] = true
310
+ end
311
+ dependencies.each(&@resolved_dependencies.method(:[]))
312
+ end
313
+ end
314
+ end
315
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Subclass LazyGraph::Builder to create new builder classes
4
+ # which can be used to easily build a rule-set to be used as a LazyGraph.
5
+ #
6
+ require_relative 'builder/dsl'
7
+
8
+ module LazyGraph
9
+ class Builder
10
+ include DSL
11
+ # This class is responsible for piece-wise building of rules,
12
+ # as a combined schema definition.
13
+ attr_accessor :schema
14
+
15
+ def initialize(schema: { type: 'object', properties: {} }) = @schema = schema
16
+ def build!(debug: false, validate: false) = @schema.to_lazy_graph(debug: debug, validate: validate, helpers: self.class.helper_modules)
17
+ def context(value, debug: false, validate: false) = build!(debug: debug, validate: validate).context(value)
18
+ def eval!(context, *value, debug: false, validate: false) = context(context, validate: validate, debug: debug).query(*value)
19
+
20
+ def self.rules_module(name, schema = { type: 'object', properties: {} }, &blk)
21
+ rules_modules[:properties][name.to_sym] = { type: :object, properties: schema }
22
+ module_body_func_name = :"_#{name}"
23
+ define_method(module_body_func_name, &blk)
24
+ define_method(name) do |**args, &inner_blk|
25
+ send(module_body_func_name, **args, &inner_blk)
26
+ self
27
+ end
28
+ end
29
+
30
+ class << self
31
+ attr_reader :helper_modules
32
+ end
33
+
34
+ def self.register_helper_module(mod)
35
+ (@helper_modules ||= []) << mod
36
+ end
37
+
38
+ def self.clear_rules_modules!
39
+ @rules_modules = nil
40
+ end
41
+
42
+ def self.clear_helper_modules!
43
+ @helper_modules = nil
44
+ end
45
+
46
+ def self.rules_modules
47
+ @rules_modules ||= {
48
+ type: :object,
49
+ properties: {},
50
+ additionalProperties: false
51
+ }
52
+ end
53
+
54
+ def self.usage
55
+ {
56
+ modules_options: rules_modules[:properties].map do |k, v|
57
+ {
58
+ name: k.to_s,
59
+ properties: v[:properties],
60
+ required: v[:required]
61
+ }
62
+ end,
63
+ context_sample_schema: rules_modules[:properties].keys.reduce(new) do |acc, (k, _v)|
64
+ acc.send(k, **{})
65
+ end.schema
66
+ }
67
+ end
68
+
69
+ def self.eval!(modules:, context:, query:, debug: false, validate: false)
70
+ context_result = (@eval_cache ||= {})[[modules, context, query, debug, validate].hash] ||= begin
71
+ invalid_modules = modules.reject { |k, _v| rules_modules[:properties].key?(k.to_sym) }
72
+ return format_error_response('Invalid Modules', invalid_modules.keys.join(',')) unless invalid_modules.empty?
73
+
74
+ error = validate_modules(modules)
75
+
76
+ return format_error_response('Invalid Module Option', error) unless error.empty?
77
+
78
+ builder = build_modules(modules)
79
+ return builder if builder.is_a?(Hash)
80
+
81
+ evaluate_context(builder, context, debug: debug, validate: validate)
82
+ end
83
+
84
+ @eval_cache.delete(@eval_cache.keys.first) if @eval_cache.size > 1000
85
+
86
+ return context_result if context_result.is_a?(Hash) && context_result[:type] == :error
87
+
88
+ {
89
+ type: :success,
90
+ result: context_result.query(*(query || ''))
91
+ }
92
+
93
+ rescue SystemStackError => e
94
+ LazyGraph.logger.error(e.message)
95
+ LazyGraph.logger.error(e.backtrace)
96
+ {
97
+ type: :error,
98
+ message: 'Recursive Query Detected',
99
+ detail: "Problem query path: #{query}"
100
+ }
101
+ end
102
+
103
+ private_class_method def self.method_missing(method_name, *args, &block) = new.send(method_name, *args, &block)
104
+ private_class_method def self.respond_to_missing?(_, _ = false) = true
105
+
106
+ private_class_method def self.validate_modules(input_options)
107
+ JSON::Validator.validate!(rules_modules, input_options)
108
+ ''
109
+ rescue StandardError => e
110
+ e.message
111
+ end
112
+
113
+ private_class_method def self.format_error_response(message, detail)
114
+ {
115
+ type: :error,
116
+ message: message,
117
+ **(detail.to_s =~ /Schema: / ? { location: detail[/^Schema: #(.*)$/, 1] } : {}),
118
+ detail: detail.to_s
119
+ }
120
+ end
121
+
122
+ private_class_method def self.build_modules(input_options)
123
+ input_options.reduce(new.additional_properties(false)) do |acc, (k, v)|
124
+ acc.send(k, **v.to_h.transform_keys(&:to_sym))
125
+ end
126
+ rescue ArgumentError => e
127
+ format_error_response('Invalid Module Argument', e.message)
128
+ LazyGraph.logger.error(e.backtrace)
129
+ end
130
+
131
+ private_class_method def self.evaluate_context(builder, context, debug: false, validate: false)
132
+ builder.context(context, debug: debug, validate: validate)
133
+ rescue ArgumentError => e
134
+ format_error_response('Invalid Context Input', e.message)
135
+ LazyGraph.logger.error(e.backtrace)
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,57 @@
1
+ module LazyGraph
2
+ class BuilderGroup
3
+ # A builder group is simply a named colleciton of builders (each a subclass of LazyGraph::Builder)
4
+ # That can be reloaded in bulk, and exposed via a simple HTTP server interface.
5
+ def self.bootstrap!(reload_paths: [], listen: ENV['RACK_ENV'] == 'development')
6
+ reload_paths = Array(reload_paths)
7
+ Module.new do
8
+ define_singleton_method(:included) do |base|
9
+ def base.each_builder(const = self, &blk)
10
+ return to_enum(__method__, const) unless blk
11
+
12
+ if const.is_a?(Class) && const < LazyGraph::Builder
13
+ blk[const]
14
+ elsif const.is_a?(Module)
15
+ const.constants.each do |c|
16
+ each_builder(const.const_get(c), &blk)
17
+ end
18
+ end
19
+ end
20
+
21
+ def base.server
22
+ LazyGraph::Server.new(
23
+ routes: each_builder.map do |builder|
24
+ [
25
+ builder.to_s.downcase.gsub('::', '/').gsub(/^#{name.downcase}/, ''),
26
+ builder
27
+ ]
28
+ end.to_h
29
+ )
30
+ end
31
+
32
+ base.define_singleton_method(:reload_lazy_graphs!) do
33
+ each_builder do |builder|
34
+ builder.clear_rules_modules!
35
+ builder.clear_helper_modules!
36
+ end
37
+
38
+ reload_paths.flat_map { |p| Dir[p] }.each do |file|
39
+ load file
40
+ rescue StandardError => e
41
+ LazyGraph.logger.error("Failed to load #{file}: #{e.message}")
42
+ end
43
+ end
44
+
45
+ base.reload_lazy_graphs!
46
+
47
+ return unless listen
48
+
49
+ require 'listen'
50
+ Listen.to(*reload_paths.map { |p| p.gsub(%r{(?:/\*\*)*/\*\.rb}, '') }) do
51
+ base.reload_lazy_graphs!
52
+ end.start
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LazyGraph
4
+ # Context class is responsible for managing ruleset and input data,
5
+ # allowing querying and dynamic method calls to access input fields.
6
+ class Context
7
+ attr_accessor :ruleset, :input
8
+
9
+ def initialize(graph, input)
10
+ HashUtils.deep_symbolize!(input)
11
+ graph.validate!(input) if [true, 'input'].include?(graph.validate)
12
+ @graph = graph
13
+ @input = input
14
+ end
15
+
16
+ def query(paths)
17
+ paths.is_a?(Array) ? paths.map { |path| resolve(input, path) } : resolve(input, paths)
18
+ end
19
+
20
+ def resolve(input, path)
21
+ @input = @graph.root_node.fetch_item({ input: input }, :input, nil)
22
+ query = PathParser.parse(path, true)
23
+ stack = StackPointer.new(nil, @input, 0, :'$', nil)
24
+ stack.root = stack
25
+
26
+ result = @graph.root_node.resolve(query, stack)
27
+ @graph.root_node.clear_visits!
28
+ if @graph.debug?
29
+ debug_trace = stack.frame[:DEBUG]
30
+ stack.frame[:DEBUG] = nil
31
+ end
32
+ {
33
+ output: HashUtils.strip_invalid(result),
34
+ debug_trace: HashUtils.strip_invalid(debug_trace)
35
+ }
36
+ rescue LazyGraph::AbortError => e
37
+ {
38
+ output: { err: e.message, status: :abort }
39
+ }
40
+ rescue StandardError => e
41
+ LazyGraph.logger.error(e.message)
42
+ LazyGraph.logger.error(e.backtrace)
43
+ {
44
+ output: { err: e.message, backtrace: e.backtrace }
45
+ }
46
+ end
47
+
48
+ def pretty_print(q)
49
+ # Start the custom pretty print
50
+ q.group(1, '<LazyGraph::Context ', '>') do
51
+ q.group do
52
+ q.text 'graph='
53
+ q.pp(@graph)
54
+ end
55
+ end
56
+ end
57
+
58
+ alias [] query
59
+ end
60
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json-schema'
4
+
5
+ module LazyGraph
6
+ # Represents a lazy graph structure based on JSON schema
7
+ VALIDATION_CACHE = {}
8
+ METASCHEMA = JSON.load_file(File.join(__dir__, 'lazy-graph.json'))
9
+
10
+ class Graph
11
+ attr_reader :json_schema, :root_node, :validate
12
+
13
+ def context(input) = Context.new(self, input)
14
+ def debug? = @debug
15
+
16
+ def initialize(json_schema, debug: false, validate: true, helpers: nil)
17
+ @json_schema = HashUtils.deep_dup!(json_schema).merge(type: :object)
18
+
19
+ @debug = debug
20
+ @validate = validate
21
+ @helpers = helpers
22
+
23
+ signature = HashUtils.deep_symbolize!(@json_schema)
24
+ if [true, 'schema'].include?(validate)
25
+ VALIDATION_CACHE[signature] ||= validate!(@json_schema, METASCHEMA)
26
+ true
27
+ end
28
+
29
+ if @json_schema[:type].to_sym != :object || @json_schema[:properties].nil?
30
+ raise ArgumentError, 'Root schema must be a non-empty object'
31
+ end
32
+
33
+ @root_node = build_node(@json_schema)
34
+ end
35
+
36
+ def build_node(schema, path = :'$', name = :root, parent = nil)
37
+ schema[:type] = schema[:type].to_sym
38
+ case schema[:type]
39
+ when :object then ObjectNode
40
+ when :array then ArrayNode
41
+ else Node
42
+ end.new(name, path, schema, parent, debug: @debug, helpers: @helpers).tap do |node|
43
+ node.children = build_children(node, schema, path)
44
+ end
45
+ end
46
+
47
+ def build_children(node, schema, path)
48
+ case node.type
49
+ when :object then build_object_children(schema, path, node)
50
+ when :array then build_node(schema.fetch(:items, {}), :"#{path}[]", :items, node)
51
+ end
52
+ end
53
+
54
+ def build_object_children(schema, path, parent)
55
+ {
56
+ properties: schema.fetch(:properties, {}).map do |key, value|
57
+ [key, build_node(value, "#{path}.#{key}", key, parent)]
58
+ end.to_h,
59
+ pattern_properties: schema.fetch(:patternProperties, {}).map do |key, value|
60
+ [Regexp.new(key.to_s), build_node(value, :"#{path}.#{key}", :'<property>', parent)]
61
+ end.to_h
62
+ }
63
+ end
64
+
65
+ def validate!(input, schema = @json_schema)
66
+ JSON::Validator.validate!(schema, input)
67
+ end
68
+ end
69
+
70
+ Hash.define_method(:to_lazy_graph, ->(**opts) { LazyGraph::Graph.new(self, **opts) })
71
+ Hash.define_method(:to_graph_ctx, ->(input, **opts) { to_lazy_graph(**opts).context(input) })
72
+ Hash.define_method(:eval_graph, ->(input, *query, **opts) { to_graph_ctx(input, **opts)[*query] })
73
+ end