lazy_graph 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
data/README.md
ADDED
@@ -0,0 +1,562 @@
|
|
1
|
+
<table>
|
2
|
+
<tr>
|
3
|
+
<td><img src="./logo.png" alt="logo" width="150"></td>
|
4
|
+
<td>
|
5
|
+
<h1 align="center">LazyGraph</h1>
|
6
|
+
<p align="center">
|
7
|
+
<a href="https://rubygems.org/gems/lazy_graph">
|
8
|
+
<img alt="GEM Version" src="https://img.shields.io/gem/v/lazy_graph?color=168AFE&include_prereleases&logo=ruby&logoColor=FE1616">
|
9
|
+
</a><br>
|
10
|
+
<a href="https://rubygems.org/gems/lazy_graph">
|
11
|
+
<img alt="GEM Downloads" src="https://img.shields.io/gem/dt/lazy_graph?color=168AFE&logo=ruby&logoColor=FE1616">
|
12
|
+
</a>
|
13
|
+
</p>
|
14
|
+
</td>
|
15
|
+
</tr>
|
16
|
+
</table>
|
17
|
+
|
18
|
+
# LazyGraph
|
19
|
+
|
20
|
+
<details>
|
21
|
+
<summary><strong>Table of Contents</strong></summary>
|
22
|
+
|
23
|
+
1. [Introduction](#introduction)
|
24
|
+
2. [Features](#features)
|
25
|
+
3. [Installation](#installation)
|
26
|
+
4. [Getting Started](#getting-started)
|
27
|
+
- [Defining a Schema](#defining-a-schema)
|
28
|
+
- [Providing Input](#providing-input)
|
29
|
+
- [Querying the Graph](#querying-the-graph)
|
30
|
+
5. [Advanced Usage](#advanced-usage)
|
31
|
+
- [Builder DSL](#builder-dsl)
|
32
|
+
- [Derived Properties and Dependency Resolution](#derived-properties-and-dependency-resolution)
|
33
|
+
- [Debug Mode & Recursive Dependency Detection](#debug-mode--recursive-dependency-detection)
|
34
|
+
- [Advanced Path Syntax](#advanced-path-syntax)
|
35
|
+
- [LazyGraph Server](#lazygraph-server)
|
36
|
+
6. [API Reference and Helpers](#api-reference-and-helpers)
|
37
|
+
7. [Contributing](#contributing)
|
38
|
+
8. [License](#license)
|
39
|
+
|
40
|
+
</details>
|
41
|
+
|
42
|
+
## Introduction
|
43
|
+
|
44
|
+
**LazyGraph** is an ideal tool for building efficient, complex rules engines and optionally exposing these as stateless HTTP services.
|
45
|
+
Unlike traditional rules engines which utilize facts to manipulate stateful memory,
|
46
|
+
LazyGraph encodes rules into a stateless and declarative knowledge graph.
|
47
|
+
|
48
|
+
A LazyGraph is a similar to a limited [**JSON Schema**](https://json-schema.org/), which allows a single structure
|
49
|
+
to define both the shape of your data domain, and all of the rules that should run within it.
|
50
|
+
|
51
|
+
To use it, you can:
|
52
|
+
|
53
|
+
1. Define a **JSON Schema-like** structure describing how data in your domain is structured, containing required, optional and derived properties.
|
54
|
+
- **Rules**: All properties that can be computed are marked with a `rule` property, which defines how to compute the value in plain old Ruby code.
|
55
|
+
The rule can reference other properties in the schema, allowing for complex, nested, and recursive computations.
|
56
|
+
|
57
|
+
2. Feed an **input document** (JSON) that partially fills out the schema with actual data.
|
58
|
+
|
59
|
+
3. Query the `LazyGraph` for computed outputs.
|
60
|
+
|
61
|
+
`LazyGraph` will:
|
62
|
+
* Validate that the input conforms to the schema’s structure and then
|
63
|
+
* Allow you to intelligently query the graph for information, lazily triggering the evaluation of rules (resolving dependencies in a single pass and caching results).
|
64
|
+
|
65
|
+
The final output is the queried slice of your JSON schema, filled with computed outputs.
|
66
|
+
|
67
|
+
The `LazyGraph` library also includes:
|
68
|
+
|
69
|
+
- A **Builder DSL** to dynamically compose targeted JSON Schemas in Ruby.
|
70
|
+
- An optional **HTTP server** to serve dynamic computations from a simple endpoint
|
71
|
+
(serve any collection of LazyGraph builders as a stateless rules microservice, no additional code required)!.
|
72
|
+
|
73
|
+
## Features
|
74
|
+
|
75
|
+
- **Lazy Evaluation & Caching**
|
76
|
+
Derived fields are efficiently calculated, at-most-once, on-demand.
|
77
|
+
|
78
|
+
- **Recursive Dependency Check**
|
79
|
+
Automatically detects cycles in your derived fields and logs warnings/debug info if recursion is found.
|
80
|
+
|
81
|
+
- **Debug Trace**
|
82
|
+
The order in which recursive calculations are processed is not always obvious.
|
83
|
+
LazyGraph is able to provide a detailed trace of exactly, when and how each value was computed.
|
84
|
+
|
85
|
+
- **Rich Querying Syntax**
|
86
|
+
Extract exactly what you need from your model with an intuitive path-like syntax. Support for nested properties, arrays, indexing, ranges, wildcards, and more.
|
87
|
+
|
88
|
+
- **Composable Builder DSL**
|
89
|
+
Support dynamic creation of large, composable schemas in Ruby with a simplified syntax.
|
90
|
+
|
91
|
+
- **Optional HTTP Server**
|
92
|
+
Spin up an efficient server that exposes several dynamic lazy-graphs as endpoints, allowing you to:
|
93
|
+
* Select a dynamic schema
|
94
|
+
* Feed in inputs and a query
|
95
|
+
* Receive the computed JSON output
|
96
|
+
all at lightning speed.
|
97
|
+
|
98
|
+
## Installation
|
99
|
+
|
100
|
+
Add this line to your application’s Gemfile:
|
101
|
+
|
102
|
+
```ruby
|
103
|
+
gem 'lazy_graph'
|
104
|
+
```
|
105
|
+
|
106
|
+
And then execute:
|
107
|
+
|
108
|
+
```bash
|
109
|
+
bundle
|
110
|
+
```
|
111
|
+
|
112
|
+
Or install it yourself:
|
113
|
+
|
114
|
+
```bash
|
115
|
+
gem install lazy_graph
|
116
|
+
```
|
117
|
+
|
118
|
+
## Getting Started
|
119
|
+
|
120
|
+
In this section, we’ll explore how to set up a minimal LazyGraph use case:
|
121
|
+
|
122
|
+
1. **Define** a JSON Schema (with LazyGraph’s extended properties and types).
|
123
|
+
2. **Provide** an input document.
|
124
|
+
3. **Query** the graph to retrieve computed data.
|
125
|
+
|
126
|
+
### Defining a Schema
|
127
|
+
|
128
|
+
A LazyGraph schema looks like a standard JSON Schema, but with a few key differences:
|
129
|
+
* The `rule` property, which defines how to compute derived fields.
|
130
|
+
- `rule:`
|
131
|
+
Defines a that computes this property’s value, if not given, referencing other fields in the graph.
|
132
|
+
* The schema also does not support computation across node types of `oneOf`, `allOf`, `anyOf`, `not`, or references
|
133
|
+
(read examples for alternative mechanisms for achieving similar flexibility in your live schema)
|
134
|
+
|
135
|
+
Any field (even object and array fields) can have a `rule` property.
|
136
|
+
|
137
|
+
Values at this property are lazily computed by the rule, if not present in the input.
|
138
|
+
However, if the value is present in the input, the rule is not triggered.
|
139
|
+
This makes a LazyGraph highly flexible.
|
140
|
+
(you can override *absolutely any* step in a complex computation graph, if you choose).
|
141
|
+
|
142
|
+
Here’s a simple **shopping cart** example:
|
143
|
+
|
144
|
+
```ruby
|
145
|
+
require 'lazy_graph'
|
146
|
+
|
147
|
+
cart_schema = {
|
148
|
+
type: 'object',
|
149
|
+
properties: {
|
150
|
+
cart: {
|
151
|
+
type: 'object',
|
152
|
+
properties: {
|
153
|
+
items: {
|
154
|
+
type: 'array',
|
155
|
+
items: {
|
156
|
+
type: 'object',
|
157
|
+
properties: {
|
158
|
+
name: {
|
159
|
+
type: 'string'
|
160
|
+
},
|
161
|
+
price: {
|
162
|
+
type: 'number',
|
163
|
+
default: 1.0
|
164
|
+
},
|
165
|
+
quantity: {
|
166
|
+
type: 'number',
|
167
|
+
default: 1
|
168
|
+
},
|
169
|
+
total: {
|
170
|
+
type: 'number',
|
171
|
+
rule: '${price} * ${quantity}'
|
172
|
+
}
|
173
|
+
}
|
174
|
+
},
|
175
|
+
required: ['name']
|
176
|
+
},
|
177
|
+
cart_total: {
|
178
|
+
type: 'number',
|
179
|
+
rule: {
|
180
|
+
inputs: {item_totals: 'items.total'},
|
181
|
+
calc: 'item_totals.sum'
|
182
|
+
}
|
183
|
+
}
|
184
|
+
}
|
185
|
+
}
|
186
|
+
}
|
187
|
+
}
|
188
|
+
|
189
|
+
cart_graph = cart_schema.to_lazy_graph
|
190
|
+
```
|
191
|
+
|
192
|
+
### Providing Input
|
193
|
+
|
194
|
+
Next, we create an **input document** that partially fills out the schema. For instance:
|
195
|
+
|
196
|
+
```ruby
|
197
|
+
input_data = {
|
198
|
+
cart: {
|
199
|
+
items: [
|
200
|
+
{ name: 'Widget', price: 5.0, quantity: 2 },
|
201
|
+
{ name: 'Gadget' }
|
202
|
+
]
|
203
|
+
}
|
204
|
+
}
|
205
|
+
```
|
206
|
+
|
207
|
+
- `Widget` is fully specified with `price=5.0` and `quantity=2`.
|
208
|
+
- `Gadget` is missing `price` and `quantity`, so it will use defaults (`1.0` for price and `1` for quantity).
|
209
|
+
|
210
|
+
### Querying the Graph
|
211
|
+
|
212
|
+
To compute derived fields and extract results, we can do:
|
213
|
+
|
214
|
+
```ruby
|
215
|
+
# Create the graph and run the query:
|
216
|
+
graph_context = cart_graph.context(input_data)
|
217
|
+
|
218
|
+
# If we query '' (empty string), we get the entire graph with computed values:
|
219
|
+
whole_output = graph_context['']
|
220
|
+
puts JSON.pretty_generate whole_output
|
221
|
+
# {
|
222
|
+
# "output": {
|
223
|
+
# "cart": {
|
224
|
+
# "items": [
|
225
|
+
# {
|
226
|
+
# "name": "Widget",
|
227
|
+
# "price": 5.0,
|
228
|
+
# "quantity": 2,
|
229
|
+
# "total": 10.0
|
230
|
+
# },
|
231
|
+
# {
|
232
|
+
# "name": "Gadget",
|
233
|
+
# "price": 1.0,
|
234
|
+
# "quantity": 1,
|
235
|
+
# "total": 1.0
|
236
|
+
# }
|
237
|
+
# ],
|
238
|
+
# "cart_total": 11.0
|
239
|
+
# }
|
240
|
+
# },
|
241
|
+
# "debug_trace": null
|
242
|
+
# }
|
243
|
+
|
244
|
+
# Query a specific path, e.g. "cart.items[0].total"
|
245
|
+
graph_context["cart.items[0].total"]
|
246
|
+
# => {output: 10.0, debug_trace: nil}
|
247
|
+
|
248
|
+
# e.g. "cart.items.total"
|
249
|
+
graph_context["cart.items.total"]
|
250
|
+
# => {output: [10.0, 1.0], debug_trace: nil}
|
251
|
+
|
252
|
+
|
253
|
+
# e.g. "cart.items[name, total]"
|
254
|
+
all_item_name_and_totals = graph_context["cart.items[name, total]"]
|
255
|
+
# => {output: [{name: "Widget", total: 10.0}, {name: "Gadget", total: 1.0}], debug_trace: nil}
|
256
|
+
```
|
257
|
+
|
258
|
+
LazyGraph **recursively computes** any derived fields, referenced in the query.
|
259
|
+
If you query only a subset of the graph, only the necessary computations are triggered, allowing for very fast responses,
|
260
|
+
even on graphs with millions of interdependent nodes.
|
261
|
+
|
262
|
+
## Advanced Usage
|
263
|
+
|
264
|
+
### Builder DSL
|
265
|
+
|
266
|
+
Rather than manually writing JSON schemas, LazyGraph also offers a **Ruby DSL** for building them.
|
267
|
+
This is useful if your schema needs to be dynamic (variable based on inputs), has repeated patterns, or is built across multiple modules,
|
268
|
+
which you wish to allow a user to combine in different ways.
|
269
|
+
|
270
|
+
```ruby
|
271
|
+
module ShoppingCart
|
272
|
+
class CartBuilder < LazyGraph::Builder
|
273
|
+
rules_module :cart_base do
|
274
|
+
object :cart do
|
275
|
+
array :items, required: true do
|
276
|
+
items do
|
277
|
+
string :name, required: true
|
278
|
+
number :price, default: 1.0
|
279
|
+
number :quantity, default: 1
|
280
|
+
number :total, rule: '${price} * ${quantity}'
|
281
|
+
end
|
282
|
+
end
|
283
|
+
|
284
|
+
number :cart_total, rule: '${items.total}.sum'
|
285
|
+
end
|
286
|
+
end
|
287
|
+
end
|
288
|
+
end
|
289
|
+
|
290
|
+
# Then we can build a cart schema, feed input, and query:
|
291
|
+
cart_schema = ShoppingCart::CartBuilder.cart_base
|
292
|
+
context = cart_schema.context({
|
293
|
+
cart: {
|
294
|
+
items: [
|
295
|
+
{ name: 'Widget', price: 10.0, quantity: 2 },
|
296
|
+
{ name: 'Thingamajig' }
|
297
|
+
]
|
298
|
+
}
|
299
|
+
})
|
300
|
+
|
301
|
+
puts context['cart.cart_total'] # => 21.0
|
302
|
+
```
|
303
|
+
|
304
|
+
### Rules and Dependency Resolution
|
305
|
+
|
306
|
+
Rules let you define logic for computing new values from existing ones. LazyGraph:
|
307
|
+
|
308
|
+
1. Gathers each property’s **dependencies** (in the example, `total` depends on `price` and `quantity`).
|
309
|
+
2. Computes them **on-demand**, in a topological order, ensuring that any fields they rely on are already resolved.
|
310
|
+
3. Caches the results so subsequent references don’t trigger re-computation.
|
311
|
+
|
312
|
+
This graph-based approach means you can nest derived fields deeply without worrying about explicit ordering. If a derived field depends on another derived field, LazyGraph naturally handles the chain.
|
313
|
+
|
314
|
+
#### Derived Rules DSL
|
315
|
+
There are several different ways to define Rules in a LazyGraph.
|
316
|
+
The most portable way (using plain old JSON) is to define a rule as a string that references other properties in the schema using
|
317
|
+
${} placeholder syntax.
|
318
|
+
E.g.
|
319
|
+
|
320
|
+
```ruby
|
321
|
+
rule: '${price} * ${quantity}'
|
322
|
+
```
|
323
|
+
|
324
|
+
You can define the inputs separately from the calculation, which can be useful for more complex rules:
|
325
|
+
|
326
|
+
As an array if you do not need to map paths
|
327
|
+
```ruby
|
328
|
+
rule: {
|
329
|
+
inputs: %[quantity],
|
330
|
+
calc: 'quantity.sum'
|
331
|
+
}
|
332
|
+
```
|
333
|
+
|
334
|
+
As a hash of input names to resolution paths
|
335
|
+
```ruby
|
336
|
+
rule: {
|
337
|
+
inputs: {item_totals: 'items.total'},
|
338
|
+
calc: 'item_totals.sum'
|
339
|
+
}
|
340
|
+
```
|
341
|
+
|
342
|
+
The most expressive way to define rules is to use a Ruby block, proc or lambda.
|
343
|
+
The arguments to the block are automatically resolved as inputs into the block.
|
344
|
+
You can use keyword arguments to map paths to the input names.
|
345
|
+
|
346
|
+
*Note:* Block rules cannot be define from inside a REPL, as LazyGraph needs to read these from disk
|
347
|
+
to be able to include the source code inside debug outputs.
|
348
|
+
```ruby
|
349
|
+
# The input price is resolved to the value at path: 'price'
|
350
|
+
# The input quantity is resolved to the value at path: 'quantity'
|
351
|
+
# The input store_details is resolved to the value at path: 'store.details'
|
352
|
+
rule: ->(price, quantity, store_details: store.details) {
|
353
|
+
price * quantity * store_details.discount
|
354
|
+
}
|
355
|
+
```
|
356
|
+
|
357
|
+
Resolutions are relative to the current node.
|
358
|
+
I.e. it will look for the path in the current node, and then in the parent node, and so on, until it finds a match.
|
359
|
+
If you wish to make an absolute reference, you can prefix the path with a `$` to indicate that it should start at the root of the schema.
|
360
|
+
Note, inside lambda rules, absolute references begin with a _ instead.
|
361
|
+
|
362
|
+
E.g.
|
363
|
+
```ruby
|
364
|
+
rule: ->(store_details: _.store.details) {
|
365
|
+
store_details.discount * price * quantity
|
366
|
+
}
|
367
|
+
```
|
368
|
+
|
369
|
+
Inside rules, the scope is set such that you are able to freely access any other node in the computation graph.
|
370
|
+
Just type a variable by name and it will automatically be recursively resolved to the correct value.
|
371
|
+
*However* it is essential that you explicitly define all inputs to the rule to ensure resolution is correct,
|
372
|
+
as LazyGraph will not automatically resolve any variables that are dynamically accessed.
|
373
|
+
This is advanced functionality, and should be used with caution. In general, it is best to define all input dependencies explicitly.
|
374
|
+
|
375
|
+
### Debug Mode & Recursive Dependency Detection
|
376
|
+
|
377
|
+
If you pass `debug: true`, the output will contain an "output_trace" array,
|
378
|
+
containing a detailed, ordered log of how each derived field was computed (inputs, calc and outputs).
|
379
|
+
|
380
|
+
```ruby
|
381
|
+
context = cart_schema.to_graph_ctx({
|
382
|
+
cart: {
|
383
|
+
items: [
|
384
|
+
{ name: 'Widget', price: 10.0, quantity: 2 },
|
385
|
+
{ name: 'Thingamajig' }
|
386
|
+
]
|
387
|
+
}
|
388
|
+
}, debug: true)
|
389
|
+
result = context['cart.cart_total'] # triggers computations
|
390
|
+
|
391
|
+
puts result[:debug_trace]
|
392
|
+
# =>
|
393
|
+
# [{output: :"$.cart.items[0].total",
|
394
|
+
# result: 20.0,
|
395
|
+
# inputs: {aa22c03893cb0018e: 10.0, a2fb52b44379212e4: 2},
|
396
|
+
# calc: ["aa22c03893cb0018e * a2fb52b44379212e4"],
|
397
|
+
# location: "$.cart.items[0]"},
|
398
|
+
# {output: :"$.cart.items[1].total",
|
399
|
+
# result: 1.0,
|
400
|
+
# inputs: {aa22c03893cb0018e: 1.0, a2fb52b44379212e4: 1},
|
401
|
+
# calc: ["aa22c03893cb0018e * a2fb52b44379212e4"],
|
402
|
+
# location: "$.cart.items[1]"},
|
403
|
+
# {output: :"$.cart.cart_total", result: 21.0, inputs: {item_totals: [20.0, 1.0]}, calc: ["item_totals.sum"], location: "$.cart"}]
|
404
|
+
```
|
405
|
+
|
406
|
+
In cases where you accidentally create **circular dependencies**, LazyGraph will log warnings to the debug logs, and detect and break infinite loops
|
407
|
+
in the dependency resolution, ensuring that the remainder of the graph is still computed correctly.
|
408
|
+
|
409
|
+
### Advanced Path Syntax
|
410
|
+
|
411
|
+
LazyGraph’s query engine supports a flexible path notation:
|
412
|
+
|
413
|
+
- **Nested properties**: `"cart.items[0].total"`, `"cart.items[*].name"`, etc.
|
414
|
+
- **Object bracket expansions**: If a property is enclosed in `[keyName]`, the key and value are kept together when extracting partial JSON. For example, `"cart[items]"` yields `{"cart"=>{"items"=>[...]}}`.
|
415
|
+
- **Array indexing**: `"items[0]"`, `"items[0..2]"`, `"items[*]"` (all items), or `"items[0, 2, 4]"` for picking multiple indexes.
|
416
|
+
- **Ranged queries**: `"items[1..3]"` returns a slice from index 1 to 3 inclusive.
|
417
|
+
- **Root Queries**: 'Useful inside rules for depdencies that are not relative to the current node. E.g. `"$.cart.items[0].total"` (or `_.cart.items[0].total` inside proc rules).
|
418
|
+
|
419
|
+
### LazyGraph Server
|
420
|
+
|
421
|
+
For situations where you want to serve rules over HTTP:
|
422
|
+
|
423
|
+
1. **Define** your schema(s) with the DSL or standard JSON approach.
|
424
|
+
2. **Implement** a small server that:
|
425
|
+
- Instantiates the schema.
|
426
|
+
- Takes JSON input from a request.
|
427
|
+
- Runs a query (passed via a query parameter or request body).
|
428
|
+
- Returns the computed JSON object.
|
429
|
+
|
430
|
+
A minimal example might look like:
|
431
|
+
|
432
|
+
[TODO] Verify example.
|
433
|
+
```ruby
|
434
|
+
require 'lazy_graph'
|
435
|
+
require 'lazy_graph/server'
|
436
|
+
|
437
|
+
module CartAPI
|
438
|
+
VERSION = '0.1.0'
|
439
|
+
|
440
|
+
# Add all classes that you want to expose in the API, as constants to the builder group module.
|
441
|
+
# These will turn into downcased, nested endpoints.
|
442
|
+
# E.g.
|
443
|
+
# /cart/v1
|
444
|
+
# - GET: Get module info
|
445
|
+
# - POST: Run a query
|
446
|
+
# Inputs: A JSON object with the following keys:
|
447
|
+
#
|
448
|
+
# - modules: { cart: {} } # The modules to merged into the combined schema,
|
449
|
+
# # you can pass multiple modules here to merge them into a single schema.
|
450
|
+
# The keys inside each module object, are passed as arguments to the rules_module dsl method
|
451
|
+
# to allow you to dynamically adjust your output schema based on API inputs.
|
452
|
+
# - query: "cart.cart_total" # The query to run. Can be a string, an array of strings or empty (in which case entire graph is returned)
|
453
|
+
# - context: { cart: { items: [ { name: "Widget", price: 2.5, quantity: 4 } ] } } # The input data to the schema
|
454
|
+
module Cart
|
455
|
+
class V1 < LazyGraph::Builder
|
456
|
+
rules_module :cart_base do |foo:, bar:|
|
457
|
+
object :cart do
|
458
|
+
array :items, required: true do
|
459
|
+
items do
|
460
|
+
string :name, required: true
|
461
|
+
number :price, default: 1.0
|
462
|
+
number :quantity, default: 1
|
463
|
+
number :total, rule: '${price} * ${quantity}'
|
464
|
+
end
|
465
|
+
end
|
466
|
+
|
467
|
+
number :cart_total, rule: '${items.total}.sum'
|
468
|
+
end
|
469
|
+
end
|
470
|
+
end
|
471
|
+
end
|
472
|
+
|
473
|
+
# Bootstrap our builder group.
|
474
|
+
include LazyGraph::BuilderGroup.bootstrap!(reload_paths: File.join(__dir__, 'cart_api/**/*.rb'))
|
475
|
+
end
|
476
|
+
```
|
477
|
+
|
478
|
+
`config.ru`
|
479
|
+
```ruby
|
480
|
+
require_relative 'lib/paybun'
|
481
|
+
|
482
|
+
run CartAPI.server
|
483
|
+
```
|
484
|
+
|
485
|
+
Then send requests like:
|
486
|
+
|
487
|
+
```bash
|
488
|
+
curl -X POST http://localhost:9292/cart/v1 \
|
489
|
+
-H 'Content-Type: application/json' \
|
490
|
+
-d '{
|
491
|
+
"modules": { "cart" : {} },
|
492
|
+
"query": "cart.cart_total",
|
493
|
+
"context": {
|
494
|
+
"cart": {
|
495
|
+
"items": [
|
496
|
+
{ "name": "Widget", "price": 2.5, "quantity": 4 }
|
497
|
+
]
|
498
|
+
}
|
499
|
+
}
|
500
|
+
}'
|
501
|
+
```
|
502
|
+
|
503
|
+
Response:
|
504
|
+
|
505
|
+
```json
|
506
|
+
{
|
507
|
+
"type": "success",
|
508
|
+
"result": {
|
509
|
+
"output": 10.0
|
510
|
+
}
|
511
|
+
}
|
512
|
+
```
|
513
|
+
|
514
|
+
## Where LazyGraph Fits
|
515
|
+
|
516
|
+
If you’re coming from a background in any of the following technologies, you might find several familiar ideas reflected in LazyGraph:
|
517
|
+
|
518
|
+
* *Excel*: Formulas in spreadsheets operate similarly to derived fields—they reference other cells (properties) and recalculate automatically when dependencies change.
|
519
|
+
LazyGraph extends that principle to hierarchical or deeply nested data, making it easier to manage complex relationships than typical flat spreadsheet cells.
|
520
|
+
* *Terraform*: Terraform is all about declarative configuration and automatic dependency resolution for infrastructure.
|
521
|
+
LazyGraph brings a similar ethos to your application data—you declare how fields depend on one another, and the engine takes care of resolving values in the correct order, even across multiple modules or partial inputs.
|
522
|
+
* *JSON Schema*: At its core, LazyGraph consumes standard JSON Schema features (type checks, required fields, etc.) but introduces extended semantics (like derived or default).
|
523
|
+
If you’re already comfortable with JSON Schema, you’ll find the basic structure familiar—just expanded to support making your data dynamic and reactive.
|
524
|
+
* *Rules Engines* / Expert Systems: Traditional rule engines (like Drools or other Rete-based systems) let you define sets of conditional statements that trigger when facts change.
|
525
|
+
In many scenarios, LazyGraph can serve as a lighter-weight alternative: each “rule” (i.e., derived property) is defined right where you need it in the schema, pulling its dependencies from anywhere else in the data. You get transparent, on-demand evaluation without the overhead of an external rules engine. Plus, debug traces show exactly how each value was computed, so there’s no confusion about which rules fired or in which order.
|
526
|
+
* *JSON* Path, GraphQL, jq: These tools are great for querying JSON data, but they don't handle automatic lazy dependency resolution.
|
527
|
+
LazyGraph’s querying syntax is designed to be expressive and powerful, allowing you to extract the data you need (triggering only the required calculations) from a complex graph of computed values.
|
528
|
+
|
529
|
+
### Typical Use Cases
|
530
|
+
You can leverage these concepts in a variety of scenarios:
|
531
|
+
* Rules Engines: Encode complex, nested and interdepdendent rules into a single, easy to reason about, JSON schema.
|
532
|
+
* Complex Chained Computation: Where multiple interdependent properties (e.g. finance calculations, policy checks) must auto-update when a single input changes.
|
533
|
+
* Dynamic Configuration: Generate derived settings based on partial overrides without duplicating business logic everywhere.
|
534
|
+
* Form or Wizard-Like Data: Reactively compute new fields as users provide partial inputs; only relevant properties are evaluated.
|
535
|
+
* Stateless Microservices / APIs: Provide an HTTP endpoint that accepts partial data, automatically fills in defaults and computed fields, and returns a fully resolved JSON object.
|
536
|
+
|
537
|
+
## API Reference and Helpers
|
538
|
+
|
539
|
+
For more fine-grained integration without the DSL or server, you can use the lower-level Ruby API entry points:
|
540
|
+
|
541
|
+
E.g. where `my_schema` is a LazyGraph compliant JSON Schema:
|
542
|
+
|
543
|
+
```ruby
|
544
|
+
# 1. Turn the schema hash into a lazy graph. (From which you can generate contexts)
|
545
|
+
graph = my_schema.to_lazy_graph() # Optional args debug: false, validate: true
|
546
|
+
|
547
|
+
# 2. Immediately create a context from the graph and input data.
|
548
|
+
ctx = my_schema.to_graph_ctx(input_hash) # Optional args debug: false, validate: true
|
549
|
+
|
550
|
+
# 3. Immediately evaluate a query on context given the graph and input data.
|
551
|
+
result = my_schema.eval!(input_hash, 'some.query') # Optional args debug: false, validate: true
|
552
|
+
```
|
553
|
+
|
554
|
+
These methods allow you to embed LazyGraph with minimal overhead if you already have your own project structure.
|
555
|
+
|
556
|
+
## Contributing
|
557
|
+
|
558
|
+
Bug reports and pull requests are welcome on GitHub at [https://github.com/wouterken/lazy_graph](https://github.com/wouterken/lazy_graph). This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to follow our [code of conduct](./CODE_OF_CONDUCT.md).
|
559
|
+
|
560
|
+
## License
|
561
|
+
|
562
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,117 @@
|
|
1
|
+
require 'vernier'
|
2
|
+
require 'memory_profiler'
|
3
|
+
require 'benchmark/ips'
|
4
|
+
require 'lazy_graph'
|
5
|
+
|
6
|
+
class PerformanceBuilder < LazyGraph::Builder
|
7
|
+
rules_module :performance, {} do
|
8
|
+
integer :employees_count, rule: { inputs: 'employees', calc: 'employees.size' }
|
9
|
+
|
10
|
+
array :employees, required: true do
|
11
|
+
items do
|
12
|
+
string :id
|
13
|
+
array :positions, required: true, default: [] do
|
14
|
+
items do
|
15
|
+
string :pay_schedule_id, invisible: true
|
16
|
+
string :position_id, invisible: true
|
17
|
+
|
18
|
+
object :position, rule: :"${$.positions[position_id]}" do
|
19
|
+
number :base_rate
|
20
|
+
number :salary
|
21
|
+
number :bonus, rule: :'${base_rate} * 0.1'
|
22
|
+
end
|
23
|
+
object :pay_schedule, rule: :'${pay_schedules[pay_schedule_id]}'
|
24
|
+
number :base_rate, rule: :"${position.base_rate}"
|
25
|
+
string :employee_id, rule: :id
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
object :positions do
|
32
|
+
object :".*", pattern_property: true do
|
33
|
+
number :base_rate
|
34
|
+
number :salary, default: 100_000
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
object :pay_schedules do
|
39
|
+
object :".*", pattern_property: true do
|
40
|
+
string :payment_frequency, enum: %w[weekly biweekly semi-monthly monthly],
|
41
|
+
description: 'Payment frequency for this pay schedule.'
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def gen_employees(n, m = 10)
|
48
|
+
{
|
49
|
+
employees: n.times.map do |i|
|
50
|
+
{
|
51
|
+
id: i.to_s,
|
52
|
+
positions: Random.rand(0..4).times.map do
|
53
|
+
{
|
54
|
+
position_id: Random.rand(1...10).to_s,
|
55
|
+
pay_schedule_id: Random.rand(1...10).to_s
|
56
|
+
}
|
57
|
+
end
|
58
|
+
}
|
59
|
+
end,
|
60
|
+
pay_schedules: [*1..m].map do |i|
|
61
|
+
[i, {
|
62
|
+
payment_frequency: %w[monthly weekly].sample
|
63
|
+
}]
|
64
|
+
end.to_h,
|
65
|
+
positions: [*1..m].map do |i|
|
66
|
+
[i, {
|
67
|
+
base_rate: Random.rand(10..100)
|
68
|
+
}]
|
69
|
+
end.to_h
|
70
|
+
}
|
71
|
+
end
|
72
|
+
|
73
|
+
def profile_n(n, debug: false, validate: false)
|
74
|
+
employees = gen_employees(n)
|
75
|
+
graph = PerformanceBuilder.performance.build!(debug: debug, validate: validate)
|
76
|
+
Vernier.profile(out: './examples/time_profile.json') do
|
77
|
+
start = Time.now
|
78
|
+
graph.context(employees).query('')
|
79
|
+
ends = Time.now
|
80
|
+
puts "Time elapsed: #{ends - start}"
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def memory_profile_n(n, debug: false, validate: false)
|
85
|
+
employees = gen_employees(n)
|
86
|
+
graph = PerformanceBuilder.performance.build!(debug: debug, validate: validate)
|
87
|
+
report = MemoryProfiler.report do
|
88
|
+
graph.context(employees).query('')
|
89
|
+
end
|
90
|
+
report.pretty_print
|
91
|
+
end
|
92
|
+
|
93
|
+
def benchmark_ips_n(n, debug: false, validate: false)
|
94
|
+
graph = PerformanceBuilder.performance.build!(debug: debug, validate: validate)
|
95
|
+
employees = gen_employees(n)
|
96
|
+
Benchmark.ips do |x|
|
97
|
+
x.report('performance') do
|
98
|
+
graph.context(employees).query('')
|
99
|
+
end
|
100
|
+
x.compare!
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def console_n(n, debug: false, validate: false)
|
105
|
+
require 'debug'
|
106
|
+
graph = PerformanceBuilder.performance.build!(debug: debug, validate: validate)
|
107
|
+
employees = gen_employees(n)
|
108
|
+
result = graph.context(employees).query('')
|
109
|
+
binding.b
|
110
|
+
end
|
111
|
+
|
112
|
+
case ARGV[0]
|
113
|
+
when 'ips' then benchmark_ips_n(ARGV.fetch(1, 1000).to_i)
|
114
|
+
when 'memory' then memory_profile_n(ARGV.fetch(1, 1000).to_i)
|
115
|
+
when 'console' then console_n(ARGV.fetch(1, 1000).to_i, debug: true)
|
116
|
+
else profile_n(ARGV.fetch(1, 100_000).to_i)
|
117
|
+
end
|