odin-foundation 1.0.4 → 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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/odin/export.rb +1 -1
  3. data/lib/odin/forms/accessibility.rb +95 -0
  4. data/lib/odin/forms/css.rb +42 -0
  5. data/lib/odin/forms/parser.rb +719 -0
  6. data/lib/odin/forms/renderer.rb +534 -0
  7. data/lib/odin/forms/types.rb +102 -0
  8. data/lib/odin/forms/units.rb +41 -0
  9. data/lib/odin/forms.rb +55 -0
  10. data/lib/odin/parsing/parser.rb +25 -1
  11. data/lib/odin/parsing/tokenizer.rb +38 -20
  12. data/lib/odin/parsing/value_parser.rb +65 -7
  13. data/lib/odin/resolver/import_resolver.rb +40 -12
  14. data/lib/odin/resolver/type_registry.rb +54 -0
  15. data/lib/odin/transform/format_exporters.rb +88 -48
  16. data/lib/odin/transform/source_parsers.rb +2 -2
  17. data/lib/odin/transform/transform_engine.rb +1388 -246
  18. data/lib/odin/transform/transform_expr.rb +222 -0
  19. data/lib/odin/transform/transform_parser.rb +377 -19
  20. data/lib/odin/transform/transform_types.rb +23 -7
  21. data/lib/odin/transform/verb_context.rb +19 -1
  22. data/lib/odin/transform/verbs/aggregation_verbs.rb +2 -1
  23. data/lib/odin/transform/verbs/collection_verbs.rb +164 -89
  24. data/lib/odin/transform/verbs/datetime_verbs.rb +86 -15
  25. data/lib/odin/transform/verbs/extra_verbs.rb +613 -0
  26. data/lib/odin/transform/verbs/financial_verbs.rb +116 -27
  27. data/lib/odin/transform/verbs/geo_verbs.rb +7 -0
  28. data/lib/odin/transform/verbs/numeric_verbs.rb +85 -64
  29. data/lib/odin/transform/verbs/object_verbs.rb +31 -26
  30. data/lib/odin/types/errors.rb +9 -1
  31. data/lib/odin/types/schema.rb +20 -3
  32. data/lib/odin/utils/format_utils.rb +31 -15
  33. data/lib/odin/validation/format_validators.rb +7 -9
  34. data/lib/odin/validation/invariant_evaluator.rb +410 -0
  35. data/lib/odin/validation/schema_definition_validator.rb +357 -0
  36. data/lib/odin/validation/schema_parser.rb +234 -21
  37. data/lib/odin/validation/validator.rb +281 -123
  38. data/lib/odin/version.rb +1 -1
  39. data/lib/odin.rb +100 -4
  40. metadata +14 -2
@@ -58,15 +58,16 @@ module Odin
58
58
 
59
59
  # Transform definition — top-level AST node
60
60
  class TransformDef
61
- attr_reader :header, :segments, :constants, :tables, :accumulators, :passes
61
+ attr_reader :header, :segments, :constants, :tables, :accumulators, :passes, :imports
62
62
 
63
- def initialize(header:, segments: [], constants: {}, tables: {}, accumulators: {}, passes: [])
63
+ def initialize(header:, segments: [], constants: {}, tables: {}, accumulators: {}, passes: [], imports: [])
64
64
  @header = header
65
65
  @segments = segments
66
66
  @constants = constants
67
67
  @tables = tables
68
68
  @accumulators = accumulators
69
69
  @passes = passes
70
+ @imports = imports
70
71
  end
71
72
 
72
73
  def direction
@@ -92,7 +93,7 @@ module Odin
92
93
  class TransformHeader
93
94
  attr_reader :odin_version, :transform_version, :direction,
94
95
  :target_format, :enforce_confidential,
95
- :source_options, :target_options,
96
+ :source_options, :target_options, :target_namespaces,
96
97
  :strict_types, :id, :name
97
98
 
