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 +7 -0
- data/README.md +183 -0
- data/Rakefile +11 -0
- data/lib/spot_feel/configuration.rb +12 -0
- data/lib/spot_feel/dmn/decision.rb +50 -0
- data/lib/spot_feel/dmn/decision_table.rb +53 -0
- data/lib/spot_feel/dmn/definitions.rb +68 -0
- data/lib/spot_feel/dmn/information_requirement.rb +29 -0
- data/lib/spot_feel/dmn/input.rb +28 -0
- data/lib/spot_feel/dmn/literal_expression.rb +370 -0
- data/lib/spot_feel/dmn/output.rb +29 -0
- data/lib/spot_feel/dmn/rule.rb +63 -0
- data/lib/spot_feel/dmn/unary_tests.rb +27 -0
- data/lib/spot_feel/dmn/variable.rb +27 -0
- data/lib/spot_feel/dmn.rb +17 -0
- data/lib/spot_feel/nodes.rb +684 -0
- data/lib/spot_feel/parser.rb +29 -0
- data/lib/spot_feel/spot_feel.treetop +659 -0
- data/lib/spot_feel/version.rb +5 -0
- data/lib/spot_feel.rb +63 -0
- metadata +317 -0
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
|
+

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