├── .formatter.exs
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .tool-versions
├── README.md
├── examples.exs
├── guides
├── code_execution.md
├── informative_row.md
└── nested_fields.md
├── lib
├── tablex.ex
└── tablex
│ ├── code_generate.ex
│ ├── decider.ex
│ ├── formatter.ex
│ ├── formatter
│ ├── align.ex
│ ├── hit_policy.ex
│ ├── horizontal.ex
│ ├── value.ex
│ ├── variable.ex
│ └── vertical.ex
│ ├── hit_policy.ex
│ ├── optimizer.ex
│ ├── optimizer
│ ├── helper.ex
│ ├── merge_rules.ex
│ ├── remove_dead_rules.ex
│ ├── remove_duplication.ex
│ └── remove_empty_rules.ex
│ ├── parser.ex
│ ├── parser
│ ├── code.ex
│ ├── comparison.ex
│ ├── expression.ex
│ ├── expression
│ │ ├── any.ex
│ │ ├── bool.ex
│ │ ├── float.ex
│ │ ├── implied_string.ex
│ │ ├── integer.ex
│ │ ├── list.ex
│ │ ├── null.ex
│ │ ├── numeric.ex
│ │ ├── quoted_string.ex
│ │ └── range.ex
│ ├── horizontal_table.ex
│ ├── informative_row.ex
│ ├── rule.ex
│ ├── space.ex
│ ├── variable.ex
│ └── vertical_table.ex
│ ├── rules.ex
│ ├── rules
│ └── rule.ex
│ ├── table.ex
│ ├── util
│ ├── deep_map.ex
│ └── list_breaker.ex
│ └── variable.ex
├── livebooks
├── tablex-intro.livemd
└── tablex_on_formular_server.livemd
├── mix.exs
├── mix.lock
└── test
├── support
└── maybe_doctest_file.ex
├── tablex
├── code_execution_test.exs
├── code_generate_test.exs
├── decider_test.exs
├── formatter
│ ├── code_test.exs
│ ├── emoji_test.exs
│ └── format_list_test.exs
├── formatter_test.exs
├── informative_row_test.exs
├── nested_test.exs
├── optimizer
│ ├── merge_rules_test.exs
│ ├── optimizer_test.exs
│ ├── remove_dead_rules_test.exs
│ ├── remove_duplicated_rules_test.exs
│ └── remove_empty_rules.exs
├── parser
│ ├── expression_test.exs
│ ├── informative_row_test.exs
│ ├── rule_test.exs
│ ├── variable_test.exs
│ └── vertical_table_test.exs
├── parser_test.exs
├── rules
│ └── rule.ex
└── rules_test.exs
├── tablex_test.exs
└── test_helper.exs
/.formatter.exs:
--------------------------------------------------------------------------------
1 | # Used by "mix format"
2 | [
3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
4 | ]
5 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: ci
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | build:
7 | name: Tablex on Elixir ${{matrix.elixir}} (Erlang/OTP ${{matrix.otp}})
8 | runs-on: ubuntu-latest
9 | strategy:
10 | matrix:
11 | otp: ['25.3']
12 | elixir: ['1.13.4', '1.14.5', 'v1.15.0-rc.1']
13 | steps:
14 | - uses: actions/checkout@v3
15 | - uses: erlef/setup-beam@v1
16 | with:
17 | otp-version: ${{matrix.otp}}
18 | elixir-version: ${{matrix.elixir}}
19 | - run: mix deps.get
20 | - run: mix compile --warnings-as-errors
21 | - run: mix test --slowest 5
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where third-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | decision_sheet-*.tar
24 |
25 | # Temporary files, for example, from tests.
26 | /tmp/
27 |
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | elixir master
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Tablex - Decision Tables in Elixir Code
2 |
3 | Tablex is an implementation of the [Decision Table][] in Elixir. Its goal is to
4 | make maitaining domain rules easy.
5 |
6 | ## Quick demo
7 |
8 | Let's assume we decide what to do everyday based on day of week and the weather,
9 | as the following table indicates:
10 |
11 |
| 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 |
12 |
13 | We can use a similar tabular form of the code in an Elixir program:
14 |
15 | ``` elixir
16 | ...> plans = Tablex.new("""
17 | ...> F day (string) weather (string) || activity
18 | ...> 1 Monday,Tuesday,Wednesday,Thursday rainy || read
19 | ...> 2 Monday,Tuesday,Wednesday,Thursday - || read,walk
20 | ...> 3 Friday sunny || soccer
21 | ...> 4 Friday - || swim
22 | ...> 5 Saturday - || "watch movie",games
23 | ...> 6 Sunday - || null
24 | ...> """)
25 | ...>
26 | ...> Tablex.decide(plans, day: "Monday")
27 | %{activity: ["read", "walk"]}
28 | ...>
29 | ...> Tablex.decide(plans, day: "Friday", weather: "sunny")
30 | %{activity: "soccer"}
31 | ...>
32 | ...> Tablex.decide(plans, day: "Sunday")
33 | %{activity: nil}
34 | ```
35 |
36 | The above code demonstrates how we can determine what to do based on a set of rules
37 | which are represented in a decision table on day and weather condition.
38 |
39 | Inside the table, we defined the decision logic with:
40 |
41 | 1. An indicator of hit policy, `F` in this case meaning the first rule matched will be applied. See [`Hit Policies` section](#hit-policies) for more information.
42 | 2. Two input stubs, `day` and `weather` which are both strings. See [`Input Stubs` section](#input-stubs)
43 | 3. An output stub, `activity` in this case. See [`Output Stubs` section](#output-stubs)
44 | 4. Six rules which take inputs and determine the activity output. See [`Rules` section](#rules)
45 | 5. A friendly expression in each cell of the rules. See [`Expression` section](#expression)
46 |
47 | ## Vertical Table
48 |
49 | Vertical tables are the same as horizontal ones. It's just a matter of direction.
50 | The following tables are the same:
51 |
52 | ```
53 | F product_category competitor_pricing product_features || launch_decision reasoning
54 | 1 Electronics "Higher than Competitor" "More Features" || Launch "Competitive Advantage"
55 | 2 Electronics "Lower than Competitor" "Same Features" || Launch "Price Advantage"
56 | 3 Fashion "Same as Competitor" "New Features" || "Do Not Launch" "Lack of Differentiation"
57 | ```
58 |
59 | 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 |
60 |
61 | ```
62 | ====
63 | F || 1 2 3
64 | product_category || Electronics Electronics Fashion
65 | competitor_pricing || "Higher than Competitor" "Lower than Competitor" "Same as Competitor"
66 | product_features || "More Features" "Same Features" "New Features"
67 | ====
68 | launch_decision || Launch Launch "Do Not Launch"
69 | reasoning || "Competitive Advantage" "Price Advantage" "Lack of Differentiation"
70 | ```
71 |
72 | 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 |
---|
73 | ## Input Stubs
74 |
75 | Inputs can be defined with a set of `name (type[, description])` pairs. For example:
76 |
77 | - `Age (integer)` defines an input field whose name is "age" and type is integer.
78 | - `DOB (date, date of birth)` defines a date input field with a description label.
79 |
80 | #### Name
81 |
82 | Names can contain spaces in them if they are quoted. The following names are valid:
83 |
84 | - `year_month_day`
85 | - `yearMonthDay`
86 | - `"year month day"`
87 |
88 | They will all be converted to `year_month_day`.
89 |
90 | #### Type
91 |
92 | Currently the following types are supported:
93 |
94 | - integer
95 | - float
96 | - number
97 | - string
98 | - bool
99 |
100 | When types are specified, the input value shall be of the same type as specified.
101 |
102 | ## Output Stubs
103 |
104 | Output stubs are defined as `name (type[, description])` where
105 |
106 | - name can be a string which will be converted to an underscored atom;
107 | - type can be either of the supported types (the same as inputs, see above section);
108 | - description is optional and is currently ignored.
109 |
110 | ## Rules
111 |
112 | After output stub definitions, each of the following rows defines a rule entry, with the format:
113 |
114 | ```
115 | rule_number input_exp_1 input_exp_2 ... input_exp_m || output_exp_1 output_exp_2 ... output_exp_n
116 | ```
117 |
118 | Rule number is primarily used for ordering. The rule with the lowest rule number has the highest priority.
119 | Input expressions and output expressions are separated by "||".
120 |
121 |
122 | ## Expression
123 |
124 | Currently only these types are supported:
125 |
126 | - literal numeric value: integer and float (without scientific notation)
127 | - literal quoted string in `"`
128 | - boolean
129 | - comparison: `>`, `>=`, `<`, `<=`
130 | - range, e.g. `5..10`
131 | - nil ("null")
132 | - list of numeric, string, range, bool, nil or comparison; can be mixed
133 | - any ("-")
134 |
135 | The following types of expressions are planned:
136 |
137 | - date
138 | - time
139 | - datetime
140 | - function
141 |
142 | ## Hit policies
143 |
144 | There are several hit policies to indicate how matched rules are applied.
145 |
146 | - `F (First matched)` - the first matched rule will be applied.
147 | - `C (Collect)` - all matched rules will be collected into result list.
148 | - `M (Merge)` - all matched rules will be reduced (merged) into a single return entry, until there's no `-` in the output.
149 | - `R (Reverse Merge)` - similar to `merge` but in a reversed order.
150 |
151 | Examples:
152 |
153 | #### First Hit
154 |
155 | ``` elixir
156 | iex> table = Tablex.new("""
157 | ...> F age (integer) || f (float)
158 | ...> 1 > 60 || 3.0
159 | ...> 2 50..60 || 2.5
160 | ...> 3 31..49 || 2.0
161 | ...> 4 15..18,20..30 || 1.0
162 | ...> 5 - || 0
163 | ...> """
164 | ...> )
165 | ...>
166 | ...> Tablex.decide(table, age: 30)
167 | %{f: 1.0}
168 | iex> Tablex.decide(table, age: 55)
169 | %{f: 2.5}
170 | iex> Tablex.decide(table, age: 22)
171 | %{f: 1.0}
172 | iex> Tablex.decide(table, age: 17)
173 | %{f: 1.0}
174 | iex> Tablex.decide(table, age: 1)
175 | %{f: 0}
176 | ```
177 |
178 | ``` elixir
179 | iex> table = Tablex.new("""
180 | ...> F age (integer) years_of_service || holidays (integer)
181 | ...> 1 >=60 - || 3
182 | ...> 2 45..59 <30 || 2
183 | ...> 3 - >=30 || 22
184 | ...> 4 <18 - || 5
185 | ...> 5 - - || 10
186 | ...> """
187 | ...> )
188 | ...>
189 | ...> Tablex.decide(table, age: 46, years_of_service: 30)
190 | %{holidays: 22}
191 | ...>
192 | iex> Tablex.decide(table, age: 17, years_of_service: 5)
193 | %{holidays: 5}
194 | ...>
195 | iex> Tablex.decide(table, age: 22)
196 | %{holidays: 10}
197 | ```
198 |
199 | #### Collect
200 |
201 | Here's an example of `collect` hit policy:
202 |
203 | ``` elixir
204 | iex> table = Tablex.new("""
205 | ...> C order_amount membership || discount
206 | ...> 1 >=100 false || "Free cupcake"
207 | ...> 2 >=100 true || "Free icecream"
208 | ...> 3 - true || "20% OFF"
209 | ...> """
210 | ...> )
211 | ...>
212 | iex> Tablex.decide(table, order_amount: 500, membership: false)
213 | [%{discount: "Free cupcake"}]
214 | ...>
215 | iex> Tablex.decide(table, order_amount: 500, membership: true)
216 | [%{discount: "Free icecream"}, %{discount: "20% OFF"}]
217 | ...>
218 | iex> Tablex.decide(table, order_amount: 80)
219 | []
220 | ```
221 |
222 | Collect policy can work without any input:
223 |
224 | ``` elixir
225 | iex> table = Tablex.new("""
226 | ...> C || country feature_version
227 | ...> 1 || "New Zealand" 3
228 | ...> 2 || "Japan" 2
229 | ...> 3 || "Brazil" 2
230 | ...> """
231 | ...> )
232 | ...>
233 | iex> Tablex.decide(table, [])
234 | [%{country: "New Zealand", feature_version: 3}, %{country: "Japan", feature_version: 2}, %{country: "Brazil", feature_version: 2}]
235 | ```
236 |
237 | #### Merge
238 |
239 | Here's an example of `merge` hit policy:
240 |
241 | ``` elixir
242 | iex> table = Tablex.new("""
243 | ...> M continent country province || feature1 feature2
244 | ...> 1 Asia Thailand - || true true
245 | ...> 2 America Canada BC,ON || - true
246 | ...> 3 America Canada - || true false
247 | ...> 4 America US - || false false
248 | ...> 5 Europe France - || true -
249 | ...> 6 Europe - - || false true
250 | ...> """
251 | ...> )
252 | ...>
253 | iex> Tablex.decide(table, continent: "Asia", country: "Thailand", province: "ACR")
254 | %{feature1: true, feature2: true}
255 | ...>
256 | iex> Tablex.decide(table, continent: "America", country: "Canada", province: "BC")
257 | %{feature1: true, feature2: true}
258 | ...>
259 | iex> Tablex.decide(table, continent: "America", country: "Canada", province: "QC")
260 | %{feature1: true, feature2: false}
261 | ...>
262 | iex> Tablex.decide(table, continent: "Europe", country: "France")
263 | %{feature1: true, feature2: true}
264 | ```
265 |
266 | The rules are applied until all the output fields are determined.
267 |
268 | #### Reverse Merge
269 |
270 | The `reverse_merge` works the same as `merge` but the rule ordering is reversed:
271 |
272 | ``` elixir
273 | iex> table = Tablex.new("""
274 | ...> R continent country province || feature1 feature2
275 | ...> 1 Europe - - || false true
276 | ...> 2 Europe France - || true -
277 | ...> 3 America US - || false false
278 | ...> 4 America Canada - || true false
279 | ...> 5 America Canada BC,ON || - true
280 | ...> 6 Asia Thailand - || true true
281 | ...> """
282 | ...> )
283 | ...>
284 | iex> Tablex.decide(table, continent: "Asia", country: "Thailand", province: "ACR")
285 | %{feature1: true, feature2: true}
286 | ...>
287 | iex> Tablex.decide(table, continent: "America", country: "Canada", province: "BC")
288 | %{feature1: true, feature2: true}
289 | ...>
290 | iex> Tablex.decide(table, continent: "America", country: "Canada", province: "QC")
291 | ...>
292 | %{feature1: true, feature2: false}
293 | ...>
294 | iex> Tablex.decide(table, continent: "Europe", country: "France")
295 | %{feature1: true, feature2: true}
296 | ```
297 |
298 | ## Generating Elixir Code
299 |
300 | It is feasible to generate Elixir code from a table with `Tablex.CodeGenerate.generate/1`, as:
301 |
302 | ``` elixir
303 | table = """
304 | F credit_score employment_status debt_to_income_ratio || action
305 | 1 700 employed <0.43 || Approved
306 | 2 700 unemployed - || "Further Review"
307 | 3 <=700 - - || Denied
308 | """
309 |
310 | Tablex.CodeGenerate.generate(table)
311 | ```
312 |
313 | The code generated in the above example is:
314 |
315 | ``` elixir
316 | case {credit_score, employment_status, debt_to_income_ratio} do
317 | {700, "employed", debt_to_income_ratio}
318 | when is_number(debt_to_income_ratio) and debt_to_income_ratio < 0.43 ->
319 | %{action: "Approved"}
320 |
321 | {700, "unemployed", _} ->
322 | %{action: "Further Review"}
323 |
324 | {credit_score, _, _} when is_number(credit_score) and credit_score <= 700 ->
325 | %{action: "Denied"}
326 | end
327 | ```
328 |
329 | ## TODOs
330 |
331 | * [x] nested input, e.g. `country.name` as an input stub name
332 | * [x] nested output, e.g. `constraints.max_distance` as an output stub name
333 | * [ ] support referring to other input entries in an input entry
334 | * [x] support functions in output entries
335 | * [ ] support input validation
336 | * [ ] support output validation
337 | * [ ] support Date data type
338 | * [ ] support Time data type
339 | * [ ] support DateTime data type
340 | * [x] vertical tables
341 | * [x] rule code format
342 | * [ ] have a full specification documentation
343 |
344 | ## Installation
345 |
346 | The package can be installed by adding `tablex` to your list of dependencies in `mix.exs`:
347 |
348 | ``` elixir
349 | def deps do
350 | [
351 | {:tablex, "~> 0.1.0"}
352 | ]
353 | end
354 | ```
355 |
356 | The docs can be found at .
357 |
358 | ## Related Projects
359 |
360 | * [Tablex View](https://github.com/elixir-tablex/tablex_view) - A renderer which transforms a decision table into HTML.
361 |
362 |
363 | ## Acknowledgements
364 |
365 | - Tablex is heavily inspired by [Decision Model and Notation (DMN)](https://en.wikipedia.org/wiki/dmn) and its [FEEL][] expression language.
366 | - Tablex is built on top of the awesome [nimble_parsec][] library.
367 |
368 | ## License
369 |
370 | Tablex is open sourced under [MIT license](https://opensource.org/license/mit/).
371 |
372 | [Decision Table]: https://en.wikipedia.org/wiki/Decision_table
373 | [DMN]: https://en.wikipedia.org/wiki/dmn
374 | [FEEL]: https://kiegroup.github.io/dmn-feel-handbook/
375 | [nimble_parsec]: https://hex.pm/packages/nimble_parsec
376 |
--------------------------------------------------------------------------------
/examples.exs:
--------------------------------------------------------------------------------
1 | import Tablex
2 |
3 | sheet = ~RULES"""
4 | F Age (integer) Years_of_service || Holidays (float)
5 | 1 >=60 - || 3
6 | 2 45..59 <30 || 2
7 | 3 - >=30 || 22
8 | 4 <18 - || 5
9 | 5 - - || 10
10 | """
11 |
12 | Tablex.decide(sheet, age: 46, years_of_service: 30)
13 | # => %{holidays: 22}
14 |
--------------------------------------------------------------------------------
/guides/code_execution.md:
--------------------------------------------------------------------------------
1 | # Code Execution
2 |
3 | We can execute code in rules. This may be useful in the following scenarios:
4 |
5 | - The output of a rule is not literal but should be calculated based on the input.
6 | - One input field's value is based on another.
7 |
8 | Currently (v0.1.0), we only support execution in output fields.
9 |
10 | > ### Security Warning {: .error}
11 | > Use this feature with extreme caution! Never trust inputs from users.
12 |
13 | ## Syntax
14 |
15 | Code is wrapped in a "\`" pair, e.g.
16 |
17 | iex> table = Tablex.new("""
18 | ...> F x || abs
19 | ...> 1 >=0 || `x`
20 | ...> 2 <0 || `-x`
21 | ...> """)
22 | ...>
23 | ...> Tablex.decide(table, x: -42)
24 | %{abs: 42}
25 |
26 | ## Restriction
27 |
28 | The code is in raw Elixir format but not all syntax tokens are allowed to use. It follows the [default restriction](https://hexdocs.pm/formular/Formular.html#module-kernel-functions-and-macros) of [formular][].
29 |
30 | For example, the following code is not allowed:
31 |
32 | `MyApp.foo()` because it is not supported to invoke "." operation.
33 |
34 | ## Variables
35 |
36 | We can refer to arbitrary variable names as long as are provided in the binding argument of `Tablex.decide/2` or `Tablex/decide/3`. For example:
37 |
38 | iex> table = Tablex.new("""
39 | ...> M day_of_week || go_to_library volunteer blogging
40 | ...> 1 1 || T - -
41 | ...> 2 2 || F T -
42 | ...> 3 - || F F `week_of_month == 4`
43 | ...> """)
44 | ...>
45 | ...> Tablex.decide(table, day_of_week: 1, week_of_month: 4)
46 | %{go_to_library: true, volunteer: false, blogging: true}
47 |
48 | Note that `week_of_month` is not an input field but a bound variable on execution.
49 |
50 | ## Functions
51 |
52 | Functions are supported through [formular][]'s [custom function](https://hexdocs.pm/formular/Formular.html#module-custom-functions).
53 |
54 | Example:
55 |
56 | iex> defmodule MyFib do
57 | ...> @table Tablex.new("""
58 | ...> F x || fib
59 | ...> 1 0 || 0
60 | ...> 2 1 || 1
61 | ...> 3 >=2 || `fib(x - 1) + fib(x - 2)`
62 | ...> """)
63 | ...>
64 | ...> def fib(x) when is_integer(x) and x >= 0 do
65 | ...> %{fib: y} = Tablex.decide(@table, [x: x], context: __MODULE__)
66 | ...> y
67 | ...> end
68 | ...> end
69 | ...>
70 | ...> MyFib.fib(5)
71 | 5
72 |
73 | [Formular]: https://github.com/qhwa/formular
74 |
--------------------------------------------------------------------------------
/guides/informative_row.md:
--------------------------------------------------------------------------------
1 | # Informative Row
2 |
3 | The second row can be an informative row, to indicate the type and/or description of the variables.
4 | This is useful when the input and output are getting too long.
5 |
6 | For each stub, either an input or output, the format is the same, as:
7 |
8 | ```
9 | (type[, description])
10 | ```
11 |
12 | where type shall be one of the supported variable types, and description can be string,
13 | or not specified (`-`).
14 |
15 | ## Examples
16 |
17 | ``` elixir
18 | iex> table = Tablex.new("""
19 | ...> F symptoms test_results medical_history || treatment medication follow_up
20 | ...> (string) (string, either NORMAL or ABNORMAL) (string) || (string) (string) (integer, in weeks)
21 | ...> 1 fever,cough NORMAL "No Allergies" || Rest OTC 2
22 | ...> 2 "Chest Pain" ABNORMAL ECG,"Family Hx of Heart Disease" || Hospital Prescription 1
23 | ...> 3 Headache NORMAL "Migraine Hx" || Rest Prescription 3
24 | ...> """)
25 | ...>
26 | ...> for %{name: name, type: type, desc: desc} <- table.inputs, do: {name, type, desc}
27 | [
28 | {:symptoms, :string, nil},
29 | {:test_results, :string, "either NORMAL or ABNORMAL"},
30 | {:medical_history, :string, nil}
31 | ]
32 | ```
33 |
34 | Descriptions are optional.
35 |
36 | ## Skipping Stubs
37 |
38 | In case you want to skip some field(s), `"-"` can be used, as:
39 |
40 | ``` elixir
41 | iex> table = Tablex.new("""
42 | ...> F customer_segment purchase_frequency || campaign_type discount email_frequency
43 | ...> - (integer, times/year) || - - -
44 | ...> 1 Loyal >8 || Personalized "20%" Monthly
45 | ...> 2 Occasional 2..8 || Seasonal "10%" Quarterly
46 | ...> 3 Inactive <2 || Re-engagement "15%" Biannually
47 | ...> """)
48 | ...>
49 | ...> for %{name: name, type: type, desc: desc} <- table.inputs, do: {name, type, desc}
50 | [
51 | {:customer_segment, :undefined, nil},
52 | {:purchase_frequency, :integer, "times/year"}
53 | ]
54 | ...> Tablex.decide(table, customer_segment: "Inactive", purchase_frequency: 1)
55 | %{campaign_type: "Re-engagement", discount: "15%", email_frequency: "Biannually"}
56 | ```
57 |
--------------------------------------------------------------------------------
/guides/nested_fields.md:
--------------------------------------------------------------------------------
1 | # Nested Fields
2 |
3 | When working with nested data structures, Tablex allows either checking against deep structs or outputting values on a deep path inside a map.
4 |
5 | ## Path
6 |
7 | Paths can be defined with `.` separator. For instance, `path.to.data` which will match `%{path: %{to: data}}` when used in input stubs, or meandeeply merging `%{path: %{to: data}}` into the output when used in output stubs.
8 |
9 | ## Nested Input Fields
10 |
11 | For example, we can decide whether to report an HTTP request based on it request method, request host and response status:
12 |
13 | ``` elixir
14 | iex> table = Tablex.new("""
15 | ...> F request.method request.host response.status || report
16 | ...> 1 GET - - || F
17 | ...> 2 - example.com - || F
18 | ...> 3 - - <400 || F
19 | ...> 4 - - - || T
20 | ...> """)
21 | ...>
22 | ...> Tablex.decide(table, request: %{method: "POST", host: "myapp.com"}, response: %{status: 500})
23 | %{report: true}
24 | ...>
25 | iex> Tablex.decide(table, request: %{method: "GET", host: "myapp.com"})
26 | %{report: false}
27 | ...>
28 | iex> Tablex.decide(table, request: %{method: "POST", host: "example.com"})
29 | %{report: false}
30 | ```
31 |
32 | ## Nested Output Fields
33 |
34 | When data is nested in output stubs, it will be put in a deep path.
35 |
36 | ### Example
37 |
38 | Following is an example of using nested output stubs in a table.
39 |
40 | ``` elixir
41 | iex> table = Tablex.new("""
42 | ...> M car_size rental_duration miles_driven || price.base price.extra_mileage_fee price.insurance_fee
43 | ...> - (number, in days) (number, per day) || (number, $/day) (number, $/mile) (number, $/day)
44 | ...> 1 compact <=3 <=100 || 50 0.25 15
45 | ...> 2 mid_size 4..7 101..200 || 70 0.30 -
46 | ...> 3 full_size >7 > 200 || 90 0.35 25
47 | ...> 4 - - - || - - 20
48 | ...> """)
49 | ...>
50 | ...> Tablex.decide(
51 | ...> table,
52 | ...> car_size: "mid_size",
53 | ...> rental_duration: 7,
54 | ...> miles_driven: 101
55 | ...> )
56 | %{price: %{base: 70, extra_mileage_fee: 0.3, insurance_fee: 20}}
57 | ```
58 |
59 |
--------------------------------------------------------------------------------
/lib/tablex.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex do
2 | @moduledoc """
3 | Tablex implements Decision Table. Its goal is to make domain rules easy to maintain.
4 | """
5 |
6 | defdelegate decide(table, args), to: Tablex.Decider
7 | defdelegate decide(table, args, opts), to: Tablex.Decider
8 |
9 | @doc """
10 | Create a new table.
11 |
12 | ## Example
13 |
14 | Tablex.new(\"""
15 | F value || color
16 | 1 >90 || red
17 | 2 80..90 || orange
18 | 3 20..79 || green
19 | 4 <20 || blue
20 | \""")
21 | """
22 | @spec new(String.t(), keyword()) :: Tablex.Table.t() | Tablex.Parser.parse_error()
23 | def new(content, opts \\ []) when is_binary(content) do
24 | content
25 | |> String.trim_trailing()
26 | |> Tablex.Parser.parse(opts)
27 | end
28 |
29 | if Version.compare(System.version(), "1.15.0-dev") in [:eq, :gt] do
30 | @doc """
31 | The same as `new/2`.
32 |
33 | ## Example
34 |
35 | ~RULES\"""
36 | F value || color
37 | 1 >90 || red
38 | 2 80..90 || orange
39 | 3 20..79 || green
40 | 4 <20 || blue
41 | \"""
42 | """
43 | @spec sigil_RULES(String.t(), keyword()) :: Tablex.Table.t()
44 | def sigil_RULES(content, opts) do
45 | new(content, opts)
46 | end
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/lib/tablex/code_generate.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.CodeGenerate do
2 | @moduledoc """
3 | This module is responsible for generating Elixir code from a table.
4 | """
5 |
6 | alias Tablex.Parser
7 | alias Tablex.Table
8 | alias Tablex.Util.DeepMap
9 |
10 | @flatten_path """
11 | put_recursively = fn
12 | _, [], value, _ ->
13 | value
14 |
15 | %{} = acc, [head | rest], value, f ->
16 | v = f.(%{}, rest, value, f)
17 |
18 | Map.update(acc, head, v, fn
19 | old_v ->
20 | Map.merge(old_v, v, fn
21 | _, %{} = v1, %{} = v2 ->
22 | Map.merge(v1, v2)
23 |
24 | _, _, v2 ->
25 | v2
26 | end)
27 | end)
28 | end
29 |
30 | flatten_path = fn outputs ->
31 | Enum.reduce(outputs, %{}, fn {path, v}, acc ->
32 | acc |> put_recursively.(path, v, put_recursively)
33 | end)
34 | end
35 | """
36 |
37 | defguardp(
38 | is_literal(exp)
39 | when is_nil(exp) or is_atom(exp) or is_number(exp) or is_binary(exp) or is_map(exp)
40 | )
41 |
42 | @doc """
43 | Transform a table into Elixir code.
44 |
45 |
46 | ## Examples
47 |
48 | iex> table = \"""
49 | ...> F CreditScore EmploymentStatus Debt-to-Income-Ratio || Action
50 | ...> 1 700 employed <0.43 || Approved
51 | ...> 2 700 unemployed - || "Further Review"
52 | ...> 3 <=700 - - || Denied
53 | ...> \"""
54 | ...>
55 | ...> code = generate(table)
56 | ...> is_binary(code)
57 | true
58 |
59 |
60 |
61 | The code generated in the above example is:
62 |
63 | case {credit_score, employment_status, debt_to_income_ratio} do
64 | {700, "employed", debt_to_income_ratio}
65 | when is_number(debt_to_income_ratio) and debt_to_income_ratio < 0.43 ->
66 | %{action: "Approved"}
67 |
68 | {700, "unemployed", _} ->
69 | %{action: "Further Review"}
70 |
71 | {credit_score, _, _} when is_number(credit_score) and credit_score <= 700 ->
72 | %{action: "Denied"}
73 | end
74 |
75 | """
76 | @spec generate(String.t() | Table.t()) :: String.t()
77 | def generate(table) when is_binary(table) do
78 | table |> Parser.parse!([]) |> generate()
79 | end
80 |
81 | def generate(%Table{hit_policy: :first_hit} = table) do
82 | [
83 | "binding =\n",
84 | [" case {", top_input_tuple_expr(table), "} do\n"],
85 | rule_clauses(table) |> Enum.intersperse("\n"),
86 | "\nend"
87 | ]
88 | |> IO.iodata_to_binary()
89 | end
90 |
91 | def generate(%Table{hit_policy: :collect, rules: rules, inputs: [], outputs: out_def}) do
92 | outputs =
93 | rules
94 | |> Stream.map(fn [_, _input, {:output, outputs}] ->
95 | to_output(outputs, out_def)
96 | end)
97 | |> Enum.intersperse(", ")
98 |
99 | code = [?[ | outputs] ++ [?]]
100 | code |> IO.iodata_to_binary()
101 | end
102 |
103 | def generate(%Table{hit_policy: :collect, inputs: in_def} = table) do
104 | rule_functions =
105 | table.rules
106 | |> Enum.map_intersperse(",\n", fn [_, {:input, inputs}, {:output, outputs}] ->
107 | [
108 | "if(match?(",
109 | rule_cond(inputs, in_def),
110 | ", {",
111 | top_input_tuple_expr(table),
112 | "}), do: ",
113 | to_output(outputs, table.outputs),
114 | ?)
115 | ]
116 | end)
117 |
118 | ["for ret when not is_nil(ret) <- [", rule_functions, "], do: ret"]
119 | |> IO.iodata_to_binary()
120 | end
121 |
122 | def generate(%Table{hit_policy: :merge} = table) do
123 | empty = table.outputs |> Enum.map(fn _ -> :any end)
124 |
125 | clauses =
126 | table.rules
127 | |> Enum.map_intersperse(", ", fn
128 | [_, {:input, rule_inputs}, {:output, rule_outputs} | _] ->
129 | [
130 | "fn\n",
131 | [
132 | " ",
133 | rule_cond(rule_inputs, table.inputs),
134 | " -> ",
135 | rule_output_values(rule_outputs),
136 | "\n"
137 | ],
138 | " _ -> nil\n",
139 | "end"
140 | ]
141 | end)
142 |
143 | [
144 | "binding = {",
145 | top_input_tuple_expr(table),
146 | ?},
147 | ?\n,
148 | "out_values = [",
149 | clauses,
150 | "]\n",
151 | "|> Enum.reduce_while(",
152 | i(empty),
153 | """
154 | , fn rule_fn, acc ->
155 | case rule_fn.(binding) do
156 | output when is_list(output) ->
157 | {acc, []} =
158 | output
159 | |> Enum.reduce({[], acc}, fn
160 | :any, {acc, [h | t]} ->
161 | {[h | acc], t}
162 |
163 | other, {acc, [:any | t]} ->
164 | {[other | acc], t}
165 |
166 | _other, {acc, [other | t]} ->
167 | {[other | acc], t}
168 | end)
169 |
170 | acc = Enum.reverse(acc)
171 |
172 | if Enum.member?(acc, :any),
173 | do: {:cont, acc},
174 | else: {:halt, acc}
175 |
176 | nil ->
177 | {:cont, acc}
178 | end
179 | end)
180 | """,
181 | @flatten_path,
182 | "\n",
183 | "Stream.zip([",
184 | output_pathes(table.outputs) |> Enum.intersperse(", "),
185 | "], out_values)\n",
186 | "|> flatten_path.()"
187 | ]
188 | |> IO.iodata_to_binary()
189 | end
190 |
191 | def generate(%Table{hit_policy: :reverse_merge} = table) do
192 | table =
193 | Map.update!(table, :rules, fn rules ->
194 | Enum.reverse(rules)
195 | |> Stream.with_index(1)
196 | |> Enum.map(fn {[_ | rest], n} -> [n | rest] end)
197 | end)
198 |
199 | generate(%{table | hit_policy: :merge})
200 | end
201 |
202 | defp top_input_tuple_expr(%{inputs: inputs}) do
203 | Enum.map_intersperse(inputs, ", ", fn %{name: var, path: path} ->
204 | (path ++ [var]) |> hd() |> to_string()
205 | end)
206 | end
207 |
208 | defp rule_clauses(%{rules: rules, inputs: in_def, outputs: out_def}) do
209 | rules
210 | |> Stream.map(&rule_clause(&1, in_def, out_def))
211 | end
212 |
213 | defp rule_clause([_n, input: inputs, output: outputs], in_def, out_def) do
214 | [rule_cond(inputs, in_def), " ->\n ", to_output(outputs, out_def)]
215 | end
216 |
217 | defp rule_cond(inputs, in_def) do
218 | {patterns, guards} =
219 | inputs
220 | |> Stream.zip(in_def)
221 | |> Enum.reduce({[], []}, fn {value, df}, {p, g} ->
222 | case pattern_guard(value, df) do
223 | {pattern, guard} ->
224 | {[to_nested_pattern(pattern, df) | p], [guard | g]}
225 |
226 | pattern ->
227 | {[to_nested_pattern(pattern, df) | p], g}
228 | end
229 | end)
230 |
231 | case {patterns, guards} do
232 | {_, []} ->
233 | ["{", patterns |> Enum.reverse() |> Enum.intersperse(", "), "}"]
234 |
235 | _ ->
236 | p = ["{", patterns |> Enum.reverse() |> Enum.intersperse(", "), "}"]
237 | w = guards |> Enum.intersperse(" and ")
238 |
239 | [p, " when ", w]
240 | end
241 | end
242 |
243 | defp pattern_guard(:any, _), do: "_"
244 |
245 | defp pattern_guard({comp, number}, %{name: name, path: path}) when comp in ~w[!= < <= >= >]a do
246 | var_name = Enum.join(path ++ [name], "_")
247 |
248 | {var_name,
249 | ["is_number(", var_name, ") and ", var_name, " ", to_string(comp), " ", to_string(number)]}
250 | end
251 |
252 | defp pattern_guard(%Range{first: first, last: last}, %{name: name, path: path}) do
253 | var_name = Enum.join(path ++ [name], "_")
254 |
255 | {var_name, [var_name, " in ", "#{first}..#{last}"]}
256 | end
257 |
258 | defp pattern_guard(list, %{name: name, path: path} = var) when is_list(list) do
259 | var_name = Enum.join(path ++ [name], "_")
260 |
261 | case Enum.split_with(list, &is_literal/1) do
262 | {[], complex_values} ->
263 | join_pattern_guard(var_name, complex_values, var)
264 |
265 | {literal_values, []} ->
266 | {var_name, join_literal_pattern_guard(var_name, literal_values)}
267 |
268 | {literal_values, complex_values} ->
269 | {^var_name, complex_pattern} = join_pattern_guard(var_name, complex_values, var)
270 |
271 | {
272 | var_name,
273 | [join_literal_pattern_guard(var_name, literal_values), " or ", complex_pattern]
274 | }
275 | end
276 | end
277 |
278 | defp pattern_guard(literal, _) when is_literal(literal) do
279 | i(literal)
280 | end
281 |
282 | defp join_literal_pattern_guard(var_name, [v]) do
283 | [var_name, " == ", i(v)]
284 | end
285 |
286 | defp join_literal_pattern_guard(var_name, list) do
287 | [var_name, " in ", i(list)]
288 | end
289 |
290 | defp join_pattern_guard(var_name, list, %{name: name, path: path}) when is_list(list) do
291 | guard =
292 | list
293 | |> Stream.map(&pattern_guard(&1, %{name: name, path: path}))
294 | |> Enum.map_intersperse(" or ", fn
295 | {_var, guard} ->
296 | guard
297 |
298 | value when is_binary(value) ->
299 | [var_name, " == ", value]
300 | end)
301 |
302 | {var_name, ["(", guard, ")"]}
303 | end
304 |
305 | defp to_output(outputs, out_def) do
306 | map =
307 | Stream.zip(out_def, outputs)
308 | |> Map.new(fn
309 | {%{name: name, path: path}, value} ->
310 | {path ++ [name], value}
311 | end)
312 |
313 | map |> DeepMap.flatten() |> to_code()
314 | end
315 |
316 | defp to_code(%{} = map) do
317 | kvs =
318 | map
319 | |> Enum.map_join(", ", fn {k, v} ->
320 | [to_string(k), ": ", to_code(v)]
321 | end)
322 |
323 | ["%{", kvs, "}"]
324 | end
325 |
326 | defp to_code({:code, code}) when is_binary(code) do
327 | code
328 | end
329 |
330 | defp to_code(v) do
331 | i(v)
332 | end
333 |
334 | defp rule_output_values(outputs) do
335 | [
336 | "[",
337 | outputs
338 | |> Stream.map(&to_code/1)
339 | |> Enum.intersperse(", "),
340 | "]"
341 | ]
342 | end
343 |
344 | defp output_pathes(outputs) do
345 | for %{name: name, path: path} <- outputs, do: i(path ++ [name])
346 | end
347 |
348 | defp to_nested_pattern("_", _) do
349 | "_"
350 | end
351 |
352 | defp to_nested_pattern(flat_pattern, %{name: name, path: path}) do
353 | %{tl(path ++ [name]) => {:code, flat_pattern}}
354 | |> DeepMap.flatten()
355 | |> to_code()
356 | end
357 |
358 | defp i(v) do
359 | inspect(v, limit: :infinity, charlists: :as_lists)
360 | end
361 | end
362 |
--------------------------------------------------------------------------------
/lib/tablex/decider.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Decider do
2 | @moduledoc """
3 | Decision engine module responsible for applying a set of rules to input data
4 | and returning the output according to the hit policy.
5 | """
6 |
7 | alias Tablex.Table
8 | import Tablex.Rules, only: [match_expect?: 2]
9 |
10 | @type options :: []
11 |
12 | @doc """
13 | Run the decision process on the given table, arguments, and options.
14 | """
15 | @spec decide(Table.t(), keyword(), options()) :: map() | {:error, :hit_policy_not_implemented}
16 | def decide(table, args, opts \\ [])
17 |
18 | def decide(%Table{hit_policy: :first_hit} = table, args, opts) do
19 | context = context(table.inputs, args)
20 |
21 | select(context, table)
22 | |> maybe_exec_code(args, opts)
23 | end
24 |
25 | def decide(%Table{hit_policy: :collect} = table, args, opts) do
26 | context = context(table.inputs, args)
27 |
28 | collect(context, table)
29 | |> Enum.map(&maybe_exec_code(&1, args, opts))
30 | end
31 |
32 | def decide(%Table{hit_policy: :merge} = table, args, opts) do
33 | context = context(table.inputs, args)
34 |
35 | merge(context, table)
36 | |> maybe_exec_code(args, opts)
37 | end
38 |
39 | def decide(%Table{hit_policy: :reverse_merge} = table, args, _opts) do
40 | context = context(table.inputs, args)
41 |
42 | reverse_merge(context, table)
43 | end
44 |
45 | def decide(%Table{}, _, _) do
46 | {:error, :hit_policy_not_implemented}
47 | end
48 |
49 | def decide({:error, _} = err, _, _) do
50 | err
51 | end
52 |
53 | defp context(inputs, args) do
54 | for %{name: name, path: path} <- inputs, into: %{} do
55 | path = path ++ [name]
56 | {path, safe_get_in(args, path)}
57 | end
58 | |> flatten_path()
59 | end
60 |
61 | defp safe_get_in(nil, _any), do: nil
62 | defp safe_get_in(value, []), do: value
63 |
64 | defp safe_get_in(args, [key | rest]) do
65 | case args do
66 | %{^key => value} -> safe_get_in(value, rest)
67 | keyword when is_list(keyword) -> safe_get_in(args[key], rest)
68 | _other -> safe_get_in(get_in(args, [key]), rest)
69 | end
70 | end
71 |
72 | defp rules(%Table{rules: rules, inputs: inputs, outputs: outputs}) do
73 | rules
74 | |> Stream.map(fn [n, input: input_values, output: output_values] ->
75 | {n, condition(input_values, inputs), output(output_values, outputs)}
76 | end)
77 | |> Enum.sort_by(fn {n, _, _} -> n end)
78 | |> Enum.map(fn {_, condition, output} -> {condition, output} end)
79 | end
80 |
81 | defp condition(input_values, defs) do
82 | for {v, %{name: var, path: path}} <- Enum.zip(input_values, defs), into: %{} do
83 | {path ++ [var], v}
84 | end
85 | end
86 |
87 | defp output(output_values, defs) do
88 | for {v, %{name: var, path: path}} <- Enum.zip(output_values, defs), into: %{} do
89 | {path ++ [var], v}
90 | end
91 | end
92 |
93 | defp select(context, %Table{} = table) do
94 | rules = rules(table)
95 |
96 | hit =
97 | rules
98 | |> Enum.find(fn {condition, _} ->
99 | match_rule?(condition, context)
100 | end)
101 |
102 | case hit do
103 | {_condition, output} ->
104 | output |> flatten_path()
105 |
106 | nil ->
107 | nil
108 | end
109 | end
110 |
111 | defp collect(context, %Table{} = table) do
112 | table
113 | |> rules()
114 | |> Stream.filter(fn {condition, _} ->
115 | match_rule?(condition, context)
116 | end)
117 | |> Stream.map(fn {_condition, outputs} ->
118 | flatten_path(outputs)
119 | end)
120 | |> Enum.to_list()
121 | end
122 |
123 | defp merge(context, %Table{outputs: outputs} = table) do
124 | empty = for %{name: var, path: path} <- outputs, into: %{}, do: {path ++ [var], :undefined}
125 |
126 | table
127 | |> rules()
128 | |> Stream.filter(fn {condition, _} ->
129 | match_rule?(condition, context)
130 | end)
131 | |> Stream.map(fn {_condition, outputs} -> outputs end)
132 | |> Enum.reduce_while(empty, &merge_if_containing_undf/2)
133 | |> flatten_path()
134 | end
135 |
136 | defp merge_if_containing_undf(output, acc) do
137 | acc =
138 | Enum.reduce(output, acc, fn
139 | {_, :any}, acc ->
140 | acc
141 |
142 | {k, v}, acc ->
143 | case Map.get(acc, k) do
144 | :undefined ->
145 | Map.put(acc, k, v)
146 |
147 | _ ->
148 | acc
149 | end
150 | end)
151 |
152 | all_hit =
153 | acc |> Map.values() |> Enum.all?(&(&1 != :undefined))
154 |
155 | if all_hit, do: {:halt, acc}, else: {:cont, acc}
156 | end
157 |
158 | defp reverse_merge(context, %Table{} = table) do
159 | table =
160 | Map.update!(table, :rules, fn rules ->
161 | Stream.map(rules, fn [number | rest] ->
162 | [-number | rest]
163 | end)
164 | end)
165 |
166 | merge(context, table)
167 | end
168 |
169 | def match_rule?(condition, context) do
170 | Enum.all?(condition, fn {key, expect} ->
171 | match_expect?(expect, get_in(context, key))
172 | end)
173 | end
174 |
175 | defp flatten_path(outputs) do
176 | Enum.reduce(outputs, %{}, fn {path, v}, acc ->
177 | acc |> put_recursively(path, v)
178 | end)
179 | end
180 |
181 | defp put_recursively(%{} = acc, [path], value) do
182 | Map.put(acc, path, value)
183 | end
184 |
185 | defp put_recursively(%{} = acc, [head | rest], value) do
186 | v = put_recursively(%{}, rest, value)
187 | Map.update(acc, head, v, &Map.merge(&1, v))
188 | end
189 |
190 | defp maybe_exec_code(output, binding, opts) do
191 | Map.new(output, fn
192 | {k, {:code, "" <> code}} ->
193 | {:ok, ret} = Formular.eval(code, binding, opts)
194 | {k, ret}
195 |
196 | {k, v} ->
197 | {k, v}
198 | end)
199 | end
200 | end
201 |
--------------------------------------------------------------------------------
/lib/tablex/formatter.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Formatter do
2 | @moduledoc """
3 | This module is responsible for turning a table into text.
4 | """
5 | alias Tablex.Table
6 |
7 | def to_s(%Table{table_dir: :h} = table) do
8 | Tablex.Formatter.Horizontal.to_s(table)
9 | end
10 |
11 | def to_s(%Table{table_dir: :v} = table) do
12 | Tablex.Formatter.Vertical.to_s(table)
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/tablex/formatter/align.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Formatter.Align do
2 | def align_columns(lines) do
3 | lines
4 | |> Stream.zip()
5 | |> Stream.map(fn columns ->
6 | columns = Tuple.to_list(columns)
7 |
8 | max_len =
9 | columns
10 | |> Stream.map(&string_width/1)
11 | |> Enum.max()
12 |
13 | columns
14 | |> Enum.map(&pad_trailing(&1, max_len))
15 | end)
16 | |> Stream.zip()
17 | |> Enum.map(fn line ->
18 | line
19 | |> Tuple.to_list()
20 | |> Enum.join(" ")
21 | |> String.trim_trailing()
22 | end)
23 | end
24 |
25 | defp string_width("" <> str),
26 | do: Ucwidth.width(str)
27 |
28 | defp pad_trailing(str, max_len) do
29 | case string_width(str) do
30 | n when n > max_len -> str
31 | n -> str <> String.duplicate(" ", max_len - n)
32 | end
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/lib/tablex/formatter/hit_policy.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Formatter.HitPolicy do
2 | def render_hit_policy(%{hit_policy: :first_hit}), do: "F"
3 | def render_hit_policy(%{hit_policy: :merge}), do: "M"
4 | def render_hit_policy(%{hit_policy: :collect}), do: "C"
5 | def render_hit_policy(%{hit_policy: :reverse_merge}), do: "R"
6 | end
7 |
--------------------------------------------------------------------------------
/lib/tablex/formatter/horizontal.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Formatter.Horizontal do
2 | alias Tablex.Table
3 |
4 | import Tablex.Formatter.HitPolicy, only: [render_hit_policy: 1]
5 | import Tablex.Formatter.Variable, only: [render_var_def: 2, render_var_desc: 1]
6 | import Tablex.Formatter.Align, only: [align_columns: 1]
7 |
8 | def to_s(%Table{} = table) do
9 | (render_header(table) ++ render_rules(table))
10 | |> align_columns()
11 | |> Enum.join("\n")
12 | end
13 |
14 | defp render_header(table) do
15 | has_info_row? = has_info_row?(table)
16 |
17 | first_row =
18 | [render_hit_policy(table)]
19 | |> Enum.concat(render_input_defs(table, has_info_row?))
20 | |> Enum.concat(["||"])
21 | |> Enum.concat(render_output_defs(table, has_info_row?))
22 |
23 | [
24 | first_row | render_info_row(table)
25 | ]
26 | end
27 |
28 | defp has_info_row?(table) do
29 | Enum.any?(table.inputs, &has_desc?/1) or Enum.any?(table.outputs, &has_desc?/1)
30 | end
31 |
32 | defp has_desc?(%{desc: desc}) when is_binary(desc), do: true
33 | defp has_desc?(_), do: false
34 |
35 | defp render_input_defs(%{inputs: inputs}, has_info_row?) do
36 | inputs
37 | |> Enum.map(&render_var_def(&1, has_info_row?))
38 | end
39 |
40 | defp render_output_defs(%{outputs: outputs}, has_info_row?) do
41 | outputs
42 | |> Enum.map(&render_var_def(&1, has_info_row?))
43 | end
44 |
45 | defp render_info_row(table) do
46 | if has_info_row?(table) do
47 | [
48 | [" "] ++
49 | (table.inputs |> Enum.map(&render_var_desc/1)) ++
50 | ["||"] ++
51 | (table.outputs |> Enum.map(&render_var_desc/1))
52 | ]
53 | else
54 | []
55 | end
56 | end
57 |
58 | defp render_rules(table) do
59 | table.rules
60 | |> Stream.with_index(1)
61 | |> Enum.map(&render_rule/1)
62 | end
63 |
64 | defp render_rule({[_original_id, {:input, input_values}, {:output, output_values}], id}) do
65 | [
66 | to_string(id),
67 | Enum.map(input_values, &render_value/1),
68 | "||",
69 | Enum.map(output_values, &render_value/1)
70 | ]
71 | |> List.flatten()
72 | end
73 |
74 | defp render_value(value) do
75 | Tablex.Formatter.Value.render_value(value)
76 | |> IO.iodata_to_binary()
77 | end
78 | end
79 |
--------------------------------------------------------------------------------
/lib/tablex/formatter/value.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Formatter.Value do
2 | @comparisons ~w[!= >= > <= <]a
3 |
4 | @doc """
5 | Render a value into inspectable text.
6 | """
7 | @spec render_value(any) :: IO.chardata()
8 | def render_value(value) when is_list(value) do
9 | ["[", Stream.map(value, &render_value/1) |> Enum.intersperse(","), "]"]
10 | end
11 |
12 | def render_value({:code, code}), do: "`#{code}`"
13 | def render_value(:any), do: "-"
14 | def render_value(true), do: "yes"
15 | def render_value(false), do: "no"
16 | def render_value(n) when is_number(n), do: to_string(n)
17 | def render_value(nil), do: "null"
18 | def render_value(%Range{first: first, last: last}), do: "#{first}..#{last}"
19 |
20 | def render_value({:!=, n}), do: "!=#{n}"
21 |
22 | def render_value({comparison, n}) when comparison in @comparisons and is_number(n),
23 | do: "#{comparison}#{n}"
24 |
25 | def render_value(str) when is_binary(str) do
26 | maybe_quoted(str)
27 | end
28 |
29 | def render_value(value) do
30 | inspect(value)
31 | end
32 |
33 | defp maybe_quoted(str) do
34 | case Tablex.Parser.expr(str) do
35 | {:ok, [^str], "", _, _, _} ->
36 | str
37 |
38 | _ ->
39 | inspect(str)
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/lib/tablex/formatter/variable.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Formatter.Variable do
2 | alias Tablex.Variable
3 |
4 | def render_var_def(%Variable{} = var, true) do
5 | maybe_quoted(var.label)
6 | end
7 |
8 | def render_var_def(%Variable{} = var, false) do
9 | case var.type do
10 | :undefined ->
11 | maybe_quoted(var.label)
12 |
13 | type ->
14 | [maybe_quoted(var.label), " (", render_var_type(type), ")"]
15 | |> IO.iodata_to_binary()
16 | end
17 | end
18 |
19 | def maybe_quoted(str) do
20 | if String.contains?(str, " ") do
21 | inspect(str)
22 | else
23 | str
24 | end
25 | end
26 |
27 | def render_var_type(:undefined) do
28 | []
29 | end
30 |
31 | def render_var_type(type) do
32 | [type_to_string(type)]
33 | end
34 |
35 | def type_to_string(type), do: to_string(type)
36 |
37 | def render_var_desc(%Variable{type: :undefined}), do: "-"
38 |
39 | def render_var_desc(%Variable{type: type, desc: desc}) when not is_nil(desc),
40 | do: ["(", type_to_string(type), ", ", desc, ")"] |> IO.iodata_to_binary()
41 |
42 | def render_var_desc(%Variable{type: type}),
43 | do: ["(", type_to_string(type), ")"] |> IO.iodata_to_binary()
44 | end
45 |
--------------------------------------------------------------------------------
/lib/tablex/formatter/vertical.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Formatter.Vertical do
2 | alias Tablex.Table
3 |
4 | import Tablex.Formatter.HitPolicy, only: [render_hit_policy: 1]
5 | import Tablex.Formatter.Variable, only: [render_var_def: 2, render_var_desc: 1]
6 | import Tablex.Formatter.Align, only: [align_columns: 1]
7 |
8 | def to_s(%Table{} = table) do
9 | lines =
10 | [render_first_line(table) | render_inputs(table)] ++ render_outputs(table)
11 |
12 | lines
13 | |> align_columns()
14 | |> add_hr_at(input_size(table) + 1)
15 | |> add_hr_at(0)
16 | |> Enum.intersperse("\n")
17 | |> IO.iodata_to_binary()
18 | end
19 |
20 | defp render_first_line(table) do
21 | [
22 | render_hit_policy(table),
23 | "||" | render_rule_numbers(table)
24 | ]
25 | end
26 |
27 | defp render_rule_numbers(table) do
28 | table.rules
29 | |> Stream.with_index(1)
30 | |> Enum.map(fn {_, id} -> to_string(id) end)
31 | end
32 |
33 | defp render_inputs(table) do
34 | table.rules
35 | |> Stream.map(fn [_id, {:input, inputs} | _] -> inputs end)
36 | |> Stream.zip()
37 | |> Stream.zip(table.inputs)
38 | |> Stream.map(fn {values, input_def} ->
39 | [render_var(input_def, false), "||" | render_values(values)]
40 | end)
41 | |> Enum.to_list()
42 | end
43 |
44 | defp render_var(%{desc: nil} = var_def, _) do
45 | render_var_def(var_def, false)
46 | end
47 |
48 | defp render_var(var_def, _) do
49 | render_var_def(var_def, true) <> " " <> render_var_desc(var_def)
50 | end
51 |
52 | defp render_values(values) do
53 | values |> Tuple.to_list() |> Enum.map(&render_value/1)
54 | end
55 |
56 | defp render_value(value) do
57 | Tablex.Formatter.Value.render_value(value)
58 | |> IO.iodata_to_binary()
59 | end
60 |
61 | defp render_outputs(table) do
62 | table.rules
63 | |> Stream.map(fn [_id, _, {:output, outputs} | _] -> outputs end)
64 | |> Stream.zip()
65 | |> Stream.zip(table.outputs)
66 | |> Stream.map(fn {values, output_def} ->
67 | [render_var(output_def, false), "||" | render_values(values)]
68 | end)
69 | |> Enum.to_list()
70 | end
71 |
72 | defp input_size(%{inputs: inputs}) do
73 | length(inputs)
74 | end
75 |
76 | defp add_hr_at(lines, pos) do
77 | List.insert_at(lines, pos, hr())
78 | end
79 |
80 | defp hr, do: "===="
81 | end
82 |
--------------------------------------------------------------------------------
/lib/tablex/hit_policy.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.HitPolicy do
2 | @moduledoc """
3 | This module defines hit policies.
4 | """
5 |
6 | @type hit_policy :: :first_hit | :collect | :merge | :reverse_merge
7 |
8 | @hit_policies [
9 | first_hit: "F",
10 | collect: "C",
11 | merge: "M",
12 | reverse_merge: "R"
13 | ]
14 |
15 | @doc """
16 | Get all supported hit policies.
17 | """
18 | @spec hit_policies() :: [{hit_policy(), mark :: String.t()}]
19 | def hit_policies do
20 | @hit_policies
21 | end
22 |
23 | @doc """
24 | """
25 | @spec to_policy(String.t()) :: hit_policy() | nil
26 | for {policy, text} <- @hit_policies do
27 | def to_policy(unquote(text)), do: unquote(policy)
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/tablex/optimizer.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Optimizer do
2 | @moduledoc """
3 | This module is responsible for optimizing a table.
4 |
5 | Optimization is done by regrouping the rules of the table.
6 | After an optimization, duplicated rules are removed, and
7 | similar rules are merged.
8 | """
9 |
10 | alias Tablex.Table
11 | alias Tablex.Optimizer.RemoveDuplication
12 | alias Tablex.Optimizer.RemoveDeadRules
13 | alias Tablex.Optimizer.RemoveEmptyRules
14 | alias Tablex.Optimizer.MergeRules
15 |
16 | @doc """
17 | Optimize a table.
18 | """
19 | @spec optimize(Table.t()) :: Table.t()
20 | def optimize(%Table{} = table) do
21 | table
22 | |> RemoveDuplication.optimize()
23 | |> RemoveDeadRules.optimize()
24 | |> RemoveEmptyRules.optimize()
25 | |> MergeRules.optimize()
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/lib/tablex/optimizer/helper.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Optimizer.Helper do
2 | @type order() :: :h2l | :l2h
3 | @type rule :: Tablex.Table.rule()
4 |
5 | @doc """
6 | Order a list of table rules by priority, high to low.
7 | """
8 | def order_by_priority_high_to_lower(rules, hp),
9 | do: order_by_priority(rules, :h2l, hp)
10 |
11 | @doc """
12 | Order an already sorted, list of rules by hit policy.
13 | """
14 | @spec order_by_priority([rule()], current_order :: order(), Tablex.HitPolicy.hit_policy()) ::
15 | [rule()]
16 | def order_by_priority(rules, :h2l, :reverse_merge),
17 | do: rules |> Enum.sort_by(fn_reverse_orders())
18 |
19 | def order_by_priority(rules, :h2l, _),
20 | do: rules |> Enum.sort_by(fn_keep_orders())
21 |
22 | def order_by_priority(rules, :l2h, :reverse_merge),
23 | do: rules |> Enum.sort_by(fn_keep_orders())
24 |
25 | def order_by_priority(rules, :l2h, _),
26 | do: rules |> Enum.sort_by(fn_reverse_orders())
27 |
28 | defp fn_keep_orders, do: fn [n | _] -> n end
29 | defp fn_reverse_orders, do: fn [n | _] -> -n end
30 |
31 | @doc """
32 | Sort the rules according to a hit policy.
33 | """
34 | def sort_rules(rules, hit_policy) do
35 | sorting =
36 | case hit_policy do
37 | :reverse_merge -> :l2h
38 | _ -> :h2l
39 | end
40 |
41 | order_by_priority(rules, sorting, hit_policy)
42 | end
43 |
44 | @doc """
45 | Fix ids of rules.
46 | """
47 | @spec fix_ids([rule()]) :: [rule()]
48 | def fix_ids(rules) do
49 | Stream.with_index(rules, 1)
50 | |> Enum.map(fn {[_ | tail], id} -> [id | tail] end)
51 | end
52 |
53 | @doc """
54 | Check if a input condition covers by another.
55 | """
56 | @spec cover_input?(covering :: any(), target :: any()) :: boolean()
57 | def cover_input?(input, input) do
58 | true
59 | end
60 |
61 | def cover_input?(existing_input, input) do
62 | Stream.zip(existing_input, input)
63 | |> Enum.all?(fn {existing, new} -> stub_covers?(existing, new) end)
64 | end
65 |
66 | def stub_covers?(:any, _), do: true
67 | def stub_covers?(same, same), do: true
68 |
69 | def stub_covers?({:>, n}, {cmp, m})
70 | when is_number(n) and is_number(m) and n < m and cmp in [:>, :>=],
71 | do: true
72 |
73 | def stub_covers?({:>=, n}, {cmp, m})
74 | when is_number(n) and is_number(m) and n <= m and cmp in [:>=, :>],
75 | do: true
76 |
77 | def stub_covers?({:<, n}, {cmp, m})
78 | when is_number(n) and is_number(m) and n > m and cmp in [:<, :<=],
79 | do: true
80 |
81 | def stub_covers?({:<=, n}, {cmp, m})
82 | when is_number(n) and is_number(m) and n >= m and cmp in [:<, :<=],
83 | do: true
84 |
85 | def stub_covers?(expr, list) when is_list(list),
86 | do: Enum.all?(list, &stub_covers?(expr, &1))
87 |
88 | def stub_covers?(list, item) when is_list(list),
89 | do: Enum.any?(list, &stub_covers?(&1, item))
90 |
91 | def stub_covers?(_, _),
92 | do: false
93 |
94 | def cover_output?(output, output) do
95 | true
96 | end
97 |
98 | def cover_output?(high_output, low_output) do
99 | Stream.zip(high_output, low_output)
100 | |> Enum.all?(fn
101 | {:any, :any} ->
102 | true
103 |
104 | {:any, _} ->
105 | false
106 |
107 | _ ->
108 | true
109 | end)
110 | end
111 |
112 | @doc """
113 | Merge two outputs. Stub value with higher priority wins.
114 |
115 | ## Example
116 |
117 | iex > merge_outputs([1, 2, 3], [2, 4, 6])
118 | [1, 2, 3]
119 |
120 | iex > merge_outputs([:any, 2, :any], [2, :any, 6])
121 | [2, 2, 6]
122 | """
123 | def merge_outputs(high_output, low_output) do
124 | Stream.zip(high_output, low_output)
125 | |> Enum.map(fn
126 | {:any, stub} ->
127 | stub
128 |
129 | {stub, _} ->
130 | stub
131 | end)
132 | end
133 |
134 | @doc """
135 | Check if an output is meaningful. A meaningful output is one that
136 | does not contain all :any elements.
137 | """
138 |
139 | def meaningful_output?(output) do
140 | output |> Enum.any?(&(&1 != :any))
141 | end
142 |
143 | def exclusive?([_, {:input, input1} | _], [_, {:input, input2} | _]) do
144 | exclusive?(input1, input2)
145 | end
146 |
147 | def exclusive?(input1, input2) do
148 | Stream.zip(input1, input2)
149 | |> Enum.any?(fn
150 | # `a` and `b` are both specified (not `:any`) and they are
151 | # different, which means these two rules will never be both
152 | # hit by the same context.
153 | {a, b} when a != b and a != :any and b != :any ->
154 | true
155 |
156 | _ ->
157 | false
158 | end)
159 | end
160 |
161 | def input_mergeable?(input1, input2) do
162 | cover_input?(input1, input2) or cover_input?(input2, input1) or
163 | only_one_different_stub?(input1, input2)
164 | end
165 |
166 | defp only_one_different_stub?(input1, input2) do
167 | diff =
168 | Stream.zip(input1, input2)
169 | |> Stream.reject(fn
170 | {same, same} -> true
171 | _ -> false
172 | end)
173 | |> Enum.count()
174 |
175 | diff == 1
176 | end
177 |
178 | @doc """
179 | Merge two inputs.
180 | """
181 | def merge_inputs(input1, input2) do
182 | Stream.zip(input1, input2)
183 | |> Enum.map(fn {a, b} -> merge_input_stubs(a, b) end)
184 | end
185 |
186 | @doc """
187 | Merge two input stubs
188 | """
189 | def merge_input_stubs(expr, expr), do: expr
190 |
191 | def merge_input_stubs(expr1, expr2) do
192 | cond do
193 | stub_covers?(expr1, expr2) ->
194 | expr1
195 |
196 | stub_covers?(expr2, expr1) ->
197 | expr2
198 |
199 | true ->
200 | List.flatten([expr1, expr2]) |> Enum.uniq() |> Enum.sort()
201 | end
202 | end
203 | end
204 |
--------------------------------------------------------------------------------
/lib/tablex/optimizer/merge_rules.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Optimizer.MergeRules do
2 | @moduledoc """
3 | This module is responsible for optimizing a table by removing dead rules.
4 | """
5 |
6 | alias Tablex.Table
7 | alias Tablex.Util.ListBreaker
8 |
9 | @hp_first_hit :first_hit
10 | @hp_merge :merge
11 | @hp_reverse_merge :reverse_merge
12 |
13 | import Tablex.Optimizer.Helper,
14 | only: [
15 | fix_ids: 1,
16 | merge_outputs: 2,
17 | merge_inputs: 2,
18 | input_mergeable?: 2,
19 | exclusive?: 2,
20 | order_by_priority_high_to_lower: 2,
21 | sort_rules: 2
22 | ]
23 |
24 | def optimize(%Table{} = table) do
25 | table
26 | |> break_list_rules()
27 | |> merge_rules_by_same_input()
28 | |> merge_rules_by_same_output()
29 | |> Map.update!(:rules, &fix_ids/1)
30 | end
31 |
32 | defp break_list_rules(%Table{rules: rules} = table) do
33 | rules =
34 | rules
35 | |> Enum.flat_map(fn [i, {:input, input}, {:output, output}] ->
36 | input
37 | |> ListBreaker.flatten_list()
38 | |> Enum.map(&[i, {:input, &1}, {:output, output}])
39 | end)
40 |
41 | %{table | rules: rules}
42 | end
43 |
44 | # if two rules have the same input, the rule with lower priority would be examined
45 | # to have only stubs that are not covered by the other rule. If all stubs after
46 | # the examination of the lower priority rule are covered by the other rule, then
47 | # the lower priority rule is removed directly.
48 | defp merge_rules_by_same_input(%Table{rules: rules, hit_policy: @hp_merge} = table) do
49 | # For tables with `:merge` hit policy, the higher priority the rule has, the
50 | # higher the prosition it is.
51 | %{
52 | table
53 | | rules:
54 | rules
55 | |> order_by_priority_high_to_lower(@hp_merge)
56 | |> do_merge_rules_by_same_input()
57 | }
58 | end
59 |
60 | defp merge_rules_by_same_input(%Table{rules: rules, hit_policy: @hp_reverse_merge} = table) do
61 | %{
62 | table
63 | | rules:
64 | rules
65 | |> order_by_priority_high_to_lower(@hp_reverse_merge)
66 | |> do_merge_rules_by_same_input()
67 | |> sort_rules(@hp_reverse_merge)
68 | }
69 | end
70 |
71 | defp merge_rules_by_same_input(%Table{} = table) do
72 | table
73 | end
74 |
75 | defp do_merge_rules_by_same_input(rules) when is_list(rules) do
76 | do_merge_rules_by_same_input(rules, [])
77 | end
78 |
79 | defp do_merge_rules_by_same_input([], merged) do
80 | merged
81 | end
82 |
83 | defp do_merge_rules_by_same_input([rule | rest], merged) do
84 | do_merge_rules_by_same_input(rest, try_merge_input(rule, merged))
85 | |> Enum.reverse()
86 | end
87 |
88 | defp try_merge_input(rule, []) do
89 | [rule]
90 | end
91 |
92 | defp try_merge_input(
93 | [n, {:input, input}, {:output, low_output}],
94 | [[_, {:input, input}, {:output, high_output}] | rest]
95 | ) do
96 | [[n, {:input, input}, {:output, merge_outputs(high_output, low_output)}] | rest]
97 | end
98 |
99 | defp try_merge_input(rule, [head | rest]) do
100 | if exclusive?(head, rule),
101 | do: [head | try_merge_input(rule, rest)],
102 | else: [rule, head | rest]
103 | end
104 |
105 | defp merge_rules_by_same_output(%Table{rules: rules, hit_policy: hp} = table)
106 | when hp in [@hp_first_hit, @hp_merge, @hp_reverse_merge] do
107 | rules =
108 | rules
109 | |> order_by_priority_high_to_lower(hp)
110 | |> do_merge_rules_by_same_output()
111 | |> sort_rules(hp)
112 |
113 | %{table | rules: rules}
114 | end
115 |
116 | defp merge_rules_by_same_output(%Table{} = table),
117 | do: table
118 |
119 | defp do_merge_rules_by_same_output(rules) when is_list(rules) do
120 | merged = do_merge_rules_by_same_output(rules, [])
121 |
122 | case do_merge_rules_by_same_output(merged, []) do
123 | ^merged -> merged
124 | acc -> do_merge_rules_by_same_output(acc, [])
125 | end
126 | end
127 |
128 | defp do_merge_rules_by_same_output([], acc), do: acc
129 |
130 | defp do_merge_rules_by_same_output([rule | rest], acc) do
131 | do_merge_rules_by_same_output(rest, try_merge_output(rule, acc))
132 | end
133 |
134 | defp try_merge_output(rule, []) do
135 | [rule]
136 | end
137 |
138 | defp try_merge_output(
139 | [n, {:input, low_input}, {:output, same_output}] = r1,
140 | [[_, {:input, high_input}, {:output, same_output}] = r2 | rest]
141 | ) do
142 | cond do
143 | input_mergeable?(low_input, high_input) ->
144 | [
145 | [n, {:input, merge_inputs(low_input, high_input)}, {:output, same_output}]
146 | | rest
147 | ]
148 |
149 | exclusive?(r1, r2) ->
150 | [r2 | try_merge_output(r1, rest)]
151 |
152 | :otherwise ->
153 | [r2, r1 | rest]
154 | end
155 | end
156 |
157 | defp try_merge_output(rule, [head | rest]) do
158 | if exclusive?(head, rule),
159 | do: [head | try_merge_output(rule, rest)],
160 | else: [rule, head | rest]
161 | end
162 | end
163 |
--------------------------------------------------------------------------------
/lib/tablex/optimizer/remove_dead_rules.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Optimizer.RemoveDeadRules do
2 | @moduledoc """
3 | This module is responsible for optimizing a table by removing dead rules.
4 | """
5 |
6 | alias Tablex.Table
7 |
8 | import Tablex.Optimizer.Helper,
9 | only: [
10 | order_by_priority: 3,
11 | cover_input?: 2,
12 | cover_output?: 2,
13 | sort_rules: 2
14 | ]
15 |
16 | def optimize(%Table{} = table) do
17 | remove_dead_rules(table)
18 | end
19 |
20 | defp remove_dead_rules(%Table{hit_policy: hit_policy} = table)
21 | when hit_policy in [:first_hit, :merge, :reverse_merge] do
22 | Map.update!(table, :rules, &remove_dead_rules(&1, hit_policy))
23 | end
24 |
25 | defp remove_dead_rules(%Table{} = table),
26 | do: table
27 |
28 | defp remove_dead_rules(rules, hit_policy) do
29 | rules = rules |> order_by_priority(:h2l, hit_policy)
30 |
31 | rules
32 | |> Enum.reduce([], fn low_rule, acc ->
33 | covered? =
34 | Enum.any?(acc, fn high_rule ->
35 | fully_covers?(high_rule, low_rule, hit_policy)
36 | end)
37 |
38 | if covered? do
39 | acc
40 | else
41 | [low_rule | acc]
42 | end
43 | end)
44 | |> sort_rules(hit_policy)
45 | end
46 |
47 | defp fully_covers?([_, {:input, input1} | _], [_, {:input, input2} | _], :first_hit) do
48 | cover_input?(input1, input2)
49 | end
50 |
51 | defp fully_covers?(
52 | [_, {:input, input1}, {:output, output1}],
53 | [_, {:input, input2}, {:output, output2}],
54 | _
55 | ) do
56 | cover_input?(input1, input2) and cover_output?(output1, output2)
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/lib/tablex/optimizer/remove_duplication.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Optimizer.RemoveDuplication do
2 | @moduledoc """
3 | This module is responsible for optimizing a table by removing duplicated rules.
4 | """
5 |
6 | alias Tablex.Table
7 |
8 | import Tablex.Optimizer.Helper,
9 | only: [
10 | order_by_priority: 3,
11 | sort_rules: 2,
12 | fix_ids: 1
13 | ]
14 |
15 | def optimize(%Table{} = table) do
16 | remove_duplicated_rules(table)
17 | end
18 |
19 | defp remove_duplicated_rules(table) do
20 | Map.update!(table, :rules, &remove_duplicated_rules(&1, table.hit_policy))
21 | end
22 |
23 | defp remove_duplicated_rules(rules, :collect),
24 | do: rules
25 |
26 | defp remove_duplicated_rules(rules, hit_policy) do
27 | rules
28 | |> order_by_priority(:h2l, hit_policy)
29 | |> do_remove_same_rules({MapSet.new(), []})
30 | |> sort_rules(hit_policy)
31 | |> fix_ids()
32 | end
33 |
34 | defp do_remove_same_rules([], {_, acc}) do
35 | acc
36 | end
37 |
38 | defp do_remove_same_rules([[_id | key] = rule | rest], {set, acc}) do
39 | if MapSet.member?(set, key) do
40 | do_remove_same_rules(rest, {set, acc})
41 | else
42 | do_remove_same_rules(rest, {MapSet.put(set, key), [rule | acc]})
43 | end
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/lib/tablex/optimizer/remove_empty_rules.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Optimizer.RemoveEmptyRules do
2 | @moduledoc """
3 | This module is responsible for optimizing a table by removing rules wihtout sense
4 | which have all `any` values in the output.
5 | """
6 |
7 | alias Tablex.Table
8 |
9 | def optimize(%Table{hit_policy: hp} = table) when hp in [:merge, :reverse_merge],
10 | do: remove_no_value_rules(table)
11 |
12 | def optimize(table),
13 | do: table
14 |
15 | defp remove_no_value_rules(%Table{} = table),
16 | do:
17 | Map.update!(table, :rules, fn rules ->
18 | rules |> Enum.reject(&no_value?(&1))
19 | end)
20 |
21 | defp no_value?([_id, _, {:output, output}]),
22 | do: Enum.all?(output, &(&1 == :any))
23 | end
24 |
--------------------------------------------------------------------------------
/lib/tablex/parser.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser do
2 | @moduledoc """
3 | Parser is responsible for parsing the table from text to a Tablex.Table struct.
4 |
5 | ## Hit Policies
6 |
7 | Currently the following hit policies are supported:
8 |
9 | - "F" for `:first_hit`
10 | - "C" for `:collect`
11 | - "M" for `:merge`
12 | - "R" for `:reverse_merge`
13 | """
14 | alias Tablex.Table
15 |
16 | use Tablex.Parser.Expression.List
17 |
18 | require Logger
19 |
20 | import NimbleParsec
21 | import Tablex.Parser.HorizontalTable
22 | import Tablex.Parser.VerticalTable
23 |
24 | @type parse_error() ::
25 | {:error,
26 | {:tablex_parse_error, {location(), reason :: :invalid | term(), rest :: binary()}}}
27 | @type location() :: [{:line, non_neg_integer()} | {:column, non_neg_integer()}]
28 |
29 | table =
30 | choice([
31 | h_table() |> tag(:horizontal),
32 | v_table() |> tag(:vertical)
33 | ])
34 |
35 | defparsec(:table, table, debug: false)
36 | defparsec(:expr, Tablex.Parser.Expression.expression())
37 |
38 | @doc """
39 | Parse a string into a table struct.
40 |
41 | ## Returns
42 |
43 | `%Tablex.Table{...}` if succeeds, other wise `{:error, {:tablex_parse_error, location, reason, rest}}`
44 | """
45 | @spec parse(String.t(), []) :: Table.t() | parse_error()
46 | def parse(content, _opts) do
47 | case table(content) do
48 | {:ok, table, "", _context, _, _} ->
49 | Table.new(table)
50 |
51 | {:ok, _table, rest, _context, {line, _offset}, _column} ->
52 | print_error("unexpected input", line, 0, content)
53 | location = [line: line, column: 0]
54 | {:error, {:tablex_parse_error, {location, :invalid, rest}}}
55 |
56 | {:error, reason, rest, _context, {line, _}, column} ->
57 | print_error(reason, line, column, content)
58 | location = [line: line, column: column]
59 | {:error, {:tablex_parse_error, {location, reason, rest}}}
60 | end
61 | end
62 |
63 | defp print_error(reason, line, column, content) do
64 | Logger.critical("""
65 | Error parsing decision table [L#{line} C#{column}]:
66 |
67 | #{String.split(content, "\n") |> Enum.at(line)}
68 | #{String.duplicate(" ", column)}^ #{reason}.
69 | """)
70 | end
71 |
72 | @doc """
73 | Raising version of `parse/2`.
74 | """
75 | @spec parse(String.t(), []) :: Table.t()
76 | def parse!(text, opts) do
77 | case parse(text, opts) do
78 | %Table{} = t ->
79 | t
80 |
81 | {:error, _} ->
82 | raise "Invalid rule."
83 | end
84 | end
85 | end
86 |
--------------------------------------------------------------------------------
/lib/tablex/parser/code.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.Code do
2 | @moduledoc false
3 |
4 | import NimbleParsec
5 |
6 | @doc """
7 | Parse a code
8 | """
9 | def code do
10 | ascii_char([?`])
11 | |> ignore()
12 | |> repeat_while(
13 | choice([
14 | ~S(\`) |> string() |> replace(?`),
15 | utf8_char([])
16 | ]),
17 | {__MODULE__, :not_end, []}
18 | )
19 | |> ignore(ascii_char([?`]))
20 | |> reduce({List, :to_string, []})
21 | |> unwrap_and_tag(:code)
22 | end
23 |
24 | @doc false
25 | def not_end(<`, _::binary>>, context, _, _) do
26 | {:halt, context}
27 | end
28 |
29 | def not_end(_, context, _, _) do
30 | {:cont, context}
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/lib/tablex/parser/comparison.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.Expression.Comparison do
2 | @moduledoc false
3 |
4 | import NimbleParsec
5 | import Tablex.Parser.Space
6 | import Tablex.Parser.Expression.Numeric
7 |
8 | def comparison do
9 | choice([
10 | string("!="),
11 | string(">="),
12 | string(">"),
13 | string("<="),
14 | string("<")
15 | ])
16 | |> optional_space()
17 | |> concat(numeric())
18 | |> reduce({__MODULE__, :trans_comparison, []})
19 | end
20 |
21 | @doc false
22 | def trans_comparison([op, num]) do
23 | {:"#{op}", num}
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/tablex/parser/expression.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.Expression do
2 | @moduledoc false
3 |
4 | use Tablex.Parser.Expression.List
5 |
6 | import NimbleParsec
7 |
8 | import Tablex.Parser.Expression.Any
9 | import Tablex.Parser.Expression.Numeric
10 | import Tablex.Parser.Expression.Range
11 | import Tablex.Parser.Expression.Bool
12 | import Tablex.Parser.Expression.ImpliedString
13 | import Tablex.Parser.Expression.QuotedString
14 | import Tablex.Parser.Expression.Null
15 | import Tablex.Parser.Expression.Comparison
16 |
17 | def expression do
18 | choice([
19 | parsec(:list),
20 | any(),
21 | range(),
22 | numeric(),
23 | bool(),
24 | null(),
25 | comparison(),
26 | quoted_string(),
27 | implied_string()
28 | ])
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/tablex/parser/expression/any.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.Expression.Any do
2 | @moduledoc false
3 |
4 | import NimbleParsec
5 | import Tablex.Parser.Space
6 |
7 | def any do
8 | "-"
9 | |> string()
10 | |> lookahead(eow())
11 | |> replace(:any)
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/tablex/parser/expression/bool.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.Expression.Bool do
2 | @moduledoc false
3 |
4 | import NimbleParsec
5 | import Tablex.Parser.Space
6 |
7 | def bool do
8 | choice([
9 | true_exp(),
10 | false_exp()
11 | ])
12 | end
13 |
14 | def true_exp do
15 | choice([
16 | string("Y"),
17 | string("T"),
18 | string("YES"),
19 | string("yes"),
20 | string("true")
21 | ])
22 | |> lookahead(eow())
23 | |> replace(true)
24 | end
25 |
26 | def false_exp do
27 | choice([
28 | string("N"),
29 | string("F"),
30 | string("NO"),
31 | string("no"),
32 | string("false")
33 | ])
34 | |> lookahead(eow())
35 | |> replace(false)
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/tablex/parser/expression/float.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.Expression.Float do
2 | @moduledoc false
3 |
4 | import NimbleParsec
5 |
6 | def float do
7 | optional(string("-"))
8 | |> ascii_string([?0..?9], min: 1)
9 | |> string(".")
10 | |> ascii_string([?0..?9], min: 1)
11 | |> reduce({__MODULE__, :trans_float, []})
12 | end
13 |
14 | @doc false
15 | def trans_float(parts) do
16 | parts |> Enum.join() |> String.to_float()
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/tablex/parser/expression/implied_string.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.Expression.ImpliedString do
2 | @moduledoc false
3 |
4 | import NimbleParsec
5 |
6 | def implied_string do
7 | utf8_string([not: 0..32, not: ?,, not: ?], not: ?[], min: 1)
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/tablex/parser/expression/integer.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.Expression.Integer do
2 | @moduledoc false
3 |
4 | import NimbleParsec
5 |
6 | def int do
7 | optional(string("-"))
8 | |> choice([
9 | underscore_int(),
10 | integer(min: 1)
11 | ])
12 | |> reduce({__MODULE__, :trans_numeric, []})
13 | end
14 |
15 | def underscore_int do
16 | ascii_string([?0..?9], min: 1)
17 | |> times(
18 | string("_")
19 | |> ignore()
20 | |> ascii_string([?0..?9], min: 1),
21 | min: 1
22 | )
23 | |> reduce({__MODULE__, :trans_underscore_int, []})
24 | end
25 |
26 | @doc false
27 | def trans_underscore_int(parts) do
28 | parts |> Enum.join() |> String.to_integer()
29 | end
30 |
31 | @doc false
32 | def trans_numeric(["-", n]), do: -n
33 | def trans_numeric([n]), do: n
34 | end
35 |
--------------------------------------------------------------------------------
/lib/tablex/parser/expression/list.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.Expression.List do
2 | @moduledoc false
3 |
4 | defmacro __using__(_) do
5 | quote do
6 | import NimbleParsec
7 | import Tablex.Parser.Expression.Range
8 | import Tablex.Parser.Expression.Numeric
9 | import Tablex.Parser.Expression.Bool
10 | import Tablex.Parser.Expression.Comparison
11 | import Tablex.Parser.Expression.QuotedString
12 | import Tablex.Parser.Expression.ImpliedString
13 | import Tablex.Parser.Expression.Null
14 | import Tablex.Parser.Space
15 |
16 | defparsec(
17 | :explicit_list,
18 | string("[")
19 | |> concat(
20 | concat(
21 | parsec(:list_item),
22 | repeat(
23 | concat(
24 | "," |> string() |> optional_space() |> ignore(),
25 | parsec(:list_item)
26 | )
27 | )
28 | )
29 | |> wrap()
30 | )
31 | |> string("]")
32 | |> reduce({unquote(__MODULE__), :trans_list, []})
33 | )
34 |
35 | defparsec(
36 | :implied_list,
37 | concat(
38 | parsec(:list_item),
39 | times(
40 | concat(
41 | "," |> string() |> optional_space() |> ignore(),
42 | parsec(:list_item)
43 | ),
44 | min: 1
45 | )
46 | )
47 | |> wrap()
48 | )
49 |
50 | defparsec(
51 | :list_item,
52 | choice([
53 | parsec(:explicit_list),
54 | range(),
55 | comparison(),
56 | numeric(),
57 | bool(),
58 | null(),
59 | quoted_string(),
60 | implied_string()
61 | ])
62 | )
63 |
64 | defparsec(
65 | :list,
66 | choice([
67 | parsec(:empty_list),
68 | parsec(:explicit_list),
69 | parsec(:implied_list)
70 | ])
71 | )
72 |
73 | defparsec(
74 | :empty_list,
75 | string("[]") |> replace([])
76 | )
77 | end
78 | end
79 |
80 | @doc false
81 | def trans_list(["[", passed, "]"]), do: passed
82 | end
83 |
--------------------------------------------------------------------------------
/lib/tablex/parser/expression/null.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.Expression.Null do
2 | @moduledoc false
3 |
4 | import NimbleParsec
5 |
6 | def null do
7 | string("null") |> replace(nil)
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/tablex/parser/expression/numeric.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.Expression.Numeric do
2 | @moduledoc false
3 |
4 | import NimbleParsec
5 | import Tablex.Parser.Expression.Integer
6 | import Tablex.Parser.Expression.Float
7 |
8 | def numeric do
9 | choice([
10 | float(),
11 | int()
12 | ])
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/tablex/parser/expression/quoted_string.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.Expression.QuotedString do
2 | @moduledoc false
3 |
4 | import NimbleParsec
5 |
6 | @doc """
7 | Parse a quoted string.
8 | """
9 | def quoted_string do
10 | ascii_char([?"])
11 | |> ignore()
12 | |> repeat_while(
13 | choice([
14 | ~S(\") |> string() |> replace(?"),
15 | utf8_char([])
16 | ]),
17 | {__MODULE__, :not_quoted, []}
18 | )
19 | |> ignore(ascii_char([?"]))
20 | |> reduce({List, :to_string, []})
21 | end
22 |
23 | @doc false
24 | def not_quoted(<", _::binary>>, context, _, _) do
25 | {:halt, context}
26 | end
27 |
28 | def not_quoted(_, context, _, _) do
29 | {:cont, context}
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/tablex/parser/expression/range.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.Expression.Range do
2 | @moduledoc false
3 |
4 | import NimbleParsec
5 | import Tablex.Parser.Expression.Integer
6 |
7 | def range do
8 | int()
9 | |> string("..")
10 | |> concat(int())
11 | |> reduce({__MODULE__, :trans_range, []})
12 | end
13 |
14 | def trans_range([first, "..", last]) do
15 | first..last
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/tablex/parser/horizontal_table.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.HorizontalTable do
2 | @moduledoc false
3 |
4 | alias Tablex.HitPolicy
5 |
6 | import NimbleParsec
7 | import Tablex.Parser.Space
8 | import Tablex.Parser.Variable
9 | import Tablex.Parser.InformativeRow
10 | import Tablex.Parser.Rule
11 |
12 | def h_table do
13 | choice([
14 | collect_hit_policy()
15 | |> space()
16 | |> times(input_field(), min: 0)
17 | |> label("`C` hit policy and optional input fields"),
18 | regular_hit_policy()
19 | |> space()
20 | |> times(input_field(), min: 1)
21 | |> label("input definitions")
22 | ])
23 | |> label("table header")
24 | |> concat(io_sperator())
25 | |> concat(space())
26 | |> times(output_field(), min: 1)
27 | |> newline()
28 | |> concat(informative_row() |> newline() |> unwrap_and_tag(:info) |> optional())
29 | |> rules()
30 | end
31 |
32 | # Only with collect hit policy, there can be zero input stubs.
33 | def collect_hit_policy do
34 | string("C")
35 | |> map({HitPolicy, :to_policy, []})
36 | |> unwrap_and_tag(:hit_policy)
37 | end
38 |
39 | def regular_hit_policy do
40 | choice([
41 | string("F"),
42 | string("M"),
43 | string("R")
44 | ])
45 | |> map({HitPolicy, :to_policy, []})
46 | |> unwrap_and_tag(:hit_policy)
47 | end
48 |
49 | def input_field do
50 | variable()
51 | |> space()
52 | |> unwrap_and_tag(:input)
53 | end
54 |
55 | def io_sperator, do: string("||") |> ignore()
56 |
57 | def output_field do
58 | variable()
59 | |> optional_space()
60 | |> unwrap_and_tag(:output)
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/lib/tablex/parser/informative_row.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.InformativeRow do
2 | @moduledoc false
3 |
4 | import NimbleParsec
5 | import Tablex.Parser.Space
6 | import Tablex.Parser.Variable
7 |
8 | def informative_row do
9 | space()
10 | |> times(
11 | concat(
12 | info(),
13 | space()
14 | )
15 | |> tag(:input),
16 | min: 0
17 | )
18 | |> concat(string("||") |> ignore())
19 | |> times(concat(space(), info()) |> tag(:output), min: 1)
20 | |> optional_space()
21 | |> reduce({__MODULE__, :trans_info_row, []})
22 | end
23 |
24 | def info do
25 | choice([
26 | type(),
27 | string("-")
28 | ])
29 | end
30 |
31 | @doc false
32 | def trans_info_row(parsed) do
33 | [
34 | Keyword.get_values(parsed, :input) |> Enum.map(&trans_type/1),
35 | Keyword.get_values(parsed, :output) |> Enum.map(&trans_type/1)
36 | ]
37 | end
38 |
39 | defp trans_type(["-"]), do: {:undefined, nil}
40 | defp trans_type([type]), do: trans_type([type, nil])
41 | defp trans_type([type, desc]), do: {type, desc}
42 | end
43 |
--------------------------------------------------------------------------------
/lib/tablex/parser/rule.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.Rule do
2 | @moduledoc false
3 |
4 | import NimbleParsec
5 | import Tablex.Parser.Code
6 | import Tablex.Parser.Expression
7 | import Tablex.Parser.Space
8 |
9 | def rules(combinitor \\ empty()) do
10 | concat(
11 | combinitor,
12 | rule()
13 | |> concat(
14 | concat(
15 | newline(),
16 | rule()
17 | )
18 | |> times(min: 1)
19 | |> optional()
20 | )
21 | |> tag(:rules)
22 | )
23 | |> ignore(
24 | choice([
25 | newline(),
26 | eos()
27 | ])
28 | )
29 | end
30 |
31 | def rule do
32 | integer(min: 1)
33 | |> space()
34 | |> repeat_while(
35 | concat(expression(), space()),
36 | {__MODULE__, :not_separator, []}
37 | )
38 | |> concat(separator())
39 | |> space()
40 | |> concat(
41 | output_expression()
42 | |> concat(
43 | times(
44 | concat(
45 | space(),
46 | output_expression()
47 | ),
48 | min: 0
49 | )
50 | )
51 | )
52 | |> optional_space()
53 | |> reduce({__MODULE__, :trans_rule, []})
54 | end
55 |
56 | @doc false
57 | def not_separator(<<"|| ", _::binary>>, context, _, _),
58 | do: {:halt, context}
59 |
60 | def not_separator(_rest, context, _, _),
61 | do: {:cont, context}
62 |
63 | def separator do
64 | string("||") |> replace(:||)
65 | end
66 |
67 | def output_expression do
68 | choice([
69 | code(),
70 | expression()
71 | ])
72 | end
73 |
74 | @doc false
75 | def trans_rule(parts) do
76 | {[rn | inputs], [:|| | outputs]} = Enum.split_while(parts, &(&1 != :||))
77 | [rn, {:input, inputs}, {:output, outputs}]
78 | end
79 | end
80 |
--------------------------------------------------------------------------------
/lib/tablex/parser/space.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.Space do
2 | @moduledoc false
3 |
4 | import NimbleParsec
5 |
6 | def space(combinitor \\ empty()) do
7 | combinitor
8 | |> concat(" " |> string() |> times(min: 1) |> ignore())
9 | end
10 |
11 | def optional_space(combinitor \\ empty()) do
12 | combinitor
13 | |> concat(space() |> optional())
14 | end
15 |
16 | def newline(combinitor \\ empty()) do
17 | combinitor
18 | |> concat(string("\n") |> ignore())
19 | end
20 |
21 | def eow(combinator \\ empty()) do
22 | combinator
23 | |> concat(
24 | choice([
25 | string(" "),
26 | string("\n"),
27 | eos()
28 | ])
29 | )
30 | end
31 |
32 | def eol(combinator \\ empty()) do
33 | combinator
34 | |> concat(
35 | choice([
36 | newline(),
37 | eos()
38 | ])
39 | )
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/tablex/parser/variable.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.Variable do
2 | @moduledoc false
3 |
4 | import NimbleParsec
5 | import Tablex.Parser.Expression.QuotedString
6 | import Tablex.Parser.Space
7 |
8 | def variable do
9 | concat(
10 | name() |> lookahead(eow()) |> reduce({__MODULE__, :trans_name, []}),
11 | optional(concat(optional_space(), type()))
12 | )
13 | |> reduce({__MODULE__, :trans_var, []})
14 | end
15 |
16 | def name do
17 | choice([
18 | varname(),
19 | quoted_string()
20 | ])
21 | end
22 |
23 | def varname do
24 | ascii_varname()
25 | |> times(
26 | string(".")
27 | |> concat(ascii_varname()),
28 | min: 0
29 | )
30 | end
31 |
32 | def ascii_varname do
33 | ascii_char([?A..?z])
34 | |> times(ascii_char([?0..?9, ?_, ?-, ?A..?z]), min: 0)
35 | |> reduce({List, :to_string, []})
36 | end
37 |
38 | @doc false
39 | def trans_name(list) do
40 | List.to_string(list)
41 | end
42 |
43 | def type_enum do
44 | choice([
45 | string("integer"),
46 | string("float"),
47 | string("number"),
48 | string("string"),
49 | string("date"),
50 | string("time"),
51 | string("datetime"),
52 | string("bool")
53 | ])
54 | |> map({String, :to_atom, []})
55 | end
56 |
57 | def type do
58 | string("(")
59 | |> ignore()
60 | |> concat(optional_space())
61 | |> concat(type_enum())
62 | |> concat(
63 | string(",")
64 | |> ignore()
65 | |> optional_space()
66 | |> utf8_string([not: ?)], min: 1)
67 | |> optional()
68 | )
69 | |> ignore(string(")"))
70 | end
71 |
72 | @doc false
73 | def trans_var([label]) do
74 | trans_var([label, :undefined])
75 | end
76 |
77 | def trans_var([label, type]) do
78 | trans_var([label, type, nil])
79 | end
80 |
81 | def trans_var([label, type, desc]) do
82 | {name, path} = to_name_path(label)
83 |
84 | %Tablex.Variable{
85 | name: name,
86 | label: label,
87 | type: type,
88 | desc: desc,
89 | path: path
90 | }
91 | end
92 |
93 | defp to_name_path(label) do
94 | [name | path] =
95 | label
96 | |> String.split(".", trim: true)
97 | |> Stream.map(fn t ->
98 | t
99 | |> String.trim()
100 | |> String.replace(["-", " "], "_")
101 | |> String.to_atom()
102 | end)
103 | |> Enum.reverse()
104 |
105 | {name, Enum.reverse(path)}
106 | end
107 | end
108 |
--------------------------------------------------------------------------------
/lib/tablex/parser/vertical_table.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.VerticalTable do
2 | @moduledoc false
3 |
4 | alias Tablex.HitPolicy
5 |
6 | import NimbleParsec
7 | import Tablex.Parser.Space
8 | import Tablex.Parser.Variable
9 | import Tablex.Parser.Expression
10 |
11 | def v_table do
12 | hr()
13 | |> concat(first_line())
14 | |> times(input_line(), min: 0)
15 | |> concat(hr())
16 | |> times(output_line(), min: 1)
17 | end
18 |
19 | def hr do
20 | ascii_string([?=], min: 4)
21 | |> optional_space()
22 | |> newline()
23 | |> ignore()
24 | end
25 |
26 | def first_line() do
27 | hit_policy()
28 | |> space()
29 | |> vertical_sep()
30 | |> rule_numbers()
31 | |> eol()
32 | end
33 |
34 | def hit_policy do
35 | choice([
36 | string("C"),
37 | string("F"),
38 | string("M"),
39 | string("R")
40 | ])
41 | |> map({HitPolicy, :to_policy, []})
42 | |> unwrap_and_tag(:hit_policy)
43 | end
44 |
45 | def rule_numbers(combinator) do
46 | combinator
47 | |> concat(rule_numbers())
48 | end
49 |
50 | def rule_numbers do
51 | times(
52 | concat(
53 | space(),
54 | integer(min: 1)
55 | ),
56 | min: 1
57 | )
58 | |> tag(:rule_numbers)
59 | end
60 |
61 | def input_line do
62 | input_field()
63 | |> vertical_sep()
64 | |> concat(conditions())
65 | |> eol()
66 | |> tag(:input)
67 | end
68 |
69 | def conditions do
70 | times(
71 | concat(
72 | space(),
73 | expression()
74 | ),
75 | min: 1
76 | )
77 | end
78 |
79 | def vertical_sep(combinator \\ empty()) do
80 | combinator
81 | |> concat(string("||") |> ignore())
82 | end
83 |
84 | def output_line do
85 | output_field()
86 | |> vertical_sep()
87 | |> concat(output_values())
88 | |> eol()
89 | |> tag(:output)
90 | end
91 |
92 | def input_field do
93 | variable()
94 | |> optional_space()
95 | end
96 |
97 | def output_field do
98 | variable()
99 | |> optional_space()
100 | end
101 |
102 | def output_values do
103 | times(
104 | concat(
105 | space(),
106 | expression()
107 | ),
108 | min: 1
109 | )
110 | end
111 | end
112 |
--------------------------------------------------------------------------------
/lib/tablex/rules.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Rules do
2 | @moduledoc """
3 | High level rule APIs for Tablex.
4 |
5 | With rule APIs, one can:
6 |
7 | - find rules by a given set of inputs,
8 | - update an existing rule,
9 | - or create a new rule.
10 | """
11 |
12 | alias Tablex.Parser
13 | alias Tablex.Table
14 |
15 | @type table :: Table.t()
16 | @type rule :: Table.rule()
17 |
18 | @doc """
19 | Find rules by a given set of inputs.
20 |
21 | ## Parameters
22 |
23 | - `table`: the table to find rules for
24 | - `args`: the set of inputs to find rules for
25 |
26 | ## Returns
27 |
28 | A list of rules matching the given set of inputs.
29 | The ordering of the list follows the priority of the rules,
30 | with the lower-priority rule taking more precedence.
31 |
32 | ## Example
33 |
34 | iex> table = Tablex.new(\"""
35 | ...> F value || color
36 | ...> 1 >90 || red
37 | ...> 2 80..90 || orange
38 | ...> 3 20..79 || green
39 | ...> 4 <20 || blue
40 | ...> \""")
41 | ...> Tablex.Rules.get_rules(table, %{})
42 | []
43 | ...> Tablex.Rules.get_rules(table, %{value: 80})
44 | [%Tablex.Rules.Rule{id: 2, inputs: [{[:value], 80..90}], outputs: [{[:color], "orange"}]}]
45 |
46 | This example shows how the returned rules are ordered by priority:
47 |
48 | iex> table = Tablex.new(\"""
49 | ...> M country state || feature_enabled
50 | ...> 1 US CA || true
51 | ...> 2 US - || false
52 | ...> 3 CA - || true
53 | ...> \""")
54 | ...> Tablex.Rules.get_rules(table, %{country: "US", state: "CA"})
55 | [
56 | %Tablex.Rules.Rule{id: 2, inputs: [{[:country], "US"}, {[:state], :any}], outputs: [{[:feature_enabled], false}]},
57 | %Tablex.Rules.Rule{id: 1, inputs: [{[:country], "US"}, {[:state], "CA"}], outputs: [{[:feature_enabled], true}]}
58 | ]
59 |
60 | Nested inputs are supported.
61 |
62 | iex> table = Tablex.new(\"""
63 | ...> F foo.value || color
64 | ...> 1 >90 || red
65 | ...> 2 80..90 || orange
66 | ...> 3 20..79 || green
67 | ...> 4 <20 || blue
68 | ...> \""")
69 | ...> Tablex.Rules.get_rules(table, %{foo: %{value: 80}})
70 | [%Tablex.Rules.Rule{id: 2, inputs: [{[:foo, :value], 80..90}], outputs: [{[:color], "orange"}]}]
71 |
72 | """
73 | @spec get_rules(table(), keyword()) :: [rule()]
74 | def get_rules(%Table{} = table, args) do
75 | context = build_context(table.inputs, args)
76 |
77 | table.rules
78 | |> Stream.map(&to_rule_struct(&1, table))
79 | |> Stream.filter(&match_rule?(&1, context))
80 | |> order_by_priority(table.hit_policy)
81 | end
82 |
83 | defp build_context(inputs, args) do
84 | inputs
85 | |> Stream.map(&value_path/1)
86 | |> Enum.map(&get_in(args, &1))
87 | end
88 |
89 | defp value_path(%Tablex.Variable{name: name, path: path}) do
90 | path ++ [name]
91 | end
92 |
93 | defp to_rule_struct([id, {:input, inputs}, {:output, output} | _], %{
94 | inputs: input_defs,
95 | outputs: output_defs
96 | }) do
97 | inputs =
98 | inputs
99 | |> Stream.zip(input_defs)
100 | |> Enum.map(fn {expect, df} ->
101 | {value_path(df), expect}
102 | end)
103 |
104 | outputs =
105 | output
106 | |> Stream.zip(output_defs)
107 | |> Enum.map(fn {value, od} ->
108 | {value_path(od), value}
109 | end)
110 |
111 | %Tablex.Rules.Rule{
112 | id: id,
113 | inputs: inputs,
114 | outputs: outputs
115 | }
116 | end
117 |
118 | defp match_rule?(rule, context) do
119 | Stream.zip(rule.inputs, context)
120 | |> Enum.all?(fn {{_path, expect}, value} ->
121 | match_expect?(expect, value)
122 | end)
123 | end
124 |
125 | @doc """
126 | Check if the given value matches the asserting expectation.
127 | """
128 | def match_expect?(expect, value) when is_list(expect) do
129 | Enum.any?(expect, &match_expect?(&1, value))
130 | end
131 |
132 | def match_expect?(%Range{first: first, last: last}, value) when is_number(value) do
133 | # we can't use `in` here because value may be a float.
134 | value >= first and value <= last
135 | end
136 |
137 | def match_expect?({:!=, x}, value) when value != x, do: true
138 | def match_expect?({:>, x}, value) when is_number(value) and value > x, do: true
139 | def match_expect?({:>=, x}, value) when is_number(value) and value >= x, do: true
140 | def match_expect?({:<, x}, value) when is_number(value) and value < x, do: true
141 | def match_expect?({:<=, x}, value) when is_number(value) and value <= x, do: true
142 |
143 | def match_expect?(:any, _), do: true
144 | def match_expect?(expect, expect), do: true
145 | def match_expect?(_, _), do: false
146 |
147 | defp order_by_priority(matched_rules, :reverse_merge) do
148 | Enum.sort_by(matched_rules, & &1.id)
149 | end
150 |
151 | defp order_by_priority(matched_rules, _hit_policy) do
152 | Enum.sort_by(matched_rules, & &1.id, :desc)
153 | end
154 |
155 | @type updates() :: [update()]
156 | @type update() :: {:input, [any()] | map()} | {:output, [any()] | map()}
157 |
158 | @doc """
159 | Update an existing rule.
160 |
161 | ## Example
162 |
163 | A basic example of updating a rule:
164 |
165 | iex> table = Tablex.new(\"""
166 | ...> F value || color
167 | ...> 1 - || red
168 | ...> \""")
169 | ...> table = Tablex.Rules.update_rule(table, 1, input: [80..90], output: ["orange"])
170 | ...> table.rules
171 | [[1, input: [80..90], output: ["orange"]]]
172 |
173 | You can also updte a rule with changes in a map format:
174 |
175 | iex> table = Tablex.new(\"""
176 | ...> F value || color
177 | ...> 1 - || red
178 | ...> \""")
179 | ...> table = Tablex.Rules.update_rule(table, 1, input: %{value: 80..90}, output: %{color: "orange"})
180 | ...> table.rules
181 | [[1, input: [80..90], output: ["orange"]]]
182 |
183 | You can only update input values:
184 |
185 | iex> table = Tablex.new(\"""
186 | ...> F value || color
187 | ...> 1 - || red
188 | ...> \""")
189 | ...> table = Tablex.Rules.update_rule(table, 1, input: %{value: 80..90})
190 | ...> table.rules
191 | [[1, input: [80..90], output: ["red"]]]
192 |
193 | You can only update output values:
194 |
195 | iex> table = Tablex.new(\"""
196 | ...> F value || color
197 | ...> 1 - || red
198 | ...> \""")
199 | ...> table = Tablex.Rules.update_rule(table, 1, output: %{color: "orange"})
200 | ...> table.rules
201 | [[1, input: [:any], output: ["orange"]]]
202 |
203 | For updating nested input or output values, both nested map or direct values are supported:
204 |
205 | iex> table = Tablex.new(\"""
206 | ...> F target.value || color
207 | ...> 1 - || red
208 | ...> \""")
209 | ...> table = Tablex.Rules.update_rule(table, 1, input: %{target: %{value: 80..90}}, output: %{color: "orange"})
210 | ...> table.rules
211 | [[1, input: [80..90], output: ["orange"]]]
212 |
213 | iex> table = Tablex.new(\"""
214 | ...> F target.value || color
215 | ...> 1 - || red
216 | ...> \""")
217 | ...> table = Tablex.Rules.update_rule(table, 1, input: [80..90], output: ["orange"])
218 | ...> table.rules
219 | [[1, input: [80..90], output: ["orange"]]]
220 | """
221 | @spec update_rule(table(), integer(), updates()) :: table()
222 | def update_rule(%Table{} = table, id, updates) do
223 | update_input = Keyword.get(updates, :input) |> to_updater(table.inputs)
224 | update_output = Keyword.get(updates, :output) |> to_updater(table.outputs)
225 |
226 | table
227 | |> Map.update!(:rules, fn
228 | rules ->
229 | Enum.map(rules, fn
230 | [^id, {:input, input}, {:output, output}] ->
231 | [id, {:input, update_input.(input)}, {:output, update_output.(output)}]
232 |
233 | otherwise ->
234 | otherwise
235 | end)
236 | end)
237 | end
238 |
239 | defp to_updater(%{} = update, defs) do
240 | defs
241 | |> Stream.with_index()
242 | |> Stream.map(fn {%Tablex.Variable{name: name, path: path}, index} ->
243 | full_path = path ++ [name]
244 |
245 | case at_path(update, full_path) do
246 | nil ->
247 | & &1
248 |
249 | value ->
250 | &List.replace_at(&1, index, value)
251 | end
252 | end)
253 | |> Enum.reduce(& &1, fn f, acc ->
254 | fn update -> acc.(update) |> f.() end
255 | end)
256 | end
257 |
258 | defp to_updater(nil, _) do
259 | & &1
260 | end
261 |
262 | defp to_updater(new_value, _) do
263 | fn _ -> new_value end
264 | end
265 |
266 | defp at_path(%{} = map, path) do
267 | Enum.reduce_while(path, map, fn
268 | seg, acc ->
269 | case acc do
270 | %{^seg => value} ->
271 | {:cont, value}
272 |
273 | _ ->
274 | {:halt, nil}
275 | end
276 | end)
277 | end
278 |
279 | @doc """
280 | Update an existing rule by input.
281 | """
282 | @spec update_rule_by_input(table(), map(), map()) :: table()
283 | def update_rule_by_input(table, input, output_updates) do
284 | rule = find_rule_by_input(table, input)
285 |
286 | case rule do
287 | [id | _] ->
288 | update_rule(table, id, output: output_updates)
289 |
290 | nil ->
291 | add_new_rule_high_priority(table, input, output_updates)
292 | end
293 | end
294 |
295 | defp find_rule_by_input(table, input) do
296 | input = to_expected_values(input, table.inputs)
297 |
298 | table.rules
299 | |> Enum.find(&match?([_id, {:input, ^input} | _], &1))
300 | end
301 |
302 | defp to_expected_values(input, defs) do
303 | for %{path: path, name: name} <- defs, do: get_expr(input, path, name) |> parse_expression()
304 | end
305 |
306 | defp get_expr(input, path, name) do
307 | path = Enum.map(path, &Access.key(&1, %{}))
308 | any = "-"
309 | get_in(input, path ++ [Access.key(name, any)])
310 | end
311 |
312 | defp add_new_rule_high_priority(table, input, output_updates) do
313 | input = to_expected_values(input, table.inputs)
314 | output = to_expected_values(output_updates, table.outputs)
315 | new_rule = [1, {:input, input}, {:output, output}]
316 |
317 | table
318 | |> Map.update!(:rules, fn rules ->
319 | case table.hit_policy do
320 | :reverse_merge ->
321 | rules ++ [new_rule]
322 |
323 | _ ->
324 | [new_rule | rules]
325 | end
326 | end)
327 | |> update_ids()
328 | end
329 |
330 | defp parse_expression(expr) when is_binary(expr) do
331 | {:ok, [parsed], _, _, _, _} = Parser.expr(expr)
332 | parsed
333 | end
334 |
335 | defp parse_expression(expr), do: expr
336 |
337 | defp update_ids(%Table{} = table) do
338 | table
339 | |> Map.update!(:rules, fn rules ->
340 | rules
341 | |> Stream.with_index(1)
342 | |> Enum.map(fn {[_ | content], index} ->
343 | [index | content]
344 | end)
345 | end)
346 | end
347 | end
348 |
--------------------------------------------------------------------------------
/lib/tablex/rules/rule.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Rules.Rule do
2 | defstruct [:id, :inputs, :outputs]
3 | end
4 |
--------------------------------------------------------------------------------
/lib/tablex/table.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Table do
2 | @moduledoc """
3 | Struct definition for tables.
4 | """
5 |
6 | alias Tablex.Variable
7 | alias __MODULE__
8 |
9 | @type t() :: %Table{
10 | hit_policy: hit_policy(),
11 | inputs: [input()],
12 | outputs: [output()],
13 | rules: [rule()],
14 | valid?: boolean() | :undefined,
15 | table_dir: :h | :v
16 | }
17 |
18 | @type hit_policy() :: :first_hit | :merge | :reverse_merge | :collect
19 | @type input() :: Variable.t()
20 | @type output() :: Variable.t()
21 | @type var_name() :: atom()
22 | @type var_type() :: :string | :number | :integer | :float | :date | :time | :datetime
23 | @type rule() :: [order :: integer() | {:input, [any()]} | {:output, [any()]}]
24 |
25 | defstruct hit_policy: :first_hit,
26 | inputs: [],
27 | outputs: [],
28 | rules: [],
29 | valid?: :undefined,
30 | table_dir: :h
31 |
32 | @doc false
33 | def new([{:horizontal, parsed}]) do
34 | %Table{
35 | hit_policy: parsed[:hit_policy],
36 | inputs: Keyword.get_values(parsed, :input),
37 | outputs: Keyword.get_values(parsed, :output),
38 | rules: parsed[:rules]
39 | }
40 | |> apply_info_row(parsed[:info])
41 | end
42 |
43 | def new([{:vertical, parsed}]) do
44 | inputs =
45 | parsed
46 | |> Enum.flat_map(fn
47 | {:input, [%Variable{} = v | _]} -> [v]
48 | _ -> []
49 | end)
50 |
51 | outputs =
52 | parsed
53 | |> Enum.flat_map(fn
54 | {:output, [%Variable{} = v | _]} -> [v]
55 | _ -> []
56 | end)
57 |
58 | %Table{
59 | hit_policy: parsed[:hit_policy],
60 | inputs: inputs,
61 | outputs: outputs,
62 | rules: build_vertical_rules(parsed),
63 | table_dir: :v
64 | }
65 | end
66 |
67 | defp apply_info_row(table, nil) do
68 | table
69 | end
70 |
71 | defp apply_info_row(table, [input_info, output_info]) do
72 | apply_info = fn
73 | {var, {type, desc}} ->
74 | %{var | type: type, desc: desc}
75 | end
76 |
77 | inputs =
78 | Stream.zip(table.inputs, input_info)
79 | |> Enum.map(apply_info)
80 |
81 | outputs =
82 | Stream.zip(table.outputs, output_info)
83 | |> Enum.map(apply_info)
84 |
85 | %{table | inputs: inputs, outputs: outputs}
86 | end
87 |
88 | defp build_vertical_rules(parsed) do
89 | has_input? =
90 | Enum.any?(
91 | parsed,
92 | fn
93 | {:input, _} -> true
94 | _ -> false
95 | end
96 | )
97 |
98 | if has_input?,
99 | do: build_vertical_rules(parsed, :has_input),
100 | else: build_vertical_rules(parsed, :no_input)
101 | end
102 |
103 | defp build_vertical_rules(parsed, :has_input) do
104 | condition_matrix =
105 | parsed
106 | |> Stream.flat_map(fn
107 | {:input, [%Variable{} | conditions]} ->
108 | [conditions]
109 |
110 | _ ->
111 | []
112 | end)
113 | |> Stream.zip()
114 |
115 | value_matrix =
116 | parsed
117 | |> Stream.flat_map(fn
118 | {:output, [%Variable{} | values]} ->
119 | [values]
120 |
121 | _ ->
122 | []
123 | end)
124 | |> Stream.zip()
125 |
126 | rule_numbers = parsed[:rule_numbers]
127 |
128 | Stream.zip([rule_numbers, condition_matrix, value_matrix])
129 | |> Enum.map(fn {nu, inputs, outputs} ->
130 | [nu, {:input, Tuple.to_list(inputs)}, {:output, Tuple.to_list(outputs)}]
131 | end)
132 | end
133 |
134 | defp build_vertical_rules(parsed, :no_input) do
135 | value_matrix =
136 | parsed
137 | |> Stream.flat_map(fn
138 | {:output, [%Variable{} | values]} ->
139 | [values]
140 |
141 | _ ->
142 | []
143 | end)
144 | |> Stream.zip()
145 |
146 | rule_numbers = parsed[:rule_numbers]
147 |
148 | Stream.zip([rule_numbers, value_matrix])
149 | |> Enum.map(fn {nu, outputs} ->
150 | [nu, {:input, []}, {:output, Tuple.to_list(outputs)}]
151 | end)
152 | end
153 | end
154 |
--------------------------------------------------------------------------------
/lib/tablex/util/deep_map.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Util.DeepMap do
2 | @doc """
3 | Flatten a map recursively.
4 | """
5 |
6 | def flatten(outputs) do
7 | Enum.reduce(outputs, %{}, fn {path, v}, acc ->
8 | acc |> put_recursively(path, v)
9 | end)
10 | end
11 |
12 | defp put_recursively(_, [], value) do
13 | value
14 | end
15 |
16 | defp put_recursively(%{} = acc, [head | rest], value) do
17 | v = put_recursively(%{}, rest, value)
18 |
19 | Map.update(
20 | acc,
21 | head,
22 | v,
23 | &Map.merge(&1, v, fn
24 | _, %{} = v1, %{} = v2 ->
25 | Map.merge(v1, v2)
26 |
27 | _, _, v2 ->
28 | v2
29 | end)
30 | )
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/lib/tablex/util/list_breaker.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Util.ListBreaker do
2 | def flatten_list([]) do
3 | [[]]
4 | end
5 |
6 | def flatten_list([head | tail]) do
7 | for i <- List.wrap(head),
8 | j <- flatten_list(tail),
9 | do: [i | j]
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/tablex/variable.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Variable do
2 | @type t :: %__MODULE__{
3 | name: atom(),
4 | label: String.t(),
5 | desc: String.t(),
6 | type: :undefined | var_type(),
7 | path: [atom()]
8 | }
9 |
10 | @type var_type :: :integer | :float | :number | :string | :bool
11 |
12 | @enforce_keys [:name]
13 |
14 | defstruct [:name, :label, :desc, type: :undefined, path: []]
15 | end
16 |
--------------------------------------------------------------------------------
/livebooks/tablex-intro.livemd:
--------------------------------------------------------------------------------
1 | # Tablex
2 |
3 | ```elixir
4 | Mix.install([
5 | {:tablex, "~> 0.3"}
6 | ])
7 | ```
8 |
9 | ## Tablex Formatted Decision Tables
10 |
11 | Tablex is first a format for presenting decision tables. It's designed to balance human readability and parser-friendlity.
12 |
13 | Here's an example table to present a decision to make for a proxy relay:
14 |
15 | ```
16 | F current_node destination_domain || next_relay
17 | 1 CA1 www.example.com || US1
18 | 2 - - || -
19 | ```
20 |
21 | We can create such a table in Elixir with Tablex library:
22 |
23 | ```elixir
24 | routing_table =
25 | Tablex.new("""
26 | F current_node destination_domain || next_relay
27 | 1 CA1 www.example.com || US1
28 | 2 - - || -
29 | """)
30 | ```
31 |
32 | ## Making a decision
33 |
34 | This first thing we can do is to make a decision accordingly.
35 |
36 | If nothing is present in the input:
37 |
38 | ```elixir
39 | Tablex.decide(
40 | routing_table,
41 | []
42 | )
43 | ```
44 |
45 | If the current node is "CA1" and destination is "www.example.com":
46 |
47 | ```elixir
48 | Tablex.decide(
49 | routing_table,
50 | current_node: "CA1",
51 | destination_domain: "www.example.com"
52 | )
53 | ```
54 |
55 | Otherwise, it returns the last `any` rule instead:
56 |
57 | ```elixir
58 | Tablex.decide(
59 | routing_table,
60 | current_node: "HK5",
61 | destination_domain: "www.example.com"
62 | )
63 | ```
64 |
65 | ## Manipulating a Decision Table Programmably
66 |
67 | Extracting rules based on a context:
68 |
69 | ```elixir
70 | Tablex.Rules.get_rules(routing_table, [])
71 | ```
72 |
73 | ```elixir
74 | Tablex.Rules.get_rules(routing_table, current_node: "CA1", destination_domain: "www.example.com")
75 | ```
76 |
77 | Inserting new rules:
78 |
79 | ```elixir
80 | routing_table =
81 | routing_table
82 | |> Tablex.Rules.update_rule_by_input(
83 | %{current_node: "CA1", destination_domain: "*.mx"},
84 | %{next_relay: "MX1"}
85 | )
86 | |> Tablex.Optimizer.optimize()
87 |
88 | routing_table |> Tablex.Formatter.to_s() |> IO.puts()
89 | ```
90 |
91 | There's no API to delete rules, but it can be accomplished by updating, since optimzer can remove unnecessary rules:
92 |
93 | ```elixir
94 | routing_table =
95 | Tablex.Rules.update_rule_by_input(
96 | routing_table,
97 | %{current_node: "CA1", destination_domain: "*.mx"},
98 | %{next_relay: :any}
99 | )
100 | # Optimizer will merge duplicated rules
101 | |> Tablex.Optimizer.optimize()
102 |
103 | routing_table |> Tablex.Formatter.to_s() |> IO.puts()
104 | ```
105 |
106 | ## Just for fun
107 |
108 | ```elixir
109 | defmodule FibWithTablex do
110 | @table Tablex.new("""
111 | F x || fib
112 | 1 1 || 1
113 | 2 >1 || `x * f.(x - 1)`
114 | """)
115 |
116 | def fib(x) do
117 | Tablex.decide(@table,
118 | x: x,
119 | f: fn x ->
120 | fib(x) |> Map.fetch!(:fib)
121 | end
122 | )
123 | end
124 | end
125 | ```
126 |
127 | ```elixir
128 | FibWithTablex.fib(1)
129 | ```
130 |
131 | ```elixir
132 | FibWithTablex.fib(10)
133 | ```
134 |
--------------------------------------------------------------------------------
/livebooks/tablex_on_formular_server.livemd:
--------------------------------------------------------------------------------
1 | # Tablex + Formular Server
2 |
3 | ```elixir
4 | Mix.install([
5 | {:tablex, "~> 0.3"},
6 | {:formular_client, "~> 0.4"}
7 | ])
8 | ```
9 |
10 | ## Section
11 |
12 | ```elixir
13 | Supervisor.start_link(Formular.Client.Supervisor,
14 | client_name: "demo: tablex + formular",
15 | url: "wss://formular-server-ose.fly.dev/socket/websocket",
16 | formulas: [
17 | {GiraRouteFM, "gira_route_table"}
18 | ]
19 | )
20 | ```
21 |
22 | ```elixir
23 | GiraRouteFM.run(current_node: "HK1", dst: "example.com")
24 | ```
25 |
26 | Now change the table on server, and re-run the above code.
27 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Tablex.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :tablex,
7 | version: "0.3.1",
8 | elixir: "~> 1.11",
9 | elixirc_paths: elixirc_paths(Mix.env()),
10 | start_permanent: Mix.env() == :prod,
11 | deps: deps(),
12 | docs: docs(),
13 | package: package()
14 | ]
15 | end
16 |
17 | # Run "mix help compile.app" to learn about applications.
18 | def application do
19 | [
20 | extra_applications: [:logger]
21 | ]
22 | end
23 |
24 | # Run "mix help deps" to learn about dependencies.
25 | defp deps do
26 | [
27 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false},
28 | {:nimble_parsec, "~> 1.3"},
29 | {:formular, "~> 0.4.1"},
30 | {:dialyxir, "~> 1.0", only: [:dev], runtime: false},
31 | {:ucwidth, "~> 0.2"}
32 | ]
33 | end
34 |
35 | defp docs do
36 | [
37 | main: "readme",
38 | extras: ~w[
39 | README.md
40 | guides/nested_fields.md
41 | guides/informative_row.md
42 | guides/code_execution.md
43 | ],
44 | before_closing_head_tag: &before_closing_head_tag/1,
45 | source_url: "https://github.com/elixir-tablex/tablex"
46 | ]
47 | end
48 |
49 | defp before_closing_head_tag(:html) do
50 | """
51 |
167 | """
168 | end
169 |
170 | defp before_closing_head_tag(_) do
171 | ""
172 | end
173 |
174 | defp package do
175 | [
176 | name: "tablex",
177 | description: "Organize business rules with decision tables.",
178 | files: ~w[lib mix.exs],
179 | licenses: ~w[MIT],
180 | links: %{
181 | "Github" => "https://github.com/elixir-tablex/tablex"
182 | }
183 | ]
184 | end
185 |
186 | # Specifies which paths to compile per environment
187 | defp elixirc_paths(:test), do: ["lib", "test/support"]
188 | defp elixirc_paths(_), do: ["lib"]
189 | end
190 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "dialyxir": {:hex, :dialyxir, "1.3.0", "fd1672f0922b7648ff9ce7b1b26fcf0ef56dda964a459892ad15f6b4410b5284", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "00b2a4bcd6aa8db9dcb0b38c1225b7277dca9bc370b6438715667071a304696f"},
3 | "earmark_parser": {:hex, :earmark_parser, "1.4.31", "a93921cdc6b9b869f519213d5bc79d9e218ba768d7270d46fdcf1c01bacff9e2", [:mix], [], "hexpm", "317d367ee0335ef037a87e46c91a2269fef6306413f731e8ec11fc45a7efd059"},
4 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
5 | "ex_doc": {:hex, :ex_doc, "0.29.3", "f07444bcafb302db86e4f02d8bbcd82f2e881a0dcf4f3e4740e4b8128b9353f7", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "3dc6787d7b08801ec3b51e9bd26be5e8826fbf1a17e92d1ebc252e1a1c75bfe1"},
6 | "formular": {:hex, :formular, "0.4.1", "48c8e0b1d9601c274ad25f01f20d224e9f09dc34c286b00ff4c7c20dfbc7ca2c", [:mix], [], "hexpm", "2caac6eda157caf91cf6cb86e047bdab311eedfd39858f847789079a4035a7ce"},
7 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"},
8 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"},
9 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"},
10 | "nimble_parsec": {:hex, :nimble_parsec, "1.3.0", "9e18a119d9efc3370a3ef2a937bf0b24c088d9c4bf0ba9d7c3751d49d347d035", [:mix], [], "hexpm", "7977f183127a7cbe9346981e2f480dc04c55ffddaef746bd58debd566070eef8"},
11 | "ucwidth": {:hex, :ucwidth, "0.2.0", "1f0a440f541d895dff142275b96355f7e91e15bca525d4a0cc788ea51f0e3441", [:mix], [], "hexpm", "c1efd1798b8eeb11fb2bec3cafa3dd9c0c3647bee020543f0340b996177355bf"},
12 | }
13 |
--------------------------------------------------------------------------------
/test/support/maybe_doctest_file.ex:
--------------------------------------------------------------------------------
1 | defmodule DoctestFile do
2 | Code.ensure_loaded!(ExUnit.DocTest)
3 |
4 | unless macro_exported?(ExUnit.DocTest, :doctest_file, 1) do
5 | require Logger
6 |
7 | def doctest_file(file) do
8 | Logger.warning(
9 | "`doctest_file(#{inspect(file)})` is skipped because we're running on Elixir #{System.version()}."
10 | )
11 |
12 | :ok
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/test/tablex/code_execution_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tablex.CodeExecutionTest do
2 | use ExUnit.Case
3 | import DoctestFile
4 |
5 | doctest_file("guides/code_execution.md")
6 |
7 | describe "Exuecting code in an output field" do
8 | test "works" do
9 | table =
10 | Tablex.new("""
11 | F x || plus_1 square
12 | 1 - || `x + 1` `x * x`
13 | """)
14 |
15 | assert %{plus_1: 5, square: 16} = run(table, x: 4)
16 | end
17 |
18 | test "works with collect hit policy" do
19 | table =
20 | Tablex.new("""
21 | C a b || div
22 | 1 - 0 || "b is zero"
23 | 2 - !=0 || `a / b`
24 | """)
25 |
26 | assert [%{div: "b is zero"}] = run(table, a: 5, b: 0)
27 | assert [%{div: 5.0}] = run(table, a: 5.0, b: 1)
28 | end
29 |
30 | test "works with collect hit policy and nested inputs" do
31 | table =
32 | Tablex.new("""
33 | C a.a b.b || div
34 | 1 - 0 || "b.b is zero"
35 | 2 - !=0 || `a.a / b.b`
36 | """)
37 |
38 | assert [%{div: "b.b is zero"}] = run(table, a: 5, b: %{b: 0})
39 | assert [%{div: 5.0}] = run(table, a: %{a: 5.0}, b: %{b: 1})
40 | end
41 |
42 | test "works with `merge` hit policy" do
43 | table =
44 | Tablex.new("""
45 | M day_of_week || go_to_library volunteer blogging
46 | 1 1 || T - -
47 | 2 2 || F T -
48 | 3 - || F F `week_of_month == 4`
49 | """)
50 |
51 | assert %{go_to_library: true, volunteer: false, blogging: false} ==
52 | run(table, day_of_week: 1, week_of_month: 1)
53 |
54 | assert %{go_to_library: true, volunteer: false, blogging: true} ==
55 | run(table, day_of_week: 1, week_of_month: 4)
56 | end
57 |
58 | test "works with `reverse_merge` hit policy" do
59 | store_ids = 1..1000 |> Enum.map_join(",", &"#{&1}")
60 |
61 | table =
62 | Tablex.new("""
63 | R store_id || active
64 | 1 - || false
65 | 2 [#{store_ids}] || true
66 | """)
67 |
68 | assert %{active: true} = run(table, store_id: :rand.uniform(999) + 1)
69 | assert %{active: false} = run(table, store_id: "foo")
70 | end
71 | end
72 |
73 | defp run(table, args) do
74 | {ret, _} =
75 | Tablex.CodeGenerate.generate(table)
76 | |> Code.eval_string(args)
77 |
78 | ret
79 | end
80 | end
81 |
--------------------------------------------------------------------------------
/test/tablex/code_generate_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tablex.CodeGenerateTest do
2 | alias Tablex.CodeGenerate
3 |
4 | use ExUnit.Case
5 | import CodeGenerate
6 |
7 | doctest CodeGenerate
8 |
9 | test "it works" do
10 | table =
11 | Tablex.new("""
12 | F gpa standardized_test_scores extracurricular_activities recommendation_letters || admission_decision
13 | 1 >=3.5 >1300 >=2 >=2 || Accepted
14 | 2 <3.5 1000..1300 1..2 1..2 || Waitlisted
15 | 3 <3.0 - - - || Denied
16 | 4 - <1000 >=2 >=2 || "Further Review"
17 | 5 - - - - || Denied
18 | """)
19 |
20 | assert_eval(
21 | table,
22 | [
23 | gpa: 5,
24 | standardized_test_scores: 0,
25 | extracurricular_activities: 3,
26 | recommendation_letters: 3
27 | ],
28 | %{admission_decision: "Further Review"}
29 | )
30 |
31 | assert_eval(
32 | table,
33 | [
34 | gpa: 5,
35 | standardized_test_scores: 1500,
36 | extracurricular_activities: 3,
37 | recommendation_letters: 3
38 | ],
39 | %{admission_decision: "Accepted"}
40 | )
41 | end
42 |
43 | test "it works with lists" do
44 | table =
45 | Tablex.new("""
46 | F day || open_hours
47 | 1 Mon,Tue,Wed,Thu,Fri || "10:00 - 20:00"
48 | 2 Sat || "10:00 - 18:00"
49 | 3 Sun || "12:00 - 18:00"
50 | """)
51 |
52 | assert_eval(table, [day: "Mon"], %{open_hours: "10:00 - 20:00"})
53 | assert_eval(table, [day: "Sat"], %{open_hours: "10:00 - 18:00"})
54 | assert_eval(table, [day: "Sun"], %{open_hours: "12:00 - 18:00"})
55 | end
56 |
57 | test "it works with a complicated list" do
58 | table =
59 | Tablex.new("""
60 | F order_value shipping_destination shipping_weight || shipping_option
61 | 1 >=100,97 domestic - || "Free Shipping"
62 | 2 - domestic,international <=5 || "Standard Shipping"
63 | 3 - international >5,null || "Expedited Shipping"
64 | 4 - space - || "Rocket Shipping"
65 | """)
66 |
67 | assert_eval(
68 | table,
69 | [
70 | order_value: 97,
71 | shipping_destination: "domestic",
72 | shipping_weight: 10_000
73 | ],
74 | %{shipping_option: "Free Shipping"}
75 | )
76 |
77 | assert_eval(
78 | table,
79 | [
80 | order_value: 1000,
81 | shipping_destination: "domestic",
82 | shipping_weight: 10
83 | ],
84 | %{shipping_option: "Free Shipping"}
85 | )
86 |
87 | assert_eval(
88 | table,
89 | [
90 | order_value: 1,
91 | shipping_destination: "international",
92 | shipping_weight: 10
93 | ],
94 | %{shipping_option: "Expedited Shipping"}
95 | )
96 | end
97 |
98 | describe "Generating code with `collect` hit policy tables" do
99 | test "works with simplest collect tables" do
100 | table =
101 | Tablex.new("""
102 | C || grade program
103 | 1 || 1 science
104 | 2 || 2 "visual art"
105 | 3 || 3 music,"visual art"
106 | """)
107 |
108 | assert_eval(
109 | table,
110 | [],
111 | [
112 | %{grade: 1, program: "science"},
113 | %{grade: 2, program: "visual art"},
114 | %{grade: 3, program: ["music", "visual art"]}
115 | ]
116 | )
117 | end
118 |
119 | test "works with inputs" do
120 | table =
121 | Tablex.new("""
122 | C grade || program
123 | 1 1 || science
124 | 2 2 || "visual art"
125 | 3 1..3 || music
126 | """)
127 |
128 | assert_eval(
129 | table,
130 | [grade: 1],
131 | [%{program: "science"}, %{program: "music"}]
132 | )
133 | end
134 | end
135 |
136 | describe "Generating code from a table with `merge` hit policy" do
137 | test "works" do
138 | table =
139 | Tablex.new("""
140 | M continent country province || feature1 feature2
141 | 1 Asia Thailand - || true true
142 | 2 America Canada BC,ON || - true
143 | 3 America Canada - || true false
144 | 4 America US - || false false
145 | 5 Europe France - || true -
146 | 6 Europe - - || false true
147 | """)
148 |
149 | assert_eval(
150 | table,
151 | [continent: "Asia", country: "Thailand", province: nil],
152 | %{feature1: true, feature2: true}
153 | )
154 |
155 | assert_eval(
156 | table,
157 | [continent: "America", country: "Canada", province: "BC"],
158 | %{feature1: true, feature2: true}
159 | )
160 | end
161 | end
162 |
163 | describe "Generating code from a table with `reverse_merge` hit policy" do
164 | test "works" do
165 | table =
166 | Tablex.new("""
167 | R continent country province || feature1 feature2
168 | 1 Europe - - || false true
169 | 2 Europe France - || true -
170 | 3 America US - || false false
171 | 4 America Canada - || true false
172 | 5 America Canada BC,ON || - true
173 | 6 Asia Thailand - || true true
174 | """)
175 |
176 | assert_eval(
177 | table,
178 | [continent: "Asia", country: "Thailand", province: nil],
179 | %{feature1: true, feature2: true}
180 | )
181 |
182 | assert_eval(
183 | table,
184 | [continent: "America", country: "Canada", province: "BC"],
185 | %{feature1: true, feature2: true}
186 | )
187 | end
188 |
189 | test "works with another example" do
190 | table =
191 | Tablex.new("""
192 | ====
193 | R || 1 2 3 4
194 | target.store_id || - - 1 1,2
195 | ====
196 | gps_provider || gps_tracker - - fancy_tracker
197 | a.b.c || xxx - - -
198 | a.b.d || no - yes -
199 | """)
200 |
201 | assert_eval(table, [target: %{store_id: 1}], %{
202 | gps_provider: "fancy_tracker",
203 | a: %{
204 | b: %{
205 | c: "xxx",
206 | d: true
207 | }
208 | }
209 | })
210 | end
211 | end
212 |
213 | describe "Nested inputs" do
214 | test "works" do
215 | table =
216 | Tablex.new("""
217 | M target.country target.province || feature1 feature2
218 | 1 Canada BC,ON || - true
219 | 2 Canada - || true false
220 | """)
221 |
222 | assert_eval(
223 | table,
224 | [target: %{country: "Canada", province: "BC"}],
225 | %{feature1: true, feature2: true}
226 | )
227 | end
228 |
229 | test "works with another example" do
230 | table =
231 | Tablex.new("""
232 | C plan.name || region id host port
233 | 1 T1,T2 || "Hong Kong" 1 example.com 8080
234 | 2 T2 || "Hong Kong" 2 example.com 8081
235 | 3 T3 || "Hong Kong" 3 example.com 8082
236 | """)
237 |
238 | assert_eval(
239 | table,
240 | [plan: %{name: "T1"}],
241 | [
242 | %{id: 1, port: 8080, host: "example.com", region: "Hong Kong"}
243 | ]
244 | )
245 |
246 | assert_eval(
247 | table,
248 | [plan: %{name: "T2"}],
249 | [
250 | %{id: 1, port: 8080, host: "example.com", region: "Hong Kong"},
251 | %{id: 2, port: 8081, host: "example.com", region: "Hong Kong"}
252 | ]
253 | )
254 |
255 | assert_eval(
256 | table,
257 | [plan: %{name: "T3"}],
258 | [
259 | %{id: 3, port: 8082, host: "example.com", region: "Hong Kong"}
260 | ]
261 | )
262 | end
263 | end
264 |
265 | describe "Nested outputs" do
266 | test "works" do
267 | table =
268 | Tablex.new("""
269 | M target.country target.province || feature1.enabled
270 | 1 Canada BC,ON || -
271 | 2 Canada - || true
272 | """)
273 |
274 | assert_eval(
275 | table,
276 | [target: %{country: "Canada", province: "BC"}],
277 | %{feature1: %{enabled: true}}
278 | )
279 | end
280 | end
281 |
282 | defp assert_eval(table, args, expect) do
283 | code = generate(table)
284 | # IO.puts(code)
285 |
286 | {ret, _} = Code.eval_string(code, args)
287 | assert expect == ret
288 | end
289 | end
290 |
--------------------------------------------------------------------------------
/test/tablex/decider_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tablex.DeciderTest do
2 | alias Tablex.Decider
3 |
4 | use ExUnit.Case
5 | doctest Decider
6 |
7 | describe "match_rule?/2" do
8 | test "works" do
9 | assert Decider.match_rule?(
10 | %{
11 | [:car_size] => "mid_size",
12 | [:miles_driven] => {:<=, 100},
13 | [:rental_duration] => {:>=, 3}
14 | },
15 | %{car_size: "mid_size", miles_driven: 100, rental_duration: 7}
16 | )
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/test/tablex/formatter/code_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Formatter.CodeTest do
2 | use ExUnit.Case
3 |
4 | describe "Formatting a code value" do
5 | test "works" do
6 | code = """
7 | C || t
8 | 1 || `now()`
9 | 2 || `1 + 3`
10 | """
11 |
12 | assert code |> Tablex.new() |> Tablex.Formatter.to_s() == String.trim_trailing(code)
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/test/tablex/formatter/emoji_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Formatter.EmojiTest do
2 | use ExUnit.Case
3 |
4 | describe "Formatting a table containing emojis" do
5 | test "works" do
6 | code = """
7 | C || flag name feature
8 | 1 || "🇸🇬 " USA yes
9 | 2 || "🇺🇸 " Singapore no
10 | """
11 |
12 | assert code |> Tablex.new() |> Tablex.Formatter.to_s() == String.trim_trailing(code)
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/test/tablex/formatter/format_list_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Formatter.FormatListTest do
2 | use ExUnit.Case
3 |
4 | alias Tablex.Formatter
5 |
6 | describe "Formatting list values" do
7 | test "works" do
8 | table =
9 | Tablex.new("""
10 | M name || items
11 | 1 Alex || [food]
12 | """)
13 |
14 | assert Formatter.to_s(table) == "M name || items\n1 Alex || [food]"
15 | end
16 |
17 | test "works with nested list" do
18 | table =
19 | Tablex.new("""
20 | M name || items
21 | 1 Alex || [[food]]
22 | """)
23 |
24 | assert Formatter.to_s(table) == "M name || items\n1 Alex || [[food]]"
25 | end
26 |
27 | test "works with more complex nested lists" do
28 | table =
29 | Tablex.new("""
30 | M name || items
31 | 1 Alex || [[food]]
32 | 2 Bob || [[food], [drink]]
33 | 3 Mike || [[1, 2], [3, 4]]
34 | 4 Zoe || [one]
35 | """)
36 |
37 | assert Formatter.to_s(table) ==
38 | "M name || items\n1 Alex || [[food]]\n2 Bob || [[food],[drink]]\n3 Mike || [[1,2],[3,4]]\n4 Zoe || [one]"
39 | end
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/test/tablex/formatter_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tablex.FormatterTest do
2 | alias Tablex.Formatter
3 |
4 | use ExUnit.Case
5 | doctest Formatter
6 |
7 | describe "to_s/1" do
8 | test "works" do
9 | table =
10 | Tablex.new("""
11 | F day (string) weather (string) || activity
12 | 1 Monday,Tuesday,Wednesday,Thursday rainy || read
13 | 2 Monday,Tuesday,Wednesday,Thursday - || read,walk
14 | 3 Friday sunny || soccer
15 | 4 Friday - || swim
16 | 5 Saturday - || "watch movie",games
17 | 6 Sunday - || null
18 | """)
19 |
20 | assert_format(table)
21 | end
22 |
23 | test "works with informative rows" do
24 | table =
25 | Tablex.new("""
26 | F day weather || activity
27 | (string, weekday) - || -
28 | 1 Monday,Tuesday,Wednesday,Thursday rainy || read
29 | 2 Monday,Tuesday,Wednesday,Thursday - || read,walk
30 | 3 Friday sunny || soccer
31 | 4 Friday - || swim
32 | 5 Saturday - || "watch movie",games
33 | 6 Sunday - || null
34 | """)
35 |
36 | assert_format(table)
37 | end
38 |
39 | test "works with empty list" do
40 | table =
41 | Tablex.new("""
42 | C foo || bar
43 | 1 1 || []
44 | 2 2 || 1,2
45 | """)
46 |
47 | assert_format(table)
48 | end
49 |
50 | test "update rule ids" do
51 | table = %Tablex.Table{
52 | hit_policy: :first_hit,
53 | inputs: [
54 | %Tablex.Variable{
55 | name: :id,
56 | label: "store.id",
57 | desc: "Store Id",
58 | type: :integer,
59 | path: [:store]
60 | },
61 | %Tablex.Variable{
62 | name: :id,
63 | label: "quest.brand.id",
64 | desc: "Brand Id",
65 | type: :integer,
66 | path: [:quest, :brand]
67 | },
68 | %Tablex.Variable{
69 | name: :pickingOnly,
70 | label: "quest.pickingAndDelivery.pickingOnly",
71 | desc: "Picking-only?",
72 | type: :bool,
73 | path: [:quest, :pickingAndDelivery]
74 | },
75 | %Tablex.Variable{
76 | name: :deliveryType,
77 | label: "quest.pickingAndDelivery.deliveryType",
78 | desc: "Delivery Type",
79 | type: :string,
80 | path: [:quest, :pickingAndDelivery]
81 | },
82 | %Tablex.Variable{
83 | name: :type,
84 | label: "quest.type",
85 | desc: nil,
86 | type: :string,
87 | path: [:quest]
88 | }
89 | ],
90 | outputs: [
91 | %Tablex.Variable{
92 | name: :enabled,
93 | label: "enabled",
94 | desc: nil,
95 | type: :undefined,
96 | path: []
97 | }
98 | ],
99 | rules: [
100 | [1, {:input, [:any, :any, true, :any, :any]}, {:output, [false]}],
101 | [2, {:input, [:any, 602, :any, "VENTEL", :any]}, {:output, [false]}],
102 | [3, {:input, [:any, [719, 749], :any, :any, :any]}, {:output, [true]}],
103 | [4, {:input, [:any, ~c"x|", :any, :any, :any]}, {:output, [true]}],
104 | [5, {:input, [:any, 131, :any, :any, :any]}, {:output, [true]}],
105 | [6, {:input, [:any, 601, :any, :any, :any]}, {:output, [true]}],
106 | [7, {:input, [:any, 729, :any, :any, :any]}, {:output, [true]}],
107 | [8, {:input, [:any, 724, :any, :any, :any]}, {:output, [true]}],
108 | [9, {:input, [:any, 723, :any, :any, :any]}, {:output, [true]}],
109 | [9, {:input, [:any, 106, :any, :any, :any]}, {:output, [true]}],
110 | [9, {:input, [:any, 244, :any, :any, :any]}, {:output, [true]}],
111 | [9, {:input, [:any, 735, :any, :any, :any]}, {:output, [true]}],
112 | [9, {:input, [:any, 700, :any, :any, :any]}, {:output, [true]}],
113 | [9, {:input, [:any, 732, :any, :any, :any]}, {:output, [true]}],
114 | [10, {:input, [2434, :any, :any, :any, :any]}, {:output, [true]}],
115 | [10, {:input, [2390, :any, :any, :any, :any]}, {:output, [true]}],
116 | [10, {:input, [19849, :any, :any, :any, :any]}, {:output, [true]}],
117 | [10, {:input, [20923, :any, :any, :any, :any]}, {:output, [true]}],
118 | [
119 | 10,
120 | {:input,
121 | [
122 | [20922, 20921, 20920],
123 | :any,
124 | :any,
125 | :any,
126 | :any
127 | ]},
128 | {:output, [true]}
129 | ],
130 | [11, {:input, [123, 719, :any, :any, :any]}, {:output, [true]}],
131 | [12, {:input, [:any, :any, :any, :any, :any]}, {:output, [false]}]
132 | ],
133 | valid?: :undefined,
134 | table_dir: :h
135 | }
136 |
137 | assert_format(table)
138 | end
139 | end
140 |
141 | describe "Formatting vertical tables" do
142 | test "works" do
143 | table =
144 | Tablex.new("""
145 | ====
146 | F || 1 2 3
147 | age || >50 - -
148 | i || >8.0 >5.0 -
149 | ====
150 | test || positive positive negative
151 | act || hospital observe rest
152 | """)
153 |
154 | assert_format(table)
155 | end
156 | end
157 |
158 | describe "Formatting horizontal tables" do
159 | test "works" do
160 | table =
161 | Tablex.new("""
162 | C || region id host port
163 | 1 || Singapore 1 example.com 2020
164 | 1 || Singapore 2 example.com 2025
165 | 2 || Japan 1 example.com 2021
166 | 2 || Japan 2 example.com 2022
167 | 2 || "Hong Kong" 1 example.com 2027
168 | 2 || "Hong Kong" 2 example.com 2028
169 | 2 || USA 2 example.com 2023
170 | 2 || USA 3 example.com 2026
171 | """)
172 |
173 | assert_format(table)
174 | end
175 | end
176 |
177 | describe "Formatting ambitional strings" do
178 | test "works" do
179 | table =
180 | Tablex.new("""
181 | C || value
182 | 1 || "1983-04-01"
183 | """)
184 |
185 | assert_format(table)
186 | end
187 | end
188 |
189 | defp assert_format(table) do
190 | # table |> Formatter.to_s() |> IO.puts()
191 | assert table |> Formatter.to_s() |> Tablex.new() == fix_ids(table)
192 | end
193 |
194 | defp fix_ids(table) do
195 | rules =
196 | table.rules
197 | |> Stream.with_index(1)
198 | |> Enum.map(fn {[_ | rest], index} -> [index | rest] end)
199 |
200 | %{table | rules: rules}
201 | end
202 | end
203 |
--------------------------------------------------------------------------------
/test/tablex/informative_row_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tablex.InformativeRowTest do
2 | alias Tablex.Table
3 | use ExUnit.Case
4 | import DoctestFile
5 |
6 | doctest_file("guides/informative_row.md")
7 |
8 | describe "Informative row" do
9 | test "works" do
10 | table =
11 | Tablex.new("""
12 | F foo || bar
13 | (string) || (string)
14 | 1 test || test
15 | """)
16 |
17 | assert %Table{
18 | inputs: [
19 | %{name: :foo, type: :string, desc: nil}
20 | ],
21 | outputs: [
22 | %{name: :bar, type: :string, desc: nil}
23 | ]
24 | } = table
25 | end
26 |
27 | test "works for output-only tables" do
28 | table =
29 | Tablex.new("""
30 | C || bar
31 | || (string)
32 | 1 || test
33 | """)
34 |
35 | assert %Table{
36 | inputs: [],
37 | outputs: [
38 | %{name: :bar, type: :string, desc: nil}
39 | ]
40 | } = table
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/test/tablex/nested_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tablex.NestedTest do
2 | use ExUnit.Case
3 | import DoctestFile
4 |
5 | doctest_file("guides/nested_fields.md")
6 |
7 | describe "Nested output definition" do
8 | test "works" do
9 | table =
10 | Tablex.new("""
11 | C || a.b a.c
12 | 1 || 1 2
13 | 2 || 3 4
14 | """)
15 |
16 | assert Tablex.decide(table, []) == [
17 | %{a: %{b: 1, c: 2}},
18 | %{a: %{b: 3, c: 4}}
19 | ]
20 | end
21 |
22 | defmodule StructA do
23 | defstruct [:a]
24 | end
25 |
26 | defmodule StructB do
27 | defstruct [:b]
28 | end
29 |
30 | test "works with structs" do
31 | table =
32 | Tablex.new("""
33 | F a.b || hit
34 | 1 <10 || T
35 | 2 - || F
36 | """)
37 |
38 | assert %{hit: false} = Tablex.decide(table, %StructA{a: %StructB{b: 10}})
39 | assert %{hit: true} = Tablex.decide(table, %StructA{a: %StructB{b: 9}})
40 | end
41 | end
42 |
43 | describe "Nested input definition" do
44 | test "works" do
45 | table =
46 | Tablex.new("""
47 | F a.b || hit
48 | 1 <10 || T
49 | 2 - || F
50 | """)
51 |
52 | assert Tablex.decide(table, a: %{b: 10}) == %{hit: false}
53 | assert Tablex.decide(table, a: %{b: 9}) == %{hit: true}
54 | end
55 | end
56 |
57 | describe "Generated code" do
58 | test "contains recursive pattern matching" do
59 | table =
60 | Tablex.new("""
61 | F a.b || hit
62 | 1 <10 || T
63 | 2 - || F
64 | """)
65 |
66 | code = Tablex.CodeGenerate.generate(table)
67 |
68 | assert {%{hit: false}, _} = Code.eval_string(code, a: %{b: 10})
69 | assert {%{hit: true}, _} = Code.eval_string(code, a: %{b: 9})
70 | end
71 |
72 | test "works with more levels" do
73 | table =
74 | Tablex.new("""
75 | F a.b.c || hit
76 | 1 <10 || T
77 | 2 - || F
78 | """)
79 |
80 | code = Tablex.CodeGenerate.generate(table)
81 |
82 | assert {%{hit: false}, _} = Code.eval_string(code, a: %{b: %{c: 10}})
83 | assert {%{hit: true}, _} = Code.eval_string(code, a: %{b: %{c: 9}})
84 | end
85 |
86 | test "works with a real example" do
87 | table =
88 | Tablex.new("""
89 | F quest.brand.id || enabled
90 | (integer, Brand Id) || (bool)
91 | 2 602 || false
92 | 2 - || true
93 | """)
94 |
95 | code = Tablex.CodeGenerate.generate(table)
96 |
97 | assert {%{enabled: false}, _} = Code.eval_string(code, quest: %{brand: %{id: 602}})
98 | end
99 | end
100 | end
101 |
--------------------------------------------------------------------------------
/test/tablex/optimizer/merge_rules_test.exs:
--------------------------------------------------------------------------------
1 | defmodule MergeRules.MergeRulesTest do
2 | alias Tablex.Optimizer.MergeRules
3 | use ExUnit.Case
4 |
5 | describe "Merge rules" do
6 | test "works" do
7 | table =
8 | Tablex.new("""
9 | M a.b || hit log
10 | 1 2,3 || T T
11 | 2 1 || T T
12 | """)
13 |
14 | assert %{
15 | rules: [
16 | [1, {:input, [[1, 2, 3]]}, {:output, [true, true]}]
17 | ]
18 | } = MergeRules.optimize(table)
19 | end
20 |
21 | test "works when there are unrelavent rules between" do
22 | table =
23 | Tablex.new("""
24 | M a.b || hit log
25 | 1 2,3 || T T
26 | 2 4 || F T
27 | 3 1 || T T
28 | """)
29 |
30 | assert %{
31 | rules: [
32 | [1, {:input, [4]}, {:output, [false, true]}],
33 | [2, {:input, [[1, 2, 3]]}, {:output, [true, true]}]
34 | ]
35 | } = MergeRules.optimize(table)
36 | end
37 |
38 | test "works with `reverse_merge` hit policy" do
39 | table =
40 | Tablex.new("""
41 | R a.b || hit log
42 | 1 1 || T T
43 | 2 4 || F T
44 | 3 2,3 || T T
45 | """)
46 |
47 | assert %{
48 | rules: [
49 | [1, {:input, [[1, 2, 3]]}, {:output, [true, true]}],
50 | [2, {:input, [4]}, {:output, [false, true]}]
51 | ]
52 | } = MergeRules.optimize(table)
53 | end
54 |
55 | test "breaks lists and merges back" do
56 | table =
57 | Tablex.new("""
58 | M a.b a.c || hit log
59 | 1 2,3 foo,bar || - T
60 | 3 2 bar || - T
61 | """)
62 |
63 | assert %{
64 | rules: [
65 | [1, {:input, [[2, 3], ["bar", "foo"]]}, {:output, [:any, true]}]
66 | ]
67 | } = MergeRules.optimize(table)
68 | end
69 |
70 | test "breaks lists and merges back, with something in the middle" do
71 | table =
72 | Tablex.new("""
73 | M a.b a.c || hit log
74 | 1 2,3 foo,bar || - T
75 | 2 4 - || F T
76 | 3 2 bar || - T
77 | """)
78 |
79 | assert %{
80 | rules: [
81 | [1, {:input, [4, :any]}, {:output, [false, true]}],
82 | [2, {:input, [[2, 3], ["bar", "foo"]]}, {:output, [:any, true]}]
83 | ]
84 | } = MergeRules.optimize(table)
85 | end
86 |
87 | test "breaks lists and merges back, example 3" do
88 | table =
89 | Tablex.new("""
90 | M a.b a.c || hit log
91 | 1 2,3 foo,bar || F T
92 | 2 4 - || F T
93 | 3 2 bar || - T
94 | """)
95 |
96 | assert %{
97 | rules: [
98 | [1, {:input, [4, :any]}, {:output, [false, true]}],
99 | [2, {:input, [[2, 3], ["bar", "foo"]]}, {:output, [false, true]}]
100 | ]
101 | } = MergeRules.optimize(table)
102 | end
103 |
104 | test "breaks lists and merges back, example 3, with `reverse_merge` hit policy" do
105 | table =
106 | Tablex.new("""
107 | R a.b a.c || hit log
108 | 1 2 bar || - T
109 | 2 4 - || F T
110 | 3 2,3 foo,bar || F T
111 | """)
112 |
113 | assert %{
114 | rules: [
115 | [1, {:input, [[2, 3], ["bar", "foo"]]}, {:output, [false, true]}],
116 | [2, {:input, [4, :any]}, {:output, [false, true]}]
117 | ]
118 | } = MergeRules.optimize(table)
119 | end
120 |
121 | test "works with two dimentions" do
122 | table =
123 | Tablex.new("""
124 | M x n || value
125 | 1 a 1 || T
126 | 2 b 1 || T
127 | 3 a 2 || T
128 | 4 b 2 || T
129 | 5 - - || F
130 | """)
131 |
132 | assert %{
133 | rules: [
134 | [1, {:input, [["a", "b"], [1, 2]]}, {:output, [true]}],
135 | [2, {:input, [:any, :any]}, {:output, [false]}]
136 | ]
137 | } = MergeRules.optimize(table)
138 | end
139 |
140 | test "works with two dimentions and `first_hit`" do
141 | table =
142 | Tablex.new("""
143 | F x n || value
144 | 1 a 1 || T
145 | 2 b 1 || T
146 | 3 a 2 || T
147 | 4 b 2 || T
148 | 5 - - || F
149 | """)
150 |
151 | assert %{
152 | rules: [
153 | [1, {:input, [["a", "b"], [1, 2]]}, {:output, [true]}],
154 | [2, {:input, [:any, :any]}, {:output, [false]}]
155 | ]
156 | } = MergeRules.optimize(table)
157 | end
158 |
159 | test "works with two dimentions and `first_hit`, when there's another rule between" do
160 | table =
161 | Tablex.new("""
162 | F x n || value
163 | 1 a 1 || T
164 | 2 c 3 || test
165 | 3 b 1 || T
166 | 4 - - || F
167 | """)
168 |
169 | assert %{
170 | rules: [
171 | [1, {:input, ["c", 3]}, {:output, ["test"]}],
172 | [2, {:input, [["a", "b"], 1]}, {:output, [true]}],
173 | [3, {:input, [:any, :any]}, {:output, [false]}]
174 | ]
175 | } = MergeRules.optimize(table)
176 | end
177 |
178 | test "should not merge two rules where there are other co-existing rules between" do
179 | table =
180 | Tablex.new("""
181 | F foo bar || x
182 | 1 false 1 || 1
183 | 2 false - || 2
184 | 3 true - || 3
185 | 4 - - || 2
186 | """)
187 |
188 | assert %{x: 3} == Tablex.decide(table, foo: true)
189 | assert %{x: 3} == table |> MergeRules.optimize() |> Tablex.decide(foo: true)
190 |
191 | assert %{
192 | rules: [
193 | [1, {:input, [false, 1]}, {:output, [1]}],
194 | [2, {:input, [true, :any]}, {:output, [3]}],
195 | [3, {:input, [:any, :any]}, {:output, [2]}]
196 | ]
197 | } = MergeRules.optimize(table)
198 | end
199 | end
200 | end
201 |
--------------------------------------------------------------------------------
/test/tablex/optimizer/optimizer_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tablex.OptimizerTest do
2 | use ExUnit.Case
3 |
4 | doctest Tablex.Optimizer
5 |
6 | describe "optimize/1" do
7 | test "works" do
8 | table =
9 | Tablex.new("""
10 | F a.b || hit hours
11 | 1 <10 || T [[1, 2], [3, 4]]
12 | 2 - || F []
13 | """)
14 |
15 | assert Tablex.Optimizer.optimize(table) == table
16 | end
17 |
18 | test "works for a more complex example" do
19 | table =
20 | Tablex.new("""
21 | ====
22 | M || 1 2 3
23 | target.company_id || - - -
24 | target.store_id || 2053 2053 -
25 | ====
26 | feature1 || - yes no
27 | feature2 || yes no no
28 | feature3 || no - yes
29 | feature4.enabled || - yes no
30 | feature4.km || - 0.3 10000
31 | """)
32 |
33 | expected_output = %{
34 | feature1: true,
35 | feature2: true,
36 | feature3: false,
37 | feature4: %{enabled: true, km: 0.3}
38 | }
39 |
40 | assert expected_output == Tablex.decide(table, target: %{store_id: 2053})
41 |
42 | assert expected_output ==
43 | table
44 | |> Tablex.Optimizer.optimize()
45 | |> Tablex.decide(target: %{store_id: 2053})
46 |
47 | assert """
48 | ====
49 | M || 1 2
50 | target.company_id || - -
51 | target.store_id || 2053 -
52 | ====
53 | feature1 || yes no
54 | feature2 || yes no
55 | feature3 || no yes
56 | feature4.enabled || yes no
57 | feature4.km || 0.3 10000
58 | """
59 | |> String.trim_trailing() ==
60 | table
61 | |> Tablex.Optimizer.optimize()
62 | |> Tablex.Formatter.to_s()
63 | end
64 |
65 | test "works for a more complex, reerse_merge, example" do
66 | table =
67 | Tablex.new("""
68 | ====
69 | R || 3 2 1
70 | target.company_id || - - -
71 | target.store_id || 2053 2053 -
72 | ====
73 | feature1 || - yes no
74 | feature2 || - yes no
75 | feature3 || no - yes
76 | feature4.enabled || - yes no
77 | feature4.km || - 0.3 10000
78 | """)
79 |
80 | expected_output = %{
81 | feature1: true,
82 | feature2: true,
83 | feature3: false,
84 | feature4: %{enabled: true, km: 0.3}
85 | }
86 |
87 | assert expected_output == Tablex.decide(table, target: %{store_id: 2053})
88 |
89 | assert expected_output ==
90 | table
91 | |> Tablex.Optimizer.optimize()
92 | |> Tablex.decide(target: %{store_id: 2053})
93 |
94 | assert """
95 | ====
96 | R || 1 2
97 | target.company_id || - -
98 | target.store_id || - 2053
99 | ====
100 | feature1 || no yes
101 | feature2 || no yes
102 | feature3 || yes no
103 | feature4.enabled || no yes
104 | feature4.km || 10000 0.3
105 | """
106 | |> String.trim_trailing() ==
107 | table
108 | |> Tablex.Optimizer.optimize()
109 | |> Tablex.Formatter.to_s()
110 | end
111 |
112 | test "works for collect hit policy" do
113 | table =
114 | Tablex.new("""
115 | C || a
116 | 1 || -
117 | """)
118 |
119 | assert table |> Tablex.Optimizer.optimize() == table
120 | end
121 | end
122 | end
123 |
--------------------------------------------------------------------------------
/test/tablex/optimizer/remove_dead_rules_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Optimizer.RemoveDeadRulesTest do
2 | alias Tablex.Optimizer.RemoveDeadRules
3 |
4 | use ExUnit.Case
5 |
6 | describe "dead rule removal" do
7 | test "works" do
8 | table =
9 | Tablex.new("""
10 | F a.b || hit
11 | 1 <10 || T
12 | 2 - || F
13 | 3 >10 || F
14 | """)
15 |
16 | assert %{
17 | rules: [
18 | [1, {:input, [<: 10]}, {:output, [true]}],
19 | [2, {:input, [:any]}, {:output, [false]}]
20 | ]
21 | } = RemoveDeadRules.optimize(table)
22 | end
23 |
24 | test "works with comparison (<)" do
25 | table =
26 | Tablex.new("""
27 | F a.b || hit
28 | 1 <10 || T
29 | 2 <5 || F
30 | 3 - || F
31 | """)
32 |
33 | assert %{
34 | rules: [
35 | [1, {:input, [<: 10]}, {:output, [true]}],
36 | [3, {:input, [:any]}, {:output, [false]}]
37 | ]
38 | } = RemoveDeadRules.optimize(table)
39 | end
40 |
41 | test "works with comparison (<=)" do
42 | table =
43 | Tablex.new("""
44 | F a.b || hit
45 | 1 <=10 || T
46 | 2 <5 || F
47 | 3 - || F
48 | """)
49 |
50 | assert %{
51 | rules: [
52 | [1, {:input, [<=: 10]}, {:output, [true]}],
53 | [3, {:input, [:any]}, {:output, [false]}]
54 | ]
55 | } = RemoveDeadRules.optimize(table)
56 | end
57 |
58 | test "works with comparison (>)" do
59 | table =
60 | Tablex.new("""
61 | F a.b || hit
62 | 1 >50 || T
63 | 2 >=51 || F
64 | 3 - || F
65 | """)
66 |
67 | assert %{
68 | rules: [
69 | [1, {:input, [{:>, 50}]}, {:output, [true]}],
70 | [3, {:input, [:any]}, {:output, [false]}]
71 | ]
72 | } = RemoveDeadRules.optimize(table)
73 | end
74 |
75 | test "works with comparison (>=)" do
76 | table =
77 | Tablex.new("""
78 | F a.b || hit
79 | 1 >=50 || T
80 | 2 >60 || F
81 | 3 - || F
82 | """)
83 |
84 | assert %{
85 | rules: [
86 | [1, {:input, [{:>=, 50}]}, {:output, [true]}],
87 | [3, {:input, [:any]}, {:output, [false]}]
88 | ]
89 | } = RemoveDeadRules.optimize(table)
90 | end
91 |
92 | test "works with lists" do
93 | table =
94 | Tablex.new("""
95 | F a.b || hit
96 | 1 1,2,3 || T
97 | 2 1 || F
98 | 3 2,3 || F
99 | """)
100 |
101 | assert %{
102 | rules: [
103 | [1, {:input, [[1, 2, 3]]}, {:output, [true]}]
104 | ]
105 | } = RemoveDeadRules.optimize(table)
106 | end
107 |
108 | test "works with `merge` hit_policy" do
109 | table =
110 | Tablex.new("""
111 | M a.b || hit
112 | 1 1,2,3 || T
113 | 2 1 || F
114 | 3 2 || F
115 | """)
116 |
117 | assert %{
118 | rules: [
119 | [1, {:input, [[1, 2, 3]]}, {:output, [true]}]
120 | ]
121 | } = RemoveDeadRules.optimize(table)
122 | end
123 | end
124 | end
125 |
--------------------------------------------------------------------------------
/test/tablex/optimizer/remove_duplicated_rules_test.exs:
--------------------------------------------------------------------------------
1 | # test Tablex.Optimizer.RemoveDuplication.optimize/1
2 | defmodule Tablex.Optimizer.RemoveDuplicationTest do
3 | alias Tablex.Optimizer.RemoveDuplication
4 |
5 | use ExUnit.Case
6 | doctest RemoveDuplication
7 |
8 | describe "optimize/1" do
9 | test "works" do
10 | table =
11 | Tablex.new("""
12 | F a.b || hit
13 | 1 <10 || T
14 | 2 <10 || T
15 | 3 >50 || T
16 | """)
17 |
18 | assert %{
19 | rules: [
20 | [1, {:input, [<: 10]}, {:output, [true]}],
21 | [2, {:input, [>: 50]}, {:output, [true]}]
22 | ]
23 | } = RemoveDuplication.optimize(table)
24 | end
25 |
26 | test "works with `reverse_merge` hit policy" do
27 | table =
28 | Tablex.new("""
29 | R a.b || hit
30 | 1 - || T
31 | 2 <10 || F
32 | 3 <10 || F
33 | 4 >50 || T
34 | """)
35 |
36 | assert %{
37 | rules: [
38 | [1, {:input, [:any]}, {:output, [true]}],
39 | [2, {:input, [<: 10]}, {:output, [false]}],
40 | [3, {:input, [>: 50]}, {:output, [true]}]
41 | ]
42 | } = RemoveDuplication.optimize(table)
43 | end
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/test/tablex/optimizer/remove_empty_rules.exs:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Optimizer.RemoveEmptyRulesTest do
2 | alias Tablex.Optimizer.RemoveEmptyRules
3 |
4 | use ExUnit.Case
5 |
6 | describe "Removing rules without meaningful values" do
7 | test "works" do
8 | table =
9 | Tablex.new("""
10 | M a.b || hit log
11 | 1 1,2,3 || T T
12 | 2 1 || F -
13 | 3 2,3 || - -
14 | """)
15 |
16 | assert %{
17 | rules: [
18 | [1, {:input, [[1, 2, 3]]}, {:output, [true, true]}],
19 | [2, {:input, [1]}, {:output, [false, :any]}]
20 | ]
21 | } = RemoveEmptyRules.optimize(table)
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/test/tablex/parser/expression_test.exs:
--------------------------------------------------------------------------------
1 | defmodule ExpressionParser do
2 | import NimbleParsec
3 | import Tablex.Parser.Expression
4 |
5 | use Tablex.Parser.Expression.List
6 |
7 | defparsec(:parse, expression(), [])
8 | end
9 |
10 | defmodule Tablex.Parser.ExpressionTest do
11 | alias Tablex.Parser.Expression
12 |
13 | use ExUnit.Case, async: true
14 | import ExpressionParser, only: [parse: 1]
15 |
16 | doctest Expression
17 |
18 | describe "Parsing boolean expressions" do
19 | test "works for `true`" do
20 | assert_parse("true", true)
21 | end
22 |
23 | test "works for `false`" do
24 | assert_parse("false", false)
25 | end
26 | end
27 |
28 | describe "Parsing numeric" do
29 | test "works with simple digits" do
30 | assert_parse("1", 1)
31 | assert_parse("01", 1)
32 | assert_parse("-90", -90)
33 | end
34 |
35 | test "works with digits and underscores" do
36 | assert_parse("1_000", 1000)
37 | assert_parse("1_000_000", 1_000_000)
38 | assert_parse("-3_2", -32)
39 | end
40 |
41 | test "works with floats" do
42 | assert_parse("3.44", 3.44)
43 | assert_parse("1.3415", 1.3415)
44 | assert_parse("-0.96", -0.96)
45 | end
46 | end
47 |
48 | describe "Parsing `nil`" do
49 | test "works" do
50 | assert_parse("null", nil)
51 | end
52 | end
53 |
54 | describe "Parsing `-`" do
55 | test "works" do
56 | assert_parse("- ", :any)
57 | end
58 | end
59 |
60 | describe "Parsing strings" do
61 | test "works" do
62 | assert_parse("test", "test")
63 | end
64 |
65 | test "stops before space" do
66 | assert_parse("foo bar", "foo")
67 | end
68 | end
69 |
70 | describe "Parsing quoted string" do
71 | test "works" do
72 | string = ~S("foo")
73 | assert_parse(string, "foo")
74 | end
75 |
76 | test "works with escaped quote" do
77 | string = ~S("a string that contains \" and \"!")
78 | assert_parse(string, "a string that contains \" and \"!")
79 | end
80 |
81 | test "works with number, null and any" do
82 | string = ~S("1.3415 null - ||")
83 | assert_parse(string, "1.3415 null - ||")
84 | end
85 | end
86 |
87 | describe "Parsing a list" do
88 | test "works" do
89 | assert_parse("a,b", ["a", "b"])
90 | end
91 |
92 | test "works with numbers" do
93 | assert_parse("1,3.44,-5", [1, 3.44, -5])
94 | end
95 |
96 | test "works with quoted strings" do
97 | assert_parse(~S(1," hello,world ",null), [1, " hello,world ", nil])
98 | assert_parse(~S("test",abc,false), ["test", "abc", false])
99 | end
100 |
101 | test "works with ranges" do
102 | assert_parse("100..200, >= 400", [100..200, {:>=, 400}])
103 | end
104 |
105 | test "works with empty lists" do
106 | assert_parse("[]", [])
107 | end
108 |
109 | test "works with single-element lists" do
110 | assert_parse("[1]", [1])
111 | end
112 |
113 | test "works with multi-element lists" do
114 | assert_parse("[1, 2]", [1, 2])
115 | assert_parse("[foo,bar]", ["foo", "bar"])
116 | end
117 |
118 | test "works with nested lists" do
119 | assert_parse("[[1, 2], [3, 4]]", [[1, 2], [3, 4]])
120 | end
121 | end
122 |
123 | describe "Parsing comparisons" do
124 | test "works with `>`" do
125 | assert_parse("> 1", {:>, 1})
126 | assert_parse("> 1", {:>, 1})
127 | assert_parse(">1", {:>, 1})
128 | end
129 |
130 | test "works with `>=`" do
131 | assert_parse(">= -5.0", {:>=, -5.0})
132 | end
133 |
134 | test "does not work with non-numeric values" do
135 | assert_parse("> abc", ">")
136 | end
137 | end
138 |
139 | describe "Parsing ranges" do
140 | test "works with integers" do
141 | assert_parse("1..100", 1..100)
142 | end
143 |
144 | test "does not work with floats" do
145 | assert_parse("1.0..5", 1.0)
146 | end
147 |
148 | test "works with underscores" do
149 | assert_parse("1_000..2_000", 1000..2000)
150 | end
151 | end
152 |
153 | defp assert_parse(code, expect) do
154 | assert {:ok, [^expect], _, _, _, _} = parse(code)
155 | end
156 | end
157 |
--------------------------------------------------------------------------------
/test/tablex/parser/informative_row_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.InformativeRowTest do
2 | use ExUnit.Case
3 |
4 | describe "Parsing informative row" do
5 | test "works" do
6 | assert [[string: nil], [integer: nil]] == parse(" (string) || (integer)")
7 | end
8 |
9 | test "works with description" do
10 | assert [[string: "foo"], [integer: "bar bar"]] ==
11 | parse(" (string, foo) || (integer, bar bar)")
12 | end
13 |
14 | test "works with multiple stubs" do
15 | assert [[string: "foo", string: "foz"], [integer: "bar bar", bool: "baz"]] ==
16 | parse(" (string, foo) (string, foz) || (integer, bar bar) (bool, baz)")
17 | end
18 |
19 | test "works with ANY" do
20 | assert [[undefined: nil], [undefined: nil]] == parse(" - || -")
21 | end
22 | end
23 |
24 | defp parse(text) do
25 | assert {:ok, [var], _, _, _, _} = InfoRowParser.parse(text)
26 | var
27 | end
28 | end
29 |
30 | defmodule InfoRowParser do
31 | import NimbleParsec
32 | import Tablex.Parser.InformativeRow
33 |
34 | defparsec(:parse, informative_row(), [])
35 | end
36 |
--------------------------------------------------------------------------------
/test/tablex/parser/rule_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RuleParser do
2 | import NimbleParsec
3 | import Tablex.Parser.Rule
4 |
5 | use Tablex.Parser.Expression.List
6 |
7 | defparsec(:parse, rules(), [])
8 | end
9 |
10 | defmodule Tablex.Parser.RuleTest do
11 | use ExUnit.Case, async: true
12 | alias Tablex.Parser.Rule
13 | doctest Rule
14 |
15 | test "it works with a simple rule" do
16 | assert {:ok, [rules: [[1, input: [1], output: [1]]]], _, _, _, _} =
17 | parse("1 1 || 1")
18 | end
19 |
20 | test "it works with numbers" do
21 | assert {:ok, [rules: [[1, input: [103, 19.5], output: [4]]]], _, _, _, _} =
22 | parse("1 103 19.5 || 4")
23 | end
24 |
25 | test "it works" do
26 | assert {:ok, [rules: [[1, input: ["test", :any], output: [4]]]], _, _, _, _} =
27 | parse("1 test - || 4")
28 | end
29 |
30 | describe "Expression of any (`-`)" do
31 | test "works" do
32 | assert {:ok,
33 | [
34 | rules: [
35 | [1, input: [:any], output: [true]]
36 | ]
37 | ], _, _, _, _} = parse("1 - || true")
38 | end
39 | end
40 |
41 | describe "Expression with quotes" do
42 | test "works" do
43 | text = ~S(50 "Hello!" "||" "null" || null)
44 |
45 | assert {:ok,
46 | [
47 | rules: [
48 | [50, input: ["Hello!", "||", "null"], output: [nil]]
49 | ]
50 | ], _, _, _, _} = parse(text)
51 | end
52 | end
53 |
54 | describe "Two output expressions" do
55 | test "works" do
56 | assert {:ok,
57 | [
58 | rules: [
59 | [1, input: [:any], output: [true, :any]]
60 | ]
61 | ], _, _, _, _} = parse("1 - || true -")
62 | end
63 | end
64 |
65 | describe "Range expression" do
66 | test "works" do
67 | assert {:ok,
68 | [
69 | rules: [
70 | [1, input: [1..10], output: [true]]
71 | ]
72 | ], _, _, _, _} = parse("1 1..10 || Y")
73 | end
74 | end
75 |
76 | defp parse(source) do
77 | RuleParser.parse(source)
78 | end
79 | end
80 |
--------------------------------------------------------------------------------
/test/tablex/parser/variable_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.VariableTest do
2 | use ExUnit.Case
3 |
4 | alias Tablex.Parser.Variable
5 | import Variable
6 | doctest Variable
7 |
8 | test "it works" do
9 | assert %{name: :F, label: "F"} = parse("F")
10 | end
11 |
12 | test "it works for multi-letter char" do
13 | assert %{name: :Test, label: "Test"} = parse("Test")
14 | end
15 |
16 | describe "Variable name cases" do
17 | test "are kept" do
18 | assert %{name: :TestFoo} = parse("TestFoo")
19 | assert %{name: :Test_Foo} = parse("Test-Foo")
20 | assert %{name: :Test__Foo} = parse("Test-_Foo")
21 | assert %{name: :Test__Foo, path: [:myBar]} = parse("myBar.Test-_Foo")
22 | end
23 | end
24 |
25 | test "it works with string type" do
26 | assert %{name: :Test, type: :string} = parse("Test (string)")
27 | end
28 |
29 | test "it works with descriptions" do
30 | assert %{
31 | name: :Test,
32 | label: "Test",
33 | type: :string,
34 | desc: "describing the test"
35 | } = parse("Test (string, describing the test)")
36 | end
37 |
38 | test "it works with quoted strings" do
39 | assert %{
40 | name: :Test__var_test,
41 | label: "Test var test",
42 | type: :string,
43 | desc: "describing the test"
44 | } = parse(~S["Test var test" (string, describing the test)])
45 | end
46 |
47 | describe "With nested variables" do
48 | test "it works with nested variables" do
49 | assert %{
50 | name: :b,
51 | path: [:a],
52 | label: "a.b"
53 | } = parse("a.b")
54 | end
55 |
56 | test "it works at two levels deep" do
57 | assert %{
58 | name: :c,
59 | path: [:a, :b],
60 | label: "a.b.c"
61 | } = parse("a.b.c")
62 | end
63 | end
64 |
65 | defp parse(text) do
66 | assert {:ok, [var], _, _, _, _} = VariableParser.parse(text)
67 | var
68 | end
69 | end
70 |
71 | defmodule VariableParser do
72 | import NimbleParsec
73 | import Tablex.Parser.Variable
74 |
75 | defparsec(:parse, variable(), [])
76 | end
77 |
--------------------------------------------------------------------------------
/test/tablex/parser/vertical_table_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Parser.VerticalTableParserTest do
2 | use ExUnit.Case, async: true
3 |
4 | describe "Parsing a vertical table" do
5 | test "works" do
6 | v_table = """
7 | ====
8 | F || 1 2 3
9 | age || >50 - -
10 | i || >8.0 >5.0 -
11 | ====
12 | test || positive positive negative
13 | act || hospital observe rest
14 | """
15 |
16 | h_table = """
17 | F age i || test act
18 | 1 >50 >8.0 || positive hospital
19 | 2 - >5.0 || positive observe
20 | 3 - - || negative rest
21 | """
22 |
23 | assert_same(v_table, h_table)
24 | end
25 |
26 | test "works without collect hit policy" do
27 | v_table = """
28 | ====
29 | C || 1 2
30 | day || Mon -
31 | rainy || - T
32 | ====
33 | act || run read
34 | """
35 |
36 | h_table = """
37 | C day rainy || act
38 | 1 Mon - || run
39 | 2 - T || read
40 | """
41 |
42 | assert_same(v_table, h_table)
43 | end
44 |
45 | test "works without inputs" do
46 | v_table = """
47 | ====
48 | C || 1 2
49 | ====
50 | act || run read
51 | """
52 |
53 | h_table = """
54 | C || act
55 | 1 || run
56 | 2 || read
57 | """
58 |
59 | assert_same(v_table, h_table)
60 | end
61 | end
62 |
63 | defp assert_same(v_table, h_table) do
64 | vtb = Tablex.new(v_table) |> Map.from_struct() |> Map.drop([:table_dir])
65 | htb = Tablex.new(h_table) |> Map.from_struct() |> Map.drop([:table_dir])
66 |
67 | assert vtb == htb
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/test/tablex/parser_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tablex.ParserTest do
2 | alias Tablex.Table
3 | use ExUnit.Case
4 |
5 | test "basic parser" do
6 | box = """
7 | F day (string) weather (string) || activity
8 | 1 Monday,Tuesday sunny || walk
9 | """
10 |
11 | assert %Table{
12 | hit_policy: :first_hit,
13 | inputs: [
14 | %{name: :day, type: :string},
15 | %{name: :weather, type: :string}
16 | ],
17 | outputs: [%{name: :activity, type: :undefined}],
18 | rules: [
19 | [1, input: [["Monday", "Tuesday"], "sunny"], output: ["walk"]]
20 | ]
21 | } = Tablex.Parser.parse(box, [])
22 | end
23 |
24 | describe "Multiple rules" do
25 | test "work" do
26 | box = """
27 | F day (string) weather (string) || activity
28 | 1 Monday,Tuesday sunny || walk
29 | 2 Monday,Tuesday rainy || read
30 | """
31 |
32 | assert %Table{
33 | hit_policy: :first_hit,
34 | inputs: [
35 | %{name: :day, type: :string},
36 | %{name: :weather, type: :string}
37 | ],
38 | outputs: [%{name: :activity, type: :undefined}],
39 | rules: [
40 | [1, input: [["Monday", "Tuesday"], "sunny"], output: ["walk"]],
41 | [2, input: [["Monday", "Tuesday"], "rainy"], output: ["read"]]
42 | ]
43 | } = Tablex.Parser.parse(box, [])
44 | end
45 | end
46 |
47 | describe "Empty input" do
48 | test "works" do
49 | box = """
50 | C || name
51 | 1 || Alex
52 | """
53 |
54 | assert %Table{
55 | hit_policy: :collect,
56 | inputs: [],
57 | outputs: [%{name: :name, type: :undefined}],
58 | rules: [
59 | [1, input: [], output: ["Alex"]]
60 | ]
61 | } = Tablex.Parser.parse(box, [])
62 | end
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/test/tablex/rules/rule.ex:
--------------------------------------------------------------------------------
1 | defmodule Tablex.Rules.Rule do
2 | @moduledoc """
3 | Rule struct for Tablex.
4 | """
5 | defstruct [:id, :inputs, :output]
6 | end
7 |
--------------------------------------------------------------------------------
/test/tablex/rules_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tablex.RulesTest do
2 | alias Tablex.Rules
3 |
4 | use ExUnit.Case
5 | doctest Rules
6 |
7 | test "getting rules from a table" do
8 | table =
9 | Tablex.new("""
10 | F store.id quest.brand.id quest.pickingAndDelivery.pickingOnly quest.pickingAndDelivery.deliveryType quest.type || enabled
11 | (integer, Store Id) (integer, Brand Id) (bool, Picking-only?) (string, Delivery Type) (string) || -
12 | 1 - - T - - || F
13 | 2 - 602 - VENTEL - || F
14 | 3 - 719,749 - - - || T
15 | 4 - 120,124 - - - || T
16 | 5 - 131 - - - || T
17 | 6 - 601 - - - || T
18 | 7 - 729 - - - || T
19 | 8 - 724 - - - || T
20 | 9 - 723 - - - || T
21 | 9 - 106 - - - || T
22 | 9 - 244 - - - || T
23 | 9 - 735 - - - || T
24 | 9 - 700 - - - || T
25 | 9 - 732 - - - || T
26 | 10 2434 - - - - || T
27 | 10 2390 - - - - || T
28 | 10 19849 - - - - || T
29 | 10 20923 - - - - || T
30 | 10 20922,20921 - - - - || T
31 | 11 - 719 - - - || T
32 | 12 - - - - - || F
33 | """)
34 |
35 | assert [
36 | %Tablex.Rules.Rule{
37 | id: 12,
38 | inputs: [
39 | {[:store, :id], :any},
40 | {[:quest, :brand, :id], :any},
41 | {[:quest, :pickingAndDelivery, :pickingOnly], :any},
42 | {[:quest, :pickingAndDelivery, :deliveryType], :any},
43 | {[:quest, :type], :any}
44 | ],
45 | outputs: [{[:enabled], false}]
46 | },
47 | %Tablex.Rules.Rule{
48 | id: 11,
49 | inputs: [
50 | {[:store, :id], :any},
51 | {[:quest, :brand, :id], 719},
52 | {[:quest, :pickingAndDelivery, :pickingOnly], :any},
53 | {[:quest, :pickingAndDelivery, :deliveryType], :any},
54 | {[:quest, :type], :any}
55 | ],
56 | outputs: [{[:enabled], true}]
57 | },
58 | %Tablex.Rules.Rule{
59 | id: 3,
60 | inputs: [
61 | {[:store, :id], :any},
62 | {[:quest, :brand, :id], [719, 749]},
63 | {[:quest, :pickingAndDelivery, :pickingOnly], :any},
64 | {[:quest, :pickingAndDelivery, :deliveryType], :any},
65 | {[:quest, :type], :any}
66 | ],
67 | outputs: [{[:enabled], true}]
68 | }
69 | ] == Rules.get_rules(table, %{quest: %{brand: %{id: 719}}})
70 | end
71 |
72 | describe "update_rule_by_input/3" do
73 | test "updates a rule" do
74 | table =
75 | Tablex.new("""
76 | F value || result
77 | 1 >5 || big
78 | 2 - || small
79 | """)
80 | |> Rules.update_rule_by_input(%{value: ">5"}, %{result: "large"})
81 |
82 | assert table.rules == [
83 | [1, {:input, [>: 5]}, {:output, ["large"]}],
84 | [2, {:input, [:any]}, {:output, ["small"]}]
85 | ]
86 | end
87 |
88 | test "updates a rule when input is nested" do
89 | table =
90 | Tablex.new("""
91 | F foo.value || result
92 | 1 >5 || big
93 | 2 - || small
94 | """)
95 | |> Rules.update_rule_by_input(%{foo: %{value: ">5"}}, %{result: "large"})
96 |
97 | assert table.rules == [
98 | [1, {:input, [>: 5]}, {:output, ["large"]}],
99 | [2, {:input, [:any]}, {:output, ["small"]}]
100 | ]
101 | end
102 |
103 | test "inserts a new rule when it doesn't exist" do
104 | table =
105 | Tablex.new("""
106 | F value || result
107 | 1 >5 || big
108 | 2 - || small
109 | """)
110 | |> Rules.update_rule_by_input(%{value: ">10"}, %{result: "large"})
111 |
112 | assert table.rules == [
113 | [1, {:input, [>: 10]}, {:output, ["large"]}],
114 | [2, {:input, [>: 5]}, {:output, ["big"]}],
115 | [3, {:input, [:any]}, {:output, ["small"]}]
116 | ]
117 | end
118 | end
119 | end
120 |
--------------------------------------------------------------------------------
/test/tablex_test.exs:
--------------------------------------------------------------------------------
1 | defmodule TablexTest do
2 | use ExUnit.Case
3 | import DoctestFile
4 |
5 | doctest Tablex
6 | doctest_file("README.md")
7 | end
8 |
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
--------------------------------------------------------------------------------