🍱 PortableExpressions
TL;DR — A simple and flexible pure Ruby library for building and evaluating expressions. Fully compatible with JSON.
I wrote PortableExpressions
because I needed a way to write math expressions that were fully JSON serializable for another project. Although that project got sidetracked, I decided to release the code for building and serializing these expressions as a library.
Because most (all?) math operators in Ruby are implemented as methods, what I really ended up building was a framework to express any procedure or function call. It works great for math, but can do anything that you can describe as a reduction of an array by some method. The elements in the array are the operands
, and the method is the operator
. Here, reduction just means:
elementN.operator(elementN+1)
.operator(elementN+2)
.operator(elementN+3)...
…and is implemented using Ruby’s reduce
(aka inject
).
# Addition
[1, 2].reduce(:+) #=> 3
# is equivalent to
1.+(2) #=> 3
# Multiplication
[2, 2, 2].reduce(:*) #=> 8
# is equivalent to
2.*(2)
.*(2) #=> 8
The library is made up of 4 main objects, all of which are fully JSON serializable (and deserializable):
Scalars
, which represent an “atom” or value that can be operated on. AScalar
’svalue
can be any JSON type.Variables
, which represent a named value stored in theEnvironment
(more on this later). These allow you to defer evaluation of some object until theVariable
is used.Expressions
, which represent an array ofoperands
and anoperator
. Theoperands
can beScalars
,Variables
, or even otherExpressions
. Theoperator
can be any Ruby symbol representing a method that all but the lastoperands
must support.Environments
, which represent thestate
of the procedure and hold the values thatVariables
“point” to.
Expressions
are stateless by default and can be evaluated by any Environment
. A procedure can be described by one or more Expressions
once and can be used multiple times. By allowing Expressions
to be built independently of the Environment
, the library allows your system to get instructions (Expressions
) from one source, and the input (Environment
) from elsewhere.
One example of this abstraction in practice is implementing authorization policies via stateless Expressions
, and then executing them on various distributed services using an Environment
that contains the relevant, local context. Here, each service might use one or some combination of policies and inject the relevant user and other info through the Environment
. The environment itself can be serialized so you can invert the architecture by having a centralized authorization service supports many policies, and various distributed services that request decisions through some API. The POST
body might contain the serialized Environment
along with info about which policy the services is requesting execution for.
In order to support the logical operators, which are not methods in Ruby, I implemented special methods on a SimpleDelegator
which wraps operands
when evaluating an Expression
. This allowed me to extend the functionality of each operand class without polluting the actual model. The delegator currently implements :and
(&&
) and :or
(||
) to support logical Expressions
.