brule 0.4.0 → 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 +16 -108
- data/lib/brule/engine.rb +16 -1
- data/lib/brule/rule.rb +26 -12
- data/lib/brule/utils.rb +2 -0
- data/lib/brule/utils/either.rb +22 -1
- metadata +57 -1
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
CHANGED
@@ -7,6 +7,8 @@ business rules. It helps when:
|
|
7
7
|
* You want to make your tests independent from your production data.
|
8
8
|
* You want to introspect the computation of a result.
|
9
9
|
|
10
|
+

|
11
|
+
|
10
12
|
## How does it work
|
11
13
|
|
12
14
|
The idea is very similar to function composition or Rack's middlewares. It is a
|
@@ -21,17 +23,20 @@ doing that, the engine apply its _rules_.
|
|
21
23
|
|
22
24
|
Each rule have two methods: `#trigger?` and `#apply`. `#apply` runs only when
|
23
25
|
`trigger?` is true. `#apply` writes stuff to the context for other rules and
|
24
|
-
for the engine to produce the result.
|
26
|
+
for the engine to produce the result. Rules can be initialized with a
|
27
|
+
_configuration_.
|
25
28
|
|
26
29
|

|
27
30
|
|
31
|
+
|
28
32
|
A typical usage for this kind of engine is to use it to compute the price of a
|
29
33
|
service or a good. But, this is not limited to that use-case as an engine
|
30
34
|
would be able to produce any kind of results.
|
31
35
|
|
32
36
|
## How does it look
|
33
37
|
|
34
|
-
Here
|
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:
|
35
40
|
|
36
41
|
> Accept 3 inputs from the user:
|
37
42
|
>
|
@@ -42,116 +47,19 @@ Here is an example from the [Elephant Carpaccio][elephant] kata. The specs are:
|
|
42
47
|
> Output the total price. Give a discount based on the total price, add state
|
43
48
|
> tax based on the state and the discounted price.
|
44
49
|
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
module Pricing
|
49
|
-
class Engine < Brule::Engine
|
50
|
-
def result
|
51
|
-
context.fetch(:price)
|
52
|
-
end
|
53
|
-
end
|
54
|
-
|
55
|
-
class OrderTotal < Brule::Rule
|
56
|
-
context_reader :unit_price, :item_count
|
57
|
-
context_writer :price
|
58
|
-
|
59
|
-
def apply
|
60
|
-
self.price = unit_price * item_count
|
61
|
-
end
|
62
|
-
end
|
63
|
-
|
64
|
-
class Discount < Brule::Rule
|
65
|
-
config_reader :rates
|
66
|
-
context_accessor :price, :discount_rate, :discount_amount
|
67
|
-
|
68
|
-
def trigger?
|
69
|
-
!applicable_discount.nil?
|
70
|
-
end
|
71
|
-
|
72
|
-
def apply
|
73
|
-
self.discount_rate = applicable_discount.last
|
74
|
-
self.discount_amount = (price * discount_rate).ceil
|
75
|
-
self.price = price - discount_amount
|
76
|
-
end
|
77
|
-
|
78
|
-
private
|
79
|
-
|
80
|
-
def applicable_discount
|
81
|
-
rates
|
82
|
-
.sort_by { |order_value, _| order_value * -1 }
|
83
|
-
.find { |order_value, _| order_value <= context.fetch(:price) }
|
84
|
-
end
|
85
|
-
end
|
86
|
-
|
87
|
-
class StateTax < Brule::Rule
|
88
|
-
config_reader :rates
|
89
|
-
context_reader :state
|
90
|
-
context_accessor :price, :state_tax
|
91
|
-
|
92
|
-
def apply
|
93
|
-
tax_rate = rates.fetch(state)
|
94
|
-
self.state_tax = (price * tax_rate).ceil
|
95
|
-
self.price = price + state_tax
|
96
|
-
end
|
97
|
-
end
|
98
|
-
end
|
99
|
-
|
100
|
-
require "bigdecimal"
|
101
|
-
require "bigdecimal/util"
|
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:
|
102
53
|
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
rates: [
|
108
|
-
[ 1_000_00, "0.03".to_d ],
|
109
|
-
[ 5_000_00, "0.05".to_d ],
|
110
|
-
[ 7_000_00, "0.07".to_d ],
|
111
|
-
[ 10_000_00, "0.10".to_d ],
|
112
|
-
[ 50_000_00, "0.15".to_d ],
|
113
|
-
],
|
114
|
-
),
|
115
|
-
Pricing::StateTax.new(
|
116
|
-
rates: {
|
117
|
-
"UT" => "0.0685".to_d,
|
118
|
-
"NV" => "0.0800".to_d,
|
119
|
-
"TX" => "0.0625".to_d,
|
120
|
-
"AL" => "0.0400".to_d,
|
121
|
-
"CA" => "0.0825".to_d,
|
122
|
-
},
|
123
|
-
),
|
124
|
-
],
|
125
|
-
)
|
126
|
-
|
127
|
-
result = engine.call(
|
128
|
-
item_count: 100,
|
129
|
-
unit_price: 100_00,
|
130
|
-
state: "NV",
|
131
|
-
)
|
132
|
-
|
133
|
-
# Access the main result
|
134
|
-
result # => 9_720_00 ($9,720.00)
|
135
|
-
|
136
|
-
# Access the context
|
137
|
-
engine.context.fetch_values(
|
138
|
-
:discount_rate, # => 0.1 (10%)
|
139
|
-
:discount_amount, # => 1_000_00 ($1,000.00)
|
140
|
-
:state_tax, # => 720_00 ($720.00)
|
141
|
-
)
|
142
|
-
|
143
|
-
# Access the history
|
144
|
-
engine.history(key: :price) # => [
|
145
|
-
# => [:initial, nil],
|
146
|
-
# => [#<struct Pricing::OrderTotal ...>, 10_000_00],
|
147
|
-
# => [#<struct Pricing::Discount ...>, 9_000_00],
|
148
|
-
# => [#<struct Pricing::StateTax ...>, 9_720_00],
|
149
|
-
# => ]
|
150
|
-
```
|
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)
|
151
58
|
|
152
59
|
## What does it bring to the table
|
153
60
|
|
154
|
-
If you compare this approach
|
61
|
+
If you compare [this approach](https://nicoolas25.github.io/brule-rb/examples/elephant_carpaccio.html)
|
62
|
+
with a simple method like this one:
|
155
63
|
|
156
64
|
```ruby
|
157
65
|
require "bigdecimal"
|
data/lib/brule/engine.rb
CHANGED
@@ -1,6 +1,8 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Brule
|
2
4
|
class Engine
|
3
|
-
attr_reader :context
|
5
|
+
attr_reader :context, :rules
|
4
6
|
|
5
7
|
def initialize(rules:)
|
6
8
|
@rules = rules
|
@@ -19,6 +21,19 @@ module Brule
|
|
19
21
|
@history.map { |tag, content| [tag, content.fetch(key, nil)] }
|
20
22
|
end
|
21
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
|
+
|
22
37
|
private
|
23
38
|
|
24
39
|
def snapshot!(tag:)
|
data/lib/brule/rule.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Brule
|
2
4
|
Rule = Struct.new(:config) do
|
3
5
|
attr_accessor :context
|
@@ -14,26 +16,38 @@ module Brule
|
|
14
16
|
self
|
15
17
|
end
|
16
18
|
|
17
|
-
def
|
18
|
-
|
19
|
-
|
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) }
|
20
34
|
end
|
21
35
|
end
|
22
36
|
|
23
|
-
def self.context_writer(*
|
24
|
-
|
25
|
-
define_method("#{
|
37
|
+
def self.context_writer(*keys)
|
38
|
+
keys.each do |key|
|
39
|
+
define_method("#{key}=") { |value| context[key] = value }
|
26
40
|
end
|
27
41
|
end
|
28
42
|
|
29
|
-
def self.context_accessor(*
|
30
|
-
context_reader(*
|
31
|
-
context_writer(*
|
43
|
+
def self.context_accessor(*keys)
|
44
|
+
context_reader(*keys)
|
45
|
+
context_writer(*keys)
|
32
46
|
end
|
33
47
|
|
34
|
-
def self.config_reader(*
|
35
|
-
|
36
|
-
define_method(
|
48
|
+
def self.config_reader(*keys)
|
49
|
+
keys.each do |key|
|
50
|
+
define_method(key) { config.fetch(key) }
|
37
51
|
end
|
38
52
|
end
|
39
53
|
end
|
data/lib/brule/utils.rb
CHANGED
data/lib/brule/utils/either.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'forwardable'
|
2
4
|
|
3
5
|
module Brule
|
@@ -10,7 +12,26 @@ module Brule
|
|
10
12
|
|
11
13
|
def_delegators :only_match, :apply, :trigger?, :to_tag
|
12
14
|
|
13
|
-
config_reader
|
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
|
14
35
|
|
15
36
|
private
|
16
37
|
|
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.
|
4
|
+
version: 0.4.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Nicolas Zermati
|
@@ -38,6 +38,62 @@ 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: []
|