Formular (formular v0.2.2)
A simple extendable DSL evaluator. It's a limited version of Code.eval_string/3 or Code.eval_quoted/3.
So far, the limitations are:
- No calling module functions;
- No calling some functions which can cause VM to exit;
- No sending messages.
Here's an example using this module to evaluate a discount number against an order struct:
iex> discount_formula = ~s"
...> case order do
...> # old books get a big promotion
...> %{book: %{year: year}} when year < 2000 ->
...> 0.5
...>
...> %{book: %{tags: tags}} ->
...> # Elixir books!
...> if ~s{elixir} in tags do
...> 0.9
...> else
...> 1.0
...> end
...>
...> _ ->
...> 1.0
...> end
...> "
...>
...> book_order = %{
...> book: %{
...> title: "Elixir in Action", year: 2019, tags: ["elixir"]
...> }
...> }
...>
...> Formular.eval(discount_formula, [order: book_order])
{:ok, 0.9}The code being evaluated is just a piece of Elixir code, so it can be expressive when describing business rules.
Usage
Simple expressions
# number
iex> Formular.eval("1", [])
{:ok, 1} # <- note that it's an integer
# plain string
iex> Formular.eval(~s["some text"], [])
{:ok, "some text"}
# atom
iex> Formular.eval(":foo", [])
{:ok, :foo}
# list
iex> Formular.eval("[:foo, Bar]", [])
{:ok, [:foo, Bar]}Variables
Variables can be passed within the binding parameter.
# bound value
iex> Formular.eval("1 + foo", [foo: 42])
{:ok, 43}Functions in the code
Kernel functions and macros
Kernel functions and macros are limitedly supported. Only a picked list of them are supported out of the box so that dangerouse functions such as Kernel.exit/1 will not be invoked.
Supported functions from Kernel are:
[
!=: 2,
!==: 2,
*: 2,
+: 1,
+: 2,
++: 2,
-: 1,
-: 2,
--: 2,
/: 2,
<: 2,
<=: 2,
==: 2,
=~: 2,
>: 2,
>=: 2,
abs: 1,
ceil: 1,
div: 2,
floor: 1,
get_in: 2,
hd: 1,
is_atom: 1,
is_binary: 1,
is_bitstring: 1,
is_boolean: 1,
is_float: 1,
is_function: 1,
is_integer: 1,
is_list: 1,
is_map: 1,
is_map_key: 2,
is_number: 1,
is_pid: 1,
is_port: 1,
is_reference: 1,
is_tuple: 1,
length: 1,
map_size: 1,
max: 2,
min: 2,
not: 1,
rem: 2,
round: 1,
tl: 1,
trunc: 1,
tuple_size: 1
]Supported macros from Kernel are:
[
!: 1,
&&: 2,
<>: 2,
and: 2,
if: 2,
in: 2,
is_exception: 1,
is_exception: 2,
is_nil: 1,
is_struct: 1,
is_struct: 2,
or: 2,
sigil_C: 2,
sigil_D: 2,
sigil_N: 2,
sigil_R: 2,
sigil_S: 2,
sigil_T: 2,
sigil_U: 2,
sigil_W: 2,
sigil_c: 2,
sigil_r: 2,
sigil_s: 2,
sigil_w: 2,
tap: 2,
then: 2,
to_string: 1,
unless: 2,
|>: 2,
||: 2
]Example:
# Kernel function
iex> Formular.eval("min(5, 100)", [])
{:ok, 5}
iex> Formular.eval("max(5, 100)", [])
{:ok, 100}Custom functions
Custom functions can be provided in two ways, either in a binding lambda:
# bound function
iex> Formular.eval("1 + add.(-1, 5)", [add: &(&1 + &2)])
{:ok, 5}... or with a context module:
iex> defmodule MyContext do
...> def foo() do
...> 42
...> end
...> end
...> Formular.eval("10 + foo", [], context: MyContext)
{:ok, 52}Directly calling to module functions in the code are disallowed for security reason. For example:
iex> Formular.eval("Map.new", [])
{:error, :no_calling_module_function}
iex> Formular.eval("min(0, :os.system_time())", [])
{:error, :no_calling_module_function}Evaluating AST instead of plain string code
You may want to use AST instead of string for performance consideration. In this case, an AST can be passed to eval/3:
iex> "a = b = 10; a * b" |> Code.string_to_quoted!() |> Formular.eval([])
{:ok, 100}...so that you don't have to parse it every time before evaluating it.
Link to this section Summary
Functions
Evaluate the code with binding context.
Link to this section Types
code()
Specs
eval_result()
Specs
option()
Specs
option() :: {:context, module()}
options()
Specs
options() :: [option()]
Link to this section Functions
eval(code, binding, opts \\ [])
Specs
eval(code(), binding :: keyword(), options()) :: eval_result()
Evaluate the code with binding context.
Parameters
code: code to eval. Could be a binary, or parsed AST.binding: the variable binding to support the evaluationoptions: current these options are supported:context: The module to import before evaluation.
Examples
iex> Formular.eval("1", [])
{:ok, 1}
iex> Formular.eval(~s["some text"], [])
{:ok, "some text"}
iex> Formular.eval("min(5, 100)", [])
{:ok, 5}
iex> Formular.eval("max(5, 100)", [])
{:ok, 100}
iex> Formular.eval("count * 5", [count: 6])
{:ok, 30}
iex> Formular.eval("add.(1, 2)", [add: &(&1 + &2)])
{:ok, 3}
iex> Formular.eval("Map.new", [])
{:error, :no_calling_module_function}
iex> Formular.eval("Enum.count([1])", [])
{:error, :no_calling_module_function}
iex> Formular.eval("min(0, :os.system_time())", [])
{:error, :no_calling_module_function}
iex> Formular.eval("inspect.(System.A)", [inspect: &Kernel.inspect/1])
{:ok, "System.A"}
iex> Formular.eval "f = &IO.inspect/1", []
{:error, :no_calling_module_function}
iex> Formular.eval("mod = IO; mod.inspect(1)", [])
{:error, :no_calling_module_function}
iex> "a = b = 10; a * b" |> Code.string_to_quoted!() |> Formular.eval([])
{:ok, 100}