lazy_graph 0.1.3 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -4,16 +4,18 @@ require 'json-schema'
4
4
 
5
5
  module LazyGraph
6
6
  # Represents a lazy graph structure based on JSON schema
7
- VALIDATION_CACHE = {}
7
+ VALIDATION_CACHE = {}.compare_by_identity
8
8
  METASCHEMA = JSON.load_file(File.join(__dir__, 'lazy-graph.json'))
9
9
 
10
10
  class Graph
11
11
  attr_reader :json_schema, :root_node, :validate
12
12
 
13
13
  def context(input) = Context.new(self, input)
14
- def debug? = @debug
14
+ def debug? = !!@debug
15
+ alias input context
16
+ alias feed context
15
17
 
16
- def initialize(input_schema, debug: false, validate: true, helpers: nil)
18
+ def initialize(input_schema, debug: false, validate: true, helpers: nil, namespace: nil)
17
19
  @json_schema = HashUtils.deep_dup(input_schema, symbolize: true, signature: signature = [0]).merge(type: :object)
18
20
  @debug = debug
19
21
  @validate = validate
@@ -25,18 +27,20 @@ module LazyGraph
25
27
  raise ArgumentError, 'Root schema must be a non-empty object'
26
28
  end
27
29
 
28
- @root_node = build_node(@json_schema)
30
+ @root_node = build_node(@json_schema, namespace: namespace)
31
+ @root_node.build_derived_inputs!
29
32
  end
30
33
 
31
- def build_node(schema, path = :'$', name = :root, parent = nil)
34
+ def build_node(schema, path = :'$', name = :root, parent = nil, namespace: nil)
32
35
  schema[:type] = schema[:type].to_sym
33
- node = case schema[:type]
34
- when :object then ObjectNode
35
- when :array then ArrayNode
36
- else Node
37
- end.new(name, path, schema, parent, debug: @debug, helpers: @helpers)
36
+ node = \
37
+ case schema[:type]
38
+ when :object then ObjectNode
39
+ when :array then ArrayNode
40
+ else Node
41
+ end.new(name, path, schema, parent, debug: @debug, helpers: @helpers, namespace: namespace)
38
42
 
39
- if node.type == :object
43
+ if node.type.equal?(:object)
40
44
  node.children = \
41
45
  {
42
46
  properties: schema.fetch(:properties, {}).map do |key, value|
@@ -46,7 +50,7 @@ module LazyGraph
46
50
  [Regexp.new(key.to_s), build_node(value, :"#{path}.#{key}", :'<property>', node)]
47
51
  end
48
52
  }
49
- elsif node.type == :array
53
+ elsif node.type.equal?(:array)
50
54
  node.children = build_node(schema.fetch(:items, {}), :"#{path}[]", :items, node)
51
55
  end
52
56
  node
@@ -54,6 +58,18 @@ module LazyGraph
54
58
 
55
59
  def validate!(input, schema = @json_schema)
56
60
  JSON::Validator.validate!(schema, input)
61
+ rescue JSON::Schema::ValidationError => e
62
+ raise ValidationError, "Input validation failed: #{e.message}", cause: e
63
+ end
64
+
65
+ def pretty_print(q)
66
+ # Start the custom pretty print
67
+ q.group(1, '<LazyGraph::Graph ', '>') do
68
+ q.group do
69
+ q.text 'props='
70
+ q.text root_node.children[:properties].keys
71
+ end
72
+ end
57
73
  end
58
74
  end
59
75
 
@@ -20,6 +20,8 @@ module LazyGraph
20
20
  signature[0] ^= key.object_id if signature
21
21
  result[key] = deep_dup(value, symbolize: symbolize, signature: signature)
22
22
  end
23
+ when Struct
24
+ deep_dup(obj.to_h, symbolize: symbolize, signature: signature)
23
25
  when Array
24
26
  obj.map { |value| deep_dup(value, symbolize: symbolize, signature: signature) }
25
27
  when String, Numeric, TrueClass, FalseClass, NilClass
@@ -47,7 +49,7 @@ module LazyGraph
47
49
  end
48
50
  end
49
51
 
50
- def strip_invalid(obj, parent_list = {}.compare_by_identity)
52
+ def strip_missing(obj, parent_list = {}.compare_by_identity)
51
53
  return { '^ref': :circular } if (circular_dependency = parent_list[obj])
52
54
 
53
55
  parent_list[obj] = true
@@ -55,18 +57,21 @@ module LazyGraph
55
57
  when Hash
56
58
  obj.each_with_object({}) do |(key, value), obj|
57
59
  next if value.is_a?(MissingValue)
