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.
- checksums.yaml +7 -0
- data/.rubocop.yml +81 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +562 -0
- data/Rakefile +4 -0
- data/examples/performance_tests.rb +117 -0
- data/lib/lazy_graph/builder/dsl.rb +315 -0
- data/lib/lazy_graph/builder.rb +138 -0
- data/lib/lazy_graph/builder_group.rb +57 -0
- data/lib/lazy_graph/context.rb +60 -0
- data/lib/lazy_graph/graph.rb +73 -0
- data/lib/lazy_graph/hash_utils.rb +87 -0
- data/lib/lazy_graph/lazy-graph.json +148 -0
- data/lib/lazy_graph/missing_value.rb +26 -0
- data/lib/lazy_graph/node/array_node.rb +67 -0
- data/lib/lazy_graph/node/derived_rules.rb +196 -0
- data/lib/lazy_graph/node/node_properties.rb +64 -0
- data/lib/lazy_graph/node/object_node.rb +113 -0
- data/lib/lazy_graph/node.rb +316 -0
- data/lib/lazy_graph/path_parser/path.rb +46 -0
- data/lib/lazy_graph/path_parser/path_group.rb +12 -0
- data/lib/lazy_graph/path_parser/path_part.rb +13 -0
- data/lib/lazy_graph/path_parser.rb +211 -0
- data/lib/lazy_graph/server.rb +86 -0
- data/lib/lazy_graph/stack_pointer.rb +56 -0
- data/lib/lazy_graph/version.rb +5 -0
- data/lib/lazy_graph.rb +32 -0
- data/logo.png +0 -0
- metadata +200 -0
@@ -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
|