98
99
  def initialize(
@@ -103,6 +104,7 @@ module Odin
103
104
  enforce_confidential: ConfidentialMode::NONE,
104
105
  source_options: {},
105
106
  target_options: {},
107
+ target_namespaces: {},
106
108
  strict_types: false,
107
109
  id: nil,
108
110
  name: nil
@@ -114,6 +116,7 @@ module Odin
114
116
  @enforce_confidential = enforce_confidential
115
117
  @source_options = source_options
116
118
  @target_options = target_options
119
+ @target_namespaces = target_namespaces
117
120
  @strict_types = strict_types
118
121
  @id = id
119
122
  @name = name
@@ -125,7 +128,9 @@ module Odin
125
128
  attr_reader :name, :path, :array_index,
126
129
  :field_mappings, :discriminator, :discriminator_value,
127
130
  :when_condition, :each_source, :if_condition,
128
- :children, :pass, :counter_name, :is_array
131
+ :elif_condition, :is_else,
132
+ :children, :pass, :counter_name, :is_array,
133
+ :loops, :is_literal, :literal_body
129
134
 
130
135
  def initialize(
131
136
  name:,
@@ -137,10 +142,15 @@ module Odin
137
142
  when_condition: nil,
138
143
  each_source: nil,
139
144
  if_condition: nil,
145
+ elif_condition: nil,
146
+ is_else: false,
140
147
  children: [],
141
148
  pass: nil,
142
149
  counter_name: nil,
143
- is_array: false
150
+ is_array: false,
151
+ loops: [],
152
+ is_literal: false,
153
+ literal_body: nil
144
154
  )
145
155
  @name = name
146
156
  @path = path
@@ -151,10 +161,15 @@ module Odin
151
161
  @when_condition = when_condition
152
162
  @each_source = each_source
153
163
  @if_condition = if_condition
164
+ @elif_condition = elif_condition
165
+ @is_else = is_else
154
166
  @children = children
155
167
  @pass = pass
156
168
  @counter_name = counter_name
157
169
  @is_array = is_array
170
+ @loops = loops
171
+ @is_literal = is_literal
172
+ @literal_body = literal_body
158
173
  end
159
174
  end
160
175
 
@@ -261,12 +276,13 @@ module Odin
261
276
 
262
277
  # Transform result
263
278
  class TransformResult
264
- attr_reader :output, :formatted, :errors, :output_dv
279
+ attr_reader :output, :formatted, :errors, :warnings, :output_dv
265
280
 
266
- def initialize(output:, formatted: nil, errors: [], output_dv: nil)
281
+ def initialize(output:, formatted: nil, errors: [], warnings: [], output_dv: nil)
267
282
  @output = output
268
283
  @formatted = formatted
269
284
  @errors = errors
285
+ @warnings = warnings
270
286
  @output_dv = output_dv
271
287
  end
272
288
 
@@ -8,6 +8,7 @@ module Odin
8
8
  :loop_index, # Integer — current loop iteration (0-based)
9
9
  :loop_length, # Integer — total loop length
10
10
  :loop_vars, # Hash<String, DynValue> — named loop variables
11
+ :aliases, # Hash<String, DynValue> — :loop :as alias bindings
11
12
  :accumulators, # Hash<String, DynValue> — accumulator state
12
13
  :tables, # Hash<String, LookupTable> — lookup tables
13
14
  :constants, # Hash<String, DynValue> — constant values
@@ -16,7 +17,12 @@ module Odin
16
17
  :loop_depth, # Integer — nesting depth for security
17
18
  :field_modifiers, # Hash<String, Array<Symbol>> — tracked field modifiers
18
19
  :errors, # Array<TransformEngine::TransformError> — collected errors
19
- :source_format # String — source format for directive handling
20
+ :warnings, # Array<String>collected warnings
21
+ :source_format, # String — source format for directive handling
22
+ :on_validation, # String — validation policy (fail / warn / skip)
23
+ :on_missing, # String — missing-data policy (fail / warn / skip / default)
24
+ :on_error, # String — error policy (fail / warn / skip)
25
+ :strict_types # Boolean — enforce verb argument type signatures
20
26
 
21
27
  MAX_LOOP_DEPTH = 10
22
28
 
@@ -26,6 +32,7 @@ module Odin
26
32
  @loop_index = 0
27
33
  @loop_length = 0
28
34
  @loop_vars = {}
35
+ @aliases = {}
29
36
  @accumulators = {}
30
37
  @tables = {}
31
38
  @constants = {}
@@ -34,7 +41,12 @@ module Odin
34
41
  @loop_depth = 0
35
42
  @field_modifiers = {}
36
43
  @errors = []
44
+ @warnings = []
37
45
  @source_format = ""
46
+ @on_validation = nil
47
+ @on_missing = nil
48
+ @on_error = nil
49
+ @strict_types = false
38
50
  end
39
51
 
40
52
  def next_sequence(name)
@@ -72,6 +84,7 @@ module Odin
72
84
  ctx = VerbContext.new
73
85
  ctx.source = @source
74
86
  ctx.loop_vars = @loop_vars.dup
87
+ ctx.aliases = @aliases.dup
75
88
  ctx.accumulators = @accumulators # shared reference
76
89
  ctx.tables = @tables
77
90
  ctx.constants = @constants
@@ -80,6 +93,11 @@ module Odin
80
93
  ctx.loop_depth = @loop_depth + 1
81
94
  ctx.field_modifiers = @field_modifiers # shared reference
82
95
  ctx.errors = @errors # shared reference
96
+ ctx.warnings = @warnings # shared reference
97
+ ctx.source_format = @source_format
98
+ ctx.on_validation = @on_validation
99
+ ctx.on_missing = @on_missing
100
+ ctx.on_error = @on_error
83
101
  ctx
84
102
  end
85
103
  end
@@ -52,7 +52,8 @@ module Odin
52
52
  items = CollectionVerbs.extract_items(args[0])
53
53
  nums = items.filter_map { |item| NumericVerbs.to_double(item) }
54
54
  return dv.of_null if nums.empty?
55
- dv.of_float(nums.sum / nums.length.to_f)
55
+ total = nums.inject(0.0) { |s, v| s + v }
56
+ NumericVerbs.numeric_result(total / nums.length.to_f)
56
57
  }
