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,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: []