portable_expressions 0.1.0 → 0.1.1
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 +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +137 -26
- data/lib/portable_expressions/environment.rb +2 -4
- data/lib/portable_expressions/modules/serializable.rb +2 -5
- data/lib/portable_expressions/{evaluator.rb → operand.rb} +1 -1
- data/lib/portable_expressions/version.rb +1 -1
- data/lib/portable_expressions.rb +5 -5
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e412dc48f673e5c346a0c5bba6017c8160b760765357acff869fccc576f60e74
|
4
|
+
data.tar.gz: fb11d81bf5e48513131bacaeb0be9ad71d380312410c13c4eb2aa67293897b32
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 478d6b0c41f9081ec78c7b783b7c13bb914bdfa8a3e37d0fbcbba50c31c222145e4b278c2a648b0c3cde5adf7a99c46a5dfcbe3a157e43543e261015c71efb38
|
7
|
+
data.tar.gz: db7a331b82a72da08945accf6164a675104d549d5702311f1e120f9a841b70c7ee8b6d7c9d2bb20ffd11ae792d635f3674cf505d212739bd42083a8be1a92c85
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
@@ -1,29 +1,93 @@
|
|
1
1
|
# PortableExpressions 🍱
|
2
2
|
|
3
|
+
 
|
4
|
+
|
3
5
|
A simple and flexible pure Ruby library for building and evaluating expressions. Expressions can be serialized to and built from JSON strings for portability.
|
4
6
|
|
5
7
|
## Installation
|
6
8
|
|
7
9
|
Install the gem and add to the application's Gemfile by executing:
|
8
10
|
|
9
|
-
|
11
|
+
`bundle add portable_expressions`
|
10
12
|
|
11
13
|
If bundler is not being used to manage dependencies, install the gem by executing:
|
12
14
|
|
13
|
-
|
15
|
+
`gem install portable_expressions`
|
16
|
+
|
17
|
+
## Why would I need this?
|
18
|
+
|
19
|
+
`PortableExpressions` can be a powerful tool when designing stateless components. It's useful when you want to transmit the actual _logic_ you want to run (i.e. an `Expression`) along with its inputs. By making your logic or procedure stateless, you can decouple services from one another in interesting and scalable ways.
|
20
|
+
|
21
|
+
Consider a serverless function (e.g. [AWS Lambda](https://aws.amazon.com/lambda/)) that adds 2 inputs together and some application code that calls it:
|
22
|
+
|
23
|
+
```ruby
|
24
|
+
# Serverless function
|
25
|
+
def add(input1, input2)
|
26
|
+
input1 + input2
|
27
|
+
end
|
28
|
+
|
29
|
+
# Application code
|
30
|
+
serverless_function_call(:add, 1, 2) #=> 3
|
31
|
+
```
|
32
|
+
|
33
|
+
So far so good, but what if you want to multiply the inputs instead? Well, you could define another function:
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
def multiply(input1, input2)
|
37
|
+
input1 * input2
|
38
|
+
end
|
39
|
+
```
|
40
|
+
|
41
|
+
But what happens as complexity increases, e.g. you want to run more steps? You could continue to define specific functions, but it would be a lot simpler if there was a way to tell the function what steps to run, the same way you tell it what the inputs are. Let's rewrite our function using `PortableExpressions`:
|
42
|
+
|
43
|
+
```ruby
|
44
|
+
# Serverless function
|
45
|
+
def run_expression(expression_json, environment_json)
|
46
|
+
expression = PortableExpressions.from_json(expression_json) # This is your logic, or steps.
|
47
|
+
environment = PortableExpressions.from_json(environment_json) # These are your inputs.
|
48
|
+
environment.evaluate(expression) # This is your output!
|
49
|
+
end
|
50
|
+
|
51
|
+
# Application code
|
52
|
+
add_step = PortableExpressions::Expression.new(
|
53
|
+
:+,
|
54
|
+
PortableExpressions::Variable.new("input1"),
|
55
|
+
PortableExpressions::Variable.new("input2")
|
56
|
+
)
|
57
|
+
multiply_step = PortableExpressions::Expression.new(
|
58
|
+
:*,
|
59
|
+
add_step,
|
60
|
+
PortableExpressions::Variable.new("input3")
|
61
|
+
)
|
62
|
+
inputs = PortableExpressions::Environment.new(
|
63
|
+
"input1" => 1,
|
64
|
+
"input2" => 2,
|
65
|
+
"input3" => 3
|
66
|
+
)
|
67
|
+
|
68
|
+
serverless_function_call(:run_expression, multiply_step.to_json, inputs.to_json) #=> 9
|
69
|
+
```
|
70
|
+
|
71
|
+
This is an oversimplified example to illustrate the kind of code you can write when your logic or procedure is **stateless** and **serializable**. In your application, the inputs and procedure may come from different sources.
|
72
|
+
|
73
|
+
We demonstrated arithmetic here, but the `operator` can be _any Ruby method_ that the `operands` respond to, which means your `Expressions` can do a lot more than just add or multiply numbers.
|
74
|
+
|
75
|
+
See [example use cases](#example-use-cases) for more ideas.
|
14
76
|
|
15
77
|
## Usage
|
16
78
|
|
17
79
|
> [!IMPORTANT]
|
18
|
-
> When using the gem, all references to the models below must be prefixed with `PortableExpressions
|
80
|
+
> When using the gem, all references to the models below must be prefixed with `PortableExpressions::` when used in your project. This is omitted in the README for simplicity.
|
19
81
|
|
20
82
|
### Scalar
|
21
83
|
|
22
|
-
A `Scalar` is the simplest object that can be evaluated. It holds a single `value`. When used in an `Expression`, this `value` must respond to the symbol (i.e. support the method) defined by the `Expression#operator`.
|
84
|
+
A `Scalar` is the simplest object that can be evaluated. It holds a single `value`. When used in an `Expression`, this `value` must respond to the symbol (i.e. support the method) defined by the `Expression#operator`. The `value` must also be serializable to JSON; `Array` and `Hash` are allowed types as long as their elements are all serializable as well.
|
23
85
|
|
24
86
|
```ruby
|
25
87
|
Scalar.new(1)
|
26
88
|
Scalar.new("some string")
|
89
|
+
Scalar.new([1.2, 3.4, 5.6])
|
90
|
+
Scalar.new({ "foo" => "bar" })
|
27
91
|
```
|
28
92
|
|
29
93
|
### Variable
|
@@ -51,8 +115,6 @@ Evaluating an `Expression` does the following:
|
|
51
115
|
|
52
116
|
In this way evaluation is "lazy"; it won't evaluate a `Variable` or `Expression` until the `operand` is about to be used.
|
53
117
|
|
54
|
-
An `Expression` can store its result back into the `Environment` by defining an `output`.
|
55
|
-
|
56
118
|
```ruby
|
57
119
|
# addition
|
58
120
|
addition = Expression.new(:+, Scalar.new(1), Scalar.new(2))
|
@@ -60,18 +122,51 @@ addition = Expression.new(:+, Scalar.new(1), Scalar.new(2))
|
|
60
122
|
# multiplication
|
61
123
|
multiplication = Expression.new(:*, Variable.new("variable_a"), Scalar.new(2))
|
62
124
|
|
63
|
-
# storing output
|
64
|
-
storing_output = Expression.new(:+, Scalar.new(1), Scalar.new(2), output: "one_plus_two")
|
65
|
-
|
66
125
|
environment = Environment.new(
|
67
126
|
"variable_a" => 2
|
68
127
|
)
|
69
128
|
environment.evaluate(addition) #=> 3
|
70
129
|
environment.evaluate(multiplication) #=> 4
|
130
|
+
```
|
131
|
+
|
132
|
+
#### Storing `output`
|
133
|
+
|
134
|
+
An `Expression` can store its result back into the `Environment` by defining an `output`. Writing the result to the `Environment` is an `Expression`'s way of updating the state.
|
135
|
+
|
136
|
+
```ruby
|
137
|
+
environment = Environment.new
|
138
|
+
storing_output = Expression.new(:+, Scalar.new(1), Scalar.new(2), output: "one_plus_two")
|
71
139
|
environment.evaluate(storing_output) #=> 3
|
140
|
+
environment.variables #=> { "one_plus_two" => 3 }
|
141
|
+
```
|
72
142
|
|
73
|
-
|
74
|
-
|
143
|
+
Storing output allows us to write composable `Expressions` that build on each other instead of having to nest them. This allows us to do things like parallelize expensive parts of our procedure. For example, consider a set of `Expressions` for solving the gravitational force formula:
|
144
|
+
|
145
|
+
$$F_g = \frac{G \cdot m_1 \cdot m_2}{r^2}$$
|
146
|
+
|
147
|
+
```ruby
|
148
|
+
grav_constant = Scalar.new(BigDecimal(6.7 / 10**11))
|
149
|
+
numerator = Expression.new(:*, grav_constant, Variable.new("mass1"), Variable.new("mass2"), output: "numerator")
|
150
|
+
denominator = Expression.new(:**, Variable.new("distance"), 2, output: "denominator") # aka "r"
|
151
|
+
|
152
|
+
grav_force = Expression.new(:/, Variable.new("numerator"), Variable.new("denominator"))
|
153
|
+
```
|
154
|
+
|
155
|
+
At this point, we can compute the numerator and denominator independently, in any order.
|
156
|
+
|
157
|
+
```ruby
|
158
|
+
environment = Environment.new(
|
159
|
+
"mass1" => 123.45,
|
160
|
+
"mass1" => 54.321,
|
161
|
+
"distance" => 67.89,
|
162
|
+
)
|
163
|
+
|
164
|
+
# In parallel...
|
165
|
+
environment.evaluate(numerator)
|
166
|
+
environment.evaluate(denominator)
|
167
|
+
|
168
|
+
# When all components are finished
|
169
|
+
environment.evaluate(grav_force)
|
75
170
|
```
|
76
171
|
|
77
172
|
#### Special `operators`
|
@@ -132,6 +227,21 @@ environment.variables["variable_a"] = 2
|
|
132
227
|
environment.evaluate(Variable.new("variable_a")) # => 2
|
133
228
|
```
|
134
229
|
|
230
|
+
> [!CAUTION]
|
231
|
+
> Ruby `symbols` are converted to `strings` when serialized to JSON, and remain `strings` when that JSON is parsed.
|
232
|
+
|
233
|
+
```ruby
|
234
|
+
environment = Environment.new(foo: "bar")
|
235
|
+
|
236
|
+
variable_foo = Variable.new(:foo)
|
237
|
+
environment.evaluate(variable_foo) #=> "bar"
|
238
|
+
|
239
|
+
variable_foo = PortableExpressions.from_json(variable_foo.to_json)
|
240
|
+
environment.evaluate(variable_foo) #=> MissingVariableError
|
241
|
+
```
|
242
|
+
|
243
|
+
In this example, the same error can be thrown if the `Environment` is serialized and parsed, even if the `Variable` remains unchanged. For this reason, it's recommended to use `strings` for all `Variable` names.
|
244
|
+
|
135
245
|
### Serialization (to JSON)
|
136
246
|
|
137
247
|
All models including the `Environment` support serialization via:
|
@@ -165,7 +275,7 @@ variable_score_a = PortableExpressions.from_json(variable_json)
|
|
165
275
|
environment.evaluate(variable_score_a) #=> 100
|
166
276
|
```
|
167
277
|
|
168
|
-
###
|
278
|
+
### Example use cases
|
169
279
|
|
170
280
|
The examples throughout the README show simple arithmetic to illustrate the mechanics of the library. However, `Scalars` and `Variables` can hold any type of value that's JSON serializable, which allows for more complex use cases such as:
|
171
281
|
|
@@ -178,7 +288,7 @@ a_greater_than_b = Expression.new(
|
|
178
288
|
Variable.new("variable_a"),
|
179
289
|
Variable.new("variable_b"),
|
180
290
|
)
|
181
|
-
|
291
|
+
condition = Expression.new(
|
182
292
|
:and,
|
183
293
|
a_greater_than_b,
|
184
294
|
Variable.new("variable_c"),
|
@@ -187,7 +297,7 @@ Environment.new(
|
|
187
297
|
"variable_a" => 2,
|
188
298
|
"variable_b" => 1,
|
189
299
|
"variable_c" => "truthy",
|
190
|
-
).evaluate(
|
300
|
+
).evaluate(condition)
|
191
301
|
#=> true
|
192
302
|
```
|
193
303
|
|
@@ -237,7 +347,7 @@ user_owns_resource_and_has_permission = Expression.new(:and, user_owns_resource,
|
|
237
347
|
File.write("user_owns_resource_and_has_permission.json", user_owns_resource_and_has_permission.to_json)
|
238
348
|
```
|
239
349
|
|
240
|
-
Or we might define a policy the relies on the `output` of other policies.
|
350
|
+
Or we might define a policy the relies on the `output` of other policies. The `Environment` must `evaluate` the dependencies first in order for their `output` to be available for the following `Expressions`.
|
241
351
|
|
242
352
|
```ruby
|
243
353
|
user_owns_resource_and_has_permission = Expression.new(
|
@@ -253,7 +363,8 @@ File.write("user_owns_resource.json", user_owns_resource.to_json)
|
|
253
363
|
File.write("user_owns_resource_and_has_permission.json", user_owns_resource_and_has_permission.to_json)
|
254
364
|
```
|
255
365
|
|
256
|
-
|
366
|
+
> [!TIP]
|
367
|
+
> These examples demonstrate portability via JSON files, but we can just as easily serve the policy directly to anyone who needs it via some HTTP controller:
|
257
368
|
|
258
369
|
```ruby
|
259
370
|
# E.g. Rails via an `ActionController`
|
@@ -267,10 +378,10 @@ Then, some consumer with access to the user's permissions and context around the
|
|
267
378
|
|
268
379
|
```ruby
|
269
380
|
environment = Environment.new(
|
270
|
-
"user_permissions" => user.permissions #=> ["blog.read", "blog.write", "comment.read", "comment.write"]
|
271
|
-
"resource" => some_model.resource_name #=> "comment"
|
272
|
-
"action" => "read"
|
273
|
-
"resource_owner" => some_model.user_id
|
381
|
+
"user_permissions" => user.permissions, #=> ["blog.read", "blog.write", "comment.read", "comment.write"]
|
382
|
+
"resource" => some_model.resource_name, #=> "comment"
|
383
|
+
"action" => "read",
|
384
|
+
"resource_owner" => some_model.user_id,
|
274
385
|
"user_id" => user.id
|
275
386
|
)
|
276
387
|
|
@@ -281,12 +392,12 @@ user_owns_resource_and_has_permission = PortableExpressions.from_json(
|
|
281
392
|
environment.evaluate(user_owns_resource_and_has_permission) #=> true
|
282
393
|
|
283
394
|
# Individual policies
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
File.read("user_owns_resource_and_has_permission.json")
|
288
|
-
|
289
|
-
environment.evaluate(
|
395
|
+
user_owns_resource_and_has_permission = [
|
396
|
+
PortableExpressions.from_json(File.read("user_has_permission.json")),
|
397
|
+
PortableExpressions.from_json(File.read("user_owns_resource.json")),
|
398
|
+
PortableExpressions.from_json(File.read("user_owns_resource_and_has_permission.json"))
|
399
|
+
]
|
400
|
+
environment.evaluate(*user_owns_resource_and_has_permission) #=> true
|
290
401
|
```
|
291
402
|
|
292
403
|
## Development
|
@@ -20,9 +20,7 @@ module PortableExpressions
|
|
20
20
|
end
|
21
21
|
|
22
22
|
def as_json
|
23
|
-
super.merge(
|
24
|
-
variables: variables
|
25
|
-
)
|
23
|
+
super.merge(variables: variables)
|
26
24
|
end
|
27
25
|
|
28
26
|
private
|
@@ -37,7 +35,7 @@ module PortableExpressions
|
|
37
35
|
end
|
38
36
|
when Expression
|
39
37
|
value = object.operands
|
40
|
-
.map { |operand|
|
38
|
+
.map { |operand| Operand.new(evaluate(operand)) }
|
41
39
|
.reduce(object.operator)
|
42
40
|
|
43
41
|
variables[object.output] = value if object.output
|
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module PortableExpressions
|
4
|
+
# Adds JSON serialization capabilities to each object.
|
4
5
|
module Serializable
|
5
6
|
def as_json
|
6
7
|
{
|
@@ -9,11 +10,7 @@ module PortableExpressions
|
|
9
10
|
end
|
10
11
|
|
11
12
|
def to_json(pretty: false)
|
12
|
-
|
13
|
-
JSON.pretty_generate(as_json)
|
14
|
-
else
|
15
|
-
JSON.generate(as_json)
|
16
|
-
end
|
13
|
+
pretty ? JSON.pretty_generate(as_json) : JSON.generate(as_json)
|
17
14
|
end
|
18
15
|
end
|
19
16
|
end
|
@@ -3,7 +3,7 @@
|
|
3
3
|
module PortableExpressions
|
4
4
|
# Used to wrap `operands` when evaluating an `Expression`. This allows us to "extend" the functionality of an object
|
5
5
|
# without polluting the app wide definition.
|
6
|
-
class
|
6
|
+
class Operand < SimpleDelegator
|
7
7
|
def and(other)
|
8
8
|
__getobj__ && other.__getobj__
|
9
9
|
end
|
data/lib/portable_expressions.rb
CHANGED
@@ -6,12 +6,13 @@ require "json"
|
|
6
6
|
|
7
7
|
require_relative "portable_expressions/version"
|
8
8
|
require_relative "portable_expressions/modules/serializable"
|
9
|
-
require_relative "portable_expressions/
|
9
|
+
require_relative "portable_expressions/operand"
|
10
10
|
require_relative "portable_expressions/scalar"
|
11
11
|
require_relative "portable_expressions/variable"
|
12
12
|
require_relative "portable_expressions/expression"
|
13
13
|
require_relative "portable_expressions/environment"
|
14
14
|
|
15
|
+
# See the README for details.
|
15
16
|
module PortableExpressions
|
16
17
|
Error = Class.new(StandardError)
|
17
18
|
|
@@ -22,7 +23,7 @@ module PortableExpressions
|
|
22
23
|
|
23
24
|
# @param json [String, Hash]
|
24
25
|
# @return [Expression, Scalar, Variable, Environment]
|
25
|
-
def self.from_json(json)
|
26
|
+
def self.from_json(json) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength
|
26
27
|
json = JSON.parse(json) if json.is_a?(String)
|
27
28
|
|
28
29
|
case json["object"]
|
@@ -30,8 +31,7 @@ module PortableExpressions
|
|
30
31
|
Environment.new(**json["variables"])
|
31
32
|
when Expression.name
|
32
33
|
operator = json["operator"].to_sym
|
33
|
-
|
34
|
-
operands = operands_json.map { |operand_json| from_json(operand_json) }
|
34
|
+
operands = json["operands"].map { |operand_json| from_json(operand_json) }
|
35
35
|
|
36
36
|
Expression.new(operator, *operands)
|
37
37
|
when Variable.name
|
@@ -39,7 +39,7 @@ module PortableExpressions
|
|
39
39
|
when Scalar.name
|
40
40
|
Scalar.new(json["value"])
|
41
41
|
else
|
42
|
-
raise DeserializationError, "Object
|
42
|
+
raise DeserializationError, "Object type #{json["object"]} not supported for deserialization."
|
43
43
|
end
|
44
44
|
rescue JSON::ParserError => e
|
45
45
|
raise DeserializationError, "Unable to parse JSON: #{e.message}."
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: portable_expressions
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Omkar Moghe
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-03-
|
11
|
+
date: 2024-03-30 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: minitest
|
@@ -52,9 +52,9 @@ files:
|
|
52
52
|
- Rakefile
|
53
53
|
- lib/portable_expressions.rb
|
54
54
|
- lib/portable_expressions/environment.rb
|
55
|
-
- lib/portable_expressions/evaluator.rb
|
56
55
|
- lib/portable_expressions/expression.rb
|
57
56
|
- lib/portable_expressions/modules/serializable.rb
|
57
|
+
- lib/portable_expressions/operand.rb
|
58
58
|
- lib/portable_expressions/scalar.rb
|
59
59
|
- lib/portable_expressions/variable.rb
|
60
60
|
- lib/portable_expressions/version.rb
|