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,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FieldStruct
|
|
4
|
+
# Base class for every error FieldStruct raises. Catch this to handle
|
|
5
|
+
# any library error generically.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised by the setter pipeline when a value cannot be coerced into a
|
|
9
|
+
# field's declared type and the class's +coercion_policy+ is +:raise+.
|
|
10
|
+
#
|
|
11
|
+
# Carries the field name and the original underlying error for callers
|
|
12
|
+
# that want to inspect what happened.
|
|
13
|
+
class CoercionError < Error
|
|
14
|
+
# @return [Symbol, nil] the field whose setter triggered the failure
|
|
15
|
+
attr_reader :field_name
|
|
16
|
+
|
|
17
|
+
# @return [StandardError, nil] the underlying error from the type's coerce
|
|
18
|
+
attr_reader :original
|
|
19
|
+
|
|
20
|
+
# @param message [String]
|
|
21
|
+
# @param field_name [Symbol, nil]
|
|
22
|
+
# @param original [StandardError, nil]
|
|
23
|
+
def initialize(message, field_name: nil, original: nil)
|
|
24
|
+
super(message)
|
|
25
|
+
@field_name = field_name
|
|
26
|
+
@original = original
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Raised when a field setter on a class marked +immutable!+ is called
|
|
31
|
+
# after construction.
|
|
32
|
+
class ImmutableError < Error; end
|
|
33
|
+
|
|
34
|
+
# Raised by {Base#initialize} / {Base#assign_attributes} when the input
|
|
35
|
+
# hash includes a key that is not a declared field on the class and
|
|
36
|
+
# the +unknown_attributes+ policy is +:raise+.
|
|
37
|
+
class UnknownAttributeError < Error
|
|
38
|
+
# @return [Array<Symbol>] the offending keys
|
|
39
|
+
attr_reader :keys
|
|
40
|
+
|
|
41
|
+
# @param message [String]
|
|
42
|
+
# @param keys [Array<Symbol>]
|
|
43
|
+
def initialize(message, keys: [])
|
|
44
|
+
super(message)
|
|
45
|
+
@keys = keys
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FieldStruct
|
|
4
|
+
# Per-instance collection of validation messages, keyed by field name.
|
|
5
|
+
#
|
|
6
|
+
# Shaped after ActiveModel::Errors but reuses none of its code. Each
|
|
7
|
+
# FieldStruct instance gets its own Errors object; the setter pipeline
|
|
8
|
+
# clears and rewrites a field's entry on every assignment.
|
|
9
|
+
class Errors
|
|
10
|
+
def initialize
|
|
11
|
+
@messages = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Ruby hook behind +dup+ / +clone+: copy the message lists too, so
|
|
15
|
+
# adding to the copy never changes the original.
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
# @param source [self] the collection being copied
|
|
19
|
+
# @return [void]
|
|
20
|
+
def initialize_copy(source)
|
|
21
|
+
super
|
|
22
|
+
@messages = source.to_h.transform_values(&:dup)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param field_name [Symbol, String]
|
|
26
|
+
# @return [Array<String>] messages for the field, or an empty array
|
|
27
|
+
def [](field_name)
|
|
28
|
+
@messages[field_name.to_sym] || []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param field_name [Symbol, String]
|
|
32
|
+
# @param message [String]
|
|
33
|
+
# @return [Array<String>] the field's updated message list
|
|
34
|
+
def add(field_name, message)
|
|
35
|
+
key = field_name.to_sym
|
|
36
|
+
(@messages[key] ||= []) << message
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Drop every message for a field.
|
|
40
|
+
#
|
|
41
|
+
# @param field_name [Symbol, String]
|
|
42
|
+
# @return [void]
|
|
43
|
+
def clear(field_name)
|
|
44
|
+
@messages.delete(field_name.to_sym)
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Boolean] true when no field has any messages
|
|
49
|
+
def empty?
|
|
50
|
+
# A block loop, not each_value.all?: that builds an Enumerator per call,
|
|
51
|
+
# and every validity check asks this.
|
|
52
|
+
@messages.each_value { |messages| return false unless messages.empty? }
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Hash{Symbol=>Array<String>}] snapshot of field → messages,
|
|
57
|
+
# skipping fields with empty message lists
|
|
58
|
+
def to_h
|
|
59
|
+
@messages.reject { |_, msgs| msgs.empty? }
|
|
60
|
+
end
|
|
61
|
+
alias messages to_h
|
|
62
|
+
|
|
63
|
+
# Every message rendered as a full, human-readable sentence: the
|
|
64
|
+
# humanized field name prepended to each message. Messages on +:base+
|
|
65
|
+
# (the cross-field convention) pass through unprefixed — they are
|
|
66
|
+
# already complete sentences. Order follows field insertion order, then
|
|
67
|
+
# message order within a field.
|
|
68
|
+
#
|
|
69
|
+
# errors.add(:first_name, 'is required')
|
|
70
|
+
# errors.full_messages # => ["First name is required"]
|
|
71
|
+
#
|
|
72
|
+
# @return [Array<String>]
|
|
73
|
+
def full_messages
|
|
74
|
+
to_h.flat_map do |field, msgs|
|
|
75
|
+
next msgs if field == :base
|
|
76
|
+
|
|
77
|
+
prefix = humanize(field)
|
|
78
|
+
msgs.map { |msg| "#{prefix} #{msg}" }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [String]
|
|
83
|
+
def inspect
|
|
84
|
+
prefix = "#{FieldStruct.inspect_namespace}::Errors"
|
|
85
|
+
return "#<#{prefix} empty>" if empty?
|
|
86
|
+
|
|
87
|
+
pairs = to_h.map { |field, msgs| "#{field}=#{msgs.inspect}" }.join(' ')
|
|
88
|
+
"#<#{prefix} #{pairs}>"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Turn a field name into a display label for {#full_messages}:
|
|
94
|
+
# underscores become spaces and the first character is upcased.
|
|
95
|
+
# Deliberately minimal — no inflection, no +_id+ stripping, no i18n.
|
|
96
|
+
#
|
|
97
|
+
# @param field [Symbol]
|
|
98
|
+
# @return [String]
|
|
99
|
+
def humanize(field)
|
|
100
|
+
field.to_s.tr('_', ' ').sub(/\A./, &:upcase)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FieldStruct
|
|
4
|
+
# A single declared attribute on a FieldStruct class.
|
|
5
|
+
#
|
|
6
|
+
# Holds the field's name, the resolved type class, whether it's
|
|
7
|
+
# required, an optional default, and any remaining keyword options
|
|
8
|
+
# (e.g. +format:+). Frozen after construction — the DSL is expected
|
|
9
|
+
# to build a fresh Field rather than mutate one.
|
|
10
|
+
class Field
|
|
11
|
+
# @return [Symbol] canonical field name
|
|
12
|
+
attr_reader :name
|
|
13
|
+
|
|
14
|
+
# @return [Class<Types::Base>] the resolved type class
|
|
15
|
+
attr_reader :type
|
|
16
|
+
|
|
17
|
+
# @return [Object, #call, nil] the configured default value, or a
|
|
18
|
+
# parameterless callable returning one, or +nil+ if no default
|
|
19
|
+
attr_reader :default
|
|
20
|
+
|
|
21
|
+
# @return [Hash{Symbol=>Object, nil}] frozen, holds the remaining keyword
|
|
22
|
+
# options forwarded to {Types::Base#coerce} (e.g. +:format+,
|
|
23
|
+
# +:round+, +:values+, +:of_type+, +:enum+, +:in+)
|
|
24
|
+
attr_reader :options
|
|
25
|
+
|
|
26
|
+
# @return [Types::Base] an eagerly-built instance of {#type}, reused by
|
|
27
|
+
# the setter pipeline so we don't allocate a fresh type per coercion
|
|
28
|
+
attr_reader :type_instance
|
|
29
|
+
|
|
30
|
+
# @return [:keep_raw, :replace, :raise, nil] this field's coercion-
|
|
31
|
+
# failure policy override, or +nil+ to defer to the class-level
|
|
32
|
+
# setting on {Base.coercion_policy}.
|
|
33
|
+
attr_reader :coercion_policy
|
|
34
|
+
|
|
35
|
+
# @return [String, nil] human-readable description of the field;
|
|
36
|
+
# intended for downstream documentation generators. Not part of
|
|
37
|
+
# the data surface — does not appear in +attributes+ / +as_json+ /
|
|
38
|
+
# pattern matches.
|
|
39
|
+
attr_reader :description
|
|
40
|
+
|
|
41
|
+
# @return [String, nil] an RBS type expression that {RBS.generate} and
|
|
42
|
+
# {RBS.type_for} emit for this field instead of deriving one from the
|
|
43
|
+
# type (e.g. +"::ActiveRecord::Relation"+ on a +:value+ field), or
|
|
44
|
+
# +nil+ to derive it. Declared with the +rbs_type:+ field option;
|
|
45
|
+
# signatures only — it never affects coercion or validation.
|
|
46
|
+
attr_reader :rbs_type
|
|
47
|
+
|
|
48
|
+
# @api private
|
|
49
|
+
# @return [Symbol] the setter's method name (+:name=+), built once here
|
|
50
|
+
# so the setter pipeline doesn't intern a dynamic Symbol per call
|
|
51
|
+
attr_reader :setter_name
|
|
52
|
+
|
|
53
|
+
# @api private
|
|
54
|
+
# @return [Symbol] the instance variable holding the value (+:@name+),
|
|
55
|
+
# built once for the same reason as {#setter_name}
|
|
56
|
+
attr_reader :ivar_name
|
|
57
|
+
|
|
58
|
+
# @param name [Symbol, String] canonical field name
|
|
59
|
+
# @param type [Class<Types::Base>] the resolved type class
|
|
60
|
+
# @param type_instance [Types::Base, nil] optional pre-built instance —
|
|
61
|
+
# used by the DSL for parameterized types (e.g. {Types::Nested}) where
|
|
62
|
+
# +type.new+ wouldn't be enough to capture the parameterization. When
|
|
63
|
+
# nil, +type.new+ is called.
|
|
64
|
+
# @param required [Boolean]
|
|
65
|
+
# @param default [Object, nil] literal default value, or a
|
|
66
|
+
# parameterless callable (Proc/Lambda/Method) that returns one.
|
|
67
|
+
# Callables are invoked once per instance during apply_defaults.
|
|
68
|
+
# @param coercion_policy [:keep_raw, :replace, :raise, nil] override
|
|
69
|
+
# the class-level coercion policy for this one field; +nil+ means
|
|
70
|
+
# "use whatever the class says"
|
|
71
|
+
# @param description [String, nil] human-readable description of the
|
|
72
|
+
# field; for documentation purposes only
|
|
73
|
+
# @param rbs_type [String, nil] RBS type expression overriding the
|
|
74
|
+
# derived one in generated signatures (see {#rbs_type})
|
|
75
|
+
# @param sensitive [Boolean] mask the value in {Base#inspect} (see {#sensitive?})
|
|
76
|
+
# @param options [Object, nil] extra keyword type/field options
|
|
77
|
+
# forwarded to coerce (e.g. +format:+, +of_type:+, +round:+,
|
|
78
|
+
# +values:+, +enum:+, +in:+)
|
|
79
|
+
def initialize(name:, type:, type_instance: nil, required: false, default: nil, coercion_policy: nil, description: nil, rbs_type: nil, sensitive: false, **options)
|
|
80
|
+
@name = name.to_sym
|
|
81
|
+
@setter_name = :"#{@name}="
|
|
82
|
+
@ivar_name = :"@#{@name}"
|
|
83
|
+
@type = type
|
|
84
|
+
@required = required
|
|
85
|
+
@default = default
|
|
86
|
+
@options = options.freeze
|
|
87
|
+
@type_instance = type_instance || type.new
|
|
88
|
+
@coercion_policy = coercion_policy
|
|
89
|
+
@description = description
|
|
90
|
+
@rbs_type = rbs_type
|
|
91
|
+
@sensitive = sensitive
|
|
92
|
+
freeze
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [Boolean]
|
|
96
|
+
def required?
|
|
97
|
+
@required
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Whether the field holds data that must not reach a log — a card
|
|
101
|
+
# number, a CVV, a token. {Base#inspect} (and so +pp+) renders
|
|
102
|
+
# {FieldStruct::FILTERED} in place of a non-nil value. Data paths —
|
|
103
|
+
# the reader, +attributes+, +to_h+, +as_json+, +to_json+, pattern
|
|
104
|
+
# matching — return the real value, so round-trips stay exact.
|
|
105
|
+
# Declared with the +sensitive:+ field option.
|
|
106
|
+
#
|
|
107
|
+
# @return [Boolean]
|
|
108
|
+
def sensitive?
|
|
109
|
+
@sensitive
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [String, nil] shorter alias of {#description}
|
|
113
|
+
alias desc description
|
|
114
|
+
|
|
115
|
+
# Concise one-line representation. Designed to be readable in IRB
|
|
116
|
+
# without dumping every ivar (the default Object#inspect is the
|
|
117
|
+
# reason +optional :x, :string+ in a console looks like a wall of
|
|
118
|
+
# text).
|
|
119
|
+
#
|
|
120
|
+
# @return [String]
|
|
121
|
+
def inspect
|
|
122
|
+
parts = [@name.inspect, type_repr]
|
|
123
|
+
parts << 'required' if @required
|
|
124
|
+
parts << 'sensitive' if @sensitive
|
|
125
|
+
parts << "default=#{@default.inspect}" unless @default.nil?
|
|
126
|
+
parts << "coercion_policy=#{@coercion_policy.inspect}" if @coercion_policy
|
|
127
|
+
parts << "description=#{@description.inspect}" if @description
|
|
128
|
+
parts << "rbs_type=#{@rbs_type.inspect}" if @rbs_type
|
|
129
|
+
@options.each { |key, value| parts << "#{key}=#{option_repr(value)}" }
|
|
130
|
+
"#<#{FieldStruct.inspect_namespace}::Field #{parts.join(" ")}>"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Delegates pretty-print to {#inspect} so IRB / pp don't fall back
|
|
134
|
+
# to the default reflection-based output.
|
|
135
|
+
#
|
|
136
|
+
# @param pp [Object] a PP-shaped sink (responds to +#text+)
|
|
137
|
+
# @return [void]
|
|
138
|
+
def pretty_print(pp)
|
|
139
|
+
pp.text(inspect)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# @return [String] the type's short name, or +Nested(...)+ / +Union(...)+
|
|
145
|
+
def type_repr
|
|
146
|
+
base = @type.name.to_s.split('::').last
|
|
147
|
+
if @type <= FieldStruct::Types::Nested
|
|
148
|
+
"Nested(#{@type_instance.struct_class.name || "AnonymousFieldStruct"})"
|
|
149
|
+
elsif @type <= FieldStruct::Types::Union
|
|
150
|
+
members = @type_instance.member_types.map { |m| m.class.name.to_s.split('::').last }
|
|
151
|
+
"Union(#{members.join(" | ")})"
|
|
152
|
+
else
|
|
153
|
+
base
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Render an option's value in inspect output. Type-class values
|
|
158
|
+
# (e.g. +:of_type+ on an Array field) get their basename instead
|
|
159
|
+
# of the full +FieldStruct::Types::String+ class.inspect.
|
|
160
|
+
#
|
|
161
|
+
# @param value [Object, nil] an option value
|
|
162
|
+
# @return [String]
|
|
163
|
+
def option_repr(value)
|
|
164
|
+
case value
|
|
165
|
+
when ::Class
|
|
166
|
+
value.name.to_s.split('::').last
|
|
167
|
+
when FieldStruct::Types::Nested
|
|
168
|
+
"Nested(#{value.struct_class.name || "AnonymousFieldStruct"})"
|
|
169
|
+
when FieldStruct::Types::Base
|
|
170
|
+
value.class.name.to_s.split('::').last
|
|
171
|
+
# Spelled out rather than Hash#inspect, whose format changed in Ruby 3.4.
|
|
172
|
+
when ::Hash
|
|
173
|
+
"{#{value.map { |key, item| "#{key}: #{option_repr(item)}" }.join(", ")}}"
|
|
174
|
+
else
|
|
175
|
+
value.inspect
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FieldStruct
|
|
4
|
+
# The per-class collection of {Field} declarations.
|
|
5
|
+
#
|
|
6
|
+
# Mutable while a class is being defined — the DSL adds fields as it
|
|
7
|
+
# encounters them — and queried thereafter by name or via iteration.
|
|
8
|
+
# Subclass inheritance is handled by {#merge}: a fresh child Metadata
|
|
9
|
+
# is seeded with the parent's fields, then accumulates its own; child
|
|
10
|
+
# declarations win on conflict.
|
|
11
|
+
class Metadata
|
|
12
|
+
include Enumerable
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@fields = {}
|
|
16
|
+
@serializations = {}
|
|
17
|
+
@reverse_serializations = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Hash{Symbol=>Hash{Symbol=>String}}] declared
|
|
21
|
+
# external-name mappings keyed by format name. Each inner hash
|
|
22
|
+
# maps an internal field name (Symbol) to its external name
|
|
23
|
+
# (String). Fields not listed in an inner hash serialize under
|
|
24
|
+
# their canonical name.
|
|
25
|
+
attr_reader :serializations
|
|
26
|
+
|
|
27
|
+
# @param field [Field]
|
|
28
|
+
# @return [self]
|
|
29
|
+
def add(field)
|
|
30
|
+
@fields[field.name] = field
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param name [Symbol, String]
|
|
35
|
+
# @return [Field, nil]
|
|
36
|
+
def [](name)
|
|
37
|
+
@fields[name.to_sym]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Array<Symbol>] field names in insertion order
|
|
41
|
+
def names
|
|
42
|
+
@fields.keys
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Yields each {Field} in insertion order.
|
|
46
|
+
#
|
|
47
|
+
# @yield [field]
|
|
48
|
+
# @yieldparam field [Field]
|
|
49
|
+
# @return [Enumerator<Field>] when no block is given
|
|
50
|
+
def each(&block)
|
|
51
|
+
@fields.each_value(&block)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# A copy-pasteable schema view of the declared fields, keyed by field
|
|
55
|
+
# name. Each value is a plain Hash of primitives/strings (no live type
|
|
56
|
+
# objects), so the result reads cleanly in a console and is safe to
|
|
57
|
+
# `pp` or dump — handy for seeing a model's shape without reading its
|
|
58
|
+
# source. Option values that are classes or type instances are rendered
|
|
59
|
+
# as short names; a callable default shows as +"<callable>"+.
|
|
60
|
+
#
|
|
61
|
+
# User.metadata.to_h
|
|
62
|
+
# # => { name: { type: "String", ruby_type: "String", required: true,
|
|
63
|
+
# # default: nil, options: {}, description: nil }, ... }
|
|
64
|
+
#
|
|
65
|
+
# When a block is given this falls back to +Enumerable#to_h+ (mapping
|
|
66
|
+
# each {Field} to a pair), preserving existing callers.
|
|
67
|
+
#
|
|
68
|
+
# @return [Hash{Symbol=>Hash{Symbol=>Object, nil}}] field name → schema, or
|
|
69
|
+
# the block-mapped pairs when a block is supplied
|
|
70
|
+
def to_h(&block)
|
|
71
|
+
return super if block
|
|
72
|
+
|
|
73
|
+
@fields.transform_values { |field| field_schema(field) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Record a serialization mapping for the named format. Repeats on
|
|
77
|
+
# the same format name replace the prior mapping (last-write-wins).
|
|
78
|
+
# The mapping is frozen on its way in.
|
|
79
|
+
#
|
|
80
|
+
# @param name [Symbol, String]
|
|
81
|
+
# @param mapping [Hash{Symbol=>String}]
|
|
82
|
+
# @return [self]
|
|
83
|
+
def add_serialization(name, mapping)
|
|
84
|
+
@serializations[name.to_sym] = mapping.freeze
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Look up the mapping for a format. Returns an empty hash when the
|
|
89
|
+
# format hasn't been declared — the implicit identity mapping that
|
|
90
|
+
# serializers fall back to.
|
|
91
|
+
#
|
|
92
|
+
# @param name [Symbol, String]
|
|
93
|
+
# @return [Hash{Symbol=>String}] frozen mapping (empty hash when undeclared)
|
|
94
|
+
def serialization(name)
|
|
95
|
+
@serializations[name.to_sym] || EMPTY_SERIALIZATION
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The reverse of {#serialization}: external name → field name, which
|
|
99
|
+
# {Base.from_json} needs on every call. Built once per mapping and reused
|
|
100
|
+
# while {#serialization} still returns that same frozen mapping, so any
|
|
101
|
+
# writer that installs a new one ({#add_serialization}, {#merge}, or a
|
|
102
|
+
# direct write to {#serializations}) is picked up on the next call.
|
|
103
|
+
#
|
|
104
|
+
# @api private
|
|
105
|
+
# @param name [Symbol, String]
|
|
106
|
+
# @return [Hash{String=>Symbol}] frozen; empty when the format is undeclared
|
|
107
|
+
def reverse_serialization(name)
|
|
108
|
+
key = name.to_sym
|
|
109
|
+
mapping = serialization(key)
|
|
110
|
+
cached = @reverse_serializations[key]
|
|
111
|
+
return cached.last if cached && cached.first.equal?(mapping)
|
|
112
|
+
|
|
113
|
+
reverse = mapping.each_with_object({}) { |(internal, external), out| out[external] = internal }.freeze
|
|
114
|
+
# A mapping that isn't frozen could change in place, so it isn't cached.
|
|
115
|
+
@reverse_serializations[key] = [mapping, reverse] if mapping.frozen?
|
|
116
|
+
reverse
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The identity mapping {#serialization} returns for an undeclared format.
|
|
120
|
+
# @return [Hash{Symbol=>String}]
|
|
121
|
+
EMPTY_SERIALIZATION = {}.freeze
|
|
122
|
+
private_constant :EMPTY_SERIALIZATION
|
|
123
|
+
|
|
124
|
+
# Copy fields and serializations from +parent+ into self, leaving
|
|
125
|
+
# already-declared entries untouched (child wins). Order: existing
|
|
126
|
+
# self fields stay where they are; parent fields are appended in
|
|
127
|
+
# iteration order if not present.
|
|
128
|
+
#
|
|
129
|
+
# @param parent [Metadata]
|
|
130
|
+
# @return [self]
|
|
131
|
+
def merge(parent)
|
|
132
|
+
parent.each do |field|
|
|
133
|
+
@fields[field.name] = field unless @fields.key?(field.name)
|
|
134
|
+
end
|
|
135
|
+
parent.serializations.each do |name, mapping|
|
|
136
|
+
@serializations[name] = mapping unless @serializations.key?(name)
|
|
137
|
+
end
|
|
138
|
+
self
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Concise one-line representation. Lists the declared field names
|
|
142
|
+
# and, when present, the declared serialization formats — without
|
|
143
|
+
# dumping the underlying ivar hashes.
|
|
144
|
+
#
|
|
145
|
+
# @return [String]
|
|
146
|
+
def inspect
|
|
147
|
+
return "#<#{FieldStruct.inspect_namespace}::Metadata empty>" if @fields.empty?
|
|
148
|
+
|
|
149
|
+
parts = ["fields=#{@fields.keys.inspect}"]
|
|
150
|
+
parts << "serializations=#{@serializations.keys.inspect}" unless @serializations.empty?
|
|
151
|
+
"#<#{FieldStruct.inspect_namespace}::Metadata #{parts.join(" ")}>"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Multi-line representation suitable for IRB / pp. Renders each
|
|
155
|
+
# field on its own indented line using {Field#inspect}, with the
|
|
156
|
+
# closing +>+ on its own line.
|
|
157
|
+
#
|
|
158
|
+
# @param pp [Object] a PP-shaped sink (responds to +#text+)
|
|
159
|
+
# @return [void]
|
|
160
|
+
def pretty_print(pp)
|
|
161
|
+
if @fields.empty?
|
|
162
|
+
pp.text(inspect)
|
|
163
|
+
return
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
pp.text("#<#{FieldStruct.inspect_namespace}::Metadata")
|
|
167
|
+
@fields.each_value do |field|
|
|
168
|
+
pp.text("\n ")
|
|
169
|
+
pp.text(field.inspect)
|
|
170
|
+
end
|
|
171
|
+
pp.text("\n serializations=#{@serializations.keys.inspect}") unless @serializations.empty?
|
|
172
|
+
pp.text("\n>")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Human/agent-readable summary of the declared fields: one line each
|
|
176
|
+
# with the field's type, required-ness, and the native options its type
|
|
177
|
+
# accepts (with value shapes, required options starred, and any presets).
|
|
178
|
+
# Answers "what can I put here?" without reading source. For the
|
|
179
|
+
# structured form use {#to_h}; for a class-titled version use
|
|
180
|
+
# {Base.describe}.
|
|
181
|
+
#
|
|
182
|
+
# name (String, required) — accepts format (Regexp | Symbol), enum (Array)
|
|
183
|
+
#
|
|
184
|
+
# @return [String]
|
|
185
|
+
def describe
|
|
186
|
+
return '(no fields)' if @fields.empty?
|
|
187
|
+
|
|
188
|
+
@fields.each_value.map { |field| describe_field(field) }.join("\n")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
# @param field [Field]
|
|
194
|
+
# @return [String] one +describe+ line for a field
|
|
195
|
+
def describe_field(field)
|
|
196
|
+
flags = [short_name(field.type), field.required? ? 'required' : 'optional']
|
|
197
|
+
flags << 'sensitive' if field.sensitive?
|
|
198
|
+
label = " #{field.name} (#{flags.join(", ")})"
|
|
199
|
+
parts = [label, declared_options_summary(field), accepts_summary(field.type), element_accepts_summary(field)].compact
|
|
200
|
+
|
|
201
|
+
parts.join(' — ')
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# What this field actually declares, as written.
|
|
205
|
+
#
|
|
206
|
+
# Without this, +describe+ answered only "what COULD go in this field" —
|
|
207
|
+
# the type's option vocabulary — and a field declaring
|
|
208
|
+
# +format: :iso8601, enum: %w[USD EUR]+ rendered identically to one
|
|
209
|
+
# declaring nothing at all. The option schema belongs to the type; this
|
|
210
|
+
# belongs to the field.
|
|
211
|
+
#
|
|
212
|
+
# @param field [Field]
|
|
213
|
+
# @return [String, nil] +nil+ when the field declares no options
|
|
214
|
+
def declared_options_summary(field)
|
|
215
|
+
# Every temporal field carries `format: nil` from apply_default_format,
|
|
216
|
+
# so nils are dropped rather than rendered as a declaration that isn't one.
|
|
217
|
+
declared = field.options.compact
|
|
218
|
+
return nil if declared.empty?
|
|
219
|
+
|
|
220
|
+
declared.map { |key, value| "#{key}: #{declared_option_repr(value)}" }.join(', ')
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# @return [String] a declared option value, rendered for a human. Type
|
|
224
|
+
# values (an Array field's +of_type:+) show their bare type name rather
|
|
225
|
+
# than a quoted string, so the line reads like the declaration it
|
|
226
|
+
# describes.
|
|
227
|
+
# @param value [Object, nil] a declared option value
|
|
228
|
+
def declared_option_repr(value)
|
|
229
|
+
case value
|
|
230
|
+
when FieldStruct::Types::Nested then "Nested(#{short_name(value.struct_class)})"
|
|
231
|
+
when FieldStruct::Types::Base then short_name(value.class)
|
|
232
|
+
when ::Class then short_name(value)
|
|
233
|
+
# Spelled out rather than Hash#inspect, whose format changed in Ruby 3.4.
|
|
234
|
+
when ::Hash then "{#{value.map { |key, item| "#{key}: #{declared_option_repr(item)}" }.join(", ")}}"
|
|
235
|
+
else value.inspect
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# What an array field's +element:+ may hold — the names of its element
|
|
240
|
+
# type's native options — so the line answers "what can I pass to each
|
|
241
|
+
# element" too.
|
|
242
|
+
#
|
|
243
|
+
# @param field [Field]
|
|
244
|
+
# @return [String, nil] +nil+ for a non-array field, or an element type
|
|
245
|
+
# with no options (a nested struct)
|
|
246
|
+
def element_accepts_summary(field)
|
|
247
|
+
return nil unless field.type <= FieldStruct::Types::Array
|
|
248
|
+
|
|
249
|
+
# Names only: the shapes are the element type's own (its
|
|
250
|
+
# +option_schema+), and repeating them doubled every array line.
|
|
251
|
+
element_class = FieldStruct::Types::Array.element_class(field.options[:of_type])
|
|
252
|
+
return nil unless element_class.respond_to?(:option_schema)
|
|
253
|
+
|
|
254
|
+
names = element_class.option_schema.keys
|
|
255
|
+
names.empty? ? nil : "element accepts #{names.join(", ")}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# @return [String, nil] the type's native option vocabulary, or +nil+ when
|
|
259
|
+
# the type declares none
|
|
260
|
+
# @param type_class [Class<Types::Base>]
|
|
261
|
+
def accepts_summary(type_class)
|
|
262
|
+
accepts = native_options_summary(type_class)
|
|
263
|
+
accepts.empty? ? nil : "accepts #{accepts}"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# @return [String] the type's native options as +name(shapes)+, required
|
|
267
|
+
# ones starred, presets appended — empty when the type has none
|
|
268
|
+
# @param type_class [Class<Types::Base>]
|
|
269
|
+
def native_options_summary(type_class)
|
|
270
|
+
return '' unless type_class.respond_to?(:option_schema)
|
|
271
|
+
|
|
272
|
+
type_class.option_schema.map do |name, descriptor|
|
|
273
|
+
shapes = descriptor[:type].map { |klass| short_name(klass) }.join(' | ')
|
|
274
|
+
star = descriptor[:required] ? '*' : ''
|
|
275
|
+
presets = descriptor[:presets].empty? ? '' : "; presets: #{descriptor[:presets].join(", ")}"
|
|
276
|
+
"#{name}#{star} (#{shapes}#{presets})"
|
|
277
|
+
end.join(', ')
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Render one field as a primitive-only schema Hash for {#to_h}.
|
|
281
|
+
#
|
|
282
|
+
# @param field [Field]
|
|
283
|
+
# @return [Hash{Symbol=>Object, nil}]
|
|
284
|
+
def field_schema(field)
|
|
285
|
+
schema = {
|
|
286
|
+
type: short_name(field.type),
|
|
287
|
+
ruby_type: ruby_type_repr(field.type_instance.ruby_type),
|
|
288
|
+
required: field.required?,
|
|
289
|
+
default: schema_value(field.default),
|
|
290
|
+
options: field.options.transform_values { |value| schema_value(value) },
|
|
291
|
+
description: field.description
|
|
292
|
+
}
|
|
293
|
+
# Only when set, so the schema of a field without one is unchanged.
|
|
294
|
+
schema[:rbs_type] = field.rbs_type if field.rbs_type
|
|
295
|
+
schema[:sensitive] = true if field.sensitive?
|
|
296
|
+
schema
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# A single class, or +A | B+ for a multi-class ruby_type (boolean, union).
|
|
300
|
+
#
|
|
301
|
+
# @param ruby_type [Class, Array<Class>] a type's {Types::Base#ruby_type}
|
|
302
|
+
# @return [String]
|
|
303
|
+
def ruby_type_repr(ruby_type)
|
|
304
|
+
Array(ruby_type).map(&:name).join(' | ')
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Reduce an option/default value to something printable: short class
|
|
308
|
+
# names for type references, +"<callable>"+ for procs, recursion into
|
|
309
|
+
# Array/Hash, everything else as-is.
|
|
310
|
+
#
|
|
311
|
+
# @param value [Object, nil] an option or default value
|
|
312
|
+
# @return [Object, nil]
|
|
313
|
+
def schema_value(value)
|
|
314
|
+
case value
|
|
315
|
+
when nil, true, false, ::Numeric, ::String, ::Symbol, ::Regexp, ::Range then value
|
|
316
|
+
when ::Class then short_name(value)
|
|
317
|
+
when FieldStruct::Types::Base then short_name(value.class)
|
|
318
|
+
when ::Array then value.map { |element| schema_value(element) }
|
|
319
|
+
when ::Hash then value.transform_values { |element| schema_value(element) }
|
|
320
|
+
else
|
|
321
|
+
value.respond_to?(:call) ? '<callable>' : value
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# @param klass [Module]
|
|
326
|
+
# @return [String] the last segment of the class name
|
|
327
|
+
def short_name(klass)
|
|
328
|
+
klass.name.to_s.split('::').last
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|