lazy_graph 0.1.3 → 0.2.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.
@@ -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