60
+ next if value.nil?
58
61
 
59
- obj[key] = strip_invalid(value, parent_list)
62
+ obj[key] = strip_missing(value, parent_list)
60
63
  end
61
64
  when Struct
62
65
  obj.members.each_with_object({}) do |key, res|
63
- next if obj[key].is_a?(MissingValue)
66
+ value = obj.original_get(key)
67
+ next if value.is_a?(MissingValue)
68
+ next if value.nil?
64
69
  next if obj.invisible.include?(key)
65
70
 
66
- res[key] = strip_invalid(obj[key], parent_list)
71
+ res[key] = strip_missing(obj[key], parent_list)
67
72
  end
68
73
  when Array
69
- obj.map { |value| strip_invalid(value, parent_list) }
74
+ obj.map { |value| strip_missing(value, parent_list) }
70
75
  when MissingValue
71
76
  nil
72
77
  else
@@ -0,0 +1,87 @@
1
+ require 'logger'
2
+ require_relative 'environment'
3
+
4
+ module LazyGraph
5
+ class << self
6
+ attr_accessor :logger
7
+ end
8
+
9
+ module Logger
10
+ COLORIZED_LOGS = !ENV['DISABLED_COLORIZED_LOGS'] && ENV.fetch('RACK_ENV', 'development') == 'development'
11
+
12
+ module_function
13
+
14
+ class << self
15
+ attr_accessor :color_enabled, :structured
16
+ end
17
+
18
+ def structured
19
+ return @structured if defined?(@structured)
20
+ @structured = !LazyGraph::Environment.development?
21
+ end
22
+
23
+ def default_logger
24
+ logger = ::Logger.new($stdout)
25
+ self.color_enabled ||= Logger::COLORIZED_LOGS
26
+ if self.color_enabled
27
+ logger.formatter = proc do |severity, datetime, progname, message|
28
+ light_gray_timestamp = "\e[90m[##{Process.pid}] #{datetime.strftime('%Y-%m-%dT%H:%M:%S.%6N')}\e[0m" # Light gray timestamp
29
+ "#{light_gray_timestamp} \e[1m#{severity}\e[0m #{progname}: #{message}\n"
30
+ end
31
+ elsif self.structured
32
+ logger.formatter = proc do |severity, datetime, progname, message|
33
+ "#{{severity:, datetime:, progname:, **(message.is_a?(Hash) ? message : {message: }) }.to_json}\n"
34
+ end
35
+ else
36
+ logger.formatter = proc do |severity, datetime, progname, message|
37
+ "[##{Process.pid}] #{datetime.strftime('%Y-%m-%dT%H:%M:%S.%6N')} #{severity} #{progname}: #{message}\n"
38
+ end
39
+ end
40
+ logger
41
+ end
42
+
43
+ def build_color_string(&blk)
44
+ return unless block_given?
45
+
46
+ instance_eval(&blk)
47
+ end
48
+
49
+ def colorize(text, color_code)
50
+ @color_enabled ? "\e[#{color_code}m#{text}\e[0m" : text
51
+ end
52
+
53
+ def green(text)
54
+ colorize(text, 32) # Green for success
55
+ end
56
+
57
+ def red(text)
58
+ colorize(text, 31) # Red for errors
59
+ end
60
+
61
+ def yellow(text)
62
+ colorize(text, 33) # Yellow for warnings or debug
63
+ end
64
+
65
+ def blue(text)
66
+ colorize(text, 34) # Blue for info
67
+ end
68
+
69
+ def light_gray(text)
70
+ colorize(text, 90) # Light gray for faded text
71
+ end
72
+
73
+ def orange(text)
74
+ colorize(text, '38;5;214')
75
+ end
76
+
77
+ def bold(text)
78
+ colorize(text, 1) # Bold text
79
+ end
80
+
81
+ def dim(text)
82
+ colorize(text, 2) # Italic text
83
+ end
84
+ end
85
+
86
+ self.logger = Logger.default_logger
87
+ end
@@ -8,13 +8,20 @@ module LazyGraph
8
8
 
9
9
  def initialize(details) = @details = details
10
10
  def to_s = "MISSING[#{@details}]"
11
-
12
11
  def inspect = to_s
13
12
  def coerce(other) = [self, other]
14
13
  def as_json = nil
14
+ def to_h = nil
15
15
  def +(other) = other
16
-
17
16
  def respond_to_missing?(_method_name, _include_private = false) = true
17
+ def to_i = 0
18
+ def to_f = 0.0
19
+
20
+ def ==(other)
21
+ return true if other.nil?
22
+
23
+ super
24
+ end
18
25
 
19
26
  def method_missing(method, *args, &block)
