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,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