Skip to content

Commit

Permalink
Merge bba496c into f7f6923
Browse files Browse the repository at this point in the history
  • Loading branch information
bpvickers committed Dec 30, 2017
2 parents f7f6923 + bba496c commit c9af598
Show file tree
Hide file tree
Showing 79 changed files with 5,336 additions and 2,328 deletions.
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
## v0.0.6, 26 December 2017.
*Additions*
- Update YARD documentation.

## v0.0.5, 26 December 2017.
*Additions*
- Update YARD documentation.

## v0.0.4, 26 December 2017.
*Additions*
- Adds symbol expressions for input columns.
- Adds non-string constants for output columns.
- Support Ruby 2.5.0
- Include yard documentation.
- Include YARD documentation.

*Changes*
- Move `benchmark.rb` to `benchmarks` folder and rename to `rufus_decision.rb`
Expand Down
205 changes: 112 additions & 93 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ CSV Decision
[![Maintainability](https://api.codeclimate.com/v1/badges/466a6c52e8f6a3840967/maintainability)](https://codeclimate.com/github/bpvickers/csv_decision/maintainability)
[![License](http://img.shields.io/badge/license-MIT-yellowgreen.svg)](#license)

### CSV based Ruby decision tables (a lightweight Hash transformation gem)
### CSV based Ruby decision tables

`csv_decision` is a RubyGem for CSV (comma separated values) based
[decision tables](https://en.wikipedia.org/wiki/Decision_table).
Expand All @@ -16,47 +16,24 @@ It accepts decision tables implemented as a
which can then be used to execute complex conditional logic against an input hash,
producing a decision as an output hash.

### CSV Decision features
* Fast decision-time performance (see `benchmark.rb`).
* In addition to simple string matching, can match common Ruby constants,
regular expressions, numeric comparisons and Ruby-style ranges.
* Can use column symbols in comparisons for guard conditions -- e.g., > :column.
* Accepts data as a file, CSV string or an array of arrays. (For safety all input data is
force encoded to UTF-8, and non-ascii strings are converted to empty strings.)
* All CSV cells are parsed for correctness, and helpful error messages generated for bad
inputs.
* Either returns the first matching row as a hash, or accumulates all matches as an
array of hashes.

### Planned features
`csv_decision` is still a work in progress, and will be enhanced to support
the following features:
* Input columns may be indexed for faster lookup performance.
* May use functions in the output columns to formulate the final decision.
* Input hash values may be conditionally defaulted using a constant or a function call
* Use of column symbol references or built-in guard functions in the input
columns for matching.
* Output columns may use interpolated strings referencing column symbols.
* May be extended with user-defined Ruby functions for tailored logic.
* Can use post-match guard conditions to filter the results of multi-row
decision output.

### Why use `csv_decision`?

Typical "business logic" is notoriously illogical -- full of corner cases and one-off
exceptions.
A decision table can capture data-based decisions in a way that comes more naturally
to subject matter experts, who typically prefer spreadsheet models.
Business logic may then be encapsulated, avoiding the need to write tortuous
conditional expressions in Ruby that draw the ire of `rubocop` and its ilk.

This gem takes its inspiration from
[rufus/decision](https://github.com/jmettraux/rufus-decision).
(That gem is no longer maintained and has issues with execution performance.)
### Why use `csv_decision`?

Typical "business logic" is notoriously illogical -- full of corner cases and one-off
exceptions.
A decision table can capture data-based decisions in a way that comes more naturally
to subject matter experts, who typically prefer spreadsheet models.
Business logic may then be encapsulated, avoiding the need to write tortuous
conditional expressions in Ruby that draw the ire of `rubocop` and its ilk.

This gem and the examples below take inspiration from
[rufus/decision](https://github.com/jmettraux/rufus-decision).
(However, that gem is no longer maintained and CSV Decision has better
decision-time performance for the trade-off of slower table parse times and more memory --
see `benchmarks/rufus_decision.rb`)

### Installation
### Installation

To get started, just add `csv_decision` to your `Gemfile`, and then run `bundle`:
To get started, just add `csv_decision` to your `Gemfile`, and then run `bundle`:

```ruby
gem 'csv_decision', '~> 0.0.1'
Expand All @@ -67,16 +44,11 @@ producing a decision as an output hash.
gem install csv_decision
```

### Simple example

A decision table may be as simple or as complex as you like (although very complex
tables defeat the whole purpose).
Basic usage will be illustrated by an example taken from:
https://jmettraux.wordpress.com/2009/04/25/rufus-decision-11-ruby-decision-tables/.

This example considers two input conditions: `topic` and `region`.
These are labeled `in`. Certain combinations yield an output value for `team_member`,
labeled `out`.
### Simple example

This table considers two input conditions: `topic` and `region`.
These are labeled `in`. Certain combinations yield an output value for `team_member`,
labeled `out`.

```
in :topic | in :region | out :team_member
Expand All @@ -92,18 +64,18 @@ politics | | Henry
| | Zach
```

When the topic is `finance` and the region is `Europe` the team member `Donald`
is selected.
This is a "first match" decision table in that as soon as a match is made execution
stops and a single output value (hash) is returned.
The ordering of rows matters. `Ernest`, who is in charge of `finance` for the rest of
the world, except for `America` and `Europe`, *must* come after his colleagues
`Charlie` and `Donald`. `Zach` has been placed last, catching all the input combos
not matching any other row.
Now for some code.
When the topic is `finance` and the region is `Europe` the team member `Donald`
is selected.

This is a "first match" decision table in that as soon as a match is made execution
stops and a single output value (hash) is returned.

The ordering of rows matters. `Ernest`, who is in charge of `finance` for the rest of
the world, except for `America` and `Europe`, *must* come after his colleagues
`Charlie` and `Donald`. `Zach` has been placed last, catching all the input combos
not matching any other row.

Here it is as code:

```ruby
# Valid CSV string
Expand All @@ -127,36 +99,48 @@ politics | | Henry
table.decide(topic: 'culture', region: 'America') # team_member: 'Zach'
```

An empty `in` cell means "matches any value".
If you have cloned this gem's git repo, then this example can also be run by loading
the table from a CSV file:
An empty `in` cell means "matches any value", even nils.

If you have cloned this gem's git repo, then the example can also be run by loading
the table from a CSV file:

```ruby
table = CSVDecision.parse(Pathname('spec/data/valid/simple_example.csv'))
```

We can also load this same table using the option: `first_match: false`.
We can also load this same table using the option: `first_match: false`, which means that
all matching rows will be accumulated into an array of hashes.

```ruby
table = CSVDecision.parse(data, first_match: false)
table.decide(topic: 'finance', region: 'Europe') # returns team_member: %w[Donald Ernest Zach]
```

For more examples see `spec/csv_decision/table_spec.rb`.
Complete documentation of all table parameters is in the code - see
`lib/csv_decision/parse.rb` and `lib/csv_decision/table.rb`.

For more examples see `spec/csv_decision/table_spec.rb`.
Complete documentation of all table parameters is in the code - see
`lib/csv_decision/parse.rb` and `lib/csv_decision/table.rb`.

### Constants other than strings
Although `csv_decision` is string oriented, it does recognise other types of constant
present in the input hash. Specifically, the following classes are recognized:
`Integer`, `BigDecimal`, `NilClass`, `TrueClass` and `FalseClass`.

This is accomplished by prefixing the value with one of the operators `=`, `==` or `:=`.
(The syntax is intentionally lax.)

For example:
### CSV Decision features
* Fast decision-time performance (see `benchmarks` folder).
* In addition to simple string matching, can match common Ruby constants,
regular expressions, numeric comparisons and Ruby-style ranges.
* Can use column symbols in comparisons for guard conditions -- e.g., > :column.
* Accepts data as a file, CSV string or an array of arrays. (For safety all input data is
force encoded to UTF-8, and non-ascii strings are converted to empty strings.)
* All CSV cells are parsed for correctness, and helpful error messages generated for bad
inputs.
* Either returns the first matching row as a hash, or accumulates all matches as an
array of hashes.

### Constants other than strings
Although `csv_decision` is string oriented, it does recognise other types of constant
present in the input hash. Specifically, the following classes are recognized:
`Integer`, `BigDecimal`, `NilClass`, `TrueClass` and `FalseClass`.

This is accomplished by prefixing the value with one of the operators `=`, `==` or `:=`.
(The syntax is intentionally lax.)

For example:
```ruby
data = <<~DATA
in :constant, out :value
Expand All @@ -173,11 +157,11 @@ table.decide(topic: 'finance', region: 'Europe') # returns team_member: %w[Donal
table.decide(constant: BigDecimal('100.0')) # returns value: BigDecimal('100.0')
```

### Column header symbols
All input and output column names are symbolized, and can be used to form simple
expressions that refer to values in the input hash.
For example:
### Column header symbols
All input and output column names are symbolized, and can be used to form simple
expressions that refer to values in the input hash.

For example:
```ruby
data = <<~DATA
in :node, in :parent, out :top?
Expand All @@ -190,21 +174,43 @@ table.decide(topic: 'finance', region: 'Europe') # returns team_member: %w[Donal
table.decide(node: 1, parent: 0) # returns top?: 'no'
```

Note that there is no need to include an input column for `:node` in the decision
table - it just needs to be present in the input hash. Also, `== :node` can be
shortened to just `:node`, so the above decision table may be simplified to:
Note that there is no need to include an input column for `:node` in the decision
table - it just needs to be present in the input hash. Also, `== :node` can be
shortened to just `:node`, so the above decision table may be simplified to:

```ruby
data = <<~DATA
in :parent, out :top?
:node, yes
, no
DATA
```
These comparison operators are also supported: `!=`, `>`, `>=`, `<`, `<=`.
For more simple examples see `spec/csv_decision/examples_spec.rb`.

### Testing
These comparison operators are also supported: `!=`, `>`, `>=`, `<`, `<=`.
For more simple examples see `spec/csv_decision/examples_spec.rb`.

### Column guard conditions
Sometimes it's more convenient to write guard conditions in a single column specialized for that purpose.
For example:

```ruby
data = <<~DATA
in :country, guard:, out :ID, out :ID_type, out :len
US, :CUSIP.present?, :CUSIP, CUSIP, :ID.length
GB, :SEDOL.present?, :SEDOL, SEDOL, :ID.length
, :ISIN.present?, :ISIN, ISIN, :ID.length
, :SEDOL.present?, :SEDOL, SEDOL, :ID.length
, :CUSIP.present?, :CUSIP, CUSIP, :ID.length
, , := nil, := nil, := nil
DATA

table = CSVDecision.parse(data)
table.decide(country: 'US', CUSIP: '123456789') #=> { ID: '123456789', ID_type: 'CUSIP', len: 9 }
table.decide(country: 'EU', CUSIP: '123456789', ISIN:'123456789012')
#=> { ID: '123456789012', ID_type: 'ISIN', len: 12 }
```
Guard columns may be anonymous, and must contain non-constant expressions.

### Testing

`csv_decision` includes thorough [RSpec](http://rspec.info) tests:

Expand All @@ -213,3 +219,16 @@ table.decide(topic: 'finance', region: 'Europe') # returns team_member: %w[Donal
bundle install
rspec
```

### Planned features
`csv_decision` is still a work in progress, and will be enhanced to support
the following features:
* Use of column symbol expressions or built-in guard functions in the input
columns for matching.
* Input columns may be indexed for faster lookup performance.
* May use functions in the output columns to formulate the final decision.
* Input hash values may be conditionally defaulted using a constant or a function call
* Output columns may use interpolated strings referencing column symbols.
* May be extended with a user-supplied library of Ruby functions for tailored logic.
* Can use post-match guard conditions to filter the results of multi-row
decision output.
10 changes: 9 additions & 1 deletion benchmarks/rufus_decision.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@
# Expected results for first_match and accumulate
first_match: { 'team_member' => 'Zach' },
accumulate: { 'team_member' => 'Zach' }
},
{
name: 'Regular expressions',
data: 'benchmark_regexp.csv',
input: { 'age' => '40', 'trait' => 'cheerful' },
# Expected results for first_match and accumulate
first_match: { 'salesperson' => 'Swanson' },
accumulate: { 'salesperson' => %w[Swanson Korolev] }
}
].deep_freeze

Expand All @@ -37,7 +45,7 @@
puts '-' * tag_width

csv_options = CSV_OPTIONS.merge(first_match: first_match)
rufus_options = RUFUS_OPTIONS.merge(first_match: first_match)
rufus_options = RUFUS_OPTIONS.merge(first_match: first_match, accumulate: !first_match)

benchmarks.each do |test|
name = test[:name]
Expand Down
12 changes: 6 additions & 6 deletions csv_decision.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)

Gem::Specification.new do |spec|
spec.name = 'csv_decision'
spec.version = '0.0.6'
spec.version = '0.0.7'
spec.authors = ['Brett Vickers']
spec.email = ['brett@phillips-vickers.com']
spec.description = 'CSV based Ruby decision tables.'
spec.summary = <<-DESC
CSV Decision implements CSV file based Ruby decision tables. It accepts decision tables implemented as a
which can then be used to execute complex conditional logic against an input hash,
producing a decision as an output hash.
CSV Decision implements CSV based Ruby decision tables. It parses and loads
decision table files which can then be used to execute complex conditional
logic against an input hash, producing a decision as an output hash.
DESC
spec.homepage = 'https://github.com/bpvickers/csv_decision.git'
spec.license = 'MIT'
Expand All @@ -25,8 +25,8 @@ Gem::Specification.new do |spec|
spec.required_ruby_version = '>= 2.3.0'

spec.add_dependency 'activesupport', '~> 5.1'
spec.add_dependency 'ice_nine', '~> 0.11'
spec.add_dependency 'values', '~> 1.8'
spec.add_dependency 'ice_nine', '~> 0.11'
spec.add_dependency 'values', '~> 1.8'

spec.add_development_dependency 'benchmark-ips', '~> 2.7'
spec.add_development_dependency 'benchmark-memory', '~> 0.1'
Expand Down
Loading

0 comments on commit c9af598

Please sign in to comment.