finch-api 0.1.0.pre.alpha.3 → 0.1.0.pre.alpha.4

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.
@@ -9,22 +9,34 @@ module FinchAPI
9
9
  #
10
10
  # @param value [Object]
11
11
  #
12
- # @return [Object]
13
- def coerce(value) = value
14
-
15
- # @api private
12
+ # @param state [Hash{Symbol=>Object}] .
16
13
  #
17
- # @param value [Object]
14
+ # @option state [Boolean, :strong] :strictness
15
+ #
16
+ # @option state [Hash{Symbol=>Object}] :exactness
17
+ #
18
+ # @option state [Integer] :branched
18
19
  #
19
20
  # @return [Object]
20
- def dump(value) = value
21
+ def coerce(value, state:) = (raise NotImplementedError)
21
22
 
22
23
  # @api private
23
24
  #
24
25
  # @param value [Object]
25
26
  #
26
- # @return [Array(true, Object, nil), Array(false, Boolean, Integer)]
27
- def try_strict_coerce(value) = (raise NotImplementedError)
27
+ # @return [Object]
28
+ def dump(value)
29
+ case value
30
+ in Array
31
+ value.map { FinchAPI::Unknown.dump(_1) }
32
+ in Hash
33
+ value.transform_values { FinchAPI::Unknown.dump(_1) }
34
+ in FinchAPI::BaseModel
35
+ value.class.dump(value)
36
+ else
37
+ value
38
+ end
39
+ end
28
40
 
29
41
  # rubocop:enable Lint/UnusedMethodArgument
30
42
 
@@ -44,14 +56,14 @@ module FinchAPI
44
56
  # @return [Proc]
45
57
  def type_info(spec)
46
58
  case spec
47
- in Hash
48
- type_info(spec.slice(:const, :enum, :union).first&.last)
49
59
  in Proc
50
60
  spec
51
- in FinchAPI::Converter | Module | Symbol
52
- -> { spec }
61
+ in Hash
62
+ type_info(spec.slice(:const, :enum, :union).first&.last)
53
63
  in true | false
54
64
  -> { FinchAPI::BooleanModel }
65
+ in FinchAPI::Converter | Class | Symbol
66
+ -> { spec }
55
67
  in NilClass | Integer | Float
56
68
  -> { spec.class }
57
69
  end
@@ -66,108 +78,127 @@ module FinchAPI
66
78
  # converted value
67
79
  # 3. otherwise, the given `value` unaltered
68
80
  #
81
+ # The coercion process is subject to improvement between minor release versions.
82
+ # See https://docs.pydantic.dev/latest/concepts/unions/#smart-mode
83
+ #
69
84
  # @param target [FinchAPI::Converter, Class]
85
+ #
70
86
  # @param value [Object]
71
87
  #
88
+ # @param state [Hash{Symbol=>Object}] The `strictness` is one of `true`, `false`, or `:strong`. This informs the
89
+ # coercion strategy when we have to decide between multiple possible conversion
90
+ # targets:
91
+ #
92
+ # - `true`: the conversion must be exact, with minimum coercion.
93
+ # - `false`: the conversion can be approximate, with some coercion.
94
+ # - `:strong`: the conversion must be exact, with no coercion, and raise an error
95
+ # if not possible.
96
+ #
97
+ # The `exactness` is `Hash` with keys being one of `yes`, `no`, or `maybe`. For
98
+ # any given conversion attempt, the exactness will be updated based on how closely
99
+ # the value recursively matches the target type:
100
+ #
101
+ # - `yes`: the value can be converted to the target type with minimum coercion.
102
+ # - `maybe`: the value can be converted to the target type with some reasonable
103
+ # coercion.
104
+ # - `no`: the value cannot be converted to the target type.
105
+ #
106
+ # See implementation below for more details.
107
+ #
108
+ # @option state [Boolean, :strong] :strictness
109
+ #
110
+ # @option state [Hash{Symbol=>Object}] :exactness
111
+ #
112
+ # @option state [Integer] :branched
113
+ #
72
114
  # @return [Object]
73
- def coerce(target, value)
115
+ def coerce(target, value, state: {strictness: true, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0})
116
+ strictness, exactness = state.fetch_values(:strictness, :exactness)
117
+
74
118
  case target
75
119
  in FinchAPI::Converter
76
- target.coerce(value)
77
- in Symbol
78
- case value
79
- in Symbol | String if (val = value.to_sym) == target
80
- val
81
- else
82
- value
120
+ return target.coerce(value, state: state)
121
+ in Class
122
+ if value.is_a?(target)
123
+ exactness[:yes] += 1
124
+ return value
83
125
  end
84
- in Module
126
+
85
127
  case target
86
128
  in -> { _1 <= NilClass }
87
- nil
129
+ exactness[value.nil? ? :yes : :maybe] += 1
130
+ return nil
88
131
  in -> { _1 <= Integer }
89
- value.is_a?(Numeric) ? Integer(value) : value
132
+ if value.is_a?(Integer)
133
+ exactness[:yes] += 1
134
+ return value
135
+ elsif strictness == :strong
136
+ message = "no implicit conversion of #{value.class} into #{target.inspect}"
137
+ raise TypeError.new(message)
138
+ else
139
+ Kernel.then do
140
+ return Integer(value).tap { exactness[:maybe] += 1 }
141
+ rescue ArgumentError, TypeError
142
+ end
143
+ end
90
144
  in -> { _1 <= Float }
