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,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module FieldStruct
|
|
6
|
+
module Types
|
|
7
|
+
# Shared String-parsing rules for {Date}, {Time} and {DateTime}.
|
|
8
|
+
#
|
|
9
|
+
# Three rules the stdlib parsers leave to their caller. Each of them is a
|
|
10
|
+
# case where the stdlib answers a question it was not asked, confidently.
|
|
11
|
+
#
|
|
12
|
+
# **Anchoring.** +strptime+ matches a PREFIX and discards the rest.
|
|
13
|
+
# +Date.strptime('2026-07-031', '%Y-%m-%d')+ returns the 3rd and throws the
|
|
14
|
+
# trailing +1+ away; +'2026-07-03T10:30:00+0000-NONSENSE'+ parses clean
|
|
15
|
+
# under the +iso8601+ preset. A declared +format:+ is a contract about the
|
|
16
|
+
# whole string, so anything left over is refused. (Leading whitespace needs
|
|
17
|
+
# no rule — +Date._strptime+ already returns +nil+ for it.)
|
|
18
|
+
#
|
|
19
|
+
# **Civil validity.** +Date.strptime+ and +DateTime.strptime+ refuse the
|
|
20
|
+
# 30th of February. +Time+ does not: +Time.parse+, +Time.strptime+ and
|
|
21
|
+
# +Time.new+ all roll it forward to 2 March and say nothing. All three
|
|
22
|
+
# types refuse it here.
|
|
23
|
+
#
|
|
24
|
+
# **Completeness.** With no declared format the stdlib parsers fill in
|
|
25
|
+
# whatever the string omits, from *today*: +Time.parse('10:30')+ is today
|
|
26
|
+
# at 10:30, +'July'+ is the 1st of July this year, +'12'+ is the 12th of
|
|
27
|
+
# this month. A value whose meaning depends on when it was parsed is not a
|
|
28
|
+
# value, so a string with no declared format must name a whole day.
|
|
29
|
+
#
|
|
30
|
+
# Anchoring is trailing-junk rejection and nothing more. Widths, sign and
|
|
31
|
+
# case stay strptime-lenient for a hand-written format: +%m+ and +%d+
|
|
32
|
+
# accept one or two digits, which is strptime's contract rather than a
|
|
33
|
+
# defect. Strictness for the built-in presets is a separate concern.
|
|
34
|
+
module TemporalParser
|
|
35
|
+
# Validate a String against a declared format.
|
|
36
|
+
#
|
|
37
|
+
# @param value [::String] the raw string
|
|
38
|
+
# @param format [::String] a resolved strftime/strptime format
|
|
39
|
+
# @return [::Hash{::Symbol => Object}] the parsed fragments
|
|
40
|
+
# @raise [ArgumentError] when the string does not match the format, has
|
|
41
|
+
# characters left over, or names a day that does not exist
|
|
42
|
+
def self.strptime_parts(value, format)
|
|
43
|
+
parts = ::Date._strptime(value, format)
|
|
44
|
+
raise ArgumentError, "does not match format #{format.inspect}" if parts.nil?
|
|
45
|
+
|
|
46
|
+
if parts.key?(:leftover)
|
|
47
|
+
raise ArgumentError,
|
|
48
|
+
"does not match format #{format.inspect} — unexpected trailing characters"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
ensure_real_day!(parts)
|
|
52
|
+
parts
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Validate a String with no declared format.
|
|
56
|
+
#
|
|
57
|
+
# @param value [::String] the raw string
|
|
58
|
+
# @return [::Hash{::Symbol => Object}] the parsed fragments
|
|
59
|
+
# @raise [ArgumentError] when the string names only part of a day, or a
|
|
60
|
+
# day that does not exist
|
|
61
|
+
def self.parse_parts(value)
|
|
62
|
+
parts = ::Date._parse(value)
|
|
63
|
+
ensure_whole_day!(parts)
|
|
64
|
+
ensure_real_day!(parts)
|
|
65
|
+
parts
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @param parts [::Hash{::Symbol => Object}] fragments from +Date._parse+
|
|
69
|
+
# @return [void]
|
|
70
|
+
# @raise [ArgumentError] when year, month or day is missing
|
|
71
|
+
def self.ensure_whole_day!(parts)
|
|
72
|
+
return if parts[:year] && parts[:mon] && parts[:mday]
|
|
73
|
+
|
|
74
|
+
# The value is deliberately not echoed: FieldStruct wraps this as
|
|
75
|
+
# "could not be coerced: <message>" and that string reaches API
|
|
76
|
+
# responses and audit rows.
|
|
77
|
+
raise ArgumentError, 'expected a date with a year, a month and a day'
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @param parts [::Hash{::Symbol => Object}] fragments from +Date._parse+
|
|
81
|
+
# or +Date._strptime+
|
|
82
|
+
# @return [void]
|
|
83
|
+
# @raise [ArgumentError] when the fragments name a day that never existed
|
|
84
|
+
def self.ensure_real_day!(parts)
|
|
85
|
+
year, month, day = parts.values_at(:year, :mon, :mday)
|
|
86
|
+
return if year.nil? || month.nil? || day.nil?
|
|
87
|
+
return if ::Date.valid_civil?(year, month, day)
|
|
88
|
+
|
|
89
|
+
raise ArgumentError, 'invalid date'
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module FieldStruct
|
|
7
|
+
module Types
|
|
8
|
+
# Time type. Returns a +Time+ as-is, builds one from a +Date+ or
|
|
9
|
+
# +DateTime+'s components, reads a String via +Time.parse+ (or +strptime+
|
|
10
|
+
# when the field declares a +format:+), and refuses everything else.
|
|
11
|
+
#
|
|
12
|
+
# Per-field +format:+ option (String or Symbol-preset) controls both
|
|
13
|
+
# parsing (strptime when input is a String) and serialization
|
|
14
|
+
# (strftime via +as_json+).
|
|
15
|
+
class Time < Base
|
|
16
|
+
# Native options: a +format:+ strftime/strptime String (or Symbol
|
|
17
|
+
# preset; see {.presets}), an +in:+ Array or Range of allowed values,
|
|
18
|
+
# and a +zone:+ String (+"UTC"+, +"±HH:MM"+ or an IANA name; see {ZoneOption}).
|
|
19
|
+
#
|
|
20
|
+
# @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
|
|
21
|
+
def self.own_option_schema
|
|
22
|
+
{
|
|
23
|
+
format: option(type: [::String, ::Symbol], presets: presets.keys),
|
|
24
|
+
in: option(type: [::Array, ::Range]),
|
|
25
|
+
zone: option(type: [::String])
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [::String, nil]
|
|
30
|
+
def self.default_format
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Named presets for the +format:+ option.
|
|
35
|
+
#
|
|
36
|
+
# +:iso8601+ is an {Rfc3339Format} rather than a strftime String: +%z+
|
|
37
|
+
# renders +\+0000+, which RFC 3339 rejects, and no strftime string can
|
|
38
|
+
# express optional fractional seconds. The interchange and display
|
|
39
|
+
# presets stay strftime-based.
|
|
40
|
+
#
|
|
41
|
+
# @return [Hash{::Symbol=>Object}]
|
|
42
|
+
def self.presets
|
|
43
|
+
{
|
|
44
|
+
iso8601: Rfc3339Format.timestamp,
|
|
45
|
+
rfc2822: '%a, %d %b %Y %H:%M:%S %z',
|
|
46
|
+
db: '%Y-%m-%d %H:%M:%S'
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Validate a Symbol +format:+ preset (see {.presets}) and a +zone:+
|
|
51
|
+
# (see {ZoneOption}) at field-declaration time.
|
|
52
|
+
#
|
|
53
|
+
# @param options [::Hash{::Symbol => Object, nil}] the per-field options
|
|
54
|
+
# @return [::Hash{::Symbol => Object, nil}] the options, unchanged
|
|
55
|
+
# @raise [ArgumentError] on an unknown preset or an unreadable +zone:+
|
|
56
|
+
def self.resolve_options(options)
|
|
57
|
+
ZoneOption.validate!('Time', TimeFormatResolver.call(options, presets))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Resolve a declared +format:+ (a Symbol preset name or a strftime
|
|
61
|
+
# String) into the String that {#coerce} and JSON rendering actually
|
|
62
|
+
# use. Declarations are stored exactly as written, so this is where a
|
|
63
|
+
# preset name becomes usable. See {TimeFormatResolver}.
|
|
64
|
+
#
|
|
65
|
+
# @param format [::String, ::Symbol, nil] the format as declared
|
|
66
|
+
# @return [::String, nil] +nil+ when the field declares no format
|
|
67
|
+
# @raise [ArgumentError] when a Symbol names no preset in {.presets}
|
|
68
|
+
def self.resolve_format(format)
|
|
69
|
+
# Skip building the presets Hash for the common, undeclared case.
|
|
70
|
+
return format if format.nil? || format.is_a?(::String)
|
|
71
|
+
|
|
72
|
+
TimeFormatResolver.resolve(format, presets)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Dispatch is on explicit stdlib classes, never on a +respond_to?+ probe
|
|
76
|
+
# against the value. ActiveSupport defines +String#to_time+, and it
|
|
77
|
+
# returns +nil+ for a string with no time in it rather than raising — so
|
|
78
|
+
# under Rails "not-a-time" was coerced to nil and reported VALID. The
|
|
79
|
+
# probe is gone; +Time.parse+ and +Time.strptime+ behave the same with or
|
|
80
|
+
# without ActiveSupport loaded.
|
|
81
|
+
#
|
|
82
|
+
# Cross-class conversion builds a Time from components rather than
|
|
83
|
+
# calling +to_time+, which ActiveSupport redefines: under AS 7.2
|
|
84
|
+
# +DateTime#to_time+ converts to the system-local zone and emits a
|
|
85
|
+
# deprecation warning, while 8.x preserves the offset. Constructing from
|
|
86
|
+
# parts gives the same instant and the same offset in all three
|
|
87
|
+
# environments, which is what makes the behaviour host-independent rather
|
|
88
|
+
# than merely documented.
|
|
89
|
+
#
|
|
90
|
+
# @param value [::String, ::Time, ::Date, ::DateTime, nil] +nil+ → +nil+;
|
|
91
|
+
# a String is read through +strptime+ when a +format:+ is set and
|
|
92
|
+
# +Time.parse+ otherwise; a +Time+ passes through; a +DateTime+, +Date+
|
|
93
|
+
# or Time-like value (ActiveSupport's TimeWithZone) is converted.
|
|
94
|
+
# Anything else is refused.
|
|
95
|
+
# @param format [::String, nil] strptime/strftime format; +nil+ uses
|
|
96
|
+
# {.default_format} (which itself defaults to +nil+ = ISO-8601 path)
|
|
97
|
+
# @param zone [::String, nil] +"UTC"+, +"±HH:MM"+ or an IANA name: the offset for a
|
|
98
|
+
# String that names none, and for a +Date+'s midnight. Input carrying
|
|
99
|
+
# its own offset keeps it. +nil+ means the process zone. See {ZoneOption}.
|
|
100
|
+
# @return [::Time, nil]
|
|
101
|
+
# @raise [ArgumentError] when a String cannot be parsed, or the value is
|
|
102
|
+
# not a String or a temporal object
|
|
103
|
+
def coerce(value, format: self.class.default_format, zone: nil, **)
|
|
104
|
+
return nil if value.nil?
|
|
105
|
+
|
|
106
|
+
fmt = self.class.resolve_format(format)
|
|
107
|
+
|
|
108
|
+
case value
|
|
109
|
+
when ::String then parse_string(value, fmt, zone)
|
|
110
|
+
# ::DateTime before ::Date — DateTime is a subclass of Date, and only
|
|
111
|
+
# this arm carries the time of day.
|
|
112
|
+
when ::DateTime then time_from_datetime(value)
|
|
113
|
+
when ::Date then zone ? ZoneOption.midnight(value, zone) : ::Time.new(value.year, value.mon, value.mday)
|
|
114
|
+
# A TimeWithZone matches here (AS overrides Time.===) but is NOT an
|
|
115
|
+
# instance of ::Time, so it must be converted rather than passed
|
|
116
|
+
# through — otherwise a :time field would store a TWZ.
|
|
117
|
+
when ::Time then value.instance_of?(::Time) ? value : ::Time.at(value.to_r).getlocal(value.utc_offset)
|
|
118
|
+
else
|
|
119
|
+
# The class, never the value — see Types::Date#coerce.
|
|
120
|
+
raise ArgumentError, "expected a String, Date, DateTime or Time, got #{value.class}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [Class] the top-level +::Time+ class
|
|
125
|
+
def ruby_type
|
|
126
|
+
::Time
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# @param value [::String] the raw string
|
|
132
|
+
# @param format [::String, nil] strptime format, when the field declares one
|
|
133
|
+
# @param zone [::String, nil] the field's +zone:+, if declared
|
|
134
|
+
# @return [::Time]
|
|
135
|
+
# @raise [ArgumentError] when the string cannot be read
|
|
136
|
+
def parse_string(value, format, zone)
|
|
137
|
+
# Validated first, then handed to the stdlib parser: the checks decide
|
|
138
|
+
# what is ACCEPTABLE, the stdlib still decides what the value IS, so
|
|
139
|
+
# the two can't drift apart. See {TemporalParser} for the three rules.
|
|
140
|
+
if format.is_a?(Rfc3339Format)
|
|
141
|
+
parts = format.parse(value)
|
|
142
|
+
return ::Time.new(parts[:year], parts[:mon], parts[:mday], parts[:hour], parts[:min],
|
|
143
|
+
parts[:sec] + parts[:sec_fraction], parts[:offset])
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
if format
|
|
147
|
+
parts = TemporalParser.strptime_parts(value, format)
|
|
148
|
+
return ZoneOption.time_at(value, format, zone, parts) if zone && ZoneOption.zoneless?(parts)
|
|
149
|
+
|
|
150
|
+
return ::Time.strptime(value, format)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
parts = TemporalParser.parse_parts(value)
|
|
154
|
+
return ZoneOption.time_at(value, nil, zone, parts) if zone && ZoneOption.zoneless?(parts)
|
|
155
|
+
|
|
156
|
+
::Time.parse(value)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @param value [::DateTime] the value to convert
|
|
160
|
+
# @return [::Time] the same instant, at the same offset, sub-second
|
|
161
|
+
# precision preserved
|
|
162
|
+
def time_from_datetime(value)
|
|
163
|
+
::Time.new(value.year, value.mon, value.mday, value.hour, value.min,
|
|
164
|
+
value.sec + value.sec_fraction, value.zone)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FieldStruct
|
|
4
|
+
module Types
|
|
5
|
+
# Shared +format:+ option resolution for {Date}/{Time}/{DateTime}.
|
|
6
|
+
# Each of those types calls +TimeFormatResolver.call(options, presets)+
|
|
7
|
+
# from its +resolve_options+ class method.
|
|
8
|
+
#
|
|
9
|
+
# A declared +format:+ is VALIDATED at declaration time and kept exactly as
|
|
10
|
+
# the user wrote it; it is resolved to a strftime string later, at coerce
|
|
11
|
+
# and render time, by {.resolve}.
|
|
12
|
+
#
|
|
13
|
+
# Keeping the declaration is the point. The Symbol used to be overwritten
|
|
14
|
+
# with the strftime String it expands to, which destroyed the only fact a
|
|
15
|
+
# documentation generator actually wants — that the field is +:iso8601+,
|
|
16
|
+
# not some anonymous String of percent-escapes. Recovering the name by
|
|
17
|
+
# reverse-mapping the String worked only while no two presets shared a
|
|
18
|
+
# value, which is luck rather than a contract.
|
|
19
|
+
module TimeFormatResolver
|
|
20
|
+
# Validate a declared +format:+ and return the options unchanged.
|
|
21
|
+
#
|
|
22
|
+
# @param options [::Hash{::Symbol => Object, nil}] the per-field options as written
|
|
23
|
+
# @param presets [::Hash{::Symbol => ::String}] the type's preset table
|
|
24
|
+
# @return [::Hash{::Symbol => Object, nil}] +options+, unmodified
|
|
25
|
+
# @raise [ArgumentError] when +format:+ is the wrong shape, or names a
|
|
26
|
+
# preset the type does not have — at declaration time, not at first use
|
|
27
|
+
def self.call(options, presets)
|
|
28
|
+
return options unless options.key?(:format)
|
|
29
|
+
|
|
30
|
+
fmt = options[:format]
|
|
31
|
+
return options if fmt.is_a?(::String) || fmt.is_a?(::Regexp) || fmt.nil?
|
|
32
|
+
raise ArgumentError, "format: must be a String, Regexp, or Symbol — got #{fmt.class}" unless fmt.is_a?(::Symbol)
|
|
33
|
+
|
|
34
|
+
resolve(fmt, presets) # raises on an unknown name; the result is discarded
|
|
35
|
+
options
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Resolve a declared +format:+ to a strftime/strptime String.
|
|
39
|
+
#
|
|
40
|
+
# @param format [::String, ::Symbol, nil] as declared
|
|
41
|
+
# @param presets [::Hash{::Symbol => ::String}] the type's preset table
|
|
42
|
+
# @return [::String, nil] +nil+ when no format is declared
|
|
43
|
+
# @raise [ArgumentError] when the Symbol names no preset in the table
|
|
44
|
+
def self.resolve(format, presets)
|
|
45
|
+
return format if format.nil? || format.is_a?(::String)
|
|
46
|
+
|
|
47
|
+
presets.fetch(format) do
|
|
48
|
+
raise ArgumentError,
|
|
49
|
+
"unknown time format preset #{format.inspect}. Available: #{presets.keys.inspect}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module FieldStruct
|
|
6
|
+
module Types
|
|
7
|
+
# Union type — a value that may be any of several types. Each
|
|
8
|
+
# member is tried in declared order; the first one whose +coerce+
|
|
9
|
+
# doesn't raise wins. Member-coercion errors are caught broadly
|
|
10
|
+
# (ArgumentError, TypeError, FieldStruct::Error) so a member's
|
|
11
|
+
# rejection lets the next member have a try; unrelated bugs (e.g.
|
|
12
|
+
# +NoMethodError+) still propagate.
|
|
13
|
+
#
|
|
14
|
+
# optional :payload, :union, of: [Payload, :boolean]
|
|
15
|
+
#
|
|
16
|
+
# If every member rejects the value, the union raises a +TypeError+
|
|
17
|
+
# which the parent's +coercion_policy+ handles like any
|
|
18
|
+
# shape-level coercion failure.
|
|
19
|
+
#
|
|
20
|
+
# Order matters: +of: [String, Integer]+ coerces "42" to "42"
|
|
21
|
+
# (String wins first); +of: [Integer, String]+ coerces "42" to 42.
|
|
22
|
+
class Union < Base
|
|
23
|
+
# Member-coercion errors caught so the next member can be tried; other
|
|
24
|
+
# exceptions (e.g. +NoMethodError+) propagate as real bugs.
|
|
25
|
+
#
|
|
26
|
+
# @return [::Array<Class<::StandardError>>]
|
|
27
|
+
RESCUABLE = [ArgumentError, TypeError, FieldStruct::Error].freeze
|
|
28
|
+
|
|
29
|
+
# Native options: a required +of:+ Array of member types (the DSL
|
|
30
|
+
# additionally requires at least two members).
|
|
31
|
+
#
|
|
32
|
+
# @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
|
|
33
|
+
def self.own_option_schema
|
|
34
|
+
{of: option(type: [::Array], required: true)}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Array<Types::Base>] the configured member type instances
|
|
38
|
+
attr_reader :member_types
|
|
39
|
+
|
|
40
|
+
# @param member_types [Array<Types::Base>] member instances, in priority order
|
|
41
|
+
def initialize(member_types)
|
|
42
|
+
super()
|
|
43
|
+
@member_types = member_types
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param value [Object, nil] anything — each member type's coerce
|
|
47
|
+
# is tried in declared order; the first that doesn't raise wins
|
|
48
|
+
# @return [Object, nil] whatever the first successful member returns
|
|
49
|
+
# @raise [TypeError] when no member can coerce, or the value is a
|
|
50
|
+
# non-finite number (NaN or ±Infinity), which no member may accept
|
|
51
|
+
def coerce(value, **)
|
|
52
|
+
return nil if value.nil?
|
|
53
|
+
# Refused before any member sees it: a numeric member rejects a
|
|
54
|
+
# non-finite value with an ArgumentError, which is RESCUABLE, so a
|
|
55
|
+
# later :string member would otherwise store "NaN" as valid.
|
|
56
|
+
if value.is_a?(::Numeric) && !value.finite?
|
|
57
|
+
# The class, never the value — see Types::Time#coerce.
|
|
58
|
+
raise ::TypeError, "Union fields expect a finite number, got a non-finite #{value.class}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
@member_types.each do |type|
|
|
62
|
+
return type.coerce(value)
|
|
63
|
+
rescue *RESCUABLE
|
|
64
|
+
next
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The class, never the value — it may be payments data.
|
|
68
|
+
members = @member_types.map { |m| m.class.name.to_s.split('::').last }.join(' | ')
|
|
69
|
+
raise ::TypeError, "no union member (#{members}) could coerce #{value.class} input"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param value [Object]
|
|
73
|
+
# @return [Boolean] true only for nil — union doesn't try to
|
|
74
|
+
# collectively reason about "missing" across its members
|
|
75
|
+
def missing?(value)
|
|
76
|
+
value.nil?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Flat list of member +ruby_type+ values. Members whose
|
|
80
|
+
# ruby_type is already an Array (like Boolean's
|
|
81
|
+
# +[TrueClass, FalseClass]+) get spliced in; duplicates are
|
|
82
|
+
# removed.
|
|
83
|
+
#
|
|
84
|
+
# @return [Array<Class>]
|
|
85
|
+
def ruby_type
|
|
86
|
+
@member_types.flat_map { |type| Array(type.ruby_type) }.uniq
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [::String]
|
|
90
|
+
def inspect
|
|
91
|
+
members = @member_types.map { |m| m.class.name.to_s.split('::').last }.join(' | ')
|
|
92
|
+
"#<#{FieldStruct.inspect_namespace}::Types::Union of=#{members}>"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'string'
|
|
4
|
+
|
|
5
|
+
module FieldStruct
|
|
6
|
+
module Types
|
|
7
|
+
# URL type — a {Types::String} that pre-fills the field's +format:+
|
|
8
|
+
# option with a practical http(s) URL pattern. Same shape as
|
|
9
|
+
# {Types::UUID}: bad-format input is a Phase 1 validation error,
|
|
10
|
+
# not a coercion failure.
|
|
11
|
+
#
|
|
12
|
+
# The default pattern matches +http://+ and +https://+ URLs with a
|
|
13
|
+
# non-empty host. It is intentionally pragmatic, not RFC 3986
|
|
14
|
+
# exhaustive — users can override via +format: /.../+ if they need
|
|
15
|
+
# different schemes or stricter host rules.
|
|
16
|
+
class URL < FieldStruct::Types::String
|
|
17
|
+
# Inherits +format:+ / +enum:+ from {Types::String}, advertising the
|
|
18
|
+
# +:format+ presets this subtype resolves (see {.presets}).
|
|
19
|
+
#
|
|
20
|
+
# @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
|
|
21
|
+
def self.own_option_schema
|
|
22
|
+
{format: option(type: [::Regexp, ::Symbol], presets: presets.keys)}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Regexp] practical http(s) pattern; override per-field via +format:+
|
|
26
|
+
def self.default_format
|
|
27
|
+
%r{\Ahttps?://[^\s/$.?#][^\s]*\z}i
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Hash{::Symbol=>Regexp}] named presets for +format:+
|
|
31
|
+
def self.presets
|
|
32
|
+
{
|
|
33
|
+
http: default_format,
|
|
34
|
+
https_only: %r{\Ahttps://[^\s/$.?#][^\s]*\z}i,
|
|
35
|
+
any_scheme: %r{\A[a-z][a-z0-9+\-.]*://[^\s/$.?#][^\s]*\z}i
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Resolve a Symbol +format:+ preset (see {.presets}) to its Regexp at
|
|
40
|
+
# field-declaration time; a user-supplied Regexp passes through.
|
|
41
|
+
def self.resolve_options(options)
|
|
42
|
+
PresetResolver.call(options, :format, presets, label: ':url format')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'string'
|
|
4
|
+
|
|
5
|
+
module FieldStruct
|
|
6
|
+
module Types
|
|
7
|
+
# UUID type — a {Types::String} that pre-fills the field's +format:+
|
|
8
|
+
# option with a canonical RFC-4122 pattern. Bad-format input becomes
|
|
9
|
+
# a Phase 1-style validation error (+"is invalid"+); coercion itself
|
|
10
|
+
# still just hands back the string.
|
|
11
|
+
#
|
|
12
|
+
# Users can override the pattern by passing +format: /.../+ on the
|
|
13
|
+
# field declaration.
|
|
14
|
+
class UUID < FieldStruct::Types::String
|
|
15
|
+
# Inherits +format:+ / +enum:+ from {Types::String}, advertising the
|
|
16
|
+
# +:format+ presets this subtype resolves (see {.presets}).
|
|
17
|
+
#
|
|
18
|
+
# @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
|
|
19
|
+
def self.own_option_schema
|
|
20
|
+
{format: option(type: [::Regexp, ::Symbol], presets: presets.keys)}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Hex-digit-only canonical RFC-4122 form. Permits any version.
|
|
24
|
+
#
|
|
25
|
+
# @return [Regexp] applied at the field level when no +format:+ is given
|
|
26
|
+
def self.default_format
|
|
27
|
+
/\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/i
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Hash{::Symbol=>Regexp}] named presets for +format:+
|
|
31
|
+
def self.presets
|
|
32
|
+
{
|
|
33
|
+
any_version: default_format,
|
|
34
|
+
v4: /\A\h{8}-\h{4}-4\h{3}-[89ab]\h{3}-\h{12}\z/i,
|
|
35
|
+
v7: /\A\h{8}-\h{4}-7\h{3}-[89ab]\h{3}-\h{12}\z/i
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Resolve a Symbol +format:+ preset (see {.presets}) to its Regexp at
|
|
40
|
+
# field-declaration time; a user-supplied Regexp passes through.
|
|
41
|
+
def self.resolve_options(options)
|
|
42
|
+
PresetResolver.call(options, :format, presets, label: ':uuid format')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module FieldStruct
|
|
6
|
+
module Types
|
|
7
|
+
# Value type. A no-coercion passthrough — whatever you give it comes
|
|
8
|
+
# back unchanged. Useful as an escape hatch when you want a field
|
|
9
|
+
# declaration to participate in metadata without committing to a
|
|
10
|
+
# specific shape.
|
|
11
|
+
class Value < Base
|
|
12
|
+
# @param value [Object] anything — passthrough, no coercion
|
|
13
|
+
# @return [Object] the value, unchanged
|
|
14
|
+
def coerce(value, **)
|
|
15
|
+
value
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [Class] +Object+ — the broadest sensible class
|
|
19
|
+
def ruby_type
|
|
20
|
+
::Object
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|