decision_agent 0.2.0 → 0.3.0
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 +4 -4
- data/README.md +41 -1
- data/bin/decision_agent +104 -0
- data/lib/decision_agent/dmn/adapter.rb +135 -0
- data/lib/decision_agent/dmn/cache.rb +306 -0
- data/lib/decision_agent/dmn/decision_graph.rb +327 -0
- data/lib/decision_agent/dmn/decision_tree.rb +192 -0
- data/lib/decision_agent/dmn/errors.rb +30 -0
- data/lib/decision_agent/dmn/exporter.rb +217 -0
- data/lib/decision_agent/dmn/feel/evaluator.rb +797 -0
- data/lib/decision_agent/dmn/feel/functions.rb +420 -0
- data/lib/decision_agent/dmn/feel/parser.rb +349 -0
- data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
- data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
- data/lib/decision_agent/dmn/feel/types.rb +276 -0
- data/lib/decision_agent/dmn/importer.rb +77 -0
- data/lib/decision_agent/dmn/model.rb +197 -0
- data/lib/decision_agent/dmn/parser.rb +191 -0
- data/lib/decision_agent/dmn/testing.rb +333 -0
- data/lib/decision_agent/dmn/validator.rb +315 -0
- data/lib/decision_agent/dmn/versioning.rb +229 -0
- data/lib/decision_agent/dmn/visualizer.rb +513 -0
- data/lib/decision_agent/dsl/condition_evaluator.rb +3 -0
- data/lib/decision_agent/dsl/schema_validator.rb +2 -1
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +221 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/web/dmn_editor.rb +426 -0
- data/lib/decision_agent/web/public/dmn-editor.css +596 -0
- data/lib/decision_agent/web/public/dmn-editor.html +250 -0
- data/lib/decision_agent/web/public/dmn-editor.js +553 -0
- data/lib/decision_agent/web/public/index.html +3 -0
- data/lib/decision_agent/web/public/styles.css +21 -0
- data/lib/decision_agent/web/server.rb +465 -0
- data/spec/ab_testing/ab_testing_agent_spec.rb +174 -0
- data/spec/auth/rbac_adapter_spec.rb +228 -0
- data/spec/dmn/decision_graph_spec.rb +282 -0
- data/spec/dmn/decision_tree_spec.rb +203 -0
- data/spec/dmn/feel/errors_spec.rb +18 -0
- data/spec/dmn/feel/functions_spec.rb +400 -0
- data/spec/dmn/feel/simple_parser_spec.rb +274 -0
- data/spec/dmn/feel/types_spec.rb +176 -0
- data/spec/dmn/feel_parser_spec.rb +489 -0
- data/spec/dmn/hit_policy_spec.rb +202 -0
- data/spec/dmn/integration_spec.rb +226 -0
- data/spec/examples.txt +1846 -1570
- data/spec/fixtures/dmn/complex_decision.dmn +81 -0
- data/spec/fixtures/dmn/invalid_structure.dmn +31 -0
- data/spec/fixtures/dmn/simple_decision.dmn +40 -0
- data/spec/monitoring/metrics_collector_spec.rb +37 -35
- data/spec/monitoring/monitored_agent_spec.rb +14 -11
- data/spec/performance_optimizations_spec.rb +10 -3
- data/spec/thread_safety_spec.rb +10 -2
- data/spec/web_ui_rack_spec.rb +294 -0
- metadata +65 -1
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
require "parslet"
|
|
2
|
+
require_relative "../errors"
|
|
3
|
+
|
|
4
|
+
module DecisionAgent
|
|
5
|
+
module Dmn
|
|
6
|
+
module Feel
|
|
7
|
+
# Transforms Parslet parse tree into AST
|
|
8
|
+
class Transformer < Parslet::Transform
|
|
9
|
+
# Literals
|
|
10
|
+
rule(null: simple(:_)) { { type: :null, value: nil } }
|
|
11
|
+
|
|
12
|
+
rule(boolean: simple(:val)) do
|
|
13
|
+
{ type: :boolean, value: val.to_s == "true" }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
rule(number: simple(:val)) do
|
|
17
|
+
str = val.to_s
|
|
18
|
+
value = str.include?(".") ? str.to_f : str.to_i
|
|
19
|
+
{ type: :number, value: value }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
rule(string: simple(:val)) do
|
|
23
|
+
{ type: :string, value: val.to_s }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Argument wrapper (unwrap the arg node to get the inner expression)
|
|
27
|
+
rule(arg: subtree(:expr)) do
|
|
28
|
+
expr
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# List literal
|
|
32
|
+
rule(list_literal: { list: subtree(:items) }) do
|
|
33
|
+
items_array = case items
|
|
34
|
+
when Array then items
|
|
35
|
+
when Hash then [items]
|
|
36
|
+
when nil then []
|
|
37
|
+
else [items]
|
|
38
|
+
end
|
|
39
|
+
{ type: :list_literal, elements: items_array }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Context entry (unwrap the entry wrapper)
|
|
43
|
+
rule(entry: { key: subtree(:k), value: subtree(:v) }) do
|
|
44
|
+
{ key: k, value: v }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Context literal
|
|
48
|
+
rule(context_literal: { context: subtree(:entries) }) do
|
|
49
|
+
entries_array = case entries
|
|
50
|
+
when Array then entries
|
|
51
|
+
when Hash then [entries]
|
|
52
|
+
when nil then []
|
|
53
|
+
else [entries]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
pairs = entries_array.map do |entry|
|
|
57
|
+
# Extract key - could be a transformed field node, string node, or raw value
|
|
58
|
+
key = if entry[:key].is_a?(Hash)
|
|
59
|
+
# Key is a structured node
|
|
60
|
+
case entry[:key][:type]
|
|
61
|
+
when :field
|
|
62
|
+
entry[:key][:name].to_s
|
|
63
|
+
when :string
|
|
64
|
+
entry[:key][:value].to_s
|
|
65
|
+
when :identifier
|
|
66
|
+
entry[:key][:name].to_s
|
|
67
|
+
else
|
|
68
|
+
entry[:key][:identifier]&.to_s || entry[:key][:string]&.to_s || entry[:key].to_s
|
|
69
|
+
end
|
|
70
|
+
elsif entry[:key].is_a?(Parslet::Slice)
|
|
71
|
+
entry[:key].to_s
|
|
72
|
+
else
|
|
73
|
+
entry[:key].to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
[key, entry[:value]]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
{ type: :context_literal, pairs: pairs }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Range literal
|
|
83
|
+
rule(range: {
|
|
84
|
+
start_bracket: simple(:sb),
|
|
85
|
+
start: subtree(:s),
|
|
86
|
+
end: subtree(:e),
|
|
87
|
+
end_bracket: simple(:eb)
|
|
88
|
+
}) do
|
|
89
|
+
{
|
|
90
|
+
type: :range,
|
|
91
|
+
start: s,
|
|
92
|
+
end: e,
|
|
93
|
+
start_inclusive: sb.to_s == "[",
|
|
94
|
+
end_inclusive: eb.to_s == "]"
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Identifier
|
|
99
|
+
rule(identifier: simple(:name)) do
|
|
100
|
+
{ type: :field, name: name.to_s.strip }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Identifier or function call (with arguments)
|
|
104
|
+
rule(identifier_or_call: { name: subtree(:name), arguments: subtree(:args) }) do
|
|
105
|
+
# It's a function call
|
|
106
|
+
args_array = case args
|
|
107
|
+
when Array then args
|
|
108
|
+
when Hash then args.empty? ? [] : [args]
|
|
109
|
+
when nil then []
|
|
110
|
+
else [args]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
func_name = case name
|
|
114
|
+
when Hash
|
|
115
|
+
# Handle transformed field nodes or raw identifier hashes
|
|
116
|
+
if name[:type] == :field
|
|
117
|
+
name[:name].to_s.strip
|
|
118
|
+
else
|
|
119
|
+
name[:identifier]&.to_s&.strip || name.to_s
|
|
120
|
+
end
|
|
121
|
+
else
|
|
122
|
+
name.to_s.strip
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
{
|
|
126
|
+
type: :function_call,
|
|
127
|
+
name: func_name,
|
|
128
|
+
arguments: args_array
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Identifier or function call (just identifier, no arguments)
|
|
133
|
+
rule(identifier_or_call: { name: subtree(:name) }) do
|
|
134
|
+
# Just an identifier
|
|
135
|
+
field_name = case name
|
|
136
|
+
when Hash
|
|
137
|
+
name[:identifier]&.to_s&.strip || name[:type] == :field ? name[:name] : name.to_s
|
|
138
|
+
else
|
|
139
|
+
name.to_s.strip
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
{ type: :field, name: field_name }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Comparison operations
|
|
146
|
+
rule(comparison: { left: subtree(:l), op: simple(:o), right: subtree(:r) }) do
|
|
147
|
+
{
|
|
148
|
+
type: :comparison,
|
|
149
|
+
operator: o.to_s,
|
|
150
|
+
left: l,
|
|
151
|
+
right: r
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Between expression
|
|
156
|
+
rule(between: { value: subtree(:val), min: subtree(:min), max: subtree(:max) }) do
|
|
157
|
+
{
|
|
158
|
+
type: :between,
|
|
159
|
+
value: val,
|
|
160
|
+
min: min,
|
|
161
|
+
max: max
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# In expression
|
|
166
|
+
rule(in: { value: subtree(:val), list: subtree(:list) }) do
|
|
167
|
+
{
|
|
168
|
+
type: :in,
|
|
169
|
+
value: val,
|
|
170
|
+
list: list
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Instance of
|
|
175
|
+
rule(instance_of: { value: subtree(:val), type: simple(:t) }) do
|
|
176
|
+
{
|
|
177
|
+
type: :instance_of,
|
|
178
|
+
value: val,
|
|
179
|
+
type_name: t.to_s
|
|
180
|
+
}
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Arithmetic operations
|
|
184
|
+
rule(arithmetic: { left: subtree(:l), op: simple(:o), right: subtree(:r) }) do
|
|
185
|
+
{
|
|
186
|
+
type: :arithmetic,
|
|
187
|
+
operator: o.to_s,
|
|
188
|
+
left: l,
|
|
189
|
+
right: r
|
|
190
|
+
}
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
rule(term: { left: subtree(:l), op: simple(:o), right: subtree(:r) }) do
|
|
194
|
+
{
|
|
195
|
+
type: :arithmetic,
|
|
196
|
+
operator: o.to_s,
|
|
197
|
+
left: l,
|
|
198
|
+
right: r
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
rule(exponentiation: { left: subtree(:l), op: simple(:o), right: subtree(:r) }) do
|
|
203
|
+
{
|
|
204
|
+
type: :arithmetic,
|
|
205
|
+
operator: o.to_s,
|
|
206
|
+
left: l,
|
|
207
|
+
right: r
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Unary operations
|
|
212
|
+
rule(unary: { op: simple(:o), operand: subtree(:operand) }) do
|
|
213
|
+
if o.to_s == "not"
|
|
214
|
+
{
|
|
215
|
+
type: :logical,
|
|
216
|
+
operator: "not",
|
|
217
|
+
operand: operand
|
|
218
|
+
}
|
|
219
|
+
elsif o.to_s == "-" && operand.is_a?(Hash) && operand[:type] == :number
|
|
220
|
+
# Special case: unary minus on a number literal -> negative number literal
|
|
221
|
+
{
|
|
222
|
+
type: :number,
|
|
223
|
+
value: -operand[:value]
|
|
224
|
+
}
|
|
225
|
+
else
|
|
226
|
+
{
|
|
227
|
+
type: :arithmetic,
|
|
228
|
+
operator: "negate",
|
|
229
|
+
operand: operand
|
|
230
|
+
}
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Logical operations
|
|
235
|
+
rule(or: { left: subtree(:l), or_ops: subtree(:ops) }) do
|
|
236
|
+
ops_array = Array(ops)
|
|
237
|
+
# Build nested or structure
|
|
238
|
+
ops_array.reduce(l) do |left_side, op|
|
|
239
|
+
{
|
|
240
|
+
type: :logical,
|
|
241
|
+
operator: "or",
|
|
242
|
+
left: left_side,
|
|
243
|
+
right: op[:right]
|
|
244
|
+
}
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
rule(and: { left: subtree(:l), and_ops: subtree(:ops) }) do
|
|
249
|
+
ops_array = Array(ops)
|
|
250
|
+
# Build nested and structure
|
|
251
|
+
ops_array.reduce(l) do |left_side, op|
|
|
252
|
+
{
|
|
253
|
+
type: :logical,
|
|
254
|
+
operator: "and",
|
|
255
|
+
left: left_side,
|
|
256
|
+
right: op[:right]
|
|
257
|
+
}
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Postfix operations (property access, function calls, filters)
|
|
262
|
+
rule(postfix: { base: subtree(:base), postfix_ops: subtree(:ops) }) do
|
|
263
|
+
ops_array = Array(ops)
|
|
264
|
+
ops_array.reduce(base) do |current, op|
|
|
265
|
+
case op
|
|
266
|
+
when Hash
|
|
267
|
+
if op[:property_access]
|
|
268
|
+
{
|
|
269
|
+
type: :property_access,
|
|
270
|
+
object: current,
|
|
271
|
+
property: op[:property_access][:property][:identifier].to_s
|
|
272
|
+
}
|
|
273
|
+
elsif op[:function_call]
|
|
274
|
+
{
|
|
275
|
+
type: :function_call,
|
|
276
|
+
name: current,
|
|
277
|
+
arguments: op[:function_call][:arguments] || []
|
|
278
|
+
}
|
|
279
|
+
elsif op[:filter]
|
|
280
|
+
{
|
|
281
|
+
type: :filter,
|
|
282
|
+
list: current,
|
|
283
|
+
condition: op[:filter][:filter]
|
|
284
|
+
}
|
|
285
|
+
else
|
|
286
|
+
current
|
|
287
|
+
end
|
|
288
|
+
else
|
|
289
|
+
current
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# If-then-else conditional
|
|
295
|
+
rule(condition: subtree(:c), then_expr: subtree(:t), else_expr: subtree(:e)) do
|
|
296
|
+
{
|
|
297
|
+
type: :conditional,
|
|
298
|
+
condition: c,
|
|
299
|
+
then_expr: t,
|
|
300
|
+
else_expr: e
|
|
301
|
+
}
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Quantified expressions
|
|
305
|
+
rule(quantifier: simple(:q), var: subtree(:v), list: subtree(:l), condition: subtree(:c)) do
|
|
306
|
+
# Variable might be already transformed to a field node or still be an identifier hash
|
|
307
|
+
var_name = if v.is_a?(Hash) && v[:type] == :field
|
|
308
|
+
v[:name]
|
|
309
|
+
elsif v.is_a?(Hash) && v[:identifier]
|
|
310
|
+
v[:identifier].to_s
|
|
311
|
+
else
|
|
312
|
+
v.to_s
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
{
|
|
316
|
+
type: :quantified,
|
|
317
|
+
quantifier: q.to_s,
|
|
318
|
+
variable: var_name,
|
|
319
|
+
list: l,
|
|
320
|
+
condition: c
|
|
321
|
+
}
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# For expression
|
|
325
|
+
rule(var: subtree(:v), list: subtree(:l), return_expr: subtree(:r)) do
|
|
326
|
+
# Variable might be already transformed to a field node or still be an identifier hash
|
|
327
|
+
var_name = if v.is_a?(Hash) && v[:type] == :field
|
|
328
|
+
v[:name]
|
|
329
|
+
elsif v.is_a?(Hash) && v[:identifier]
|
|
330
|
+
v[:identifier].to_s
|
|
331
|
+
else
|
|
332
|
+
v.to_s
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
{
|
|
336
|
+
type: :for,
|
|
337
|
+
variable: var_name,
|
|
338
|
+
list: l,
|
|
339
|
+
return_expr: r
|
|
340
|
+
}
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Function definition
|
|
344
|
+
rule(function_def: { params: subtree(:params), body: subtree(:body) }) do
|
|
345
|
+
params_array = case params
|
|
346
|
+
when Array then params.map { |p| p[:param][:identifier].to_s }
|
|
347
|
+
when Hash then [params[:param][:identifier].to_s]
|
|
348
|
+
when nil then []
|
|
349
|
+
else []
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
{
|
|
353
|
+
type: :function_definition,
|
|
354
|
+
parameters: params_array,
|
|
355
|
+
body: body
|
|
356
|
+
}
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Helper to convert parse tree to AST
|
|
360
|
+
def self.to_ast(parse_tree)
|
|
361
|
+
new.apply(parse_tree)
|
|
362
|
+
rescue StandardError => e
|
|
363
|
+
raise FeelTransformError.new(
|
|
364
|
+
"Failed to transform parse tree to AST: #{e.message}",
|
|
365
|
+
parse_tree: parse_tree,
|
|
366
|
+
error: e
|
|
367
|
+
)
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
require "date"
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
|
|
6
|
+
module DecisionAgent
|
|
7
|
+
module Dmn
|
|
8
|
+
module Feel
|
|
9
|
+
# Type system for FEEL values
|
|
10
|
+
# Provides thin wrappers around Ruby types to maintain FEEL semantics
|
|
11
|
+
module Types
|
|
12
|
+
# Wrapper for numbers with precision tracking
|
|
13
|
+
class Number
|
|
14
|
+
attr_reader :value, :scale
|
|
15
|
+
|
|
16
|
+
def initialize(value, scale: nil)
|
|
17
|
+
@value = case value
|
|
18
|
+
when BigDecimal then value
|
|
19
|
+
when String then BigDecimal(value)
|
|
20
|
+
when Integer, Float then value
|
|
21
|
+
else
|
|
22
|
+
raise FeelTypeError, "Cannot convert #{value.class} to Number: #{value.inspect}"
|
|
23
|
+
end
|
|
24
|
+
@scale = scale
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_ruby
|
|
28
|
+
@value
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_i
|
|
32
|
+
@value.to_i
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_f
|
|
36
|
+
@value.to_f
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ==(other)
|
|
40
|
+
return @value == other.value if other.is_a?(Number)
|
|
41
|
+
|
|
42
|
+
@value == other
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def inspect
|
|
46
|
+
"#<Feel::Number #{@value}#{" (scale: #{@scale})" if @scale}>"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Wrapper for dates (ISO 8601)
|
|
51
|
+
class Date
|
|
52
|
+
attr_reader :value
|
|
53
|
+
|
|
54
|
+
def initialize(value)
|
|
55
|
+
@value = case value
|
|
56
|
+
when ::Time, ::Date, ::DateTime then value
|
|
57
|
+
when String then parse_date(value)
|
|
58
|
+
else
|
|
59
|
+
raise FeelTypeError, "Cannot convert #{value.class} to Date: #{value.inspect}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def to_ruby
|
|
64
|
+
@value
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def ==(other)
|
|
68
|
+
return @value == other.value if other.is_a?(Date)
|
|
69
|
+
|
|
70
|
+
@value == other
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def inspect
|
|
74
|
+
"#<Feel::Date #{@value}>"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def parse_date(str)
|
|
80
|
+
# Try ISO 8601 formats first
|
|
81
|
+
iso_result = begin
|
|
82
|
+
::Time.iso8601(str)
|
|
83
|
+
rescue ArgumentError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
return iso_result if iso_result
|
|
88
|
+
|
|
89
|
+
# Fall back to Date parsing
|
|
90
|
+
::Date.parse(str).to_time
|
|
91
|
+
rescue ::Date::Error => e
|
|
92
|
+
raise FeelTypeError, "Invalid date format: #{e.message}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Wrapper for time values
|
|
97
|
+
class Time
|
|
98
|
+
attr_reader :value
|
|
99
|
+
|
|
100
|
+
def initialize(value)
|
|
101
|
+
@value = case value
|
|
102
|
+
when ::Time then value
|
|
103
|
+
when String then parse_time(value)
|
|
104
|
+
else
|
|
105
|
+
raise FeelTypeError.new(
|
|
106
|
+
"Cannot convert to Time",
|
|
107
|
+
expected_type: "Time",
|
|
108
|
+
actual_type: value.class,
|
|
109
|
+
value: value
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def to_ruby
|
|
115
|
+
@value
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def ==(other)
|
|
119
|
+
return @value == other.value if other.is_a?(Time)
|
|
120
|
+
|
|
121
|
+
@value == other
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def inspect
|
|
125
|
+
"#<Feel::Time #{@value}>"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def parse_time(str)
|
|
131
|
+
# Try ISO 8601 time parsing
|
|
132
|
+
::Time.iso8601(str)
|
|
133
|
+
rescue ArgumentError => e
|
|
134
|
+
raise FeelTypeError.new(
|
|
135
|
+
"Invalid time format: #{e.message}",
|
|
136
|
+
expected_type: "ISO 8601 time",
|
|
137
|
+
value: str
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Wrapper for durations (ISO 8601: P1Y2M3DT4H5M6S)
|
|
143
|
+
class Duration
|
|
144
|
+
attr_reader :years, :months, :days, :hours, :minutes, :seconds
|
|
145
|
+
|
|
146
|
+
def initialize(years: 0, months: 0, days: 0, hours: 0, minutes: 0, seconds: 0)
|
|
147
|
+
@years = years
|
|
148
|
+
@months = months
|
|
149
|
+
@days = days
|
|
150
|
+
@hours = hours
|
|
151
|
+
@minutes = minutes
|
|
152
|
+
@seconds = seconds
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Parse ISO 8601 duration string
|
|
156
|
+
# Examples: P1Y, P1M, P1D, PT1H, PT1M, PT1S, P1Y2M3DT4H5M6S
|
|
157
|
+
def self.parse(iso_string)
|
|
158
|
+
raise FeelTypeError, "Duration must be a string, got #{iso_string.class}" unless iso_string.is_a?(String)
|
|
159
|
+
raise FeelTypeError, "Invalid duration format: must start with 'P'" unless iso_string.start_with?("P")
|
|
160
|
+
|
|
161
|
+
# Split on 'T' to separate date and time parts
|
|
162
|
+
parts = iso_string[1..].split("T")
|
|
163
|
+
date_part = parts[0] || ""
|
|
164
|
+
time_part = parts[1] || ""
|
|
165
|
+
|
|
166
|
+
duration_attrs = {}
|
|
167
|
+
|
|
168
|
+
# Parse date part (Y, M, D)
|
|
169
|
+
duration_attrs[:years] = extract_unit(date_part, "Y")
|
|
170
|
+
duration_attrs[:months] = extract_unit(date_part, "M")
|
|
171
|
+
duration_attrs[:days] = extract_unit(date_part, "D")
|
|
172
|
+
|
|
173
|
+
# Parse time part (H, M, S)
|
|
174
|
+
duration_attrs[:hours] = extract_unit(time_part, "H")
|
|
175
|
+
duration_attrs[:minutes] = extract_unit(time_part, "M")
|
|
176
|
+
duration_attrs[:seconds] = extract_unit(time_part, "S")
|
|
177
|
+
|
|
178
|
+
new(**duration_attrs)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Convert to total seconds (approximation for date parts)
|
|
182
|
+
def to_seconds
|
|
183
|
+
total = @seconds
|
|
184
|
+
total += @minutes * 60
|
|
185
|
+
total += @hours * 3600
|
|
186
|
+
total += @days * 86_400
|
|
187
|
+
total += @months * 2_592_000 # 30 days average
|
|
188
|
+
total += @years * 31_536_000 # 365 days
|
|
189
|
+
total
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def to_ruby
|
|
193
|
+
to_seconds
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def ==(other)
|
|
197
|
+
return false unless other.is_a?(Duration)
|
|
198
|
+
|
|
199
|
+
@years == other.years &&
|
|
200
|
+
@months == other.months &&
|
|
201
|
+
@days == other.days &&
|
|
202
|
+
@hours == other.hours &&
|
|
203
|
+
@minutes == other.minutes &&
|
|
204
|
+
@seconds == other.seconds
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def inspect
|
|
208
|
+
"#<Feel::Duration P#{@years}Y#{@months}M#{@days}DT#{@hours}H#{@minutes}M#{@seconds}S>"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def self.extract_unit(str, unit)
|
|
212
|
+
match = str.match(/(\d+(?:\.\d+)?)#{unit}/)
|
|
213
|
+
match ? match[1].to_f.to_i : 0
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Wrapper for lists (adds FEEL semantics to Ruby Array)
|
|
218
|
+
class List < Array
|
|
219
|
+
def initialize(array = [])
|
|
220
|
+
super
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def to_ruby
|
|
224
|
+
to_a
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def inspect
|
|
228
|
+
"#<Feel::List #{to_a.inspect}>"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Wrapper for contexts (FEEL key-value maps)
|
|
233
|
+
class Context < Hash
|
|
234
|
+
def initialize(hash = {})
|
|
235
|
+
super()
|
|
236
|
+
hash.each { |k, v| self[k.to_sym] = v }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def to_ruby
|
|
240
|
+
to_h
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def inspect
|
|
244
|
+
"#<Feel::Context #{to_h.inspect}>"
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Type conversion utilities
|
|
249
|
+
module Converter
|
|
250
|
+
def self.to_feel_type(value)
|
|
251
|
+
case value
|
|
252
|
+
when Integer, Float, BigDecimal
|
|
253
|
+
Number.new(value)
|
|
254
|
+
when ::Time, ::DateTime
|
|
255
|
+
Time.new(value)
|
|
256
|
+
when ::Date
|
|
257
|
+
Date.new(value)
|
|
258
|
+
when Array
|
|
259
|
+
List.new(value)
|
|
260
|
+
when Hash
|
|
261
|
+
Context.new(value)
|
|
262
|
+
else
|
|
263
|
+
value # Return as-is for strings, booleans, etc.
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def self.to_ruby(value)
|
|
268
|
+
return value.to_ruby if value.respond_to?(:to_ruby)
|
|
269
|
+
|
|
270
|
+
value
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|