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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73fe064a2bdc58380f03167913ebea10dce6c722a8b0b2b30703134b28f4ce2e
4
- data.tar.gz: c60ca22db57be40b617169266dbd97c0e3e181674eaf1e5ec57796fc24729635
3
+ metadata.gz: a42b39245e4ab2f8821e0e12100d16418316b22758071d7acf28db0b7a754f20
4
+ data.tar.gz: 99f7bc6624af35e90864f3ccc1239eb2c3c8ab1cd1a50bcd5daffdca36655e98
5
5
  SHA512:
6
- metadata.gz: 3495932fb340947ca0bc35a381557d5b1bf55e18d50150b9117ae073965faeb2dac8f23eedbcc0fdf8058013288b819aa41da3cf1436418819bc941847018064
7
- data.tar.gz: 2d01979d2e392f6ed40b84a2626b2056d9fda8b281f1cfe6978604f7ae0359b7882db6c2465555cc272337560b010fe1d018f2fcf773647ab0da126a296e0652
6
+ metadata.gz: 00027a7160bb7e6605bdbb3e77e2ea54232147902027cdc98d3dccc6b6ce06635563d4ca84698a12a3325d29b1310e88355911dcd0c75099ad13ba35eef84a64
7
+ data.tar.gz: 4ad5366860defb9e19affa8f666eccd4d3d6bd3a9cba539131bb189987c9b878a6217cb60260bb58ff03660e71d4bb3865eb77fed4bb4ff47a3e01124f4621fb
@@ -1105,9 +1105,10 @@ module Odin
1105
1105
  def process_mapping(mapping, source, context, output, modifier_prefix: "")
1106
1106
  target = mapping.target_field
1107
1107
 
1108
- # Handle _pass directive and other underscore-prefixed targets
1109
- # but still evaluate `_` (bare underscore) for side effects like accumulate
1110
- if target == "_"
1108
+ # An unrecognized `_`-prefixed target is a computation-only sink: it runs
1109
+ # for side effects (accumulators, counters) but is never emitted. This
1110
+ # lets several %accumulate/%set advance in one loop pass.
1111
+ if target.start_with?("_")
1111
1112
  begin
1112
1113
  evaluate(mapping.expression, context)
1113
1114
  rescue StandardError => e
@@ -1115,7 +1116,6 @@ module Odin
1115
1116
  end
1116
1117
  return
1117
1118
  end
1118
- return if target.start_with?("_")
1119
1119
 
1120
1120
  begin
1121
1121
  mods = mapping_mods(mapping)
@@ -1532,17 +1532,10 @@ module Odin
1532
1532
  verb_name = expr.verb_name
1533
1533
  args = expr.arguments
1534
1534
 
1535
- # Lazy evaluation for conditional verbs — apply extraction to result
1536
- case verb_name
1537
- when "ifElse"
1538
- val = evaluate_if_else(args, context)
1539
- return apply_extraction_directives(val, extraction_directives)
1540
- when "cond"
1541
- val = evaluate_cond(args, context)
1542
- return apply_extraction_directives(val, extraction_directives)
1543
- when "switch"
1544
- val = evaluate_switch(args, context)
1545
- return apply_extraction_directives(val, extraction_directives)
1535
+ # Lazy evaluation for control-flow verbs — apply extraction to result.
1536
+ if !context.strict_types && LAZY_VERBS.include?(verb_name) && !(expr.respond_to?(:custom) && expr.custom)
1537
+ handled, val = evaluate_lazy_verb(verb_name, args, context)
1538
+ return apply_extraction_directives(val, extraction_directives) if handled
1546
1539
  end
1547
1540
 
1548
1541
  # Eager evaluation: apply extraction directives to CopyExpr arguments
@@ -1579,18 +1572,19 @@ module Odin
1579
1572
  invoke_verb(verb_name, evaluated_args, context)
1580
1573
  end
1581
1574
 
1575
+ # Control-flow verbs that evaluate the condition first and run only the
1576
+ # selected branch; and/or/coalesce short-circuit. Strict-types mode
1577
+ # evaluates eagerly so every argument is validated.
1578
+ LAZY_VERBS = %w[ifElse ifNull ifEmpty coalesce and or cond switch].freeze
1579
+
1582
1580
  def evaluate_verb(expr, context)
