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,113 @@
1
+ module LazyGraph
2
+ class ObjectNode < Node
3
+ # An object supports the following types of path resolutions.
4
+ # 1. Property name: obj.property => value
5
+ # 2. Property name group: obj[property1, property2] => { property1: value1, property2: value2 }
6
+ # 3. All [*]
7
+ def resolve(
8
+ path,
9
+ stack_memory,
10
+ should_recycle = stack_memory,
11
+ preserve_keys: false
12
+ )
13
+ input = stack_memory.frame
14
+ @visited[input.object_id ^ (path.object_id << 8)] ||= begin
15
+ if (path_segment = path.segment).is_a?(PathParser::PathGroup)
16
+ return path_segment.options.each_with_object({}.tap(&:compare_by_identity)) do |part, object|
17
+ resolve(part.merge(path.next), stack_memory, nil, preserve_keys: object)
18
+ end
19
+ end
20
+
21
+ if !segment = path_segment&.part
22
+ @properties_a.each do |key, node|
23
+ item = node.fetch_item(input, key, stack_memory)
24
+ node.resolve(path.next, stack_memory.push(item, key))
25
+ end
26
+ if @pattern_properties_a.any? && input.keys.length > @properties_a.length
27
+ input.each_key do |key|
28
+ node = !@properties[key] && @pattern_properties_a.find { |pattern, _value| pattern.match?(key) }&.last
29
+ item = node.fetch_item(input, key, stack_memory)
30
+ node.resolve(path.next, stack_memory.push(item, key))
31
+ end
32
+ end
33
+ input
34
+ elsif (prop = @properties[segment])
35
+ item = prop.fetch_item(input, segment, stack_memory)
36
+ value = prop.resolve(
37
+ path.next, stack_memory.push(item, segment)
38
+ )
39
+ preserve_keys ? preserve_keys[segment] = value : value
40
+ elsif segment == :*
41
+ # rubocop:disable
42
+ (input.keys | @properties_a.map(&:first)).each do |key|
43
+ next unless (node = @properties[key] || @pattern_properties_a.find do |pattern, _value|
44
+ pattern.match?(key)
45
+ end&.last)
46
+
47
+ item = node.fetch_item(input, key, stack_memory)
48
+ preserve_keys[key] = node.resolve(path.next, stack_memory.push(item, key))
49
+ end
50
+ elsif (_, prop = @pattern_properties_a.find { |key, _val| key.match?(segment) })
51
+ item = prop.fetch_item(input, segment, stack_memory)
52
+ value = prop.resolve(
53
+ path.next, stack_memory.push(item, segment)
54
+ )
55
+ preserve_keys ? preserve_keys[segment] = value : value
56
+ elsif input.key?(segment)
57
+ prop = @properties[segment] = lazy_init_node!(input[segment], segment)
58
+ @properties_a = @properties.to_a
59
+ item = prop.fetch_item(input, segment, stack_memory)
60
+ value = prop.resolve(
61
+ path.next, stack_memory.push(item, segment)
62
+ )
63
+ preserve_keys ? preserve_keys[segment] = value : value
64
+ else
65
+ value = MissingValue()
66
+ preserve_keys ? preserve_keys[segment] = value : value
67
+ end
68
+ end
69
+ ensure
70
+ should_recycle&.recycle!
71
+ end
72
+
73
+ def find_resolver_for(segment)
74
+ if segment == :'$'
75
+ root
76
+ elsif @properties.key?(segment)
77
+ self
78
+ else
79
+ @parent&.find_resolver_for(segment)
80
+ end
81
+ end
82
+
83
+ def children=(value)
84
+ @children = value
85
+
86
+ @properties = @children.fetch(:properties, {})
87
+ @properties.compare_by_identity
88
+ @pattern_properties = @children.fetch(:pattern_properties, {})
89
+
90
+ @properties_a = @properties.to_a
91
+ @pattern_properties_a = @pattern_properties.to_a
92
+
93
+ @has_properties = @properties.any? || @pattern_properties.any?
94
+ return if @pattern_properties.any?
95
+ return unless @properties.any?
96
+
97
+ invisible = @properties.select { |_k, v| v.invisible }.map(&:first)
98
+ @property_class = PROPERTY_CLASSES[{ members: @properties.keys + (@debug && !parent ? [:DEBUG] : []),
99
+ invisible: invisible }]
100
+ end
101
+
102
+ def cast(value)
103
+ if !@property_class && value.is_a?(Hash)
104
+ value.default_proc = ->(h, k) { k.is_a?(Symbol) ? nil : h[k.to_s.to_sym] }
105
+ value.compare_by_identity
106
+ elsif @property_class && !value.is_a?(@property_class)
107
+ @property_class.new(value.to_h)
108
+ else
109
+ value
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal/util'
4
+ require 'json'
5
+
6
+ module LazyGraph
7
+ require_relative 'node/derived_rules'
8
+ require_relative 'node/array_node'
9
+ require_relative 'node/object_node'
10
+ require_relative 'node/node_properties'
11
+
12
+ DIGIT_REGEXP = /^-?\d+$/
13
+ SAFE_TOKEN_REGEXP = /^[A-Za-z][A-Za-z0-9]*$/
14
+ PROPERTY_CLASSES = Hash.new do |h, members|
15
+ h[members] = LazyGraph.const_set("NodeProperties#{h.hash.abs}", NodeProperties.build(**members))
16
+ end
17
+
18
+ # Class: Node
19
+ # Represents A single Node within our LazyGraph structure
20
+ # A node is a logical position with a graph structure.
21
+ # The node might capture knowledge about how to derived values at its position
22
+ # if a value is not provided.
23
+ # This can be in the form of a default value or a derivation rule.
24
+ #
25
+ # This class is heavily optimized to resolve values in a graph structure
26
+ # with as little overhead as possible. (Note heavy use of ivars,
27
+ # and minimal method calls in the recursive resolve method).
28
+ #
29
+ # Nodes support (non-circular) recursive resolution of values, i.e.
30
+ # if a node depends on the output of several other nodes in the graph,
31
+ # it will resolve those nodes first before resolving itself.
32
+ #
33
+ # Node resolution maintains a full stack, so that values can be resolved relative to the position
34
+ # of the node itself.
35
+ #
36
+
37
+ class Node
38
+ include DerivedRules
39
+ attr_accessor :name, :path, :type, :derived, :depth, :parent, :root, :invisible
40
+ attr_accessor :children
41
+ attr_reader :is_object
42
+
43
+ def initialize(name, path, node, parent, debug: false, helpers: nil)
44
+ @name = name
45
+ @path = path
46
+ @parent = parent
47
+ @debug = debug
48
+ @depth = parent ? parent.depth + 1 : 0
49
+ @root = parent ? parent.root : self
50
+ @type = node[:type]
51
+ @invisible = node[:invisible]
52
+ @visited = {}.compare_by_identity
53
+
54
+ define_missing_value_proc!
55
+
56
+ @has_default = node.key?(:default)
57
+ @default = @has_default ? node[:default] : MissingValue { @name }
58
+ @resolution_stack = []
59
+
60
+ instance_variable_set("@is_#{@type}", true)
61
+ build_derived_inputs(node[:rule], helpers) if node[:rule]
62
+ end
63
+
64
+ def define_missing_value_proc!
65
+ define_singleton_method(
66
+ :MissingValue,
67
+ @debug ? ->(&blk) { MissingValue.new(blk&.call || absolute_path) } : -> { MissingValue::BLANK }
68
+ )
69
+ end
70
+
71
+ def clear_visits!
72
+ @visited.clear
73
+ return unless @children
74
+ return @children.clear_visits! if @children.is_a?(Node)
75
+
76
+ @children[:properties]&.each do |_, node|
77
+ node.clear_visits!
78
+ end
79
+
80
+ @children[:pattern_properties]&.each do |_, node|
81
+ node.clear_visits!
82
+ end
83
+ end
84
+
85
+ # When we assign children to a node, we preemptively extract the properties, and pattern properties
86
+ # in both hash and array form. This micro-optimization pays off when we resolve values in the graph at
87
+ # very high frequency.
88
+
89
+ def resolve(
90
+ path,
91
+ stack_memory,
92
+ should_recycle = stack_memory,
93
+ **
94
+ )
95
+ path.empty? ? stack_memory.frame : MissingValue()
96
+ ensure
97
+ should_recycle&.recycle!
98
+ end
99
+
100
+ def lazy_init_node!(input, key)
101
+ case input
102
+ when Hash
103
+ node = Node.new(key, "#{path}.#{key}", { type: :object }, self)
104
+ node.children = { properties: {}, pattern_properties: {} }
105
+ node
106
+ when Array
107
+ node = Node.new(key, :"#{path}.#{key}[]", { type: :array }, self)
108
+ child_type = \
109
+ case input.first
110
+ when Hash then :object
111
+ when Array then :array
112
+ end
113
+ node.children = Node.new(:items, :"#{path}.#{key}[].items", { type: child_type }, node)
114
+ node.children.children = { properties: {}, pattern_properties: {} } if child_type == :object
115
+ node
116
+ else
117
+ Node.new(key, :"#{path}.#{key}", {}, self)
118
+ end
119
+ end
120
+
121
+ def absolute_path
122
+ @absolute_path ||= begin
123
+ next_node = self
124
+ path = []
125
+ while next_node
126
+ path << next_node.name
127
+ next_node = next_node.parent
128
+ end
129
+ path.reverse.join('.')
130
+ end
131
+ end
132
+
133
+ def resolve_input(stack_memory, path, key)
134
+ input_id = key.object_id ^ (stack_memory.object_id << 8)
135
+ if @resolution_stack.include?(input_id)
136
+ if @debug
137
+ stack_memory.log_debug(
138
+ property: "#{stack_memory}.#{key}",
139
+ exception: 'Infinite Recursion Detected during dependency resolution'
140
+ )
141
+ end
142
+ return MissingValue { "Infinite Recursion in #{stack_memory} => #{path.to_path_str}" }
143
+ end
144
+
145
+ @resolution_stack << (input_id)
146
+ first_segment = path.parts.first.part
147
+ resolver_node = @resolvers[first_segment] ||= (first_segment == key ? parent.parent : parent).find_resolver_for(first_segment)
148
+
149
+ if resolver_node
150
+ input_frame_pointer = stack_memory.ptr_at(resolver_node.depth)
151
+ resolver_node.resolve(
152
+ first_segment == :'$' ? path.next : path,
153
+ input_frame_pointer,
154
+ nil
155
+ )
156
+ else
157
+ MissingValue { path.to_path_str }
158
+ end
159
+ ensure
160
+ @resolution_stack.pop
161
+ end
162
+
163
+ def ancestors
164
+ @ancestors ||= [self, *(parent ? parent.ancestors : [])]
165
+ end
166
+
167
+ def find_resolver_for(segment)
168
+ segment == :'$' ? root : @parent&.find_resolver_for(segment)
169
+ end
170
+
171
+ def cast(value)
172
+ if @is_decimal
173
+ value.is_a?(BigDecimal) ? value : value.to_d
174
+ elsif @is_date
175
+ value.is_a?(String) ? Date.parse(value) : value
176
+ elsif @is_boolean
177
+ if value.is_a?(TrueClass) || value.is_a?(FalseClass)
178
+ value
179
+ else
180
+ value.is_a?(MissingValue) ? false : !!value
181
+ end
182
+ elsif @is_timestamp
183
+ case value
184
+ when String then DateTime.parse(value).to_time
185
+ when Numeric then Time.at(value)
186
+ else value
187
+ end
188
+ else
189
+ value
190
+ end
191
+ end
192
+
193
+ def fetch_item(input, key, stack)
194
+ return MissingValue { key } unless input
195
+
196
+ has_value = \
197
+ case input
198
+ when Array then input.length > key && input[key]
199
+ when Hash, Struct then input.key?(key) && !input[key].is_a?(MissingValue)
200
+ end
201
+
202
+ if has_value
203
+ value = input[key]
204
+ value = cast(value) if value || @is_boolean
205
+ return input[key] = value
206
+ end
207
+
208
+ return input[key] = @default unless derived
209
+
210
+ if @copy_input
211
+ copy_item!(input, key, stack, @inputs.first)
212
+ else
213
+ derive_item!(input, key, stack)
214
+ end
215
+ end
216
+
217
+ def copy_item!(input, key, stack, (path, i, segment_indexes))
218
+ if segment_indexes
219
+ missing_value = nil
220
+ parts = path.parts.dup
221
+ parts_identity = path.identity
222
+ segment_indexes.each do |index|
223
+ part = resolve_input(stack, parts[index].options.first, key)
224
+ break missing_value = part if part.is_a?(MissingValue)
225
+
226
+ part_sym = part.to_s.to_sym
227
+ parts_identity ^= part_sym.object_id << index
228
+ parts[index] = @path_cache[part_sym] ||= PathParser::PathPart.new(part: part_sym)
229
+ end
230
+ path = @path_cache[parts_identity] ||= PathParser::Path.new(parts: parts) unless missing_value
231
+ end
232
+
233
+ result = missing_value || cast(resolve_input(stack, path, key))
234
+
235
+ if @debug
236
+ stack.log_debug(
237
+ output: :"#{stack}.#{key}",
238
+ result: result,
239
+ inputs: @node_context.to_h.except(:itself, :stack_ptr),
240
+ calc: @src
241
+ )
242
+ end
243
+
244
+ input[key] = result.nil? ? MissingValue { key } : result
245
+ end
246
+
247
+ def derive_item!(input, key, stack)
248
+ @inputs.each do |path, i, segment_indexes|
249
+ if segment_indexes
250
+ missing_value = nil
251
+ parts = path.parts.dup
252
+ parts_identity = path.identity
253
+ segment_indexes.each do |index|
254
+ part = resolve_input(stack, parts[index].options.first, key)
255
+ break missing_value = part if part.is_a?(MissingValue)
256
+
257
+ part_sym = part.to_s.to_sym
258
+ parts_identity ^= part_sym.object_id << index
259
+ parts[index] = @path_cache[part_sym] ||= PathParser::PathPart.new(part: part_sym)
260
+ end
261
+ path = @path_cache[parts_identity] ||= PathParser::Path.new(parts: parts) unless missing_value
262
+ end
263
+
264
+ result = missing_value || resolve_input(stack, path, key)
265
+ @node_context[i] = result.is_a?(MissingValue) ? nil : result
266
+ end
267
+
268
+ @node_context[:itself] = input
269
+ @node_context[:stack_ptr] = stack
270
+
271
+ conditions_passed = !@conditions || @conditions.all? do |field, allowed_value|
272
+ allowed_value.is_a?(Array) ? allowed_value.include?(@node_context[field]) : allowed_value == @node_context[field]
273
+ end
274
+
275
+ ex = nil
276
+ result = \
277
+ if conditions_passed
278
+ output = begin
279
+ cast(@node_context.process!)
280
+ rescue LazyGraph::AbortError => e
281
+ raise e
282
+ rescue StandardError => e
283
+ ex = e
284
+ MissingValue { "#{key} raised exception: #{e.message}" }
285
+ end
286
+ output = output.dup if @has_properties
287
+ input[key] = output.nil? ? MissingValue { key } : output
288
+ else
289
+ MissingValue { key }
290
+ end
291
+
292
+ if @debug
293
+ stack.log_debug(
294
+ output: :"#{stack}.#{key}",
295
+ result: result,
296
+ inputs: @node_context.to_h.except(:itself, :stack_ptr),
297
+ calc: @src,
298
+ **(@conditions ? { conditions: @conditions } : {}),
299
+ **(
300
+ if ex
301
+ {
302
+ exception: ex,
303
+ backtrace: ex.backtrace.take_while do |line|
304
+ !line.include?('lazy_graph/node.rb')
305
+ end
306
+ }
307
+ else
308
+ {}
309
+ end
310
+ )
311
+ )
312
+ end
313
+ result
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LazyGraph
4
+ module PathParser
5
+ # This module is responsible for parsing complex path strings into structured components.
6
+
7
+ # Path represents a structured component of a complex path string.
8
+ # It provides methods to navigate and manipulate the path parts.
9
+
10
+ Path = Struct.new(:parts, keyword_init: true) do
11
+ def next = @next ||= parts.length <= 1 ? Path::BLANK : Path.new(parts: parts[1..])
12
+ def empty? = @empty ||= parts.empty?
13
+ def segment = @segment ||= parts&.[](0)
14
+ def index? = @index ||= parts.any? && parts.first.index?
15
+ def identity = @identity ||= parts&.each_with_index&.reduce(0) { |acc, (p, i)| acc ^ (p.object_id) << (i * 4) }
16
+ def map(&block) = empty? ? self : Path.new(parts: parts.map(&block))
17
+
18
+ def merge(other)
19
+ (@merged ||= {})[other] ||= \
20
+ if other.empty?
21
+ self
22
+ else
23
+ empty? ? other : Path.new(parts: parts + other.parts)
24
+ end
25
+ end
26
+
27
+ def to_path_str
28
+ @to_path_str ||= create_path_str
29
+ end
30
+
31
+ private
32
+
33
+ def create_path_str
34
+ parts.inject('$') do |path_str, part|
35
+ path_str + \
36
+ if part.is_a?(PathPart)
37
+ ".#{part.part}"
38
+ else
39
+ "[#{part.options.map(&:to_path_str).join(',').delete_prefix('$.')}]"
40
+ end
41
+ end
42
+ end
43
+ end
44
+ Path::BLANK = Path.new(parts: [])
45
+ end
46
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LazyGraph
4
+ module PathParser
5
+ # Represents a group of paths with a list of options, which must all be resolved.
6
+ PathGroup = Struct.new(:options, keyword_init: true) do
7
+ def index?
8
+ @index ||= options.all?(&:index?)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LazyGraph
4
+ module PathParser
5
+ INDEX_REGEXP = /\A-?\d+\z/
6
+ # Represents a single part of a path.
7
+ PathPart = Struct.new(:part, keyword_init: true) do
8
+ def index?
9
+ @index ||= part =~ INDEX_REGEXP
10
+ end
11
+ end
12
+ end
13
+ end