Skip to content

Commit b53b6cc

Browse files
fix: issue where we cannot mutate arrays on base model derivatives
array properties are now always recursively coerced into the desire type upon being set, instead of "almost always" hash key names are no longer unnecessarily translated when creating base models via hash coercion errors are now stored and re-thrown instead of being re-computed each property access fixed inconsistencies where sometimes `TypeError`s would be thrown instead of `ArgumentError`s, and vice versa
1 parent c557aa7 commit b53b6cc

18 files changed

Lines changed: 301 additions & 110 deletions

File tree

lib/orb/errors.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,28 @@ class Error < StandardError
99
end
1010

1111
class ConversionError < Orb::Errors::Error
12+
# @return [StandardError, nil]
13+
def cause = @cause.nil? ? super : @cause
14+
15+
# @api private
16+
#
17+
# @param on [Class<StandardError>]
18+
# @param method [Symbol]
19+
# @param target [Object]
20+
# @param value [Object]
21+
# @param cause [StandardError, nil]
22+
def initialize(on:, method:, target:, value:, cause: nil)
23+
cls = on.name.split("::").last
24+
25+
message = [
26+
"Failed to parse #{cls}.#{method} from #{value.class} to #{target.inspect}.",
27+
"To get the unparsed API response, use #{cls}[#{method.inspect}].",
28+
cause && "Cause: #{cause.message}"
29+
].filter(&:itself).join(" ")
30+
31+
@cause = cause
32+
super(message)
33+
end
1234
end
1335

1436
class APIError < Orb::Errors::Error

lib/orb/internal/type/array_of.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,14 @@ def hash = [self.class, item_type].hash
6262
#
6363
# @param state [Hash{Symbol=>Object}] .
6464
#
65-
# @option state [Boolean, :strong] :strictness
65+
# @option state [Boolean] :translate_names
66+
#
67+
# @option state [Boolean] :strictness
6668
#
6769
# @option state [Hash{Symbol=>Object}] :exactness
6870
#
71+
# @option state [Class<StandardError>] :error
72+
#
6973
# @option state [Integer] :branched
7074
#
7175
# @return [Array<Object>, Object]
@@ -74,6 +78,7 @@ def coerce(value, state:)
7478

7579
unless value.is_a?(Array)
7680
exactness[:no] += 1
81+
state[:error] = TypeError.new("#{value.class} can't be coerced into #{Array}")
7782
return value
7883
end
7984

lib/orb/internal/type/base_model.rb

Lines changed: 79 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def fields
6060
[Orb::Internal::Type::Converter.type_info(type_info), type_info]
6161
end
6262

63-
setter = "#{name_sym}="
63+
setter = :"#{name_sym}="
6464
api_name = info.fetch(:api_name, name_sym)
6565
nilable = info.fetch(:nil?, false)
6666
const = required && !nilable ? info.fetch(:const, Orb::Internal::OMIT) : Orb::Internal::OMIT
@@ -77,30 +77,63 @@ def fields
7777
type_fn: type_fn
7878
}
7979

80-
define_method(setter) { @data.store(name_sym, _1) }
80+
define_method(setter) do |value|
81+
target = type_fn.call
82+
state = Orb::Internal::Type::Converter.new_coerce_state(translate_names: false)
83+
coerced = Orb::Internal::Type::Converter.coerce(target, value, state: state)
84+
status = @coerced.store(name_sym, state.fetch(:error) || true)
85+
stored =
86+
case [target, status]
87+
in [Orb::Internal::Type::Converter | Symbol, true]
88+
coerced
89+
else
90+
value
91+
end
92+
@data.store(name_sym, stored)
93+
end
8194