57
58
 
58
59
  registry["first"] = ->(args, _ctx) {
@@ -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)
@@ -37,6 +42,24 @@ module Odin
37
42
  a.to_string == b.to_string
38
43
  end
39
44
 
45
+ # Field/operator/value condition matching used by find, findIndex, partition.
46
+ def matches_condition?(item, field, op, compare)
47
+ val = item.object? ? item.get(field) : item
48
+ val ||= Types::DynValue.of_null
49
+ case op
50
+ when "=", "==" then val.to_string == compare.to_string
51
+ when "!=", "<>" then val.to_string != compare.to_string
52
+ when "<" then (NumericVerbs.to_double(val) || 0) < (NumericVerbs.to_double(compare) || 0)
53
+ when "<=" then (NumericVerbs.to_double(val) || 0) <= (NumericVerbs.to_double(compare) || 0)
54
+ when ">" then (NumericVerbs.to_double(val) || 0) > (NumericVerbs.to_double(compare) || 0)
55
+ when ">=" then (NumericVerbs.to_double(val) || 0) >= (NumericVerbs.to_double(compare) || 0)
56
+ when "contains" then val.to_string.include?(compare.to_string)
57
+ when "startsWith" then val.to_string.start_with?(compare.to_string)
58
+ when "endsWith" then val.to_string.end_with?(compare.to_string)
59
+ else false
60
+ end
61
+ end
62
+
40
63
  def register(registry)
41
64
  dv = Types::DynValue
42
65
 
