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