95+
# rubocop:disable Style/CaseEquality
96+
# rubocop:disable Metrics/BlockLength
8297
define_method(name_sym) do
8398
target = type_fn.call
84-
value = @data.fetch(name_sym) { const == Orb::Internal::OMIT ? nil : const }
85-
state = {strictness: :strong, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0}
86-
if (nilable || !required) && value.nil?
87-
nil
88-
else
89-
Orb::Internal::Type::Converter.coerce(
90-
target,
91-
value,
92-
state: state
99+
100+
case @coerced[name_sym]
101+
in true | false if Orb::Internal::Type::Converter === target
102+
@data.fetch(name_sym)
103+
in ::StandardError => e
104+
raise Orb::Errors::ConversionError.new(
105+
on: self.class,
106+
method: __method__,
107+
target: target,
108+
value: @data.fetch(name_sym),
109+
cause: e
93110
)
111+
else
112+
Kernel.then do
113+
value = @data.fetch(name_sym) { const == Orb::Internal::OMIT ? nil : const }
114+
state = Orb::Internal::Type::Converter.new_coerce_state(translate_names: false)
115+
if (nilable || !required) && value.nil?
116+
nil
117+
else
118+
Orb::Internal::Type::Converter.coerce(
119+
target,
120+
value,
121+
state: state
122+
)
123+
end
124+
rescue StandardError => e
125+
raise Orb::Errors::ConversionError.new(
126+
on: self.class,
127+
method: __method__,
128+
target: target,
129+
value: value,
130+
cause: e
131+
)
132+
end
94133
end
95-
rescue StandardError => e
96-
cls = self.class.name.split("::").last
97-
message = [
98-
"Failed to parse #{cls}.#{__method__} from #{value.class} to #{target.inspect}.",
99-
"To get the unparsed API response, use #{cls}[#{__method__.inspect}].",
100-
"Cause: #{e.message}"
101-
].join(" ")
102-
raise Orb::Errors::ConversionError.new(message)
103134
end
135+
# rubocop:enable Metrics/BlockLength
136+
# rubocop:enable Style/CaseEquality
104137
end
105138

106139
# @api private
@@ -200,37 +233,44 @@ class << self
200233
#
201234
# @param state [Hash{Symbol=>Object}] .
202235
#
203-
# @option state [Boolean, :strong] :strictness
236+
# @option state [Boolean] :translate_names
237+
#
238+
# @option state [Boolean] :strictness
204239
#
205240
# @option state [Hash{Symbol=>Object}] :exactness
206241
#
242+
# @option state [Class<StandardError>] :error
243+
#
207244
# @option state [Integer] :branched
208245
#
209246
# @return [self, Object]
210247
def coerce(value, state:)
211248
exactness = state.fetch(:exactness)
212249

213-
if value.is_a?(self.class)
250+
if value.is_a?(self)
214251
exactness[:yes] += 1
215252
return value
216253
end
217254

218255
unless (val = Orb::Internal::Util.coerce_hash(value)).is_a?(Hash)
219256
exactness[:no] += 1
257+
state[:error] = TypeError.new("#{value.class} can't be coerced into #{Hash}")
220258
return value
221259
end
222260
exactness[:yes] += 1
223261

224262
keys = val.keys.to_set
225263
instance = new
226264
data = instance.to_h
265+
status = instance.instance_variable_get(:@coerced)
227266

228267
# rubocop:disable Metrics/BlockLength
229268
fields.each do |name, field|
230269
mode, required, target = field.fetch_values(:mode, :required, :type)
231270
api_name, nilable, const = field.fetch_values(:api_name, :nilable, :const)
271+
src_name = state.fetch(:translate_names) ? api_name : name
232272

233-
unless val.key?(api_name)
273+
unless val.key?(src_name)
234274
if required && mode != :dump && const == Orb::Internal::OMIT
235275
exactness[nilable ? :maybe : :no] += 1
236276
else
@@ -239,9 +279,10 @@ def coerce(value, state:)
239279
next
240280
end
241281

242-
item = val.fetch(api_name)
243-
keys.delete(api_name)
282+
item = val.fetch(src_name)
283+
keys.delete(src_name)
244284

285+
state[:error] = nil
245286
converted =
246287
if item.nil? && (nilable || !required)
247288
exactness[nilable ? :yes : :maybe] += 1
@@ -255,6 +296,8 @@ def coerce(value, state:)
255296
item
256297
end
257298
end
299+
300+
status.store(name, state.fetch(:error) || true)
258301
data.store(name, converted)
259302
end
260303
# rubocop:enable Metrics/BlockLength
@@ -430,7 +473,18 @@ def to_yaml(*a) = Orb::Internal::Type::Converter.dump(self.class, self).to_yaml(
430473
# Create a new instance of a model.
431474
#
432475
# @param data [Hash{Symbol=>Object}, self]
433-
def initialize(data = {}) = (@data = Orb::Internal::Util.coerce_hash!(data).to_h)
476+
def initialize(data = {})
477+
@data = {}
478+
@coerced = {}
479+
Orb::Internal::Util.coerce_hash!(data).each do
480+
if self.class.known_fields.key?(_1)
481+
public_send(:"#{_1}=", _2)
482+
else
483+
@data.store(_1, _2)
484+
@coerced.store(_1, false)
485+
end
486+
end
487+
end
434488

435489
class << self
436490
# @api private

lib/orb/internal/type/boolean.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,20 @@ def self.==(other) = other.is_a?(Class) && other <= Orb::Internal::Type::Boolean
3131
class << self
3232
# @api private
3333
#
34+
# Coerce value to Boolean if possible, otherwise return the original value.
35+
#
3436
# @param value [Boolean, Object]
3537
#
3638
# @param state [Hash{Symbol=>Object}] .
3739
#
38-
# @option state [Boolean, :strong] :strictness
40+
# @option state [Boolean] :translate_names
41+
#
42+
# @option state [Boolean] :strictness
3943
#
4044
# @option state [Hash{Symbol=>Object}] :exactness
4145
#
46+
# @option state [Class<StandardError>] :error
47+
#
4248
# @option state [Integer] :branched
4349
#
4450
# @return [Boolean, Object]

0 commit comments

Comments
 (0)