20
27
  return super if method == :to_ary
@@ -14,42 +14,38 @@ module LazyGraph
14
14
  should_recycle = stack_memory,
15
15
  **
16
16
  )
17
- input = stack_memory.frame
17
+ return MissingValue() unless input = stack_memory.frame
18
+
18
19
  @visited[input.object_id >> 2 ^ path.shifted_id] ||= begin
20
+ path_next = path.next
19
21
  if (path_segment = path.segment).is_a?(PathParser::PathGroup)
20
22
  unless path_segment.index?
21
23
  return input.length.times.map do |index|
22
- item = children.fetch_item(input, index, stack_memory)
23
- children.resolve(path, stack_memory.push(item, index))
24
+ children.fetch_and_resolve(path, input, index, stack_memory)
24
25
  end
25
26
  end
26
27
 
27
- return resolve(path_segment.options.first.merge(path.next), stack_memory, nil) if path_segment.options.one?
28
+ return resolve(path_segment.options.first.merge(path_next), stack_memory, nil) if path_segment.options.one?
28
29
 
29
- return path_segment.options.map { |part| resolve(part.merge(path.next), stack_memory, nil) }
30
+ return path_segment.options.map { |part| resolve(part.merge(path_next), stack_memory, nil) }
30
31
  end
31
32
 
32
33
  segment = path_segment&.part
33
34
  case segment
34
35
  when nil
35
- input.length.times do |index|
36
- item = children.fetch_item(input, index, stack_memory)
37
- children.resolve(path, stack_memory.push(item, index))
36
+
37
+ unless @children.simple?
38
+ input.length.times do |index|
39
+ @children.fetch_and_resolve(path, input, index, stack_memory)
40
+ end
38
41
  end
39
42
  input
40
43
  when DIGIT_REGEXP
41
- item = @children.fetch_item(input, segment.to_s.to_i, stack_memory)
42
- children.resolve(path.next, stack_memory.push(item, segment))
43
- when :*
44
- input.length.times.map do |index|
45
- item = children.fetch_item(input, index, stack_memory)
46
- @children.resolve(path.next, stack_memory.push(item, index))
47
- end
44
+ @children.fetch_and_resolve(path_next, input, segment.to_s.to_i, stack_memory)
48
45
  else
49
46
  if @child_properties&.key?(segment) || input&.first&.key?(segment)
50
47
  input.length.times.map do |index|
51
- item = children.fetch_item(input, index, stack_memory)
52
- @children.resolve(path, stack_memory.push(item, index))
48
+ @children.fetch_and_resolve(path, input, index, stack_memory)
53
49
  end
54
50
  else
55
51
  MissingValue()
@@ -66,7 +62,7 @@ module LazyGraph
66
62
  end
67
63
 
68
64
  def cast(value)
69
- value
65
+ Array(value)
70
66
  end
71
67
  end
72
68
  end
@@ -25,6 +25,7 @@ module LazyGraph
25
25
  def build_derived_inputs(derived, helpers)
26
26
  @resolvers = {}.compare_by_identity
27
27
  @path_cache = {}.compare_by_identity
28
+ @resolution_stack = {}.compare_by_identity
28
29
 
29
30
  derived = interpret_derived_proc(derived) if derived.is_a?(Proc)
30
31
  derived = { inputs: derived.to_s } if derived.is_a?(String) || derived.is_a?(Symbol)
@@ -43,9 +44,10 @@ module LazyGraph
43
44
  end
44
45
 
45
46
  def interpret_derived_proc(derived)
46
- src, requireds, optionals, keywords, proc_line, = DerivedRules.extract_expr_from_source_location(derived.source_location)
47
- src = src.body&.slice || ''
48
- @src = src.lines.map(&:strip)
47
+ src, requireds, optionals, keywords, loc = DerivedRules.extract_expr_from_source_location(derived.source_location)
48
+ body = src.body&.slice || ''
49
+ @src = body.lines.map(&:strip)
50
+ offset = src.slice.lines.length - body.lines.length
49
51
  inputs, conditions = parse_args_with_conditions(requireds, optionals, keywords)
50
52
 
51
53
  {
@@ -53,11 +55,11 @@ module LazyGraph
53
55
  mtime: File.mtime(derived.source_location.first),
54
56
  conditions: conditions,
55
57
  calc: instance_eval(
56
- "->(#{inputs.keys.map { |k| "#{k}=self.#{k}" }.join(', ')}){ #{src}}",
58
+ "->(#{inputs.keys.map { |k| "#{k}=self.#{k}" }.join(', ')}){ #{body}}",
57
59
  # rubocop:disable:next-line
58
60
  derived.source_location.first,
59
61
  # rubocop:enable
60
- derived.source_location.last.succ.succ
62
+ derived.source_location.last + offset
61
63
  )
62
64
  }
