brule 0.2.0 → 0.3.0
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 +91 -20
- data/lib/brule.rb +94 -4
- metadata +2 -5
- data/lib/brule/context.rb +0 -24
- data/lib/brule/engine.rb +0 -37
- data/lib/brule/rule.rb +0 -13
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 13f788107d09cf82f121f314df0fd7247e808d4895a369afc56315aceaa21e84
|
4
|
+
data.tar.gz: 92d514d8305373969d48069d5627432d8d13e136ae0f38f1a94b1368ebbfb63c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: fba662d4ee2caaffba41aba4368069c1ab1aab6730fcd0b84a73a7158a799e1e34149eac0f7e37627a56bf9c16ea0f4af26298e0ef15ce0a76df0487ddbcf895
|
7
|
+
data.tar.gz: 4f798d703d68a4e109b19c69469e1c86c2d16762ba45bb3cacc5cbf8ec3fe61ea64d57e1d942428d488655ea24068a541f79875071d2b3578f66d13bec02432b
|
data/README.md
CHANGED
@@ -22,7 +22,9 @@ information for the engine to assemble a _result_. The rules are arranged in an
|
|
22
22
|
ordered sequence and are picked depending on the _context_. Each rule will write
|
23
23
|
in the context too.
|
24
24
|
|
25
|
-
The idea is very similar to function composition or Rack's middlewares.
|
25
|
+
The idea is very similar to function composition or Rack's middlewares. It is a
|
26
|
+
layering abstraction where each layer works for the next layers in order for the
|
27
|
+
stack to produce a single value.
|
26
28
|
|
27
29
|
## How does it look
|
28
30
|
|
@@ -43,50 +45,51 @@ require "brule"
|
|
43
45
|
module Pricing
|
44
46
|
class Engine < Brule::Engine
|
45
47
|
def result
|
46
|
-
context
|
48
|
+
context.fetch(:price)
|
47
49
|
end
|
48
50
|
end
|
49
51
|
|
50
52
|
class OrderTotal < Brule::Rule
|
53
|
+
config_reader :unit_price, :item_count
|
54
|
+
context_writer :price
|
55
|
+
|
51
56
|
def apply
|
52
|
-
|
57
|
+
self.price = unit_price * item_count
|
53
58
|
end
|
54
59
|
end
|
55
60
|
|
56
61
|
class Discount < Brule::Rule
|
62
|
+
config_reader :rates
|
63
|
+
context_accessor :price, :discount_rate, :discount_amount
|
64
|
+
|
57
65
|
def trigger?
|
58
66
|
!applicable_discount.nil?
|
59
67
|
end
|
60
68
|
|
61
69
|
def apply
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
context.merge!(
|
66
|
-
price: price - discount_amount,
|
67
|
-
discount_rate: discount_rate,
|
68
|
-
discount_amount: discount_amount,
|
69
|
-
)
|
70
|
+
self.discount_rate = applicable_discount.last
|
71
|
+
self.discount_amount = (price * discount_rate).ceil
|
72
|
+
self.price = price - discount_amount
|
70
73
|
end
|
71
74
|
|
72
75
|
private
|
73
76
|
|
74
77
|
def applicable_discount
|
75
|
-
|
78
|
+
rates
|
76
79
|
.sort_by { |order_value, _| order_value * -1 }
|
77
|
-
.find { |order_value, _| order_value <= context
|
80
|
+
.find { |order_value, _| order_value <= context.fetch(:price) }
|
78
81
|
end
|
79
82
|
end
|
80
83
|
|
81
84
|
class StateTax < Brule::Rule
|
85
|
+
config_reader :rates
|
86
|
+
context_reader :state
|
87
|
+
context_accessor :price, :state_tax
|
88
|
+
|
82
89
|
def apply
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
context.merge!(
|
87
|
-
price: price + state_tax,
|
88
|
-
state_tax: state_tax,
|
89
|
-
)
|
90
|
+
tax_rate = rates.fetch(state)
|
91
|
+
self.state_tax = (price * tax_rate).ceil
|
92
|
+
self.price = price + state_tax
|
90
93
|
end
|
91
94
|
end
|
92
95
|
end
|
@@ -136,10 +139,78 @@ engine.context.fetch_values(
|
|
136
139
|
|
137
140
|
# Access the history
|
138
141
|
engine.history(key: :price) # => [
|
142
|
+
# => [:initial, nil],
|
139
143
|
# => [#<struct Pricing::OrderTotal ...>, 10_000_00],
|
140
144
|
# => [#<struct Pricing::Discount ...>, 9_000_00],
|
141
145
|
# => [#<struct Pricing::StateTax ...>, 9_720_00],
|
142
146
|
# => ]
|
143
147
|
```
|
144
148
|
|
149
|
+
## What does it bring to the table
|
150
|
+
|
151
|
+
If you compare this approach with a simple method like this one:
|
152
|
+
|
153
|
+
```ruby
|
154
|
+
require "bigdecimal"
|
155
|
+
require "bigdecimal/util"
|
156
|
+
|
157
|
+
STATE_TAXES = {
|
158
|
+
"UT" => "0.0685".to_d,
|
159
|
+
"NV" => "0.0800".to_d,
|
160
|
+
"TX" => "0.0625".to_d,
|
161
|
+
"AL" => "0.0400".to_d,
|
162
|
+
"CA" => "0.0825".to_d,
|
163
|
+
}
|
164
|
+
|
165
|
+
DISCOUNT_RATES = [
|
166
|
+
[ 50_000_00, "0.15".to_d ],
|
167
|
+
[ 10_000_00, "0.10".to_d ],
|
168
|
+
[ 7_000_00, "0.07".to_d ],
|
169
|
+
[ 5_000_00, "0.05".to_d ],
|
170
|
+
[ 1_000_00, "0.03".to_d ],
|
171
|
+
[ 0, "0.00".to_d ],
|
172
|
+
]
|
173
|
+
|
174
|
+
def pricing(item_count, unit_price, state)
|
175
|
+
price = item_count * unit_price
|
176
|
+
discount_rate = DISCOUNT_RATES.find { |limit, _| limit <= price }.last
|
177
|
+
state_tax = STATE_TAXES.fetch(state)
|
178
|
+
price * (1 - discount_rate) * (1 + state_tax)
|
179
|
+
end
|
180
|
+
```
|
181
|
+
|
182
|
+
... then you'll find significant differences:
|
183
|
+
|
184
|
+
* Over-abstraction versus under-abstraction
|
185
|
+
* 1st example uses the layering abstraction provided by the gem
|
186
|
+
* 2nd example uses nothing, [YAGNI][yagni]
|
187
|
+
* Generalization vs specialization
|
188
|
+
* 1st approach is more generic and could handle changes
|
189
|
+
* 2nd approach is more specialized and would require a rewrite
|
190
|
+
* Complexity versus brievety
|
191
|
+
* 1st example is more verbose and thus is harder to grasp
|
192
|
+
* 2nd example is more concise and fits in the head
|
193
|
+
* Configuration versus constants
|
194
|
+
* 1st example relies on rule's configuration
|
195
|
+
* 2nd example relies on `STATE_TAXES` and `DISCOUNT_RATES`
|
196
|
+
* Observability vs black-box
|
197
|
+
* 1st example allows to provide more information (`state_tax` and so on)
|
198
|
+
* 2nd example only gives a single result
|
199
|
+
* Data-independent versus hard-coded values
|
200
|
+
* 1st example considers as much logic as possible as data
|
201
|
+
* 2nd example mixes data and logic together (with hidden dependencies and assumptions)
|
202
|
+
* Temporal extensibility or versionning
|
203
|
+
* 1st example can compute the price using different rules
|
204
|
+
* Without discounts or with different discount rate per client
|
205
|
+
* With tax rates from on year or another
|
206
|
+
* 2nd example will have to introduce options, or even different methods
|
207
|
+
* Testability
|
208
|
+
* 1st example could be tested at various levels without any mocks
|
209
|
+
* 2nd example have to mock hidden dependencies from the implementation
|
210
|
+
|
211
|
+
Overall, it is about finding the right level of abtsraction. This tiny framework
|
212
|
+
helps you by providing you a little abstraction. Even if you're not using this
|
213
|
+
gem directly, it can give you some ideas behind it.
|
214
|
+
|
145
215
|
[elephant]: https://docs.google.com/document/d/1Ls6pTmhY_LV8LwFiboUXoFXenXZl0qVZWPZ8J4uoqpI/edit#
|
216
|
+
[yagni]: https://en.wikipedia.org/wiki/You_aren%27t_gonna_need_it
|
data/lib/brule.rb
CHANGED
@@ -1,8 +1,98 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'forwardable'
|
4
|
+
|
3
5
|
module Brule
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
6
|
+
module RuleHelpers
|
7
|
+
def context_reader(*symbols)
|
8
|
+
symbols.each do |symbol|
|
9
|
+
define_method(symbol) { context.fetch(symbol) }
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def context_writer(*symbols)
|
14
|
+
symbols.each do |symbol|
|
15
|
+
define_method("#{symbol}=") { |value| context[symbol] = value }
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def context_accessor(*symbols)
|
20
|
+
context_reader(*symbols)
|
21
|
+
context_writer(*symbols)
|
22
|
+
end
|
23
|
+
|
24
|
+
def config_reader(*symbols)
|
25
|
+
symbols.each do |symbol|
|
26
|
+
define_method(symbol) { config.fetch(symbol) }
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
Rule = Struct.new(:config) do
|
32
|
+
extend RuleHelpers
|
33
|
+
|
34
|
+
attr_accessor :context
|
35
|
+
|
36
|
+
def trigger?
|
37
|
+
true
|
38
|
+
end
|
39
|
+
|
40
|
+
def apply
|
41
|
+
raise NotImplementedError
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
class Context
|
46
|
+
extend Forwardable
|
47
|
+
|
48
|
+
def_delegators :@content, :[], :fetch, :fetch_values, :key?, :[]=, :merge!
|
49
|
+
|
50
|
+
def self.wrap(context)
|
51
|
+
context.is_a?(self) ? context : new(context)
|
52
|
+
end
|
53
|
+
|
54
|
+
def initialize(hash = {})
|
55
|
+
@content = hash
|
56
|
+
end
|
57
|
+
|
58
|
+
def initialize_copy(orig)
|
59
|
+
super
|
60
|
+
@content = orig.instance_variable_get(:@content).dup
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
class Engine
|
65
|
+
attr_reader :context
|
66
|
+
|
67
|
+
def initialize(rules:)
|
68
|
+
@rules = rules
|
69
|
+
@history = {}
|
70
|
+
end
|
71
|
+
|
72
|
+
def call(context = {})
|
73
|
+
@history = {}
|
74
|
+
@context = Context.wrap(context)
|
75
|
+
snapshot!(tag: :initial)
|
76
|
+
@rules.each { |rule| apply(rule) }
|
77
|
+
result
|
78
|
+
end
|
79
|
+
|
80
|
+
def history(key:)
|
81
|
+
@history.map { |tag, content| [tag, content.fetch(key, nil)] }
|
82
|
+
end
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
def snapshot!(tag:)
|
87
|
+
@history[tag] = @context.dup
|
88
|
+
end
|
89
|
+
|
90
|
+
def apply(rule)
|
91
|
+
rule.context = @context
|
92
|
+
return unless rule.trigger?
|
93
|
+
|
94
|
+
rule.apply
|
95
|
+
snapshot!(tag: rule)
|
96
|
+
end
|
97
|
+
end
|
8
98
|
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.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Nicolas Zermati
|
@@ -46,10 +46,7 @@ extra_rdoc_files: []
|
|
46
46
|
files:
|
47
47
|
- README.md
|
48
48
|
- lib/brule.rb
|
49
|
-
|
50
|
-
- lib/brule/engine.rb
|
51
|
-
- lib/brule/rule.rb
|
52
|
-
homepage: https://rubygems.org/gems/brule
|
49
|
+
homepage: https://github.com/nicoolas25/brule-rb
|
53
50
|
licenses:
|
54
51
|
- MIT
|
55
52
|
metadata: {}
|
data/lib/brule/context.rb
DELETED
@@ -1,24 +0,0 @@
|
|
1
|
-
require 'forwardable'
|
2
|
-
|
3
|
-
module Brule
|
4
|
-
class Context
|
5
|
-
extend Forwardable
|
6
|
-
|
7
|
-
def_delegators :@content, :fetch, :fetch_values, :key?, :[]=, :merge!
|
8
|
-
|
9
|
-
def self.wrap(context)
|
10
|
-
context.is_a?(self) ? context : new(context)
|
11
|
-
end
|
12
|
-
|
13
|
-
def initialize(hash = {})
|
14
|
-
@content = hash
|
15
|
-
end
|
16
|
-
|
17
|
-
def initialize_copy(orig)
|
18
|
-
super
|
19
|
-
@content = orig.instance_variable_get(:@content).dup
|
20
|
-
end
|
21
|
-
|
22
|
-
alias [] fetch
|
23
|
-
end
|
24
|
-
end
|
data/lib/brule/engine.rb
DELETED
@@ -1,37 +0,0 @@
|
|
1
|
-
module Brule
|
2
|
-
class Engine
|
3
|
-
attr_reader :context
|
4
|
-
|
5
|
-
def initialize(rules:)
|
6
|
-
@rules = rules
|
7
|
-
end
|
8
|
-
|
9
|
-
def call(context = {})
|
10
|
-
@history = {}
|
11
|
-
@context = Context.wrap(context)
|
12
|
-
snapshot!(tag: :initial)
|
13
|
-
@rules.each { |rule| apply(rule) }
|
14
|
-
result
|
15
|
-
end
|
16
|
-
|
17
|
-
def history(key:)
|
18
|
-
return [] unless defined?(@history)
|
19
|
-
|
20
|
-
@history.map { |tag, content| [tag, content.fetch(key, nil)] }
|
21
|
-
end
|
22
|
-
|
23
|
-
private
|
24
|
-
|
25
|
-
def snapshot!(tag:)
|
26
|
-
@history[tag] = @context.dup
|
27
|
-
end
|
28
|
-
|
29
|
-
def apply(rule)
|
30
|
-
rule.context = @context
|
31
|
-
return unless rule.trigger?
|
32
|
-
|
33
|
-
rule.apply
|
34
|
-
snapshot!(tag: rule)
|
35
|
-
end
|
36
|
-
end
|
37
|
-
end
|