dmn 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b9993953a743d1305667dd56353fa828ff9e3bf3cfecd1f1fffb94dd7eb1842
4
- data.tar.gz: a7a498cf1098796664b33dc5cac84969ddacc6df8a2b418032befcdc51ea5eb2
3
+ metadata.gz: 49694c6ba7cf12162a99192f8a7080d0b7134e8e5a2a0a5d0f88ad1c14bb59f0
4
+ data.tar.gz: 63d487600e8b8019d96d7596407a71a4c0e188c07e9ee901c914242cfdcf3bc4
5
5
  SHA512:
6
- metadata.gz: fc9de27ca64c128d7d6401792fbdf8a61ce97e6eef64c936e7c1e7bab74214fa109bcdb3f07fcc9f63aeeceddf74490e4ea6ca61d54061c46e6576e322ececef
7
- data.tar.gz: 88913ed1996eeaf913e366368d180d6cb7e3b941bfaa7366b8d387b2ccc6213677471797cd5ffc02f9c15959cae80798b1a6f0cf3d16662e03b3608b020e8f2b
6
+ metadata.gz: 29eb3d6a96514f1056bb3d37779afaeb4d26a62eb5cafe87cb179425d3791d8acea77934cf8f48d51ad94341726c4e38a43221f8e3913e5df8ed6a77306a5036
7
+ data.tar.gz: 3b965748993649d0af143dbcc1eb237f203dedf2e37de054e5622d04010d81293ddf116ae4128c3dfef3f37ecbfe5d5e74ee0f33e951cd59ff43208f7a5f8a57
data/README.md CHANGED
@@ -18,7 +18,7 @@ This project was inspired by these excellent libraries:
18
18
  To evaluate an expression:
19
19
 
20
20
  ```ruby
21
- SpotFeel.evaluate('"👋 Hello " + name', variables: { name: "World" })
21
+ DMN.evaluate('"👋 Hello " + name', variables: { name: "World" })
22
22
  # => "👋 Hello World"
23
23
  ```
24
24
 
@@ -31,36 +31,36 @@ variables = {
31
31
  age: 59,
32
32
  }
33
33
  }
34
- SpotFeel.evaluate('if person.age >= 18 then "adult" else "minor"', variables:)
34
+ DMN.evaluate('if person.age >= 18 then "adult" else "minor"', variables:)
35
35
  # => "adult"
36
36
  ```
37
37
 
38
38
  Calling a built-in function:
39
39
 
40
40
  ```ruby
41
- SpotFeel.evaluate('sum([1, 2, 3])')
41
+ DMN.evaluate('sum([1, 2, 3])')
42
42
  # => 6
43
43
  ```
44
44
 
45
45
  Calling a user-defined function:
46
46
 
47
47
  ```ruby
48
- SpotFeel.config.functions = {
48
+ DMN.config.functions = {
49
49
  "reverse": ->(s) { s.reverse }
50
50
  }
51
- SpotFeel.evaluate('reverse("Hello World!")', functions:)
51
+ DMN.evaluate('reverse("Hello World!")', functions:)
52
52
  # => "!dlroW olleH"
53
53
  ```
54
54
 
55
55
  To evaluate a unary tests:
56
56
 
57
57
  ```ruby
58
- SpotFeel.test(3, '<= 10, > 50'))
58
+ DMN.test(3, '<= 10, > 50'))
59
59
  # => true
60
60
  ```
61
61
 
62
62
  ```ruby
63
- SpotFeel.test("Eric", '"Bob", "Holly", "Eric"')
63
+ DMN.test("Eric", '"Bob", "Holly", "Eric"')
64
64
  # => true
65
65
  ```
66
66
 
@@ -76,7 +76,7 @@ variables = {
76
76
  speed_limit: 65,
77
77
  }
78
78
  }
79
- result = SpotFeel.decide('fine_decision', definitions_xml: fixture_source("fine.dmn"), variables:)
79
+ result = DMN.decide('fine_decision', definitions_xml: fixture_source("fine.dmn"), variables:)
80
80
  # => { "amount" => 1000, "points" => 7 })
81
81
  ```
