spot_feel 0.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6323936066f69077c032e9e4f376d10f1ae2c4d343279b4aeded36b9204aa73a
4
+ data.tar.gz: 2ce14493ddc4432073b2ecd2c966a2f05d29c209b06b0b3f4c66df0d932888fe
5
+ SHA512:
6
+ metadata.gz: b52024d181ce210c77ec71e9dc040476bde8a96ca1126b7cd3f7b4242cbea24dd6a164ea627d5dc463f9b70975d7eca142ce2836a2b7d72445f37358ab1796f9
7
+ data.tar.gz: 01ce5c7901b08b926caf0a79806742f33d6051edcff0d9b33a153d5b0e601cd959377db81eede03c14f2fc340d27cb366821b6fc1f736047db14f88a40890a39
data/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # Spot Feel
2
+
3
+ A light-weight DMN FEEL expression evaluator and business rule engine in Ruby.
4
+
5
+ This gem implements a subset of FEEL (Friendly Enough Expression Language) as defined in the [DMN 1.3 specification](https://www.omg.org/spec/DMN/1.3/PDF) with some additional extensions.
6
+
7
+ FEEL expressions are parsed into an abstract syntax tree (AST) and then evaluated in a context. The context is a hash of variables and functions to be resolved inside the expression.
8
+
9
+ Expressions are safe, side-effect free, and deterministic. They are ideal for capturing business logic for storage in a database or embedded in DMN, BPMN, or Form documents for execution in a workflow engine like [Spot Flow](https://github.com/connectedbits/spot-flow).
10
+
11
+ This project was inspired by these excellent libraries:
12
+
13
+ - [feelin](https://github.com/nikku/feelin)
14
+ - [dmn-eval-js](https://github.com/mineko-io/dmn-eval-js)
15
+
16
+ ## Usage
17
+
18
+ To evaluate an expression:
19
+
20
+ ```ruby
21
+ SpotFeel.evaluate('"👋 Hello " + name', variables: { name: "World" })
22
+ # => "👋 Hello World"
23
+ ```
24
+
25
+ A slightly more complex example:
26
+
27
+ ```ruby
28
+ variables = {
29
+ person: {
30
+ name: "Eric",
31
+ age: 59,
32
+ }
33
+ }
34
+ SpotFeel.evaluate('if person.age >= 18 then "adult" else "minor"', variables:)
35
+ # => "adult"
36
+ ```
37
+
38
+ Calling a built-in function:
39
+
40
+ ```ruby
41
+ SpotFeel.evaluate('sum([1, 2, 3])')
42
+ # => 6
43
+ ```
44
+
45
+ Calling a user-defined function:
46
+
47
+ ```ruby
48
+ SpotFeel.config.functions = {
49
+ "reverse": ->(s) { s.reverse }
50
+ }
51
+ SpotFeel.evaluate('reverse("Hello World!")', functions:)
52
+ # => "!dlroW olleH"
53
+ ```
54
+
55
+ To evaluate a unary tests:
56
+
57
+ ```ruby
58
+ SpotFeel.test(3, '<= 10, > 50'))
59
+ # => true
60
+ ```
61
+
62
+ ```ruby
63
+ SpotFeel.test("Eric", '"Bob", "Holly", "Eric"')
64
+ # => true
65
+ ```
66
+
67
+ ![Decision Table](docs/media/decision_table.png)
68
+
69
+ To evaluate a DMN decision table:
70
+
71
+ ```ruby
72
+ variables = {
73
+ violation: {
74
+ type: "speed",
75
+ actual_speed: 100,
76
+ speed_limit: 65,
77
+ }
78
+ }
79
+ result = SpotFeel.decide('fine_decision', definitions_xml: fixture_source("fine.dmn"), variables:)
80
+ # => { "amount" => 1000, "points" => 7 })
81
+ ```
82
+
83
+ To get a list of variables or functions used in an expression:
84
+
85
+ ```ruby
86
+ LiteralExpression.new(text: 'person.first_name + " " + person.last_name').variable_names
87
+ # => ["person.age, person.last_name"]
88
+ ```
89
+
90
+ ```ruby
91
+ LiteralExpression.new(text: 'sum([1, 2, 3])').function_names
92
+ # => ["sum"]
93
+ ```
94
+
95
+ ```ruby
96
+ UnaryTests.new(text: '> speed - speed_limit').variable_names
97
+ # => ["speed, speed_limit"]
98
+ ```
99
+
100
+ ## Supported Features
101
+
102
+ ### Data Types
103
+
104
+ - [x] Boolean (true, false)
105
+ - [x] Number (integer, decimal)
106
+ - [x] String (single and double quoted)
107
+ - [x] Date, Time, Duration (ISO 8601)
108
+ - [x] List (array)
109
+ - [x] Context (hash)
110
+
111
+ ### Expressions
112
+
113
+ - [x] Literal
114
+ - [x] Path
115
+ - [x] Arithmetic
116
+ - [x] Comparison
117
+ - [x] Function Invocation
118
+ - [x] Positional Parameters
119
+ - [x] If Expression
120
+ - [ ] For Expression
121
+ - [ ] Quantified Expression
122
+ - [ ] Filter Expression
123
+ - [ ] Disjunction
124
+ - [ ] Conjuction
125
+ - [ ] Instance Of
126
+ - [ ] Function Definition
127
+
128
+ ### Unary Tests
129
+
130
+ - [x] Comparison
131
+ - [x] Interval/Range (inclusive and exclusive)
132
+ - [x] Disjunction
133
+ - [x] Negation
134
+ - [ ] Expression
135
+
136
+ ### Built-in Functions
137
+
138
+ - [x] Conversion: `string`, `number`
139
+ - [x] Boolean: `is defined`, `get or else`
140
+ - [x] String: `substring`, `substring before`, `substring after`, `string length`, `upper case`, `lower case`, `contains`, `starts with`, `ends with`, `matches`, `replace`, `split`, `strip`, `extract`
141
+ - [x] Numeric: `decimal`, `floor`, `ceiling`, `round`, `abs`, `modulo`, `sqrt`, `log`, `exp`, `odd`, `even`, `random number`
142
+ - [x] List: `list contains`, `count`, `min`, `max`, `sum`, `product`, `mean`, `median`, `stddev`, `mode`, `all`, `any`, `sublist`, `append`, `concatenate`, `insert before`, `remove`, `reverse`, `index of`, `union`, `distinct values`, `duplicate values`, `flatten`, `sort`, `string join`
143
+ - [x] Context: `get entries`, `get value`, `get keys`
144
+ - [x] Temporal: `now`, `today`, `day of week`, `day of year`, `month of year`, `week of year`
145
+
146
+ ### DMN
147
+
148
+ - [x] Parse DMN XML documents
149
+ - [x] Evaluate DMN Decision Tables
150
+ - [x] Evaluate dependent DMN Decision Tables
151
+ - [x] Evaluate Expression Decisions
152
+
153
+ ## Installation
154
+
155
+ Execute:
156
+
157
+ ```bash
158
+ $ bundle add "spot_feel"
159
+ ```
160
+
161
+ Or install it directly:
162
+
163
+ ```bash
164
+ $ gem install spot_feel
165
+ ```
166
+
167
+ ### Setup
168
+
169
+ ```bash
170
+ $ git clone ...
171
+ $ bin/setup
172
+ $ bin/guard
173
+ ```
174
+
175
+ ## Development
176
+
177
+ [Treetop Doumentation](https://cjheath.github.io/treetop/syntactic_recognition.html) is a good place to start learning about Treetop.
178
+
179
+ ## License
180
+
181
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
182
+
183
+ Developed by [Connected Bits](http://www.connectedbits.com)
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require "bundler/setup"
2
+ require "bundler/gem_tasks"
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << "test"
7
+ t.libs << "lib"
8
+ t.test_files = FileList["test/**/*_test.rb"]
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpotFeel
4
+ class Configuration
5
+ attr_accessor :functions, :strict
6
+
7
+ def initialize
8
+ @functions = HashWithIndifferentAccess.new
9
+ @strict = false
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpotFeel
4
+ module Dmn
5
+ class Decision
6
+ attr_reader :id, :name, :decision_table, :variable, :literal_expression, :information_requirements
7
+
8
+ def self.from_json(json)
9
+ information_requirements = Array.wrap(json[:information_requirement]).map { |ir| InformationRequirement.from_json(ir) } if json[:information_requirement]
10
+ decision_table = DecisionTable.from_json(json[:decision_table]) if json[:decision_table]
11
+ literal_expression = LiteralExpression.from_json(json[:literal_expression]) if json[:literal_expression]
12
+ variable = Variable.from_json(json[:variable]) if json[:variable]
13
+ Decision.new(id: json[:id], name: json[:name], decision_table:, variable:, literal_expression:, information_requirements:)
14
+ end
15
+
16
+ def initialize(id:, name:, decision_table:, variable:, literal_expression:, information_requirements:)
17
+ @id = id
18
+ @name = name
19
+ @decision_table = decision_table
20
+ @variable = variable
21
+ @literal_expression = literal_expression
22
+ @information_requirements = information_requirements
23
+ end
24
+
25
+ def evaluate(variables = {})
26
+ if literal_expression.present?
27
+ result = literal_expression.evaluate(variables)
28
+ variable.present? ? { variable.name => result } : result
29
+ elsif decision_table.present?
30
+ decision_table.evaluate(variables)
31
+ end
32
+ end
33
+
34
+ def required_decision_ids
35
+ information_requirements&.map(&:required_decision_id)
36
+ end
37
+
38
+ def as_json
39
+ {
40
+ id: id,
41
+ name: name,
42
+ decision_table: decision_table.as_json,
43
+ variable: variable.as_json,
44
+ literal_expression: literal_expression.as_json,
45
+ information_requirements: information_requirements&.map(&:as_json),
46
+ }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpotFeel
4
+ module Dmn
5
+ class DecisionTable
6
+ attr_reader :id, :hit_policy, :inputs, :outputs, :rules
7
+
8
+ def self.from_json(json)
9
+ inputs = Array.wrap(json[:input]).map { |input| Input.from_json(input) }
10
+ outputs = Array.wrap(json[:output]).map { |output| Output.from_json(output) }
11
+ rules = Array.wrap(json[:rule]).map { |rule| Rule.from_json(rule) }
12
+ DecisionTable.new(id: json[:id], hit_policy: json[:hit_policy], inputs: inputs, outputs: outputs, rules: rules)
13
+ end
14
+
15
+ def initialize(id:, hit_policy:, inputs:, outputs:, rules:)
16
+ @id = id
17
+ @hit_policy = hit_policy&.downcase&.to_sym || :unique
18
+ @inputs = inputs
19
+ @outputs = outputs
20
+ @rules = rules
21
+ end
22
+
23
+ def evaluate(variables = {})
24
+ output_values = []
25
+
26
+ input_values = inputs.map do |input|
27
+ input.input_expression.evaluate(variables)
28
+ end
29
+
30
+ rules.each do |rule|
31
+ results = rule.evaluate(input_values, variables)
32
+ if results.all?
33
+ output_value = rule.output_value(outputs, variables)
34
+ return output_value if hit_policy == :first || hit_policy == :unique
35
+ output_values << output_value
36
+ end
37
+ end
38
+
39
+ output_values.empty? ? nil : output_values
40
+ end
41
+
42
+ def as_json
43
+ {
44
+ id: id,
45
+ hit_policy: hit_policy,
46
+ inputs: inputs.map(&:as_json),
47
+ outputs: outputs.map(&:as_json),
48
+ rules: rules.map(&:as_json),
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpotFeel
4
+ module Dmn
5
+ class Definitions
6
+ attr_reader :id, :name, :namespace, :exporter, :exporter_version, :execution_platform, :execution_platform_version
7
+ attr_reader :decisions
8
+
9
+ def self.from_xml(xml)
10
+ XmlHasher.configure do |config|
11
+ config.snakecase = true
12
+ config.ignore_namespaces = true
13
+ config.string_keys = false
14
+ end
15
+ json = XmlHasher.parse(xml)
16
+ Definitions.from_json(json[:definitions])
17
+ end
18
+
19
+ def self.from_json(json)
20
+ decisions = Array.wrap(json[:decision]).map { |decision| Decision.from_json(decision) }
21
+ Definitions.new(id: json[:id], name: json[:name], namespace: json[:namespace], exporter: json[:exporter], exporter_version: json[:exporter_version], execution_platform: json[:execution_platform], execution_platform_version: json[:execution_platform_version], decisions: decisions)
22
+ end
23
+
24
+ def initialize(id:, name:, namespace:, exporter:, exporter_version:, execution_platform:, execution_platform_version:, decisions:)
25
+ @id = id
26
+ @name = name
27
+ @namespace = namespace
28
+ @exporter = exporter
29
+ @exporter_version = exporter_version
30
+ @execution_platform = execution_platform
31
+ @execution_platform_version = execution_platform_version
32
+ @decisions = decisions
33
+ end
34
+
35
+ def evaluate(decision_id, variables: {}, already_evaluated_decisions: {})
36
+ decision = decisions.find { |d| d.id == decision_id }
37
+ raise EvaluationError, "Decision #{decision_id} not found" unless decision
38
+
39
+ # Evaluate required decisions recursively
40
+ decision.required_decision_ids&.each do |required_decision_id|
41
+ next if already_evaluated_decisions[required_decision_id]
42
+ next if decisions.find { |d| d.id == required_decision_id }.nil?
43
+
44
+ result = evaluate(required_decision_id, variables:, already_evaluated_decisions:)
45
+
46
+ variables.merge!(result) if result.is_a?(Hash)
47
+
48
+ already_evaluated_decisions[required_decision_id] = true
49
+ end
50
+
51
+ decision.evaluate(variables)
52
+ end
53
+
54
+ def as_json
55
+ {
56
+ id: id,
57
+ name: name,
58
+ namespace: namespace,
59
+ exporter: exporter,
60
+ exporter_version: exporter_version,
61
+ execution_platform: execution_platform,
62
+ execution_platform_version: execution_platform_version,
63
+ decisions: decisions.map(&:as_json),
64
+ }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpotFeel
4
+ module Dmn
5
+ class InformationRequirement
6
+ attr_reader :id, :required_input_id, :required_decision_id
7
+
8
+ def self.from_json(json)
9
+ required_input_id = json[:required_input][:href].delete_prefix("#") if json[:required_input]
10
+ required_decision_id = json[:required_decision][:href].delete_prefix("#") if json[:required_decision]
11
+ InformationRequirement.new(id: json[:id], required_input_id: required_input_id, required_decision_id: required_decision_id)
12
+ end
13
+
14
+ def initialize(id:, required_input_id:, required_decision_id:)
15
+ @id = id
16
+ @required_input_id = required_input_id
17
+ @required_decision_id = required_decision_id
18
+ end
19
+
20
+ def as_json
21
+ {
22
+ id: id,
23
+ required_decision_id: required_decision_id,
24
+ required_input_id: required_input_id,
25
+ }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpotFeel
4
+ module Dmn
5
+ class Input
6
+ attr_reader :id, :label, :input_expression
7
+
8
+ def self.from_json(json)
9
+ input_expression = LiteralExpression.from_json(json[:input_expression]) if json[:input_expression]
10
+ Input.new(id: json[:id], label: json[:label], input_expression:)
11
+ end
12
+
13
+ def initialize(id:, label:, input_expression:)
14
+ @id = id
15
+ @label = label
16
+ @input_expression = input_expression
17
+ end
18
+
19
+ def as_json
20
+ {
21
+ id: id,
22
+ label: label,
23
+ input_expression: input_expression.as_json,
24
+ }
25
+ end
26
+ end
27
+ end
28
+ end