field_struct 0.15.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 +7 -0
- data/AGENTS.md +58 -0
- data/CHANGELOG.md +653 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/README.md +645 -0
- data/UPGRADING.md +743 -0
- data/USAGE.md +820 -0
- data/docs/getting_started.md +337 -0
- data/lib/field_struct/base.rb +1316 -0
- data/lib/field_struct/error.rb +48 -0
- data/lib/field_struct/errors.rb +103 -0
- data/lib/field_struct/field.rb +179 -0
- data/lib/field_struct/metadata.rb +331 -0
- data/lib/field_struct/model_name.rb +48 -0
- data/lib/field_struct/rbs.rb +299 -0
- data/lib/field_struct/registry.rb +85 -0
- data/lib/field_struct/scaffold.rb +331 -0
- data/lib/field_struct/types/array.rb +156 -0
- data/lib/field_struct/types/base.rb +162 -0
- data/lib/field_struct/types/big_decimal.rb +78 -0
- data/lib/field_struct/types/binary.rb +38 -0
- data/lib/field_struct/types/boolean.rb +126 -0
- data/lib/field_struct/types/date.rb +158 -0
- data/lib/field_struct/types/datetime.rb +163 -0
- data/lib/field_struct/types/email.rb +49 -0
- data/lib/field_struct/types/float.rb +80 -0
- data/lib/field_struct/types/immutable_string.rb +18 -0
- data/lib/field_struct/types/integer.rb +133 -0
- data/lib/field_struct/types/nested.rb +82 -0
- data/lib/field_struct/types/preset_resolver.rb +37 -0
- data/lib/field_struct/types/rfc3339_format.rb +197 -0
- data/lib/field_struct/types/string.rb +73 -0
- data/lib/field_struct/types/symbol.rb +38 -0
- data/lib/field_struct/types/temporal_parser.rb +93 -0
- data/lib/field_struct/types/time.rb +168 -0
- data/lib/field_struct/types/time_format_resolver.rb +54 -0
- data/lib/field_struct/types/union.rb +96 -0
- data/lib/field_struct/types/url.rb +46 -0
- data/lib/field_struct/types/uuid.rb +46 -0
- data/lib/field_struct/types/value.rb +24 -0
- data/lib/field_struct/types/zone_option.rb +267 -0
- data/lib/field_struct/version.rb +7 -0
- data/lib/field_struct.rb +159 -0
- data/sig/field_struct.rbs +2845 -0
- data/skills/field-struct/SKILL.md +183 -0
- metadata +139 -0
|
@@ -0,0 +1,1316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FieldStruct
|
|
4
|
+
# Superclass for FieldStruct value classes.
|
|
5
|
+
#
|
|
6
|
+
# class User < FieldStruct::Base
|
|
7
|
+
# field :name, :string
|
|
8
|
+
# field :age, :integer
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# u = User.new(name: 'Alice', age: '30')
|
|
12
|
+
# u.name # => 'Alice'
|
|
13
|
+
# u.age # => 30 (coerced from the string)
|
|
14
|
+
# u.attributes # => { name: 'Alice', age: 30 }
|
|
15
|
+
#
|
|
16
|
+
# Phase 1 wires up the +field+ macro, getter/setter accessors with
|
|
17
|
+
# coercion via the resolved type, +initialize+ from a hash, +attributes+
|
|
18
|
+
# / +attribute_names+, and metadata inheritance via the +inherited+
|
|
19
|
+
# hook. Macros and validation layer on in later slices.
|
|
20
|
+
class Base
|
|
21
|
+
# Accepted values for the +coercion_policy+ macro (class- and field-level).
|
|
22
|
+
# @return [Array<Symbol>]
|
|
23
|
+
VALID_COERCION_POLICIES = %i[keep_raw replace raise].freeze
|
|
24
|
+
# Accepted values for the +unknown_attributes+ macro.
|
|
25
|
+
# @return [Array<Symbol>]
|
|
26
|
+
VALID_UNKNOWN_POLICIES = %i[ignore raise].freeze
|
|
27
|
+
# Field names {.field} refuses because Base itself calls a method of
|
|
28
|
+
# that name on its own instances — a field would define a reader that
|
|
29
|
+
# shadows it (+field :errors+ broke validation, +field :hash+ broke
|
|
30
|
+
# equality). Curated, not +instance_methods+: names Base never calls on
|
|
31
|
+
# itself (+:method+, +:name+, +:type+) stay legal. {Scaffold::RESERVED}
|
|
32
|
+
# renames these too.
|
|
33
|
+
#
|
|
34
|
+
# @return [Array<Symbol>]
|
|
35
|
+
RESERVED_FIELD_NAMES = %i[
|
|
36
|
+
errors attributes attribute_names assign_attributes
|
|
37
|
+
as_json to_json to_h to_model model_name inspect hash class
|
|
38
|
+
send public_send freeze instance_variable_set initialize initialize_copy raise with dup
|
|
39
|
+
deconstruct deconstruct_keys
|
|
40
|
+
json_value temporal_json_value apply_defaults run_cross_field_validators
|
|
41
|
+
validate_field apply_coercion_policy record_coercion_failure
|
|
42
|
+
].freeze
|
|
43
|
+
# Sentinel default for the macro readers/writers, so +nil+ can be a value.
|
|
44
|
+
# @return [Object]
|
|
45
|
+
UNSET = Object.new.freeze
|
|
46
|
+
private_constant :UNSET
|
|
47
|
+
# Oj options for {.from_json}, built once rather than as a literal per call.
|
|
48
|
+
# @return [Hash{Symbol=>Object}]
|
|
49
|
+
JSON_LOAD_OPTIONS = {mode: :compat, decimal_class: ::BigDecimal}.freeze
|
|
50
|
+
private_constant :JSON_LOAD_OPTIONS
|
|
51
|
+
# Oj options for {#to_json}.
|
|
52
|
+
# @return [Hash{Symbol=>Object}]
|
|
53
|
+
JSON_DUMP_OPTIONS = {mode: :compat}.freeze
|
|
54
|
+
private_constant :JSON_DUMP_OPTIONS
|
|
55
|
+
|
|
56
|
+
class << self
|
|
57
|
+
# @return [Metadata] the per-class field collection (memoized)
|
|
58
|
+
def metadata
|
|
59
|
+
@metadata ||= Metadata.new
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Mark this class (and its descendants) immutable. Setters called
|
|
63
|
+
# after {#initialize} completes raise {ImmutableError}. One-way
|
|
64
|
+
# switch: there is no companion +mutable!+ macro in Phase 1.
|
|
65
|
+
#
|
|
66
|
+
# @return [true]
|
|
67
|
+
def immutable!
|
|
68
|
+
@immutable = true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Boolean] whether this class is marked immutable
|
|
72
|
+
# (locally or via an ancestor)
|
|
73
|
+
def immutable?
|
|
74
|
+
return @immutable if defined?(@immutable)
|
|
75
|
+
return false if equal?(FieldStruct::Base)
|
|
76
|
+
|
|
77
|
+
superclass.immutable?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Mark this class (and its descendants) frozen-on-construct.
|
|
81
|
+
# Instances will be Ruby-frozen at the end of {#initialize} —
|
|
82
|
+
# any subsequent attempt to mutate ivars raises +FrozenError+.
|
|
83
|
+
#
|
|
84
|
+
# Independent of {.immutable!}. +immutable!+ blocks our setters
|
|
85
|
+
# with a custom error; +frozen!+ engages Ruby's frozen-state
|
|
86
|
+
# mechanism, which also locks the instance against any other
|
|
87
|
+
# ivar mutation. Pick the one that matches the level of
|
|
88
|
+
# strictness you need (or stack both).
|
|
89
|
+
#
|
|
90
|
+
# @return [true]
|
|
91
|
+
def frozen!
|
|
92
|
+
@frozen_on_construct = true
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [Boolean] whether instances of this class are frozen at
|
|
96
|
+
# the end of {#initialize} (locally or via an ancestor)
|
|
97
|
+
def frozen_on_construct?
|
|
98
|
+
return @frozen_on_construct if defined?(@frozen_on_construct)
|
|
99
|
+
return false if equal?(FieldStruct::Base)
|
|
100
|
+
|
|
101
|
+
superclass.frozen_on_construct?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Class macro: get or set the policy used by the setter pipeline
|
|
105
|
+
# when a value can't be coerced into a field's declared type.
|
|
106
|
+
#
|
|
107
|
+
# coercion_policy :keep_raw # default — store raw, add error
|
|
108
|
+
# coercion_policy :replace # store nil, add error
|
|
109
|
+
# coercion_policy :raise # raise CoercionError
|
|
110
|
+
#
|
|
111
|
+
# Inherited by subclasses; descendants can override. Default on
|
|
112
|
+
# Base itself is +:keep_raw+.
|
|
113
|
+
#
|
|
114
|
+
# @param value [:keep_raw, :replace, :raise] policy to set; omit to read
|
|
115
|
+
# @return [:keep_raw, :replace, :raise] the resolved policy
|
|
116
|
+
def coercion_policy(value = UNSET)
|
|
117
|
+
if value.equal?(UNSET)
|
|
118
|
+
return @coercion_policy if defined?(@coercion_policy)
|
|
119
|
+
return :keep_raw if equal?(FieldStruct::Base)
|
|
120
|
+
|
|
121
|
+
superclass.coercion_policy
|
|
122
|
+
else
|
|
123
|
+
unless VALID_COERCION_POLICIES.include?(value)
|
|
124
|
+
raise ArgumentError, "unknown coercion policy #{value.inspect} " \
|
|
125
|
+
"(expected one of #{VALID_COERCION_POLICIES.inspect})"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
@coercion_policy = value
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Class macro: get or set how {Base#initialize} responds to input
|
|
133
|
+
# keys that do not match any declared field.
|
|
134
|
+
#
|
|
135
|
+
# unknown_attributes :ignore # default — silently drop them
|
|
136
|
+
# unknown_attributes :raise # raise UnknownAttributeError
|
|
137
|
+
#
|
|
138
|
+
# Inherited by subclasses; descendants can override.
|
|
139
|
+
#
|
|
140
|
+
# @param value [:ignore, :raise] policy to set; omit to read
|
|
141
|
+
# @return [:ignore, :raise] the resolved policy
|
|
142
|
+
def unknown_attributes(value = UNSET)
|
|
143
|
+
if value.equal?(UNSET)
|
|
144
|
+
return @unknown_attributes if defined?(@unknown_attributes)
|
|
145
|
+
return :ignore if equal?(FieldStruct::Base)
|
|
146
|
+
|
|
147
|
+
superclass.unknown_attributes
|
|
148
|
+
else
|
|
149
|
+
unless VALID_UNKNOWN_POLICIES.include?(value)
|
|
150
|
+
raise ArgumentError, "unknown unknown_attributes policy #{value.inspect} " \
|
|
151
|
+
"(expected one of #{VALID_UNKNOWN_POLICIES.inspect})"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
@unknown_attributes = value
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @return [Array<Symbol>] declared field names in insertion order
|
|
159
|
+
def attribute_names
|
|
160
|
+
metadata.names
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Declare a field. Resolves +type_arg+ — a registered symbol or a
|
|
164
|
+
# {FieldStruct::Base} subclass — into a type class (and, for
|
|
165
|
+
# parameterized types like {Types::Nested}, a pre-built instance),
|
|
166
|
+
# builds a {Field}, adds it to the class's {Metadata}, and defines
|
|
167
|
+
# the getter/setter pair.
|
|
168
|
+
#
|
|
169
|
+
# @param name [Symbol, String] canonical field name
|
|
170
|
+
# @param type_arg [Symbol, Class<Types::Base>, Class<FieldStruct::Base>]
|
|
171
|
+
# a registered name in the resolving registry, OR a +Types::Base+
|
|
172
|
+
# subclass, OR a +FieldStruct::Base+ subclass (auto-wrapped in
|
|
173
|
+
# {Types::Nested})
|
|
174
|
+
# @param options [Object, nil] each keyword option's value: +required:+
|
|
175
|
+
# (Boolean), +default:+ (literal or callable), +coercion_policy:+ (Symbol),
|
|
176
|
+
# +description:+ / +desc:+ (String), +rbs_type:+ (String — an RBS type
|
|
177
|
+
# expression {RBS.generate} emits for this field instead of the derived one),
|
|
178
|
+
# +sensitive:+ (Boolean — mask the value in +inspect+; see {Field#sensitive?}),
|
|
179
|
+
# plus type-specific options (e.g. +format:+, +round:+, +of:+,
|
|
180
|
+
# +values:+, +enum:+, +in:+) — see each type's +coerce+ for the
|
|
181
|
+
# options it consumes
|
|
182
|
+
# @return [Metadata] the class-level metadata, so the DSL reads
|
|
183
|
+
# cleanly in IRB (each line prints the running set of declared
|
|
184
|
+
# fields rather than just the most recently added one). The
|
|
185
|
+
# added Field is still available as +metadata[name]+.
|
|
186
|
+
def field(name, type_arg, **options)
|
|
187
|
+
refuse_reserved_name!(name)
|
|
188
|
+
type_class, type_instance = resolve_type_arg(type_arg)
|
|
189
|
+
if type_class <= FieldStruct::Types::Union
|
|
190
|
+
type_instance, options = build_union_instance(type_class, options)
|
|
191
|
+
end
|
|
192
|
+
options = resolve_array_options(type_class, options)
|
|
193
|
+
options = apply_default_format(type_class, options)
|
|
194
|
+
options = type_class.resolve_options(options)
|
|
195
|
+
validate_options!(type_class, options)
|
|
196
|
+
options = resolve_element_options(name, options) if type_class <= FieldStruct::Types::Array
|
|
197
|
+
required = options.delete(:required) { false }
|
|
198
|
+
default = options.delete(:default)
|
|
199
|
+
coercion_policy = options.delete(:coercion_policy)
|
|
200
|
+
validate_coercion_policy_override!(coercion_policy) if coercion_policy
|
|
201
|
+
description = extract_description!(options)
|
|
202
|
+
rbs_type = options.delete(:rbs_type)
|
|
203
|
+
validate_rbs_type!(rbs_type) unless rbs_type.nil?
|
|
204
|
+
sensitive = options.delete(:sensitive) { false }
|
|
205
|
+
validate_sensitive!(sensitive)
|
|
206
|
+
field = Field.new(
|
|
207
|
+
name: name, type: type_class, type_instance: type_instance,
|
|
208
|
+
required: required, default: default,
|
|
209
|
+
coercion_policy: coercion_policy, description: description,
|
|
210
|
+
rbs_type: rbs_type, sensitive: sensitive, **options
|
|
211
|
+
)
|
|
212
|
+
metadata.add(field)
|
|
213
|
+
define_field_accessors(field)
|
|
214
|
+
metadata
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Sugar for {.field} with +required: true+.
|
|
218
|
+
#
|
|
219
|
+
# @param name [Symbol, String]
|
|
220
|
+
# @param type_name [Symbol, Class<Types::Base>, Class<FieldStruct::Base>]
|
|
221
|
+
# @param options [Object, nil] keyword options, as for {.field}
|
|
222
|
+
# @return [Metadata]
|
|
223
|
+
def required(name, type_name, **options)
|
|
224
|
+
field(name, type_name, **options, required: true)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Sugar for {.field} with +required: false+ (the default).
|
|
228
|
+
#
|
|
229
|
+
# @param name [Symbol, String]
|
|
230
|
+
# @param type_name [Symbol, Class<Types::Base>, Class<FieldStruct::Base>]
|
|
231
|
+
# @param options [Object, nil] keyword options, as for {.field}
|
|
232
|
+
# @return [Metadata]
|
|
233
|
+
def optional(name, type_name, **options)
|
|
234
|
+
field(name, type_name, **options, required: false)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Declare an external-name mapping for a serialization format.
|
|
238
|
+
# The mapping reads internal-field-name (Symbol) on the left, the
|
|
239
|
+
# external name (String) on the right; it applies to both
|
|
240
|
+
# directions (import reverse-maps, export forward-maps).
|
|
241
|
+
#
|
|
242
|
+
# serialize :json,
|
|
243
|
+
# first_name: 'firstName',
|
|
244
|
+
# last_name: 'lastName'
|
|
245
|
+
#
|
|
246
|
+
# Fields not listed in the mapping serialize under their canonical
|
|
247
|
+
# name (identity). Repeating +serialize :json, ...+ on the same
|
|
248
|
+
# class replaces the prior mapping (last-write-wins).
|
|
249
|
+
#
|
|
250
|
+
# All mapping keys must be declared fields at the moment +serialize+
|
|
251
|
+
# is called — declare fields first, then declare serializations.
|
|
252
|
+
# Mapping values are normalized to Strings.
|
|
253
|
+
#
|
|
254
|
+
# The +:json+ format ships in-gem (Phase B wires it into +to_json+ /
|
|
255
|
+
# +from_json+). Future formats (CSV, XML, etc.) will be supported
|
|
256
|
+
# by downstream gems reading the same metadata.
|
|
257
|
+
#
|
|
258
|
+
# @param name [Symbol, String] the format name (e.g. +:json+)
|
|
259
|
+
# @param mapping [String, Symbol] each external key, passed as a
|
|
260
|
+
# keyword named for the internal field. Symbol values are
|
|
261
|
+
# normalized to Strings.
|
|
262
|
+
# @return [self]
|
|
263
|
+
# @raise [ArgumentError] when a mapping key is not a declared field
|
|
264
|
+
def serialize(name, **mapping)
|
|
265
|
+
validate_serialize_mapping!(name, mapping)
|
|
266
|
+
normalized = mapping.transform_values(&:to_s)
|
|
267
|
+
metadata.add_serialization(name, normalized)
|
|
268
|
+
self
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Resolve a type name through the registry chain visible to this
|
|
272
|
+
# class. Walks the containing modules of the class's name in
|
|
273
|
+
# outermost-first order, looking for the nearest one that responds
|
|
274
|
+
# to +field_types+. Falls back to {FieldStruct.types}.
|
|
275
|
+
#
|
|
276
|
+
# @param type_name [Symbol]
|
|
277
|
+
# @return [Class] a Types::Base subclass
|
|
278
|
+
def resolve_type(type_name)
|
|
279
|
+
registry = namespace_field_types || FieldStruct.types
|
|
280
|
+
registry.lookup(type_name)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Resolve the second positional argument of {.field}.
|
|
284
|
+
#
|
|
285
|
+
# @param type_arg [Symbol, Class]
|
|
286
|
+
# @return [Array(Class, Types::Base)] +[type_class, type_instance]+ —
|
|
287
|
+
# +type_instance+ is +nil+ for stock symbolic types (Field will
|
|
288
|
+
# call +type_class.new+ itself); for nested-class args (and for
|
|
289
|
+
# symbol args that resolve to a FieldStruct::Base subclass) it's
|
|
290
|
+
# a pre-built {Types::Nested} instance.
|
|
291
|
+
def resolve_type_arg(type_arg)
|
|
292
|
+
klass = type_arg.is_a?(::Class) ? type_arg : resolve_type(type_arg)
|
|
293
|
+
if klass.is_a?(::Class) && klass < FieldStruct::Base
|
|
294
|
+
[FieldStruct::Types::Nested, FieldStruct::Types::Nested.new(klass)]
|
|
295
|
+
else
|
|
296
|
+
[klass, nil]
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# @return [ModelName] an ActiveModel::Name-shaped value for this class
|
|
301
|
+
def model_name
|
|
302
|
+
@model_name ||= ModelName.new(name)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# A human/agent-readable summary of this class's schema: the class name
|
|
306
|
+
# followed by one line per field (type, required-ness, and the native
|
|
307
|
+
# options that field's type accepts). A single call that answers "what
|
|
308
|
+
# does this model look like, and what can I put where" without reading
|
|
309
|
+
# source. See {Metadata#describe} for the field-line format.
|
|
310
|
+
#
|
|
311
|
+
# @return [String]
|
|
312
|
+
def describe
|
|
313
|
+
"#{name || "AnonymousFieldStruct"}\n#{metadata.describe}"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Parse a JSON string and build an instance.
|
|
317
|
+
#
|
|
318
|
+
# Person.from_json('{"name":"Alice","address":{"street":"1","city":"NYC"}}')
|
|
319
|
+
# # => #<Person name: "Alice", address: #<Address ...>>
|
|
320
|
+
#
|
|
321
|
+
# The parsed hash is fed through {#initialize} like any other input,
|
|
322
|
+
# so the full setter pipeline runs: coercion (strings re-coerce to
|
|
323
|
+
# Date/Time/BigDecimal), nested Hash → Types::Nested instantiation,
|
|
324
|
+
# +unknown_attributes+ policy, +coercion_policy+. Invalid JSON
|
|
325
|
+
# raises the underlying Oj parse error's class (+EncodingError+ or a
|
|
326
|
+
# subclass) with its position but not the input, which Oj's own
|
|
327
|
+
# message quotes; a non-object root (+[...]+, +"hi"+, +42+) raises
|
|
328
|
+
# +ArgumentError+ naming its class.
|
|
329
|
+
#
|
|
330
|
+
# JSON numbers with a fraction or exponent parse as +BigDecimal+, never
|
|
331
|
+
# through a Float, so a +:decimal+ field gets the exact digits sent.
|
|
332
|
+
# +:float+ fields still convert to Float; +:value+ fields (and numbers
|
|
333
|
+
# inside their Hashes and Arrays) receive the +BigDecimal+.
|
|
334
|
+
#
|
|
335
|
+
# @param json_string [String]
|
|
336
|
+
# @return [Base]
|
|
337
|
+
# @raise [ArgumentError] when the parsed root is not a JSON object
|
|
338
|
+
# @raise [EncodingError] when the input is not valid JSON
|
|
339
|
+
def from_json(json_string)
|
|
340
|
+
parsed = parse_json(json_string)
|
|
341
|
+
raise ArgumentError, "from_json expects a JSON object root, got #{parsed.class}" unless parsed.is_a?(::Hash)
|
|
342
|
+
|
|
343
|
+
new(canonicalize_serialized_hash(parsed))
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Oj quotes the whole input in its parse-error message, and that input
|
|
347
|
+
# may be payments data. Re-raise the same class with Oj's description
|
|
348
|
+
# and position only, and without the original as +cause+ (which error
|
|
349
|
+
# reporters print).
|
|
350
|
+
#
|
|
351
|
+
# @api private
|
|
352
|
+
# @param json_string [String]
|
|
353
|
+
# @return [Object, nil] the parsed JSON value
|
|
354
|
+
# @raise [EncodingError] when the input is not valid JSON
|
|
355
|
+
def parse_json(json_string)
|
|
356
|
+
Oj.load(json_string, JSON_LOAD_OPTIONS)
|
|
357
|
+
rescue ::EncodingError => e
|
|
358
|
+
description = e.message[/\A[^(\[]*/].to_s.strip
|
|
359
|
+
position = e.message[/line \d+, column \d+/]
|
|
360
|
+
detail = [description, position && "at #{position}"].compact.reject(&:empty?).join(' ')
|
|
361
|
+
raise e.class, "from_json could not parse its input as JSON (#{detail})", e.backtrace, cause: nil
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Walk a parsed JSON hash (string-keyed, possibly with external
|
|
365
|
+
# names) and reverse-map every key to its canonical Symbol via
|
|
366
|
+
# +metadata.serialization(:json)+. Recurses into nested
|
|
367
|
+
# FieldStruct values and arrays of nested. Unmapped keys are
|
|
368
|
+
# converted to Symbols and passed through — they'll be handled
|
|
369
|
+
# by +new+'s +unknown_attributes+ check.
|
|
370
|
+
#
|
|
371
|
+
# @api private
|
|
372
|
+
# @param hash [Hash{String, Symbol=>Object, nil}] parsed JSON object
|
|
373
|
+
# @return [Hash{Symbol=>Object, nil}] canonical-keyed input ready for +.new+
|
|
374
|
+
def canonicalize_serialized_hash(hash)
|
|
375
|
+
reverse = metadata.reverse_serialization(:json)
|
|
376
|
+
|
|
377
|
+
hash.each_with_object({}) do |(raw_key, value), result|
|
|
378
|
+
canonical = reverse[raw_key.to_s] || raw_key.to_sym
|
|
379
|
+
field = metadata[canonical]
|
|
380
|
+
result[canonical] = canonicalize_serialized_value(value, field)
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Reverse-map a single serialized value for its field, recursing into
|
|
385
|
+
# nested FieldStructs and arrays of nested. Companion to
|
|
386
|
+
# {.canonicalize_serialized_hash}.
|
|
387
|
+
#
|
|
388
|
+
# @api private
|
|
389
|
+
# @param value [Object, nil] the parsed JSON value
|
|
390
|
+
# @param field [Field, nil] the field it is for; +nil+ for an unknown key
|
|
391
|
+
# @return [Object, nil] +value+, with nested Hashes reverse-mapped
|
|
392
|
+
def canonicalize_serialized_value(value, field)
|
|
393
|
+
return value if field.nil?
|
|
394
|
+
|
|
395
|
+
if value.is_a?(::Hash) && field.type <= FieldStruct::Types::Nested
|
|
396
|
+
field.type_instance.struct_class.canonicalize_serialized_hash(value)
|
|
397
|
+
elsif value.is_a?(::Hash) && field.type <= FieldStruct::Types::Union
|
|
398
|
+
canonicalize_serialized_union_hash(value, field.type_instance)
|
|
399
|
+
elsif value.is_a?(::Array) && field.type <= FieldStruct::Types::Array
|
|
400
|
+
of_type = field.options[:of_type]
|
|
401
|
+
if of_type.is_a?(FieldStruct::Types::Nested)
|
|
402
|
+
nested_class = of_type.struct_class
|
|
403
|
+
value.map { |element| element.is_a?(::Hash) ? nested_class.canonicalize_serialized_hash(element) : element }
|
|
404
|
+
else
|
|
405
|
+
value
|
|
406
|
+
end
|
|
407
|
+
else
|
|
408
|
+
value
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Which external names apply depends on which member the Hash is for,
|
|
413
|
+
# so try the members in declared order, as {Types::Union#coerce} does:
|
|
414
|
+
# a struct member gets the Hash reverse-mapped through its own
|
|
415
|
+
# +serialize :json+ mapping, and the first member that accepts wins. A
|
|
416
|
+
# winning struct is returned built (the union's coerce passes it
|
|
417
|
+
# through to that member); a winning non-struct member gets the Hash
|
|
418
|
+
# unchanged. When nothing accepts it, the Hash goes on as-is and the
|
|
419
|
+
# setter's +coercion_policy+ handles the failure.
|
|
420
|
+
#
|
|
421
|
+
# @api private
|
|
422
|
+
# @param hash [Hash{String, Symbol=>Object, nil}] parsed JSON object for a union field
|
|
423
|
+
# @param union [Types::Union]
|
|
424
|
+
# @return [Base, Hash{String, Symbol=>Object, nil}]
|
|
425
|
+
def canonicalize_serialized_union_hash(hash, union)
|
|
426
|
+
union.member_types.each do |member|
|
|
427
|
+
if member.is_a?(FieldStruct::Types::Nested)
|
|
428
|
+
return member.coerce(member.struct_class.canonicalize_serialized_hash(hash))
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
member.coerce(hash)
|
|
432
|
+
return hash
|
|
433
|
+
rescue *FieldStruct::Types::Union::RESCUABLE
|
|
434
|
+
next
|
|
435
|
+
end
|
|
436
|
+
hash
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
private
|
|
440
|
+
|
|
441
|
+
# @return [Registry, nil] the +field_types+ of the nearest enclosing
|
|
442
|
+
# module that defines one, or +nil+ to fall back to {FieldStruct.types}
|
|
443
|
+
def namespace_field_types
|
|
444
|
+
return nil unless name
|
|
445
|
+
|
|
446
|
+
parts = name.split('::')
|
|
447
|
+
parts.pop # drop the class's own name
|
|
448
|
+
|
|
449
|
+
until parts.empty?
|
|
450
|
+
mod = ::Object.const_get(parts.join('::'))
|
|
451
|
+
return mod.field_types if mod.respond_to?(:field_types)
|
|
452
|
+
|
|
453
|
+
parts.pop
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
nil
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# @param type_class [Class<Types::Base>]
|
|
460
|
+
# @param options [Hash{Symbol=>Object, nil}] the field's declared options
|
|
461
|
+
# @return [Hash{Symbol=>Object, nil}] for an +:array+ field, +of:+ resolved
|
|
462
|
+
# into +of_type:+; otherwise +options+ unchanged
|
|
463
|
+
def resolve_array_options(type_class, options)
|
|
464
|
+
return options unless type_class <= FieldStruct::Types::Array
|
|
465
|
+
raise ArgumentError, 'array field requires an `of:` option naming the element type' unless options.key?(:of)
|
|
466
|
+
|
|
467
|
+
rest = options.dup
|
|
468
|
+
element_class, element_instance = resolve_type_arg(rest.delete(:of))
|
|
469
|
+
# of_type first, so to_h / describe read like the declaration.
|
|
470
|
+
{of_type: element_instance || element_class}.merge(rest)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Check an array field's +element:+ options against the element type's
|
|
474
|
+
# own schema, exactly as a scalar field of that type is checked, and
|
|
475
|
+
# store them resolved and frozen. An empty or absent +element:+ leaves
|
|
476
|
+
# no +:element+ key, so an array without one is unchanged.
|
|
477
|
+
#
|
|
478
|
+
# @param name [Symbol, String] the field name, for error messages
|
|
479
|
+
# @param options [Hash{Symbol=>Object, nil}] the array field's resolved options
|
|
480
|
+
# @return [Hash{Symbol=>Object, nil}] +options+ with +:element+ resolved
|
|
481
|
+
# @raise [ArgumentError] prefixed +in element: of array field :name,+
|
|
482
|
+
# when an element option is not one the element type accepts, has
|
|
483
|
+
# the wrong shape, or is refused by the element type's resolver
|
|
484
|
+
def resolve_element_options(name, options)
|
|
485
|
+
element = options[:element]
|
|
486
|
+
return options.except(:element) if element.nil? || element.empty?
|
|
487
|
+
|
|
488
|
+
element_class = FieldStruct::Types::Array.element_class(options[:of_type])
|
|
489
|
+
resolved = element_class.resolve_options(element_options_checked(element_class, element))
|
|
490
|
+
validate_options!(element_class, resolved)
|
|
491
|
+
options.merge(element: resolved.freeze)
|
|
492
|
+
rescue ArgumentError => e
|
|
493
|
+
raise ArgumentError, "in element: of array field :#{name}, #{e.message}"
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# @param element_class [Class<Types::Base>] the array's element type
|
|
497
|
+
# @param element [Hash{Symbol=>Object, nil}] the declared +element:+ options
|
|
498
|
+
# @return [Hash{Symbol=>Object, nil}] +element+, every key native to +element_class+
|
|
499
|
+
# @raise [ArgumentError] on a key that isn't one of +element_class+'s
|
|
500
|
+
# options, or when the element type is an Array or Union
|
|
501
|
+
def element_options_checked(element_class, element)
|
|
502
|
+
if element_class <= FieldStruct::Types::Array || element_class <= FieldStruct::Types::Union
|
|
503
|
+
raise ArgumentError, "element options are not supported for #{short_type_name(element_class)} elements"
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
schema = element_class.option_schema
|
|
507
|
+
element.each_key do |key|
|
|
508
|
+
next if schema.key?(key)
|
|
509
|
+
|
|
510
|
+
if UNIVERSAL_FIELD_OPTIONS.include?(key)
|
|
511
|
+
raise ArgumentError, "#{key}: is a field option, not an element option. Declare it on the field itself."
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
type = short_type_name(element_class)
|
|
515
|
+
accepts = schema.empty? ? 'accepts no options' : "accepts: #{schema.keys.join(", ")}"
|
|
516
|
+
raise ArgumentError, "#{key}: is not #{article(type)} #{type} option. #{type} #{accepts}."
|
|
517
|
+
end
|
|
518
|
+
element
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# @param word [String]
|
|
522
|
+
# @return [String] +"an"+ before a vowel, otherwise +"a"+
|
|
523
|
+
def article(word)
|
|
524
|
+
word.match?(/\A[AEIOU]/i) ? 'an' : 'a'
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# @param type_class [Class<Types::Union>]
|
|
528
|
+
# @param options [Hash{Symbol=>Object, nil}] the field's declared options
|
|
529
|
+
# @return [Array(Types::Union, Hash{Symbol=>Object, nil})] the built union
|
|
530
|
+
# and the options without +of:+
|
|
531
|
+
def build_union_instance(type_class, options)
|
|
532
|
+
raise ArgumentError, 'union field requires an `of: [...]` option naming the member types' unless options.key?(:of)
|
|
533
|
+
|
|
534
|
+
rest = options.dup
|
|
535
|
+
members = rest.delete(:of)
|
|
536
|
+
raise ArgumentError, 'union `of:` must be an Array of member types' unless members.is_a?(::Array)
|
|
537
|
+
raise ArgumentError, 'union `of:` must have at least two members' if members.size < 2
|
|
538
|
+
|
|
539
|
+
instances = members.map do |member|
|
|
540
|
+
member_class, member_instance = resolve_type_arg(member)
|
|
541
|
+
member_instance || member_class.new
|
|
542
|
+
end
|
|
543
|
+
[type_class.new(instances), rest]
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# @param type_class [Class<Types::Base>]
|
|
547
|
+
# @param options [Hash{Symbol=>Object, nil}] the field's declared options
|
|
548
|
+
# @return [Hash{Symbol=>Object, nil}] +options+ with the type's default
|
|
549
|
+
# +format:+ added when it has one and none was declared
|
|
550
|
+
def apply_default_format(type_class, options)
|
|
551
|
+
return options if options.key?(:format)
|
|
552
|
+
return options unless type_class.respond_to?(:default_format)
|
|
553
|
+
|
|
554
|
+
options.merge(format: type_class.default_format)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# Pull +description:+ (canonical) or +desc:+ (alias) out of the
|
|
558
|
+
# field-declaration options. Raises if both are given — pick one.
|
|
559
|
+
#
|
|
560
|
+
# @param options [Hash{Symbol=>Object, nil}] mutated in place (keys deleted)
|
|
561
|
+
# @return [String, nil]
|
|
562
|
+
def extract_description!(options)
|
|
563
|
+
description = options.delete(:description)
|
|
564
|
+
desc = options.delete(:desc)
|
|
565
|
+
if description && desc
|
|
566
|
+
raise ArgumentError, 'pass either description: or desc:, not both'
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
description || desc
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Field options reserved by FieldStruct's own DSL on *every* field,
|
|
573
|
+
# regardless of type. They're consumed by {.field} itself, so the
|
|
574
|
+
# type-option check skips them.
|
|
575
|
+
#
|
|
576
|
+
# @return [Array<Symbol>]
|
|
577
|
+
UNIVERSAL_FIELD_OPTIONS = %i[required default coercion_policy description desc rbs_type sensitive].freeze
|
|
578
|
+
private_constant :UNIVERSAL_FIELD_OPTIONS
|
|
579
|
+
|
|
580
|
+
# Three-way option check (design invariant 7): validate native, pass
|
|
581
|
+
# through foreign. For each declared option:
|
|
582
|
+
#
|
|
583
|
+
# 1. **Native to this type** (in its +option_schema+) → validate the
|
|
584
|
+
# value's shape against the descriptor.
|
|
585
|
+
# 2. **Native to *another* registered type but not this one** → raise,
|
|
586
|
+
# naming the types it does apply to (the old "wrong family" errors,
|
|
587
|
+
# now schema-driven).
|
|
588
|
+
# 3. **Not a FieldStruct option at all** → leave it untouched. Foreign
|
|
589
|
+
# options persist on the Field for downstream tooling (e.g. an Avro
|
|
590
|
+
# schema exporter).
|
|
591
|
+
#
|
|
592
|
+
# @param type_class [Class<Types::Base>]
|
|
593
|
+
# @param options [Hash{Symbol=>Object, nil}] the resolved per-field options
|
|
594
|
+
# @return [void]
|
|
595
|
+
# @raise [ArgumentError] on a misapplied or wrong-shaped native option
|
|
596
|
+
def validate_options!(type_class, options)
|
|
597
|
+
schema = type_class.option_schema
|
|
598
|
+
registry = namespace_field_types || FieldStruct.types
|
|
599
|
+
reserved = reserved_option_names(registry)
|
|
600
|
+
|
|
601
|
+
options.each do |key, value|
|
|
602
|
+
next if UNIVERSAL_FIELD_OPTIONS.include?(key)
|
|
603
|
+
|
|
604
|
+
if (descriptor = schema[key])
|
|
605
|
+
validate_option_value!(type_class, key, value, descriptor)
|
|
606
|
+
elsif reserved.include?(key)
|
|
607
|
+
raise ArgumentError, wrong_family_message(key, type_class, registry)
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# Every option name FieldStruct treats as its own: the union of every
|
|
613
|
+
# registered type's native options plus the universal field options.
|
|
614
|
+
# Anything outside this set is foreign and passes through.
|
|
615
|
+
#
|
|
616
|
+
# @param registry [Registry]
|
|
617
|
+
# @return [Array<Symbol>]
|
|
618
|
+
def reserved_option_names(registry)
|
|
619
|
+
native = registry.type_classes.flat_map do |type_class|
|
|
620
|
+
type_class.respond_to?(:option_schema) ? type_class.option_schema.keys : []
|
|
621
|
+
end
|
|
622
|
+
(native + UNIVERSAL_FIELD_OPTIONS).uniq
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
# @param type_class [Class<Types::Base>] the field's type
|
|
626
|
+
# @param key [Symbol] the option name
|
|
627
|
+
# @param value [Object, nil] the declared option value
|
|
628
|
+
# @param descriptor [Hash{Symbol=>Object}] the option's schema entry
|
|
629
|
+
# @return [void]
|
|
630
|
+
# @raise [ArgumentError] when +value+ isn't one of the descriptor's
|
|
631
|
+
# accepted classes. +nil+ is always allowed (matches the
|
|
632
|
+
# nil-exemption of format:/enum:/in: validation).
|
|
633
|
+
def validate_option_value!(type_class, key, value, descriptor)
|
|
634
|
+
return if value.nil?
|
|
635
|
+
return if descriptor[:type].any? { |klass| value.is_a?(klass) }
|
|
636
|
+
|
|
637
|
+
expected = descriptor[:type].map { |klass| short_type_name(klass) }.join(' or ')
|
|
638
|
+
raise ArgumentError,
|
|
639
|
+
"#{key}: on #{short_type_name(type_class)} expects #{expected}, " \
|
|
640
|
+
"got #{value.class} (#{value.inspect})"
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# @param key [Symbol] the misapplied option name
|
|
644
|
+
# @param type_class [Class<Types::Base>] the field's type
|
|
645
|
+
# @param registry [Registry] the registry the field resolves through
|
|
646
|
+
# @return [String] the message for a native-elsewhere option, naming
|
|
647
|
+
# the registered types that do accept it
|
|
648
|
+
def wrong_family_message(key, type_class, registry)
|
|
649
|
+
accepting = registry.type_classes
|
|
650
|
+
.select { |candidate| candidate.respond_to?(:option_schema) && candidate.option_schema.key?(key) }
|
|
651
|
+
.map { |candidate| short_type_name(candidate) }
|
|
652
|
+
.uniq
|
|
653
|
+
applies = accepting.empty? ? 'no registered types' : accepting.join(', ')
|
|
654
|
+
"#{key}: option does not apply to #{short_type_name(type_class)} fields. " \
|
|
655
|
+
"It applies to: #{applies}."
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# @return [String] the unqualified class name (+"Integer"+, not
|
|
659
|
+
# +"FieldStruct::Types::Integer"+) for readable messages
|
|
660
|
+
# @param type_class [Module] a type class or an option-value class
|
|
661
|
+
def short_type_name(type_class)
|
|
662
|
+
type_class.name.to_s.split('::').last
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# @param name [Symbol, String] the field name being declared
|
|
666
|
+
# @return [void]
|
|
667
|
+
# @raise [ArgumentError] when +name+ is in {RESERVED_FIELD_NAMES}
|
|
668
|
+
def refuse_reserved_name!(name)
|
|
669
|
+
sym = name.to_sym
|
|
670
|
+
return unless RESERVED_FIELD_NAMES.include?(sym)
|
|
671
|
+
|
|
672
|
+
raise ArgumentError,
|
|
673
|
+
"field :#{sym} is reserved: FieldStruct::Base relies on ##{sym}, and a field " \
|
|
674
|
+
'would shadow it. Pick another name; serialize :json can keep the external key ' \
|
|
675
|
+
"(e.g. serialize :json, #{sym}_field: '#{sym}')."
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# @param name [Symbol, String] the format name
|
|
679
|
+
# @param mapping [Hash{Symbol=>String, Symbol}] internal → external names
|
|
680
|
+
# @return [void]
|
|
681
|
+
# @raise [ArgumentError] when a key is not a declared field
|
|
682
|
+
def validate_serialize_mapping!(name, mapping)
|
|
683
|
+
unknown = mapping.keys.reject { |key| metadata[key] }
|
|
684
|
+
return if unknown.empty?
|
|
685
|
+
|
|
686
|
+
raise ArgumentError,
|
|
687
|
+
"serialize :#{name} references undeclared field(s): #{unknown.map(&:inspect).join(", ")}. " \
|
|
688
|
+
'Declare fields before calling serialize.'
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# @param value [Object] the declared field-level +coercion_policy:+
|
|
692
|
+
# @return [void]
|
|
693
|
+
# @raise [ArgumentError] unless +value+ is a {VALID_COERCION_POLICIES} entry
|
|
694
|
+
def validate_coercion_policy_override!(value)
|
|
695
|
+
return if VALID_COERCION_POLICIES.include?(value)
|
|
696
|
+
|
|
697
|
+
raise ArgumentError, "unknown coercion policy #{value.inspect} " \
|
|
698
|
+
"(expected one of #{VALID_COERCION_POLICIES.inspect})"
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
# @param value [Object] the declared +rbs_type:+
|
|
702
|
+
# @return [void]
|
|
703
|
+
# @raise [ArgumentError] unless +value+ is a non-blank String
|
|
704
|
+
def validate_rbs_type!(value)
|
|
705
|
+
return if value.is_a?(::String) && !value.strip.empty?
|
|
706
|
+
|
|
707
|
+
raise ArgumentError, 'rbs_type: must be a non-empty String of RBS (e.g. ' \
|
|
708
|
+
"rbs_type: '::ActiveRecord::Relation'), got #{value.inspect}"
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# @param value [Object] the declared +sensitive:+
|
|
712
|
+
# @return [void]
|
|
713
|
+
# @raise [ArgumentError] unless +value+ is +true+ or +false+
|
|
714
|
+
def validate_sensitive!(value)
|
|
715
|
+
return if [true, false].include?(value)
|
|
716
|
+
|
|
717
|
+
raise ArgumentError, "sensitive: must be true or false, got #{value.class}"
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# @param field [Field]
|
|
721
|
+
# @return [void]
|
|
722
|
+
def define_field_accessors(field)
|
|
723
|
+
attr_reader field.name
|
|
724
|
+
|
|
725
|
+
ivar = field.ivar_name
|
|
726
|
+
define_method(field.setter_name) do |value|
|
|
727
|
+
if @_initialized && self.class.immutable?
|
|
728
|
+
raise ImmutableError, "#{self.class} is immutable; cannot reassign #{field.name}"
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
begin
|
|
732
|
+
coerced = field.type_instance.coerce(value, **field.options)
|
|
733
|
+
# RangeError covers FloatDomainError (Float#to_i / Integer() on NaN
|
|
734
|
+
# or Infinity) and Complex-to-real conversion — coercion failures
|
|
735
|
+
# like any other, not exceptions to escape the setter.
|
|
736
|
+
rescue ArgumentError, TypeError, RangeError => e
|
|
737
|
+
return apply_coercion_policy(field, value, e)
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
instance_variable_set(ivar, coerced)
|
|
741
|
+
validate_field(field, coerced)
|
|
742
|
+
coerced
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
public
|
|
747
|
+
|
|
748
|
+
# @return [Array<Proc>] the registered cross-field validators
|
|
749
|
+
# for this class. Each entry is a proc that takes the
|
|
750
|
+
# record. Subclasses start with a +dup+ of the parent's list at
|
|
751
|
+
# inheritance time and accumulate their own.
|
|
752
|
+
def validators
|
|
753
|
+
@validators ||= []
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# Declare a cross-field validator.
|
|
757
|
+
#
|
|
758
|
+
# class Schedule < FieldStruct::Base
|
|
759
|
+
# required :start_date, :date
|
|
760
|
+
# required :end_date, :date
|
|
761
|
+
#
|
|
762
|
+
# validate :ensure_chronological
|
|
763
|
+
# validate do |record|
|
|
764
|
+
# record.errors.add(:base, '...') if record.something_else?
|
|
765
|
+
# end
|
|
766
|
+
#
|
|
767
|
+
# def ensure_chronological
|
|
768
|
+
# return unless start_date && end_date
|
|
769
|
+
#
|
|
770
|
+
# errors.add(:base, 'end_date must not precede start_date') if start_date > end_date
|
|
771
|
+
# end
|
|
772
|
+
# end
|
|
773
|
+
#
|
|
774
|
+
# Multiple symbols can be passed in a single call. Method-symbol
|
|
775
|
+
# and block forms are interchangeable — the symbol form is sugar
|
|
776
|
+
# for +validate { |r| r.send(:name) }+. Validators run on every
|
|
777
|
+
# call to {#valid?}, in declaration order, after per-field
|
|
778
|
+
# validation.
|
|
779
|
+
#
|
|
780
|
+
# Per convention, validators add errors via
|
|
781
|
+
# +record.errors.add(:base, '...')+ — +errors[:base]+ is cleared
|
|
782
|
+
# at the start of each {#valid?} run, so prior cross-field errors
|
|
783
|
+
# don't pile up. Adding to a field-specific key bypasses that
|
|
784
|
+
# auto-clearing; the user is then responsible for clearing those
|
|
785
|
+
# entries.
|
|
786
|
+
#
|
|
787
|
+
# @param method_names [Symbol] zero or more instance method
|
|
788
|
+
# names to invoke on each record at +valid?+ time
|
|
789
|
+
# @yield [record] optional block validator
|
|
790
|
+
# @yieldparam record [Base] the instance being validated
|
|
791
|
+
# @return [self]
|
|
792
|
+
def validate(*method_names, &block)
|
|
793
|
+
method_names.each do |method_name|
|
|
794
|
+
validators << ->(record) { record.public_send(method_name) }
|
|
795
|
+
end
|
|
796
|
+
validators << block if block
|
|
797
|
+
self
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
# Ruby hook: give each subclass its own metadata seeded from the
|
|
801
|
+
# parent's (merged copy) plus inherited macro settings, so field
|
|
802
|
+
# declarations and config flow down the class chain.
|
|
803
|
+
#
|
|
804
|
+
# @api private
|
|
805
|
+
# @param subclass [Class<Base>]
|
|
806
|
+
# @return [void]
|
|
807
|
+
def inherited(subclass)
|
|
808
|
+
super
|
|
809
|
+
subclass.metadata.merge(metadata)
|
|
810
|
+
subclass.instance_variable_set(:@validators, validators.dup)
|
|
811
|
+
end
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
# @param attrs [Hash{Symbol,String=>Object, nil}, #to_hash, nil] input values,
|
|
815
|
+
# by symbol or string key. Anything responding to +to_hash+ (e.g. a
|
|
816
|
+
# permitted +ActionController::Parameters+) is converted first; +nil+
|
|
817
|
+
# is treated as no input.
|
|
818
|
+
# @raise [ArgumentError] when +attrs+ is neither +nil+, a Hash, nor
|
|
819
|
+
# convertible to one via +to_hash+
|
|
820
|
+
def initialize(attrs = {})
|
|
821
|
+
@errors = Errors.new
|
|
822
|
+
attrs = hash_input!(attrs.nil? ? {} : attrs, '.new')
|
|
823
|
+
apply_defaults(attrs)
|
|
824
|
+
assign_attributes(attrs)
|
|
825
|
+
run_cross_field_validators if self.class.validators.any?
|
|
826
|
+
@_initialized = true
|
|
827
|
+
freeze if self.class.frozen_on_construct?
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# Bulk-update declared attributes from a hash. Routes each value
|
|
831
|
+
# through the same setter pipeline used everywhere else — so coercion,
|
|
832
|
+
# presence checks, and the +coercion_policy+ all run as normal.
|
|
833
|
+
#
|
|
834
|
+
# Respects {.unknown_attributes} on input and {.immutable?} on the
|
|
835
|
+
# setter side. Keys must match declared field names. For data arriving
|
|
836
|
+
# in an external naming convention, see {Base.serialize} and
|
|
837
|
+
# {Base.from_json}.
|
|
838
|
+
#
|
|
839
|
+
# @param attrs [Hash{Symbol,String=>Object, nil}, #to_hash] anything
|
|
840
|
+
# responding to +to_hash+ is converted first
|
|
841
|
+
# @return [self]
|
|
842
|
+
# @raise [ArgumentError] when +attrs+ is not a Hash and does not
|
|
843
|
+
# respond to +to_hash+ (including +nil+)
|
|
844
|
+
def assign_attributes(attrs)
|
|
845
|
+
attrs = hash_input!(attrs, '#assign_attributes')
|
|
846
|
+
reject_unknown_attributes!(attrs)
|
|
847
|
+
attrs.each do |key, value|
|
|
848
|
+
field = self.class.metadata[key.to_sym]
|
|
849
|
+
next unless field
|
|
850
|
+
|
|
851
|
+
public_send(field.setter_name, value)
|
|
852
|
+
end
|
|
853
|
+
self
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# A new instance with the given attributes changed; the receiver is left
|
|
857
|
+
# untouched. Like +Data#with+.
|
|
858
|
+
#
|
|
859
|
+
# moved = person.with(city: 'Provo', zip: '84601')
|
|
860
|
+
#
|
|
861
|
+
# Changed fields run through the normal setter pipeline (coercion, the
|
|
862
|
+
# field's +coercion_policy+, its errors rewritten), then cross-field
|
|
863
|
+
# validators rerun. Unchanged fields are carried over exactly as they
|
|
864
|
+
# are, errors included, and callable defaults are not called again.
|
|
865
|
+
# The copy is shallow, as with +dup+: an unchanged nested struct or
|
|
866
|
+
# array is the same object in both. Unknown keys follow
|
|
867
|
+
# {.unknown_attributes}. Works on +immutable!+ and frozen instances;
|
|
868
|
+
# the copy is an instance of the receiver's class, immutable when the
|
|
869
|
+
# class is, and frozen when the receiver is.
|
|
870
|
+
#
|
|
871
|
+
# @param positional [Object] refused — changes are keywords only
|
|
872
|
+
# @param changes [Object, nil] new values, by field name
|
|
873
|
+
# @return [Base] a new instance of the receiver's class
|
|
874
|
+
# @raise [ArgumentError] when given a positional argument
|
|
875
|
+
# @raise [UnknownAttributeError] under +unknown_attributes :raise+
|
|
876
|
+
# @raise [CoercionError] under +coercion_policy :raise+
|
|
877
|
+
def with(*positional, **changes)
|
|
878
|
+
unless positional.empty?
|
|
879
|
+
raise ArgumentError,
|
|
880
|
+
"#{self.class.name || "AnonymousFieldStruct"}#with takes keyword arguments only, " \
|
|
881
|
+
"got #{positional.size} positional; pass a Hash as with(**hash)"
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
freeze_copy = frozen?
|
|
885
|
+
copy = dup
|
|
886
|
+
copy.instance_exec do
|
|
887
|
+
# Reopen the copy for its setters (immutable! checks this flag), as
|
|
888
|
+
# #initialize does, then close it again.
|
|
889
|
+
@_initialized = false
|
|
890
|
+
assign_attributes(changes)
|
|
891
|
+
run_cross_field_validators if self.class.validators.any?
|
|
892
|
+
@_initialized = true
|
|
893
|
+
freeze if freeze_copy
|
|
894
|
+
end
|
|
895
|
+
copy
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
# @return [Hash{Symbol=>Object, nil}] a fresh hash of current attribute values
|
|
899
|
+
# keyed by canonical field name. For format-aware export (with the
|
|
900
|
+
# declared +:json+ mapping applied), see {#as_json} / {#to_json}.
|
|
901
|
+
def attributes
|
|
902
|
+
self.class.metadata.to_h { |field| [field.name, public_send(field.name)] }
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
# @return [Array<Symbol>]
|
|
906
|
+
def attribute_names
|
|
907
|
+
self.class.attribute_names
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
# @return [Errors] the per-instance error collection
|
|
911
|
+
def errors
|
|
912
|
+
@errors ||= Errors.new
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
# Run the class's cross-field validators (if any), then report
|
|
916
|
+
# whether the record has any errors.
|
|
917
|
+
#
|
|
918
|
+
# Each call clears +errors[:base]+ before running validators, so
|
|
919
|
+
# cross-field errors don't accumulate across calls. Field-level
|
|
920
|
+
# errors written by setters are NOT cleared by +valid?+ — Phase 1's
|
|
921
|
+
# "setter owns its field's errors" contract is preserved.
|
|
922
|
+
#
|
|
923
|
+
# For classes that declare no +validate+ blocks, +valid?+ stays a
|
|
924
|
+
# cheap +errors.empty?+ read.
|
|
925
|
+
#
|
|
926
|
+
# @return [Boolean]
|
|
927
|
+
def valid?
|
|
928
|
+
run_cross_field_validators if self.class.validators.any?
|
|
929
|
+
errors.empty?
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
# @return [Boolean] true when {#valid?} is false
|
|
933
|
+
def invalid?
|
|
934
|
+
!valid?
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
# Structural equality: same class AND same {#attributes}. Errors are
|
|
938
|
+
# ignored — equality is about value, not validity.
|
|
939
|
+
#
|
|
940
|
+
# @param other [Object]
|
|
941
|
+
# @return [Boolean]
|
|
942
|
+
def ==(other)
|
|
943
|
+
other.instance_of?(self.class) && attributes == other.attributes
|
|
944
|
+
end
|
|
945
|
+
alias eql? ==
|
|
946
|
+
|
|
947
|
+
# @return [Integer] consistent with {#==} so instances work as Hash
|
|
948
|
+
# keys and Set members
|
|
949
|
+
def hash
|
|
950
|
+
[self.class, attributes].hash
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
# @return [Hash{Symbol=>Object, nil}] alias of {#attributes}
|
|
954
|
+
def to_h
|
|
955
|
+
attributes
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
# @param _options [Object] unused — accepted for ActiveSupport-style symmetry
|
|
959
|
+
# @return [Hash{Symbol=>Object, nil}] a JSON-ready hash with the
|
|
960
|
+
# +:json+ serialize mapping applied. Date/Time/DateTime convert
|
|
961
|
+
# to ISO-8601 strings, BigDecimal to its plain-form string,
|
|
962
|
+
# Symbol to String. Arrays recurse. Nested FieldStructs walk
|
|
963
|
+
# through their own +as_json+, applying their own mapping.
|
|
964
|
+
# Covers exactly the declared fields, each read through its reader
|
|
965
|
+
# (as {#attributes} reads them) in one pass; an override of
|
|
966
|
+
# {#attributes} does not change it.
|
|
967
|
+
def as_json(_options = nil)
|
|
968
|
+
metadata = self.class.metadata
|
|
969
|
+
mapping = metadata.serialization(:json)
|
|
970
|
+
metadata.each_with_object({}) do |field, out|
|
|
971
|
+
name = field.name
|
|
972
|
+
key = mapping.key?(name) ? mapping[name].to_sym : name
|
|
973
|
+
out[key] = json_value(public_send(name), field)
|
|
974
|
+
end
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
# @param _options [Object] unused — accepted for ActiveSupport-style symmetry
|
|
978
|
+
# @return [String] JSON representation, via Oj
|
|
979
|
+
def to_json(_options = nil)
|
|
980
|
+
Oj.dump(as_json, JSON_DUMP_OPTIONS)
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
# Readable representation. Valid instances render as
|
|
984
|
+
# +#<ClassName field: value, ...>+. An *invalid* instance additionally
|
|
985
|
+
# surfaces its errors — +#<ClassName name: "" errors: {name: ["is
|
|
986
|
+
# required"]}>+ — so the most-read debug output never hides invalidity
|
|
987
|
+
# (design invariant 7). This reads the current {#errors}; it does not
|
|
988
|
+
# re-run validation, so printing an instance never mutates it.
|
|
989
|
+
#
|
|
990
|
+
# A +sensitive: true+ field renders {FieldStruct::FILTERED} in place of
|
|
991
|
+
# a non-nil value (+nil+ still shows, so a missing value stays visible).
|
|
992
|
+
# +pp+ goes through here too.
|
|
993
|
+
#
|
|
994
|
+
# @return [String]
|
|
995
|
+
def inspect
|
|
996
|
+
pairs = self.class.metadata.map do |field|
|
|
997
|
+
value = public_send(field.name)
|
|
998
|
+
"#{field.name}: #{field.sensitive? && !value.nil? ? FieldStruct::FILTERED : value.inspect}"
|
|
999
|
+
end.join(', ')
|
|
1000
|
+
body = "#{self.class.name || "AnonymousFieldStruct"} #{pairs}"
|
|
1001
|
+
current = errors.to_h
|
|
1002
|
+
unless current.empty?
|
|
1003
|
+
rendered = current.map { |field, msgs| "#{field}: #{msgs.inspect}" }.join(', ')
|
|
1004
|
+
body = "#{body} errors: {#{rendered}}"
|
|
1005
|
+
end
|
|
1006
|
+
"#<#{body}>"
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
# @return [ModelName]
|
|
1010
|
+
def model_name
|
|
1011
|
+
self.class.model_name
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
# @return [self] for ActiveModel-shaped helpers that call .to_model
|
|
1015
|
+
def to_model
|
|
1016
|
+
self
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
# Ruby pattern-matching hash protocol. Returns the declared field
|
|
1020
|
+
# values keyed by canonical name, sliced down to +keys+ if the
|
|
1021
|
+
# pattern asked for specific ones.
|
|
1022
|
+
#
|
|
1023
|
+
# case user
|
|
1024
|
+
# in { name: 'Alice', age: }
|
|
1025
|
+
# ...
|
|
1026
|
+
# in { name: String => name }
|
|
1027
|
+
# ...
|
|
1028
|
+
# end
|
|
1029
|
+
#
|
|
1030
|
+
# Nested FieldStruct values respond to the same protocol, so deep
|
|
1031
|
+
# patterns (+{ address: { city: 'NYC' } }+) work without extra code.
|
|
1032
|
+
# Aliases (per the field-name aliases feature) do *not* participate;
|
|
1033
|
+
# pattern matching uses canonical names, the same as Ruby-side
|
|
1034
|
+
# accessors.
|
|
1035
|
+
#
|
|
1036
|
+
# @param keys [Array<Symbol>, nil] keys the pattern requested, or
|
|
1037
|
+
# +nil+ for the +**rest+ pattern (every declared field)
|
|
1038
|
+
# @return [Hash{Symbol=>Object, nil}]
|
|
1039
|
+
def deconstruct_keys(keys)
|
|
1040
|
+
return attributes if keys.nil?
|
|
1041
|
+
|
|
1042
|
+
attributes.slice(*keys)
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
# Ruby pattern-matching array protocol. Returns the declared field
|
|
1046
|
+
# values in declaration order — useful for positional unpacking of
|
|
1047
|
+
# small POROs whose field order is meaningful.
|
|
1048
|
+
#
|
|
1049
|
+
# class Point < FieldStruct::Base
|
|
1050
|
+
# required :x, :integer
|
|
1051
|
+
# required :y, :integer
|
|
1052
|
+
# end
|
|
1053
|
+
#
|
|
1054
|
+
# case point
|
|
1055
|
+
# in [x, y]
|
|
1056
|
+
# Math.hypot(x, y)
|
|
1057
|
+
# end
|
|
1058
|
+
#
|
|
1059
|
+
# @return [Array<Object, nil>]
|
|
1060
|
+
def deconstruct
|
|
1061
|
+
attributes.values
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1064
|
+
private
|
|
1065
|
+
|
|
1066
|
+
# Ruby hook behind +dup+ / +clone+: give the copy its own {Errors},
|
|
1067
|
+
# seeded with the original's messages, so validating or reassigning
|
|
1068
|
+
# one instance never changes the other's validity.
|
|
1069
|
+
#
|
|
1070
|
+
# @param source [self] the instance being copied
|
|
1071
|
+
# @return [void]
|
|
1072
|
+
def initialize_copy(source)
|
|
1073
|
+
super
|
|
1074
|
+
@errors = source.errors.dup
|
|
1075
|
+
end
|
|
1076
|
+
|
|
1077
|
+
# Return +attrs+ as a Hash, converting via +to_hash+ when it isn't one.
|
|
1078
|
+
# The message names the input's class, never its value.
|
|
1079
|
+
#
|
|
1080
|
+
# @param attrs [Object, nil] the caller's input
|
|
1081
|
+
# @param method_label [String] the public method, for the message
|
|
1082
|
+
# @return [Hash{Symbol, String=>Object, nil}]
|
|
1083
|
+
# @raise [ArgumentError] when +attrs+ is not a Hash and has no +to_hash+
|
|
1084
|
+
def hash_input!(attrs, method_label)
|
|
1085
|
+
return attrs if attrs.is_a?(::Hash)
|
|
1086
|
+
return attrs.to_hash if attrs.respond_to?(:to_hash)
|
|
1087
|
+
|
|
1088
|
+
raise ArgumentError,
|
|
1089
|
+
"#{self.class.name || "AnonymousFieldStruct"}#{method_label} expects a Hash " \
|
|
1090
|
+
"(or an object responding to #to_hash), got #{attrs.class}"
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
# @param value [Object, nil] a field value, or an element inside one
|
|
1094
|
+
# @param field [Field, nil] the field it belongs to, for its +format:+
|
|
1095
|
+
# @return [Object, nil] a JSON-ready value for Oj
|
|
1096
|
+
def json_value(value, field = nil)
|
|
1097
|
+
case value
|
|
1098
|
+
when nil, true, false, ::String, ::Integer, ::Float then value
|
|
1099
|
+
when ::Symbol then value.to_s
|
|
1100
|
+
when ::BigDecimal then value.to_s('F')
|
|
1101
|
+
when ::DateTime, ::Date, ::Time then temporal_json_value(value, field)
|
|
1102
|
+
when ::Array then value.map { |element| json_value(element, field) }
|
|
1103
|
+
when ::Hash then value.transform_values { |v| json_value(v, field) }
|
|
1104
|
+
when FieldStruct::Base then value.as_json
|
|
1105
|
+
else
|
|
1106
|
+
value.respond_to?(:as_json) ? value.as_json : value
|
|
1107
|
+
end
|
|
1108
|
+
end
|
|
1109
|
+
|
|
1110
|
+
# A declared +format:+ is now stored as written, so a preset name has to be
|
|
1111
|
+
# expanded before strftime sees it — and only the field's own type knows
|
|
1112
|
+
# its preset table. An :array field's elements use the +format:+ in its
|
|
1113
|
+
# +element:+ options, read through the element type, so +from_json+ reads
|
|
1114
|
+
# back what +to_json+ wrote. With no declared format (an :array without
|
|
1115
|
+
# one, a :value field holding a date) the answer is ISO-8601, as it has
|
|
1116
|
+
# always been.
|
|
1117
|
+
#
|
|
1118
|
+
# @param value [::Date, ::Time, ::DateTime]
|
|
1119
|
+
# @param field [Field, nil]
|
|
1120
|
+
# @return [String]
|
|
1121
|
+
def temporal_json_value(value, field)
|
|
1122
|
+
options, type_class = temporal_format_source(field)
|
|
1123
|
+
format = options&.[](:format)
|
|
1124
|
+
return value.iso8601 if format.nil? || type_class.nil?
|
|
1125
|
+
|
|
1126
|
+
resolved = type_class.resolve_format(format)
|
|
1127
|
+
# A format FieldStruct implements itself renders itself; a strftime
|
|
1128
|
+
# String is handed to strftime, as it always was.
|
|
1129
|
+
return resolved.render(value) if resolved.is_a?(Types::Rfc3339Format)
|
|
1130
|
+
|
|
1131
|
+
value.strftime(resolved)
|
|
1132
|
+
end
|
|
1133
|
+
|
|
1134
|
+
# @param field [Field, nil]
|
|
1135
|
+
# @return [Array(Hash{Symbol=>Object, nil}, Class), Array(nil, Class), Array(nil, nil)] the
|
|
1136
|
+
# options holding the +format:+ for this field's temporal values and
|
|
1137
|
+
# the type class that resolves it — an array's +element:+ options and
|
|
1138
|
+
# element type, or the field's own
|
|
1139
|
+
def temporal_format_source(field)
|
|
1140
|
+
return [nil, nil] if field.nil?
|
|
1141
|
+
return [field.options, field.type_instance.class] unless field.type <= Types::Array
|
|
1142
|
+
|
|
1143
|
+
[field.options[:element], Types::Array.element_class(field.options[:of_type])]
|
|
1144
|
+
end
|
|
1145
|
+
|
|
1146
|
+
# Give every field its default, through the setter. A field with no
|
|
1147
|
+
# default whose key +attrs+ carries only gets its ivar (so the ivar order
|
|
1148
|
+
# stays the declaration order): the assignment that follows runs its
|
|
1149
|
+
# setter, so a nil pass first would only be undone.
|
|
1150
|
+
#
|
|
1151
|
+
# @param attrs [Hash{Symbol, String=>Object, nil}] the input about to be assigned
|
|
1152
|
+
# @return [void]
|
|
1153
|
+
def apply_defaults(attrs)
|
|
1154
|
+
self.class.metadata.each do |field|
|
|
1155
|
+
value = field.default
|
|
1156
|
+
if value.nil? && (attrs.key?(field.name) || attrs.key?(field.name.name))
|
|
1157
|
+
instance_variable_set(field.ivar_name, nil)
|
|
1158
|
+
next
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
value = value.call if value.respond_to?(:call)
|
|
1162
|
+
public_send(field.setter_name, value)
|
|
1163
|
+
end
|
|
1164
|
+
end
|
|
1165
|
+
|
|
1166
|
+
# @return [void]
|
|
1167
|
+
def run_cross_field_validators
|
|
1168
|
+
errors.clear(:base)
|
|
1169
|
+
self.class.validators.each { |validator| validator.call(self) }
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1172
|
+
# @param attrs [Hash{Symbol, String=>Object, nil}]
|
|
1173
|
+
# @return [void]
|
|
1174
|
+
# @raise [UnknownAttributeError] under +unknown_attributes :raise+
|
|
1175
|
+
def reject_unknown_attributes!(attrs)
|
|
1176
|
+
return if self.class.unknown_attributes == :ignore
|
|
1177
|
+
|
|
1178
|
+
meta = self.class.metadata
|
|
1179
|
+
unknown = attrs.keys.map(&:to_sym).reject { |k| meta[k] }
|
|
1180
|
+
return if unknown.empty?
|
|
1181
|
+
|
|
1182
|
+
raise UnknownAttributeError.new(
|
|
1183
|
+
"unknown attribute(s) for #{self.class}: #{unknown.map(&:inspect).join(", ")}",
|
|
1184
|
+
keys: unknown
|
|
1185
|
+
)
|
|
1186
|
+
end
|
|
1187
|
+
|
|
1188
|
+
# @param field [Field]
|
|
1189
|
+
# @param value [Object, nil] the coerced value just stored
|
|
1190
|
+
# @return [void]
|
|
1191
|
+
def validate_field(field, value)
|
|
1192
|
+
errors.clear(field.name)
|
|
1193
|
+
if field.type_instance.missing?(value)
|
|
1194
|
+
errors.add(field.name, 'is required') if field.required?
|
|
1195
|
+
return
|
|
1196
|
+
end
|
|
1197
|
+
|
|
1198
|
+
if field_value_invalid?(field, value)
|
|
1199
|
+
errors.add(field.name, 'is invalid')
|
|
1200
|
+
elsif (indexes = invalid_element_indexes(field, value))
|
|
1201
|
+
verb = indexes.size == 1 ? 'is' : 'are'
|
|
1202
|
+
errors.add(field.name, "#{Types::Array.elements_at(indexes)} #{verb} invalid")
|
|
1203
|
+
end
|
|
1204
|
+
end
|
|
1205
|
+
|
|
1206
|
+
# @param field [Field]
|
|
1207
|
+
# @param value [Object, nil] a present (non-missing) coerced value
|
|
1208
|
+
# @return [Boolean]
|
|
1209
|
+
def field_value_invalid?(field, value)
|
|
1210
|
+
outside_options?(field.options, value) || nested_invalid?(value)
|
|
1211
|
+
end
|
|
1212
|
+
|
|
1213
|
+
# Whether +value+ breaks a +format:+ Regexp, +enum:+ or +in:+ in +options+.
|
|
1214
|
+
#
|
|
1215
|
+
# @param options [Hash{Symbol=>Object, nil}] a field's options, or an
|
|
1216
|
+
# array field's +element:+ options
|
|
1217
|
+
# @param value [Object, nil] a present coerced value
|
|
1218
|
+
# @return [Boolean]
|
|
1219
|
+
def outside_options?(options, value)
|
|
1220
|
+
fmt = options[:format]
|
|
1221
|
+
return true if fmt.is_a?(::Regexp) && !fmt.match?(value)
|
|
1222
|
+
return true if options[:enum] && !options[:enum].include?(value)
|
|
1223
|
+
|
|
1224
|
+
!options[:in].nil? && !within_allowed?(options[:in], value)
|
|
1225
|
+
end
|
|
1226
|
+
|
|
1227
|
+
# @param field [Field]
|
|
1228
|
+
# @param value [Object, nil] a present coerced value
|
|
1229
|
+
# @return [Array<Integer>, nil] the indexes of every non-nil element
|
|
1230
|
+
# that breaks the array field's +element:+ +format:+ / +enum:+ / +in:+,
|
|
1231
|
+
# or +nil+ when there is none (or the field declares no +element:+)
|
|
1232
|
+
def invalid_element_indexes(field, value)
|
|
1233
|
+
element_options = field.options[:element]
|
|
1234
|
+
return nil unless element_options && value.is_a?(::Array)
|
|
1235
|
+
|
|
1236
|
+
# Array#index keeps an all-valid array allocation-free; only an
|
|
1237
|
+
# invalid one goes on to collect the rest.
|
|
1238
|
+
first = value.index { |item| !item.nil? && outside_options?(element_options, item) }
|
|
1239
|
+
return nil if first.nil?
|
|
1240
|
+
|
|
1241
|
+
((first + 1)...value.size).each_with_object([first]) do |index, found|
|
|
1242
|
+
item = value[index]
|
|
1243
|
+
found << index if !item.nil? && outside_options?(element_options, item)
|
|
1244
|
+
end
|
|
1245
|
+
end
|
|
1246
|
+
|
|
1247
|
+
# `in:` with a Range means a BOUNDS check, so ask the Range for its bounds.
|
|
1248
|
+
#
|
|
1249
|
+
# Range#include? ENUMERATES a non-numeric range rather than comparing
|
|
1250
|
+
# endpoints. For a Date range that is merely wasteful — 365 successor steps
|
|
1251
|
+
# to answer one comparison. For a DateTime range it is wrong: the range
|
|
1252
|
+
# steps by whole days, so a value that isn't exactly midnight is reported
|
|
1253
|
+
# outside a range that plainly contains it.
|
|
1254
|
+
#
|
|
1255
|
+
# (DateTime.new(2026, 1, 1)..DateTime.new(2026, 12, 31))
|
|
1256
|
+
# .include?(DateTime.new(2026, 7, 3, 10, 30)) # => false
|
|
1257
|
+
# .cover?(DateTime.new(2026, 7, 3, 10, 30)) # => true
|
|
1258
|
+
#
|
|
1259
|
+
# An Array stays a membership test. There is no string-range case to worry
|
|
1260
|
+
# about: the DSL refuses `in:` on String fields outright.
|
|
1261
|
+
#
|
|
1262
|
+
# @param allowed [Range<Object>, Enumerable<Object>] the field's +in:+ option
|
|
1263
|
+
# (an Array for the stock types; a custom type may declare another)
|
|
1264
|
+
# @param value [Object, nil]
|
|
1265
|
+
# @return [Boolean]
|
|
1266
|
+
def within_allowed?(allowed, value)
|
|
1267
|
+
allowed.is_a?(::Range) ? allowed.cover?(value) : allowed.include?(value)
|
|
1268
|
+
end
|
|
1269
|
+
|
|
1270
|
+
# @param value [Object, nil]
|
|
1271
|
+
# @return [Boolean] whether +value+ is, or is an Array holding, an
|
|
1272
|
+
# invalid FieldStruct
|
|
1273
|
+
def nested_invalid?(value)
|
|
1274
|
+
return value.invalid? if value.is_a?(FieldStruct::Base)
|
|
1275
|
+
return false unless value.is_a?(::Array)
|
|
1276
|
+
# The pattern form scans in C, so an array of scalars (the common case)
|
|
1277
|
+
# skips the Ruby block entirely.
|
|
1278
|
+
return false unless value.any?(FieldStruct::Base)
|
|
1279
|
+
|
|
1280
|
+
value.any? { |element| element.is_a?(FieldStruct::Base) && element.invalid? }
|
|
1281
|
+
end
|
|
1282
|
+
|
|
1283
|
+
# @param field [Field]
|
|
1284
|
+
# @param raw_value [Object, nil] the value that failed to coerce
|
|
1285
|
+
# @param error [StandardError] what the type raised
|
|
1286
|
+
# @return [Object, nil] what the setter stored: +raw_value+ under
|
|
1287
|
+
# +:keep_raw+, +nil+ under +:replace+
|
|
1288
|
+
# @raise [CoercionError] under +:raise+
|
|
1289
|
+
def apply_coercion_policy(field, raw_value, error)
|
|
1290
|
+
case field.coercion_policy || self.class.coercion_policy
|
|
1291
|
+
when :keep_raw
|
|
1292
|
+
record_coercion_failure(field, raw_value, error)
|
|
1293
|
+
raw_value
|
|
1294
|
+
when :replace
|
|
1295
|
+
record_coercion_failure(field, nil, error)
|
|
1296
|
+
nil
|
|
1297
|
+
when :raise
|
|
1298
|
+
raise CoercionError.new(
|
|
1299
|
+
"could not coerce #{raw_value.class} input for #{field.name}: #{error.message}",
|
|
1300
|
+
field_name: field.name,
|
|
1301
|
+
original: error
|
|
1302
|
+
)
|
|
1303
|
+
end
|
|
1304
|
+
end
|
|
1305
|
+
|
|
1306
|
+
# @param field [Field]
|
|
1307
|
+
# @param stored_value [Object, nil] what to store in the field
|
|
1308
|
+
# @param error [StandardError] what the type raised
|
|
1309
|
+
# @return [void]
|
|
1310
|
+
def record_coercion_failure(field, stored_value, error)
|
|
1311
|
+
instance_variable_set(field.ivar_name, stored_value)
|
|
1312
|
+
errors.clear(field.name)
|
|
1313
|
+
errors.add(field.name, "could not be coerced: #{error.message}")
|
|
1314
|
+
end
|
|
1315
|
+
end
|
|
1316
|
+
end
|