91
- value.is_a?(Numeric) ? Float(value) : value
92
- in -> { _1 <= Symbol }
93
- value.is_a?(String) ? value.to_sym : value
145
+ if value.is_a?(Numeric)
146
+ exactness[:yes] += 1
147
+ return Float(value)
148
+ elsif strictness == :strong
149
+ message = "no implicit conversion of #{value.class} into #{target.inspect}"
150
+ raise TypeError.new(message)
151
+ else
152
+ Kernel.then do
153
+ return Float(value).tap { exactness[:maybe] += 1 }
154
+ rescue ArgumentError, TypeError
155
+ end
156
+ end
94
157
  in -> { _1 <= String }
95
- value.is_a?(Symbol) ? value.to_s : value
158
+ case value
159
+ in String | Symbol | Numeric
160
+ exactness[value.is_a?(Numeric) ? :maybe : :yes] += 1
161
+ return value.to_s
162
+ else
163
+ if strictness == :strong
164
+ message = "no implicit conversion of #{value.class} into #{target.inspect}"
165
+ raise TypeError.new(message)
166
+ end
167
+ end
96
168
  in -> { _1 <= Date || _1 <= Time }
97
- value.is_a?(String) ? target.parse(value) : value
98
- in -> { _1 <= IO }
99
- value.is_a?(String) ? StringIO.new(value) : value
169
+ Kernel.then do
170
+ return target.parse(value).tap { exactness[:yes] += 1 }
171
+ rescue ArgumentError, TypeError => e
172
+ raise e if strictness == :strong
173
+ end
174
+ in -> { _1 <= IO } if value.is_a?(String)
175
+ exactness[:yes] += 1
176
+ return StringIO.new(value.b)
100
177
  else
101
- value
102
178
  end
103
- end
104
- end
105
-
106
- # @api private
107
- #
108
- # @param target [FinchAPI::Converter, Class]
109
- # @param value [Object]
110
- #
111
- # @return [Object]
112
- def dump(target, value)
113
- case target
114
- in FinchAPI::Converter
115
- target.dump(value)
179
+ in Symbol
180
+ if (value.is_a?(Symbol) || value.is_a?(String)) && value.to_sym == target
181
+ exactness[:yes] += 1
182
+ return target
183
+ elsif strictness == :strong
184
+ message = "cannot convert non-matching #{value.class} into #{target.inspect}"
185
+ raise ArgumentError.new(message)
186
+ end
116
187
  else
117
- value
118
188
  end
189
+
190
+ exactness[:no] += 1
191
+ value
119
192
  end
120
193
 
121
194
  # @api private
122
195
  #
123
- # The underlying algorithm for computing maximal compatibility is subject to
124
- # future improvements.
125
- #
126
- # Similar to `#.coerce`, used to determine the best union variant to decode into.
127
- #
128
- # 1. determine if strict-ish coercion is possible
129
- # 2. return either result of successful coercion or if loose coercion is possible
130
- # 3. return a score for recursively tallied count for fields that can be coerced
131
- #
132
196
  # @param target [FinchAPI::Converter, Class]
133
197
  # @param value [Object]
134
198
  #
135
199
  # @return [Object]
136
- def try_strict_coerce(target, value)
137
- case target
138
- in FinchAPI::Converter
139
- target.try_strict_coerce(value)
140
- in Symbol
141
- case value
142
- in Symbol | String if (val = value.to_sym) == target
143
- [true, val, 1]
144
- else
145
- [false, false, 0]
146
- end
147
- in Module
148
- case [target, value]
149
- in [-> { _1 <= NilClass }, _]
150
- [true, nil, value.nil? ? 1 : 0]
151
- in [-> { _1 <= Integer }, Numeric]
152
- [true, Integer(value), 1]
153
- in [-> { _1 <= Float }, Numeric]
154
- [true, Float(value), 1]
155
- in [-> { _1 <= Symbol }, String]
156
- [true, value.to_sym, 1]
157
- in [-> { _1 <= String }, Symbol]
158
- [true, value.to_s, 1]
159
- in [-> { _1 <= Date || _1 <= Time }, String]
160
- Kernel.then do
161
- [true, target.parse(value), 1]
162
- rescue ArgumentError
163
- [false, false, 0]
164
- end
165
- in [_, ^target]
166
- [true, value, 1]
167
- else
168
- [false, false, 0]
169
- end
170
- end
200
+ def dump(target, value)
201
+ target.is_a?(FinchAPI::Converter) ? target.dump(value) : FinchAPI::Unknown.dump(value)
171
202
  end
172
203
  end
173
204
  end
@@ -193,13 +224,23 @@ module FinchAPI
193
224
  def self.==(other) = other.is_a?(Class) && other <= FinchAPI::Unknown
194
225
 
195
226
  class << self
196
- # @!parse
197
- # # @api private
198
- # #
199
- # # @param value [Object]
200
- # #
201
- # # @return [Object]
202
- # def coerce(value) = super
227
+ # @api private
228
+ #
229
+ # @param value [Object]
230
+ #
231
+ # @param state [Hash{Symbol=>Object}] .
232
+ #
233
+ # @option state [Boolean, :strong] :strictness
234
+ #
235
+ # @option state [Hash{Symbol=>Object}] :exactness
236
+ #
237
+ # @option state [Integer] :branched
238
+ #
239
+ # @return [Object]
240
+ def coerce(value, state:)
241
+ state.fetch(:exactness)[:yes] += 1
242
+ value
243
+ end
203
244
 
204
245
  # @!parse
205
246
  # # @api private
@@ -208,16 +249,6 @@ module FinchAPI
208
249
  # #
209
250
  # # @return [Object]
210
251
  # def dump(value) = super
