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 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