lazy_graph 0.1.0

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.
@@ -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