@@ -204,9 +227,15 @@ module Odin
204
227
  }
205
228
 
206
229
  registry["every"] = ->(args, _ctx) {
207
- items = CollectionVerbs.extract_items(args[0])
230
+ items = CollectionVerbs.as_items(args[0])
231
+ return dv.of_null if items.nil?
208
232
  return dv.of_bool(true) if items.empty?
209
- 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
210
239
  field = args[1]&.to_string || ""
211
240
  dv.of_bool(items.all? { |item| (item.object? ? item.get(field) : item)&.truthy? || false })
212
241
  else
@@ -215,9 +244,15 @@ module Odin
215
244
  }
216
245
 
217
246
  registry["some"] = ->(args, _ctx) {
218
- items = CollectionVerbs.extract_items(args[0])
247
+ items = CollectionVerbs.as_items(args[0])
248
+ return dv.of_null if items.nil?
219
249
  return dv.of_bool(false) if items.empty?
220
- 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
221
256
  field = args[1]&.to_string || ""
222
257
  dv.of_bool(items.any? { |item| (item.object? ? item.get(field) : item)&.truthy? || false })
223
258
  else
@@ -226,24 +261,24 @@ module Odin
226
261
  }
227
262
 
228
263
  registry["find"] = ->(args, _ctx) {
264
+ return dv.of_null if args.length < 4
265
+
229
266
  items = CollectionVerbs.extract_items(args[0])
230
- if args.length >= 2
231
- field = args[1]&.to_string || ""
232
- found = items.find { |item| (item.object? ? item.get(field) : item)&.truthy? || false }
233
- else
234
- found = items.find(&:truthy?)
235
- end
267
+ field = args[1]&.to_string || ""
268
+ op = args[2]&.to_string || "="
269
+ compare = args[3]
270
+ found = items.find { |item| CollectionVerbs.matches_condition?(item, field, op, compare) }
236
271
  found || dv.of_null
237
272
  }
238
273
 
239
274
  registry["findIndex"] = ->(args, _ctx) {
275
+ return dv.of_integer(-1) if args.length < 4
276
+
240
277
  items = CollectionVerbs.extract_items(args[0])
241
- if args.length >= 2
242
- field = args[1]&.to_string || ""
243
- idx = items.index { |item| (item.object? ? item.get(field) : item)&.truthy? || false }
244
- else
245
- idx = items.index(&:truthy?)
246
- end
278
+ field = args[1]&.to_string || ""
279
+ op = args[2]&.to_string || "="
280
+ compare = args[3]
281
+ idx = items.index { |item| CollectionVerbs.matches_condition?(item, field, op, compare) }
247
282
  dv.of_integer(idx || -1)
248
283
  }
249
284
 
@@ -254,73 +289,78 @@ module Odin
254
289
  }
255
290
 
256
291
  registry["concatArrays"] = ->(args, _ctx) {
292
+ extracted = args.map { |a| CollectionVerbs.as_items(a) }
293
+ return dv.of_null if extracted.all?(&:nil?)
257
294
  result = []
258
- args.each do |a|
259
- if a&.array?
260
- result.concat(a.value)
261
- elsif !a.nil? && !a.null?
262
- result << a
263
- end
264
- end
295
+ extracted.each { |items| result.concat(items) if items }
265
296
  dv.of_array(result)
266
297
  }
267
298
 
268
299
  registry["zip"] = ->(args, _ctx) {
269
- arrays = args.map { |a| CollectionVerbs.extract_items(a) }
270
- return dv.of_array([]) if arrays.empty?
271
- max_len = arrays.map(&:length).max || 0
272
- result = (0...max_len).map do |i|
273
- pair = arrays.map { |arr| i < arr.length ? arr[i] : dv.of_null }
274
- 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] })
275
305
  end
276
306
  dv.of_array(result)
277
307
  }
278
308
 