82
82
 
@@ -155,13 +155,13 @@ UnaryTests.new(text: '> speed - speed_limit').variable_names
155
155
  Execute:
156
156
 
157
157
  ```bash
158
- $ bundle add "spot_feel"
158
+ $ bundle add "dmn"
159
159
  ```
160
160
 
161
161
  Or install it directly:
162
162
 
163
163
  ```bash
164
- $ gem install spot_feel
164
+ $ gem install dmn
165
165
  ```
166
166
 
167
167
  ### Setup
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SpotFeel
3
+ module DMN
4
4
  class Configuration
5
5
  attr_accessor :functions, :strict
6
6
 
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DMN
4
+ class Decision
5
+ attr_reader :id, :name, :decision_table, :variable, :literal_expression, :information_requirements
6
+
7
+ def self.from_json(json)
8
+ information_requirements = Array.wrap(json[:information_requirement]).map { |ir| InformationRequirement.from_json(ir) } if json[:information_requirement]
9
+ decision_table = DecisionTable.from_json(json[:decision_table]) if json[:decision_table]
10
+ literal_expression = LiteralExpression.from_json(json[:literal_expression]) if json[:literal_expression]
11
+ variable = Variable.from_json(json[:variable]) if json[:variable]
12
+ Decision.new(id: json[:id], name: json[:name], decision_table:, variable:, literal_expression:, information_requirements:)
13
+ end
14
+
15
+ def initialize(id:, name:, decision_table:, variable:, literal_expression:, information_requirements:)
16
+ @id = id
17
+ @name = name
18
+ @decision_table = decision_table
19
+ @variable = variable
20
+ @literal_expression = literal_expression
21
+ @information_requirements = information_requirements
22
+ end
23
+
24
+ def evaluate(variables = {})
25
+ if literal_expression.present?
26
+ result = literal_expression.evaluate(variables)
27
+ variable.present? ? { variable.name => result } : result
28
+ elsif decision_table.present?
29
+ decision_table.evaluate(variables)
30
+ end
31
+ end
32
+
33
+ def required_decision_ids
34
+ information_requirements&.map(&:required_decision_id)
35
+ end
36
+
37
+ def as_json
38
+ {
39
+ id: id,
40
+ name: name,
41
+ decision_table: decision_table.as_json,
42
+ variable: variable.as_json,
43
+ literal_expression: literal_expression.as_json,
44
+ information_requirements: information_requirements&.map(&:as_json),
45
+ }
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DMN
4
+ class DecisionTable
5
+ attr_reader :id, :hit_policy, :inputs, :outputs, :rules
6
+
7
+ def self.from_json(json)
8
+ inputs = Array.wrap(json[:input]).map { |input| Input.from_json(input) }
9
+ outputs = Array.wrap(json[:output]).map { |output| Output.from_json(output) }
10
+ rules = Array.wrap(json[:rule]).map { |rule| Rule.from_json(rule) }
11
+ DecisionTable.new(id: json[:id], hit_policy: json[:hit_policy], inputs: inputs, outputs: outputs, rules: rules)
12
+ end
13
+
14
+ def initialize(id:, hit_policy:, inputs:, outputs:, rules:)
15
+ @id = id
16
+ @hit_policy = hit_policy&.downcase&.to_sym || :unique
17
+ @inputs = inputs
18
+ @outputs = outputs
19
+ @rules = rules
20
+ end
21
+
22
+ def evaluate(variables = {})
23
+ output_values = []
24
+
25
+ input_values = inputs.map do |input|
26
+ input.input_expression.evaluate(variables)
27
+ end
28
+
29
+ rules.each do |rule|
30
+ results = rule.evaluate(input_values, variables)
31
+ if results.all?
32
+ output_value = rule.output_value(outputs, variables)
33
+ return output_value if hit_policy == :first || hit_policy == :unique
34
+ output_values << output_value
35
+ end
36
+ end
37
+
38
+ output_values.empty? ? nil : output_values
39
+ end
40
+
41
+ def as_json
42
+ {
43
+ id: id,
44
+ hit_policy: hit_policy,
45
+ inputs: inputs.map(&:as_json),
46
+ outputs: outputs.map(&:as_json),
47
+ rules: rules.map(&:as_json),
48
+ }
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DMN
4
+ class Definitions
5
+ attr_reader :id, :name, :namespace, :exporter, :exporter_version, :execution_platform, :execution_platform_version
6
+ attr_reader :decisions
7
+
8
+ def self.from_xml(xml)
9
+ XmlHasher.configure do |config|
10
+ config.snakecase = true
11
+ config.ignore_namespaces = true
12
+ config.string_keys = false
13
+ end
14
+ json = XmlHasher.parse(xml)
15
+ Definitions.from_json(json[:definitions])
16
+ end
17
+
18
+ def self.from_json(json)
19
+ decisions = Array.wrap(json[:decision]).map { |decision| Decision.from_json(decision) }
20
+ 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)
21
+ end
22
+
23
+ def initialize(id:, name:, namespace:, exporter:, exporter_version:, execution_platform:, execution_platform_version:, decisions:)
24
+ @id = id
25
+ @name = name
26
+ @namespace = namespace
27
+ @exporter = exporter
28
+ @exporter_version = exporter_version
29
+ @execution_platform = execution_platform
30
+ @execution_platform_version = execution_platform_version
31
+ @decisions = decisions
32
+ end
33
+
34
+ def evaluate(decision_id, variables: {}, already_evaluated_decisions: {})
35
+ decision = decisions.find { |d| d.id == decision_id }
36
+ raise EvaluationError, "Decision #{decision_id} not found" unless decision
37
+
38
+ # Evaluate required decisions recursively
39
+ decision.required_decision_ids&.each do |required_decision_id|
40
+ next if already_evaluated_decisions[required_decision_id]
41
+ next if decisions.find { |d| d.id == required_decision_id }.nil?
42
+
43
+ result = evaluate(required_decision_id, variables:, already_evaluated_decisions:)
44
+
45
+ variables.merge!(result) if result.is_a?(Hash)
46
+
47
+ already_evaluated_decisions[required_decision_id] = true
48
+ end
49
+
50
+ decision.evaluate(variables)
51
+ end
52
+
53
+ def as_json
54
+ {
55
+ id: id,
56
+ name: name,
57
+ namespace: namespace,
58
+ exporter: exporter,
59
+ exporter_version: exporter_version,
60
+ execution_platform: execution_platform,
61
+ execution_platform_version: execution_platform_version,
62
+ decisions: decisions.map(&:as_json),
63
+ }
64
+ end
65
+ end
66
+ end
@@ -1,4 +1,4 @@
1
- grammar SpotFeel
1
+ grammar DMN
2
2
 