211
-
212
- # @api private
213
- #
214
- # @param value [Object]
215
- #
216
- # @return [Array(true, Object, nil), Array(false, Boolean, Integer)]
217
- def try_strict_coerce(value)
218
- # prevent unknown variant from being chosen during the first coercion pass
219
- [false, true, 0]
220
- end
221
252
  end
222
253
 
223
254
  # rubocop:enable Lint/UnusedMethodArgument
@@ -242,13 +273,23 @@ module FinchAPI
242
273
  def self.==(other) = other.is_a?(Class) && other <= FinchAPI::BooleanModel
243
274
 
244
275
  class << self
245
- # @!parse
246
- # # @api private
247
- # #
248
- # # @param value [Boolean, Object]
249
- # #
250
- # # @return [Boolean, Object]
251
- # def coerce(value) = super
276
+ # @api private
277
+ #
278
+ # @param value [Boolean, Object]
279
+ #
280
+ # @param state [Hash{Symbol=>Object}] .
281
+ #
282
+ # @option state [Boolean, :strong] :strictness
283
+ #
284
+ # @option state [Hash{Symbol=>Object}] :exactness
285
+ #
286
+ # @option state [Integer] :branched
287
+ #
288
+ # @return [Boolean, Object]
289
+ def coerce(value, state:)
290
+ state.fetch(:exactness)[value == true || value == false ? :yes : :no] += 1
291
+ value
292
+ end
252
293
 
253
294
  # @!parse
254
295
  # # @api private
@@ -257,20 +298,6 @@ module FinchAPI
257
298
  # #
258
299
  # # @return [Boolean, Object]
259
300
  # def dump(value) = super
260
-
261
- # @api private
262
- #
263
- # @param value [Object]
264
- #
265
- # @return [Array(true, Object, nil), Array(false, Boolean, Integer)]
266
- def try_strict_coerce(value)
267
- case value
268
- in true | false
269
- [true, value, 1]
270
- else
271
- [false, false, 0]
272
- end
273
- end
274
301
  end
275
302
  end
276
303
 
@@ -333,19 +360,34 @@ module FinchAPI
333
360
  #
334
361
  # @return [Boolean]
335
362
  def ==(other)
336
- other.is_a?(Module) && other.singleton_class.ancestors.include?(FinchAPI::Enum) && other.values.to_set == values.to_set
363
+ other.is_a?(Module) && other.singleton_class <= FinchAPI::Enum && other.values.to_set == values.to_set
337
364
  end
338
365
 
339
366
  # @api private
340
367
  #
368
+ # Unlike with primitives, `Enum` additionally validates that the value is a member
369
+ # of the enum.
370
+ #
341
371
  # @param value [String, Symbol, Object]
342
372
  #
373
+ # @param state [Hash{Symbol=>Object}] .
374
+ #
375
+ # @option state [Boolean, :strong] :strictness
376
+ #
377
+ # @option state [Hash{Symbol=>Object}] :exactness
378
+ #
379
+ # @option state [Integer] :branched
380
+ #
343
381
  # @return [Symbol, Object]
344
- def coerce(value)
345
- case value
346
- in Symbol | String if values.include?(val = value.to_sym)
382
+ def coerce(value, state:)
383
+ exactness = state.fetch(:exactness)
384
+ val = value.is_a?(String) ? value.to_sym : value
385
+
386
+ if values.include?(val)
387
+ exactness[:yes] += 1
347
388
  val
348
389
  else
390
+ exactness[values.first&.class == val.class ? :maybe : :no] += 1
349
391
  value
350
392
  end
351
393
  end
@@ -357,27 +399,6 @@ module FinchAPI
357
399
  # #
358
400
  # # @return [Symbol, Object]
359
401
  # def dump(value) = super
360
-
361
- # @api private
362
- #
363
- # @param value [Object]
364
- #
365
- # @return [Array(true, Object, nil), Array(false, Boolean, Integer)]
366
- def try_strict_coerce(value)
367
- return [true, value, 1] if values.include?(value)
368
-
369
- case value
370
- in Symbol | String if values.include?(val = value.to_sym)
371
- [true, val, 1]
372
- else
373
- case [value, values.first]
374
- in [true | false, true | false] | [Integer, Integer] | [Symbol | String, Symbol]
375
- [false, true, 0]
376
- else
377
- [false, false, 0]
378
- end
379
- end
380
- end
381
402
  end
382
403
 
383
404
  # @api private
@@ -422,9 +443,7 @@ module FinchAPI
422
443
  # All of the specified variants for this union.
423
444
  #
424
445
  # @return [Array<Object>]
425
- def variants
426
- derefed_variants.map(&:last)
427
- end
446
+ def variants = derefed_variants.map(&:last)
428
447
 
429
448
  # @api private
430
449
  #
@@ -454,7 +473,7 @@ module FinchAPI
454
473
  case key
455
474
  in Symbol
456
475
  [key, FinchAPI::Converter.type_info(spec)]
457
- in Proc | FinchAPI::Converter | Module | Hash
476
+ in Proc | FinchAPI::Converter | Class | Hash
458
477
  [nil, FinchAPI::Converter.type_info(key)]
459
478
  end
460
479
 
@@ -471,16 +490,14 @@ module FinchAPI
471
490
  in [_, FinchAPI::BaseModel]
472
491
  value.class
473
492
  in [Symbol, Hash]
474
- key =
475
- if value.key?(@discriminator)
476
- value.fetch(@discriminator)
477
- elsif value.key?((discriminator = @discriminator.to_s))
478
- value.fetch(discriminator)
479
- end
493
+ key = value.fetch(@discriminator) do
494
+ value.fetch(@discriminator.to_s, FinchAPI::Util::OMIT)
495
+ end
496
+
497
+ return nil if key == FinchAPI::Util::OMIT
480
498
 