1583
1581
  verb_name = expr.verb_name
1584
1582
  args = expr.arguments
1585
1583
 
1586
- # Lazy evaluation for conditional verbs
1587
- case verb_name
1588
- when "ifElse"
1589
- return evaluate_if_else(args, context)
1590
- when "cond"
1591
- return evaluate_cond(args, context)
1592
- when "switch"
1593
- return evaluate_switch(args, context)
1584
+ # Lazy evaluation for control-flow verbs (skipped under strict types).
1585
+ if !context.strict_types && LAZY_VERBS.include?(verb_name) && !(expr.respond_to?(:custom) && expr.custom)
1586
+ handled, value = evaluate_lazy_verb(verb_name, args, context)
1587
+ return value if handled
1594
1588
  end
1595
1589
 
1596
1590
  # Eager evaluation for all other verbs
@@ -1618,6 +1612,49 @@ module Odin
1618
1612
  invoke_verb(verb_name, evaluated_args, context)
1619
1613
  end
1620
1614
 
1615
+ # Dispatch a lazy control-flow verb. Returns [handled, value]; handled is
1616
+ # false when arity is too low, so the caller falls back to eager paths.
1617
+ def evaluate_lazy_verb(verb_name, args, context)
1618
+ ev = ->(i) { evaluate(args[i], context) }
1619
+ case verb_name
1620
+ when "ifElse"
1621
+ return [false, Types::DynValue.of_null] if args.length < 3
1622
+ [true, ev.call(0).truthy? ? ev.call(1) : ev.call(2)]
1623
+ when "ifNull"
1624
+ return [false, Types::DynValue.of_null] if args.length < 2
1625
+ v0 = ev.call(0)
1626
+ [true, v0.null? ? ev.call(1) : v0]
1627
+ when "ifEmpty"
1628
+ return [false, Types::DynValue.of_null] if args.length < 2
1629
+ v0 = ev.call(0)
1630
+ [true, lazy_empty?(v0) ? ev.call(1) : v0]
1631
+ when "coalesce"
1632
+ args.each_index do |i|
1633
+ v = ev.call(i)
1634
+ return [true, v] unless v.null?
1635
+ end
1636
+ [true, Types::DynValue.of_null]
1637
+ when "and"
1638
+ return [false, Types::DynValue.of_null] if args.length < 2
1639
+ return [true, Types::DynValue.of_bool(false)] unless ev.call(0).truthy?
1640
+ [true, Types::DynValue.of_bool(ev.call(1).truthy?)]
1641
+ when "or"
1642
+ return [false, Types::DynValue.of_null] if args.length < 2
1643
+ return [true, Types::DynValue.of_bool(true)] if ev.call(0).truthy?
1644
+ [true, Types::DynValue.of_bool(ev.call(1).truthy?)]
1645
+ when "cond"
1646
+ [true, evaluate_cond(args, context)]
1647
+ when "switch"
1648
+ [true, evaluate_switch(args, context)]
1649
+ else
1650
+ [false, Types::DynValue.of_null]
1651
+ end
1652
+ end
1653
+
1654
+ def lazy_empty?(v)
1655
+ v.nil? || v.null? || (v.string? && v.value.empty?)
1656
+ end
1657
+
1621
1658
  def evaluate_if_else(args, context)
1622
1659
  return Types::DynValue.of_null if args.length < 3
1623
1660
 
@@ -2735,6 +2772,31 @@ module Odin
2735
2772
  .gsub("'", "&apos;")
2736
2773
  end
2737
2774
 
2775
+ # True when every backslash escape in a JSON string body is well-formed.
2776
+ def valid_json_escapes?(s)
2777
+ i = 0
2778
+ len = s.length
2779
+ while i < len
2780
+ if s[i] == "\\"
2781
+ i += 1
2782
+ return false if i >= len
2783
+ case s[i]
2784
+ when "\"", "\\", "/", "b", "f", "n", "r", "t"
2785
+ i += 1
2786
+ when "u"
2787
+ hex = s[(i + 1), 4]
2788
+ return false if hex.nil? || hex.length < 4 || hex !~ /\A[0-9a-fA-F]{4}\z/
2789
+ i += 5
2790
+ else
2791
+ return false
2792
+ end
2793
+ else
2794
+ i += 1
2795
+ end
2796
+ end
2797
+ true
2798
+ end
2799
+
2738
2800
  # ── Verb Registry ──
