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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +58 -0
  3. data/CHANGELOG.md +653 -0
  4. data/CODE_OF_CONDUCT.md +132 -0
  5. data/README.md +645 -0
  6. data/UPGRADING.md +743 -0
  7. data/USAGE.md +820 -0
  8. data/docs/getting_started.md +337 -0
  9. data/lib/field_struct/base.rb +1316 -0
  10. data/lib/field_struct/error.rb +48 -0
  11. data/lib/field_struct/errors.rb +103 -0
  12. data/lib/field_struct/field.rb +179 -0
  13. data/lib/field_struct/metadata.rb +331 -0
  14. data/lib/field_struct/model_name.rb +48 -0
  15. data/lib/field_struct/rbs.rb +299 -0
  16. data/lib/field_struct/registry.rb +85 -0
  17. data/lib/field_struct/scaffold.rb +331 -0
  18. data/lib/field_struct/types/array.rb +156 -0
  19. data/lib/field_struct/types/base.rb +162 -0
  20. data/lib/field_struct/types/big_decimal.rb +78 -0
  21. data/lib/field_struct/types/binary.rb +38 -0
  22. data/lib/field_struct/types/boolean.rb +126 -0
  23. data/lib/field_struct/types/date.rb +158 -0
  24. data/lib/field_struct/types/datetime.rb +163 -0
  25. data/lib/field_struct/types/email.rb +49 -0
  26. data/lib/field_struct/types/float.rb +80 -0
  27. data/lib/field_struct/types/immutable_string.rb +18 -0
  28. data/lib/field_struct/types/integer.rb +133 -0
  29. data/lib/field_struct/types/nested.rb +82 -0
  30. data/lib/field_struct/types/preset_resolver.rb +37 -0
  31. data/lib/field_struct/types/rfc3339_format.rb +197 -0
  32. data/lib/field_struct/types/string.rb +73 -0
  33. data/lib/field_struct/types/symbol.rb +38 -0
  34. data/lib/field_struct/types/temporal_parser.rb +93 -0
  35. data/lib/field_struct/types/time.rb +168 -0
  36. data/lib/field_struct/types/time_format_resolver.rb +54 -0
  37. data/lib/field_struct/types/union.rb +96 -0
  38. data/lib/field_struct/types/url.rb +46 -0
  39. data/lib/field_struct/types/uuid.rb +46 -0
  40. data/lib/field_struct/types/value.rb +24 -0
  41. data/lib/field_struct/types/zone_option.rb +267 -0
  42. data/lib/field_struct/version.rb +7 -0
  43. data/lib/field_struct.rb +159 -0
  44. data/sig/field_struct.rbs +2845 -0
  45. data/skills/field-struct/SKILL.md +183 -0
  46. metadata +139 -0
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module FieldStruct
6
+ module Types
7
+ # Float type. Coerces via +Kernel#Float+, which parses numeric strings
8
+ # strictly and rejects anything non-numeric. Strings are base 10, as for
9
+ # {Types::Integer}: a hex string like "0x1A", which +Kernel#Float+ would
10
+ # read as 26.0, is refused.
11
+ class Float < Base
12
+ # A string +Kernel#Float+ would read as hex: optional whitespace and
13
+ # sign, then +0x+. (Binary and octal prefixes it already refuses.)
14
+ # @return [::Regexp]
15
+ HEX_PREFIX = /\A\s*[+-]?0x/i
16
+ # Native options: a +round:+ Integer precision and an +in:+ Array or
17
+ # Range of allowed values.
18
+ #
19
+ # @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
20
+ def self.own_option_schema
21
+ {
22
+ round: option(type: [::Integer]),
23
+ in: option(type: [::Array, ::Range])
24
+ }
25
+ end
26
+
27
+ # @return [::Integer, nil] field-level rounding precision; +nil+ disables.
28
+ def self.default_round
29
+ nil
30
+ end
31
+
32
+ # @param value [Numeric, ::String, nil] +nil+ → +nil+; everything
33
+ # else flows through +Kernel#Float+ — strict on string parsing,
34
+ # rejects non-numeric strings, Symbols, Booleans, Arrays, and
35
+ # hex strings ("0x1A").
36
+ # @param round [::Integer, nil] decimal places, rounding half up (ties
37
+ # away from zero); +nil+ leaves the value unrounded. The result is a
38
+ # Float even at +round: 0+.
39
+ # @return [::Float, nil] +nil+ for +nil+ input, otherwise the parsed float
40
+ # @raise [ArgumentError, TypeError] when the value cannot be coerced
41
+ # (a String that is not a number, a Complex), or coerces to NaN or
42
+ # ±Infinity (including a String like "1e400")
43
+ def coerce(value, round: self.class.default_round, **)
44
+ return nil if value.nil?
45
+
46
+ result = value.is_a?(::Float) ? value : parse(value)
47
+ # The class, never the value — see Types::Time#coerce.
48
+ raise ArgumentError, "Float fields expect a finite number, got a non-finite #{value.class}" unless result.finite?
49
+
50
+ # Float#round returns an Integer at round: 0; .to_f keeps the
51
+ # field's type. half: :up pins ties away from zero.
52
+ round ? result.round(round, half: :up).to_f : result
53
+ end
54
+
55
+ # @return [Class] the top-level +::Float+ class
56
+ def ruby_type
57
+ ::Float
58
+ end
59
+
60
+ private
61
+
62
+ # @param value [Object] a non-Float value
63
+ # @return [::Float]
64
+ # @raise [ArgumentError] when it is a hex string or +Kernel#Float+
65
+ # refuses it; the message names the class, never the value
66
+ # (Kernel's own message echoes it)
67
+ def parse(value)
68
+ if value.is_a?(::String) && HEX_PREFIX.match?(value)
69
+ raise ArgumentError, 'Float fields expect a base-10 number, got a String that is not one'
70
+ end
71
+
72
+ begin
73
+ ::Kernel.Float(value)
74
+ rescue ::ArgumentError, ::RangeError
75
+ raise ArgumentError, "Float fields could not read a #{value.class} as a number", cause: nil
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'string'
4
+
5
+ module FieldStruct
6
+ module Types
7
+ # Like {Types::String}, but the coerced value is frozen. Useful when
8
+ # downstream code must not mutate a stored string.
9
+ class ImmutableString < FieldStruct::Types::String
10
+ # @param value [#to_s, nil] anything stringable; +nil+ stays +nil+
11
+ # @return [::String, nil] frozen string, or +nil+ if input is +nil+
12
+ def coerce(value, **)
13
+ result = super
14
+ result.nil? ? nil : result.dup.freeze
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module FieldStruct
6
+ module Types
7
+ # Integer type. Strings parse in base 10 unless the field declares a
8
+ # +base:+, so "010" is ten (not octal eight) and "0x1A" is refused rather
9
+ # than read as hex. Float-shaped strings like "3.14" raise rather than
10
+ # silently truncate. Float numerics, by contrast, are truncated — that
11
+ # matches +Kernel#Integer+'s own behavior on numeric input — unless the
12
+ # field declares +strict: true+, which refuses any non-integral value.
13
+ class Integer < Base
14
+ # The radixes +Kernel#Integer+ accepts for a +base:+ option.
15
+ # @return [::Range<::Integer>]
16
+ BASES = (2..36)
17
+
18
+ # Native options: an +in:+ Array or Range of allowed values, a
19
+ # +base:+ Integer radix (2..36) for string input, and a +strict:+
20
+ # Boolean.
21
+ #
22
+ # @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
23
+ def self.own_option_schema
24
+ {
25
+ in: option(type: [::Array, ::Range]),
26
+ base: option(type: [::Integer]),
27
+ strict: option(type: [::TrueClass, ::FalseClass])
28
+ }
29
+ end
30
+
31
+ # Refuse a +base:+ outside 2..36 at field-declaration time. A
32
+ # non-Integer +base:+ is left for the option-shape check to report.
33
+ #
34
+ # @param options [::Hash{::Symbol => Object, nil}] the per-field options hash
35
+ # @return [::Hash{::Symbol => Object, nil}] the options, unchanged
36
+ # @raise [ArgumentError] when +base:+ is an Integer outside 2..36
37
+ def self.resolve_options(options)
38
+ base = options[:base]
39
+ if base.is_a?(::Integer) && !BASES.cover?(base)
40
+ raise ArgumentError, "base: on Integer expects an Integer from 2 to 36, got #{base}"
41
+ end
42
+
43
+ options
44
+ end
45
+
46
+ # @param value [::Integer, Numeric, ::String, nil] +nil+ → +nil+;
47
+ # +Integer+ passes through; a String parses in +base+ (surrounding
48
+ # whitespace and digit underscores allowed; float-shaped or
49
+ # out-of-base strings raise ArgumentError); other numerics flow
50
+ # through +Kernel#Integer+ — Floats are truncated;
51
+ # Symbols/Booleans/Arrays raise TypeError.
52
+ # @param base [::Integer, nil] radix (2..36) for String input; +nil+
53
+ # means 10
54
+ # @param strict [Boolean, nil] when true, refuse a value that is not a
55
+ # whole number (+12.7+, a +Time+) instead of truncating it; an
56
+ # integral one (+12.0+) still becomes its Integer
57
+ # @return [::Integer, nil] +nil+ for +nil+ input, otherwise the parsed integer
58
+ # @raise [ArgumentError, TypeError] when the value cannot be coerced,
59
+ # including a NaN or ±Infinity Numeric (Float, BigDecimal) and a
60
+ # Complex with an imaginary part
61
+ def coerce(value, base: 10, strict: false, **)
62
+ return nil if value.nil?
63
+ return value if value.is_a?(::Integer)
64
+ return parse_string(value, base || 10) if value.is_a?(::String)
65
+ if value.is_a?(::Numeric) && !value.finite?
66
+ # The class, never the value — see Types::Time#coerce.
67
+ raise ArgumentError, "Integer fields expect a finite number, got a non-finite #{value.class}"
68
+ end
69
+
70
+ refuse_huge_decimal!(value)
71
+
72
+ whole = begin
73
+ ::Kernel.Integer(value)
74
+ rescue ::RangeError
75
+ # A Complex with an imaginary part; Kernel's message echoes the value.
76
+ raise ArgumentError, "Integer fields could not read a #{value.class} as a number", cause: nil
77
+ end
78
+ refuse_inexact!(value, whole) if strict
79
+ whole
80
+ end
81
+
82
+ # The most integer digits a decimal may expand to. +BigDecimal('1e10000000')+
83
+ # is 11 bytes, but its Integer takes seconds to build, so untrusted JSON
84
+ # could burn CPU. The same limit as Python's int/str conversion
85
+ # (CVE-2020-10735).
86
+ #
87
+ # @return [::Integer]
88
+ MAX_DECIMAL_DIGITS = 4300
89
+
90
+ # @return [Class] the top-level +::Integer+ class
91
+ def ruby_type
92
+ ::Integer
93
+ end
94
+
95
+ private
96
+
97
+ # @param value [Object]
98
+ # @return [void]
99
+ # @raise [ArgumentError] when +value+ is a decimal whose integer part has
100
+ # more than {MAX_DECIMAL_DIGITS} digits
101
+ def refuse_huge_decimal!(value)
102
+ return unless defined?(::BigDecimal) && value.is_a?(::BigDecimal)
103
+ return if value.exponent <= MAX_DECIMAL_DIGITS
104
+
105
+ raise ArgumentError, "Integer fields read a decimal of at most #{MAX_DECIMAL_DIGITS} digits, " \
106
+ "got a larger #{value.class}"
107
+ end
108
+
109
+ # @param value [Object] the input, already read by +Kernel#Integer+
110
+ # @param whole [::Integer] what +Kernel#Integer+ made of it
111
+ # @return [void]
112
+ # @raise [ArgumentError] unless +whole+ equals +value+ — i.e. the read
113
+ # truncated a fraction, or went through +to_i+ on a non-number
114
+ def refuse_inexact!(value, whole)
115
+ return if whole == value
116
+
117
+ # The class, never the value — see Types::Time#coerce.
118
+ raise ArgumentError, "Integer fields with strict: true expect a whole number, got a #{value.class} that is not one"
119
+ end
120
+
121
+ # @param value [::String]
122
+ # @param base [::Integer]
123
+ # @return [::Integer]
124
+ # @raise [ArgumentError] when the string is not an integer in +base+
125
+ def parse_string(value, base)
126
+ ::Kernel.Integer(value, base)
127
+ rescue ::ArgumentError
128
+ # The class, never the value — see Types::Time#coerce.
129
+ raise ArgumentError, "Integer fields expect a base-#{base} integer, got a String that is not one", cause: nil
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module FieldStruct
6
+ module Types
7
+ # Type wrapper for nested FieldStruct fields.
8
+ #
9
+ # class Address < FieldStruct::Base
10
+ # required :street, :string
11
+ # required :city, :string
12
+ # end
13
+ #
14
+ # class Person < FieldStruct::Base
15
+ # required :address, Address
16
+ # end
17
+ #
18
+ # Built and held per-Field by the DSL when it sees a class argument
19
+ # that descends from {FieldStruct::Base}. Each instance is
20
+ # parameterized by its target +struct_class+; instances are not
21
+ # interchangeable across different nested classes.
22
+ class Nested < Base
23
+ # @return [Class] the FieldStruct::Base subclass this type wraps
24
+ attr_reader :struct_class
25
+
26
+ # @param struct_class [Class] a FieldStruct::Base subclass
27
+ def initialize(struct_class)
28
+ super()
29
+ @struct_class = struct_class
30
+ end
31
+
32
+ # Coerce input into a {#struct_class} instance.
33
+ #
34
+ # - +nil+ → +nil+
35
+ # - already a {#struct_class} (or subclass) instance → passthrough
36
+ # - +Hash+ → +struct_class.new(hash)+
37
+ # - anything else → +TypeError+ (which the parent's coercion_policy
38
+ # handles like any shape-level coercion failure)
39
+ #
40
+ # Errors raised inside +struct_class.new+ (e.g.
41
+ # {UnknownAttributeError}, {CoercionError}) propagate to the caller
42
+ # rather than being caught by the parent's policy — those are
43
+ # structural rejections of the nested record's input, not parent-
44
+ # shape coercion failures.
45
+ #
46
+ # @param value [FieldStruct::Base, ::Hash{::Symbol, ::String => Object, nil}, nil] +nil+ → +nil+; an
47
+ # instance of {#struct_class} (or subclass) → passthrough; a
48
+ # +Hash+ → constructs via +struct_class.new(hash)+. Anything
49
+ # else raises TypeError.
50
+ # @return [FieldStruct::Base, nil]
51
+ # @raise [TypeError] when input is non-nil and neither a
52
+ # {#struct_class} instance nor a Hash
53
+ def coerce(value, **)
54
+ return nil if value.nil?
55
+ return value if value.is_a?(@struct_class)
56
+ return @struct_class.new(value) if value.is_a?(::Hash)
57
+
58
+ raise ::TypeError, "expected #{@struct_class} or Hash, got #{value.class}"
59
+ end
60
+
61
+ # @param value [Object]
62
+ # @return [Boolean] true only when value is nil — an instance with
63
+ # all required fields missing is not "missing" here, it is just
64
+ # invalid. Invalidity is surfaced by the parent's validate_field.
65
+ def missing?(value)
66
+ value.nil?
67
+ end
68
+
69
+ # @return [Class] the +struct_class+ itself, so introspection sees
70
+ # the actual nested type rather than a generic FieldStruct::Base
71
+ def ruby_type
72
+ @struct_class
73
+ end
74
+
75
+ # @return [::String]
76
+ def inspect
77
+ name = @struct_class.name || 'AnonymousFieldStruct'
78
+ "#<#{FieldStruct.inspect_namespace}::Types::Nested struct_class=#{name}>"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FieldStruct
4
+ module Types
5
+ # Generic preset resolution for a single named option on a single
6
+ # type. Symbol values are looked up in the supplied presets hash;
7
+ # anything else passes through. Unknown preset names raise
8
+ # +ArgumentError+ at field-declaration time.
9
+ #
10
+ # Used by string-shaped types (Email/UUID/URL) for +format:+ and by
11
+ # time-shaped types (Date/Time/DateTime) via {TimeFormatResolver}
12
+ # (which adds a stricter set of allowed value types).
13
+ module PresetResolver
14
+ # Resolve +options[key]+ when it's a Symbol preset name, leaving other
15
+ # values untouched.
16
+ #
17
+ # @param options [::Hash{::Symbol => Object, nil}] the per-field options
18
+ # @param key [::Symbol] the option to resolve (e.g. +:format+)
19
+ # @param presets [::Hash{::Symbol => Object}] preset name → concrete value
20
+ # @param label [::String] used in the unknown-preset error message
21
+ # @return [::Hash{::Symbol => Object, nil}] options with +key+ resolved (a copy when changed)
22
+ # @raise [ArgumentError] when +options[key]+ names an unknown preset
23
+ def self.call(options, key, presets, label:)
24
+ return options unless options.key?(key)
25
+
26
+ value = options[key]
27
+ return options unless value.is_a?(::Symbol)
28
+
29
+ resolved = presets.fetch(value) do
30
+ raise ArgumentError,
31
+ "unknown #{label} preset #{value.inspect}. Available: #{presets.keys.inspect}"
32
+ end
33
+ options.merge(key => resolved)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'time'
5
+
6
+ module FieldStruct
7
+ module Types
8
+ # RFC 3339 — the profile of ISO 8601 that "ISO-8601" means in an API
9
+ # contract, and what JSON Schema's +date-time+ refers to. This is what the
10
+ # +:iso8601+ preset resolves to for {Date}, {Time} and {DateTime}.
11
+ #
12
+ # FieldStruct parses and renders it itself, for two reasons.
13
+ #
14
+ # **It cannot be written as a strftime string.** +%z+ renders +\+0000+,
15
+ # which RFC 3339 rejects — so the preset named +iso8601+ used to emit a
16
+ # value its own documented format would refuse. And no strftime string can
17
+ # express "optional fractional seconds", which most JSON APIs emit.
18
+ #
19
+ # **The stdlib helpers disagree with each other.** Routing +Time+ through
20
+ # +Time.iso8601+ and +DateTime+ through +DateTime.iso8601+ would give the
21
+ # same declared preset two different contracts:
22
+ #
23
+ # Time.iso8601('2026-02-30T10:30:00Z') # => 2026-03-02 10:30 UTC
24
+ # DateTime.iso8601('2026-02-30T10:30:00Z') # => refused
25
+ # Time.iso8601('2026-07-03T10:30Z') # => refused
26
+ # DateTime.iso8601('2026-07-03T10:30Z') # => accepted
27
+ # DateTime.iso8601('10:30:00Z') # => TODAY at 10:30
28
+ #
29
+ # +Time.iso8601+ reading 30 February as 2 March is the exact
30
+ # silently-wrong-day defect this release exists to remove, so it cannot be
31
+ # the thing that implements the strict preset.
32
+ #
33
+ # The pattern is deliberately narrow: fixed-width fields, a mandatory
34
+ # offset, and no week dates, ordinal dates or basic (unseparated) forms.
35
+ # That narrowness is the point — it is what lets a caller replace a
36
+ # hand-rolled +/\A\d{4}-\d{2}-\d{2}\z/+ with a declared format.
37
+ class Rfc3339Format
38
+ # Full-width calendar date. No sign, no week or ordinal forms.
39
+ # @return [::Regexp]
40
+ DATE = /\A(\d{4})-(\d{2})-(\d{2})\z/
41
+
42
+ # Date, +T+, time to whole seconds, optional fraction, mandatory offset.
43
+ # +±hhmm+ (no colon) is accepted on input because that is what FieldStruct
44
+ # v0.9.0 emitted, and its own output has to keep parsing.
45
+ #
46
+ # @return [::Regexp]
47
+ TIMESTAMP = /\A(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-]\d{2}:?\d{2})\z/
48
+
49
+ # @return [Rfc3339Format] the date-only form, for {Types::Date}
50
+ def self.date
51
+ @date ||= new(:date)
52
+ end
53
+
54
+ # @return [Rfc3339Format] the timestamp form, for {Types::Time} and {Types::DateTime}
55
+ def self.timestamp
56
+ @timestamp ||= new(:timestamp)
57
+ end
58
+
59
+ # @return [::Symbol] +:date+ or +:timestamp+
60
+ attr_reader :kind
61
+
62
+ # @param kind [::Symbol] +:date+ or +:timestamp+
63
+ def initialize(kind)
64
+ @kind = kind
65
+ freeze
66
+ end
67
+
68
+ # @return [::Symbol] the preset name this format implements
69
+ def name
70
+ :iso8601
71
+ end
72
+
73
+ # The accepted shape, as a regular expression whose source is also valid
74
+ # as an ECMA pattern — so a schema or documentation generator can publish
75
+ # the enforced contract rather than a prose approximation of it.
76
+ #
77
+ # @return [::Regexp]
78
+ def pattern
79
+ kind == :date ? DATE : TIMESTAMP
80
+ end
81
+
82
+ # @return [::String] the format's name, for error messages and inspection
83
+ def to_s
84
+ 'iso8601 (RFC 3339)'
85
+ end
86
+
87
+ # Parse a String into date/time fragments.
88
+ #
89
+ # @param value [::String] the raw string
90
+ # @return [::Hash{::Symbol => Object}] +:year+, +:mon+, +:mday+ and, for a
91
+ # timestamp, +:hour+, +:min+, +:sec+, +:sec_fraction+, +:offset+
92
+ # @raise [ArgumentError] when the string is not RFC 3339, or names a day,
93
+ # hour, minute or second that cannot exist
94
+ def parse(value)
95
+ match = pattern.match(value)
96
+ # The value is not echoed: FieldStruct wraps this as "could not be
97
+ # coerced: <message>" and that string reaches API responses.
98
+ raise ArgumentError, "expected an RFC 3339 #{kind}" if match.nil?
99
+
100
+ kind == :date ? date_parts(match) : timestamp_parts(match)
101
+ end
102
+
103
+ # Render a coerced value back to RFC 3339.
104
+ #
105
+ # @param value [::Date, ::Time, ::DateTime] the coerced value
106
+ # @return [::String]
107
+ def render(value)
108
+ # Date#iso8601, never Date#xmlschema: ActiveSupport redefines the
109
+ # latter to emit a full timestamp, which would make output depend on
110
+ # whether the host loaded Rails.
111
+ return value.iso8601 if kind == :date
112
+
113
+ "#{value.strftime("%Y-%m-%dT%H:%M:%S")}#{fraction_of(value)}#{offset_of(value)}"
114
+ end
115
+
116
+ private
117
+
118
+ # @param match [::MatchData] a {DATE} match
119
+ # @return [::Hash{::Symbol => ::Integer}]
120
+ def date_parts(match)
121
+ year, month, day = match.captures.map { |capture| Integer(capture, 10) }
122
+ TemporalParser.ensure_real_day!({year: year, mon: month, mday: day})
123
+
124
+ {year: year, mon: month, mday: day}
125
+ end
126
+
127
+ # @param match [::MatchData] a {TIMESTAMP} match
128
+ # @return [::Hash{::Symbol => Object}]
129
+ def timestamp_parts(match)
130
+ year, month, day, hour, minute, second = match.captures[0, 6].map { |c| Integer(c, 10) }
131
+ TemporalParser.ensure_real_day!({year: year, mon: month, mday: day})
132
+ # RFC 3339 permits a leap second, but Time rolls :60 into the next
133
+ # minute and DateTime clamps it to :59 — the two stdlib classes give
134
+ # different answers, so the strict preset refuses it rather than
135
+ # picking one silently. Hour 24 is not RFC 3339 at all.
136
+ raise ArgumentError, 'invalid time of day' if hour > 23 || minute > 59 || second > 59
137
+
138
+ {
139
+ year: year, mon: month, mday: day, hour: hour, min: minute, sec: second,
140
+ sec_fraction: fraction_from(match.captures[6]),
141
+ offset: offset_from(match.captures[7])
142
+ }
143
+ end
144
+
145
+ # @param text [::String, nil] the +.digits+ capture, if any
146
+ # @return [::Rational] seconds after the whole second, exact
147
+ def fraction_from(text)
148
+ return 0r if text.nil?
149
+
150
+ ::Kernel.Rational(text[1..], 10**(text.length - 1))
151
+ end
152
+
153
+ # @return [::Integer] seconds east of UTC
154
+ # @raise [ArgumentError] when the offset names an hour or minute that
155
+ # cannot exist. Validated here rather than left to the constructors,
156
+ # because they disagree: +Time.new+ refuses a +25:00 offset while
157
+ # +DateTime.new+ accepts it and silently drops it to +00:00. A preset
158
+ # that means two different things depending on the field's type is the
159
+ # defect this class exists to avoid.
160
+ # @param text [::String] the offset capture: +Z+ or +±hh:mm+ / +±hhmm+
161
+ def offset_from(text)
162
+ return 0 if text.casecmp('Z').zero?
163
+
164
+ sign = text.start_with?('-') ? -1 : 1
165
+ digits = text[1..].delete(':')
166
+ hours = Integer(digits[0, 2], 10)
167
+ minutes = Integer(digits[2, 2], 10)
168
+ raise ArgumentError, 'invalid UTC offset' if hours > 23 || minutes > 59
169
+
170
+ sign * ((hours * 3600) + (minutes * 60))
171
+ end
172
+
173
+ # @param value [::Time, ::DateTime]
174
+ # @return [::String] +".123"+, or empty when the value is on a whole second
175
+ def fraction_of(value)
176
+ subsec = value.is_a?(::Time) ? value.subsec : value.sec_fraction
177
+ return '' if subsec.zero?
178
+
179
+ # Exact digits from the Rational; %f would round to six places and
180
+ # invent precision the value never carried.
181
+ digits = (subsec.to_r * 1_000_000_000).to_i.to_s.rjust(9, '0').sub(/0+\z/, '')
182
+ ".#{digits}"
183
+ end
184
+
185
+ # @param value [::Time, ::DateTime]
186
+ # @return [::String] +"Z"+ for UTC, otherwise +"+HH:MM"+
187
+ def offset_of(value)
188
+ seconds = value.is_a?(::Time) ? value.utc_offset : (value.offset * 86_400).to_i
189
+ return 'Z' if seconds.zero?
190
+
191
+ sign = seconds.negative? ? '-' : '+'
192
+ magnitude = seconds.abs
193
+ format('%s%02d:%02d', sign, magnitude / 3600, (magnitude % 3600) / 60)
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module FieldStruct
6
+ module Types
7
+ # String type. Coerces any non-nil value via +to_s+; treats nil, empty,
8
+ # and whitespace-only strings as missing. A field declared +strict: true+
9
+ # accepts only scalars (String, Symbol, Integer) and objects
10
+ # responding to +to_str+, so a Hash or Array is refused rather than stored
11
+ # as its +inspect+ text.
12
+ class String < Base
13
+ # Native options: a +format:+ Regexp (or Symbol preset, used by the
14
+ # +:uuid+ / +:url+ / +:email+ subtypes), an +enum:+ Array of
15
+ # allowed values, and a +strict:+ Boolean.
16
+ #
17
+ # @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
18
+ def self.own_option_schema
19
+ {
20
+ format: option(type: [::Regexp, ::Symbol]),
21
+ enum: option(type: [::Array]),
22
+ strict: option(type: [::TrueClass, ::FalseClass])
23
+ }
24
+ end
25
+
26
+ # @param value [#to_s, nil] anything stringable (everything in
27
+ # Ruby responds to +to_s+); +nil+ stays +nil+
28
+ # @param strict [Boolean, nil] when true, refuse anything but a String,
29
+ # Symbol, Integer or +to_str+ object (a Float, BigDecimal or Rational
30
+ # is refused: its +to_s+ is +"0.12e2"+ / +"1/2"+, not the caller's text). Strict never changes
31
+ # what an accepted value becomes — it is still +value.to_s+.
32
+ # @return [::String, nil] +nil+ when input is +nil+, otherwise +value.to_s+
33
+ # @raise [TypeError] under +strict:+, for any other value
34
+ def coerce(value, strict: false, **)
35
+ return nil if value.nil?
36
+
37
+ refuse_non_scalar!(value) if strict
38
+ value.to_s
39
+ end
40
+
41
+ # Missing if nil, empty, or whitespace-only.
42
+ #
43
+ # @param value [Object]
44
+ # @return [Boolean]
45
+ def missing?(value)
46
+ return true if value.nil?
47
+ return true if value.respond_to?(:strip) && value.strip.empty?
48
+
49
+ false
50
+ end
51
+
52
+ # @return [Class] the top-level +::String+ class
53
+ def ruby_type
54
+ ::String
55
+ end
56
+
57
+ private
58
+
59
+ # @param value [Object] a non-nil value
60
+ # @return [void]
61
+ # @raise [TypeError] unless +value+ is a String, Symbol, Integer, or
62
+ # responds to +to_str+; the message names the class, never the value
63
+ # (see Types::Time#coerce)
64
+ def refuse_non_scalar!(value)
65
+ return if value.is_a?(::String) || value.is_a?(::Symbol) || value.is_a?(::Integer)
66
+ return if value.respond_to?(:to_str)
67
+
68
+ raise ::TypeError, 'String fields with strict: true expect a String, Symbol, Integer ' \
69
+ "or an object responding to #to_str, got a #{value.class}"
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module FieldStruct
6
+ module Types
7
+ # Symbol type. Accepts an existing +Symbol+ or a +String+. Anything else
8
+ # raises +TypeError+, which the parent's +coercion_policy+ handles.
9
+ #
10
+ # Empty symbols (+:""+) are unusual; like other scalar non-string types,
11
+ # +missing?+ is nil-only — an empty symbol counts as present.
12
+ class Symbol < Base
13
+ # Native options: an +enum:+ Array of allowed values (symbols are
14
+ # string-like, so they share +enum:+ with {Types::String}).
15
+ #
16
+ # @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
17
+ def self.own_option_schema
18
+ {enum: option(type: [::Array])}
19
+ end
20
+
21
+ # @param value [::Symbol, ::String, nil] anything else raises TypeError
22
+ # @return [::Symbol, nil]
23
+ # @raise [TypeError] when input is not nil/Symbol/String
24
+ def coerce(value, **)
25
+ return nil if value.nil?
26
+ return value if value.is_a?(::Symbol)
27
+ return value.to_sym if value.is_a?(::String)
28
+
29
+ raise ::TypeError, "expected Symbol or String, got #{value.class}"
30
+ end
31
+
32
+ # @return [Class] the top-level +::Symbol+ class
33
+ def ruby_type
34
+ ::Symbol
35
+ end
36
+ end
37
+ end
38
+ end