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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +58 -0
  3. data/CHANGELOG.md +653 -0
  4. data/CODE_OF_CONDUCT.md +132 -0
  5. data/README.md +645 -0
  6. data/UPGRADING.md +743 -0
  7. data/USAGE.md +820 -0
  8. data/docs/getting_started.md +337 -0
  9. data/lib/field_struct/base.rb +1316 -0
  10. data/lib/field_struct/error.rb +48 -0
  11. data/lib/field_struct/errors.rb +103 -0
  12. data/lib/field_struct/field.rb +179 -0
  13. data/lib/field_struct/metadata.rb +331 -0
  14. data/lib/field_struct/model_name.rb +48 -0
  15. data/lib/field_struct/rbs.rb +299 -0
  16. data/lib/field_struct/registry.rb +85 -0
  17. data/lib/field_struct/scaffold.rb +331 -0
  18. data/lib/field_struct/types/array.rb +156 -0
  19. data/lib/field_struct/types/base.rb +162 -0
  20. data/lib/field_struct/types/big_decimal.rb +78 -0
  21. data/lib/field_struct/types/binary.rb +38 -0
  22. data/lib/field_struct/types/boolean.rb +126 -0
  23. data/lib/field_struct/types/date.rb +158 -0
  24. data/lib/field_struct/types/datetime.rb +163 -0
  25. data/lib/field_struct/types/email.rb +49 -0
  26. data/lib/field_struct/types/float.rb +80 -0
  27. data/lib/field_struct/types/immutable_string.rb +18 -0
  28. data/lib/field_struct/types/integer.rb +133 -0
  29. data/lib/field_struct/types/nested.rb +82 -0
  30. data/lib/field_struct/types/preset_resolver.rb +37 -0
  31. data/lib/field_struct/types/rfc3339_format.rb +197 -0
  32. data/lib/field_struct/types/string.rb +73 -0
  33. data/lib/field_struct/types/symbol.rb +38 -0
  34. data/lib/field_struct/types/temporal_parser.rb +93 -0
  35. data/lib/field_struct/types/time.rb +168 -0
  36. data/lib/field_struct/types/time_format_resolver.rb +54 -0
  37. data/lib/field_struct/types/union.rb +96 -0
  38. data/lib/field_struct/types/url.rb +46 -0
  39. data/lib/field_struct/types/uuid.rb +46 -0
  40. data/lib/field_struct/types/value.rb +24 -0
  41. data/lib/field_struct/types/zone_option.rb +267 -0
  42. data/lib/field_struct/version.rb +7 -0
  43. data/lib/field_struct.rb +159 -0
  44. data/sig/field_struct.rbs +2845 -0
  45. data/skills/field-struct/SKILL.md +183 -0
  46. 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