481
499
  key = key.to_sym if key.is_a?(String)
482
- _, resolved = known_variants.find { |k,| k == key }
483
- resolved.nil? ? FinchAPI::Unknown : resolved.call
500
+ known_variants.find { |k,| k == key }&.last&.call
484
501
  else
485
502
  nil
486
503
  end
@@ -502,36 +519,63 @@ module FinchAPI
502
519
  #
503
520
  # @return [Boolean]
504
521
  def ==(other)
505
- other.is_a?(Module) && other.singleton_class.ancestors.include?(FinchAPI::Union) && other.derefed_variants == derefed_variants
522
+ other.is_a?(Module) && other.singleton_class <= FinchAPI::Union && other.derefed_variants == derefed_variants
506
523
  end
507
524
 
508
525
  # @api private
509
526
  #
510
527
  # @param value [Object]
511
528
  #
529
+ # @param state [Hash{Symbol=>Object}] .
530
+ #
531
+ # @option state [Boolean, :strong] :strictness
532
+ #
533
+ # @option state [Hash{Symbol=>Object}] :exactness
534
+ #
535
+ # @option state [Integer] :branched
536
+ #
512
537
  # @return [Object]
513
- def coerce(value)
514
- if (variant = resolve_variant(value))
515
- return FinchAPI::Converter.coerce(variant, value)
538
+ def coerce(value, state:)
539
+ if (target = resolve_variant(value))
540
+ return FinchAPI::Converter.coerce(target, value, state: state)
516
541
  end
517
542
 
518
- matches = []
543
+ strictness = state.fetch(:strictness)
544
+ exactness = state.fetch(:exactness)
545
+ state[:strictness] = strictness == :strong ? true : strictness
519
546
 
547
+ alternatives = []
520
548
  known_variants.each do |_, variant_fn|
521
- variant = variant_fn.call
522
-
523
- case FinchAPI::Converter.try_strict_coerce(variant, value)
524
- in [true, coerced, _]
549
+ target = variant_fn.call
550
+ exact = state[:exactness] = {yes: 0, no: 0, maybe: 0}
551
+ state[:branched] += 1
552
+
553
+ coerced = FinchAPI::Converter.coerce(target, value, state: state)
554
+ yes, no, maybe = exact.values
555
+ if (no + maybe).zero? || (!strictness && yes.positive?)
556
+ exact.each { exactness[_1] += _2 }
557
+ state[:exactness] = exactness
525
558
  return coerced
526
- in [false, true, score]
527
- matches << [score, variant]
528
- in [false, false, _]
529
- nil
559
+ elsif maybe.positive?
560
+ alternatives << [[-yes, -maybe, no], exact, coerced]
530
561
  end
531
562
  end
532
563
 
533
- _, variant = matches.sort! { _2.first <=> _1.first }.find { |score,| !score.zero? }
534
- variant.nil? ? value : FinchAPI::Converter.coerce(variant, value)
564
+ case alternatives.sort_by(&:first)
565
+ in []
566
+ exactness[:no] += 1
567
+ if strictness == :strong
568
+ message = "no possible conversion of #{value.class} into a variant of #{target.inspect}"
569
+ raise ArgumentError.new(message)
570
+ end
571
+ value
572
+ in [[_, exact, coerced], *]
573
+ exact.each { exactness[_1] += _2 }
574
+ coerced
575
+ end
576
+ .tap { state[:exactness] = exactness }
577
+ ensure
578
+ state[:strictness] = strictness
535
579
  end
536
580
 
537
581
  # @api private
@@ -540,49 +584,16 @@ module FinchAPI
540
584
  #
541
585
  # @return [Object]
542
586
  def dump(value)
543
- if (variant = resolve_variant(value))
544
- return FinchAPI::Converter.dump(variant, value)
545
- end
546
-
547
- known_variants.each do |_, variant_fn|
548
- variant = variant_fn.call
549
- if variant === value
550
- return FinchAPI::Converter.dump(variant, value)
551
- end
587
+ if (target = resolve_variant(value))
588
+ return FinchAPI::Converter.dump(target, value)
552
589
  end
553
- value
554
- end
555
590
 
556
- # @api private
557
- #
558
- # @param value [Object]
559
- #
560
- # @return [Array(true, Object, nil), Array(false, Boolean, Integer)]
561
- def try_strict_coerce(value)
562
- # TODO(ruby) this will result in super linear decoding behaviour for nested unions
563
- # follow up with a decoding context that captures current strictness levels
564
- if (variant = resolve_variant(value))
565
- return Converter.try_strict_coerce(variant, value)
591
+ known_variants.each do
592
+ target = _2.call
593
+ return FinchAPI::Converter.dump(target, value) if target === value
566
594
  end
567
595
 
568
- coercible = false
569
- max_score = 0
570
-
571
- known_variants.each do |_, variant_fn|
572
- variant = variant_fn.call
573
-
574
- case FinchAPI::Converter.try_strict_coerce(variant, value)
575
- in [true, coerced, score]
576
- return [true, coerced, score]
577
- in [false, true, score]
578
- coercible = true
579
- max_score = [max_score, score].max
580
- in [false, false, _]
581
- nil
582
- end
583
- end
584
-
585
- [false, coercible, max_score]
596
+ super
586
597
  end
587
598
 
588
599
  # rubocop:enable Style/CaseEquality
@@ -613,36 +624,46 @@ module FinchAPI
613
624
  # @param other [Object]
614
625
  #
