defmodule Tablex.Rules do
@moduledoc """
High level rule APIs for Tablex.
With rule APIs, one can:
- find rules by a given set of inputs,
- update an existing rule,
- or create a new rule.
"""
alias Tablex.Parser
alias Tablex.Table
@type table :: Table.t()
@type rule :: Table.rule()
@doc """
Find rules by a given set of inputs.
## Parameters
- `table`: the table to find rules for
- `args`: the set of inputs to find rules for
## Returns
A list of rules matching the given set of inputs.
The ordering of the list follows the priority of the rules,
with the lower-priority rule taking more precedence.
## Example
iex> table = Tablex.new(\"""
...> F value || color
...> 1 >90 || red
...> 2 80..90 || orange
...> 3 20..79 || green
...> 4 <20 || blue
...> \""")
...> Tablex.Rules.get_rules(table, %{})
[]
...> Tablex.Rules.get_rules(table, %{value: 80})
[%Tablex.Rules.Rule{id: 2, inputs: [{[:value], 80..90}], outputs: [{[:color], "orange"}]}]
This example shows how the returned rules are ordered by priority:
iex> table = Tablex.new(\"""
...> M country state || feature_enabled
...> 1 US CA || true
...> 2 US - || false
...> 3 CA - || true
...> \""")
...> Tablex.Rules.get_rules(table, %{country: "US", state: "CA"})
[
%Tablex.Rules.Rule{id: 2, inputs: [{[:country], "US"}, {[:state], :any}], outputs: [{[:feature_enabled], false}]},
%Tablex.Rules.Rule{id: 1, inputs: [{[:country], "US"}, {[:state], "CA"}], outputs: [{[:feature_enabled], true}]}
]
Nested inputs are supported.
iex> table = Tablex.new(\"""
...> F foo.value || color
...> 1 >90 || red
...> 2 80..90 || orange
...> 3 20..79 || green
...> 4 <20 || blue
...> \""")
...> Tablex.Rules.get_rules(table, %{foo: %{value: 80}})
[%Tablex.Rules.Rule{id: 2, inputs: [{[:foo, :value], 80..90}], outputs: [{[:color], "orange"}]}]
"""
@spec get_rules(table(), keyword()) :: [rule()]
def get_rules(%Table{} = table, args) do
context = build_context(table.inputs, args)
table.rules
|> Stream.map(&to_rule_struct(&1, table))
|> Stream.filter(&match_rule?(&1, context))
|> order_by_priority(table.hit_policy)
end
defp build_context(inputs, args) do
inputs
|> Stream.map(&value_path/1)
|> Enum.map(&get_in(args, &1))
end
defp value_path(%Tablex.Variable{name: name, path: path}) do
path ++ [name]
end
defp to_rule_struct([id, {:input, inputs}, {:output, output} | _], %{
inputs: input_defs,
outputs: output_defs
}) do
inputs =
inputs
|> Stream.zip(input_defs)
|> Enum.map(fn {expect, df} ->
{value_path(df), expect}
end)
outputs =
output
|> Stream.zip(output_defs)
|> Enum.map(fn {value, od} ->
{value_path(od), value}
end)
%Tablex.Rules.Rule{
id: id,
inputs: inputs,
outputs: outputs
}
end
defp match_rule?(rule, context) do
Stream.zip(rule.inputs, context)
|> Enum.all?(fn {{_path, expect}, value} ->
match_expect?(expect, value)
end)
end
@doc """
Check if the given value matches the asserting expectation.
"""
def match_expect?(expect, value) when is_list(expect) do
Enum.any?(expect, &match_expect?(&1, value))
end
def match_expect?(%Range{first: first, last: last}, value) when is_number(value) do
# we can't use `in` here because value may be a float.
value >= first and value <= last
end
def match_expect?({:!=, x}, value) when value != x, do: true
def match_expect?({:>, x}, value) when is_number(value) and value > x, do: true
def match_expect?({:>=, x}, value) when is_number(value) and value >= x, do: true
def match_expect?({:<, x}, value) when is_number(value) and value < x, do: true
def match_expect?({:<=, x}, value) when is_number(value) and value <= x, do: true
def match_expect?(:any, _), do: true
def match_expect?(expect, expect), do: true
def match_expect?(_, _), do: false
defp order_by_priority(matched_rules, :reverse_merge) do
Enum.sort_by(matched_rules, & &1.id)
end
defp order_by_priority(matched_rules, _hit_policy) do
Enum.sort_by(matched_rules, & &1.id, :desc)
end
@type updates() :: [update()]
@type update() :: {:input, [any()] | map()} | {:output, [any()] | map()}
@doc """
Update an existing rule.
## Example
A basic example of updating a rule:
iex> table = Tablex.new(\"""
...> F value || color
...> 1 - || red
...> \""")
...> table = Tablex.Rules.update_rule(table, 1, input: [80..90], output: ["orange"])
...> table.rules
[[1, input: [80..90], output: ["orange"]]]
You can also updte a rule with changes in a map format:
iex> table = Tablex.new(\"""
...> F value || color
...> 1 - || red
...> \""")
...> table = Tablex.Rules.update_rule(table, 1, input: %{value: 80..90}, output: %{color: "orange"})
...> table.rules
[[1, input: [80..90], output: ["orange"]]]
You can only update input values:
iex> table = Tablex.new(\"""
...> F value || color
...> 1 - || red
...> \""")
...> table = Tablex.Rules.update_rule(table, 1, input: %{value: 80..90})
...> table.rules
[[1, input: [80..90], output: ["red"]]]
You can only update output values:
iex> table = Tablex.new(\"""
...> F value || color
...> 1 - || red
...> \""")
...> table = Tablex.Rules.update_rule(table, 1, output: %{color: "orange"})
...> table.rules
[[1, input: [:any], output: ["orange"]]]
For updating nested input or output values, both nested map or direct values are supported:
iex> table = Tablex.new(\"""
...> F target.value || color
...> 1 - || red
...> \""")
...> table = Tablex.Rules.update_rule(table, 1, input: %{target: %{value: 80..90}}, output: %{color: "orange"})
...> table.rules
[[1, input: [80..90], output: ["orange"]]]
iex> table = Tablex.new(\"""
...> F target.value || color
...> 1 - || red
...> \""")
...> table = Tablex.Rules.update_rule(table, 1, input: [80..90], output: ["orange"])
...> table.rules
[[1, input: [80..90], output: ["orange"]]]
"""
@spec update_rule(table(), integer(), updates()) :: table()
def update_rule(%Table{} = table, id, updates) do
update_input = Keyword.get(updates, :input) |> to_updater(table.inputs)
update_output = Keyword.get(updates, :output) |> to_updater(table.outputs)
table
|> Map.update!(:rules, fn
rules ->
Enum.map(rules, fn
[^id, {:input, input}, {:output, output}] ->
[id, {:input, update_input.(input)}, {:output, update_output.(output)}]
otherwise ->
otherwise
end)
end)
end
defp to_updater(%{} = update, defs) do
defs
|> Stream.with_index()
|> Stream.map(fn {%Tablex.Variable{name: name, path: path}, index} ->
full_path = path ++ [name]
case at_path(update, full_path) do
nil ->
& &1
value ->
&List.replace_at(&1, index, value)
end
end)
|> Enum.reduce(& &1, fn f, acc ->
fn update -> acc.(update) |> f.() end
end)
end
defp to_updater(nil, _) do
& &1
end
defp to_updater(new_value, _) do
fn _ -> new_value end
end
defp at_path(%{} = map, path) do
Enum.reduce_while(path, map, fn
seg, acc ->
case acc do
%{^seg => value} ->
{:cont, value}
_ ->
{:halt, nil}
end
end)
end
@doc """
Update an existing rule by input.
"""
@spec update_rule_by_input(table(), map(), map()) :: table()
def update_rule_by_input(table, input, output_updates) do
rule = find_rule_by_input(table, input)
case rule do
[id | _] ->
update_rule(table, id, output: output_updates)
nil ->
add_new_rule_high_priority(table, input, output_updates)
end
end
defp find_rule_by_input(table, input) do
input = to_expected_values(input, table.inputs)
table.rules
|> Enum.find(&match?([_id, {:input, ^input} | _], &1))
end
defp to_expected_values(input, defs) do
for %{path: path, name: name} <- defs, do: get_expr(input, path, name) |> parse_expression()
end
defp get_expr(input, path, name) do
path = Enum.map(path, &Access.key(&1, %{}))
any = "-"
get_in(input, path ++ [Access.key(name, any)])
end
defp add_new_rule_high_priority(table, input, output_updates) do
input = to_expected_values(input, table.inputs)
output = to_expected_values(output_updates, table.outputs)
new_rule = [1, {:input, input}, {:output, output}]
table
|> Map.update!(:rules, fn rules ->
case table.hit_policy do
:reverse_merge ->
rules ++ [new_rule]
_ ->
[new_rule | rules]
end
end)
|> update_ids()
end
defp parse_expression(expr) when is_binary(expr) do
{:ok, [parsed], _, _, _, _} = Parser.expr(expr)
parsed
end
defp parse_expression(expr), do: expr
defp update_ids(%Table{} = table) do
table
|> Map.update!(:rules, fn rules ->
rules
|> Stream.with_index(1)
|> Enum.map(fn {[_ | content], index} ->
[index | content]
end)
end)
end
end