brule 0.0.2 → 0.4.1

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: 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: {}