Packages

A simple to use, simple to extend rules engine, written in Elixir.

Current section

Files

Jump to
rul_ex lib builder.ex
Raw

lib/builder.ex

defmodule RulEx.Builder do
@moduledoc false
import RulEx.Guards
@doc false
defmacro __using__(_opts) do
quote do
import RulEx.Guards
@behaviour RulEx.Behaviour
@before_compile RulEx.Builder
@doc """
Default implementation for `RulEx.Behaviour.eval/2`.
## Examples
iex> import #{__MODULE__}
iex> # Success cases
iex> truthy_expression = [:=, [:val, "string", "hello"], [:var, "string", "what?"]]
iex> falsy_expression = [:=, [:val, "string", "hello"], [:val, "string", "world"]]
iex> {:ok, true} = eval(truthy_expression, %{"what?" => "hello"})
iex> {:ok, false} = eval(falsy_expression, %{"what?" => "hello"})
iex> invalid_expression = []
iex> {:error, _reason} = eval(invalid_expression, %{})
"""
@impl RulEx.Behaviour
def eval(expr, db)
when is_val_or_var(expr) do
with {:ok, value} <- value(expr, db),
do: {:ok, is_truthy(value)}
end
def eval([:| | exprs], db) do
Enum.any?(exprs, &expr_evaluator(&1, db))
catch
reason -> {:error, reason}
else
result -> {:ok, result}
end
def eval([:& | exprs], db) do
Enum.all?(exprs, &expr_evaluator(&1, db))
catch
reason -> {:error, reason}
else
result -> {:ok, result}
end
def eval([op, expr0, expr1] = expr, db)
when is_comparison(expr) do
with t0 = Enum.at(expr0, 1),
t1 = Enum.at(expr1, 1),
true <- t0 == t1,
{:ok, v0} <- value(expr0, db),
{:ok, v1} <- value(expr1, db) do
op =
case op do
:< -> &less_than(t0, &1, &2)
:<= -> &less_than_or_equal(t0, &1, &2)
:> -> &greater_than(t0, &1, &2)
:>= -> &greater_than_or_equal(t0, &1, &2)
:= -> &Kernel.==/2
:!= -> &Kernel.!=/2
end
{:ok, op.(v0, v1)}
else
false -> {:error, "type mismatch in `#{op}` operand"}
reason -> reason
end
end
def eval([op | args], _db)
when op in [:<, :<=, :>, :>=, :=, :!=] do
{
:error,
"operand `#{op}` given invalid values `#{inspect(args)}` can only accept two arguments to compare"
}
end
def eval([:in, needle, haystack], db)
when is_val_or_var(needle) and
is_list(haystack) do
with {:ok, needle} <- value(needle, db),
do: {:ok, needle in haystack}
end
def eval([:in | args], _db) do
{
:error,
"operand `in` given invalid values `#{inspect(args)}` can only accept list of two elements with the first one being a `val` or `var` expression and the second being a list"
}
end
def eval([:!, expr], db) do
with {:ok, result} <- eval(expr, db),
do: {:ok, not result}
end
def eval([:! | args], _db) do
{
:error,
"operand `!` given in valid values `#{inspect(args)}` can only accept a single expression to negate"
}
end
def eval([op | args], db)
when is_reserved_operand(op) and
is_binary(op),
do: eval([String.to_existing_atom(op) | args], db)
def eval([op | args], db)
when not is_reserved_operand(op) do
with {:ok, result} <- operand(op, args, db), do: {:ok, is_truthy(result)}
end
def eval(_invalid_expr, _db), do: {:error, "invalid expression given"}
@doc "Default implementation for `RulEx.Behaviour.eval!/2`."
@impl RulEx.Behaviour
def eval!(expr, db) do
case eval(expr, db) do
{:ok, evaluation} ->
evaluation
{:error, reason} ->
raise RulEx.EvalError,
message: "failed to evaluate expression `#{inspect(expr)}`",
reason: reason,
facts: db,
expr: expr
end
end
@doc """
Default implementation for `RulEx.Behaviour.expr?/1`.
## Examples
iex> import #{__MODULE__}
iex> correct_expression = [:=, [:val, "string", "hello"], [:var, "string", "what?"]]
iex> incorrect_expression = []
iex> true = expr?(correct_expression)
iex> false = expr?(incorrect_expression)
"""
@impl RulEx.Behaviour
def expr?(expr), do: RulEx.Builder.expr?(expr)
@doc """
Default implementation for `RulEx.Behaviour.value/2`.
## Examples
iex> import #{__MODULE__}
iex> # Success cases
iex> val_expression = [:val, "string", "foo"]
iex> var_expression = [:var, "number", "x"]
iex> {:ok, "foo"} = value(val_expression, %{})
iex> {:ok, 10} = value(var_expression, %{"x" => 10})
iex> # Error cases
iex> {:error, _reason} = value(var_expression, %{})
iex> {:error, _reason} = value([], %{})
"""
@impl RulEx.Behaviour
def value([:val, type, value], _db) do
if valid_value?(type, value),
do: {:ok, maybe_parse!(type, value)},
else: {:error, "invalid value '#{inspect(value)}' given for type '#{type}'"}
end
def value([:var, type, variable], db) do
value = RulEx.DataBag.get(db, variable)
if not is_nil(value) and valid_value?(type, value),
do: {:ok, maybe_parse!(type, value)},
else: {:error, "invalid value '#{inspect(value)}' given for type '#{type}'"}
end
def value([:var, type, variable, default], db) do
value = RulEx.DataBag.get(db, variable, default)
if not is_nil(value) and valid_value?(type, value),
do: {:ok, maybe_parse!(type, value)},
else: {:error, "invalid value '#{inspect(value)}' given for type '#{type}'"}
end
def value([:val | args], _db) do
{
:error,
"invalid `val` arguments (`#{inspect(args)}`) provided can only accept two arguments (given as list of two elements) with the first one a supported type"
}
end
def value([:var | args], _db) do
{
:error,
"invalid `val` arguments (`#{inspect(args)}`) provided can only accept two or three arguments (given as list of two elements) with the first one a supported type"
}
end
def value(expr, _db) do
if expr?(expr) and not is_val_or_var(expr) do
{:error, "cannot get value for non `val` or `var` expression"}
else
{:error, "invalid `val` or `var` expression (`#{inspect(expr)}`) provided"}
end
end
@doc "Default implementation for `RulEx.Behaviour.value!/2`."
@impl RulEx.Behaviour
def value!(expr, db) do
case value(expr, db) do
{:ok, result} ->
result
{:error, reason} ->
raise RulEx.EvalError,
message: "failed to evaluate value expression `#{inspect(expr)}`",
reason: reason,
facts: db,
expr: expr
end
end
@doc """
Default implementation for `RulEx.Behaviour.operand/3`. If not overridden this will always
yield an error. No catchall clause is needed as it is already implemented.
## Examples
iex> import #{__MODULE__}
iex> {:error, _reason} = operand("whatever", "any value", %{})
"""
@impl RulEx.Behaviour
def operand(op, args, db)
defoverridable operand: 3
#
# Private defined APIs
#
defp expr_evaluator(expr, db) do
with true <- expr?(expr),
{:ok, result} <- eval(expr, db) do
result
else
false -> throw("non expressions provided")
{:error, reason} -> throw(reason)
end
end
defp valid_value?("any", _value), do: true
defp valid_value?("number", value), do: is_number(value)
defp valid_value?("integer", value), do: is_integer(value)
defp valid_value?("float", value), do: is_float(value)
defp valid_value?("string", value), do: is_binary(value)
defp valid_value?("boolean", value), do: is_boolean(value)
defp valid_value?("list", value), do: is_list(value)
defp valid_value?("map", value), do: is_map(value)
# We allow for time, date, and datetime data to be passed as either
# structs of their respective types or as ISO 8601 formatted
# strings indicating for RulEx that it needs to parse them.
defp valid_value?("time", %Time{} = _value), do: true
defp valid_value?("time", value) when is_binary(value),
do: match?({:ok, _time}, Time.from_iso8601(value))
defp valid_value?("date", %Date{} = _value), do: true
defp valid_value?("date", value) when is_binary(value),
do: match?({:ok, _date}, Date.from_iso8601(value))
defp valid_value?("datetime", %NaiveDateTime{} = _value), do: true
defp valid_value?("datetime", %DateTime{} = _value), do: true
defp valid_value?("datetime", value) when is_binary(value),
do: match?({:ok, _datetime}, NaiveDateTime.from_iso8601(value))
# We allow for time, date, and datetime data types to be passed as
# ISO 8601 formatted strings, those are automatically parsed
# when being evaluated.
defp maybe_parse!("time", value) when is_binary(value), do: Time.from_iso8601!(value)
defp maybe_parse!("date", value) when is_binary(value), do: Date.from_iso8601!(value)
defp maybe_parse!("datetime", value) when is_binary(value),
do: NaiveDateTime.from_iso8601!(value)
defp maybe_parse!(_type, value), do: value
defp less_than("time", t0, t1), do: Time.compare(t0, t1) == :lt
defp less_than("date", t0, t1), do: Date.compare(t0, t1) == :lt
defp less_than("datetime", t0, t1), do: DateTime.compare(t0, t1) == :lt
defp less_than(_type, t0, t1), do: t0 < t1
defp less_than_or_equal("time", t0, t1), do: Time.compare(t0, t1) in [:lt, :eq]
defp less_than_or_equal("date", t0, t1), do: Date.compare(t0, t1) in [:lt, :eq]
defp less_than_or_equal("datetime", t0, t1), do: DateTime.compare(t0, t1) in [:lt, :eq]
defp less_than_or_equal(_type, t0, t1), do: t0 <= t1
defp greater_than("time", t0, t1), do: Time.compare(t0, t1) == :gt
defp greater_than("date", t0, t1), do: Date.compare(t0, t1) == :gt
defp greater_than("datetime", t0, t1), do: DateTime.compare(t0, t1) == :gt
defp greater_than(_type, t0, t1), do: t0 > t1
defp greater_than_or_equal("time", t0, t1), do: Time.compare(t0, t1) in [:gt, :eq]
defp greater_than_or_equal("date", t0, t1), do: Date.compare(t0, t1) in [:gt, :eq]
defp greater_than_or_equal("datetime", t0, t1), do: DateTime.compare(t0, t1) in [:gt, :eq]
defp greater_than_or_equal(_type, t0, t1), do: t0 >= t1
end
end
@doc false
defmacro __before_compile__(_env) do
quote do
# Catchall operand call to reject with an error any undefined operands
# TODO: add dialyzer ignore clause if this never matches
def operand(op, _args, _db), do: {:error, "unsupported operand '#{op}' provided"}
end
end
# This is defined here as to be reused by other internal modules
# of RulEx, this isn't intended to be used externall for that
# use the `expr?/1` function provided by the implementor
# of RulEx behaviour.
@doc false
def expr?(expr) when is_val_or_var(expr), do: true
def expr?([op]) when op in [:!, "!"], do: false
def expr?([op, arg]) when op in [:!, "!"], do: expr?(arg)
def expr?([op | _args]) when op in [:!, "!"], do: false
def expr?([op | _args] = expr) when op in [:val, :var, "val", "var"], do: is_val_or_var(expr)
def expr?([op | args])
when is_comparison_operand(op) and length(args) == 2,
do: Enum.all?(args, &expr?/1)
def expr?([op | _args])
when is_comparison_operand(op),
do: false
def expr?([op | args])
when is_valid_operand(op) and is_list(args),
do: Enum.all?(args, &expr?/1)
def expr?(_invalid_expr), do: false
end