brule 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4c5f4dc5b794023937b44626faefd5feb047030cad7dd4a6b1fa5ccb42624af3
4
- data.tar.gz: 2be8c8844cbba6594cfe3d758ee69410aa2ff4df92288c42b6526a667c8969f7
3
+ metadata.gz: 13f788107d09cf82f121f314df0fd7247e808d4895a369afc56315aceaa21e84
4
+ data.tar.gz: 92d514d8305373969d48069d5627432d8d13e136ae0f38f1a94b1368ebbfb63c
5
5
  SHA512:
6
- metadata.gz: b40a40a8b365cdd628d4382fd3a092905c43f4cc34e036eea47036cf41379a46ec806bef50abb98bc8a0b499ab19a0413a328a13199bd50b15e1130b39cf9b89
7
- data.tar.gz: 73a0f66735eabb08f486df0760f9383e098ec11ea7630d73597c20e6e0c21fc5b1a4f84e54c395774e8afa46366383bee2d1050d6f5bf97388f2490f2a7ba15d
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[:price]
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
- context[:price] = context[:unit_price] * context[:item_count]
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
- order_value, discount_rate = applicable_discount
63
- price = context[:price]
64
- discount_amount = (price * discount_rate).ceil
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
- config[:rates]
78
+ rates
76
79
  .sort_by { |order_value, _| order_value * -1 }
77
- .find { |order_value, _| order_value <= context[:price] }
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
- price, state = context.fetch_values(:price, :state)
84
- tax_rate = config[:rates].fetch(state)
85
- state_tax = (price * tax_rate).ceil
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
- autoload :Context, 'brule/context'
5
- autoload :Engine, 'brule/engine'
6
- autoload :Result, 'brule/result'
7
- autoload :Rule, 'brule/rule'
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.2.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
- - lib/brule/context.rb
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
data/lib/brule/rule.rb DELETED
@@ -1,13 +0,0 @@
1
- module Brule
2
- Rule = Struct.new(:config) do
3
- attr_accessor :context
4
-
5
- def trigger?
6
- true
7
- end
8
-
9
- def apply
10
- raise NotImplementedError
11
- end
12
- end
13
- end