View Source Tablex - Decision Tables in Elixir Code
Tablex is an implementation of the Decision Table in Elixir. Its goal is to make maitaining domain rules easy.
quick-demo
Quick demo
Let's assume we decide what to do everyday based on day of week and the weather, as the following table indicates:
Day (string) | Weather (string) | Activity | |
---|---|---|---|
1 | Monday, Tuesday, Wednesday, Thursday | rainy | read |
2 | - | read, walk | |
3 | Friday | sunny | soccer |
4 | - | swim | |
5 | Saturday | - | watch movie, games |
6 | Sunday | - | null |
We can use a similar tabular form of the code in an Elixir program:
...> plans = Tablex.new("""
...> F Day (string) Weather (string) || Activity
...> 1 Monday,Tuesday,Wednesday,Thursday rainy || read
...> 2 Monday,Tuesday,Wednesday,Thursday - || read,walk
...> 3 Friday sunny || soccer
...> 4 Friday - || swim
...> 5 Saturday - || "watch movie",games
...> 6 Sunday - || null
...> """)
...>
...> Tablex.decide(plans, day: "Monday")
%{activity: ["read", "walk"]}
...>
...> Tablex.decide(plans, day: "Friday", weather: "sunny")
%{activity: "soccer"}
...>
...> Tablex.decide(plans, day: "Sunday")
%{activity: nil}
The above code demonstrates how we can determine what to do based on a set of rules which are represented in a decision table on day and weather condition.
Inside the table, we defined the decision logic with:
- An indicator of hit policy,
F
in this case meaning the first rule matched will be applied. SeeHit Policies
section for more information. - Two input stubs,
day
andweather
which are both strings. SeeInput Stubs
section - An output stub,
activity
in this case. SeeOutput Stubs
section - Six rules which take inputs and determine the acitivity output. See
Rules
section - A friendly expression in each cell of the rules. See
Expression
section
vertical-table
Vertical Table
Vertical tables are the same as horinzontal ones. It's just a matter of direction. The following tables are the same:
F "Product Category" "Competitor Pricing" "Product Features" || "Launch Decision" Reasoning
1 Electronics "Higher than Competitor" "More Features" || Launch "Competitive Advantage"
2 Electronics "Lower than Competitor" "Same Features" || Launch "Price Advantage"
3 Fashion "Same as Competitor" "New Features" || "Do Not Launch" "Lack of Differentiation"
F | Product Category | Competitor Pricing | Product Features | Launch Decision | Reasoning |
---|---|---|---|---|---|
1 | Electronics | Higher than Competitor | More Features | Launch | Competitive Advantage |
2 | Lower than Competitor | Same Features | Launch | Price Advantage | |
3 | Fashion | Same as Competitor | New Features | Do Not Launch | Lack of Differentiation |
====
F || 1 2 3
"Product Category" || Electronics Electronics Fashion
"Competitor Pricing" || "Higher than Competitor" "Lower than Competitor" "Same as Competitor"
"Product Features" || "More Features" "Same Features" "New Features"
====
"Launch Decision" || Launch Launch "Do Not Launch"
Reasoning || "Competitive Advantage" "Price Advantage" "Lack of Differentiation"
F | 1 | 2 | 3 |
---|---|---|---|
Product Category | Electronics | Fashion | |
Competitor Pricing | Higher than Competitor | Lower than Competitor | Same as Competitor |
Product Features | More Features | Same Features | New Features |
Launch Decision | Launch | Launch | Do Not Launch |
Reasoning | Competitive Advantage | Price Advantage | Lack of Differentiation |
input-stubs
Input Stubs
Inputs can be defined with a set of name (type[, description])
pairs. For example:
Age (integer)
defines an input field whose name is "age" and type is integer.DOB (date, date of birth)
defines a date input field with a description label.
Name
Names can contain spaces in them if they are quoted. The following names are valid:
year_month_day
yearMonthDay
"year month day"
They will all be converted to year_month_day
.
Type
Currently the following types are supported:
- integer
- float
- number
- string
- bool
When types are specified, the input value shall be of the same type as specified.
output-stubs
Output Stubs
Output stubs are defined as name (type[, description])
where
- name can be a string which will be converted to an underscored atom;
- type can be either of the supported types (the same as inputs, see above section);
- description is optional and is currently ignored.
rules
Rules
After output stub definitions, each of the following rows defines a rule entry, with the format:
rule_number input_exp_1 input_exp_2 ... input_exp_m || output_exp_1 output_exp_2 ... output_exp_n
Rule number is primarily used for ordering. The rule with the lowest rule number has the highest priority. Input expressions and output expressions are separated by "||".
expression
Expression
Currently only these types are supported:
- literal numeric value: integer and float (without scientific notation)
- literal quoted string in
"
- boolean
- comparation:
>
,>=
,<
,<=
- range, e.g.
5..10
- nil ("null")
- list of numeric, string, range, bool, nil or comparation; can be mixed
- any ("-")
The following types of expressions are planned:
- date
- time
- datetime
- function
hit-policies
Hit policies
There are several hit policies to indicate how matched rules are applied.
F (First matched)
- the first matched rule will be applied.C (Collect)
- all matched rules will be collected into result list.M (Merge)
- all matched rules will be reduced (merged) into a single return entry, until there's no-
in the output.R (Reverse Merge)
- similar tomerge
but in a reversed order.
Examples:
First Hit
iex> table = Tablex.new("""
...> F Age (integer) || f (float)
...> 1 > 60 || 3.0
...> 2 50..60 || 2.5
...> 3 31..49 || 2.0
...> 4 15..18,20..30 || 1.0
...> 5 - || 0
...> """
...> )
...>
...> Tablex.decide(table, age: 30)
%{f: 1.0}
iex> Tablex.decide(table, age: 55)
%{f: 2.5}
iex> Tablex.decide(table, age: 22)
%{f: 1.0}
iex> Tablex.decide(table, age: 17)
%{f: 1.0}
iex> Tablex.decide(table, age: 1)
%{f: 0}
iex> table = Tablex.new("""
...> F Age (integer) "Years of Service" || Holidays (integer)
...> 1 >=60 - || 3
...> 2 45..59 <30 || 2
...> 3 - >=30 || 22
...> 4 <18 - || 5
...> 5 - - || 10
...> """
...> )
...>
...> Tablex.decide(table, age: 46, years_of_service: 30)
%{holidays: 22}
...>
iex> Tablex.decide(table, age: 17, years_of_service: 5)
%{holidays: 5}
...>
iex> Tablex.decide(table, age: 22)
%{holidays: 10}
Collect
Here's an example of collect
hit policy:
iex> table = Tablex.new("""
...> C OrderAmount Membership || Discount
...> 1 >=100 false || "Free cupcake"
...> 2 >=100 true || "Free icecream"
...> 3 - true || "20% OFF"
...> """
...> )
...>
iex> Tablex.decide(table, order_amount: 500, membership: false)
[%{discount: "Free cupcake"}]
...>
iex> Tablex.decide(table, order_amount: 500, membership: true)
[%{discount: "Free icecream"}, %{discount: "20% OFF"}]
...>
iex> Tablex.decide(table, order_amount: 80)
[]
Collect policy can work without any input:
iex> table = Tablex.new("""
...> C || Country FeatureVersion
...> 1 || "New Zealand" 3
...> 2 || "Japan" 2
...> 3 || "Brazil" 2
...> """
...> )
...>
iex> Tablex.decide(table, [])
[%{country: "New Zealand", feature_version: 3}, %{country: "Japan", feature_version: 2}, %{country: "Brazil", feature_version: 2}]
Merge
Here's an example of merge
hit policy:
iex> table = Tablex.new("""
...> M Continent Country Province || Feature1 Feature2
...> 1 Asia Thailand - || true true
...> 2 America Canada BC,ON || - true
...> 3 America Canada - || true false
...> 4 America US - || false false
...> 5 Europe France - || true -
...> 6 Europe - - || false true
...> """
...> )
...>
iex> Tablex.decide(table, continent: "Asia", country: "Thailand", province: "ACR")
%{feature1: true, feature2: true}
...>
iex> Tablex.decide(table, continent: "America", country: "Canada", province: "BC")
%{feature1: true, feature2: true}
...>
iex> Tablex.decide(table, continent: "America", country: "Canada", province: "QC")
%{feature1: true, feature2: false}
...>
iex> Tablex.decide(table, continent: "Europe", country: "France")
%{feature1: true, feature2: true}
The rules are applied until all the output fields are determined.
Reverse Merge
The reverse_merge
works the same as merge
but the rule ordering is reversed:
iex> table = Tablex.new("""
...> R Continent Country Province || Feature1 Feature2
...> 1 Europe - - || false true
...> 2 Europe France - || true -
...> 3 America US - || false false
...> 4 America Canada - || true false
...> 5 America Canada BC,ON || - true
...> 6 Asia Thailand - || true true
...> """
...> )
...>
iex> Tablex.decide(table, continent: "Asia", country: "Thailand", province: "ACR")
%{feature1: true, feature2: true}
...>
iex> Tablex.decide(table, continent: "America", country: "Canada", province: "BC")
%{feature1: true, feature2: true}
...>
iex> Tablex.decide(table, continent: "America", country: "Canada", province: "QC")
...>
%{feature1: true, feature2: false}
...>
iex> Tablex.decide(table, continent: "Europe", country: "France")
%{feature1: true, feature2: true}
generating-elixir-code
Generating Elixir Code
It is feasible to generate Elixir code from a table with Tablex.CodeGenerate.generate/1
, as:
table = """
F CreditScore EmploymentStatus Debt-to-Income-Ratio || Action
1 700 employed <0.43 || Approved
2 700 unemployed - || "Further Review"
3 <=700 - - || Denied
"""
Tablex.CodeGenerate.generate(table)
The code generated in the above example is:
case {credit_score, employment_status, debt_to_income_ratio} do
{700, "employed", debt_to_income_ratio}
when is_number(debt_to_income_ratio) and debt_to_income_ratio < 0.43 ->
%{action: "Approved"}
{700, "unemployed", _} ->
%{action: "Further Review"}
{credit_score, _, _} when is_number(credit_score) and credit_score <= 700 ->
%{action: "Denied"}
end
todos
TODOs
- [x] nested input, e.g.
country.name
as an input stub name - [x] nested output, e.g.
constraints.max_distance
as an output stub name - [ ] support referring to other input entries in an input entry
- [x] support functions in output entries
- [ ] support input validation
- [ ] support output validation
- [ ] support Date data type
- [ ] support Time data type
- [ ] support DateTime data type
- [x] vertical tables
- [ ] rule code format
- [ ] have a full specification documentation
installation
Installation
The package can be installed by adding tablex
to your list of dependencies in mix.exs
:
def deps do
[
{:tablex, "~> 0.1.0"}
]
end
The docs can be found at https://hexdocs.pm/tablex.
related-projects
Related Projects
- Tablex View - A renderer which transforms a decision table into HTML.
acknowledgements
Acknowledgements
- Tablex is heavily inspired by Decision Model and Notation (DMN) and its FEEL expression language.
- Tablex is built on top of the awesome nimble_parsec library.
license
License
Tablex is open sourced under MIT license.