brule 0.0.2 → 0.2.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: 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