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.
@@ -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
- next if key.start_with?("_") && key != "_"
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
- return [] if v.nil? || v.null?
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.extract_items(args[0])
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 >= 2
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.extract_items(args[0])
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 >= 2
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
- args.each do |a|
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
- arrays = args.map { |a| CollectionVerbs.extract_items(a) }
288
- return dv.of_array([]) if arrays.empty?
289
- max_len = arrays.map(&:length).max || 0
290
- result = (0...max_len).map do |i|
291
- pair = arrays.map { |arr| i < arr.length ? arr[i] : dv.of_null }
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.extract_items(args[0])
346
+ items = CollectionVerbs.as_items(args[0])
335
347
  n = NumericVerbs.to_double(args[1])&.to_i || 0
336
- dv.of_array(items.first([n, 0].max))
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.extract_items(args[0])
354
+ items = CollectionVerbs.as_items(args[0])
342
355
  n = NumericVerbs.to_double(args[1])&.to_i || 0
343
- dv.of_array(items.drop([n, 0].max))
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.extract_items(args[0])
361
+ items = CollectionVerbs.as_items(args[0])
348
362
  size = NumericVerbs.to_double(args[1])&.to_i || 1
349
- size = 1 if size < 1
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.extract_items(args[0])
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.extract_items(args[0])
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.extract_items(args[0])
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.extract_items(args[0])
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.extract_items(args[0])
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.extract_items(args[0])
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
- dv.of_float((curr - prev) / prev)
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.abs)
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
- d = DateTimeVerbs.parse_date(s)
536
- dv.of_bool(!d.nil?)
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) {