brule 0.0.2 → 0.2.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: 3bd5036cc83ccf0a96b4140382d2e2653fc06e0d82f302b32c81f1c709c5f5d5
4
- data.tar.gz: 12e045f88777df92fe435b7a0c0afda4bda6d2c737722f5932519a9d955086f5
3
+ metadata.gz: 4c5f4dc5b794023937b44626faefd5feb047030cad7dd4a6b1fa5ccb42624af3
4
+ data.tar.gz: 2be8c8844cbba6594cfe3d758ee69410aa2ff4df92288c42b6526a667c8969f7
5
5
  SHA512:
6
- metadata.gz: 95c5b969619e184e19286eab6738d923bdeb2acbe2c81ccf59c178333f1ce99ef0b1bdc237644652fdc40e28bce346ee5d85ed849ff23c13377d714339b2a1aa
7
- data.tar.gz: bc9dcfd653ebe89004d28db445607a37ad3f844dee96f3fedad93007293d1add45a40fdc44042025162263b2b2564a26347ec90d3c595594bb017276c837bdc1
6
+ metadata.gz: b40a40a8b365cdd628d4382fd3a092905c43f4cc34e036eea47036cf41379a46ec806bef50abb98bc8a0b499ab19a0413a328a13199bd50b15e1130b39cf9b89
7
+ data.tar.gz: 73a0f66735eabb08f486df0760f9383e098ec11ea7630d73597c20e6e0c21fc5b1a4f84e54c395774e8afa46366383bee2d1050d6f5bf97388f2490f2a7ba15d
@@ -0,0 +1,145 @@
1
+ This project aims at providing a set of tools to build somehow complex
2
+ business processes or rules. It helps when:
3
+
4
+ * You decided to split the process into smaller parts working together.
5
+ * You need to keep the rules evolving while maintaining the old versions.
6
+ * You want to persist the rules that lead to the result of a process.
7
+ * You want to make your tests independent from your production data.
8
+ * You want to introspect what lead to the result of a process.
9
+
10
+ ## How does it work
11
+
12
+ If you use this gem, you'll have a couple of premade elements to build and test
13
+ an _engine_ that, given a set of _rules_ and a _context_ will produce a
14
+ _result_.
15
+
16
+ A typical usage for this kind of engine is to use it to compute the price of a
17
+ service or a good. But, this is not limited to that use-case as an engine
18
+ would be able to produce any kind of results.
19
+
20
+ An _engine_ orchestrate _rules_ in a way that they would produce enough
21
+ information for the engine to assemble a _result_. The rules are arranged in an
22
+ ordered sequence and are picked depending on the _context_. Each rule will write
23
+ in the context too.
24
+
25
+ The idea is very similar to function composition or Rack's middlewares.
26
+
27
+ ## How does it look
28
+
29
+ Here is an example from the [Elephant Carpaccio][elephant] kata. The specs are:
30
+
31
+ > Accept 3 inputs from the user:
32
+ >
33
+ > * How many items
34
+ > * Price per item
35
+ > * 2-letter state code
36
+ >
37
+ > Output the total price. Give a discount based on the total price, add state
38
+ > tax based on the state and the discounted price.
39
+
40
+ ```ruby
41
+ require "brule"
42
+
43
+ module Pricing
44
+ class Engine < Brule::Engine
45
+ def result
46
+ context[:price]
47
+ end
48
+ end
49
+
50
+ class OrderTotal < Brule::Rule
51
+ def apply
52
+ context[:price] = context[:unit_price] * context[:item_count]
53
+ end
54
+ end
55
+
56
+ class Discount < Brule::Rule
57
+ def trigger?
58
+ !applicable_discount.nil?
59
+ end
60
+
61
+ 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
+ end
71
+
72
+ private
73
+
74
+ def applicable_discount
75
+ config[:rates]
76
+ .sort_by { |order_value, _| order_value * -1 }
77
+ .find { |order_value, _| order_value <= context[:price] }
78
+ end
79
+ end
80
+
81
+ class StateTax < Brule::Rule
82
+ 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
+ end
91
+ end
92
+ end
93
+
94
+ require "bigdecimal"
95
+ require "bigdecimal/util"
96
+
97
+ engine = Pricing::Engine.new(
98
+ rules: [
99
+ Pricing::OrderTotal.new,
100
+ Pricing::Discount.new(
101
+ rates: [
102
+ [ 1_000_00, "0.03".to_d ],
103
+ [ 5_000_00, "0.05".to_d ],
104
+ [ 7_000_00, "0.07".to_d ],
105
+ [ 10_000_00, "0.10".to_d ],
106
+ [ 50_000_00, "0.15".to_d ],
107
+ ],
108
+ ),
109
+ Pricing::StateTax.new(
110
+ rates: {
111
+ "UT" => "0.0685".to_d,
112
+ "NV" => "0.0800".to_d,
113
+ "TX" => "0.0625".to_d,
114
+ "AL" => "0.0400".to_d,
115
+ "CA" => "0.0825".to_d,
116
+ },
117
+ ),
118
+ ],
119
+ )
120
+
121
+ result = engine.call(
122
+ item_count: 100,
123
+ unit_price: 100_00,
124
+ state: "NV",
125
+ )
126
+
127
+ # Access the main result
128
+ result # => 9_720_00 ($9,720.00)
129
+
130
+ # Access the context
131
+ engine.context.fetch_values(
132
+ :discount_rate, # => 0.1 (10%)
133
+ :discount_amount, # => 1_000_00 ($1,000.00)
134
+ :state_tax, # => 720_00 ($720.00)
135
+ )
136
+
137
+ # Access the history
138
+ engine.history(key: :price) # => [
139
+ # => [#<struct Pricing::OrderTotal ...>, 10_000_00],
140
+ # => [#<struct Pricing::Discount ...>, 9_000_00],
141
+ # => [#<struct Pricing::StateTax ...>, 9_720_00],
142
+ # => ]
143
+ ```
144
+
145
+ [elephant]: https://docs.google.com/document/d/1Ls6pTmhY_LV8LwFiboUXoFXenXZl0qVZWPZ8J4uoqpI/edit#
@@ -0,0 +1,24 @@
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
@@ -0,0 +1,37 @@
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
@@ -0,0 +1,13 @@
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
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.0.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolas Zermati
@@ -44,7 +44,11 @@ executables: []
44
44
  extensions: []
45
45
  extra_rdoc_files: []
46
46
  files:
47
+ - README.md
47
48
  - lib/brule.rb
49
+ - lib/brule/context.rb
50
+ - lib/brule/engine.rb
51
+ - lib/brule/rule.rb
48
52
  homepage: https://rubygems.org/gems/brule
49
53
  licenses:
50
54
  - MIT