2739
2801
 
2740
2802
  def build_verb_registry
@@ -2751,6 +2813,7 @@ module Odin
2751
2813
  Verbs::AggregationVerbs.register(registry)
2752
2814
  Verbs::ObjectVerbs.register(registry)
2753
2815
  Verbs::GeoVerbs.register(registry)
2816
+ Verbs::ExtraVerbs.register(registry)
2754
2817
 
2755
2818
  registry
2756
2819
  end
@@ -2824,7 +2887,19 @@ module Odin
2824
2887
  # Type checks
2825
2888
  registry["typeOf"] = ->(args, _ctx) {
2826
2889
  v = args[0]
2827
- type_str = v.nil? ? "null" : v.type.to_s
2890
+ type_str =
2891
+ if v.nil?
2892
+ "null"
2893
+ else
2894
+ {
2895
+ null: "null", bool: "boolean", integer: "integer",
2896
+ float: "number", float_raw: "number", string: "string",
2897
+ array: "array", object: "object", date: "date",
2898
+ timestamp: "timestamp", time: "time", duration: "duration",
2899
+ currency: "currency", currency_raw: "currency", percent: "percent",
2900
+ reference: "reference", binary: "binary"
2901
+ }.fetch(v.type, "unknown")
2902
+ end
2828
2903
  Types::DynValue.of_string(type_str)
2829
2904
  }
2830
2905
  registry["isString"] = ->(args, _ctx) { Types::DynValue.of_bool(args[0]&.string? || false) }
@@ -2836,9 +2911,9 @@ module Odin
2836
2911
 
2837
2912
  # Coercion
2838
2913
  registry["coerceString"] = ->(args, _ctx) { Types::DynValue.of_string(args[0]&.to_string || "") }
2839
- registry["coerceNumber"] = ->(args, _ctx) { Types::DynValue.of_float(args[0]&.to_number&.to_f || 0.0) }
2840
- registry["coerceInteger"] = ->(args, _ctx) { Types::DynValue.of_integer(args[0]&.to_number&.to_i || 0) }
2841
- registry["coerceBoolean"] = ->(args, _ctx) { Types::DynValue.of_bool(args[0]&.truthy? || false) }
2914
+ registry["coerceNumber"] = ->(args, _ctx) { Verbs::NumericVerbs.numeric_result(Verbs::NumericVerbs.to_number(args[0])) }
2915
+ registry["coerceInteger"] = ->(args, _ctx) { Types::DynValue.of_integer(Verbs::NumericVerbs.to_number(args[0]).floor) }
2916
+ registry["coerceBoolean"] = ->(args, _ctx) { Types::DynValue.of_bool(Verbs::NumericVerbs.coerce_boolean(args[0])) }
2842
2917
 
2843
2918
  # Arithmetic
2844
2919
  registry["add"] = ->(args, _ctx) {
@@ -3126,19 +3201,20 @@ module Odin
3126
3201
  Types::DynValue.of_bool(args[0]&.truthy? || args[1]&.truthy?)
3127
3202
  }
3128
3203
 
3129
- # Comparison
3130
- registry["lt"] = ->(args, _ctx) {
3131
- Types::DynValue.of_bool((args[0]&.to_number || 0) < (args[1]&.to_number || 0))
3132
- }
3133
- registry["lte"] = ->(args, _ctx) {
3134
- Types::DynValue.of_bool((args[0]&.to_number || 0) <= (args[1]&.to_number || 0))
3135
- }
3136
- registry["gt"] = ->(args, _ctx) {
3137
- Types::DynValue.of_bool((args[0]&.to_number || 0) > (args[1]&.to_number || 0))
3138
- }
3139
- registry["gte"] = ->(args, _ctx) {
3140
- Types::DynValue.of_bool((args[0]&.to_number || 0) >= (args[1]&.to_number || 0))
3141
- }
3204
+ # Comparison: numeric when both coerce to numbers, else lexical.
3205
+ compare = lambda do |a, b|
3206
+ an = Verbs::NumericVerbs.to_double(a)
3207
+ bn = Verbs::NumericVerbs.to_double(b)
3208
+ if !an.nil? && !bn.nil?
3209
+ an <=> bn
3210
+ else
3211
+ (a&.to_string || "") <=> (b&.to_string || "")
3212
+ end
3213
+ end
3214
+ registry["lt"] = ->(args, _ctx) { Types::DynValue.of_bool(compare.call(args[0], args[1]) < 0) }
3215
+ registry["lte"] = ->(args, _ctx) { Types::DynValue.of_bool(compare.call(args[0], args[1]) <= 0) }
3216
+ registry["gt"] = ->(args, _ctx) { Types::DynValue.of_bool(compare.call(args[0], args[1]) > 0) }
3217
+ registry["gte"] = ->(args, _ctx) { Types::DynValue.of_bool(compare.call(args[0], args[1]) >= 0) }
3142
3218
 
