dmn 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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 +374 -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 +654 -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: 8b9993953a743d1305667dd56353fa828ff9e3bf3cfecd1f1fffb94dd7eb1842
|
4
|
+
data.tar.gz: a7a498cf1098796664b33dc5cac84969ddacc6df8a2b418032befcdc51ea5eb2
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: fc9de27ca64c128d7d6401792fbdf8a61ce97e6eef64c936e7c1e7bab74214fa109bcdb3f07fcc9f63aeeceddf74490e4ea6ca61d54061c46e6576e322ececef
|
7
|
+
data.tar.gz: 88913ed1996eeaf913e366368d180d6cb7e3b941bfaa7366b8d387b2ccc6213677471797cd5ffc02f9c15959cae80798b1a6f0cf3d16662e03b3608b020e8f2b
|
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: `not`, `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
|