279
309
  registry["groupBy"] = ->(args, _ctx) {
310
+ return dv.of_null if args.length < 2
311
+
280
312
  items = CollectionVerbs.extract_items(args[0])
281
313
  field = args[1]&.to_string || ""
282
314
  groups = {}
315
+ order = []
283
316
  items.each do |item|
284
317
  key = if item.object?
285
- (item.get(field) || dv.of_null).to_string
286
- else
287
- item.to_string
288
- end
289
- groups[key] ||= []
318
+ (item.get(field) || dv.of_null).to_string
319
+ else
320
+ item.to_string
321
+ end
322
+ unless groups.key?(key)
323
+ groups[key] = []
324
+ order << key
325
+ end
290
326
  groups[key] << item
291
327
  end
292
- obj = groups.transform_values { |v| dv.of_array(v) }
293
- dv.of_object(obj)
328
+ result = order.map do |key|
329
+ dv.of_object({ "key" => dv.of_string(key), "items" => dv.of_array(groups[key]) })
330
+ end
331
+ dv.of_array(result)
294
332
  }
295
333
 
296
334
  registry["partition"] = ->(args, _ctx) {
335
+ return dv.of_null if args.length < 4
336
+
297
337
  items = CollectionVerbs.extract_items(args[0])
298
- if args.length >= 2
299
- field = args[1]&.to_string || ""
300
- pass_items, fail_items = items.partition { |item| (item.object? ? item.get(field) : item)&.truthy? || false }
301
- else
302
- pass_items, fail_items = items.partition(&:truthy?)
303
- end
338
+ field = args[1]&.to_string || ""
339
+ op = args[2]&.to_string || "="
340
+ compare = args[3]
341
+ pass_items, fail_items = items.partition { |item| CollectionVerbs.matches_condition?(item, field, op, compare) }
304
342
  dv.of_array([dv.of_array(pass_items), dv.of_array(fail_items)])
305
343
  }
306
344
 
307
345
  registry["take"] = ->(args, _ctx) {
308
- items = CollectionVerbs.extract_items(args[0])
346
+ items = CollectionVerbs.as_items(args[0])
309
347
  n = NumericVerbs.to_double(args[1])&.to_i || 0
310
- 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))
311
350
  }
312
351
  registry["limit"] = registry["take"]
313
352
 
314
353
  registry["drop"] = ->(args, _ctx) {
315
- items = CollectionVerbs.extract_items(args[0])
354
+ items = CollectionVerbs.as_items(args[0])
316
355
  n = NumericVerbs.to_double(args[1])&.to_i || 0
317
- 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))
318
358
  }
319
359
 
320
360
  registry["chunk"] = ->(args, _ctx) {
321
- items = CollectionVerbs.extract_items(args[0])
361
+ items = CollectionVerbs.as_items(args[0])
322
362
  size = NumericVerbs.to_double(args[1])&.to_i || 1
323
- size = 1 if size < 1
363
+ return dv.of_null if items.nil? || size < 1
324
364
  chunks = items.each_slice(size).map { |c| dv.of_array(c) }
325
365
  dv.of_array(chunks)
326
366
  }
@@ -360,21 +400,24 @@ module Odin
360
400
  }
361
401
 
362
402
  registry["compact"] = ->(args, _ctx) {
363
- items = CollectionVerbs.extract_items(args[0])
403
+ items = CollectionVerbs.as_items(args[0])
404
+ return dv.of_null if items.nil?
364
405
  dv.of_array(items.reject { |item| item.null? || (item.string? && item.value.empty?) })
365
406
  }
366
407
 
