Formular (formular v0.4.1)
A tiny extendable DSL evaluator. It's a wrap around Elixir's Code.eval_string/3
or Code.eval_quoted/3
, with the following limitations:
- No calling module functions;
- No calling some functions which can cause VM to exit;
- No sending messages;
- (optional) memory usage limit;
- (optional) execution time limit.
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.
Literals
# 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]}
# keyword list
iex> Formular.eval("[a: 1, b: :hi]", [])
{:ok, [a: 1, b: :hi]}
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_and_update_in: 3,
get_in: 2,
hd: 1,
inspect: 2,
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,
pop_in: 2,
put_elem: 3,
put_in: 3,
rem: 2,
round: 1,
struct: 2,
...
]
Supported macros from Kernel
are:
[
!: 1,
&&: 2,
..: 2,
..//: 3,
<>: 2,
and: 2,
get_and_update_in: 2,
if: 2,
in: 2,
is_exception: 1,
is_exception: 2,
is_nil: 1,
is_struct: 1,
is_struct: 2,
match?: 2,
or: 2,
pop_in: 1,
put_in: 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_charlist: 1,
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}
unless you explicitly allow it via allow_modules
option, as shown below:
iex> Formular.eval("Map.new([])", [], allow_modules: [Map])
{:ok, %{}}
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.
Compiling the code into an Elixir module
Most of the likelihood Code.eval_*
functions are fast enough for your application. However, compiling to an Elixir module will significantly improve the performance.
Code can be compiled into an Elixir module via Formular.compile_to_module!/3
function, as the following:
iex> code = quote do: min(a, b)
...> compiled = Formular.compile_to_module!(code, MyCompiledMod)
{:module, MyCompiledMod}
...> Formular.eval(compiled, [a: 5, b: 15], timeout: 5_000)
{:ok, 5}
Alternatively, you can directly call MyCompiledMod.run(a: 5, b: 15)
when none limitation of CPU or memory will apply.
Limiting execution time
The execution time can be limited with the :timeout
option:
iex> sleep = fn -> :timer.sleep(:infinity) end
...> Formular.eval("sleep.()", [sleep: sleep], timeout: 10)
{:error, :timeout}
Default timeout is 5_000 milliseconds.
Limiting heap usage
The evaluation can also be limited in heap size, with :max_heap_size
option. When the limit is exceeded, an error {:error, :killed}
will be returned.
Example:
iex> code = "for a <- 0..999_999_999_999, do: to_string(a)"
...> Formular.eval(code, [], timeout: :infinity, max_heap_size: 1_000)
{:error, :killed}
The default max heap size is 1_000_000 words.
Link to this section Summary
Functions
Compile the code into an Elixir module function.
Evaluate the code with binding context.
Returns used variables in the code. This can be helpful if you intend to build some UI based on the variables, or to validate if the code is using variables within the allowed list.
Link to this section Types
code()
Specs
eval_result()
Specs
option()
Specs
option() :: {:context, module()} | {:allow_modules, [module()]} | {:max_heap_size, non_neg_integer() | :infinity} | {:timeout, non_neg_integer() | :infinity}
options()
Specs
options() :: [option()]
Link to this section Functions
compile_to_module!(code, mod, opts \\ [])
Specs
Compile the code into an Elixir module function.
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 modules to import before evaluation.allow_modules
: The modules allowed to use in the code.timeout
: A timer used to terminate the evaluation after x milliseconds.infinity
milliseconds by default.max_heap_size
: A limit on heap memory usage. If set to zero, the max heap size limit is disabled.infinity
words by default.
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}
used_vars(code)
Specs
Returns used variables in the code. This can be helpful if you intend to build some UI based on the variables, or to validate if the code is using variables within the allowed list.
Example
iex> code = "f.(a + b)"
...> Formular.used_vars(code) |> Enum.sort()
[:a, :b, :f]