615
626
  # @return [Boolean]
616
- def ===(other)
617
- type = item_type
618
- case other
619
- in Array
620
- # rubocop:disable Style/CaseEquality
621
- other.all? { type === _1 }
622
- # rubocop:enable Style/CaseEquality
623
- else
624
- false
625
- end
626
- end
627
+ def ===(other) = other.is_a?(Array) && other.all?(item_type)
627
628
 
628
629
  # @param other [Object]
629
630
  #
630
631
  # @return [Boolean]
631
- def ==(other) = other.is_a?(FinchAPI::ArrayOf) && other.item_type == item_type
632
+ def ==(other) = other.is_a?(FinchAPI::ArrayOf) && other.nilable? == nilable? && other.item_type == item_type
632
633
 
633
634
  # @api private
634
635
  #
635
636
  # @param value [Enumerable, Object]
636
637
  #
638
+ # @param state [Hash{Symbol=>Object}] .
639
+ #
640
+ # @option state [Boolean, :strong] :strictness
641
+ #
642
+ # @option state [Hash{Symbol=>Object}] :exactness
643
+ #
644
+ # @option state [Integer] :branched
645
+ #
637
646
  # @return [Array<Object>, Object]
638
- def coerce(value)
639
- type = item_type
640
- case value
641
- in Enumerable unless value.is_a?(Hash)
642
- value.map { FinchAPI::Converter.coerce(type, _1) }
643
- else
644
- value
647
+ def coerce(value, state:)
648
+ exactness = state.fetch(:exactness)
649
+
650
+ unless value.is_a?(Array)
651
+ exactness[:no] += 1
652
+ return value
645
653
  end
654
+
655
+ target = item_type
656
+ exactness[:yes] += 1
657
+ value
658
+ .map do |item|
659
+ case [nilable?, item]
660
+ in [true, nil]
661
+ exactness[:yes] += 1
662
+ nil
663
+ else
664
+ FinchAPI::Converter.coerce(target, item, state: state)
665
+ end
666
+ end
646
667
  end
647
668
 
648
669
  # @api private
@@ -651,57 +672,19 @@ module FinchAPI
651
672
  #
652
673
  # @return [Array<Object>, Object]
653
674
  def dump(value)
654
- type = item_type
655
- case value
656
- in Enumerable unless value.is_a?(Hash)
657
- value.map { FinchAPI::Converter.dump(type, _1) }.to_a
658
- else
659
- value
660
- end
675
+ target = item_type
676
+ value.is_a?(Array) ? value.map { FinchAPI::Converter.dump(target, _1) } : super
661
677
  end
662
678
 
663
679
  # @api private
664
680
  #
665
- # @param value [Object]
666
- #
667
- # @return [Array(true, Object, nil), Array(false, Boolean, Integer)]
668
- def try_strict_coerce(value)
669
- case value
670
- in Array
671
- type = item_type
672
- great_success = true
673
- tally = 0
674
-
675
- mapped =
676
- value.map do |item|
677
- case FinchAPI::Converter.try_strict_coerce(type, item)
678
- in [true, coerced, score]
679
- tally += score
680
- coerced
681
- in [false, true, score]
682
- great_success = false
683
- tally += score
684
- item
685
- in [false, false, _]
686
- great_success &&= item.nil?
687
- item
688
- end
689
- end
690
-
691
- if great_success
692
- [true, mapped, tally]
693
- else
694
- [false, true, tally]
695
- end
696
- else
697
- [false, false, 0]
698
- end
699
- end
681
+ # @return [FinchAPI::Converter, Class]
682
+ protected def item_type = @item_type_fn.call
700
683
 
701
684
  # @api private
702
685
  #
703
- # @return [FinchAPI::Converter, Class]
704
- protected def item_type = @item_type_fn.call
686
+ # @return [Boolean]
687
+ protected def nilable? = @nilable
705
688
 
706
689
  # @api private
707
690
  #
@@ -718,6 +701,7 @@ module FinchAPI
718
701
  # @option spec [Boolean] :"nil?"
719
702
  def initialize(type_info, spec = {})
720
703
  @item_type_fn = FinchAPI::Converter.type_info(type_info || spec)
704
+ @nilable = spec[:nil?]
721
705
  end
722
706
  end
723
707
 
@@ -765,24 +749,46 @@ module FinchAPI
765
749
  # @param other [Object]
766
750
  #
767
751
  # @return [Boolean]
768
- def ==(other) = other.is_a?(FinchAPI::HashOf) && other.item_type == item_type
752
+ def ==(other) = other.is_a?(FinchAPI::HashOf) && other.nilable? == nilable? && other.item_type == item_type
769
753
 
770
754
  # @api private
771
755
  #
772
756
  # @param value [Hash{Object=>Object}, Object]
773
757
  #
758
+ # @param state [Hash{Symbol=>Object}] .
759
+ #
760
+ # @option state [Boolean, :strong] :strictness
761
+ #
762
+ # @option state [Hash{Symbol=>Object}] :exactness
763
+ #
764
+ # @option state [Integer] :branched
765
+ #
774
766
  # @return [Hash{Symbol=>Object}, Object]
775
- def coerce(value)
776
- type = item_type
777
- case value
778
- in Hash
779
- value.to_h do |key, val|
780
- coerced = FinchAPI::Converter.coerce(type, val)
781
- [key.is_a?(String) ? key.to_sym : key, coerced]
782
- end
783
- else
784
- value
767
+ def coerce(value, state:)
768
+ exactness = state.fetch(:exactness)
769
+
770
+ unless value.is_a?(Hash)
771
+ exactness[:no] += 1
772
+ return value
785
773
  end
