PortableExpressions 🍱
A simple and flexible pure Ruby library for building and evaluating expressions. Expressions can be serialized to and built from JSON strings for portability.
Installation
Install the gem and add to the application's Gemfile by executing:
bundle add portable_expressions
If bundler is not being used to manage dependencies, install the gem by executing:
gem install portable_expressions
Why would I need this?
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.
Consider a serverless function (e.g. AWS Lambda) that adds 2 inputs together and some application code that calls it:
# Serverless function
def add(input1, input2)
input1 + input2
end
# Application code
serverless_function_call(:add, 1, 2) #=> 3
So far so good, but what if you want to multiply the inputs instead? Well, you could define another function:
def multiply(input1, input2)
input1 * input2
end
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:
# Serverless function
def run_expression(expression_json, environment_json)
expression = PortableExpressions.from_json(expression_json) # This is your logic, or steps.
environment = PortableExpressions.from_json(environment_json) # These are your inputs.
environment.evaluate(expression) # This is your output!
end
# Application code
add_step = PortableExpressions::Expression.new(
:+,
PortableExpressions::Variable.new("input1"),
PortableExpressions::Variable.new("input2")
)
multiply_step = PortableExpressions::Expression.new(
:*,
add_step,
PortableExpressions::Variable.new("input3")
)
inputs = PortableExpressions::Environment.new(
"input1" => 1,
"input2" => 2,
"input3" => 3
)
serverless_function_call(:run_expression, multiply_step.to_json, inputs.to_json) #=> 9
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.
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.
See example use cases for more ideas.
Usage
[!IMPORTANT] 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.
Scalar
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.
Scalar.new(1)
Scalar.new("some string")
Scalar.new([1.2, 3.4, 5.6])
Scalar.new({ "foo" => "bar" })
Variable
A Variable represents a named value stored in the Environment. Unlike Scalars, Variables have no value until they are evaluated by an Environment. Evaluating a Variable that isn't present in the Environment will result in a MissingVariableError.
variable_a = Variable.new("variable_a")
variable_b = Variable.new("variable_b")
environment = Environment.new(
"variable_a" => 1
)
environment.evaluate(variable_a) #=> 1
environment.evaluate(variable_b) #=> MissingVariableError
Expression
An expression represents 2 or more operands that are reduced using a defined operator. The operands of an Expression can be Scalars, Variables, or other Expressions. All operands must respond to the symbol (i.e. support the method) defined by the Expression#operator. Just like Variables, Expressions have non value until they're evaluated by an Environment.
Evaluating an Expression does the following:
- all
operandsare first evaluated in order - all resulting values are reduced using the symbol defined by the
operator
In this way evaluation is "lazy"; it won't evaluate a Variable or Expression until the operand is about to be used.
# addition
addition = Expression.new(:+, Scalar.new(1), Scalar.new(2))
# multiplication
multiplication = Expression.new(:*, Variable.new("variable_a"), Scalar.new(2))
environment = Environment.new(
"variable_a" => 2
)
environment.evaluate(addition) #=> 3
environment.evaluate(multiplication) #=> 4
Storing output
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.
environment = Environment.new
storing_output = Expression.new(:+, Scalar.new(1), Scalar.new(2), output: "one_plus_two")
environment.evaluate(storing_output) #=> 3
environment.variables #=> { "one_plus_two" => 3 }
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:
$$F_g = \frac\cdot m_1 \cdot m_2r^2$$
grav_constant = Scalar.new(BigDecimal(6.7 / 10**11))
numerator = Expression.new(:*, grav_constant, Variable.new("mass1"), Variable.new("mass2"), output: "numerator")
denominator = Expression.new(:**, Variable.new("distance"), 2, output: "denominator") # aka "r"
grav_force = Expression.new(:/, Variable.new("numerator"), Variable.new("denominator"))
At this point, we can compute the numerator and denominator independently, in any order.
environment = Environment.new(
"mass1" => 123.45,
"mass1" => 54.321,
"distance" => 67.89,
)
# In parallel...
environment.evaluate(numerator)
environment.evaluate(denominator)
# When all components are finished
environment.evaluate(grav_force)
Special operators
Some operators, like logical && and || are not methods in Ruby, so we pass a special string/symbol that PortableExpressions understands.
&&is represented by:and||is represented by:or
Environment
The Environment holds state in the form of a variables hash and can evaluate Expressions, Scalars, and Variables within a context. The environment handles updates to the state as Expressions run.
environment = Environment.new(
"variable_a" => 1,
"variable_b" => 2,
)
environment.evaluate(Variable.new("variable_a"))
#=> 1
environment.evaluate(Variable.new("variable_c"))
#=> MissingVariableError "Environment missing variable variable_c."
environment.evaluate(
Expression.new(
:+,
Variable.new("variable_a"),
Variable.new("variable_b"),
output: "variable_c" # defines where to store the result value
)
)
#=> 3
environment.variables
#=> { "variable_a" => 1, "variable_b" => 2, "variable_c" => 3 }
When evaluating multiple objects at a time, the value of the last object will be returned.
environment = Environment.new
environment.evaluate(
Scalar.new(1),
Expression.new(:+, Scalar.new(1), Scalar.new(2))
)
#=> 3
You can update or modify the variables hash directly at any time.
environment = Environment.new(
"variable_a" => 1
)
environment.evaluate(Variable.new("variable_a")) # => 1
environment.variables["variable_a"] = 2
environment.evaluate(Variable.new("variable_a")) # => 2
[!CAUTION] Ruby
symbolsare converted tostringswhen serialized to JSON, and remainstringswhen that JSON is parsed.
environment = Environment.new(foo: "bar")
variable_foo = Variable.new(:foo)
environment.evaluate(variable_foo) #=> "bar"
variable_foo = PortableExpressions.from_json(variable_foo.to_json)
environment.evaluate(variable_foo) #=> MissingVariableError
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.
Serialization (to JSON)
All models including the Environment support serialization via:
as_json: builds a serializableHashrepresentation of the objectto_json: builds a JSONStringrepresenting the object
All models have a required object key that indicates the type of object.
Building (from JSON)
To parse a JSON string, use the PortableExpressions.from_json method.
environment_json = "{\n \"object\": \"PortableExpressions::Environment\",\n \"variables\": {\n \"score_a\": 100\n }\n}\n"
variable_json = "{\n \"object\": \"PortableExpressions::Variable\",\n \"name\": \"score_a\"\n}\n"
environment = PortableExpressions.from_json(environment_json)
variable_score_a = PortableExpressions.from_json(variable_json)
environment.evaluate(variable_score_a) #=> 100
Example use cases
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:
Logical statements
# variable_a > variable_b && variable_c
a_greater_than_b = Expression.new(
:>,
Variable.new("variable_a"),
Variable.new("variable_b"),
)
condition = Expression.new(
:and,
a_greater_than_b,
Variable.new("variable_c"),
)
Environment.new(
"variable_a" => 2,
"variable_b" => 1,
"variable_c" => "truthy",
).evaluate(condition)
#=> true
[!TIP] Some operators have special symbols, see special operators for more details.
String manipulation
# Define a reusable `Expression` using `Variables`.
repeat_count = Variable.new("repeat")
string_to_repeat = Variable.new("user_input")
repeater = Expression.new(:*, string_to_repeat, repeat_count)
# Get inputs from some HTTP controller (e.g. Rails)
# GET /repeater?repeat=3&user_input=cool
Environment.new(**params).evaluate(repeater) #=> "coolcoolcool"
# GET /repeater?repeat=3&user_input=alright
Environment.new(**params).evaluate(repeater) #=> "alrightalrightalright"
Authorization policies
First, we define a portable and reusable policies.
# This is a composable policy that checks if a user has permissions for a requested resource and action.
= Variable.new("user_permissions")
resource = Variable.new("resource")
action = Variable.new("action")
= Expression.new(:+, resource, Scalar.new("."), action)
= Expression.new(:include?, , , output: "user_has_permission")
# Another composable policy that checks if the resource belongs to a user.
resource_owner = Variable.new("resource_owner")
user_id = Variable.new("user_id")
user_owns_resource = Expression.new(:==, resource_owner, user_id, output: "user_owns_resource")
We might decide to combine the policies into a single one:
= Expression.new(:and, user_owns_resource, )
# Write to a JSON file
File.write("user_owns_resource_and_has_permission.json", .to_json)
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.
= Expression.new(
:and,
Variable.new("user_owns_resource"),
Variable.new("user_has_permission")
)
# Each of these can be individually run
File.write("user_has_permission.json", .to_json)
File.write("user_owns_resource.json", user_owns_resource.to_json)
# This one relies on the previous 2 being run, or the corresponding variables being set in the `Environment`.
File.write("user_owns_resource_and_has_permission.json", .to_json)
[!TIP] 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:
# E.g. Rails via an `ActionController`
render json: .as_json, :ok
# Elsewhere, in the requesting service
= PortableExpressions.from_json(response.body.to_s)
Then, some consumer with access to the user's permissions and context around the requested resource and action can execute the policy.
environment = Environment.new(
"user_permissions" => user., #=> ["blog.read", "blog.write", "comment.read", "comment.write"]
"resource" => some_model.resource_name, #=> "comment"
"action" => "read",
"resource_owner" => some_model.user_id,
"user_id" => user.id
)
# Combined policy
= PortableExpressions.from_json(
File.read("user_owns_resource_and_has_permission.json")
)
environment.evaluate() #=> true
# Individual policies
= [
PortableExpressions.from_json(File.read("user_has_permission.json")),
PortableExpressions.from_json(File.read("user_owns_resource.json")),
PortableExpressions.from_json(File.read("user_owns_resource_and_has_permission.json"))
]
environment.evaluate(*) #=> true
Development
After checking out the repo, run bin/setup to install dependencies. Then, run rake test to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/omkarmoghe/portable_expressions.
License
The gem is available as open source under the terms of the MIT License.