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

Specs

code() :: binary() | Macro.t()
Link to this type

eval_result()

Specs

eval_result() :: {:ok, term()} | {:error, term()}

Specs

option() ::
  {:context, module()}
  | {:max_heap_size, non_neg_integer()}
  | {:timeout, non_neg_integer() | :infinity}

Specs

options() :: [option()]

Link to this section Functions

Link to this 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 evaluation
  • options : 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}