3143
3219
  # String operations
3144
3220
  registry["contains"] = ->(args, _ctx) {
@@ -3212,13 +3288,10 @@ module Odin
3212
3288
  end
3213
3289
  }
3214
3290
 
3215
- # Assertions
3291
+ # Assertions: pass the value through when truthy, else null.
3216
3292
  registry["assert"] = ->(args, _ctx) {
3217
3293
  condition = args[0]
3218
- msg = args[1]&.to_string || "Assertion failed"
3219
- raise TransformError.new(msg) unless condition&.truthy?
3220
-
3221
- Types::DynValue.of_bool(true)
3294
+ condition&.truthy? ? condition : Types::DynValue.of_null
3222
3295
  }
3223
3296
 
3224
3297
  # Switch/cond handled via lazy evaluation in evaluate_verb
@@ -3266,16 +3339,24 @@ module Odin
3266
3339
  }
3267
3340
 
3268
3341
  registry["titleCase"] = ->(args, _ctx) {
3269
- s = args[0]&.to_string || ""
3270
- Types::DynValue.of_string(s.gsub(/\b\w/) { |m| m.upcase })
3342
+ v = args[0]
3343
+ next Types::DynValue.of_null if v.nil? || v.null?
3344
+ s = v.to_string
3345
+ next Types::DynValue.of_string("") if s.empty?
3346
+ result = s.split(/\s+/).map { |w| w.empty? ? "" : w[0].upcase + w[1..].downcase }.join(" ")
3347
+ Types::DynValue.of_string(result)
3271
3348
  }
3272
3349
 
3273
3350
  registry["slugify"] = ->(args, _ctx) {
3274
- s = args[0]&.to_string || ""
3351
+ v = args[0]
3352
+ next Types::DynValue.of_null if v.nil? || v.null?
3353
+ s = v.to_string
3354
+ next Types::DynValue.of_string("") if s.empty?
3275
3355
  result = s.downcase
3276
- .gsub(/[^a-z0-9\s-]/, "")
3277
- .strip
3278
- .gsub(/[\s-]+/, "-")
3356
+ .gsub(/[^a-z0-9_\s-]/, "")
3357
+ .gsub(/[\s_]+/, "-")
3358
+ .gsub(/-+/, "-")
3359
+ .gsub(/\A-+|-+\z/, "")
3279
3360
  Types::DynValue.of_string(result)
3280
3361
  }
3281
3362
 
@@ -3352,12 +3433,15 @@ module Odin
3352
3433
  }
3353
3434
 
3354
3435
  registry["split"] = ->(args, _ctx) {
3355
- s = args[0]&.to_string || ""
3356
- delimiter = args[1]&.to_string || ","
3357
- parts = s.split(delimiter, -1)
3358
- # If a third argument (index) is provided, return that element
3436
+ v = args[0]
3437
+ next Types::DynValue.of_null if v.nil? || v.null?
3438
+ s = v.to_string
3439
+ delimiter = args[1]&.to_string || ""
3440
+ parts = delimiter.empty? ? [s] : s.split(delimiter, -1)
3441
+ # If a third argument (index) is provided, return that element.
3359
3442
  if args[2] && !args[2].null?
3360
- idx = args[2].to_number&.to_i || 0
3443
+ idx = Verbs::NumericVerbs.to_double(args[2])&.to_i || 0
3444
+ idx += parts.length if idx < 0
3361
3445
  if idx >= 0 && idx < parts.length
3362
3446
  Types::DynValue.of_string(parts[idx])
3363
3447
  else
@@ -3388,12 +3472,14 @@ module Odin
3388
3472
  }
