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,331 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FieldStruct
|
|
4
|
+
# Scaffolds FieldStruct class source from one or more JSON example objects.
|
|
5
|
+
#
|
|
6
|
+
# The goal is a *working prototype* you then refine by hand — the generator
|
|
7
|
+
# is deliberately conservative about types and never guesses past the
|
|
8
|
+
# evidence. Pass a single object to seed the shape, or an array of
|
|
9
|
+
# same-shape objects to gather more signal (empty fields get resolved from
|
|
10
|
+
# other samples, presence drives required/optional, and small repeated
|
|
11
|
+
# vocabularies surface as enum candidates). A single object is just the
|
|
12
|
+
# one-sample case of the same engine.
|
|
13
|
+
#
|
|
14
|
+
# FieldStruct::Scaffold.from_json(<<~JSON, class_name: 'User')
|
|
15
|
+
# {"name": "Al", "age": 30, "active": true}
|
|
16
|
+
# JSON
|
|
17
|
+
# # class User < FieldStruct::Base
|
|
18
|
+
# # required :name, :string
|
|
19
|
+
# # required :age, :integer
|
|
20
|
+
# # required :active, :boolean
|
|
21
|
+
# # end
|
|
22
|
+
#
|
|
23
|
+
# Inference rules (conservative on purpose):
|
|
24
|
+
#
|
|
25
|
+
# * JSON object → a nested FieldStruct class (emitted before its parent).
|
|
26
|
+
# * JSON array → +:array, of: <element type>+ (element class for arrays of objects).
|
|
27
|
+
# * JSON +true+/+false+ → +:boolean+; integer → +:integer+; real → +:float+.
|
|
28
|
+
# * JSON string → +:string+ — *including numeric-looking strings* (an
|
|
29
|
+
# +authorization_code+ must not become an Integer). Hints like "maybe
|
|
30
|
+
# :big_decimal" are emitted as trailing comments, not committed types.
|
|
31
|
+
# * required when present and non-blank across *every* sample; else optional.
|
|
32
|
+
#
|
|
33
|
+
# The developer adds enums, descriptions, formats, and final scalar types;
|
|
34
|
+
# the comments point at where.
|
|
35
|
+
module Scaffold
|
|
36
|
+
# Matches an integer- or decimal-looking string (kept as +:string+, only hinted).
|
|
37
|
+
# @return [Regexp]
|
|
38
|
+
NUMERIC_STRING = /\A-?\d+(?:\.\d+)?\z/
|
|
39
|
+
# Most distinct repeated string values before a field is flagged as an enum candidate.
|
|
40
|
+
# @return [Integer]
|
|
41
|
+
ENUM_MAX_DISTINCT = 6
|
|
42
|
+
# Names a scaffolded attribute can't take (suffixed with +_field+): Ruby
|
|
43
|
+
# keywords, plus {Base::RESERVED_FIELD_NAMES}, which +field+ refuses.
|
|
44
|
+
#
|
|
45
|
+
# @return [Array<String>]
|
|
46
|
+
RESERVED = (%w[
|
|
47
|
+
BEGIN END alias and begin break case class def defined? do else elsif end
|
|
48
|
+
ensure false for if in module next nil not or redo rescue retry return self
|
|
49
|
+
super then true undef unless until when while yield __FILE__ __LINE__
|
|
50
|
+
] + Base::RESERVED_FIELD_NAMES.map(&:to_s)).uniq.freeze
|
|
51
|
+
|
|
52
|
+
# Generate FieldStruct class source from JSON example(s).
|
|
53
|
+
#
|
|
54
|
+
# @param input [String, Hash{Object=>Object, nil}, Array<Hash{Object=>Object, nil}>] a JSON string (object or array
|
|
55
|
+
# root), a parsed Hash, or an Array of same-shape Hashes
|
|
56
|
+
# @param class_name [String] the root class name
|
|
57
|
+
# @return [String] Ruby source defining the class and any nested classes
|
|
58
|
+
# @raise [ArgumentError] for an empty set or non-object samples
|
|
59
|
+
def self.from_json(input, class_name: 'Generated')
|
|
60
|
+
samples = normalize(input)
|
|
61
|
+
raise ArgumentError, 'no samples to scaffold from' if samples.empty?
|
|
62
|
+
raise ArgumentError, 'every sample must be a JSON object' unless samples.all?(::Hash)
|
|
63
|
+
|
|
64
|
+
classes = []
|
|
65
|
+
emit_object(class_name, samples, classes, [])
|
|
66
|
+
"#{classes.join("\n\n")}\n"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param input [String, Hash{Object=>Object, nil}, Array<Hash{Object=>Object, nil}>]
|
|
70
|
+
# @return [Array<Object>] the sample set (a lone object becomes a set of
|
|
71
|
+
# one); {.from_json} checks every element is a Hash
|
|
72
|
+
def self.normalize(input)
|
|
73
|
+
data = input.is_a?(::String) ? Oj.load(input, mode: :strict) : input
|
|
74
|
+
case data
|
|
75
|
+
when ::Array then data
|
|
76
|
+
when ::Hash then [data]
|
|
77
|
+
else
|
|
78
|
+
raise ArgumentError, "expected a JSON object or array of objects, got #{data.class}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
private_class_method :normalize
|
|
82
|
+
|
|
83
|
+
# Build the class for +samples+ as +class_name+, appending nested classes
|
|
84
|
+
# to +classes+ first (post-order, so references resolve), and return the
|
|
85
|
+
# (deduplicated) class name used.
|
|
86
|
+
#
|
|
87
|
+
# @param class_name [String] the name wanted
|
|
88
|
+
# @param samples [Array<Hash{Object=>Object, nil}>]
|
|
89
|
+
# @param classes [Array<String>] class sources emitted so far (appended to)
|
|
90
|
+
# @param used [Array<String>] class names taken so far (appended to)
|
|
91
|
+
# @return [String]
|
|
92
|
+
def self.emit_object(class_name, samples, classes, used)
|
|
93
|
+
name = unique_name(class_name, used)
|
|
94
|
+
total = samples.length
|
|
95
|
+
mappings = {}
|
|
96
|
+
attrs = []
|
|
97
|
+
lines = ordered_keys(samples).map do |key|
|
|
98
|
+
# Two keys can normalize to one name ("fooBar"/"foo_bar"); suffix the
|
|
99
|
+
# later one so neither value is lost.
|
|
100
|
+
attr = unique_name(safe_attr(key), attrs)
|
|
101
|
+
mappings[attr] = key unless attr == key
|
|
102
|
+
field_line(attr, observe(samples, key), total, classes, used)
|
|
103
|
+
end
|
|
104
|
+
classes << build_class(name, mappings, lines)
|
|
105
|
+
name
|
|
106
|
+
end
|
|
107
|
+
private_class_method :emit_object
|
|
108
|
+
|
|
109
|
+
# @param samples [Array<Hash{Object=>Object, nil}>]
|
|
110
|
+
# @param key [Object] a sample key
|
|
111
|
+
# @return [Hash{Symbol=>Integer, Array<Object, nil>}] +{ present: Integer, values: Array }+
|
|
112
|
+
# for +key+ across samples
|
|
113
|
+
def self.observe(samples, key)
|
|
114
|
+
present = samples.select { |sample| sample.key?(key) }
|
|
115
|
+
{present: present.length, values: present.map { |sample| sample[key] }}
|
|
116
|
+
end
|
|
117
|
+
private_class_method :observe
|
|
118
|
+
|
|
119
|
+
# @param attr [String] the attribute name
|
|
120
|
+
# @param obs [Hash{Symbol=>Integer, Array<Object, nil>}] from {.observe}
|
|
121
|
+
# @param total [Integer] the sample count
|
|
122
|
+
# @param classes [Array<String>] class sources emitted so far
|
|
123
|
+
# @param used [Array<String>] class names taken so far
|
|
124
|
+
# @return [String] the indented DSL line
|
|
125
|
+
def self.field_line(attr, obs, total, classes, used)
|
|
126
|
+
values = obs[:values]
|
|
127
|
+
non_nil = values.compact
|
|
128
|
+
missing = values.count(&:nil?) + values.count { |v| blank_string?(v) }
|
|
129
|
+
required = obs[:present] == total && missing.zero?
|
|
130
|
+
avail = availability_comment(obs[:present], total, missing)
|
|
131
|
+
|
|
132
|
+
type_part, hint = type_for(attr, non_nil, classes, used)
|
|
133
|
+
decorate("#{required ? "required" : "optional"} :#{attr}, #{type_part}", avail, hint)
|
|
134
|
+
end
|
|
135
|
+
private_class_method :field_line
|
|
136
|
+
|
|
137
|
+
# @param attr [String] the attribute name
|
|
138
|
+
# @param non_nil [Array<Object>] the attribute's non-nil sample values
|
|
139
|
+
# @param classes [Array<String>] class sources emitted so far
|
|
140
|
+
# @param used [Array<String>] class names taken so far
|
|
141
|
+
# @return [Array(String, String)] +[type_part, comment_hint]+
|
|
142
|
+
def self.type_for(attr, non_nil, classes, used)
|
|
143
|
+
if non_nil.any?(::Hash)
|
|
144
|
+
[emit_object(class_name_for(attr), non_nil.grep(::Hash), classes, used), nil]
|
|
145
|
+
elsif non_nil.any?(::Array)
|
|
146
|
+
of_part, hint = array_element(attr, non_nil.grep(::Array).flatten(1), classes, used)
|
|
147
|
+
[":array, of: #{of_part}", hint]
|
|
148
|
+
else
|
|
149
|
+
sym, hint = infer_scalar(non_nil)
|
|
150
|
+
[":#{sym}", hint]
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
private_class_method :type_for
|
|
154
|
+
|
|
155
|
+
# @param attr [String] the attribute name
|
|
156
|
+
# @param elements [Array<Object, nil>] every element of the attribute's arrays
|
|
157
|
+
# @param classes [Array<String>] class sources emitted so far
|
|
158
|
+
# @param used [Array<String>] class names taken so far
|
|
159
|
+
# @return [Array(String, String)] +of:+ value and a comment hint
|
|
160
|
+
def self.array_element(attr, elements, classes, used)
|
|
161
|
+
non_nil = elements.compact
|
|
162
|
+
return [':value', 'empty array(s) in samples — element type unknown'] if non_nil.empty?
|
|
163
|
+
return [emit_object(class_name_for(singularize(attr)), non_nil.grep(::Hash), classes, used), nil] \
|
|
164
|
+
if non_nil.any?(::Hash)
|
|
165
|
+
|
|
166
|
+
sym, hint = infer_scalar(non_nil)
|
|
167
|
+
[":#{sym}", hint]
|
|
168
|
+
end
|
|
169
|
+
private_class_method :array_element
|
|
170
|
+
|
|
171
|
+
# @param values [Array<Object>] non-nil scalar sample values
|
|
172
|
+
# @return [Array(Symbol, String)] inferred scalar type and a comment hint
|
|
173
|
+
def self.infer_scalar(values)
|
|
174
|
+
return [:string, 'always null/absent in samples — type unknown'] if values.empty?
|
|
175
|
+
|
|
176
|
+
kinds = values.map { |v| scalar_kind(v) }.uniq
|
|
177
|
+
return [:boolean, nil] if kinds == [:boolean]
|
|
178
|
+
return [:integer, nil] if kinds == [:integer]
|
|
179
|
+
return [:float, nil] if (kinds - %i[integer float]).empty?
|
|
180
|
+
return string_inference(values) if kinds == [:string]
|
|
181
|
+
|
|
182
|
+
[:value, "mixed types in samples (#{kinds.join(", ")}) — review"]
|
|
183
|
+
end
|
|
184
|
+
private_class_method :infer_scalar
|
|
185
|
+
|
|
186
|
+
# @param values [Array<String>]
|
|
187
|
+
# @return [Array(Symbol, String)] +:string+ and a comment hint
|
|
188
|
+
def self.string_inference(values)
|
|
189
|
+
non_empty = values.reject { |s| blank_string?(s) }
|
|
190
|
+
return [:string, 'always empty in samples — type unknown'] if non_empty.empty?
|
|
191
|
+
|
|
192
|
+
if non_empty.all? { |s| s.match?(NUMERIC_STRING) }
|
|
193
|
+
suggestion = non_empty.any? { |s| s.include?('.') } ? ':big_decimal' : ':integer'
|
|
194
|
+
return [:string, %(numeric-looking (e.g. "#{non_empty.first}") — #{suggestion}? or keep :string if an id/code)]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
distinct = non_empty.uniq
|
|
198
|
+
if non_empty.length >= 3 && distinct.length < non_empty.length && distinct.length <= ENUM_MAX_DISTINCT
|
|
199
|
+
return [:string, "values: #{distinct.inspect} — enum?"]
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
[:string, nil]
|
|
203
|
+
end
|
|
204
|
+
private_class_method :string_inference
|
|
205
|
+
|
|
206
|
+
# @param value [Object]
|
|
207
|
+
# @return [Symbol] +:boolean+, +:integer+, +:float+, +:string+ or +:other+
|
|
208
|
+
def self.scalar_kind(value)
|
|
209
|
+
case value
|
|
210
|
+
when true, false then :boolean
|
|
211
|
+
when ::Integer then :integer
|
|
212
|
+
when ::Float then :float
|
|
213
|
+
when ::String then :string
|
|
214
|
+
else :other
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
private_class_method :scalar_kind
|
|
218
|
+
|
|
219
|
+
# +serialize+ refuses undeclared fields, so the mapping goes after them.
|
|
220
|
+
#
|
|
221
|
+
# @param name [String] the class name
|
|
222
|
+
# @param mappings [Hash{String=>Object}] attribute name → original sample key
|
|
223
|
+
# @param lines [Array<String>] the field lines
|
|
224
|
+
# @return [String] the class source
|
|
225
|
+
def self.build_class(name, mappings, lines)
|
|
226
|
+
body = lines.dup
|
|
227
|
+
body << ' # (no fields detected in samples)' if body.empty?
|
|
228
|
+
unless mappings.empty?
|
|
229
|
+
body << ''
|
|
230
|
+
body << " serialize :json, #{mappings.map { |attr, key| "#{attr}: #{key.inspect}" }.join(", ")}"
|
|
231
|
+
end
|
|
232
|
+
"class #{name} < FieldStruct::Base\n#{body.join("\n")}\nend"
|
|
233
|
+
end
|
|
234
|
+
private_class_method :build_class
|
|
235
|
+
|
|
236
|
+
# @param declaration [String] the DSL call, without indentation
|
|
237
|
+
# @param comment_parts [String, nil] trailing-comment fragments; nil and
|
|
238
|
+
# empty ones are dropped
|
|
239
|
+
# @return [String] the indented line, with a +#+ comment when any part remains
|
|
240
|
+
def self.decorate(declaration, *comment_parts)
|
|
241
|
+
parts = comment_parts.compact.reject(&:empty?)
|
|
242
|
+
line = " #{declaration}"
|
|
243
|
+
parts.empty? ? line : "#{line} # #{parts.join("; ")}"
|
|
244
|
+
end
|
|
245
|
+
private_class_method :decorate
|
|
246
|
+
|
|
247
|
+
# @param present [Integer] samples holding the key
|
|
248
|
+
# @param total [Integer] the sample count
|
|
249
|
+
# @param missing [Integer] nil or blank values among them
|
|
250
|
+
# @return [String, nil]
|
|
251
|
+
def self.availability_comment(present, total, missing)
|
|
252
|
+
return "present #{present}/#{total}" if present < total
|
|
253
|
+
# Only note partial emptiness; when every sample is empty the scalar
|
|
254
|
+
# hint ("always empty — type unknown") already says so.
|
|
255
|
+
return "empty/null in #{missing}/#{total}" if missing.positive? && missing < total
|
|
256
|
+
|
|
257
|
+
nil
|
|
258
|
+
end
|
|
259
|
+
private_class_method :availability_comment
|
|
260
|
+
|
|
261
|
+
# First-seen union of keys across all samples.
|
|
262
|
+
#
|
|
263
|
+
# @param samples [Array<Hash{Object=>Object, nil}>]
|
|
264
|
+
# @return [Array<Object>]
|
|
265
|
+
def self.ordered_keys(samples)
|
|
266
|
+
samples.each_with_object([]) do |sample, keys|
|
|
267
|
+
sample.each_key { |key| keys << key unless keys.include?(key) }
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
private_class_method :ordered_keys
|
|
271
|
+
|
|
272
|
+
# @param base [String] the name wanted
|
|
273
|
+
# @param used [Array<String>] names taken so far (appended to)
|
|
274
|
+
# @return [String] +base+, or +base+ with a numeric suffix
|
|
275
|
+
def self.unique_name(base, used)
|
|
276
|
+
name = base
|
|
277
|
+
suffix = 1
|
|
278
|
+
while used.include?(name)
|
|
279
|
+
suffix += 1
|
|
280
|
+
name = "#{base}#{suffix}"
|
|
281
|
+
end
|
|
282
|
+
used << name
|
|
283
|
+
name
|
|
284
|
+
end
|
|
285
|
+
private_class_method :unique_name
|
|
286
|
+
|
|
287
|
+
# @param key [String] an attribute name
|
|
288
|
+
# @return [String] a constant-safe class name
|
|
289
|
+
def self.class_name_for(key)
|
|
290
|
+
name = camelize(key)
|
|
291
|
+
name = "N#{name}" unless name.match?(/\A[A-Z]/)
|
|
292
|
+
name
|
|
293
|
+
end
|
|
294
|
+
private_class_method :class_name_for
|
|
295
|
+
|
|
296
|
+
# @param str [String]
|
|
297
|
+
# @return [String]
|
|
298
|
+
def self.camelize(str)
|
|
299
|
+
str.to_s.split(/[^a-zA-Z0-9]+/).reject(&:empty?).map { |part| part[0].upcase + (part[1..] || '') }.join
|
|
300
|
+
end
|
|
301
|
+
private_class_method :camelize
|
|
302
|
+
|
|
303
|
+
# Naive: strip one trailing "s". Good enough for a prototype name; the
|
|
304
|
+
# developer renames as needed.
|
|
305
|
+
#
|
|
306
|
+
# @param str [String]
|
|
307
|
+
# @return [String]
|
|
308
|
+
def self.singularize(str)
|
|
309
|
+
str.end_with?('s') && str.length > 1 ? str[0..-2] : str
|
|
310
|
+
end
|
|
311
|
+
private_class_method :singularize
|
|
312
|
+
|
|
313
|
+
# @param key [Object] a sample key
|
|
314
|
+
# @return [String] a snake_case attribute name that +field+ accepts
|
|
315
|
+
def self.safe_attr(key)
|
|
316
|
+
attr = key.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').gsub(/[^a-zA-Z0-9]+/, '_').downcase
|
|
317
|
+
attr = attr.sub(/\A_+/, '').sub(/_+\z/, '')
|
|
318
|
+
attr = "field_#{attr}" if attr.empty? || attr.match?(/\A\d/)
|
|
319
|
+
attr = "#{attr}_field" if RESERVED.include?(attr)
|
|
320
|
+
attr
|
|
321
|
+
end
|
|
322
|
+
private_class_method :safe_attr
|
|
323
|
+
|
|
324
|
+
# @param value [Object, nil]
|
|
325
|
+
# @return [Boolean]
|
|
326
|
+
def self.blank_string?(value)
|
|
327
|
+
value.is_a?(::String) && value.strip.empty?
|
|
328
|
+
end
|
|
329
|
+
private_class_method :blank_string?
|
|
330
|
+
end
|
|
331
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module FieldStruct
|
|
6
|
+
module Types
|
|
7
|
+
# Array type. Coerces each element through the registered element
|
|
8
|
+
# type named by the +:of+ DSL option (resolved at field-declaration
|
|
9
|
+
# time and stashed as +options[:of_type]+ — a class, not a symbol).
|
|
10
|
+
#
|
|
11
|
+
# An optional +:element+ Hash carries the element type's own options
|
|
12
|
+
# (+strict:+, +base:+, +format:+, …), passed to every element's coerce:
|
|
13
|
+
#
|
|
14
|
+
# optional :ids, :array, of: :integer, element: {strict: true, base: 16}
|
|
15
|
+
#
|
|
16
|
+
# A refused element is reported with its index — +element at index 2: …+ —
|
|
17
|
+
# and several with all of theirs (see {.elements_at}).
|
|
18
|
+
#
|
|
19
|
+
# Missing: +nil+ or empty array. ruby_type: +::Array+.
|
|
20
|
+
class Array < Base
|
|
21
|
+
# Native options: a required +of:+ naming the element type — a
|
|
22
|
+
# registered Symbol or a Class (a +Types::Base+ or +FieldStruct::Base+
|
|
23
|
+
# subclass) — and an +element:+ Hash of that element type's options.
|
|
24
|
+
#
|
|
25
|
+
# @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
|
|
26
|
+
def self.own_option_schema
|
|
27
|
+
{
|
|
28
|
+
of: option(type: [::Symbol, ::Class], required: true),
|
|
29
|
+
element: option(type: [::Hash])
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The element type class behind an +of_type+ option, whether it holds
|
|
34
|
+
# a class (+:integer+ → {Types::Integer}) or a built instance (a
|
|
35
|
+
# {Types::Nested}). Its +option_schema+ is what +element:+ accepts.
|
|
36
|
+
#
|
|
37
|
+
# @param of_type [Class, Types::Base] an array field's +:of_type+ option
|
|
38
|
+
# @return [Class<Types::Base>] a {Types::Base} subclass
|
|
39
|
+
def self.element_class(of_type)
|
|
40
|
+
of_type.is_a?(::Class) ? of_type : of_type.class
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param value [::Array<Object, nil>, nil] anything else raises TypeError
|
|
44
|
+
# @param of_type [Class, Types::Base, nil] the element type — a
|
|
45
|
+
# Types::Base subclass for stock types, or an already-built
|
|
46
|
+
# instance for parameterized types like {Types::Nested}. The DSL
|
|
47
|
+
# stashes this when the user writes +of: :string+ / +of: SomeStruct+.
|
|
48
|
+
# Required at the type level; nil here means the type was called
|
|
49
|
+
# directly without DSL help.
|
|
50
|
+
# @param element [::Hash{::Symbol => Object, nil}, nil] the element
|
|
51
|
+
# type's options, passed to every element's coerce (the DSL's
|
|
52
|
+
# +element:+, already validated against the element type's schema)
|
|
53
|
+
# @return [::Array<Object, nil>, nil] each element coerced by +of_type+
|
|
54
|
+
# @raise [TypeError] when input is non-nil and not an Array
|
|
55
|
+
# @raise [ArgumentError] when +of_type+ is missing
|
|
56
|
+
# @raise [StandardError] whatever the element type raises on a bad
|
|
57
|
+
# element; an ArgumentError, TypeError or RangeError keeps its class
|
|
58
|
+
# and gains an +element at index N: + prefix — or, when several
|
|
59
|
+
# elements are refused, +elements at index N, M (index N shown): +
|
|
60
|
+
def coerce(value, of_type: nil, element: nil, **)
|
|
61
|
+
return nil if value.nil?
|
|
62
|
+
raise ::TypeError, "expected Array, got #{value.class}" unless value.is_a?(::Array)
|
|
63
|
+
raise ArgumentError, 'array coerce requires of_type:' if of_type.nil?
|
|
64
|
+
|
|
65
|
+
element_type = element_type_for(of_type)
|
|
66
|
+
# A manual index and no empty **splat keep this hot loop as cheap as it
|
|
67
|
+
# was before element options (each_with_index.map + **{} cost ~40%).
|
|
68
|
+
index = -1
|
|
69
|
+
value.map do |item|
|
|
70
|
+
index += 1
|
|
71
|
+
element ? element_type.coerce(item, **element) : element_type.coerce(item)
|
|
72
|
+
rescue ArgumentError, ::TypeError, ::RangeError => e
|
|
73
|
+
# Off the hot loop: only a refused array pays for finding the rest.
|
|
74
|
+
raise_refused!(value, index, e, element_type, element)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# How many indexes an element error lists before counting the rest.
|
|
79
|
+
# @return [::Integer]
|
|
80
|
+
INDEXES_LISTED = 5
|
|
81
|
+
|
|
82
|
+
# Name a set of element indexes for an error message: one reads
|
|
83
|
+
# +element at index 2+, several +elements at index 2, 5+, and past
|
|
84
|
+
# {INDEXES_LISTED} the rest are counted (+… and 7 more+). Indexes only,
|
|
85
|
+
# never elements, so a message carries no value.
|
|
86
|
+
#
|
|
87
|
+
# @param indexes [::Array<::Integer>] ascending, at least one
|
|
88
|
+
# @return [::String]
|
|
89
|
+
def self.elements_at(indexes)
|
|
90
|
+
return "element at index #{indexes.first}" if indexes.size == 1
|
|
91
|
+
|
|
92
|
+
listed = indexes.first(INDEXES_LISTED).join(', ')
|
|
93
|
+
rest = indexes.size - INDEXES_LISTED
|
|
94
|
+
rest.positive? ? "elements at index #{listed} and #{rest} more" : "elements at index #{listed}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @param value [Object]
|
|
98
|
+
# @return [Boolean] +true+ when +nil+ or empty
|
|
99
|
+
def missing?(value)
|
|
100
|
+
return true if value.nil?
|
|
101
|
+
return true if value.respond_to?(:empty?) && value.empty?
|
|
102
|
+
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [Class] the top-level +::Array+ class
|
|
107
|
+
def ruby_type
|
|
108
|
+
::Array
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# Coerce the elements after the first refused one, so the error names
|
|
114
|
+
# every refused index, then raise the first element's error with them.
|
|
115
|
+
# The same message reaches every coercion_policy, :raise included —
|
|
116
|
+
# finishing the scan costs a refused array no more than an accepted one.
|
|
117
|
+
#
|
|
118
|
+
# @param value [::Array<Object, nil>] the input array
|
|
119
|
+
# @param first [::Integer] the index of the first refused element
|
|
120
|
+
# @param error [StandardError] what the element type raised for it
|
|
121
|
+
# @param element_type [Types::Base] the element type instance
|
|
122
|
+
# @param element [::Hash{::Symbol => Object, nil}, nil] element options
|
|
123
|
+
# @return [void]
|
|
124
|
+
# @raise [StandardError] +error+'s class, prefixed with the indexes
|
|
125
|
+
def raise_refused!(value, first, error, element_type, element)
|
|
126
|
+
indexes = [first]
|
|
127
|
+
(first + 1).upto(value.size - 1) do |index|
|
|
128
|
+
item = value[index]
|
|
129
|
+
element ? element_type.coerce(item, **element) : element_type.coerce(item)
|
|
130
|
+
rescue ArgumentError, ::TypeError, ::RangeError
|
|
131
|
+
indexes << index
|
|
132
|
+
end
|
|
133
|
+
shown = indexes.size == 1 ? '' : " (index #{first} shown)"
|
|
134
|
+
# Exception#exception keeps the class without calling a custom
|
|
135
|
+
# initializer; the indexes, never the elements, are added. The cause
|
|
136
|
+
# chain stays what it was, rather than gaining the unprefixed copy.
|
|
137
|
+
raise error.exception("#{self.class.elements_at(indexes)}#{shown}: #{error.message}"), cause: error.cause
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# +of_type+ may be either a Types::Base subclass (the DSL form for
|
|
141
|
+
# stock types like +:string+) or an already-built Types::Base
|
|
142
|
+
# instance (the DSL form for parameterized types like
|
|
143
|
+
# {Types::Nested}). Both produce a single cached instance per
|
|
144
|
+
# configured element type.
|
|
145
|
+
#
|
|
146
|
+
# @param type_or_instance [Class<Types::Base>, Types::Base]
|
|
147
|
+
# @return [Types::Base]
|
|
148
|
+
def element_type_for(type_or_instance)
|
|
149
|
+
return type_or_instance unless type_or_instance.is_a?(::Class)
|
|
150
|
+
|
|
151
|
+
@element_type_instances ||= {}
|
|
152
|
+
@element_type_instances[type_or_instance] ||= type_or_instance.new
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FieldStruct
|
|
4
|
+
# Namespace for the built-in coercion types ({Types::String}, {Types::Integer},
|
|
5
|
+
# …) and the {Types::Base} contract that custom types implement.
|
|
6
|
+
module Types
|
|
7
|
+
# Abstract base for all FieldStruct type classes.
|
|
8
|
+
#
|
|
9
|
+
# Subclasses must implement {#coerce} and {#ruby_type}. They may override
|
|
10
|
+
# {#missing?} when their notion of "missing" is broader than nil-only
|
|
11
|
+
# (e.g. strings consider empty/whitespace missing, arrays consider empty
|
|
12
|
+
# missing).
|
|
13
|
+
class Base
|
|
14
|
+
# Native per-field options this type understands, as a frozen Hash of
|
|
15
|
+
# option-name (Symbol) → descriptor. Each descriptor is built by
|
|
16
|
+
# {.option} and carries the accepted value classes, whether the option
|
|
17
|
+
# is required at declaration time, and any Symbol presets it accepts.
|
|
18
|
+
#
|
|
19
|
+
# This merges {.own_option_schema} down the class chain so a subtype
|
|
20
|
+
# inherits its parent's options for free (e.g. {Types::UUID} picks up
|
|
21
|
+
# {Types::String}'s +format:+ / +enum:+). The merged result is frozen and
|
|
22
|
+
# memoized per type class — a type's option vocabulary is fixed at load,
|
|
23
|
+
# so this is computed once rather than on every field declaration.
|
|
24
|
+
#
|
|
25
|
+
# This is the single source of truth for both *validation* (the DSL
|
|
26
|
+
# checks declared values against these descriptors) and *discoverability*
|
|
27
|
+
# (an agent or human can ask a type what it accepts without reading
|
|
28
|
+
# source). Options *not* listed here are treated as foreign and pass
|
|
29
|
+
# through onto the Field untouched, available to downstream tooling.
|
|
30
|
+
#
|
|
31
|
+
# @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
|
|
32
|
+
def self.option_schema
|
|
33
|
+
@option_schema ||= begin
|
|
34
|
+
inherited = superclass.respond_to?(:option_schema) ? superclass.option_schema : {}
|
|
35
|
+
inherited.merge(own_option_schema).freeze
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The options this type adds on top of its superclass's. Subclasses
|
|
40
|
+
# override *this* (not {.option_schema}) to declare their native options;
|
|
41
|
+
# the empty default means "adds nothing." {.option_schema} handles the
|
|
42
|
+
# inheritance merge.
|
|
43
|
+
#
|
|
44
|
+
# @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
|
|
45
|
+
def self.own_option_schema
|
|
46
|
+
{}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Build a normalized descriptor for {.option_schema}.
|
|
50
|
+
#
|
|
51
|
+
# @param type [::Class, ::Array<::Class>] the Ruby class(es) a declared
|
|
52
|
+
# value may be an instance of
|
|
53
|
+
# @param required [Boolean] whether the option must be present at
|
|
54
|
+
# field-declaration time
|
|
55
|
+
# @param presets [::Array<::Symbol>] Symbol preset names the option also accepts
|
|
56
|
+
# @return [::Hash{::Symbol => Object}] a frozen descriptor: +{ type:, required:, presets: }+
|
|
57
|
+
def self.option(type:, required: false, presets: [])
|
|
58
|
+
{type: ::Kernel.Array(type).freeze, required: required, presets: presets.to_a.freeze}.freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Resolve symbolic preset references on the per-field options hash
|
|
62
|
+
# at field-declaration time. Default implementation passes options
|
|
63
|
+
# through unchanged; concrete types override to interpret their
|
|
64
|
+
# own options (e.g. +format: :iso8601+ → strftime string).
|
|
65
|
+
#
|
|
66
|
+
# @param options [::Hash{::Symbol => Object, nil}] the per-field options hash, as written by the user
|
|
67
|
+
# @return [::Hash{::Symbol => Object, nil}] possibly-modified options to store on the Field
|
|
68
|
+
def self.resolve_options(options)
|
|
69
|
+
options
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Resolve a declared +format:+ into the form coercion and rendering use.
|
|
73
|
+
#
|
|
74
|
+
# Declared options are kept exactly as the user wrote them — see
|
|
75
|
+
# {TimeFormatResolver} for why — so a Symbol preset name has to be
|
|
76
|
+
# expanded somewhere, and the type owning the preset table is the only
|
|
77
|
+
# thing that can do it. This default serves types with no preset table:
|
|
78
|
+
# a String passes through, a Symbol is refused.
|
|
79
|
+
#
|
|
80
|
+
# @param format [::String, ::Symbol, nil] the format as declared
|
|
81
|
+
# @return [::String, nil]
|
|
82
|
+
# @raise [ArgumentError] when given a Symbol and the type has no presets
|
|
83
|
+
def self.resolve_format(format)
|
|
84
|
+
return format if format.nil? || format.is_a?(::String)
|
|
85
|
+
|
|
86
|
+
raise ArgumentError, "#{name || "this type"} has no format presets; got #{format.inspect}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Coerce an input value into the type's domain.
|
|
90
|
+
#
|
|
91
|
+
# The contract here is intentionally loose — subclasses override with
|
|
92
|
+
# explicit kwargs for the options they consume (e.g. +format:+ for
|
|
93
|
+
# Date, +round:+ for Float, +of_type:+ for Array), plus a trailing
|
|
94
|
+
# +**+ catch-all so a call site that splats +field.options+ can pass
|
|
95
|
+
# keys that some types ignore. See each subclass's +coerce+ for the
|
|
96
|
+
# exact accepted options.
|
|
97
|
+
#
|
|
98
|
+
# @param value [Object] raw input — each subclass narrows this to
|
|
99
|
+
# the inputs it actually accepts
|
|
100
|
+
# @return [Object] the coerced value
|
|
101
|
+
# @raise [NotImplementedError] always, unless overridden by a subclass
|
|
102
|
+
def coerce(value, **)
|
|
103
|
+
raise NotImplementedError, "#{self.class} must implement #coerce"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Whether the given value should be treated as "missing" for the purpose
|
|
107
|
+
# of presence checks. The default is nil-only; subclasses override for
|
|
108
|
+
# emptier notions.
|
|
109
|
+
#
|
|
110
|
+
# @param value [Object]
|
|
111
|
+
# @return [Boolean]
|
|
112
|
+
def missing?(value)
|
|
113
|
+
value.nil?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The native Ruby class a coerced value will be an instance of.
|
|
117
|
+
# Used by the (deferred) RBS generator and for introspection.
|
|
118
|
+
#
|
|
119
|
+
# @return [Class]
|
|
120
|
+
# @raise [NotImplementedError] always, unless overridden by a subclass
|
|
121
|
+
def ruby_type
|
|
122
|
+
raise NotImplementedError, "#{self.class} must implement #ruby_type"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The RBS type expression {FieldStruct::RBS} emits for fields of this
|
|
126
|
+
# type, or +nil+ (the default) to derive one from {#ruby_type}. Override
|
|
127
|
+
# in a custom type whose values {#ruby_type} can't describe on its own —
|
|
128
|
+
# typically a generic class, which RBS needs type arguments for:
|
|
129
|
+
#
|
|
130
|
+
# def ruby_type = ::Hash
|
|
131
|
+
# def rbs_type = '::Hash[::Symbol, ::String]'
|
|
132
|
+
#
|
|
133
|
+
# Without it a bare generic +ruby_type+ is emitted with +untyped+
|
|
134
|
+
# arguments (+::Hash[untyped, untyped]+). A field's own +rbs_type:+
|
|
135
|
+
# option wins over this. Fully qualify constants (+::Hash+, not
|
|
136
|
+
# +Hash+): the RBS is emitted inside the struct's module nesting.
|
|
137
|
+
#
|
|
138
|
+
# @return [::String, nil]
|
|
139
|
+
def rbs_type
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Concise type-instance representation. The default shows just the
|
|
144
|
+
# type-class name (e.g. +#<FieldStruct::Types::String>+). Subclasses
|
|
145
|
+
# that carry parameter state (Nested, Union, …) override to surface
|
|
146
|
+
# what they wrap.
|
|
147
|
+
#
|
|
148
|
+
# @return [::String]
|
|
149
|
+
def inspect
|
|
150
|
+
full = self.class.name || 'AnonymousType'
|
|
151
|
+
prefixed = full.sub(/\AFieldStruct\b/, FieldStruct.inspect_namespace)
|
|
152
|
+
"#<#{prefixed}>"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @param pp [Object] a PP-shaped sink (responds to +#text+)
|
|
156
|
+
# @return [void]
|
|
157
|
+
def pretty_print(pp)
|
|
158
|
+
pp.text(inspect)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|