dmn 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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,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