3389
3473
 
3390
3474
  registry["match"] = ->(args, _ctx) {
3475
+ next Types::DynValue.of_null if args.length < 2
3391
3476
  s = args[0]&.to_string || ""
3392
3477
  pattern = args[1]&.to_string || ""
3393
3478
  begin
3479
+ next Types::DynValue.of_null if pattern.length > 256 || s.length > 100_000
3394
3480
  Types::DynValue.of_bool(!!(s =~ Regexp.new(pattern)))
3395
3481
  rescue RegexpError
3396
- Types::DynValue.of_bool(false)
3482
+ Types::DynValue.of_null
3397
3483
  end
3398
3484
  }
3399
3485
  registry["matches"] = registry["match"]
@@ -3437,20 +3523,24 @@ module Odin
3437
3523
  }
3438
3524
 
3439
3525
  registry["repeat"] = ->(args, _ctx) {
3440
- s = args[0]&.to_string || ""
3441
- count = args[1]&.to_number&.to_i || 0
3442
- count = 0 if count < 0
3443
- Types::DynValue.of_string(s * count)
3526
+ v = args[0]
3527
+ next Types::DynValue.of_null if v.nil? || v.null?
3528
+ count = Verbs::NumericVerbs.to_double(args[1])&.to_i
3529
+ next Types::DynValue.of_null if count.nil? || count < 0
3530
+ count = 100_000 if count > 100_000
3531
+ Types::DynValue.of_string(v.to_string * count)
3444
3532
  }
3445
3533
 
3446
3534
  registry["replaceRegex"] = ->(args, _ctx) {
3447
- s = args[0]&.to_string || ""
3535
+ next Types::DynValue.of_null if args.length < 3 || args[0].nil? || args[0].null?
3536
+ s = args[0].to_string
3448
3537
  pattern = args[1]&.to_string || ""
3449
3538
  replacement = args[2]&.to_string || ""
3450
3539
  begin
3540
+ next Types::DynValue.of_null if pattern.length > 256 || s.length > 100_000
3451
3541
  Types::DynValue.of_string(s.gsub(Regexp.new(pattern), replacement))
3452
3542
  rescue RegexpError
3453
- Types::DynValue.of_string(s)
3543
+ Types::DynValue.of_null
3454
3544
  end
3455
3545
  }
3456
3546
 
@@ -3531,10 +3621,17 @@ module Odin
3531
3621
  }
3532
3622
 
3533
3623
  registry["base64Decode"] = ->(args, _ctx) {
3534
- s = args[0]&.to_string || ""
3624
+ v = args[0]
3625
+ next Types::DynValue.of_null if v.nil? || v.null?
3626
+ s = v.to_string.tr("-_", "+/")
3627
+ pad = (4 - s.length % 4) % 4
3628
+ s += "=" * pad
3535
3629
  require "base64"
3536
3630
  begin
3537
- Types::DynValue.of_string(Base64.strict_decode64(s))
3631
+ decoded = Base64.strict_decode64(s)
3632
+ decoded.force_encoding("UTF-8")
3633
+ next Types::DynValue.of_null unless decoded.valid_encoding?
3634
+ Types::DynValue.of_string(decoded)
3538
3635
  rescue ArgumentError
3539
3636
  Types::DynValue.of_null
3540
3637
  end
@@ -3546,42 +3643,67 @@ module Odin
3546
3643
  }
3547
3644
 
3548
3645
  registry["hexDecode"] = ->(args, _ctx) {
3549
- s = args[0]&.to_string || ""
3550
- begin
3551
- Types::DynValue.of_string([s].pack("H*"))
3552
- rescue ArgumentError
3553
- Types::DynValue.of_null
3554
- end
3646
+ v = args[0]
3647
+ next Types::DynValue.of_null if v.nil? || v.null?
3648
+ s = v.to_string
3649
+ next Types::DynValue.of_null if s.length.odd? || s.match?(/[^0-9a-fA-F]/)
3650
+ decoded = [s].pack("H*")
3651
+ decoded.force_encoding("UTF-8")
3652
+ next Types::DynValue.of_null unless decoded.valid_encoding?
3653
+ Types::DynValue.of_string(decoded)
3555
3654
  }
