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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d220e43573965935526913968ea27b528aed8eda49f98411f532b9f8c6d134da
4
- data.tar.gz: 87bacf2336c7bee890f8dedb7a14db1d79bd87d4678acc33be9a79cf93cd76e8
3
+ metadata.gz: e412dc48f673e5c346a0c5bba6017c8160b760765357acff869fccc576f60e74
4
+ data.tar.gz: fb11d81bf5e48513131bacaeb0be9ad71d380312410c13c4eb2aa67293897b32
5
5
  SHA512:
6
- metadata.gz: 64009910d3f1eab111f64c7c211ed02b4e3a27c94108bcc7cf8f98b7f8548255a1215185a46b0a70c021e18d8b6301dfcccbe3af625764258fe7e0515a2c8783
7
- data.tar.gz: eeda6228dbdd334fef7e3f87089e910073e3405d77f0bb426f7e6a56390a89bb0faff70ea2a38a4fe924087fe7f88adbc23e6d0dd1f7177a0ae918e2f5fc8b56
6
+ metadata.gz: 478d6b0c41f9081ec78c7b783b7c13bb914bdfa8a3e37d0fbcbba50c31c222145e4b278c2a648b0c3cde5adf7a99c46a5dfcbe3a157e43543e261015c71efb38
7
+ data.tar.gz: db7a331b82a72da08945accf6164a675104d549d5702311f1e120f9a841b70c7ee8b6d7c9d2bb20ffd11ae792d635f3674cf505d212739bd42083a8be1a92c85
data/CHANGELOG.md CHANGED
@@ -1,4 +1,8 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.1.1] - 2024-03-30
4
+ - Tons of README updates
5
+ - Rename the operand delegator from `Evaluator` -> `Operand`
6
+
3
7
  ## [0.1.0] - 2024-03-27
4
8
  - Initial release
data/README.md CHANGED
@@ -1,29 +1,93 @@
1
1
  # PortableExpressions 🍱
2
2
 
3
+ ![Gem Version](https://img.shields.io/gem/v/portable_expressions) ![Gem Total Downloads](https://img.shields.io/gem/dt/portable_expressions)
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
- `bundle add portable_expressions`
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
- `gem install portable_expressions`
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::`. This is omitted in the README for simplicity.
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
- environment.variables
74
- #=> { "variable_a" => 2, "one_plus_two" => 3 }
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
- ### Beyond math
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
- conditional = Expression.new(
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(conditional)
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. This means that the `Environment` must run the dependencies first in order for their `output` to be available in the `Environment#variables`.
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
- 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:
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
- user_has_permission = PortableExpressions.from_json(File.read("user_has_permission.json"))
285
- user_owns_resource = PortableExpressions.from_json(File.read("user_owns_resource.json"))
286
- user_owns_resource_and_has_permission = PortableExpressions.from_json(
287
- File.read("user_owns_resource_and_has_permission.json")
288
- )
289
- environment.evaluate(user_has_permission, user_owns_resource, user_owns_resource_and_has_permission) #=> true
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| Evaluator.new(evaluate(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
- if pretty
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 Evaluator < SimpleDelegator
6
+ class Operand < SimpleDelegator
7
7
  def and(other)
8
8
  __getobj__ && other.__getobj__
9
9
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PortableExpressions
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
@@ -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/evaluator"
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
- operands_json = json["operands"]
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 class #{json["object"]} does not support deserialization."
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.0
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-27 00:00:00.000000000 Z
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