63
65
  end
@@ -77,13 +79,21 @@ module LazyGraph
77
79
  [keywords, conditions.any? ? conditions : nil]
78
80
  end
79
81
 
82
+ def self.get_file_body(file_path)
83
+ @file_body_cache ||= {}
84
+ if @file_body_cache[file_path]&.last.to_i < File.mtime(file_path).to_i
85
+ @file_body_cache[file_path] = [IO.readlines(file_path), File.mtime(file_path).to_i]
86
+ end
87
+ @file_body_cache[file_path]&.first
88
+ end
89
+
80
90
  def self.extract_expr_from_source_location(source_location)
81
91
  @derived_proc_cache ||= {}
82
92
  mtime = File.mtime(source_location.first).to_i
83
-
84
93
  if @derived_proc_cache[source_location]&.last.to_i.< mtime
85
94
  @derived_proc_cache[source_location] = begin
86
- source_lines = IO.readlines(source_location.first)
95
+ source_lines = get_file_body(source_location.first)
96
+
87
97
  proc_line = source_location.last - 1
88
98
  first_line = source_lines[proc_line]
89
99
  until first_line =~ /(?:lambda|proc|->)/ || proc_line.zero?
@@ -124,7 +134,7 @@ module LazyGraph
124
134
  @derived_proc_cache[source_location]
125
135
  rescue StandardError => e
126
136
  LazyGraph.logger.error(e.message)
127
- LazyGraph.logger.error(e.backtrace)
137
+ LazyGraph.logger.error(e.backtrace.join("\n"))
128
138
  raise "Failed to extract expression from source location: #{source_location}. Ensure the file exists and the line number is correct. Extraction from a REPL is not supported"
129
139
  end
130
140
 
@@ -132,15 +142,16 @@ module LazyGraph
132
142
  inputs = derived[:inputs]
133
143
  case inputs
134
144
  when Symbol, String
135
- if inputs =~ PLACEHOLDER_VAR_REGEX && !derived[:calc]
145
+ if !derived[:calc]
136
146
  @src ||= inputs
137
147
  input_hash = {}
138
148
  @input_mapper = {}
139
- derived[:calc] = inputs.gsub(PLACEHOLDER_VAR_REGEX) do |match|
149
+ calc = inputs.gsub(PLACEHOLDER_VAR_REGEX) do |match|
140
150
  sub = input_hash[match[2...-1]] ||= "a#{::SecureRandom.hex(8)}"
141
151
  @input_mapper[sub.to_sym] = match[2...-1].to_sym
142
152
  sub
143
153
  end
154
+ derived[:calc] = calc unless calc == input_hash.values.first
144
155
  input_hash.invert
145
156
  else
146
157
  { inputs.to_s.gsub(/[^(?:[A-Za-z][A-Za-z0-9_])]/, '__') => inputs.to_s.freeze }
@@ -167,10 +178,20 @@ module LazyGraph
167
178
 
168
179
  def parse_rule_string(derived)
169
180
  calc_str = derived[:calc]
170
- src = @src
171
- instance_eval(
172
- "->{ begin; #{calc_str}; rescue StandardError => e; LazyGraph.logger.error(\"Exception in \#{src}. \#{e.message}\"); LazyGraph.logger.error(e.backtrace.join(\"\\n\")); raise; end }", __FILE__, __LINE__
173
- )
181
+ node_path = path
182
+
183
+ src = <<~RUBY, @rule_location&.first, @rule_location&.last.to_i - 2
184
+ ->{
185
+ begin
186
+ #{calc_str}
187
+ rescue StandardError => e;
188
+ LazyGraph.logger.error("Exception in \#{calc_str} => \#{node_path}. \#{e.message}")
189
+ raise e
190
+ end
191
+ }
192
+ RUBY
193
+
194
+ instance_eval(*src)
174
195
  rescue SyntaxError
175
196
  missing_value = MissingValue { "Syntax error in #{derived[:src]}" }
176
197
  -> { missing_value }
@@ -194,12 +215,46 @@ module LazyGraph
194
215
  end.new
195
216
  end
196
217
 