774
+
775
+ target = item_type
776
+ exactness[:yes] += 1
777
+ value
778
+ .to_h do |key, val|
779
+ k = key.is_a?(String) ? key.to_sym : key
780
+ v =
781
+ case [nilable?, val]
782
+ in [true, nil]
783
+ exactness[:yes] += 1
784
+ nil
785
+ else
786
+ FinchAPI::Converter.coerce(target, val, state: state)
787
+ end
788
+
789
+ exactness[:no] += 1 unless k.is_a?(Symbol)
790
+ [k, v]
791
+ end
786
792
  end
787
793
 
788
794
  # @api private
@@ -791,59 +797,19 @@ module FinchAPI
791
797
  #
792
798
  # @return [Hash{Symbol=>Object}, Object]
793
799
  def dump(value)
794
- type = item_type
795
- case value
796
- in Hash
797
- value.transform_values do |val|
798
- FinchAPI::Converter.dump(type, val)
799
- end
800
- else
801
- value
802
- end
800
+ target = item_type
801
+ value.is_a?(Hash) ? value.transform_values { FinchAPI::Converter.dump(target, _1) } : super
803
802
  end
804
803
 
805
804
  # @api private
806
805
  #
807
- # @param value [Object]
808
- #
809
- # @return [Array(true, Object, nil), Array(false, Boolean, Integer)]
810
- def try_strict_coerce(value)
811
- case value
812
- in Hash
813
- type = item_type
814
- great_success = true
815
- tally = 0
816
-
817
- mapped =
818
- value.transform_values do |val|
819
- case FinchAPI::Converter.try_strict_coerce(type, val)
820
- in [true, coerced, score]
821
- tally += score
822
- coerced
823
- in [false, true, score]
824
- great_success = false
825
- tally += score
826
- val
827
- in [false, false, _]
828
- great_success &&= val.nil?
829
- val
830
- end
831
- end
832
-
833
- if great_success
834
- [true, mapped, tally]
835
- else
836
- [false, true, tally]
837
- end
838
- else
839
- [false, false, 0]
840
- end
841
- end
806
+ # @return [FinchAPI::Converter, Class]
807
+ protected def item_type = @item_type_fn.call
842
808
 
843
809
  # @api private
844
810
  #
845
- # @return [FinchAPI::Converter, Class]
846
- protected def item_type = @item_type_fn.call
811
+ # @return [Boolean]
812
+ protected def nilable? = @nilable
847
813
 
848
814
  # @api private
849
815
  #
@@ -860,6 +826,7 @@ module FinchAPI
860
826
  # @option spec [Boolean] :"nil?"
861
827
  def initialize(type_info, spec = {})
862
828
  @item_type_fn = FinchAPI::Converter.type_info(type_info || spec)
829
+ @nilable = spec[:nil?]
863
830
  end
864
831
  end
865
832
 
@@ -886,13 +853,6 @@ module FinchAPI
886
853
  @known_fields ||= (self < FinchAPI::BaseModel ? superclass.known_fields.dup : {})
887
854
  end
888
855
 
889
- # @api private
890
- #
891
- # @return [Hash{Symbol=>Symbol}]
892
- def reverse_map
893
- @reverse_map ||= (self < FinchAPI::BaseModel ? superclass.reverse_map.dup : {})
894
- end
895
-
896
856
  # @api private
897
857
  #
898
858
  # @return [Hash{Symbol=>Hash{Symbol=>Object}}]
@@ -902,11 +862,6 @@ module FinchAPI
902
862
  end
903
863
  end
904
864
 
905
- # @api private
906
- #
907
- # @return [Hash{Symbol=>Proc}]
908
- def defaults = (@defaults ||= {})
909
-
910
865
  # @api private
911
866
  #
912
867
  # @param name_sym [Symbol]
@@ -927,38 +882,40 @@ module FinchAPI
927
882
  private def add_field(name_sym, required:, type_info:, spec:)
928
883
  type_fn, info =
929
884
  case type_info
930
- in Proc | Module | FinchAPI::Converter
885
+ in Proc | FinchAPI::Converter | Class
931
886
  [FinchAPI::Converter.type_info({**spec, union: type_info}), spec]
932
887
  in Hash
933
888
  [FinchAPI::Converter.type_info(type_info), type_info]
934
889
  end
935
890
 
936
- fallback = info[:const]
937
- defaults[name_sym] = fallback if required && !info[:nil?] && info.key?(:const)
938
-
939
- key = info[:api_name]&.tap { reverse_map[_1] = name_sym } || name_sym
940
891
  setter = "#{name_sym}="
892
+ api_name = info.fetch(:api_name, name_sym)
893
+ nilable = info[:nil?]
894
+ const = required && !nilable ? info.fetch(:const, FinchAPI::Util::OMIT) : FinchAPI::Util::OMIT
941
895
 
942
- if known_fields.key?(name_sym)
943
- [name_sym, setter].each { undef_method(_1) }
944
- end
896
+ [name_sym, setter].each { undef_method(_1) } if known_fields.key?(name_sym)
945
897
 
946
- known_fields[name_sym] = {mode: @mode, key: key, required: required, type_fn: type_fn}
898
+ known_fields[name_sym] =
899
+ {
900
+ mode: @mode,
901
+ api_name: api_name,
902
+ required: required,
903
+ nilable: nilable,
904
+ const: const,
905
+ type_fn: type_fn
906
+ }
947
907
 
948
- define_method(setter) do |val|
949
- @data[key] = val
950
- end
908
+ define_method(setter) { @data.store(name_sym, _1) }
951
909
 
