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,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ # LazyGraph supports a bespoke path structure for querying a LazyGraph.
4
+ # It has some overlap with
5
+ # * JSON path (but supports querying a subset of object properties), and
6
+ # * GraphQL (but it supports direct references to deeply nested properties without preserving structure)/
7
+ #
8
+ # Example query paths and outputs
9
+ #
10
+ # "employees" => {"employees": [{...whole object...}, {...whole object...}]}
11
+ # "employees[id,name]" => {"employees": [{ "id": "3", "name": "John Smith}, { "id": "4", "name": "Jane Smith}]}
12
+ # "employees[0..1] => {"employees": [{...whole object...}, {...whole object...}]}"
13
+ # "employees[0..1] => {"employees": [{...whole object...}, {...whole object...}]}"
14
+ #
15
+ module LazyGraph
16
+ module PathParser
17
+ require_relative 'path_parser/path'
18
+ require_relative 'path_parser/path_group'
19
+ require_relative 'path_parser/path_part'
20
+ # This module is responsible for parsing complex path strings into structured components.
21
+ # Public class method to parse the path string
22
+ def self.parse(path, strip_root = false)
23
+ return Path::BLANK if path.nil? || path.empty?
24
+
25
+ start = strip_root && path.to_s.start_with?('$.') ? 2 : 0
26
+ parse_recursive(path, start)
27
+ end
28
+
29
+ class << self
30
+ # Recursively parses the path starting from index 'start'
31
+ # Returns [Path object, new_index]
32
+ def parse_recursive(path, start)
33
+ parse_structure = {
34
+ parts: [],
35
+ buffer: ''.dup,
36
+ i: start
37
+ }
38
+
39
+ parse_main_loop(path, parse_structure)
40
+ parse_finalize(parse_structure)
41
+
42
+ Path.new(parts: parse_structure[:parts])
43
+ end
44
+
45
+ def parse_main_loop(path, parse_structure)
46
+ while parse_structure[:i] < path.length
47
+ char = path[parse_structure[:i]]
48
+ handle_char(char, path, parse_structure)
49
+ end
50
+ end
51
+
52
+ def handle_char(char, path, structure)
53
+ case char
54
+ when '.'
55
+ handle_dot(path, structure)
56
+ when '['
57
+ handle_open_bracket(path, structure)
58
+ when ','
59
+ handle_comma(structure)
60
+ when ']'
61
+ handle_close_bracket(structure)
62
+ else
63
+ structure[:buffer] += char
64
+ structure[:i] += 1
65
+ end
66
+ end
67
+
68
+ def handle_dot(path, structure)
69
+ # Check if it's part of a range ('..' or '...')
70
+ if path[structure[:i] + 1] == '.'
71
+ handle_range_dot(path, structure)
72
+ else
73
+ handle_single_dot(structure)
74
+ end
75
+ end
76
+
77
+ def handle_range_dot(path, structure)
78
+ if path[structure[:i] + 2] == '.'
79
+ structure[:buffer] += '...'
80
+ structure[:i] += 3
81
+ else
82
+ structure[:buffer] += '..'
83
+ structure[:i] += 2
84
+ end
85
+ end
86
+
87
+ def handle_single_dot(structure)
88
+ unless structure[:buffer].strip.empty?
89
+ parsed = parse_buffer(structure[:buffer].strip)
90
+ append_parsed(parsed, structure[:parts])
91
+ structure[:buffer] = ''.dup
92
+ end
93
+ structure[:i] += 1
94
+ end
95
+
96
+ def append_parsed(parsed, parts)
97
+ if parsed.is_a?(Array)
98
+ parsed.each { |p| parts << p }
99
+ elsif parsed.is_a?(Range)
100
+ parts << PathGroup.new(options: parsed.map { |p| Path.new(parts: [PathPart.new(part: p.to_sym)]) })
101
+ else
102
+ parts << parsed
103
+ end
104
+ end
105
+
106
+ def handle_open_bracket(path, structure)
107
+ unless structure[:buffer].strip.empty?
108
+ paths = structure[:buffer].strip.split('.').map(&:strip)
109
+ paths.each { |p| structure[:parts] << PathPart.new(part: p.to_sym) }
110
+ structure[:buffer] = ''.dup
111
+ end
112
+
113
+ closing_bracket = find_matching_bracket(path, structure[:i])
114
+ raise 'Unbalanced brackets in path.' if closing_bracket == -1
115
+
116
+ inside = path[(structure[:i] + 1)...closing_bracket]
117
+ elements = split_by_comma(inside)
118
+ parsed_elements = elements.map { |el| parse_recursive(el, 0) }
119
+ path_group = PathGroup.new(options: parsed_elements)
120
+ structure[:parts] << path_group
121
+ structure[:i] = closing_bracket + 1
122
+ end
123
+
124
+ def handle_comma(structure)
125
+ unless structure[:buffer].strip.empty?
126
+ parsed = parse_buffer(structure[:buffer].strip)
127
+ append_parsed(parsed, structure[:parts])
128
+ structure[:buffer] = ''.dup
129
+ end
130
+ structure[:i] += 1
131
+ end
132
+
133
+ def handle_close_bracket(structure)
134
+ raise 'Unbalanced closing bracket in path.' if structure[:buffer].strip.empty?
135
+
136
+ parsed = parse_buffer(structure[:buffer].strip)
137
+ append_parsed(parsed, structure[:parts])
138
+ structure[:buffer] = ''.dup
139
+ end
140
+
141
+ def parse_finalize(structure)
142
+ return if structure[:buffer].strip.empty?
143
+
144
+ parsed = parse_buffer(structure[:buffer].strip)
145
+ append_parsed(parsed, structure[:parts])
146
+ end
147
+
148
+ def find_matching_bracket(path, start)
149
+ depth = 1
150
+ i = start + 1
151
+ while i < path.length
152
+ if path[i] == '['
153
+ depth += 1
154
+ elsif path[i] == ']'
155
+ depth -= 1
156
+ return i if depth.zero?
157
+ end
158
+ i += 1
159
+ end
160
+ -1 # No matching closing bracket found
161
+ end
162
+
163
+ def split_by_comma(str)
164
+ elements = []
165
+ buffer = ''.dup
166
+ depth = 0
167
+ str.each_char do |c|
168
+ case c
169
+ when '['
170
+ depth += 1
171
+ buffer << c
172
+ when ']'
173
+ depth -= 1
174
+ buffer << c
175
+ when ','
176
+ if depth.zero?
177
+ elements << buffer.strip
178
+ buffer = ''.dup
179
+ else
180
+ buffer << c
181
+ end
182
+ else
183
+ buffer << c
184
+ end
185
+ end
186
+ elements << buffer.strip unless buffer.strip.empty?
187
+ elements
188
+ end
189
+
190
+ def parse_buffer(buffer)
191
+ if buffer.include?('...')
192
+ parse_range(buffer, '...', true)
193
+ elsif buffer.include?('..')
194
+ parse_range(buffer, '..', false)
195
+ elsif buffer.include?('.')
196
+ paths = buffer.split('.').map(&:strip)
197
+ paths.map { |p| PathPart.new(part: p.to_sym) }
198
+ else
199
+ PathPart.new(part: buffer.to_sym)
200
+ end
201
+ end
202
+
203
+ def parse_range(buffer, delimiter, exclude_end)
204
+ parts = buffer.split(delimiter)
205
+ return buffer unless parts.size == 2
206
+
207
+ Range.new(parts[0].strip, parts[1].strip, exclude_end)
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LazyGraph
4
+ class Server
5
+ ALLOWED_VALUES_VALIDATE = [true, false, nil, 'input', 'context'].to_set.freeze
6
+ ALLOWED_VALUES_DEBUG = [true, false, nil].to_set.freeze
7
+
8
+ def initialize(routes: {})
9
+ @routes = routes.transform_keys(&:to_sym).compare_by_identity
10
+ end
11
+
12
+ def call(env)
13
+ # Rack environment contains request details
14
+ request = Rack::Request.new(env)
15
+
16
+ unless (graph_module = @routes[request.path.to_sym])
17
+ return not_found!(request.path)
18
+ end
19
+
20
+ return success!(request, graph_module.usage) if request.get?
21
+
22
+ if request.post?
23
+ body = JSON.parse(request.body.read)
24
+ context, modules, validate, debug, query = body.values_at('context', 'modules', 'validate', 'debug', 'query')
25
+ unless context.is_a?(Hash) && !context.empty?
26
+ return not_acceptable!(request, "Invalid 'context' Parameter", 'Should be a non-empty object.')
27
+ end
28
+
29
+ unless modules.is_a?(Hash) && !modules.empty?
30
+ return not_acceptable!(request, "Invalid 'modules' Parameter", 'Should be a non-empty object.')
31
+ end
32
+
33
+ unless ALLOWED_VALUES_VALIDATE.include?(validate)
34
+ return not_acceptable!(
35
+ request, "Invalid 'validate' Parameter", "Should be nil, bool, or one of 'input', 'context'"
36
+ )
37
+ end
38
+
39
+ unless ALLOWED_VALUES_DEBUG.include?(debug)
40
+ return not_acceptable!(request, "Invalid 'debug' Parameter", 'Should be nil or bool')
41
+ end
42
+
43
+ unless query.nil? || query.is_a?(String) || (query.is_a?(Array) && query.all? do |q|
44
+ q.is_a?(String)
45
+ end)
46
+ return not_acceptable!(request, "Invalid 'query' Parameter", 'Should be nil, array or string array')
47
+ end
48
+
49
+ begin
50
+ result = graph_module.eval!(
51
+ modules: modules, context: context,
52
+ validate: validate, debug: debug,
53
+ query: query
54
+ )
55
+ return not_acceptable!(request, result[:message], result[:detail]) if result[:type] == :error
56
+
57
+ return success!(request, result)
58
+ rescue StandardError => e
59
+ LazyGraph.logger.error(e.message)
60
+ LazyGraph.logger.error(e.backtrace.join("\n"))
61
+ return error!(request, 500, 'Internal Server Error', e.message)
62
+ end
63
+ end
64
+
65
+ not_found!("#{request.request_method} #{request.path}")
66
+ end
67
+
68
+ def not_acceptable!(request, message, details = '')
69
+ error!(request, 406, message, details)
70
+ end
71
+
72
+ def not_found!(request, details = '')
73
+ error!(request, 404, 'Not Found', details)
74
+ end
75
+
76
+ def success!(request, result, status: 200)
77
+ LazyGraph.logger.info("#{request.request_method}: #{request.path} => #{status}")
78
+ [status, { 'Content-Type' => 'text/json' }, [result.to_json]]
79
+ end
80
+
81
+ def error!(request, status, message, details = '')
82
+ LazyGraph.logger.info("#{request.request_method}: #{request.path} => #{status}")
83
+ [status, { 'Content-Type' => 'text/json' }, [{ 'error': message, 'details': details }.to_json]]
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LazyGraph
4
+ # Module to provide lazy graph functionalities using stack pointers.
5
+ POINTER_POOL = []
6
+
7
+ StackPointer = Struct.new(:parent, :frame, :depth, :key, :root) do
8
+ def push(frame, key)
9
+ (POINTER_POOL.pop || StackPointer.new).tap do |pointer|
10
+ pointer.parent = self
11
+ pointer.frame = frame
12
+ pointer.key = key
13
+ pointer.depth = depth + 1
14
+ pointer.root = root || self
15
+ end
16
+ end
17
+
18
+ def recycle!
19
+ POINTER_POOL.push(self)
20
+ nil
21
+ end
22
+
23
+ def ptr_at(index)
24
+ return self if depth == index
25
+
26
+ parent&.ptr_at(index)
27
+ end
28
+
29
+ def method_missing(name, *args, &block)
30
+ if frame.respond_to?(name)
31
+ frame.send(name, *args, &block)
32
+ elsif parent
33
+ parent.send(name, *args, &block)
34
+ else
35
+ super
36
+ end
37
+ end
38
+
39
+ def log_debug(**log_item)
40
+ root.frame[:DEBUG] = [] if !root.frame[:DEBUG] || root.frame[:DEBUG].is_a?(MissingValue)
41
+ root.frame[:DEBUG] << { **log_item, location: to_s }
42
+ end
43
+
44
+ def respond_to_missing?(name, include_private = false)
45
+ frame.respond_to?(name, include_private) || parent.respond_to?(name, include_private)
46
+ end
47
+
48
+ def to_s
49
+ if parent
50
+ "#{parent}#{key.to_s =~ /\d+/ ? "[#{key}]" : ".#{key}"}"
51
+ else
52
+ key.to_s
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LazyGraph
4
+ VERSION = '0.1.0'
5
+ end
data/lib/lazy_graph.rb ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ # LazyGraph is a library which allows you to define a strictly typed hierarchical Graph structure.
6
+ # Within this graph you can annotate certain nodes as derived nodes, which compute their outputs based
7
+ # on input dependencies on other nodes in the graph structure (can be relative and absolute references).
8
+ # Dependencies can be nested any level deep (but cannot be circular).
9
+ #
10
+ # You can then provide a subset of the graph as input context, and extract calculated outputs from the same graph
11
+ # which will be lazily computed (only derived rules actually needed for the queried output is computed).
12
+ #
13
+ module LazyGraph
14
+ class AbortError < StandardError; end
15
+ class << self
16
+ attr_accessor :logger
17
+ end
18
+
19
+ self.logger = Logger.new($stdout)
20
+ end
21
+
22
+ require_relative 'lazy_graph/context'
23
+ require_relative 'lazy_graph/missing_value'
24
+ require_relative 'lazy_graph/node'
25
+ require_relative 'lazy_graph/graph'
26
+ require_relative 'lazy_graph/version'
27
+ require_relative 'lazy_graph/path_parser'
28
+ require_relative 'lazy_graph/hash_utils'
29
+ require_relative 'lazy_graph/builder'
30
+ require_relative 'lazy_graph/builder_group'
31
+ require_relative 'lazy_graph/stack_pointer'
32
+ require_relative 'lazy_graph/server'
data/logo.png ADDED
Binary file
metadata ADDED
@@ -0,0 +1,200 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lazy_graph
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Wouter Coppieters
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-12-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: json-schema
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: logger
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: prism
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rack
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: ostruct
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: fiddle
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: debug
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '1.0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '1.0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: rdoc
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ description: JSON Driven, Stateless Rules Engine for JIT and efficient evaluation
140
+ of complex rules and computation graphs.
141
+ email:
142
+ - wc@pico.net.nz
143
+ executables: []
144
+ extensions: []
145
+ extra_rdoc_files: []
146
+ files:
147
+ - ".rubocop.yml"
148
+ - CODE_OF_CONDUCT.md
149
+ - LICENSE.txt
150
+ - README.md
151
+ - Rakefile
152
+ - examples/performance_tests.rb
153
+ - lib/lazy_graph.rb
154
+ - lib/lazy_graph/builder.rb
155
+ - lib/lazy_graph/builder/dsl.rb
156
+ - lib/lazy_graph/builder_group.rb
157
+ - lib/lazy_graph/context.rb
158
+ - lib/lazy_graph/graph.rb
159
+ - lib/lazy_graph/hash_utils.rb
160
+ - lib/lazy_graph/lazy-graph.json
161
+ - lib/lazy_graph/missing_value.rb
162
+ - lib/lazy_graph/node.rb
163
+ - lib/lazy_graph/node/array_node.rb
164
+ - lib/lazy_graph/node/derived_rules.rb
165
+ - lib/lazy_graph/node/node_properties.rb
166
+ - lib/lazy_graph/node/object_node.rb
167
+ - lib/lazy_graph/path_parser.rb
168
+ - lib/lazy_graph/path_parser/path.rb
169
+ - lib/lazy_graph/path_parser/path_group.rb
170
+ - lib/lazy_graph/path_parser/path_part.rb
171
+ - lib/lazy_graph/server.rb
172
+ - lib/lazy_graph/stack_pointer.rb
173
+ - lib/lazy_graph/version.rb
174
+ - logo.png
175
+ homepage: https://github.com/wouterken/lazy_graph
176
+ licenses:
177
+ - MIT
178
+ metadata:
179
+ homepage_uri: https://github.com/wouterken/lazy_graph
180
+ source_code_uri: https://github.com/wouterken/lazy_graph
181
+ post_install_message:
182
+ rdoc_options: []
183
+ require_paths:
184
+ - lib
185
+ required_ruby_version: !ruby/object:Gem::Requirement
186
+ requirements:
187
+ - - ">="
188
+ - !ruby/object:Gem::Version
189
+ version: 3.0.0
190
+ required_rubygems_version: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ">="
193
+ - !ruby/object:Gem::Version
194
+ version: '0'
195
+ requirements: []
196
+ rubygems_version: 3.5.22
197
+ signing_key:
198
+ specification_version: 4
199
+ summary: JSON Driven, Stateless Rules Engine
200
+ test_files: []