odin-foundation 1.1.0 → 1.2.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/lib/odin/transform/transform_engine.rb +241 -101
- data/lib/odin/transform/transform_expr.rb +222 -0
- data/lib/odin/transform/transform_parser.rb +44 -3
- data/lib/odin/transform/verbs/collection_verbs.rb +54 -34
- data/lib/odin/transform/verbs/datetime_verbs.rb +20 -3
- data/lib/odin/transform/verbs/extra_verbs.rb +613 -0
- data/lib/odin/transform/verbs/financial_verbs.rb +69 -5
- data/lib/odin/transform/verbs/geo_verbs.rb +7 -0
- data/lib/odin/transform/verbs/numeric_verbs.rb +80 -57
- data/lib/odin/transform/verbs/object_verbs.rb +17 -10
- data/lib/odin/version.rb +1 -1
- data/lib/odin.rb +2 -0
- metadata +4 -2
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Odin
|
|
4
|
+
module Transform
|
|
5
|
+
# Compiles an infix arithmetic formula string into a tree of existing verbs
|
|
6
|
+
# at parse time. No runtime evaluator: the result is an ordinary verb
|
|
7
|
+
# expression, so arithmetic runs through the deterministic verbs (add,
|
|
8
|
+
# subtract, multiply, divide, mod, negate, pow, and a whitelist of numeric
|
|
9
|
+
# functions). Variables resolve under an explicit bindings object passed as
|
|
10
|
+
# the second argument: in %expr "a + b" @.vars, the name a reads @.vars.a.
|
|
11
|
+
#
|
|
12
|
+
# Precedence, high to low:
|
|
13
|
+
# 1. parentheses, function call
|
|
14
|
+
# 2. ^ power (right-associative)
|
|
15
|
+
# 3. unary - / + (looser than ^, so -2^2 = -(2^2) = -4; (-2)^2 = 4)
|
|
16
|
+
# 4. * / % (left-associative)
|
|
17
|
+
# 5. + - (left-associative)
|
|
18
|
+
module Expr
|
|
19
|
+
class ExprSyntaxError < StandardError
|
|
20
|
+
attr_reader :code
|
|
21
|
+
|
|
22
|
+
def initialize(message)
|
|
23
|
+
@code = "T015"
|
|
24
|
+
super("Invalid %expr formula: #{message}")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
BINARY_OP = { "+" => "add", "-" => "subtract", "*" => "multiply", "/" => "divide", "%" => "mod" }.freeze
|
|
29
|
+
|
|
30
|
+
FUNCTIONS = {
|
|
31
|
+
"abs" => { verb: "abs", min: 1, max: 1 },
|
|
32
|
+
"floor" => { verb: "floor", min: 1, max: 1 },
|
|
33
|
+
"ceil" => { verb: "ceil", min: 1, max: 1 },
|
|
34
|
+
"trunc" => { verb: "trunc", min: 1, max: 1 },
|
|
35
|
+
"sqrt" => { verb: "sqrt", min: 1, max: 1 },
|
|
36
|
+
"round" => { verb: "round", min: 1, max: 2 },
|
|
37
|
+
"pow" => { verb: "pow", min: 2, max: 2 },
|
|
38
|
+
"min" => { verb: "minOf", min: 1, max: Float::INFINITY },
|
|
39
|
+
"max" => { verb: "maxOf", min: 1, max: Float::INFINITY }
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
Token = Struct.new(:kind, :value, :is_float)
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
def compile(formula, binding_path = nil)
|
|
47
|
+
Parser.new(tokenize(formula), binding_path).parse
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def tokenize(src)
|
|
51
|
+
tokens = []
|
|
52
|
+
i = 0
|
|
53
|
+
len = src.length
|
|
54
|
+
while i < len
|
|
55
|
+
c = src[i]
|
|
56
|
+
if c =~ /\s/
|
|
57
|
+
i += 1
|
|
58
|
+
next
|
|
59
|
+
end
|
|
60
|
+
if c =~ /[0-9]/
|
|
61
|
+
j = i
|
|
62
|
+
is_float = false
|
|
63
|
+
j += 1 while j < len && src[j] =~ /[0-9]/
|
|
64
|
+
if src[j] == "."
|
|
65
|
+
is_float = true
|
|
66
|
+
j += 1
|
|
67
|
+
j += 1 while j < len && src[j] =~ /[0-9]/
|
|
68
|
+
end
|
|
69
|
+
if src[j] == "e" || src[j] == "E"
|
|
70
|
+
is_float = true
|
|
71
|
+
j += 1
|
|
72
|
+
j += 1 if src[j] == "+" || src[j] == "-"
|
|
73
|
+
j += 1 while j < len && src[j] =~ /[0-9]/
|
|
74
|
+
end
|
|
75
|
+
tokens << Token.new(:num, src[i...j], is_float)
|
|
76
|
+
i = j
|
|
77
|
+
next
|
|
78
|
+
end
|
|
79
|
+
if c =~ /[A-Za-z_]/
|
|
80
|
+
j = i
|
|
81
|
+
j += 1 while j < len && src[j] =~ /[A-Za-z0-9_.]/
|
|
82
|
+
tokens << Token.new(:ident, src[i...j], false)
|
|
83
|
+
i = j
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
case c
|
|
87
|
+
when "(" then tokens << Token.new(:lparen, c, false)
|
|
88
|
+
when ")" then tokens << Token.new(:rparen, c, false)
|
|
89
|
+
when "," then tokens << Token.new(:comma, c, false)
|
|
90
|
+
when "+", "-", "*", "/", "%", "^" then tokens << Token.new(:op, c, false)
|
|
91
|
+
else raise ExprSyntaxError, "unexpected character '#{c}'"
|
|
92
|
+
end
|
|
93
|
+
i += 1
|
|
94
|
+
end
|
|
95
|
+
tokens
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def literal(text, is_float)
|
|
99
|
+
value = is_float ? Types::DynValue.of_float(text.to_f) : Types::DynValue.of_integer(text.to_i)
|
|
100
|
+
LiteralExpr.new(value)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def verb_node(verb, args)
|
|
104
|
+
VerbExpr.new(verb, args, custom: false)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
class Parser
|
|
108
|
+
def initialize(tokens, binding_path)
|
|
109
|
+
@tokens = tokens
|
|
110
|
+
@binding_path = binding_path
|
|
111
|
+
@pos = 0
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse
|
|
115
|
+
raise ExprSyntaxError, "empty formula" if @tokens.empty?
|
|
116
|
+
expr = parse_additive
|
|
117
|
+
raise ExprSyntaxError, "unexpected token '#{peek.value}'" if @pos < @tokens.length
|
|
118
|
+
expr
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def peek
|
|
124
|
+
@tokens[@pos]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def advance
|
|
128
|
+
t = @tokens[@pos]
|
|
129
|
+
@pos += 1
|
|
130
|
+
t
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def parse_additive
|
|
134
|
+
left = parse_multiplicative
|
|
135
|
+
while peek&.kind == :op && (peek.value == "+" || peek.value == "-")
|
|
136
|
+
op = advance.value
|
|
137
|
+
right = parse_multiplicative
|
|
138
|
+
left = Expr.verb_node(BINARY_OP[op], [left, right])
|
|
139
|
+
end
|
|
140
|
+
left
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def parse_multiplicative
|
|
144
|
+
left = parse_unary
|
|
145
|
+
while peek&.kind == :op && %w[* / %].include?(peek.value)
|
|
146
|
+
op = advance.value
|
|
147
|
+
right = parse_unary
|
|
148
|
+
left = Expr.verb_node(BINARY_OP[op], [left, right])
|
|
149
|
+
end
|
|
150
|
+
left
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def parse_unary
|
|
154
|
+
t = peek
|
|
155
|
+
if t&.kind == :op && (t.value == "-" || t.value == "+")
|
|
156
|
+
advance
|
|
157
|
+
operand = parse_unary
|
|
158
|
+
return t.value == "-" ? Expr.verb_node("negate", [operand]) : operand
|
|
159
|
+
end
|
|
160
|
+
parse_power
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def parse_power
|
|
164
|
+
base = parse_primary
|
|
165
|
+
if peek&.kind == :op && peek.value == "^"
|
|
166
|
+
advance
|
|
167
|
+
exponent = parse_unary
|
|
168
|
+
return Expr.verb_node("pow", [base, exponent])
|
|
169
|
+
end
|
|
170
|
+
base
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def parse_primary
|
|
174
|
+
t = advance
|
|
175
|
+
raise ExprSyntaxError, "unexpected end of formula" if t.nil?
|
|
176
|
+
|
|
177
|
+
case t.kind
|
|
178
|
+
when :num
|
|
179
|
+
Expr.literal(t.value, t.is_float)
|
|
180
|
+
when :lparen
|
|
181
|
+
inner = parse_additive
|
|
182
|
+
close = advance
|
|
183
|
+
raise ExprSyntaxError, "missing closing parenthesis" if close.nil? || close.kind != :rparen
|
|
184
|
+
inner
|
|
185
|
+
when :ident
|
|
186
|
+
return parse_call(t.value) if peek&.kind == :lparen
|
|
187
|
+
if @binding_path.nil?
|
|
188
|
+
raise ExprSyntaxError, "variable '#{t.value}' requires a bindings object, e.g. %expr \"...\" @.vars"
|
|
189
|
+
end
|
|
190
|
+
CopyExpr.new("#{@binding_path}.#{t.value}")
|
|
191
|
+
else
|
|
192
|
+
raise ExprSyntaxError, "unexpected token '#{t.value}'"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def parse_call(name)
|
|
197
|
+
fn = FUNCTIONS[name]
|
|
198
|
+
raise ExprSyntaxError, "unknown function '#{name}'" if fn.nil?
|
|
199
|
+
|
|
200
|
+
advance # consume '('
|
|
201
|
+
args = []
|
|
202
|
+
if peek&.kind != :rparen
|
|
203
|
+
args << parse_additive
|
|
204
|
+
while peek&.kind == :comma
|
|
205
|
+
advance
|
|
206
|
+
args << parse_additive
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
close = advance
|
|
210
|
+
raise ExprSyntaxError, "missing ) after #{name}(" if close.nil? || close.kind != :rparen
|
|
211
|
+
|
|
212
|
+
if args.length < fn[:min] || args.length > fn[:max]
|
|
213
|
+
bound = fn[:min] == fn[:max] ? fn[:min].to_s : "#{fn[:min]}-#{fn[:max]}"
|
|
214
|
+
raise ExprSyntaxError, "#{name}() takes #{bound} arguments, got #{args.length}"
|
|
215
|
+
end
|
|
216
|
+
args << LiteralExpr.new(Types::DynValue.of_integer(0)) if name == "round" && args.length == 1
|
|
217
|
+
Expr.verb_node(fn[:verb], args)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -71,6 +71,20 @@ module Odin
|
|
|
71
71
|
"has" => 2, "merge" => 2, "jsonPath" => 2,
|
|
72
72
|
"assert" => 2,
|
|
73
73
|
"formatPhone" => 2, "movingAvg" => 2, "businessDays" => 2,
|
|
74
|
+
"fromEntries" => 1, "invert" => 1, "defaults" => 2, "renameKeys" => 2,
|
|
75
|
+
"compactObject" => 1,
|
|
76
|
+
"intersection" => 2, "union" => 2, "difference" => 2,
|
|
77
|
+
"symmetricDifference" => 2, "countBy" => 2, "keyBy" => 2,
|
|
78
|
+
"explode" => 2, "window" => 2,
|
|
79
|
+
"base64urlEncode" => 1, "base64urlDecode" => 1, "hmac" => 3,
|
|
80
|
+
"parseUrl" => 1, "buildUrl" => 1, "parseQuery" => 1, "buildQuery" => 1,
|
|
81
|
+
"stableStringify" => 1, "canonicalHash" => 1,
|
|
82
|
+
"escapeHtml" => 1, "unescapeHtml" => 1, "escapeXml" => 1, "stripTags" => 1,
|
|
83
|
+
"template" => 2,
|
|
84
|
+
"gcd" => 2, "lcm" => 2, "factorial" => 1,
|
|
85
|
+
"expr" => 2,
|
|
86
|
+
"countIf" => 4, "sumIf" => 5, "avgIf" => 5,
|
|
87
|
+
"xnpv" => 3, "xirr" => 3,
|
|
74
88
|
|
|
75
89
|
# Arity 3
|
|
76
90
|
"ifElse" => 3, "between" => 3,
|
|
@@ -100,7 +114,7 @@ module Odin
|
|
|
100
114
|
}.freeze
|
|
101
115
|
|
|
102
116
|
VARIADIC_VERBS = %w[
|
|
103
|
-
concat coalesce cond switch lookup lookupDefault minOf maxOf
|
|
117
|
+
concat coalesce cond switch lookup lookupDefault minOf maxOf pick omit
|
|
104
118
|
].freeze
|
|
105
119
|
|
|
106
120
|
ALL_DIRECTIVES = %w[
|
|
@@ -717,8 +731,9 @@ module Odin
|
|
|
717
731
|
when "_literalBody"
|
|
718
732
|
literal_body = raw_val
|
|
719
733
|
else
|
|
720
|
-
|
|
721
|
-
|
|
734
|
+
# An unrecognized `_`-prefixed field is a computation-only sink:
|
|
735
|
+
# it runs for side effects but is not emitted. Recognized loop
|
|
736
|
+
# directives are handled above; these flow through as mappings.
|
|
722
737
|
mapping = parse_field_mapping(key, raw_val)
|
|
723
738
|
field_mappings << mapping
|
|
724
739
|
end
|
|
@@ -1047,6 +1062,11 @@ module Odin
|
|
|
1047
1062
|
verb_name = verb_token[1..]
|
|
1048
1063
|
end
|
|
1049
1064
|
|
|
1065
|
+
# %expr is a parse-time macro: compile the formula string into a verb tree.
|
|
1066
|
+
if verb_name == "expr" && !custom
|
|
1067
|
+
return compile_expr_macro(tokens)
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1050
1070
|
arity = VERB_ARITY[verb_name]
|
|
1051
1071
|
|
|
1052
1072
|
if arity.nil?
|
|
@@ -1070,6 +1090,27 @@ module Odin
|
|
|
1070
1090
|
end
|
|
1071
1091
|
end
|
|
1072
1092
|
|
|
1093
|
+
# Compile a %expr formula from the remaining tokens. arg0 is a quoted
|
|
1094
|
+
# formula string; arg1, if present, is an @-reference bindings object.
|
|
1095
|
+
def compile_expr_macro(tokens)
|
|
1096
|
+
formula_token = tokens.shift
|
|
1097
|
+
unless formula_token&.match?(/\A".*"\z/m)
|
|
1098
|
+
raise Expr::ExprSyntaxError, "expected a quoted formula string"
|
|
1099
|
+
end
|
|
1100
|
+
formula = unescape_string(formula_token[1...-1])
|
|
1101
|
+
|
|
1102
|
+
binding_path = nil
|
|
1103
|
+
if !tokens.empty? && !tokens.first.start_with?(":")
|
|
1104
|
+
ref_token = tokens.shift
|
|
1105
|
+
unless ref_token.start_with?("@")
|
|
1106
|
+
raise Expr::ExprSyntaxError, "the bindings argument must be a reference such as @.vars"
|
|
1107
|
+
end
|
|
1108
|
+
binding_path = ref_token == "@" ? "" : ref_token[1..]
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
[Expr.compile(formula, binding_path), tokens]
|
|
1112
|
+
end
|
|
1113
|
+
|
|
1073
1114
|
EXTRACTION_DIRECTIVES = %w[pos len field trim].freeze
|
|
1074
1115
|
# Directives that take a value argument (not boolean flags)
|
|
1075
1116
|
VALUE_DIRECTIVES = %w[pos len field].freeze
|
|
@@ -7,17 +7,22 @@ module Odin
|
|
|
7
7
|
module_function
|
|
8
8
|
|
|
9
9
|
def extract_items(v)
|
|
10
|
-
|
|
10
|
+
as_items(v) || []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Returns the underlying array items, or nil when v is not array-like.
|
|
14
|
+
def as_items(v)
|
|
15
|
+
return nil if v.nil? || v.null?
|
|
11
16
|
return v.value if v.array?
|
|
12
17
|
if v.string?
|
|
13
18
|
begin
|
|
14
19
|
parsed = Types::DynValue.extract_array(v.value)
|
|
15
|
-
return parsed.value
|
|
20
|
+
return parsed.value if parsed.array?
|
|
16
21
|
rescue
|
|
17
|
-
return
|
|
22
|
+
return nil
|
|
18
23
|
end
|
|
19
24
|
end
|
|
20
|
-
|
|
25
|
+
nil
|
|
21
26
|
end
|
|
22
27
|
|
|
23
28
|
def compare_dyn_values(a, b)
|
|
@@ -222,9 +227,15 @@ module Odin
|
|
|
222
227
|
}
|
|
223
228
|
|
|
224
229
|
registry["every"] = ->(args, _ctx) {
|
|
225
|
-
items = CollectionVerbs.
|
|
230
|
+
items = CollectionVerbs.as_items(args[0])
|
|
231
|
+
return dv.of_null if items.nil?
|
|
226
232
|
return dv.of_bool(true) if items.empty?
|
|
227
|
-
if args.length >=
|
|
233
|
+
if args.length >= 4
|
|
234
|
+
field = args[1]&.to_string || ""
|
|
235
|
+
op = args[2]&.to_string || "="
|
|
236
|
+
compare = args[3]
|
|
237
|
+
dv.of_bool(items.all? { |item| CollectionVerbs.matches_condition?(item, field, op, compare) })
|
|
238
|
+
elsif args.length >= 2
|
|
228
239
|
field = args[1]&.to_string || ""
|
|
229
240
|
dv.of_bool(items.all? { |item| (item.object? ? item.get(field) : item)&.truthy? || false })
|
|
230
241
|
else
|
|
@@ -233,9 +244,15 @@ module Odin
|
|
|
233
244
|
}
|
|
234
245
|
|
|
235
246
|
registry["some"] = ->(args, _ctx) {
|
|
236
|
-
items = CollectionVerbs.
|
|
247
|
+
items = CollectionVerbs.as_items(args[0])
|
|
248
|
+
return dv.of_null if items.nil?
|
|
237
249
|
return dv.of_bool(false) if items.empty?
|
|
238
|
-
if args.length >=
|
|
250
|
+
if args.length >= 4
|
|
251
|
+
field = args[1]&.to_string || ""
|
|
252
|
+
op = args[2]&.to_string || "="
|
|
253
|
+
compare = args[3]
|
|
254
|
+
dv.of_bool(items.any? { |item| CollectionVerbs.matches_condition?(item, field, op, compare) })
|
|
255
|
+
elsif args.length >= 2
|
|
239
256
|
field = args[1]&.to_string || ""
|
|
240
257
|
dv.of_bool(items.any? { |item| (item.object? ? item.get(field) : item)&.truthy? || false })
|
|
241
258
|
else
|
|
@@ -272,24 +289,19 @@ module Odin
|
|
|
272
289
|
}
|
|
273
290
|
|
|
274
291
|
registry["concatArrays"] = ->(args, _ctx) {
|
|
292
|
+
extracted = args.map { |a| CollectionVerbs.as_items(a) }
|
|
293
|
+
return dv.of_null if extracted.all?(&:nil?)
|
|
275
294
|
result = []
|
|
276
|
-
|
|
277
|
-
if a&.array?
|
|
278
|
-
result.concat(a.value)
|
|
279
|
-
elsif !a.nil? && !a.null?
|
|
280
|
-
result << a
|
|
281
|
-
end
|
|
282
|
-
end
|
|
295
|
+
extracted.each { |items| result.concat(items) if items }
|
|
283
296
|
dv.of_array(result)
|
|
284
297
|
}
|
|
285
298
|
|
|
286
299
|
registry["zip"] = ->(args, _ctx) {
|
|
287
|
-
|
|
288
|
-
return dv.
|
|
289
|
-
|
|
290
|
-
result = (0...
|
|
291
|
-
|
|
292
|
-
dv.of_array(pair)
|
|
300
|
+
extracted = args.map { |a| CollectionVerbs.as_items(a) }
|
|
301
|
+
return dv.of_null if extracted.any?(&:nil?) || extracted.empty?
|
|
302
|
+
min_len = extracted.map(&:length).min || 0
|
|
303
|
+
result = (0...min_len).map do |i|
|
|
304
|
+
dv.of_array(extracted.map { |arr| arr[i] })
|
|
293
305
|
end
|
|
294
306
|
dv.of_array(result)
|
|
295
307
|
}
|
|
@@ -331,22 +343,24 @@ module Odin
|
|
|
331
343
|
}
|
|
332
344
|
|
|
333
345
|
registry["take"] = ->(args, _ctx) {
|
|
334
|
-
items = CollectionVerbs.
|
|
346
|
+
items = CollectionVerbs.as_items(args[0])
|
|
335
347
|
n = NumericVerbs.to_double(args[1])&.to_i || 0
|
|
336
|
-
dv.
|
|
348
|
+
return dv.of_null if items.nil? || n < 0
|
|
349
|
+
dv.of_array(items.first(n))
|
|
337
350
|
}
|
|
338
351
|
registry["limit"] = registry["take"]
|
|
339
352
|
|
|
340
353
|
registry["drop"] = ->(args, _ctx) {
|
|
341
|
-
items = CollectionVerbs.
|
|
354
|
+
items = CollectionVerbs.as_items(args[0])
|
|
342
355
|
n = NumericVerbs.to_double(args[1])&.to_i || 0
|
|
343
|
-
dv.
|
|
356
|
+
return dv.of_null if items.nil? || n < 0
|
|
357
|
+
dv.of_array(items.drop(n))
|
|
344
358
|
}
|
|
345
359
|
|
|
346
360
|
registry["chunk"] = ->(args, _ctx) {
|
|
347
|
-
items = CollectionVerbs.
|
|
361
|
+
items = CollectionVerbs.as_items(args[0])
|
|
348
362
|
size = NumericVerbs.to_double(args[1])&.to_i || 1
|
|
349
|
-
|
|
363
|
+
return dv.of_null if items.nil? || size < 1
|
|
350
364
|
chunks = items.each_slice(size).map { |c| dv.of_array(c) }
|
|
351
365
|
dv.of_array(chunks)
|
|
352
366
|
}
|
|
@@ -386,7 +400,8 @@ module Odin
|
|
|
386
400
|
}
|
|
387
401
|
|
|
388
402
|
registry["compact"] = ->(args, _ctx) {
|
|
389
|
-
items = CollectionVerbs.
|
|
403
|
+
items = CollectionVerbs.as_items(args[0])
|
|
404
|
+
return dv.of_null if items.nil?
|
|
390
405
|
dv.of_array(items.reject { |item| item.null? || (item.string? && item.value.empty?) })
|
|
391
406
|
}
|
|
392
407
|
|
|
@@ -435,7 +450,8 @@ module Odin
|
|
|
435
450
|
}
|
|
436
451
|
|
|
437
452
|
registry["dedupe"] = ->(args, _ctx) {
|
|
438
|
-
items = CollectionVerbs.
|
|
453
|
+
items = CollectionVerbs.as_items(args[0])
|
|
454
|
+
return dv.of_null if items.nil?
|
|
439
455
|
field = args[1]&.to_string
|
|
440
456
|
result = []
|
|
441
457
|
last_key = nil
|
|
@@ -454,7 +470,8 @@ module Odin
|
|
|
454
470
|
}
|
|
455
471
|
|
|
456
472
|
registry["cumsum"] = ->(args, _ctx) {
|
|
457
|
-
items = CollectionVerbs.
|
|
473
|
+
items = CollectionVerbs.as_items(args[0])
|
|
474
|
+
return dv.of_null if items.nil?
|
|
458
475
|
sum = 0.0
|
|
459
476
|
result = items.map do |item|
|
|
460
477
|
n = NumericVerbs.to_double(item)
|
|
@@ -469,7 +486,8 @@ module Odin
|
|
|
469
486
|
}
|
|
470
487
|
|
|
471
488
|
registry["cumprod"] = ->(args, _ctx) {
|
|
472
|
-
items = CollectionVerbs.
|
|
489
|
+
items = CollectionVerbs.as_items(args[0])
|
|
490
|
+
return dv.of_null if items.nil?
|
|
473
491
|
prod = 1.0
|
|
474
492
|
result = items.map do |item|
|
|
475
493
|
n = NumericVerbs.to_double(item)
|
|
@@ -484,7 +502,8 @@ module Odin
|
|
|
484
502
|
}
|
|
485
503
|
|
|
486
504
|
registry["diff"] = ->(args, _ctx) {
|
|
487
|
-
items = CollectionVerbs.
|
|
505
|
+
items = CollectionVerbs.as_items(args[0])
|
|
506
|
+
return dv.of_null if items.nil?
|
|
488
507
|
lag = args[1] ? (NumericVerbs.to_double(args[1])&.to_i || 1) : 1
|
|
489
508
|
result = items.each_with_index.map do |item, i|
|
|
490
509
|
if i < lag
|
|
@@ -503,7 +522,8 @@ module Odin
|
|
|
503
522
|
}
|
|
504
523
|
|
|
505
524
|
registry["pctChange"] = ->(args, _ctx) {
|
|
506
|
-
items = CollectionVerbs.
|
|
525
|
+
items = CollectionVerbs.as_items(args[0])
|
|
526
|
+
return dv.of_null if items.nil?
|
|
507
527
|
lag = args[1] ? (NumericVerbs.to_double(args[1])&.to_i || 1) : 1
|
|
508
528
|
result = items.each_with_index.map do |item, i|
|
|
509
529
|
if i < lag
|
|
@@ -514,7 +534,7 @@ module Odin
|
|
|
514
534
|
if curr.nil? || prev.nil? || prev == 0.0
|
|
515
535
|
dv.of_null
|
|
516
536
|
else
|
|
517
|
-
|
|
537
|
+
NumericVerbs.numeric_result((curr - prev) / prev)
|
|
518
538
|
end
|
|
519
539
|
end
|
|
520
540
|
end
|
|
@@ -21,6 +21,19 @@ module Odin
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# Strict yyyy-MM-dd parse; returns nil for any other shape or invalid date.
|
|
25
|
+
def parse_iso_date(s)
|
|
26
|
+
s = s.to_string if s.is_a?(Types::DynValue)
|
|
27
|
+
return nil unless s.is_a?(String)
|
|
28
|
+
m = s.match(/\A(\d{4})-(\d{2})-(\d{2})\z/)
|
|
29
|
+
return nil unless m
|
|
30
|
+
begin
|
|
31
|
+
Date.new(m[1].to_i, m[2].to_i, m[3].to_i)
|
|
32
|
+
rescue ArgumentError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
24
37
|
def parse_timestamp(s)
|
|
25
38
|
return nil if s.nil?
|
|
26
39
|
s = s.to_string if s.is_a?(Types::DynValue)
|
|
@@ -511,7 +524,7 @@ module Odin
|
|
|
511
524
|
d1 = DateTimeVerbs.parse_date(v1)
|
|
512
525
|
d2 = DateTimeVerbs.parse_date(v2)
|
|
513
526
|
return dv.of_null unless d1 && d2
|
|
514
|
-
dv.of_integer((d2 - d1).to_i
|
|
527
|
+
dv.of_integer((d2 - d1).to_i)
|
|
515
528
|
}
|
|
516
529
|
|
|
517
530
|
registry["ageFromDate"] = ->(args, _ctx) {
|
|
@@ -532,8 +545,12 @@ module Odin
|
|
|
532
545
|
registry["isValidDate"] = ->(args, _ctx) {
|
|
533
546
|
s = args[0]&.to_string
|
|
534
547
|
return dv.of_bool(false) if s.nil? || s.empty?
|
|
535
|
-
|
|
536
|
-
|
|
548
|
+
return dv.of_bool(true) unless DateTimeVerbs.parse_iso_date(s).nil?
|
|
549
|
+
if args.length >= 2
|
|
550
|
+
pattern = args[1]&.to_string || ""
|
|
551
|
+
return dv.of_bool(true) unless DateTimeVerbs.parse_date_with_pattern(s, pattern).nil?
|
|
552
|
+
end
|
|
553
|
+
dv.of_bool(false)
|
|
537
554
|
}
|
|
538
555
|
|
|
539
556
|
registry["formatLocaleDate"] = ->(args, _ctx) {
|