952
910
  define_method(name_sym) do
953
- field_type = type_fn.call
954
- value = @data.fetch(key) { self.class.defaults[key] }
955
- FinchAPI::Converter.coerce(field_type, value)
911
+ target = type_fn.call
912
+ value = @data.fetch(name_sym) { const == FinchAPI::Util::OMIT ? nil : const }
913
+ state = {strictness: :strong, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0}
914
+ (nilable || !required) && value.nil? ? nil : FinchAPI::Converter.coerce(target, value, state: state)
956
915
  rescue StandardError
957
- name = self.class.name.split("::").last
958
- raise FinchAPI::ConversionError.new(
959
- "Failed to parse #{name}.#{name_sym} as #{field_type.inspect}. " \
960
- "To get the unparsed API response, use #{name}[:#{key}]."
961
- )
916
+ cls = self.class.name.split("::").last
917
+ message = "Failed to parse #{cls}.#{__method__} from #{value.class} to #{target.inspect}. To get the unparsed API response, use #{cls}[:#{__method__}]."
918
+ raise FinchAPI::ConversionError.new(message)
962
919
  end
963
920
  end
964
921
 
@@ -1024,120 +981,124 @@ module FinchAPI
1024
981
  ensure
1025
982
  @mode = nil
1026
983
  end
984
+
985
+ # @param other [Object]
986
+ #
987
+ # @return [Boolean]
988
+ def ==(other) = other.is_a?(Class) && other <= FinchAPI::BaseModel && other.fields == fields
1027
989
  end
1028
990
 
1029
991
  # @param other [Object]
1030
992
  #
1031
993
  # @return [Boolean]
1032
- def ==(other)
1033
- case other
1034
- in FinchAPI::BaseModel
1035
- self.class.fields == other.class.fields && @data == other.to_h
1036
- else
1037
- false
1038
- end
1039
- end
994
+ def ==(other) = self.class == other.class && @data == other.to_h
1040
995
 
1041
996
  class << self
1042
997
  # @api private
1043
998
  #
1044
999
  # @param value [FinchAPI::BaseModel, Hash{Object=>Object}, Object]
1045
1000
  #
1001
+ # @param state [Hash{Symbol=>Object}] .
1002
+ #
1003
+ # @option state [Boolean, :strong] :strictness
1004
+ #
1005
+ # @option state [Hash{Symbol=>Object}] :exactness
1006
+ #
1007
+ # @option state [Integer] :branched
1008
+ #
1046
1009
  # @return [FinchAPI::BaseModel, Object]
1047
- def coerce(value)
1048
- case FinchAPI::Util.coerce_hash(value)
1049
- in Hash => coerced
1050
- new(coerced)
1051
- else
1052
- value
1010
+ def coerce(value, state:)
1011
+ exactness = state.fetch(:exactness)
1012
+
1013
+ if value.is_a?(self.class)
1014
+ exactness[:yes] += 1
1015
+ return value
1053
1016
  end
1054
- end
1055
1017
 
1056
- # @api private
1057
- #
1058
- # @param value [FinchAPI::BaseModel, Object]
1059
- #
1060
- # @return [Hash{Object=>Object}, Object]
1061
- def dump(value)
1062
- unless (coerced = FinchAPI::Util.coerce_hash(value)).is_a?(Hash)
1018
+ unless (val = FinchAPI::Util.coerce_hash(value)).is_a?(Hash)
1019
+ exactness[:no] += 1
1063
1020
  return value
1064
1021
  end
1022
+ exactness[:yes] += 1
1065
1023
 
1066
- values = coerced.filter_map do |key, val|
1067
- name = key.to_sym
1068
- case (field = known_fields[name])
1069
- in nil
1070
- [name, val]
1071
- else
1072
- mode, type_fn, api_name = field.fetch_values(:mode, :type_fn, :key)
1073
- case mode
1074
- in :coerce
1075
- next
1024
+ keys = val.keys.to_set
1025
+ instance = new
1026
+ data = instance.to_h
1027
+
1028
+ fields.each do |name, field|
1029
+ mode, required, target = field.fetch_values(:mode, :required, :type)
1030
+ api_name, nilable, const = field.fetch_values(:api_name, :nilable, :const)
1031
+
1032
+ unless val.key?(api_name)
1033
+ if const != FinchAPI::Util::OMIT
1034
+ exactness[:yes] += 1
1035
+ elsif required && mode != :dump
1036
+ exactness[nilable ? :maybe : :no] += 1
1076
1037
  else
1077
- target = type_fn.call
1078
- [api_name, FinchAPI::Converter.dump(target, val)]
1038
+ exactness[:yes] += 1
1079
1039
  end
1040
+ next
1080
1041
  end
1081
- end.to_h
1082
1042
 
1083
- defaults.each do |key, val|
1084
- next if values.key?(key)
1043
+ item = val.fetch(api_name)
1044
+ keys.delete(api_name)
1085
1045
 
1086
- values[key] = val
1046
+ converted =
1047
+ if item.nil? && (nilable || !required)
1048
+ exactness[nilable ? :yes : :maybe] += 1
1049
+ nil
1050
+ else
1051
+ coerced = FinchAPI::Converter.coerce(target, item, state: state)
1052
+ case target
1053
+ in FinchAPI::Converter | Symbol
1054
+ coerced
1055
+ else
1056
+ item
1057
+ end
1058
+ end
1059
+ data.store(name, converted)
1087
1060
  end
1088
1061
 
1089
- values
1062
+ keys.each { data.store(_1, val.fetch(_1)) }
1063
+ instance
1090
1064
  end
1091
1065
 
