brule 0.0.2 → 0.4.1

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: 669938141fcd377863b801be38726843c5f1ed81737754e76178923298794541
4
+ data.tar.gz: aa7e4e0b3c4a84f0ab2c449518097e3519d8ede931f350119946d7beeed11ea0
5
5
  SHA512:
6
- metadata.gz: 95c5b969619e184e19286eab6738d923bdeb2acbe2c81ccf59c178333f1ce99ef0b1bdc237644652fdc40e28bce346ee5d85ed849ff23c13377d714339b2a1aa
7
- data.tar.gz: bc9dcfd653ebe89004d28db445607a37ad3f844dee96f3fedad93007293d1add45a40fdc44042025162263b2b2564a26347ec90d3c595594bb017276c837bdc1
6
+ metadata.gz: 2e68b2077b6c133989d430b4b8226aea8b06dda3264b0e92892ec2684265350fcc969a9737d290cf32683c50cedee0699331a35c28830cd00b01872272aaf32f
7
+ data.tar.gz: 44da1bdbecb77ae14b17230997ce3b3b5a47c623ebf86a44836bd8a245f2f12644d152edb2f50f47834e99eed3f5cd2c7dbc072319dab7b9403891cfac16adce
@@ -0,0 +1,127 @@
1
+ This project aims at providing a set of tools to build complex and evolving
2
+ business rules. It helps when:
3
+
4
+ * You decided to split the computation into smaller parts working together.
5
+ * You need to keep old versions of the rules working.
6
+ * You want to persist the rules leading to a result.
7
+ * You want to make your tests independent from your production data.
8
+ * You want to introspect the computation of a result.
9
+
10
+ ![Test](https://github.com/nicoolas25/brule-rb/workflows/Test/badge.svg?branch=master)
11
+
12
+ ## How does it work
13
+
14
+ The idea is very similar to function composition or Rack's middlewares. It is a
15
+ layering abstraction where each layer works for the next layers in order for the
16
+ whole to produce a single result.
17
+
18
+ An _engine_ respond to `#call`, taking a `context` in argument. It produces a
19
+ _result_ that is extracted from the _context_ by the `#result` method. Before
20
+ doing that, the engine apply its _rules_.
21
+
22
+ ![Engine](https://github.com/nicoolas25/brule-rb/blob/master/docs/img/engine.png?raw=true)
23
+
24
+ Each rule have two methods: `#trigger?` and `#apply`. `#apply` runs only when
25
+ `trigger?` is true. `#apply` writes stuff to the context for other rules and
26
+ for the engine to produce the result. Rules can be initialized with a
27
+ _configuration_.
28
+
29
+ ![Rule](https://github.com/nicoolas25/brule-rb/blob/master/docs/img/rule.png?raw=true)
30
+
31
+
32
+ A typical usage for this kind of engine is to use it to compute the price of a
33
+ service or a good. But, this is not limited to that use-case as an engine
34
+ would be able to produce any kind of results.
35
+
36
+ ## How does it look
37
+
38
+ [Here](https://nicoolas25.github.io/brule-rb/examples/elephant_carpaccio.html)
39
+ is an example from the [Elephant Carpaccio][elephant] kata. The specs are:
40
+
41
+ > Accept 3 inputs from the user:
42
+ >
43
+ > * How many items
44
+ > * Price per item
45
+ > * 2-letter state code
46
+ >
47
+ > Output the total price. Give a discount based on the total price, add state
48
+ > tax based on the state and the discounted price.
49
+
50
+ [This](https://nicoolas25.github.io/brule-rb/examples/elephant_carpaccio.html)
51
+ is the smallest example and the _best_ one to refer to. More examples are
52
+ available:
53
+
54
+ - [Elephant Carpaccio](https://nicoolas25.github.io/brule-rb/examples/elephant_carpaccio.html)
55
+ - [Car rental](https://nicoolas25.github.io/brule-rb/examples/car_rental.html)
56
+ - [Studio rental](https://nicoolas25.github.io/brule-rb/examples/studio_rental.html)
57
+ - [Promo code](https://nicoolas25.github.io/brule-rb/examples/promo_code.html) (WIP)
58
+
59
+ ## What does it bring to the table
60
+
61
+ If you compare [this approach](https://nicoolas25.github.io/brule-rb/examples/elephant_carpaccio.html)
62
+ with a simple method like this one:
63
+
64
+ ```ruby
65
+ require "bigdecimal"
66
+ require "bigdecimal/util"
67
+
68
+ STATE_TAXES = {
69
+ "UT" => "0.0685".to_d,
70
+ "NV" => "0.0800".to_d,
71
+ "TX" => "0.0625".to_d,
72
+ "AL" => "0.0400".to_d,
73
+ "CA" => "0.0825".to_d,
74
+ }
75
+
76
+ DISCOUNT_RATES = [
77
+ [ 50_000_00, "0.15".to_d ],
78
+ [ 10_000_00, "0.10".to_d ],
79
+ [ 7_000_00, "0.07".to_d ],
80
+ [ 5_000_00, "0.05".to_d ],
81
+ [ 1_000_00, "0.03".to_d ],
82
+ [ 0, "0.00".to_d ],
83
+ ]
84
+
85
+ def pricing(item_count, unit_price, state)
86
+ price = item_count * unit_price
87
+ discount_rate = DISCOUNT_RATES.find { |limit, _| limit <= price }.last
88
+ state_tax = STATE_TAXES.fetch(state)
89
+ price * (1 - discount_rate) * (1 + state_tax)
90
+ end
91
+ ```
92
+
93
+ ... then you'll find significant differences:
94
+
95
+ * Over-abstraction versus under-abstraction
96
+ * 1st example uses the layering abstraction provided by the gem
97
+ * 2nd example uses nothing, [YAGNI][yagni]
98
+ * Generalization vs specialization
99
+ * 1st approach is more generic and could handle changes
100
+ * 2nd approach is more specialized and would require a rewrite
101
+ * Complexity versus brievety
102
+ * 1st example is more verbose and thus is harder to grasp
103
+ * 2nd example is more concise and fits in the head
104
+ * Configuration versus constants
105
+ * 1st example relies on rule's configuration
106
+ * 2nd example relies on `STATE_TAXES` and `DISCOUNT_RATES`
107
+ * Observability vs black-box
108
+ * 1st example allows to provide more information (`state_tax` and so on)
109
+ * 2nd example only gives a single result
110
+ * Data-independent versus hard-coded values
111
+ * 1st example considers as much logic as possible as data
112
+ * 2nd example mixes data and logic together (with hidden dependencies and assumptions)
113
+ * Temporal extensibility or versionning
114
+ * 1st example can compute the price using different rules
115
+ * Without discounts or with different discount rate per client
116
+ * With tax rates from on year or another
117
+ * 2nd example will have to introduce options, or even different methods
118
+ * Testability
119
+ * 1st example could be tested at various levels without any mocks
120
+ * 2nd example have to mock hidden dependencies from the implementation
121
+
122
+ Overall, it is about finding the right level of abtsraction. This tiny framework
123
+ helps you by providing you a little abstraction. Even if you're not using this
124
+ gem directly, it can give you some ideas behind it.
125
+
126
+ [elephant]: https://docs.google.com/document/d/1Ls6pTmhY_LV8LwFiboUXoFXenXZl0qVZWPZ8J4uoqpI/edit#
127
+ [yagni]: https://en.wikipedia.org/wiki/You_aren%27t_gonna_need_it
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brule
4
- autoload :Context, 'brule/context'
5
4
  autoload :Engine, 'brule/engine'
6
- autoload :Result, 'brule/result'
7
5
  autoload :Rule, 'brule/rule'
6
+ autoload :Utils, 'brule/utils'
8
7
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brule
4
+ class Engine
5
+ attr_reader :context, :rules
6
+
7
+ def initialize(rules:)
8
+ @rules = rules
9
+ @history = {}
10
+ end
11
+
12
+ def call(context = {})
13
+ @history = {}
14
+ @context = context
15
+ snapshot!(tag: :initial)
16
+ @rules.each { |rule| apply(rule) }
17
+ result
18
+ end
19
+
20
+ def history(key:)
21
+ @history.map { |tag, content| [tag, content.fetch(key, nil)] }
22
+ end
23
+
24
+ def to_hash
25
+ { 'engine_class' => self.class.name, 'rules' => @rules.map(&:to_hash) }
26
+ end
27
+
28
+ def self.from_hash(hash)
29
+ engine_class = Object.const_get(hash.fetch('engine_class'))
30
+ rules = hash.fetch('rules').map do |rule_hash|
31
+ rule_class = Object.const_get(rule_hash.fetch('rule_class'))
32
+ rule_class.from_hash(rule_hash)
33
+ end
34
+ engine_class.new(rules: rules)
35
+ end
36
+
37
+ private
38
+
39
+ def snapshot!(tag:)
40
+ @history[tag] = @context.dup
41
+ end
42
+
43
+ def apply(rule)
44
+ rule.context = @context
45
+ return unless rule.trigger?
46
+
47
+ rule.apply
48
+ snapshot!(tag: rule.to_tag)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brule
4
+ Rule = Struct.new(:config) do
5
+ attr_accessor :context
6
+
7
+ def trigger?
8
+ true
9
+ end
10
+
11
+ def apply
12
+ raise NotImplementedError
13
+ end
14
+
15
+ def to_tag
16
+ self
17
+ end
18
+
19
+ def to_hash
20
+ config_hash = block_given? ? yield(config) : config&.to_hash
21
+ { 'rule_class' => self.class.name, 'config' => config_hash }
22
+ end
23
+
24
+ def self.from_hash(hash)
25
+ raise ArgumentError unless hash.fetch('rule_class') == name
26
+
27
+ config_hash = hash.fetch('config')
28
+ new(block_given? ? yield(config_hash) : config_hash)
29
+ end
30
+
31
+ def self.context_reader(*keys)
32
+ keys.each do |key|
33
+ define_method(key) { context.fetch(key) }
34
+ end
35
+ end
36
+
37
+ def self.context_writer(*keys)
38
+ keys.each do |key|
39
+ define_method("#{key}=") { |value| context[key] = value }
40
+ end
41
+ end
42
+
43
+ def self.context_accessor(*keys)
44
+ context_reader(*keys)
45
+ context_writer(*keys)
46
+ end
47
+
48
+ def self.config_reader(*keys)
49
+ keys.each do |key|
50
+ define_method(key) { config.fetch(key) }
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brule
4
+ module Utils
5
+ autoload :Either, 'brule/utils/either'
6
+ end
7
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module Brule
6
+ module Utils
7
+ class Either < Brule::Rule
8
+ TooManyMatches = Class.new(StandardError)
9
+ NoMatchFound = Class.new(StandardError)
10
+
11
+ extend Forwardable
12
+
13
+ def_delegators :only_match, :apply, :trigger?, :to_tag
14
+
15
+ config_reader 'rules'
16
+
17
+ def to_hash
18
+ super do |config|
19
+ config.to_hash.tap do |config_hash|
20
+ config_hash['rules'] = config.fetch('rules').map(&:to_hash)
21
+ end
22
+ end
23
+ end
24
+
25
+ def self.from_hash(*)
26
+ super do |config_hash|
27
+ config_hash.tap do |config|
28
+ config['rules'] = config_hash.fetch('rules').map do |rule_hash|
29
+ rule_class = Object.const_get(rule_hash.fetch('rule_class'))
30
+ rule_class.from_hash(rule_hash)
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def only_match
39
+ matches = rules.select do |rule|
40
+ rule.context = context
41
+ rule.trigger?
42
+ end
43
+
44
+ if matches.size >= 2
45
+ raise TooManyMatches, "Rules #{matches.join(', ')} are all a match"
46
+ end
47
+
48
+ if matches.empty?
49
+ raise NoMatchFound, "No rules from #{rules.join(', ')} is a match"
50
+ end
51
+
52
+ matches.first
53
+ end
54
+ end
55
+ end
56
+ 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.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolas Zermati
@@ -38,14 +38,75 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: sequel
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '5.32'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '5.32'
55
+ - !ruby/object:Gem::Dependency
56
+ name: simplecov
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.18'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.18'
69
+ - !ruby/object:Gem::Dependency
70
+ name: simplecov-lcov
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.8'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.8'
83
+ - !ruby/object:Gem::Dependency
84
+ name: sqlite3
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.4'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.4'
41
97
  description:
42
98
  email: ''
43
99
  executables: []
44
100
  extensions: []
45
101
  extra_rdoc_files: []
46
102
  files:
103
+ - README.md
47
104
  - lib/brule.rb
48
- homepage: https://rubygems.org/gems/brule
105
+ - lib/brule/engine.rb
106
+ - lib/brule/rule.rb
107
+ - lib/brule/utils.rb
108
+ - lib/brule/utils/either.rb
109
+ homepage: https://github.com/nicoolas25/brule-rb
49
110
  licenses:
50
111
  - MIT
51
112
  metadata: {}