dmn 0.0.1 → 0.0.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b9993953a743d1305667dd56353fa828ff9e3bf3cfecd1f1fffb94dd7eb1842
4
- data.tar.gz: a7a498cf1098796664b33dc5cac84969ddacc6df8a2b418032befcdc51ea5eb2
3
+ metadata.gz: 83ab9c509d78447fc0dcf11d993e79c9105baf97904af53e932e16ae7eaee2df
4
+ data.tar.gz: be469b68cdeab12f0ffee64e6a1c884ee9b67d10e2d9b899f1f3ecfa7905a975
5
5
  SHA512:
6
- metadata.gz: fc9de27ca64c128d7d6401792fbdf8a61ce97e6eef64c936e7c1e7bab74214fa109bcdb3f07fcc9f63aeeceddf74490e4ea6ca61d54061c46e6576e322ececef
7
- data.tar.gz: 88913ed1996eeaf913e366368d180d6cb7e3b941bfaa7366b8d387b2ccc6213677471797cd5ffc02f9c15959cae80798b1a6f0cf3d16662e03b3608b020e8f2b
6
+ metadata.gz: a5b1b6a24a4c6d2f305d41e94ac83bcb951f1dbfe8fc4ddccc6d5675ee5f3af70529778f288db5c7dea989a5ef7af685250ad6b7abd5dea7b80bef1afe20d019
7
+ data.tar.gz: 62e2cecb612379245854e09684e1632ae6fb670d0618577685d00e92c944aaf44c9e8a5e59173372280a5887879a190603bd1ec47e07f34bebb9e533f91d9a3b
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