brule 0.0.2 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +127 -0
- data/lib/brule.rb +1 -2
- data/lib/brule/engine.rb +51 -0
- data/lib/brule/rule.rb +54 -0
- data/lib/brule/utils.rb +7 -0
- data/lib/brule/utils/either.rb +56 -0
- metadata +63 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 669938141fcd377863b801be38726843c5f1ed81737754e76178923298794541
|
4
|
+
data.tar.gz: aa7e4e0b3c4a84f0ab2c449518097e3519d8ede931f350119946d7beeed11ea0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2e68b2077b6c133989d430b4b8226aea8b06dda3264b0e92892ec2684265350fcc969a9737d290cf32683c50cedee0699331a35c28830cd00b01872272aaf32f
|
7
|
+
data.tar.gz: 44da1bdbecb77ae14b17230997ce3b3b5a47c623ebf86a44836bd8a245f2f12644d152edb2f50f47834e99eed3f5cd2c7dbc072319dab7b9403891cfac16adce
|
data/README.md
ADDED
@@ -0,0 +1,127 @@
|
|
1
|
+
This project aims at providing a set of tools to build complex and evolving
|
2
|
+
business rules. It helps when:
|
3
|
+
|
4
|
+
* You decided to split the computation into smaller parts working together.
|
5
|
+
* You need to keep old versions of the rules working.
|
6
|
+
* You want to persist the rules leading to a result.
|
7
|
+
* You want to make your tests independent from your production data.
|
8
|
+
* You want to introspect the computation of a result.
|
9
|
+
|
10
|
+

|
11
|
+
|
12
|
+
## How does it work
|
13
|
+
|
14
|
+
The idea is very similar to function composition or Rack's middlewares. It is a
|
15
|
+
layering abstraction where each layer works for the next layers in order for the
|
16
|
+
whole to produce a single result.
|
17
|
+
|
18
|
+
An _engine_ respond to `#call`, taking a `context` in argument. It produces a
|
19
|
+
_result_ that is extracted from the _context_ by the `#result` method. Before
|
20
|
+
doing that, the engine apply its _rules_.
|
21
|
+
|
22
|
+

|
23
|
+
|
24
|
+
Each rule have two methods: `#trigger?` and `#apply`. `#apply` runs only when
|
25
|
+
`trigger?` is true. `#apply` writes stuff to the context for other rules and
|
26
|
+
for the engine to produce the result. Rules can be initialized with a
|
27
|
+
_configuration_.
|
28
|
+
|
29
|
+

