├── .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
1Monday, Tuesday, Wednesday, Thursdayrainyread
2-read, walk
3Fridaysunnysoccer
4-swim
5Saturday-watch movie, games
6Sunday-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 |
FProduct CategoryCompetitor PricingProduct FeaturesLaunch DecisionReasoning
1ElectronicsHigher than CompetitorMore FeaturesLaunchCompetitive Advantage
2Lower than CompetitorSame FeaturesLaunchPrice Advantage
3FashionSame as CompetitorNew FeaturesDo Not LaunchLack 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 |
F123
Product CategoryElectronicsFashion
Competitor PricingHigher than CompetitorLower than CompetitorSame as Competitor
Product FeaturesMore FeaturesSame FeaturesNew Features
Launch DecisionLaunchLaunchDo Not Launch
ReasoningCompetitive AdvantagePrice AdvantageLack 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(<>, 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(<>, 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 | --------------------------------------------------------------------------------