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,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module FieldStruct
|
|
7
|
+
module Types
|
|
8
|
+
# The +zone:+ option shared by {Time} and {DateTime}.
|
|
9
|
+
#
|
|
10
|
+
# A zone-less string (+"2026-10-22 10:00"+) names a wall-clock time, not an
|
|
11
|
+
# instant: by default +:time+ reads it in the *process* zone and +:datetime+
|
|
12
|
+
# in UTC, so the same input meant different instants on different servers.
|
|
13
|
+
# +zone:+ supplies the missing offset, so it means one instant everywhere.
|
|
14
|
+
#
|
|
15
|
+
# It only fills in an offset that is MISSING. A string with an offset, a
|
|
16
|
+
# +Time+, a +DateTime+ (and ActiveSupport's TimeWithZone) keep theirs; a
|
|
17
|
+
# +Date+, which has none, becomes midnight in the zone.
|
|
18
|
+
#
|
|
19
|
+
# Accepted values are +"UTC"+, fixed offsets +"±HH:MM"+ and IANA
|
|
20
|
+
# Area/Location names (+"America/Denver"+). A fixed offset is the same all
|
|
21
|
+
# year. A name is read through tzinfo's zone database, so a wall-clock time
|
|
22
|
+
# gets the offset in force at that date and an instant (an epoch) the
|
|
23
|
+
# offset in force at that instant. tzinfo is required only when a field
|
|
24
|
+
# declares a name.
|
|
25
|
+
#
|
|
26
|
+
# Daylight-saving edges, for a name:
|
|
27
|
+
#
|
|
28
|
+
# - A wall-clock time the zone skips (02:30 on a spring-forward day) is
|
|
29
|
+
# refused, as a 30 February is: it names no instant. ActiveSupport moves
|
|
30
|
+
# it an hour forward instead; FieldStruct does not guess.
|
|
31
|
+
# - A wall-clock time the zone repeats (01:30 on a fall-back day) is read
|
|
32
|
+
# at its daylight-time offset — the earlier instant — which is what
|
|
33
|
+
# ActiveSupport's TimeWithZone picks, so a Rails app sees the same answer.
|
|
34
|
+
# - A +Date+ whose midnight the zone skips becomes the first moment of that
|
|
35
|
+
# day (01:00 in Santiago on its spring-forward day), as
|
|
36
|
+
# +Date#in_time_zone+ does.
|
|
37
|
+
#
|
|
38
|
+
# The value returned is a plain +::Time+ (or +::DateTime+) carrying the
|
|
39
|
+
# offset, never a +TZInfo::TimeWithOffset+; it does not carry the zone
|
|
40
|
+
# itself, so arithmetic on it keeps that offset rather than following DST.
|
|
41
|
+
#
|
|
42
|
+
# Refused: abbreviations and legacy aliases with no +/+ (+"MST"+ is a fixed
|
|
43
|
+
# -07:00 in the database, not Mountain time with DST) and Rails' friendly
|
|
44
|
+
# names (+"Mountain Time (US & Canada)"+), which only exist when
|
|
45
|
+
# ActiveSupport is loaded and so would make a declaration valid in one
|
|
46
|
+
# process and invalid in another (invariant 7).
|
|
47
|
+
module ZoneOption
|
|
48
|
+
# A fixed UTC offset, colon required.
|
|
49
|
+
# @return [::Regexp]
|
|
50
|
+
OFFSET = /\A([+-])(\d{2}):(\d{2})\z/
|
|
51
|
+
|
|
52
|
+
# Refuse a +zone:+ value FieldStruct cannot read, at declaration. A
|
|
53
|
+
# non-String is left for the option-shape check to report.
|
|
54
|
+
#
|
|
55
|
+
# @param type_name [::String] the type's short name, for the message
|
|
56
|
+
# @param options [::Hash{::Symbol => Object, nil}] the per-field options
|
|
57
|
+
# @return [::Hash{::Symbol => Object, nil}] the options, unchanged
|
|
58
|
+
# @raise [ArgumentError] when +zone:+ is a String that is not +"UTC"+, a
|
|
59
|
+
# valid +±HH:MM+ offset or an IANA name in the zone database, or when
|
|
60
|
+
# no zone database can be found
|
|
61
|
+
def self.validate!(type_name, options)
|
|
62
|
+
zone = options[:zone]
|
|
63
|
+
return options unless zone.is_a?(::String)
|
|
64
|
+
return options if offset_seconds(zone)
|
|
65
|
+
|
|
66
|
+
raise ArgumentError, refusal(type_name, zone) unless zone.include?('/')
|
|
67
|
+
|
|
68
|
+
load_timezone(type_name, zone)
|
|
69
|
+
options
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param type_name [::String] the type's short name
|
|
73
|
+
# @param zone [::String] the refused +zone:+
|
|
74
|
+
# @return [::String] the declaration error, with a hint for the
|
|
75
|
+
# shape of +zone+
|
|
76
|
+
def self.refusal(type_name, zone)
|
|
77
|
+
hint = if zone.match?(/[ ()&]/)
|
|
78
|
+
'Rails zone names are an ActiveSupport alias, not IANA names, and exist only when ' \
|
|
79
|
+
'ActiveSupport is loaded: use the name it maps to (ActiveSupport::TimeZone::MAPPING[name]).'
|
|
80
|
+
elsif zone.include?('/')
|
|
81
|
+
'The zone database has no zone by that name.'
|
|
82
|
+
else
|
|
83
|
+
'Use an Area/Location name: abbreviations and aliases are refused ("MST" is a fixed ' \
|
|
84
|
+
'-07:00 in the zone database, not Mountain time with DST), and an offset needs its colon.'
|
|
85
|
+
end
|
|
86
|
+
"zone: on #{type_name} expects an IANA name like \"America/Denver\", \"UTC\" or a UTC offset " \
|
|
87
|
+
"like \"-06:00\", got #{zone.inspect}. #{hint}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Look up (and cache) a zone by name, requiring tzinfo on first use.
|
|
91
|
+
#
|
|
92
|
+
# @param type_name [::String] the type's short name, for the message
|
|
93
|
+
# @param zone [::String] an IANA name
|
|
94
|
+
# @return [Object] the +TZInfo::Timezone+
|
|
95
|
+
# @raise [ArgumentError] when the name is unknown, or no zone data is
|
|
96
|
+
# installed
|
|
97
|
+
def self.load_timezone(type_name, zone)
|
|
98
|
+
require 'tzinfo'
|
|
99
|
+
@timezones ||= {}
|
|
100
|
+
@timezones[zone] ||= ::TZInfo::Timezone.get(zone)
|
|
101
|
+
rescue ::TZInfo::InvalidTimezoneIdentifier
|
|
102
|
+
raise ArgumentError, refusal(type_name, zone)
|
|
103
|
+
rescue ::TZInfo::DataSourceNotFound
|
|
104
|
+
raise ArgumentError,
|
|
105
|
+
"zone: on #{type_name} names #{zone.inspect}, but no time zone data was found on this system. " \
|
|
106
|
+
'Add the tzinfo-data gem to your Gemfile (or install the OS zoneinfo package).'
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @param zone [::String] an IANA name {.validate!} accepted
|
|
110
|
+
# @return [Object] the cached +TZInfo::Timezone+
|
|
111
|
+
# @raise [ArgumentError] for a name {.validate!} would refuse, when a
|
|
112
|
+
# type is called directly with one
|
|
113
|
+
def self.timezone(zone)
|
|
114
|
+
cached = @timezones && @timezones[zone]
|
|
115
|
+
return cached if cached
|
|
116
|
+
raise ArgumentError, refusal('the field', zone) unless zone.include?('/')
|
|
117
|
+
|
|
118
|
+
load_timezone('the field', zone)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @param zone [::String] a declared +zone:+
|
|
122
|
+
# @return [::Integer, nil] seconds east of UTC, or +nil+ when +zone+ is
|
|
123
|
+
# not a value {.validate!} accepts
|
|
124
|
+
def self.offset_seconds(zone)
|
|
125
|
+
return 0 if zone == 'UTC'
|
|
126
|
+
|
|
127
|
+
match = OFFSET.match(zone)
|
|
128
|
+
return nil if match.nil?
|
|
129
|
+
|
|
130
|
+
hours = Integer(match[2], 10)
|
|
131
|
+
minutes = Integer(match[3], 10)
|
|
132
|
+
return nil if hours > 23 || minutes > 59
|
|
133
|
+
|
|
134
|
+
(match[1] == '-' ? -1 : 1) * ((hours * 3600) + (minutes * 60))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Whether parsed fragments leave the offset to +zone:+.
|
|
138
|
+
#
|
|
139
|
+
# @param parts [::Hash{::Symbol => Object}] fragments from +Date._parse+
|
|
140
|
+
# or +Date._strptime+
|
|
141
|
+
# @return [Boolean] +true+ when the string named no offset at all
|
|
142
|
+
# @raise [ArgumentError] when the string names a zone the stdlib cannot
|
|
143
|
+
# turn into an offset (+"XYZ"+, +"America/Denver"+): +zone:+ never
|
|
144
|
+
# overrides a zone the input stated
|
|
145
|
+
def self.zoneless?(parts)
|
|
146
|
+
# An epoch (%s / %Q) is an instant already; {.time_at} keeps it and
|
|
147
|
+
# only places it at the zone's offset, as it does for any other input.
|
|
148
|
+
return false unless parts[:offset].nil?
|
|
149
|
+
return true if parts[:zone].nil?
|
|
150
|
+
|
|
151
|
+
# The input is not echoed — see Types::Time#coerce.
|
|
152
|
+
raise ArgumentError, 'names a time zone with no fixed offset, which zone: does not override'
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Read a zone-less String as a wall-clock time in +zone+.
|
|
156
|
+
#
|
|
157
|
+
# @param value [::String] a string {.zoneless?} accepted
|
|
158
|
+
# @param format [::String, nil] a resolved strptime format, or +nil+ for
|
|
159
|
+
# the no-format path (which has already required a whole day)
|
|
160
|
+
# @param zone [::String] a declared +zone:+
|
|
161
|
+
# @param parts [::Hash{::Symbol => Object}] the fragments the string
|
|
162
|
+
# parsed to; an epoch (+%s+ / +%Q+) shows up as +:seconds+
|
|
163
|
+
# @return [::Time] at +zone+'s offset (+utc?+ for +"UTC"+)
|
|
164
|
+
# @raise [ArgumentError] when +zone+ is a name and the wall-clock time
|
|
165
|
+
# falls in a gap the zone skips
|
|
166
|
+
def self.time_at(value, format, zone, parts)
|
|
167
|
+
offset = offset_seconds(zone)
|
|
168
|
+
return named_time_at(value, format, timezone(zone), parts) if offset.nil?
|
|
169
|
+
|
|
170
|
+
time = if format
|
|
171
|
+
# Appending the offset lets strptime do everything else as usual.
|
|
172
|
+
# Given an offset, it also fills a date the format omits from today
|
|
173
|
+
# *at that offset*, where plain strptime uses the process zone's
|
|
174
|
+
# today, and places an epoch (%s) at that offset.
|
|
175
|
+
::Time.strptime("#{value} #{offset_text(offset)}", "#{format} %z")
|
|
176
|
+
else
|
|
177
|
+
# DateTime.parse reads the wall clock with no zone involved, so a
|
|
178
|
+
# time the process zone skips for DST (02:30 on a spring-forward day)
|
|
179
|
+
# is kept rather than moved, as Time.parse would.
|
|
180
|
+
wall = ::DateTime.parse(value)
|
|
181
|
+
::Time.new(wall.year, wall.mon, wall.mday, wall.hour, wall.min, wall.sec + wall.sec_fraction, offset)
|
|
182
|
+
end
|
|
183
|
+
zone == 'UTC' ? time.utc : time
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# {.time_at} for an IANA name: the offset depends on the date.
|
|
187
|
+
#
|
|
188
|
+
# @param value [::String] the zone-less string
|
|
189
|
+
# @param format [::String, nil] a resolved strptime format, or +nil+
|
|
190
|
+
# @param timezone [Object] a +TZInfo::Timezone+
|
|
191
|
+
# @param parts [::Hash{::Symbol => Object}] the parsed fragments
|
|
192
|
+
# @return [::Time]
|
|
193
|
+
def self.named_time_at(value, format, timezone, parts)
|
|
194
|
+
# An epoch is an instant already: keep it, at the offset of that instant.
|
|
195
|
+
return in_zone(::Time.strptime(value, format), timezone) if format && parts.key?(:seconds)
|
|
196
|
+
|
|
197
|
+
wall = if format
|
|
198
|
+
# The offset in force NOW only decides which day "today" is when
|
|
199
|
+
# the format omits the date; the wall clock is resolved below.
|
|
200
|
+
now = offset_text(timezone.period_for(::Time.now).observed_utc_offset)
|
|
201
|
+
::Time.strptime("#{value} #{now}", "#{format} %z")
|
|
202
|
+
else
|
|
203
|
+
::DateTime.parse(value)
|
|
204
|
+
end
|
|
205
|
+
sec = wall.sec + (wall.is_a?(::DateTime) ? wall.sec_fraction : wall.subsec)
|
|
206
|
+
local(timezone, [wall.year, wall.mon, wall.mday, wall.hour, wall.min, sec])
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# A wall-clock time in a named zone.
|
|
210
|
+
#
|
|
211
|
+
# @param timezone [Object] a +TZInfo::Timezone+
|
|
212
|
+
# @param civil [::Array<::Numeric>] year, month, day, hour, minute and
|
|
213
|
+
# seconds (with any fraction)
|
|
214
|
+
# @return [::Time] at the offset in force at that wall-clock time
|
|
215
|
+
# @raise [ArgumentError] when the zone skips that wall-clock time
|
|
216
|
+
def self.local(timezone, civil)
|
|
217
|
+
# tzinfo reads the components and ignores the offset of the Time it is
|
|
218
|
+
# given. In an overlap, dst = true and the block pick what
|
|
219
|
+
# ActiveSupport's TimeZone#period_for_local picks.
|
|
220
|
+
period = timezone.period_for_local(::Time.utc(*civil), true, &:last)
|
|
221
|
+
::Time.new(*civil, period.observed_utc_offset)
|
|
222
|
+
rescue ::TZInfo::PeriodNotFound
|
|
223
|
+
# The input is not echoed — see Types::Time#coerce. The zone is the
|
|
224
|
+
# declaration, not the value.
|
|
225
|
+
raise ArgumentError,
|
|
226
|
+
"names a local time that does not exist in #{timezone.identifier} (skipped by a daylight-saving change)"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# @param time [::Time] an instant
|
|
230
|
+
# @param timezone [Object] a +TZInfo::Timezone+
|
|
231
|
+
# @return [::Time] the same instant at the zone's offset at that instant
|
|
232
|
+
def self.in_zone(time, timezone)
|
|
233
|
+
time.getlocal(timezone.period_for(time).observed_utc_offset)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# @param value [::Date] a plain Date
|
|
237
|
+
# @param zone [::String] a declared +zone:+
|
|
238
|
+
# @return [::Time] midnight at +zone+'s offset — for a name, the first
|
|
239
|
+
# moment of that day
|
|
240
|
+
def self.midnight(value, zone)
|
|
241
|
+
offset = offset_seconds(zone)
|
|
242
|
+
return named_midnight(value, timezone(zone)) if offset.nil?
|
|
243
|
+
|
|
244
|
+
time = ::Time.new(value.year, value.mon, value.mday, 0, 0, 0, offset)
|
|
245
|
+
zone == 'UTC' ? time.utc : time
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# @param value [::Date] a plain Date
|
|
249
|
+
# @param timezone [Object] a +TZInfo::Timezone+
|
|
250
|
+
# @return [::Time] the first moment of that day in the zone
|
|
251
|
+
def self.named_midnight(value, timezone)
|
|
252
|
+
local(timezone, [value.year, value.mon, value.mday, 0, 0, 0])
|
|
253
|
+
rescue ArgumentError
|
|
254
|
+
# The zone skips midnight: the day starts where the gap ends, the
|
|
255
|
+
# instant midnight would be at the offset in force just before it.
|
|
256
|
+
before = timezone.period_for_local(::Time.utc(value.year, value.mon, value.mday) - 1, true, &:last)
|
|
257
|
+
in_zone(::Time.utc(value.year, value.mon, value.mday) - before.observed_utc_offset, timezone)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# @param seconds [::Integer] seconds east of UTC
|
|
261
|
+
# @return [::String] +"+HH:MM"+ / +"-HH:MM"+
|
|
262
|
+
def self.offset_text(seconds)
|
|
263
|
+
format('%s%02d:%02d', seconds.negative? ? '-' : '+', seconds.abs / 3600, (seconds.abs % 3600) / 60)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
data/lib/field_struct.rb
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'oj'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require_relative 'field_struct/version'
|
|
6
|
+
require_relative 'field_struct/error'
|
|
7
|
+
require_relative 'field_struct/model_name'
|
|
8
|
+
require_relative 'field_struct/types/base'
|
|
9
|
+
require_relative 'field_struct/types/preset_resolver'
|
|
10
|
+
require_relative 'field_struct/types/temporal_parser'
|
|
11
|
+
require_relative 'field_struct/types/rfc3339_format'
|
|
12
|
+
require_relative 'field_struct/types/time_format_resolver'
|
|
13
|
+
require_relative 'field_struct/types/zone_option'
|
|
14
|
+
require_relative 'field_struct/types/string'
|
|
15
|
+
require_relative 'field_struct/types/immutable_string'
|
|
16
|
+
require_relative 'field_struct/types/integer'
|
|
17
|
+
require_relative 'field_struct/types/float'
|
|
18
|
+
require_relative 'field_struct/types/big_decimal'
|
|
19
|
+
require_relative 'field_struct/types/boolean'
|
|
20
|
+
require_relative 'field_struct/types/date'
|
|
21
|
+
require_relative 'field_struct/types/time'
|
|
22
|
+
require_relative 'field_struct/types/datetime'
|
|
23
|
+
require_relative 'field_struct/types/value'
|
|
24
|
+
require_relative 'field_struct/types/array'
|
|
25
|
+
require_relative 'field_struct/types/nested'
|
|
26
|
+
require_relative 'field_struct/types/symbol'
|
|
27
|
+
require_relative 'field_struct/types/uuid'
|
|
28
|
+
require_relative 'field_struct/types/url'
|
|
29
|
+
require_relative 'field_struct/types/email'
|
|
30
|
+
require_relative 'field_struct/types/union'
|
|
31
|
+
require_relative 'field_struct/types/binary'
|
|
32
|
+
require_relative 'field_struct/registry'
|
|
33
|
+
require_relative 'field_struct/field'
|
|
34
|
+
require_relative 'field_struct/metadata'
|
|
35
|
+
require_relative 'field_struct/errors'
|
|
36
|
+
require_relative 'field_struct/base'
|
|
37
|
+
require_relative 'field_struct/rbs'
|
|
38
|
+
require_relative 'field_struct/scaffold'
|
|
39
|
+
|
|
40
|
+
# Typed PORO foundation: declare fields with enforced types, presence checks,
|
|
41
|
+
# and per-field validation. Subclass {FieldStruct::Base} to get a value object
|
|
42
|
+
# whose attributes coerce, validate on assignment, and introspect cleanly.
|
|
43
|
+
module FieldStruct
|
|
44
|
+
# What {Base#inspect} renders in place of a +sensitive: true+ field's
|
|
45
|
+
# value (the placeholder ActiveSupport's parameter filter uses).
|
|
46
|
+
#
|
|
47
|
+
# @return [String]
|
|
48
|
+
FILTERED = '[FILTERED]'
|
|
49
|
+
|
|
50
|
+
# The library's root directory — the parent of +lib/+. Computed from this
|
|
51
|
+
# file's location, so it resolves to the repo root in development and to the
|
|
52
|
+
# installed gem directory when packaged.
|
|
53
|
+
#
|
|
54
|
+
# FieldStruct.root.join('lib', 'field_struct.rb') # => #<Pathname …>
|
|
55
|
+
#
|
|
56
|
+
# @return [Pathname] absolute path to the gem root
|
|
57
|
+
def self.root
|
|
58
|
+
@root ||= Pathname.new(File.expand_path('..', __dir__))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Build a new {Registry}, parented to +parent+ (defaults to
|
|
62
|
+
# {FieldStruct.types}), and optionally configure it with a block
|
|
63
|
+
# evaluated in the new registry's instance scope.
|
|
64
|
+
#
|
|
65
|
+
# module Acme
|
|
66
|
+
# def self.field_types
|
|
67
|
+
# @field_types ||= FieldStruct.new_registry do
|
|
68
|
+
# register :money, Acme::Types::Money
|
|
69
|
+
# end
|
|
70
|
+
# end
|
|
71
|
+
# end
|
|
72
|
+
#
|
|
73
|
+
# Equivalent to the longhand:
|
|
74
|
+
#
|
|
75
|
+
# FieldStruct::Registry.new(FieldStruct.types).tap do |r|
|
|
76
|
+
# r.register :money, Acme::Types::Money
|
|
77
|
+
# end
|
|
78
|
+
#
|
|
79
|
+
# Pass +parent: nil+ for an unparented registry (no fallback chain).
|
|
80
|
+
#
|
|
81
|
+
# @param parent [Registry, nil] the parent registry; defaults to
|
|
82
|
+
# {FieldStruct.types}. Pass +nil+ explicitly for no parent.
|
|
83
|
+
# @yield evaluated in the new registry's instance scope, so methods
|
|
84
|
+
# like +register+ / +lookup+ / +key?+ work without an explicit receiver
|
|
85
|
+
# @return [Registry]
|
|
86
|
+
def self.new_registry(parent = types, &block)
|
|
87
|
+
registry = Registry.new(parent)
|
|
88
|
+
registry.instance_eval(&block) if block
|
|
89
|
+
registry
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The base type registry, seeded with every v1 scalar type and the
|
|
93
|
+
# +:decimal+ alias for +:big_decimal+. Namespace registries should be
|
|
94
|
+
# built with this as their parent (see {Registry}).
|
|
95
|
+
#
|
|
96
|
+
# @return [Registry]
|
|
97
|
+
def self.types
|
|
98
|
+
@types ||= Registry.new.tap do |r|
|
|
99
|
+
r.register :string, Types::String
|
|
100
|
+
r.register :immutable_string, Types::ImmutableString
|
|
101
|
+
r.register :integer, Types::Integer
|
|
102
|
+
r.register :float, Types::Float
|
|
103
|
+
r.register :big_decimal, Types::BigDecimal
|
|
104
|
+
r.register :decimal, :big_decimal
|
|
105
|
+
r.register :boolean, Types::Boolean
|
|
106
|
+
r.register :date, Types::Date
|
|
107
|
+
r.register :time, Types::Time
|
|
108
|
+
r.register :datetime, Types::DateTime
|
|
109
|
+
r.register :value, Types::Value
|
|
110
|
+
r.register :array, Types::Array
|
|
111
|
+
r.register :symbol, Types::Symbol
|
|
112
|
+
r.register :uuid, Types::UUID
|
|
113
|
+
r.register :url, Types::URL
|
|
114
|
+
r.register :email, Types::Email
|
|
115
|
+
r.register :union, Types::Union
|
|
116
|
+
r.register :binary, Types::Binary
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Opt-in short alias for the +FieldStruct+ module.
|
|
121
|
+
#
|
|
122
|
+
# Defines a top-level constant (default: +FS+) that points back to
|
|
123
|
+
# +FieldStruct+, *and* swaps the prefix used by every +#inspect+
|
|
124
|
+
# method in the library so IRB output reads with the short name too:
|
|
125
|
+
#
|
|
126
|
+
# FieldStruct.use_alias!
|
|
127
|
+
# FS::Base # => FieldStruct::Base
|
|
128
|
+
# User.metadata.inspect # => "#<FS::Metadata fields=[...]>"
|
|
129
|
+
#
|
|
130
|
+
# Off by default — the user has to ask for it. Idempotent. If the
|
|
131
|
+
# chosen constant is already defined to something other than
|
|
132
|
+
# FieldStruct, raises +NameError+ rather than clobbering.
|
|
133
|
+
#
|
|
134
|
+
# @param name [Symbol] the top-level constant to define (default +:FS+)
|
|
135
|
+
# @return [self] for chaining
|
|
136
|
+
# @raise [NameError] when the constant already points at something else
|
|
137
|
+
def self.use_alias!(name = :FS)
|
|
138
|
+
sym = name.to_sym
|
|
139
|
+
if Object.const_defined?(sym, false)
|
|
140
|
+
existing = Object.const_get(sym)
|
|
141
|
+
unless existing.equal?(self)
|
|
142
|
+
raise ::NameError, "::#{sym} is already defined (#{existing.inspect}); not aliasing FieldStruct"
|
|
143
|
+
end
|
|
144
|
+
else
|
|
145
|
+
Object.const_set(sym, self)
|
|
146
|
+
end
|
|
147
|
+
@short_alias = sym.to_s
|
|
148
|
+
self
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The current prefix that library +#inspect+ methods should render.
|
|
152
|
+
# Defaults to +"FieldStruct"+; flipped to the alias string (e.g.
|
|
153
|
+
# +"FS"+) by {.use_alias!}.
|
|
154
|
+
#
|
|
155
|
+
# @return [String]
|
|
156
|
+
def self.inspect_namespace
|
|
157
|
+
@short_alias || 'FieldStruct'
|
|
158
|
+
end
|
|
159
|
+
end
|