/portable_expressions

A simple and flexible pure Ruby library for building and evaluating expressions.

Primary LanguageRubyMIT LicenseMIT

PortableExpressions 🍱

Gem Version Gem Total Downloads

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 no value until they're evaluated by an Environment.

Evaluating an Expression does the following:

  1. all operands are first evaluated in order
  2. 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{G \cdot m_1 \cdot m_2}{r^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 symbols are converted to strings when serialized to JSON, and remain strings when 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 serializable Hash representation of the object
  • to_json: builds a JSON String representing 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 = <<~JSON
  {
    "object": "PortableExpressions::Environment",
    "variables": {
      "score_a": 100
    }
  }
JSON
variable_json = <<~JSON
  {
    "object": "PortableExpressions::Variable",
    "name": "score_a"
  }
JSON

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.
user_permissions = Variable.new("user_permissions")
resource = Variable.new("resource")
action = Variable.new("action")
requested_permission = Expression.new(:+, resource, Scalar.new("."), action)
user_has_permission = Expression.new(:include?, user_permissions, requested_permission, 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:

user_owns_resource_and_has_permission = Expression.new(:and, user_owns_resource, user_has_permission)

# Write to a JSON file
File.write("user_owns_resource_and_has_permission.json", user_owns_resource_and_has_permission.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.

user_owns_resource_and_has_permission = 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", user_has_permission.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", user_owns_resource_and_has_permission.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: user_owns_resource_and_has_permission.as_json, :ok

# Elsewhere, in the requesting service
user_owns_resource_and_has_permission = 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.permissions, #=> ["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
user_owns_resource_and_has_permission = PortableExpressions.from_json(
  File.read("user_owns_resource_and_has_permission.json")
)
environment.evaluate(user_owns_resource_and_has_permission) #=> true

# Individual policies
user_owns_resource_and_has_permission = [
  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(*user_owns_resource_and_has_permission) #=> 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.