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
|
+
# Minimal ActiveModel::Name-shaped value for {Base.model_name}.
|
|
5
|
+
#
|
|
6
|
+
# We mirror only what Rails-adjacent callers commonly reach for:
|
|
7
|
+
# +name+, +singular+, +plural+, +element+, and string coercion.
|
|
8
|
+
# No ActiveModel code is reused; pluralization is naive +s+-suffix
|
|
9
|
+
# English. If a richer Name is needed, a downstream gem can build on
|
|
10
|
+
# this without changing the surface.
|
|
11
|
+
class ModelName
|
|
12
|
+
# @return [String] the fully-qualified class name (or "" for anonymous classes)
|
|
13
|
+
attr_reader :name
|
|
14
|
+
|
|
15
|
+
# @return [String] last segment, snake_case (e.g. "user_account")
|
|
16
|
+
attr_reader :singular
|
|
17
|
+
|
|
18
|
+
# @return [String] naive +s+-suffixed plural of {#singular}
|
|
19
|
+
attr_reader :plural
|
|
20
|
+
|
|
21
|
+
# @return [String] same as {#singular} (the AM convention for last segment)
|
|
22
|
+
attr_reader :element
|
|
23
|
+
|
|
24
|
+
# @param class_name [String, nil]
|
|
25
|
+
def initialize(class_name)
|
|
26
|
+
@name = class_name.to_s
|
|
27
|
+
base = @name.split('::').last.to_s
|
|
28
|
+
@element = camel_to_snake(base)
|
|
29
|
+
@singular = @element
|
|
30
|
+
@plural = "#{@element}s"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [String]
|
|
34
|
+
def to_s
|
|
35
|
+
@name
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
alias to_str to_s
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# @param str [String] a bare class name
|
|
43
|
+
# @return [String] its snake_case form
|
|
44
|
+
def camel_to_snake(str)
|
|
45
|
+
str.gsub(/([a-z\d])([A-Z])/, '\\1_\\2').gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2').downcase
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FieldStruct
|
|
4
|
+
# Generates RBS type signatures for the *generated* instance surface of
|
|
5
|
+
# user-defined {FieldStruct::Base} subclasses — the per-field accessors the
|
|
6
|
+
# +field+ DSL defines dynamically.
|
|
7
|
+
#
|
|
8
|
+
# Sord generates +sig/field_struct.rbs+ from the library's static source, so
|
|
9
|
+
# it covers {Base}, {Types}, {Registry}, … but it can never see +User#name+
|
|
10
|
+
# or +User#age=+: those methods don't exist until a consumer declares the
|
|
11
|
+
# fields. This is the deferred "track 2" of the plan's RBS strategy (D13) —
|
|
12
|
+
# walk a subclass's {Metadata} and emit signatures for its accessors so
|
|
13
|
+
# downstream Steep / Solargraph can type-check code that touches them.
|
|
14
|
+
#
|
|
15
|
+
# class User < FieldStruct::Base
|
|
16
|
+
# required :name, :string
|
|
17
|
+
# optional :age, :integer
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# puts FieldStruct::RBS.generate(User)
|
|
21
|
+
# # # Generated by FieldStruct::RBS. ...
|
|
22
|
+
# # class User < ::FieldStruct::Base
|
|
23
|
+
# # attr_reader name: ::String
|
|
24
|
+
# # def name=: (::String? value) -> ::String?
|
|
25
|
+
# #
|
|
26
|
+
# # attr_reader age: ::Integer?
|
|
27
|
+
# # def age=: (::Integer? value) -> ::Integer?
|
|
28
|
+
# # end
|
|
29
|
+
#
|
|
30
|
+
# Design choices:
|
|
31
|
+
#
|
|
32
|
+
# * **Types describe a valid instance.** The reader type is the field's
|
|
33
|
+
# {Types::Base#ruby_type} (or an override, below). Nullability follows
|
|
34
|
+
# {Field#required?}: required → +T+, optional → +T?+. An invalid
|
|
35
|
+
# instance — a required field left unset, or a +:keep_raw+ coercion
|
|
36
|
+
# failure holding the raw input — can hold other values; check +valid?+
|
|
37
|
+
# before trusting the signature. The generated header says so.
|
|
38
|
+
# * **Writer** is +(T? value) -> T?+: the runtime setter accepts +nil+ and
|
|
39
|
+
# returns the coerced value. Loose input the type would coerce
|
|
40
|
+
# (+user.age = "30"+) is valid Ruby but a type error to Steep — cast or
|
|
41
|
+
# coerce at the boundary.
|
|
42
|
+
# * **Generic arguments** are filled in: arrays as +::Array[Element]+
|
|
43
|
+
# (a scalar element as +Element?+, since a valid instance can hold nil
|
|
44
|
+
# elements; a nested element stays non-nil, since nil makes it invalid),
|
|
45
|
+
# unions as +(A | B)+ (a boolean member as +bool+, a member's own
|
|
46
|
+
# {Types::Base#rbs_type} first), nested structs by qualified name; an
|
|
47
|
+
# anonymous nested class raises +ArgumentError+. A custom type whose +ruby_type+ is a bare generic
|
|
48
|
+
# class (+::Hash+) gets +untyped+ arguments unless it declares
|
|
49
|
+
# {Types::Base#rbs_type}.
|
|
50
|
+
# * **Anything-valued fields** (+:value+, or a union with a +:value+
|
|
51
|
+
# member) are +untyped+ — reader and writer, never +untyped?+.
|
|
52
|
+
# * **Overrides:** a field's +rbs_type:+ option, then the type's
|
|
53
|
+
# {Types::Base#rbs_type}, replace the derived type +T+ verbatim;
|
|
54
|
+
# nullability is still added for optional fields and writers.
|
|
55
|
+
# * **+new+ / +initialize+** are inherited from {Base}, which already
|
|
56
|
+
# accepts +User.new(name: ...)+.
|
|
57
|
+
#
|
|
58
|
+
# Each call emits one class. Referenced types (nested structs, array
|
|
59
|
+
# elements) appear as qualified names (+::Address+) — generate RBS for those
|
|
60
|
+
# classes too so the references resolve. {.type_for} exposes the per-field
|
|
61
|
+
# mapping for other generators.
|
|
62
|
+
module RBS
|
|
63
|
+
# The pair a +:boolean+ field's +ruby_type+ returns, rendered as +bool+.
|
|
64
|
+
# @return [Array<Class>]
|
|
65
|
+
BOOL_CLASSES = [TrueClass, FalseClass].freeze
|
|
66
|
+
private_constant :BOOL_CLASSES
|
|
67
|
+
|
|
68
|
+
# Comment lines opening every {.generate} result: the contract the
|
|
69
|
+
# signatures hold to.
|
|
70
|
+
# @return [String]
|
|
71
|
+
HEADER = <<~RBS
|
|
72
|
+
# Generated by FieldStruct::RBS. Types describe a valid instance: an
|
|
73
|
+
# invalid one (a required field left unset, or a :keep_raw coercion
|
|
74
|
+
# failure holding the raw input) can hold other values. Check valid?.
|
|
75
|
+
RBS
|
|
76
|
+
private_constant :HEADER
|
|
77
|
+
|
|
78
|
+
# Ruby classes RBS declares with type parameters, and how many. A bare
|
|
79
|
+
# +ruby_type+ naming one of these is emitted with +untyped+ arguments,
|
|
80
|
+
# since RBS rejects a generic class without them.
|
|
81
|
+
# @return [Hash{Class=>Integer}]
|
|
82
|
+
GENERIC_ARITY = {::Array => 1, ::Hash => 2, ::Range => 1}.freeze
|
|
83
|
+
private_constant :GENERIC_ARITY
|
|
84
|
+
|
|
85
|
+
# Generate RBS source for a single FieldStruct subclass.
|
|
86
|
+
#
|
|
87
|
+
# @param klass [Class<FieldStruct::Base>]
|
|
88
|
+
# @return [String] RBS declaring the class (wrapped in its module nesting)
|
|
89
|
+
# and a typed reader and writer for every field it declares, after a
|
|
90
|
+
# comment header stating that the types describe a valid instance
|
|
91
|
+
# @raise [ArgumentError] when +klass+ is not a named {FieldStruct::Base}
|
|
92
|
+
# subclass
|
|
93
|
+
def self.generate(klass)
|
|
94
|
+
unless klass.is_a?(::Class) && klass < FieldStruct::Base
|
|
95
|
+
raise ArgumentError,
|
|
96
|
+
"FieldStruct::RBS.generate expects a FieldStruct::Base subclass, got #{klass.inspect}"
|
|
97
|
+
end
|
|
98
|
+
raise ArgumentError, 'cannot generate RBS for an anonymous class' unless klass.name
|
|
99
|
+
|
|
100
|
+
HEADER + wrap_namespace(klass.name, class_block(klass))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The RBS type of one field's value, as {.generate} emits it — for other
|
|
104
|
+
# generators (e.g. one typing a method that takes or returns the field)
|
|
105
|
+
# that need the same mapping:
|
|
106
|
+
#
|
|
107
|
+
# FieldStruct::RBS.type_for(Order.metadata[:items])
|
|
108
|
+
# # => "::Array[::LineItem]?"
|
|
109
|
+
# FieldStruct::RBS.type_for(Order.metadata[:items], nullable: false)
|
|
110
|
+
# # => "::Array[::LineItem]"
|
|
111
|
+
#
|
|
112
|
+
# The type comes from the field's +rbs_type:+ option, else its type's
|
|
113
|
+
# {Types::Base#rbs_type}, else its {Types::Base#ruby_type} with generic
|
|
114
|
+
# arguments filled in. Qualified names (+::LineItem+) assume the
|
|
115
|
+
# referenced classes get RBS too.
|
|
116
|
+
#
|
|
117
|
+
# @param field [Field] a declared field, e.g. +Order.metadata[:items]+
|
|
118
|
+
# @param nullable [Boolean, nil] append +?+ (the reader type of an
|
|
119
|
+
# optional field, and every writer's); +nil+, the default, means
|
|
120
|
+
# +!field.required?+. Never applied to +untyped+, which already
|
|
121
|
+
# admits +nil+.
|
|
122
|
+
# @return [String] an RBS type expression
|
|
123
|
+
# @raise [ArgumentError] when +field+ is not a {Field}
|
|
124
|
+
def self.type_for(field, nullable: nil)
|
|
125
|
+
unless field.is_a?(FieldStruct::Field)
|
|
126
|
+
raise ArgumentError, 'FieldStruct::RBS.type_for expects a FieldStruct::Field ' \
|
|
127
|
+
"(e.g. User.metadata[:name]), got #{field.inspect}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
nullable = !field.required? if nullable.nil?
|
|
131
|
+
base = override_expression(field.rbs_type || field.type_instance.rbs_type) ||
|
|
132
|
+
expression_for(field.type_instance, field.options)
|
|
133
|
+
nullable ? optional(base) : base
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Build the +class ... end+ block (unindented, no surrounding modules).
|
|
137
|
+
#
|
|
138
|
+
# @param klass [Class<FieldStruct::Base>]
|
|
139
|
+
# @return [String]
|
|
140
|
+
def self.class_block(klass)
|
|
141
|
+
simple = klass.name.split('::').last
|
|
142
|
+
lines = ["class #{simple} < ::#{klass.superclass.name}"]
|
|
143
|
+
own_fields(klass).each_with_index do |field, index|
|
|
144
|
+
writer = type_for(field, nullable: true)
|
|
145
|
+
lines << '' unless index.zero?
|
|
146
|
+
lines << " attr_reader #{field.name}: #{type_for(field)}"
|
|
147
|
+
lines << " def #{field.name}=: (#{writer} value) -> #{writer}"
|
|
148
|
+
end
|
|
149
|
+
lines << 'end'
|
|
150
|
+
lines.join("\n")
|
|
151
|
+
end
|
|
152
|
+
private_class_method :class_block
|
|
153
|
+
|
|
154
|
+
# Fields declared on +klass+ itself — inherited fields (the same {Field}
|
|
155
|
+
# objects merged in at inheritance time) belong to the parent's RBS.
|
|
156
|
+
#
|
|
157
|
+
# @param klass [Class<FieldStruct::Base>]
|
|
158
|
+
# @return [Array<Field>]
|
|
159
|
+
def self.own_fields(klass)
|
|
160
|
+
parent = klass.superclass
|
|
161
|
+
parent_meta = parent.respond_to?(:metadata) ? parent.metadata : nil
|
|
162
|
+
klass.metadata.reject { |field| parent_meta && parent_meta[field.name].equal?(field) }
|
|
163
|
+
end
|
|
164
|
+
private_class_method :own_fields
|
|
165
|
+
|
|
166
|
+
# An override, parenthesized when it is a union, intersection or proc
|
|
167
|
+
# type, so a trailing +?+ applies to the whole of it.
|
|
168
|
+
#
|
|
169
|
+
# @param rbs [String, nil] a field's +rbs_type:+ or a type's +rbs_type+
|
|
170
|
+
# @return [String, nil] +nil+ when there is no override
|
|
171
|
+
def self.override_expression(rbs)
|
|
172
|
+
return nil if rbs.nil?
|
|
173
|
+
|
|
174
|
+
rbs = rbs.strip
|
|
175
|
+
return rbs if wrapped?(rbs)
|
|
176
|
+
|
|
177
|
+
rbs.match?(/[|&^]/) ? "(#{rbs})" : rbs
|
|
178
|
+
end
|
|
179
|
+
private_class_method :override_expression
|
|
180
|
+
|
|
181
|
+
# @param rbs [String] a stripped RBS type expression
|
|
182
|
+
# @return [Boolean] whether one parenthesized group spans all of +rbs+
|
|
183
|
+
# (optionally followed by +?+), so wrapping it again adds nothing
|
|
184
|
+
def self.wrapped?(rbs)
|
|
185
|
+
return false unless rbs.start_with?('(')
|
|
186
|
+
|
|
187
|
+
depth = 0
|
|
188
|
+
rbs.each_char.with_index do |char, index|
|
|
189
|
+
depth += 1 if char == '('
|
|
190
|
+
depth -= 1 if char == ')'
|
|
191
|
+
return rbs[(index + 1)..].then { |rest| rest.empty? || rest == '?' } if depth.zero?
|
|
192
|
+
end
|
|
193
|
+
false
|
|
194
|
+
end
|
|
195
|
+
private_class_method :wrapped?
|
|
196
|
+
|
|
197
|
+
# @param base [String] an RBS type expression
|
|
198
|
+
# @return [String] +base+ made nullable, unless it already admits +nil+
|
|
199
|
+
def self.optional(base)
|
|
200
|
+
return base if base == 'untyped' || base.end_with?('?')
|
|
201
|
+
|
|
202
|
+
"#{base}?"
|
|
203
|
+
end
|
|
204
|
+
private_class_method :optional
|
|
205
|
+
|
|
206
|
+
# Map a type instance (plus its field options, needed for arrays) to an
|
|
207
|
+
# RBS type expression.
|
|
208
|
+
#
|
|
209
|
+
# @param type_instance [Types::Base]
|
|
210
|
+
# @param options [Hash{Symbol=>Object, nil}]
|
|
211
|
+
# @return [String]
|
|
212
|
+
def self.expression_for(type_instance, options)
|
|
213
|
+
return "::Array[#{element_type(options[:of_type])}]" if type_instance.is_a?(FieldStruct::Types::Array)
|
|
214
|
+
return union_expression(type_instance) if type_instance.is_a?(FieldStruct::Types::Union)
|
|
215
|
+
|
|
216
|
+
ruby_type = type_instance.ruby_type
|
|
217
|
+
classes = ruby_type.is_a?(::Array) ? ruby_type : [ruby_type]
|
|
218
|
+
mapped = classes_to_rbs(classes)
|
|
219
|
+
return 'untyped' if mapped.include?('untyped')
|
|
220
|
+
|
|
221
|
+
mapped.one? ? mapped.first : "(#{mapped.join(" | ")})"
|
|
222
|
+
end
|
|
223
|
+
private_class_method :expression_for
|
|
224
|
+
|
|
225
|
+
# A union as its members' types, each member's own +rbs_type+ first.
|
|
226
|
+
#
|
|
227
|
+
# @param union [Types::Union]
|
|
228
|
+
# @return [String] +untyped+ when any member is
|
|
229
|
+
def self.union_expression(union)
|
|
230
|
+
members = union.member_types.map { |member| override_expression(member.rbs_type) || expression_for(member, {}) }
|
|
231
|
+
return 'untyped' if members.include?('untyped')
|
|
232
|
+
|
|
233
|
+
members = members.uniq
|
|
234
|
+
members.one? ? members.first : "(#{members.join(" | ")})"
|
|
235
|
+
end
|
|
236
|
+
private_class_method :union_expression
|
|
237
|
+
|
|
238
|
+
# @param classes [Array<Class>] a {Types::Base#ruby_type}, as a list
|
|
239
|
+
# @return [Array<String>] one RBS type per class, with +TrueClass+ /
|
|
240
|
+
# +FalseClass+ together collapsed to +bool+ where the pair appears
|
|
241
|
+
def self.classes_to_rbs(classes)
|
|
242
|
+
bool = BOOL_CLASSES.all? { |klass| classes.include?(klass) }
|
|
243
|
+
classes.map { |klass| bool && BOOL_CLASSES.include?(klass) ? 'bool' : class_to_rbs(klass) }.uniq
|
|
244
|
+
end
|
|
245
|
+
private_class_method :classes_to_rbs
|
|
246
|
+
|
|
247
|
+
# The element type of an array field, derived from its +:of_type+ (a
|
|
248
|
+
# +Types::Base+ subclass for stock types, or a built instance for
|
|
249
|
+
# parameterized ones like {Types::Nested}).
|
|
250
|
+
#
|
|
251
|
+
# @param of_type [Class, Types::Base, nil]
|
|
252
|
+
# @return [String]
|
|
253
|
+
def self.element_type(of_type)
|
|
254
|
+
return 'untyped' if of_type.nil?
|
|
255
|
+
|
|
256
|
+
instance = of_type.is_a?(::Class) ? of_type.new : of_type
|
|
257
|
+
expression = override_expression(instance.rbs_type) || expression_for(instance, {})
|
|
258
|
+
# A valid instance may hold nil scalar elements; a nil nested element
|
|
259
|
+
# fails validation, so nested elements stay non-nil.
|
|
260
|
+
instance.is_a?(FieldStruct::Types::Nested) ? expression : optional(expression)
|
|
261
|
+
end
|
|
262
|
+
private_class_method :element_type
|
|
263
|
+
|
|
264
|
+
# @param klass [Class] a Ruby class from a {Types::Base#ruby_type}
|
|
265
|
+
# @return [String] qualified RBS name (with +untyped+ type arguments for
|
|
266
|
+
# a generic class), or +untyped+ for the value type's +::Object+
|
|
267
|
+
def self.class_to_rbs(klass)
|
|
268
|
+
return 'untyped' if klass == ::Object
|
|
269
|
+
raise ArgumentError, 'FieldStruct::RBS cannot name an anonymous class; assign it to a constant' if klass.name.nil?
|
|
270
|
+
|
|
271
|
+
arity = GENERIC_ARITY[klass]
|
|
272
|
+
return "::#{klass.name}" unless arity
|
|
273
|
+
|
|
274
|
+
"::#{klass.name}[#{Array.new(arity, "untyped").join(", ")}]"
|
|
275
|
+
end
|
|
276
|
+
private_class_method :class_to_rbs
|
|
277
|
+
|
|
278
|
+
# Wrap an inner block in the +module ... end+ nesting implied by a
|
|
279
|
+
# qualified class name, indenting two spaces per level.
|
|
280
|
+
#
|
|
281
|
+
# @param full_name [String] e.g. +"Acme::Order"+
|
|
282
|
+
# @param inner [String] the class block
|
|
283
|
+
# @return [String] RBS source ending in a trailing newline
|
|
284
|
+
def self.wrap_namespace(full_name, inner)
|
|
285
|
+
modules = full_name.split('::')[0...-1]
|
|
286
|
+
block = inner
|
|
287
|
+
modules.reverse_each { |mod| block = "module #{mod}\n#{indent(block)}\nend" }
|
|
288
|
+
"#{block}\n"
|
|
289
|
+
end
|
|
290
|
+
private_class_method :wrap_namespace
|
|
291
|
+
|
|
292
|
+
# @param text [String]
|
|
293
|
+
# @return [String] +text+ with every non-blank line indented two spaces
|
|
294
|
+
def self.indent(text)
|
|
295
|
+
text.split("\n", -1).map { |line| line.empty? ? line : " #{line}" }.join("\n")
|
|
296
|
+
end
|
|
297
|
+
private_class_method :indent
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FieldStruct
|
|
4
|
+
# Name → type-class map with optional parent fallback.
|
|
5
|
+
#
|
|
6
|
+
# Holds the resolution table that maps symbolic type names (`:string`,
|
|
7
|
+
# `:integer`, …) to the {Types} classes that implement them. A namespace
|
|
8
|
+
# can build its own registry parented to the base ({FieldStruct.types})
|
|
9
|
+
# to extend or override the default set without mutating it.
|
|
10
|
+
class Registry
|
|
11
|
+
# @return [Registry, nil] the parent registry, or +nil+ for an unparented one
|
|
12
|
+
attr_reader :parent
|
|
13
|
+
|
|
14
|
+
# @param parent [Registry, nil] the registry to fall back to when a name
|
|
15
|
+
# isn't found locally
|
|
16
|
+
def initialize(parent = nil)
|
|
17
|
+
@parent = parent
|
|
18
|
+
@types = {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Register a type class — or an alias to an already-registered name.
|
|
22
|
+
#
|
|
23
|
+
# registry.register(:integer, MyInteger) # type class
|
|
24
|
+
# registry.register(:decimal, :big_decimal) # alias (resolved now)
|
|
25
|
+
#
|
|
26
|
+
# Aliases are eagerly resolved through {#lookup}, so the alias target
|
|
27
|
+
# must already be registered (locally or in a parent). A later
|
|
28
|
+
# registration of the same name overwrites silently — this is the
|
|
29
|
+
# mechanism that lets a namespace registry shadow a parent's type.
|
|
30
|
+
#
|
|
31
|
+
# @param name [Symbol, String] the name to register
|
|
32
|
+
# @param target [Class, Symbol] a type class, or an existing registered name
|
|
33
|
+
# @return [self]
|
|
34
|
+
# @raise [KeyError] when +target+ is a Symbol that isn't registered yet
|
|
35
|
+
def register(name, target)
|
|
36
|
+
type_class = target.is_a?(::Symbol) ? lookup(target) : target
|
|
37
|
+
@types[name.to_sym] = type_class
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Resolve a name to its type class, walking the parent chain.
|
|
42
|
+
#
|
|
43
|
+
# @param name [Symbol, String]
|
|
44
|
+
# @return [Class] the type class
|
|
45
|
+
# @raise [KeyError] when the name isn't registered locally or via the parent chain
|
|
46
|
+
def lookup(name)
|
|
47
|
+
key = name.to_sym
|
|
48
|
+
return @types[key] if @types.key?(key)
|
|
49
|
+
return @parent.lookup(key) if @parent
|
|
50
|
+
|
|
51
|
+
raise ::KeyError, "no FieldStruct type registered for #{name.inspect}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Whether the name resolves to anything (locally or via the parent chain).
|
|
55
|
+
#
|
|
56
|
+
# @param name [Symbol, String]
|
|
57
|
+
# @return [Boolean]
|
|
58
|
+
def key?(name)
|
|
59
|
+
key = name.to_sym
|
|
60
|
+
return true if @types.key?(key)
|
|
61
|
+
|
|
62
|
+
!@parent.nil? && @parent.key?(key)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Every distinct type class resolvable through this registry, including
|
|
66
|
+
# those inherited from the parent chain. Local entries come first;
|
|
67
|
+
# duplicates (e.g. an alias pointing at an already-listed class, or a
|
|
68
|
+
# parent class also registered locally) are removed.
|
|
69
|
+
#
|
|
70
|
+
# @return [Array<Class>] deduplicated, local-first
|
|
71
|
+
def type_classes
|
|
72
|
+
own = @types.values
|
|
73
|
+
(@parent ? own + @parent.type_classes : own).uniq
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [String] one-line summary of the locally-registered names
|
|
77
|
+
# (does not enumerate the parent chain), with a +parent+ marker
|
|
78
|
+
# when the registry has one
|
|
79
|
+
def inspect
|
|
80
|
+
parts = ["types=#{@types.keys.inspect}"]
|
|
81
|
+
parts << 'parent' if @parent
|
|
82
|
+
"#<#{FieldStruct.inspect_namespace}::Registry #{parts.join(" ")}>"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|