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 +4 -4
- data/README.md +145 -0
- data/lib/brule/context.rb +24 -0
- data/lib/brule/engine.rb +37 -0
- data/lib/brule/rule.rb +13 -0
- metadata +5 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4c5f4dc5b794023937b44626faefd5feb047030cad7dd4a6b1fa5ccb42624af3
|
4
|
+
data.tar.gz: 2be8c8844cbba6594cfe3d758ee69410aa2ff4df92288c42b6526a667c8969f7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b40a40a8b365cdd628d4382fd3a092905c43f4cc34e036eea47036cf41379a46ec806bef50abb98bc8a0b499ab19a0413a328a13199bd50b15e1130b39cf9b89
|
7
|
+
data.tar.gz: 73a0f66735eabb08f486df0760f9383e098ec11ea7630d73597c20e6e0c21fc5b1a4f84e54c395774e8afa46366383bee2d1050d6f5bf97388f2490f2a7ba15d
|
data/README.md
ADDED
@@ -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
|
data/lib/brule/engine.rb
ADDED
@@ -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
|
data/lib/brule/rule.rb
ADDED
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
|
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
|