3
3
  rule start
4
4
  expression_or_tests
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DMN
4
+ class InformationRequirement
5
+ attr_reader :id, :required_input_id, :required_decision_id
6
+
7
+ def self.from_json(json)
8
+ required_input_id = json[:required_input][:href].delete_prefix("#") if json[:required_input]
9
+ required_decision_id = json[:required_decision][:href].delete_prefix("#") if json[:required_decision]
10
+ InformationRequirement.new(id: json[:id], required_input_id: required_input_id, required_decision_id: required_decision_id)
11
+ end
12
+
13
+ def initialize(id:, required_input_id:, required_decision_id:)
14
+ @id = id
15
+ @required_input_id = required_input_id
16
+ @required_decision_id = required_decision_id
17
+ end
18
+
19
+ def as_json
20
+ {
21
+ id: id,
22
+ required_decision_id: required_decision_id,
23
+ required_input_id: required_input_id,
24
+ }
25
+ end
26
+ end
27
+ end
data/lib/dmn/input.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DMN
4
+ class Input
5
+ attr_reader :id, :label, :input_expression
6
+
7
+ def self.from_json(json)
8
+ input_expression = LiteralExpression.from_json(json[:input_expression]) if json[:input_expression]
9
+ Input.new(id: json[:id], label: json[:label], input_expression:)
10
+ end
11
+
12
+ def initialize(id:, label:, input_expression:)
13
+ @id = id
14
+ @label = label
15
+ @input_expression = input_expression
16
+ end
17
+
18
+ def as_json
19
+ {
20
+ id: id,
21
+ label: label,
22
+ input_expression: input_expression.as_json,
23
+ }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,372 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DMN
4
+ class LiteralExpression
5
+ attr_reader :id, :text
6
+
7
+ def self.from_json(json)
8
+ LiteralExpression.new(id: json[:id], text: json[:text])
9
+ end
10
+
11
+ def initialize(id: nil, text:)
12
+ @id = id
13
+ @text = text&.strip
14
+ end
15
+
16
+ def tree
17
+ @tree ||= DMN::Parser.parse(text)
18
+ end
19
+
20
+ def valid?
21
+ return false if text.blank?
22
+ tree.present?
23
+ end
24
+
25
+ def evaluate(variables = {})
26
+ tree.eval(functions.merge(variables))
27
+ end
28
+
29
+ def functions
30
+ builtins = LiteralExpression.builtin_functions
31
+ custom = (DMN.config.functions || {})
32
+ ActiveSupport::HashWithIndifferentAccess.new(builtins.merge(custom))
33
+ end
34
+
35
+ def named_functions
36
+ # Initialize a set to hold the qualified names
37
+ function_names = Set.new
38
+
39
+ # Define a lambda for the recursive function
40
+ walk_tree = lambda do |node|
41
+ # If the node is a qualified name, add it to the set
42
+ if node.is_a?(DMN::FunctionInvocation)
43
+ function_names << node.fn_name.text_value
44
+ end
45
+
46
+ # Recursively walk the child nodes
47
+ node.elements&.each do |child|
48
+ walk_tree.call(child)
49
+ end
50
+ end
51
+
52
+ # Start walking the tree from the root
53
+ walk_tree.call(tree)
54
+
55
+ # Return the array of functions
56
+ function_names.to_a
57
+ end
58
+
59
+ def named_variables
60
+ # Initialize a set to hold the qualified names
61
+ qualified_names = Set.new
62
+
63
+ # Define a lambda for the recursive function
64
+ walk_tree = lambda do |node|
65
+ # If the node is a qualified name, add it to the set
66
+ if node.is_a?(DMN::QualifiedName)
67
+ qualified_names << node.text_value
68
+ end
69
+
70
+ # Recursively walk the child nodes
71
+ node.elements&.each do |child|
72
+ walk_tree.call(child)
73
+ end
74
+ end
75
+
76
+ # Start walking the tree from the root
77
+ walk_tree.call(tree)
78
+
79
+ # Return the array of qualified names
80
+ qualified_names.to_a
81
+ end
82
+
83
+ def self.builtin_functions
84
+ HashWithIndifferentAccess.new({
85
+ # Conversion functions
86
+ "string": ->(from) {
87
+ return if from.nil?
88
+ from.to_s
89
+ },
90
+ "number": ->(from) {
91
+ return if from.nil?
92
+ from.include?(".") ? from.to_f : from.to_i
93
+ },
94
+ # Boolean functions
95
+ "not": ->(value) {
96
+ if value == true || value == false
97
+ !value
98
+ end
99
+ },
100
+ "is defined": ->(value) {
101
+ return if value.nil?
102
+ !value.nil?
103
+ },
104
+ "get or else": ->(value, default) {
105
+ value.nil? ? default : value
106
+ },
107
+ # String functions
108
+ "substring": ->(string, start, length) {
109
+ return if string.nil? || start.nil?
110
+ return "" if length.nil?
111
+ string[start - 1, length]
112
+ },
113
+ "substring before": ->(string, match) {
114
+ return if string.nil? || match.nil?
115
+ string.split(match).first
116
+ },
117
+ "substring after": ->(string, match) {
118
+ return if string.nil? || match.nil?
119
+ string.split(match).last
120
+ },
121
+ "string length": ->(string) {
122
+ return if string.nil?
123
+ string.length
124
+ },
125
+ "upper case": ->(string) {
126
+ return if string.nil?
127
+ string.upcase
128
+ },
129
+ "lower case": -> (string) {
130
+ return if string.nil?
131
+ string.downcase
132
+ },
133
+ "contains": ->(string, match) {
134
+ return if string.nil? || match.nil?
135
+ string.include?(match)
136
+ },
137
+ "starts with": ->(string, match) {
138
+ return if string.nil? || match.nil?
139
+ string.start_with?(match)
140
+ },
141
+ "ends with": ->(string, match) {
142
+ return if string.nil? || match.nil?
143
+ string.end_with?(match)
144
+ },
145
+ "matches": ->(string, match) {
146
+ return if string.nil? || match.nil?
147
+ string.match?(match)
148
+ },
149
+ "replace": ->(string, match, replacement) {
150
+ return if string.nil? || match.nil? || replacement.nil?
151
+ string.gsub(match, replacement)
152
+ },
153
+ "split": ->(string, match) {
154
+ return if string.nil? || match.nil?
155
+ string.split(match)
156
+ },
157
+ "strip": -> (string) {
158
+ return if string.nil?
159
+ string.strip
160
+ },
161
+ "extract": -> (string, pattern) {
162
+ return if string.nil? || pattern.nil?
163
+ string.match(pattern).captures
164
+ },
165
+ # Numeric functions
166
+ "decimal": ->(n, scale) {
167
+ return if n.nil? || scale.nil?
168
+ n.round(scale)
169
+ },
170
+ "floor": ->(n) {
171
+ return if n.nil?
172
+ n.floor
173
+ },
174
+ "ceiling": ->(n) {
175
+ return if n.nil?
176
+ n.ceil
177
+ },
178
+ "round up": ->(n) {
179
+ return if n.nil?
180
+ n.ceil
181
+ },
182
+ "round down": ->(n) {
183
+ return if n.nil?
184
+ n.floor
185
+ },
186
+ "abs": ->(n) {
187
+ return if n.nil?
188
+ n.abs
189
+ },
190
+ "modulo": ->(n, divisor) {
191
+ return if n.nil? || divisor.nil?
192
+ n % divisor
193
+ },
194
+ "sqrt": ->(n) {
195
+ return if n.nil?
196
+ Math.sqrt(n)
197
+ },
198
+ "log": ->(n) {
199
+ return if n.nil?
200
+ Math.log(n)
201
+ },
202
+ "exp": ->(n) {
203
+ return if n.nil?
204
+ Math.exp(n)
205
+ },
206
+ "odd": ->(n) {
207
+ return if n.nil?
208
+ n.odd?
209
+ },
210
+ "even": ->(n) {
211
+ return if n.nil?
212
+ n.even?
213
+ },
214
+ "random number": ->(n) {
215
+ return if n.nil?
216
+ rand(n)
217
+ },
218
+ # List functions
219
+ "list contains": ->(list, match) {
220
+ return if list.nil?
221
+ return false if match.nil?
222
+ list.include?(match)
223
+ },
224
+ "count": ->(list) {
225
+ return if list.nil?
226
+ return 0 if list.empty?
227
+ list.length
228
+ },
229
+ "min": ->(list) {
230
+ return if list.nil?
231
+ list.min
232
+ },
233
+ "max": ->(list) {
234
+ return if list.nil?
235
+ list.max
236
+ },
237
+ "sum": ->(list) {
238
+ return if list.nil?
239
+ list.sum
240
+ },
241
+ "product": ->(list) {
242
+ return if list.nil?
243
+ list.inject(:*)
244
+ },
245
+ "mean": ->(list) {
246
+ return if list.nil?
247
+ list.sum / list.length
248
+ },
249
+ "median": ->(list) {
250
+ return if list.nil?
251
+ list.sort[list.length / 2]
252
+ },
253
+ "stddev": ->(list) {
254
+ return if list.nil?
255
+ mean = list.sum / list.length.to_f
256
+ Math.sqrt(list.map { |n| (n - mean)**2 }.sum / list.length)
257
+ },
258
+ "mode": ->(list) {
259
+ return if list.nil?
260
+ list.group_by(&:itself).values.max_by(&:size).first
261
+ },
262
+ "all": ->(list) {
263
+ return if list.nil?
264
+ list.all?
265
+ },
266
+ "any": ->(list) {
267
+ return if list.nil?
268
+ list.any?
269
+ },
270
+ "sublist": ->(list, start, length) {
271
+ return if list.nil? || start.nil?
272
+ return [] if length.nil?
273
+ list[start - 1, length]
274
+ },
275
+ "append": ->(list, item) {
276
+ return if list.nil?
277
+ list + [item]
278
+ },
279
+ "concatenate": ->(list1, list2) {
280
+ return [nil, nil] if list1.nil? && list2.nil?
281
+ return [nil] + list2 if list1.nil?
282
+ return list1 + [nil] if list2.nil?
283
+ Array.wrap(list1) + Array.wrap(list2)
284
+ },
285
+ "insert before": ->(list, position, item) {
286
+ return if list.nil? || position.nil?
287
+ list.insert(position - 1, item)
288
+ },
289
+ "remove": ->(list, position) {
290
+ return if list.nil? || position.nil?
291
+ list.delete_at(position - 1); list
292
+ },
293
+ "reverse": ->(list) {
294
+ return if list.nil?
295
+ list.reverse
296
+ },
297
+ "index of": ->(list, match) {
298
+ return if list.nil?
299
+ return [] if match.nil?
300
+ list.index(match) + 1
301
+ },
302
+ "union": ->(list1, list2) {
303
+ return if list1.nil? || list2.nil?
304
+ list1 | list2
305
+ },
306
+ "distinct values": ->(list) {
307
+ return if list.nil?
308
+ list.uniq
309
+ },
310
+ "duplicate values": ->(list) {
311
+ return if list.nil?
312
+ list.select { |e| list.count(e) > 1 }.uniq
313
+ },
314
+ "flatten": ->(list) {
315
+ return if list.nil?
316
+ list.flatten
317
+ },
318
+ "sort": ->(list) {
319
+ return if list.nil?
320
+ list.sort
321
+ },
322
+ "string join": ->(list, separator) {
323
+ return if list.nil?
324
+ list.join(separator)
325
+ },
326
+ # Context functions
327
+ "get value": ->(context, name) {
328
+ return if context.nil? || name.nil?
329
+ context[name]
330
+ },
331
+ "context put": ->(context, name, value) {
332
+ return if context.nil? || name.nil?
333
+ context[name] = value; context
334
+ },
335
+ "context merge": ->(context1, context2) {
336
+ return if context1.nil? || context2.nil?
337
+ context1.merge(context2)
338
+ },
339
+ "get entries": ->(context) {
340
+ return if context.nil?
341
+ context.entries
342
+ },
343
+ # Temporal functions
344
+ "now": ->() { Time.now },
345
+ "today": ->() { Date.today },
346
+ "day of week": ->(date) {
347
+ return if date.nil?
348
+ date.wday
349
+ },
350
+ "day of year": ->(date) {
351
+ return if date.nil?
352
+ date.yday
353
+ },
354
+ "week of year": ->(date) {
355
+ return if date.nil?
356
+ date.cweek
357
+ },
358
+ "month of year": ->(date) {
359
+ return if date.nil?
360
+ date.month
361
+ },
362
+ })
363
+ end
364
+
365
+ def as_json
366
+ {
367
+ id: id,
368
+ text: text,
369
+ }
370
+ end
371
+ end
372
+ end