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,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