1092
1066
  # @api private
1093
1067
  #
1094
- # @param value [Object]
1068
+ # @param value [FinchAPI::BaseModel, Object]
1095
1069
  #
1096
- # @return [Array(true, Object, nil), Array(false, Boolean, Integer)]
1097
- def try_strict_coerce(value)
1098
- case value
1099
- in Hash | FinchAPI::BaseModel
1100
- value = value.to_h
1101
- else
1102
- return [false, false, 0]
1070
+ # @return [Hash{Object=>Object}, Object]
1071
+ def dump(value)
1072
+ unless (coerced = FinchAPI::Util.coerce_hash(value)).is_a?(Hash)
1073
+ return super
1103
1074
  end
1104
1075
 
1105
- keys = value.keys.to_set
1106
- great_success = true
1107
- tally = 0
1108
1076
  acc = {}
1109
1077
 
1110
- known_fields.each_value do |field|
1111
- mode, required, type_fn, api_name = field.fetch_values(:mode, :required, :type_fn, :key)
1112
- keys.delete(api_name)
1113
-
1114
- case [required && mode != :dump, value.key?(api_name)]
1115
- in [_, true]
1116
- target = type_fn.call
1117
- item = value.fetch(api_name)
1118
- case FinchAPI::Converter.try_strict_coerce(target, item)
1119
- in [true, coerced, score]
1120
- tally += score
1121
- acc[api_name] = coerced
1122
- in [false, true, score]
1123
- great_success = false
1124
- tally += score
1125
- acc[api_name] = item
1126
- in [false, false, _]
1127
- great_success &&= item.nil?
1078
+ coerced.each do |key, val|
1079
+ name = key.is_a?(String) ? key.to_sym : key
1080
+ case (field = known_fields[name])
1081
+ in nil
1082
+ acc.store(name, super(val))
1083
+ else
1084
+ mode, api_name, type_fn = field.fetch_values(:mode, :api_name, :type_fn)
1085
+ case mode
1086
+ in :coerce
1087
+ next
1088
+ else
1089
+ target = type_fn.call
1090
+ acc.store(api_name, FinchAPI::Converter.dump(target, val))
1128
1091
  end
1129
- in [true, false]
1130
- great_success = false
1131
- in [false, false]
1132
- nil
1133
1092
  end
1134
1093
  end
1135
1094
 
1136
- keys.each do |key|
1137
- acc[key] = value.fetch(key)
1095
+ known_fields.each_value do |field|
1096
+ mode, api_name, const = field.fetch_values(:mode, :api_name, :const)
1097
+ next if mode == :coerce || acc.key?(api_name) || const == FinchAPI::Util::OMIT
1098
+ acc.store(api_name, const)
1138
1099
  end
1139
1100
 
1140
- great_success ? [true, new(acc), tally] : [false, true, tally]
1101
+ acc
1141
1102
  end
1142
1103
  end
1143
1104
 
@@ -1177,14 +1138,15 @@ module FinchAPI
1177
1138
  #
1178
1139
  # @return [Hash{Symbol=>Object}]
1179
1140
  def deconstruct_keys(keys)
1180
- (keys || self.class.known_fields.keys).filter_map do |k|
1181
- unless self.class.known_fields.key?(k)
1182
- next
1183
- end
1141
+ (keys || self.class.known_fields.keys)
1142
+ .filter_map do |k|
1143
+ unless self.class.known_fields.key?(k)
1144
+ next
1145
+ end
1184
1146
 
1185
- [k, method(k).call]
1186
- end
1187
- .to_h
1147
+ [k, public_send(k)]
1148
+ end
1149
+ .to_h
1188
1150
  end
1189
1151
 
1190
1152
  # Create a new instance of a model.
@@ -1193,34 +1155,20 @@ module FinchAPI
1193
1155
  def initialize(data = {})
1194
1156
  case FinchAPI::Util.coerce_hash(data)
1195
1157
  in Hash => coerced
1196
- @data = coerced.to_h do |key, value|
1197
- name = key.to_sym
1198
- mapped = self.class.reverse_map.fetch(name, name)
1199
- type = self.class.fields[mapped]&.fetch(:type)
1200
- stored =
1201
- case [type, value]
1202
- in [Module, Hash] if type <= FinchAPI::BaseModel
1203
- type.new(value)
1204
- in [FinchAPI::ArrayOf, Array] | [FinchAPI::HashOf, Hash]
1205
- type.coerce(value)
1206
- else
1207
- value
1208
- end
1209
- [name, stored]
1210
- end
1158
+ @data = coerced
1211
1159
  else
1212
1160
  raise ArgumentError.new("Expected a #{Hash} or #{FinchAPI::BaseModel}, got #{data.inspect}")
1213
1161
  end
1214
1162
  end
1215
1163
 
1216
- # @return [String]
1217
- def to_s = @data.to_s
1218
-
1219
1164
  # @return [String]
1220
1165
  def inspect
1221
- "#<#{self.class.name}:0x#{object_id.to_s(16)} #{deconstruct_keys(nil).map do |k, v|
1222
- "#{k}=#{v.inspect}"
1223
- end.join(' ')}>"
1166
+ rows = self.class.known_fields.keys.map do
1167
+ "#{_1}=#{@data.key?(_1) ? public_send(_1) : ''}"
1168
+ rescue FinchAPI::ConversionError
1169
+ "#{_1}=#{@data.fetch(_1)}"
1170
+ end
1171
+ "#<#{self.class.name}:0x#{object_id.to_s(16)} #{rows.join(' ')}>"
1224
1172
  end
1225
1173
  end
1226
1174
  end