Skip to content

Commit ebaaa42

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 1e06613 commit ebaaa42

File tree

18 files changed

+299
-110
lines changed

18 files changed

+299
-110
lines changed

lib/brand_dev/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 < BrandDev::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 < BrandDev::Errors::Error

lib/brand_dev/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/brand_dev/internal/type/base_model.rb

Lines changed: 77 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def fields
6060
[BrandDev::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 = if required && !nilable
@@ -84,30 +84,61 @@ def fields
8484
type_fn: type_fn
8585
}
8686

87-
define_method(setter) { @data.store(name_sym, _1) }
87+
define_method(setter) do |value|
88+
target = type_fn.call
89+
state = BrandDev::Internal::Type::Converter.new_coerce_state(translate_names: false)
90+
coerced = BrandDev::Internal::Type::Converter.coerce(target, value, state: state)
91+
status = @coerced.store(name_sym, state.fetch(:error) || true)
92+
stored =
93+
case [target, status]
94+
in [BrandDev::Internal::Type::Converter | Symbol, true]
95+
coerced
96+
else
97+
value
98+
end
99+
@data.store(name_sym, stored)
100+
end
88101

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

113144
# @api private
@@ -207,37 +238,44 @@ class << self
207238
#
208239
# @param state [Hash{Symbol=>Object}] .
209240
#
210-
# @option state [Boolean, :strong] :strictness
241+
# @option state [Boolean] :translate_names
242+
#
243+
# @option state [Boolean] :strictness
211244
#
212245
# @option state [Hash{Symbol=>Object}] :exactness
213246
#
247+
# @option state [Class<StandardError>] :error
248+
#
214249
# @option state [Integer] :branched
215250
#
216251
# @return [self, Object]
217252
def coerce(value, state:)
218253
exactness = state.fetch(:exactness)
219254

220-
if value.is_a?(self.class)
255+
if value.is_a?(self)
221256
exactness[:yes] += 1
222257
return value
223258
end
224259

225260
unless (val = BrandDev::Internal::Util.coerce_hash(value)).is_a?(Hash)
226261
exactness[:no] += 1
262+
state[:error] = TypeError.new("#{value.class} can't be coerced into #{Hash}")
227263
return value
228264
end
229265
exactness[:yes] += 1
230266

231267
keys = val.keys.to_set
232268
instance = new
233269
data = instance.to_h
270+
status = instance.instance_variable_get(:@coerced)
234271

235272
# rubocop:disable Metrics/BlockLength
236273
fields.each do |name, field|
237274
mode, required, target = field.fetch_values(:mode, :required, :type)
238275
api_name, nilable, const = field.fetch_values(:api_name, :nilable, :const)
276+
src_name = state.fetch(:translate_names) ? api_name : name
239277

240-
unless val.key?(api_name)
278+
unless val.key?(src_name)
241279
if required && mode != :dump && const == BrandDev::Internal::OMIT
242280
exactness[nilable ? :maybe : :no] += 1
243281
else
@@ -246,9 +284,10 @@ def coerce(value, state:)
246284
next
247285
end
248286

249-
item = val.fetch(api_name)
250-
keys.delete(api_name)
287+
item = val.fetch(src_name)
288+
keys.delete(src_name)
251289

290+
state[:error] = nil
252291
converted =
253292
if item.nil? && (nilable || !required)
254293
exactness[nilable ? :yes : :maybe] += 1
@@ -262,6 +301,8 @@ def coerce(value, state:)
262301
item
263302
end
264303
end
304+
305+
status.store(name, state.fetch(:error) || true)
265306
data.store(name, converted)
266307
end
267308
# rubocop:enable Metrics/BlockLength
@@ -437,7 +478,18 @@ def to_yaml(*a) = BrandDev::Internal::Type::Converter.dump(self.class, self).to_
437478
# Create a new instance of a model.
438479
#
439480
# @param data [Hash{Symbol=>Object}, self]
440-
def initialize(data = {}) = (@data = BrandDev::Internal::Util.coerce_hash!(data).to_h)
481+
def initialize(data = {})
482+
@data = {}
483+
@coerced = {}
484+
BrandDev::Internal::Util.coerce_hash!(data).each do
485+
if self.class.known_fields.key?(_1)
486+
public_send(:"#{_1}=", _2)
487+
else
488+
@data.store(_1, _2)
489+
@coerced.store(_1, false)
490+
end
491+
end
492+
end
441493

442494
class << self
443495
# @api private

lib/brand_dev/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 <= BrandDev::Internal::Type::Bo
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)