|
30
|
+
|
31
|
+
|
32
|
+
A typical usage for this kind of engine is to use it to compute the price of a
|
33
|
+
service or a good. But, this is not limited to that use-case as an engine
|
34
|
+
would be able to produce any kind of results.
|
35
|
+
|
36
|
+
## How does it look
|
37
|
+
|
38
|
+
[Here](https://nicoolas25.github.io/brule-rb/examples/elephant_carpaccio.html)
|
39
|
+
is an example from the [Elephant Carpaccio][elephant] kata. The specs are:
|
40
|
+
|
41
|
+
> Accept 3 inputs from the user:
|
42
|
+
>
|
43
|
+
> * How many items
|
44
|
+
> * Price per item
|
45
|
+
> * 2-letter state code
|
46
|
+
>
|
47
|
+
> Output the total price. Give a discount based on the total price, add state
|
48
|
+
> tax based on the state and the discounted price.
|
49
|
+
|
50
|
+
[This](https://nicoolas25.github.io/brule-rb/examples/elephant_carpaccio.html)
|
51
|
+
is the smallest example and the _best_ one to refer to. More examples are
|
52
|
+
available:
|
53
|
+
|
54
|
+
- [Elephant Carpaccio](https://nicoolas25.github.io/brule-rb/examples/elephant_carpaccio.html)
|
55
|
+
- [Car rental](https://nicoolas25.github.io/brule-rb/examples/car_rental.html)
|
56
|
+
- [Studio rental](https://nicoolas25.github.io/brule-rb/examples/studio_rental.html)
|
57
|
+
- [Promo code](https://nicoolas25.github.io/brule-rb/examples/promo_code.html) (WIP)
|
58
|
+
|
59
|
+
## What does it bring to the table
|
60
|
+
|
61
|
+
If you compare [this approach](https://nicoolas25.github.io/brule-rb/examples/elephant_carpaccio.html)
|
62
|
+
with a simple method like this one:
|
63
|
+
|
64
|
+
```ruby
|
65
|
+
require "bigdecimal"
|
66
|
+
require "bigdecimal/util"
|
67
|
+
|
68
|
+
STATE_TAXES = {
|
69
|
+
"UT" => "0.0685".to_d,
|
70
|
+
"NV" => "0.0800".to_d,
|
71
|
+
"TX" => "0.0625".to_d,
|
72
|
+
"AL" => "0.0400".to_d,
|
73
|
+
"CA" => "0.0825".to_d,
|
74
|
+
}
|
75
|
+
|
76
|
+
DISCOUNT_RATES = [
|
77
|
+
[ 50_000_00, "0.15".to_d ],
|
78
|
+
[ 10_000_00, "0.10".to_d ],
|
79
|
+
[ 7_000_00, "0.07".to_d ],
|
80
|
+
[ 5_000_00, "0.05".to_d ],
|
81
|
+
[ 1_000_00, "0.03".to_d ],
|
82
|
+
[ 0, "0.00".to_d ],
|
83
|
+
]
|
84
|
+
|
85
|
+
def pricing(item_count, unit_price, state)
|
86
|
+
price = item_count * unit_price
|
87
|
+
discount_rate = DISCOUNT_RATES.find { |limit, _| limit <= price }.last
|
88
|
+
state_tax = STATE_TAXES.fetch(state)
|
89
|
+
price * (1 - discount_rate) * (1 + state_tax)
|
90
|
+
end
|
91
|
+
```
|
92
|
+
|
93
|
+
... then you'll find significant differences:
|
94
|
+
|
95
|
+
* Over-abstraction versus under-abstraction
|
96
|
+
* 1st example uses the layering abstraction provided by the gem
|
97
|
+
* 2nd example uses nothing, [YAGNI][yagni]
|
98
|
+
* Generalization vs specialization
|
99
|
+
* 1st approach is more generic and could handle changes
|
100
|
+
* 2nd approach is more specialized and would require a rewrite
|
101
|
+
* Complexity versus brievety
|
102
|
+
* 1st example is more verbose and thus is harder to grasp
|
103
|
+
* 2nd example is more concise and fits in the head
|
104
|
+
* Configuration versus constants
|
105
|
+
* 1st example relies on rule's configuration
|
106
|
+
* 2nd example relies on `STATE_TAXES` and `DISCOUNT_RATES`
|
107
|
+
* Observability vs black-box
|
108
|
+
* 1st example allows to provide more information (`state_tax` and so on)
|
109
|
+
* 2nd example only gives a single result
|
110
|
+
* Data-independent versus hard-coded values
|
111
|
+
* 1st example considers as much logic as possible as data
|
112
|
+
* 2nd example mixes data and logic together (with hidden dependencies and assumptions)
|
113
|
+
* Temporal extensibility or versionning
|
114
|
+
* 1st example can compute the price using different rules
|
115
|
+
* Without discounts or with different discount rate per client
|
116
|
+
* With tax rates from on year or another
|
117
|
+
* 2nd example will have to introduce options, or even different methods
|
118
|
+
* Testability
|
119
|
+
* 1st example could be tested at various levels without any mocks
|
120
|
+
* 2nd example have to mock hidden dependencies from the implementation
|
121
|
+
|
122
|
+
Overall, it is about finding the right level of abtsraction. This tiny framework
|
123
|
+
helps you by providing you a little abstraction. Even if you're not using this
|
124
|
+
gem directly, it can give you some ideas behind it.
|
125
|
+
|
126
|
+
[elephant]: https://docs.google.com/document/d/1Ls6pTmhY_LV8LwFiboUXoFXenXZl0qVZWPZ8J4uoqpI/edit#
|
127
|
+
[yagni]: https://en.wikipedia.org/wiki/You_aren%27t_gonna_need_it
|
data/lib/brule.rb
CHANGED
data/lib/brule/engine.rb
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Brule
|
4
|
+
class Engine
|
5
|
+
attr_reader :context, :rules
|
6
|
+
|
7
|
+
def initialize(rules:)
|
8
|
+
@rules = rules
|
9
|
+
@history = {}
|
10
|
+
end
|
11
|
+
|
12
|
+
def call(context = {})
|
13
|
+
@history = {}
|
14
|
+
@context = context
|
15
|
+
snapshot!(tag: :initial)
|
16
|
+
@rules.each { |rule| apply(rule) }
|
17
|
+
result
|
18
|
+
end
|
19
|
+
|
20
|
+
def history(key:)
|
21
|
+
@history.map { |tag, content| [tag, content.fetch(key, nil)] }
|
22
|
+
end
|
23
|
+
|
24
|
+
def to_hash
|
25
|
+
{ 'engine_class' => self.class.name, 'rules' => @rules.map(&:to_hash) }
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.from_hash(hash)
|
29
|
+
engine_class = Object.const_get(hash.fetch('engine_class'))
|
30
|
+
rules = hash.fetch('rules').map do |rule_hash|
|
31
|
+
rule_class = Object.const_get(rule_hash.fetch('rule_class'))
|
32
|
+
rule_class.from_hash(rule_hash)
|
33
|
+
end
|
34
|
+
engine_class.new(rules: rules)
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def snapshot!(tag:)
|
40
|
+
@history[tag] = @context.dup
|
41
|
+
end
|
42
|
+
|
43
|
+
def apply(rule)
|
44
|
+
rule.context = @context
|
45
|
+
return unless rule.trigger?
|
46
|
+
|
47
|
+
rule.apply
|
48
|
+
snapshot!(tag: rule.to_tag)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
data/lib/brule/rule.rb
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Brule
|
4
|
+
Rule = Struct.new(:config) do
|
5
|
+
attr_accessor :context
|
6
|
+
|
7
|
+
def trigger?
|
8
|
+
true
|
9
|
+
end
|
10
|
+
|
11
|
+
def apply
|
12
|
+
raise NotImplementedError
|
13
|
+
end
|
14
|
+
|
15
|
+
def to_tag
|
16
|
+
self
|
17
|
+
end
|
18
|
+
|
19
|
+
def to_hash
|
20
|
+
config_hash = block_given? ? yield(config) : config&.to_hash
|
21
|
+
{ 'rule_class' => self.class.name, 'config' => config_hash }
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.from_hash(hash)
|
25
|
+
raise ArgumentError unless hash.fetch('rule_class') == name
|
26
|
+
|
27
|
+
config_hash = hash.fetch('config')
|
28
|
+
new(block_given? ? yield(config_hash) : config_hash)
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.context_reader(*keys)
|
32
|
+
keys.each do |key|
|
33
|
+
define_method(key) { context.fetch(key) }
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.context_writer(*keys)
|
38
|
+
keys.each do |key|
|
39
|
+
define_method("#{key}=") { |value| context[key] = value }
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.context_accessor(*keys)
|
44
|
+
context_reader(*keys)
|
45
|
+
context_writer(*keys)
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.config_reader(*keys)
|
49
|
+
keys.each do |key|
|
50
|
+
define_method(key) { config.fetch(key) }
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
data/lib/brule/utils.rb
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
|
5
|
+
module Brule
|
6
|
+
module Utils
|
7
|
+
class Either < Brule::Rule
|
8
|
+
TooManyMatches = Class.new(StandardError)
|
9
|
+
NoMatchFound = Class.new(StandardError)
|
10
|
+
|
11
|
+
extend Forwardable
|
12
|
+
|
13
|
+
def_delegators :only_match, :apply, :trigger?, :to_tag
|
14
|
+
|
15
|
+
config_reader 'rules'
|
16
|
+
|
17
|
+
def to_hash
|
18
|
+
super do |config|
|
19
|
+
config.to_hash.tap do |config_hash|
|
20
|
+
config_hash['rules'] = config.fetch('rules').map(&:to_hash)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.from_hash(*)
|
26
|
+
super do |config_hash|
|
27
|
+
config_hash.tap do |config|
|
28
|
+
config['rules'] = config_hash.fetch('rules').map do |rule_hash|
|
29
|
+
rule_class = Object.const_get(rule_hash.fetch('rule_class'))
|
30
|
+
rule_class.from_hash(rule_hash)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def only_match
|
39
|
+
matches = rules.select do |rule|
|
40
|
+
rule.context = context
|
41
|
+
rule.trigger?
|
42
|
+
end
|
43
|
+
|
44
|
+
if matches.size >= 2
|
45
|
+
raise TooManyMatches, "Rules #{matches.join(', ')} are all a match"
|
46
|
+
end
|
47
|
+
|
48
|
+
if matches.empty?
|
49
|
+
raise NoMatchFound, "No rules from #{rules.join(', ')} is a match"
|
50
|
+
end
|
51
|
+
|
52
|
+
matches.first
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: brule
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Nicolas Zermati
|
@@ -38,14 +38,75 @@ dependencies:
|
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '13.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: sequel
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '5.32'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '5.32'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: simplecov
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0.18'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0.18'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: simplecov-lcov
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0.8'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0.8'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: sqlite3
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '1.4'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '1.4'
|
41
97
|
description:
|
42
98
|
email: ''
|
43
99
|
executables: []
|
44
100
|
extensions: []
|
45
101
|
extra_rdoc_files: []
|
46
102
|
files:
|
103
|
+
- README.md
|
47
104
|
- lib/brule.rb
|
48
|
-
|
105
|
+
- lib/brule/engine.rb
|
106
|
+
- lib/brule/rule.rb
|
107
|
+
- lib/brule/utils.rb
|
108
|
+
- lib/brule/utils/either.rb
|
109
|
+
homepage: https://github.com/nicoolas25/brule-rb
|
49
110
|
licenses:
|
50
111
|
- MIT
|
51
112
|
metadata: {}
|