Formular (formular v0.3.0-pre.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,
++: 2,
-: 1,
-: 2,
--: 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,
...
]
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,
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}
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.
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
Evaluate the code with binding context.
Link to this section Types
code()
Specs
eval_result()
Specs
option()
Specs
option() :: {:context, module()} | {:max_heap_size, non_neg_integer()} | {:timeout, non_neg_integer() | :infinity}
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.timeout
: A timer used to terminate the evaluation after x milliseconds.5000
milliseconds by default.max_heap_size
: A limit on heap memory usage. If set to zero, the max heap size limit is disabled.1000000
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}