3556
3655
 
3557
3656
  registry["urlEncode"] = ->(args, _ctx) {
3558
- s = args[0]&.to_string || ""
3657
+ v = args[0]
3658
+ next Types::DynValue.of_null if v.nil? || v.null?
3559
3659
  require "uri"
3560
- Types::DynValue.of_string(URI.encode_www_form_component(s).gsub("+", "%20"))
3660
+ Types::DynValue.of_string(URI.encode_www_form_component(v.to_string, "UTF-8").gsub("+", "%20").gsub("%7E", "~"))
3561
3661
  }
3562
3662
 
3563
3663
  registry["urlDecode"] = ->(args, _ctx) {
3564
- s = args[0]&.to_string || ""
3664
+ v = args[0]
3665
+ next Types::DynValue.of_null if v.nil? || v.null?
3666
+ s = v.to_string
3667
+ # Reject malformed percent-encoding (% not followed by two hex digits).
3668
+ next Types::DynValue.of_null if s.scan(/%(..?|.?)/).any? { |seq| seq[0].length != 2 || seq[0].match?(/[^0-9a-fA-F]/) }
3565
3669
  require "uri"
3566
3670
  begin
3567
- Types::DynValue.of_string(URI.decode_www_form_component(s))
3671
+ decoded = URI.decode_www_form_component(s, "UTF-8")
3672
+ next Types::DynValue.of_null unless decoded.valid_encoding?
3673
+ Types::DynValue.of_string(decoded)
3568
3674
  rescue ArgumentError
3569
- Types::DynValue.of_string(s)
3675
+ Types::DynValue.of_null
3570
3676
  end
3571
3677
  }
3572
3678
 
3573
3679
  registry["jsonEncode"] = ->(args, _ctx) {
3574
3680
  v = args[0]
3681
+ next Types::DynValue.of_null if v.nil? || v.null?
3575
3682
  require "json"
3576
- Types::DynValue.of_string(v.nil? || v.null? ? "null" : JSON.generate(v.to_ruby))
3683
+ if v.object? || v.array?
3684
+ next Types::DynValue.of_string(JSON.generate(v.to_ruby))
3685
+ end
3686
+ # Scalars: JSON-escape the string and drop the surrounding quotes.
3687
+ encoded = JSON.generate(v.to_string)
3688
+ Types::DynValue.of_string(encoded[1...-1])
3577
3689
  }
3578
3690
 