218
+ def resolver_for(path)
219
+ segment = path.segment.part
220
+ return root.properties[path.next.segment.part] if segment == :'$'
221
+
222
+ (segment == name ? parent.parent : @parent).find_resolver_for(segment)
223
+ end
224
+
225
+ def rule_definition_backtrace
226
+ if @rule_location && @rule_location.size >= 2
227
+ rule_file, rule_line = @rule_location
228
+ rule_entry = "#{rule_file}:#{rule_line}:in `rule`"
229
+ else
230
+ rule_entry = 'unknown_rule_location'
231
+ end
232
+
233
+ current_backtrace = caller.reverse.take_while { |line| !line.include?('/lib/lazy_graph/') }.reverse
234
+ [rule_entry] + current_backtrace
235
+ end
236
+
197
237
  def map_derived_inputs_to_paths(inputs)
198
238
  inputs.values.map.with_index do |path, idx|
199
- segment_indexes = path.parts.map.with_index do |segment, i|
200
- segment.is_a?(PathParser::PathGroup) && segment.options.length == 1 ? i : nil
239
+ segments = path.parts.map.with_index do |segment, i|
240
+ if segment.is_a?(PathParser::PathGroup) &&
241
+ segment.options.length == 1 && !((resolver = resolver_for(segment.options.first)) || segment.options.first.segment.part.to_s =~ /\d+/)
242
+ raise(ValidationError.new(
243
+ "Invalid dependency in #{@path}: #{segment.options.first.to_path_str} cannot be resolved."
244
+ ).tap { |e| e.set_backtrace(rule_definition_backtrace) })
245
+ end
246
+
247
+ resolver ? [i, resolver] : nil
201
248
  end.compact
202
- [path, idx, segment_indexes.any? ? segment_indexes : nil]
249
+ resolver = resolver_for(path)
250
+
251
+ unless resolver
252
+ raise(ValidationError.new(
253
+ "Invalid dependency in #{@path}: #{path.to_path_str} cannot be resolved."
254
+ ).tap { |e| e.set_backtrace(rule_definition_backtrace) })
255
+ end
256
+
257
+ [path, resolver, idx, segments.any? ? segments : nil]
203
258
  end
204
259
  end
205
260
  end
@@ -8,14 +8,27 @@ module LazyGraph
8
8
  members.each { |k| self[k] = kws[k].then { |v| v.nil? ? MissingValue::BLANK : v } }
9
9
  end
10
10
 
11
+ members.each do |m|
12
+ define_method(m) do
13
+ self[m]
14
+ end
15
+ end
16
+
17
+ alias_method :original_get, :[]
18
+
11
19
  define_method(:key?) do |x|
12
- !self[x].equal?(MissingValue::BLANK)
20
+ !original_get(x).equal?(MissingValue::BLANK)
13
21
  end
14
22
 
15
23
  define_method(:[]=) do |key, val|
16
24
  super(key, val)
17
25
  end
18
26
 
27
+ define_method(:[]) do |key|
28
+ res = original_get(key)
29
+ res.is_a?(MissingValue) ? nil : res
30
+ end
31
+
19
32
  define_method(:members) do
20
33
  members
21
34
  end
@@ -28,6 +41,17 @@ module LazyGraph
28
41
  to_h
29
42
  end
30
43
 
44
+ def to_h
45
+ HashUtils.strip_missing(self)
46
+ end
47
+
48
+ def ==(other)
49
+ return super if other.is_a?(self.class)
50
+ return to_h.eql?(other.to_h.keep_if { |_, v| !v.nil? }) if other.respond_to?(:to_h)
51
+
52
+ super
53
+ end
54
+
31
55
  define_method(:each_key, &members.method(:each))
32
56
 
33
57
  def dup
@@ -36,23 +60,24 @@ module LazyGraph
36
60
 
37
61
  def get_first_of(*props)
38
62
  key = props.find do |prop|
39
- !self[prop].is_a?(MissingValue)
63
+ !original_get(prop).is_a?(MissingValue)
40
64
  end
41
65
  key ? self[key] : MissingValue::BLANK
42
66
  end
43
67
 
44
68
  def pretty_print(q)
45
- q.group(1, '<Props ', '>') do
69
+ q.group(1, '<', '>') do
70
+ q.text "#{self.class.name} "
46
71
  q.seplist(members.zip(values).reject do |m, v|
47
- m == :DEBUG && (v.nil? || v.is_a?(MissingValue))
48
- end) do |member, value|
49
- q.group do
50
- q.text "#{member}="
51
- value.respond_to?(:pretty_print) ? q.pp(value) : q.text(value.inspect)
52
- end
72
+ m == :DEBUG || v.nil? || v.is_a?(MissingValue)
73
+ end.to_h) do |k, v|
74
+ q.text "#{k}: "
75
+ q.pp v
53
76
  end
54
77
  end
55
78
  end
79
+
80
+ alias_method :keys, :members
56
81
  end
57
82
  end
58
83
  end