367
- registry["rowNumber"] = ->(args, ctx) {
368
- name = "_rowNumber"
369
- current = ctx.get_accumulator(name)
370
- if current.null?
371
- ctx.set_accumulator(name, dv.of_integer(1))
372
- dv.of_integer(1)
373
- else
374
- next_val = current.to_number.to_i + 1
375
- ctx.set_accumulator(name, dv.of_integer(next_val))
376
- dv.of_integer(next_val)
408
+ registry["rowNumber"] = ->(args, _ctx) {
409
+ return dv.of_null if args.empty?
410
+
411
+ items = CollectionVerbs.extract_items(args[0])
412
+ result = items.each_with_index.map do |item, i|
413
+ num = dv.of_integer(i + 1)
414
+ if item.object?
415
+ dv.of_object({ "_rowNum" => num }.merge(item.value))
416
+ else
417
+ dv.of_object({ "_rowNum" => num, "value" => item })
418
+ end
377
419
  end
420
+ dv.of_array(result)
378
421
  }
379
422
 
380
423
  registry["sample"] = ->(args, _ctx) {
@@ -407,7 +450,8 @@ module Odin
407
450
  }
408
451
 
409
452
  registry["dedupe"] = ->(args, _ctx) {
410
- items = CollectionVerbs.extract_items(args[0])
453
+ items = CollectionVerbs.as_items(args[0])
454
+ return dv.of_null if items.nil?
411
455
  field = args[1]&.to_string
412
456
  result = []
413
457
  last_key = nil
@@ -426,7 +470,8 @@ module Odin
426
470
  }
427
471
 
428
472
  registry["cumsum"] = ->(args, _ctx) {
429
- items = CollectionVerbs.extract_items(args[0])
473
+ items = CollectionVerbs.as_items(args[0])
474
+ return dv.of_null if items.nil?
430
475
  sum = 0.0
431
476
  result = items.map do |item|
432
477
  n = NumericVerbs.to_double(item)
@@ -441,7 +486,8 @@ module Odin
441
486
  }
442
487
 
443
488
  registry["cumprod"] = ->(args, _ctx) {
444
- items = CollectionVerbs.extract_items(args[0])
489
+ items = CollectionVerbs.as_items(args[0])
490
+ return dv.of_null if items.nil?
445
491
  prod = 1.0
446
492
  result = items.map do |item|
447
493
  n = NumericVerbs.to_double(item)
@@ -456,7 +502,8 @@ module Odin
456
502
  }
457
503
 
458
504
  registry["diff"] = ->(args, _ctx) {
459
- items = CollectionVerbs.extract_items(args[0])
505
+ items = CollectionVerbs.as_items(args[0])
506
+ return dv.of_null if items.nil?
460
507
  lag = args[1] ? (NumericVerbs.to_double(args[1])&.to_i || 1) : 1
461
508
  result = items.each_with_index.map do |item, i|
462
509
  if i < lag
@@ -475,7 +522,8 @@ module Odin
475
522
  }
476
523
 
477
524
  registry["pctChange"] = ->(args, _ctx) {
478
- items = CollectionVerbs.extract_items(args[0])
525
+ items = CollectionVerbs.as_items(args[0])
526
+ return dv.of_null if items.nil?
479
527
  lag = args[1] ? (NumericVerbs.to_double(args[1])&.to_i || 1) : 1
480
528
  result = items.each_with_index.map do |item, i|
481
529
  if i < lag
@@ -486,7 +534,7 @@ module Odin
486
534
  if curr.nil? || prev.nil? || prev == 0.0
487
535
  dv.of_null
488
536
  else
489
- dv.of_float((curr - prev) / prev)
537
+ NumericVerbs.numeric_result((curr - prev) / prev)
490
538
  end
491
539
  end
492
540
  end
@@ -528,40 +576,62 @@ module Odin
528
576
  }
529
577
 
530
578
  registry["rank"] = ->(args, _ctx) {
579
+ return dv.of_null if args.empty?
580
+
531
581
  items = CollectionVerbs.extract_items(args[0])
532
- field = args[1]&.to_string
533
- direction = args[2]&.to_string || "desc"
582
+ field = args.length > 1 ? args[1]&.to_string : nil
583
+ field = nil if field&.empty?
584
+ direction = (args.length > 2 ? args[2]&.to_string : "desc").to_s.downcase
534
585
 
535
- values = items.map do |item|
536
- if field && item.object?
537
- NumericVerbs.to_double(item.get(field))
586
+ comparable = ->(item) do
587
+ raw = field && item.object? ? item.get(field) : item
588
+ num = NumericVerbs.to_double(raw)
589
+ num.nil? ? (raw.nil? ? "" : raw.to_string) : num
590
+ end
591
+
592
+ indexed = items.each_index.map { |i| [i, comparable.call(items[i])] }
593
+ mult = direction == "asc" ? 1 : -1
594
+ sorted = indexed.sort do |a, b|
595
+ av = a[1]
596
+ bv = b[1]
597
+ if av.is_a?(Numeric) && bv.is_a?(Numeric)
598
+ (av <=> bv) * mult
538
599
  else
539
- NumericVerbs.to_double(item)
600
+ (av.to_s <=> bv.to_s) * mult
540
601
  end
541
602
  end
542
603
 
543
- sorted_unique = values.compact.uniq.sort
544
- sorted_unique.reverse! if direction == "desc"
545
-
546
- rank_map = {}
547
- sorted_unique.each_with_index { |v, i| rank_map[v] = i + 1 }
604
+ ranks = {}
605
+ current_rank = 1
606
+ sorted.each_with_index do |(orig_idx, val), i|
607
+ current_rank = i + 1 if i.positive? && val != sorted[i - 1][1]
608
+ ranks[orig_idx] = current_rank
609
+ end
548
610
 
549
- result = values.map do |v|
550
- v.nil? ? dv.of_null : dv.of_integer(rank_map[v])
611
+ result = items.each_with_index.map do |item, i|
612
+ rank_val = dv.of_integer(ranks[i] || (i + 1))
613
+ if item.object?
614
+ dv.of_object({ "_rank" => rank_val }.merge(item.value))
615
+ else
616
+ dv.of_object({ "_rank" => rank_val, "value" => item })
617
+ end
551
618
  end
552
619
  dv.of_array(result)
553
620
  }
554
621
 
555
622
  registry["fillMissing"] = ->(args, _ctx) {
623
+ return dv.of_null if args.empty?
624
+
556
625
  items = CollectionVerbs.extract_items(args[0])
557
- strategy = args[1]&.to_string || "value"
558
- fill_val = args[2] || dv.of_null
626
+ fill_val = args.length >= 2 ? args[1] : dv.of_null
627
+ strategy = (args.length >= 3 ? args[2]&.to_string : "value").to_s.downcase
628
+ nullish = ->(item) { item.nil? || item.null? }
559
629
 
560
630
  case strategy
561
631
  when "forward"
562
- last_val = dv.of_null
632
+ last_val = fill_val
563
633
  result = items.map do |item|
564
- if item.null?
634
+ if nullish.call(item)
565
635
  last_val
566
636
  else
567
637
  last_val = item
@@ -569,17 +639,22 @@ module Odin
569
639
  end
570
640
  end
571
641
  when "backward"
572
- last_val = dv.of_null
642
+ last_val = fill_val
573
643
  result = items.reverse.map do |item|
574
- if item.null?
644
+ if nullish.call(item)
575
645
  last_val
576
646
  else
577
647
  last_val = item
578
648
  item
579
649
  end
580
650
  end.reverse
651
+ when "mean"
652
+ nums = items.reject { |i| nullish.call(i) }.filter_map { |i| NumericVerbs.to_double(i) }
653
+ mean = nums.empty? ? 0.0 : nums.inject(0.0) { |s, v| s + v } / nums.length
654
+ mean_val = dv.of_float(mean)
655
+ result = items.map { |item| nullish.call(item) ? mean_val : item }
581
656
  else
582
- result = items.map { |item| item.null? ? fill_val : item }
657
+ result = items.map { |item| nullish.call(item) ? fill_val : item }
583
658
  end
584
659
  dv.of_array(result)
585
660
  }