3579
3691
  registry["jsonDecode"] = ->(args, _ctx) {
3580
- s = args[0]&.to_string || ""
3692
+ v = args[0]
3693
+ next Types::DynValue.of_null if v.nil? || v.null?
3694
+ s = v.to_string
3581
3695
  require "json"
3696
+ if s.start_with?("{", "[")
3697
+ begin
3698
+ parsed = JSON.parse(s)
3699
+ next Types::DynValue.from_ruby(parsed) if parsed.is_a?(Hash) || parsed.is_a?(Array)
3700
+ rescue JSON::ParserError
3701
+ end
3702
+ end
3703
+ # Unescape as a JSON string; an invalid escape yields null.
3704
+ next Types::DynValue.of_null unless valid_json_escapes?(s)
3582
3705
  begin
3583
- parsed = JSON.parse(s)
3584
- Types::DynValue.from_ruby(parsed)
3706
+ Types::DynValue.of_string(JSON.parse("\"#{s}\""))
3585
3707
  rescue JSON::ParserError
3586
3708
  Types::DynValue.of_null
3587
3709
  end
@@ -3678,13 +3800,38 @@ module Odin
3678
3800
  registry["coerceDate"] = ->(args, _ctx) {
3679
3801
  v = args[0]
3680
3802
  return Types::DynValue.of_null if v.nil? || v.null?
3803
+ return v if v.type == :date
3804
+ if v.type == :timestamp
3805
+ s = v.to_string
3806
+ s = s[0...s.index("T")] if s.include?("T")
3807
+ next Types::DynValue.of_date(s)
3808
+ end
3809
+
3681
3810
  s = v.to_string.strip
3682
- begin
3683
- d = Date.parse(s)
3684
- Types::DynValue.of_date(d.strftime("%Y-%m-%d"))
3685
- rescue ArgumentError, TypeError
3686
- Types::DynValue.of_null
3811
+ next Types::DynValue.of_null if s.empty?
3812
+
3813
+ valid_ymd = ->(y, mo, d) { mo.between?(1, 12) && d >= 1 && Date.valid_date?(y, mo, d) }
3814
+
3815
+ # yyyy-MM-dd prefix
3816
+ if s.length >= 10 && s[4] == "-" && s[7] == "-" &&
3817
+ s[0, 4] =~ /\A\d{4}\z/ && s[5, 2] =~ /\A\d{2}\z/ && s[8, 2] =~ /\A\d{2}\z/
3818
+ next Types::DynValue.of_date(s[0, 10])
3687
3819
  end
3820
+
3821
+ # Compact YYYYMMDD
3822
+ if (m = s.match(/\A(\d{4})(\d{2})(\d{2})\z/))
3823
+ y, mo, d = m[1].to_i, m[2].to_i, m[3].to_i
3824
+ next valid_ymd.call(y, mo, d) ? Types::DynValue.of_date(format("%04d-%02d-%02d", y, mo, d)) : Types::DynValue.of_null
3825
+ end
3826
+
3827
+ # Slash MM/DD/YYYY (US), or DD/MM/YYYY when first > 12
3828
+ if (m = s.match(%r{\A(\d{1,2})/(\d{1,2})/(\d{4})\z}))
3829
+ first, second, y = m[1].to_i, m[2].to_i, m[3].to_i
3830
+ mo, d = first > 12 ? [second, first] : [first, second]
3831
+ next valid_ymd.call(y, mo, d) ? Types::DynValue.of_date(format("%04d-%02d-%02d", y, mo, d)) : Types::DynValue.of_null
3832
+ end
3833
+
3834
+ Types::DynValue.of_null
3688
3835
  }
3689
3836
 
3690
3837
  registry["coerceTimestamp"] = ->(args, _ctx) {
@@ -3715,23 +3862,19 @@ module Odin
3715
3862
  registry["toObject"] = ->(args, _ctx) {
3716
3863
  v = args[0]
3717
3864
  if v.nil? || v.null?
3718
- Types::DynValue.of_object({})
3865
+ Types::DynValue.of_null
3719
3866
  elsif v.object?
3720
3867
  v
3721
3868
  elsif v.array?
3722
3869
  items = v.value || []
3723
- pairs = items.map { |item| to_object_pair(item) }
3724
- if !items.empty? && pairs.all?
3725
- obj = {}
3726
- pairs.each { |k, val| obj[k] = val }
3727
- Types::DynValue.of_object(obj)
3728
- else
3729
- obj = {}
3730
- items.each_with_index { |item, i| obj[i.to_s] = item }
3731
- Types::DynValue.of_object(obj)
3870
+ obj = {}
3871
+ items.each do |item|
3872
+ pair = to_object_pair(item)
3873
+ obj[pair[0]] = pair[1] if pair
3732
3874
  end
3875
+ obj.empty? ? Types::DynValue.of_null : Types::DynValue.of_object(obj)
3733
3876
  else
3734
- Types::DynValue.of_object({ "value" => v })
3877
+ Types::DynValue.of_null
3735
3878
  end
3736
3879
  }
3737
3880
 
@@ -3795,11 +3938,8 @@ module Odin
3795
3938
  digits = raw.gsub(/\D/, "")
3796
3939
  formatted = case country
3797
3940
  when "US", "CA"
3798
- if digits.length == 10
3799
- "(#{digits[0..2]}) #{digits[3..5]}-#{digits[6..9]}"
3800
- elsif digits.length == 11 && digits.start_with?("1")
3801
- "+1 (#{digits[1..3]}) #{digits[4..6]}-#{digits[7..10]}"
3802
- end
3941
+ d = (digits.length == 11 && digits.start_with?("1")) ? digits[1..] : digits
3942
+ "(#{d[0..2]}) #{d[3..5]}-#{d[6..9]}" if d.length == 10
3803
3943
  when "GB"
3804
3944
  if digits.length == 11 && digits.start_with?("0")
3805
3945
  "+44 #{digits[1..4]} #{digits[5..10]}"