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,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+ require_relative 'base'
5
+
6
+ module FieldStruct
7
+ module Types
8
+ # BigDecimal type. Accepts a {::BigDecimal}, +Integer+, +Float+, or a
9
+ # numeric string. Float input is converted with +Float::DIG+ precision
10
+ # to avoid the bare +BigDecimal(Float)+ "can't omit precision" error.
11
+ class BigDecimal < Base
12
+ # Native options: a +round:+ Integer precision and an +in:+ Array or
13
+ # Range of allowed values.
14
+ #
15
+ # @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
16
+ def self.own_option_schema
17
+ {
18
+ round: option(type: [::Integer]),
19
+ in: option(type: [::Array, ::Range])
20
+ }
21
+ end
22
+
23
+ # @return [::Integer, nil] the field-level rounding precision, or +nil+
24
+ # for no rounding. Subclasses override.
25
+ def self.default_round
26
+ nil
27
+ end
28
+
29
+ # @param value [::BigDecimal, Numeric, ::String, nil] +nil+ → +nil+;
30
+ # a +BigDecimal+ passes through; +Float+ uses +Float::DIG+
31
+ # precision; everything else goes through
32
+ # +Kernel.BigDecimal(value.to_s)+ — non-numeric strings raise
33
+ # ArgumentError.
34
+ # @param round [::Integer, nil] decimal places, rounding half up (ties
35
+ # away from zero) whatever the global +BigDecimal.mode+; +nil+ leaves
36
+ # the value unrounded. The result is a BigDecimal even at +round: 0+.
37
+ # @return [::BigDecimal, nil]
38
+ # @raise [ArgumentError] when a value's string form cannot be parsed
39
+ # (e.g. a non-numeric String, or a Rational's "1/3"), or the value
40
+ # is NaN or ±Infinity (as a Float, a BigDecimal, or a String like "NaN")
41
+ def coerce(value, round: self.class.default_round, **)
42
+ return nil if value.nil?
43
+
44
+ result = if value.is_a?(::BigDecimal)
45
+ value
46
+ elsif value.is_a?(::Float)
47
+ ::Kernel.BigDecimal(value, ::Float::DIG)
48
+ else
49
+ parse(value)
50
+ end
51
+ # The class, never the value — see Types::Time#coerce.
52
+ raise ArgumentError, "BigDecimal fields expect a finite number, got a non-finite #{value.class}" unless result.finite?
53
+
54
+ # An explicit mode keeps the result a BigDecimal (bare round(0)
55
+ # returns an Integer) and ignores the process-global
56
+ # BigDecimal.mode, which a host may have set to banker's rounding.
57
+ round ? result.round(round, ::BigDecimal::ROUND_HALF_UP) : result
58
+ end
59
+
60
+ # @return [Class] the top-level +::BigDecimal+ class
61
+ def ruby_type
62
+ ::BigDecimal
63
+ end
64
+
65
+ private
66
+
67
+ # @param value [Object] a value that is neither a BigDecimal nor a Float
68
+ # @return [::BigDecimal]
69
+ # @raise [ArgumentError] when its string form is not a number; the
70
+ # message names the class, never the value (Kernel's echoes it)
71
+ def parse(value)
72
+ ::Kernel.BigDecimal(value.to_s)
73
+ rescue ::ArgumentError
74
+ raise ArgumentError, "BigDecimal fields could not read a #{value.class} as a number", cause: nil
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'string'
4
+
5
+ module FieldStruct
6
+ module Types
7
+ # Binary type — coerces input to a String and forces +ASCII-8BIT+
8
+ # encoding. Intended for raw bytes (file contents, BLOBs, etc.)
9
+ # where whitespace bytes (+\x20+, +\n+, etc.) are meaningful data,
10
+ # not "missing" content.
11
+ #
12
+ # +missing?+ is therefore +nil+-or-empty only — unlike
13
+ # {Types::String} which treats whitespace-only as missing.
14
+ class Binary < FieldStruct::Types::String
15
+ # @param value [#to_s, nil] anything stringable; +nil+ stays +nil+
16
+ # @return [::String, nil] +nil+ when input is +nil+, otherwise a
17
+ # fresh ASCII-8BIT-encoded copy of +value.to_s+
18
+ def coerce(value, **)
19
+ result = super
20
+ return nil if result.nil?
21
+
22
+ result.dup.force_encoding(::Encoding::ASCII_8BIT)
23
+ end
24
+
25
+ # Missing if nil or empty. Whitespace bytes are meaningful in
26
+ # binary data, so they don't count as missing.
27
+ #
28
+ # @param value [Object]
29
+ # @return [Boolean]
30
+ def missing?(value)
31
+ return true if value.nil?
32
+ return true if value.respond_to?(:empty?) && value.empty?
33
+
34
+ false
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module FieldStruct
6
+ module Types
7
+ # Boolean type. Accepts the literal +true+/+false+, the strings
8
+ # "true"/"false" (case-insensitive), and the integers/strings 1/0.
9
+ # Anything else raises +ArgumentError+.
10
+ #
11
+ # Per-field +values:+ option customizes the truthy/falsy vocabulary:
12
+ #
13
+ # required :active, :boolean, values: {truthy: %w[on yes y],
14
+ # falsy: %w[off no n]}
15
+ # required :flag, :boolean, values: :english_yes_no
16
+ #
17
+ # Hash form takes +:truthy+ and +:falsy+ Arrays of strings (matched
18
+ # case-insensitively). Symbol form names a built-in preset; see
19
+ # {.presets} for the available names.
20
+ #
21
+ # Ruby has no Boolean class, so {#ruby_type} returns the pair
22
+ # +[TrueClass, FalseClass]+.
23
+ class Boolean < Base
24
+ # Native options: a +values:+ Hash with +:truthy+ / +:falsy+ keys, or a
25
+ # Symbol naming one of {.presets}.
26
+ #
27
+ # @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
28
+ def self.own_option_schema
29
+ {values: option(type: [::Hash, ::Symbol], presets: presets.keys)}
30
+ end
31
+
32
+ # @return [Array<::String>] case-insensitive strings that coerce to true
33
+ def self.default_truthy
34
+ %w[true 1]
35
+ end
36
+
37
+ # @return [Array<::String>] case-insensitive strings that coerce to false
38
+ def self.default_falsy
39
+ %w[false 0]
40
+ end
41
+
42
+ # @return [::Hash{::Symbol => ::Hash{::Symbol => ::Array<::String>}}] named presets for the +values:+ option
43
+ def self.presets
44
+ {
45
+ english_yes_no: {truthy: %w[true yes y on 1], falsy: %w[false no n off 0]},
46
+ english: {truthy: %w[true yes y on 1], falsy: %w[false no n off 0]},
47
+ numeric: {truthy: %w[1], falsy: %w[0]}
48
+ }
49
+ end
50
+
51
+ # Resolve a Symbol +values:+ to its preset Hash at field-declaration
52
+ # time. Pass-through for Hash form; raises for unknown preset names.
53
+ def self.resolve_options(options)
54
+ return options unless options.key?(:values)
55
+
56
+ resolved = options.dup
57
+ if resolved[:values].is_a?(::Symbol)
58
+ preset = presets.fetch(resolved[:values]) do
59
+ raise ArgumentError,
60
+ "unknown :boolean values preset #{resolved[:values].inspect}. " \
61
+ "Available: #{presets.keys.inspect}"
62
+ end
63
+ resolved[:values] = preset
64
+ end
65
+ unless resolved[:values].is_a?(::Hash)
66
+ raise ArgumentError, 'boolean values: must be a Hash {truthy:, falsy:} or a preset Symbol'
67
+ end
68
+
69
+ resolved
70
+ end
71
+
72
+ # @param value [Boolean, ::Integer, ::String, nil] accepts literal
73
+ # +true+/+false+; the integers +1+/+0+; or a String matched
74
+ # case-insensitively against the truthy/falsy vocabularies.
75
+ # Anything else raises ArgumentError.
76
+ # @param values [Hash{::Symbol=>Array<::String>}, nil] +{truthy: [...], falsy: [...]}+
77
+ # custom vocabulary; +nil+ uses {.default_truthy} / {.default_falsy}
78
+ # @return [Boolean, nil]
79
+ # @raise [ArgumentError] when the value can't be mapped to true/false
80
+ def coerce(value, values: nil, **)
81
+ return nil if value.nil?
82
+ return value if value == true || value == false # rubocop:disable Style/MultipleComparison
83
+ return true if value == 1
84
+ return false if value == 0
85
+
86
+ truthy, falsy = truthy_and_falsy_for(values)
87
+ if value.is_a?(::String)
88
+ downcased = value.downcase
89
+ return true if truthy.include?(downcased)
90
+ return false if falsy.include?(downcased)
91
+ end
92
+
93
+ raise ArgumentError, refusal_message(value, truthy + falsy)
94
+ end
95
+
96
+ # @return [Array(Class, Class)] +[TrueClass, FalseClass]+
97
+ def ruby_type
98
+ [TrueClass, FalseClass]
99
+ end
100
+
101
+ private
102
+
103
+ # The accepted vocabulary is declared schema, so it is listed; the
104
+ # refused value is named by class only — it may be payments data.
105
+ #
106
+ # @param value [Object] the refused input
107
+ # @param accepted [Array<::String>] the truthy then falsy strings
108
+ # @return [::String]
109
+ def refusal_message(value, accepted)
110
+ got = value.is_a?(::String) ? 'a String that is not one' : value.class.to_s
111
+ "Boolean fields expect true, false, 1, 0 or one of the strings #{accepted.map(&:inspect).join(", ")}, got #{got}"
112
+ end
113
+
114
+ # @param values [::Hash{::Symbol=>::Array<::String>}, nil] the field's +values:+
115
+ # @return [Array(::Array<::String>, ::Array<::String>)] +[truthy, falsy]+, downcased
116
+ def truthy_and_falsy_for(values)
117
+ return [self.class.default_truthy, self.class.default_falsy] unless values
118
+
119
+ [
120
+ Array(values[:truthy]).map { |s| s.to_s.downcase },
121
+ Array(values[:falsy]).map { |s| s.to_s.downcase }
122
+ ]
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require_relative 'base'
5
+
6
+ module FieldStruct
7
+ module Types
8
+ # Date type. Returns a +Date+ as-is, narrows a +DateTime+ or +Time+ down
9
+ # to a plain Date, reads a String via +Date.parse+ (or +strptime+ when the
10
+ # 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 when emitting via +as_json+ / +to_json+). When no format
15
+ # is set, the type uses +Date.parse+ for input and ISO-8601 for output.
16
+ class Date < Base
17
+ # Native options: a +format:+ strftime/strptime String (or Symbol
18
+ # preset; see {.presets}) and an +in:+ Array or Range of allowed values.
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
+ }
26
+ end
27
+
28
+ # @return [::String, nil] field-level strftime/strptime format; +nil+
29
+ # leaves the type using ISO-8601 in/out
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: the
37
+ # contract cannot be written as one (see that class). The display presets
38
+ # stay strftime-based — they are for humans, not wire formats.
39
+ #
40
+ # @return [Hash{::Symbol=>Object}]
41
+ def self.presets
42
+ {
43
+ iso8601: Rfc3339Format.date,
44
+ us: '%m/%d/%Y',
45
+ eu: '%d/%m/%Y'
46
+ }
47
+ end
48
+
49
+ # Resolve a Symbol +format:+ preset (see {.presets}) to its strftime
50
+ # string at field-declaration time; other forms pass through.
51
+ def self.resolve_options(options)
52
+ TimeFormatResolver.call(options, presets)
53
+ end
54
+
55
+ # Resolve a declared +format:+ (a Symbol preset name or a strftime
56
+ # String) into the String that {#coerce} and JSON rendering actually
57
+ # use. Declarations are stored exactly as written, so this is where a
58
+ # preset name becomes usable. See {TimeFormatResolver}.
59
+ #
60
+ # @param format [::String, ::Symbol, nil] the format as declared
61
+ # @return [::String, nil] +nil+ when the field declares no format
62
+ # @raise [ArgumentError] when a Symbol names no preset in {.presets}
63
+ def self.resolve_format(format)
64
+ # Skip building the presets Hash for the common, undeclared case.
65
+ return format if format.nil? || format.is_a?(::String)
66
+
67
+ TimeFormatResolver.resolve(format, presets)
68
+ end
69
+
70
+ # Dispatch is on explicit stdlib classes, never on a +respond_to?+ probe
71
+ # against the value. ActiveSupport defines +String#to_date+, so a probe
72
+ # that is false on plain Ruby is TRUE under Rails, and the type would take
73
+ # a different branch there than it does in its own suite — which is
74
+ # exactly how a declared +format:+ came to be ignored under Rails while
75
+ # every spec here passed. +Date.parse+ and +Date.strptime+ are not
76
+ # redefined by ActiveSupport, so dispatching on them gives one behaviour
77
+ # in both worlds.
78
+ #
79
+ # @param value [::String, ::Date, ::DateTime, ::Time, nil] +nil+ → +nil+;
80
+ # a String is read through +strptime+ when a +format:+ is set and
81
+ # +Date.parse+ otherwise; a +Date+ passes through; a +DateTime+ or
82
+ # +Time+ (including ActiveSupport's TimeWithZone) is narrowed to a
83
+ # plain +Date+. Anything else is refused.
84
+ # @param format [::String, nil] strptime/strftime format; +nil+ uses
85
+ # {.default_format} (which itself defaults to +nil+ = ISO-8601 path)
86
+ # @return [::Date, nil]
87
+ # @raise [ArgumentError] when a String cannot be parsed, or the value is
88
+ # not a String or a temporal object
89
+ def coerce(value, format: self.class.default_format, **)
90
+ return nil if value.nil?
91
+
92
+ fmt = self.class.resolve_format(format)
93
+
94
+ case value
95
+ when ::String then parse_string(value, fmt)
96
+ # ::DateTime is a ::Date, so this arm narrows both. A plain Date is
97
+ # returned untouched; a DateTime is rebuilt as one.
98
+ when ::Date then value.instance_of?(::Date) ? value : ::Date.new(value.year, value.mon, value.mday)
99
+ # ActiveSupport::TimeWithZone lands here: AS overrides Time.=== so it
100
+ # matches, even though it is not an instance of ::Time.
101
+ when ::Time then ::Date.new(value.year, value.mon, value.mday)
102
+ else
103
+ # The class, never the value. FieldStruct wraps this as "could not be
104
+ # coerced: <message>" and that string reaches API responses and audit
105
+ # rows, so a value echoed here is a value published.
106
+ raise ArgumentError, "expected a String, Date, DateTime or Time, got #{value.class}"
107
+ end
108
+ end
109
+
110
+ # @return [Class] the top-level +::Date+ class
111
+ def ruby_type
112
+ ::Date
113
+ end
114
+
115
+ private
116
+
117
+ # @param value [::String] the raw string
118
+ # @param format [::String, nil] strptime format, when the field declares one
119
+ # @return [::Date]
120
+ # @raise [ArgumentError] when the string cannot be read
121
+ def parse_string(value, format)
122
+ # Validated first, then handed to the stdlib parser: the checks decide
123
+ # what is ACCEPTABLE, the stdlib still decides what the value IS, so
124
+ # the two can't drift apart. See {TemporalParser} for the three rules.
125
+ if format.is_a?(Rfc3339Format)
126
+ parts = format.parse(value)
127
+ return ::Date.new(parts[:year], parts[:mon], parts[:mday])
128
+ end
129
+
130
+ # The validation step has already parsed the string once; build the
131
+ # Date from those parts rather than having the stdlib parse it again.
132
+ if format
133
+ parts = TemporalParser.strptime_parts(value, format)
134
+ return date_from_parts(parts) || ::Date.strptime(value, format)
135
+ end
136
+
137
+ date_from_parts(TemporalParser.parse_parts(value)) || ::Date.parse(value)
138
+ end
139
+
140
+ # +Date.parse+ and +Date.strptime+ build the Date straight from year,
141
+ # month and day whenever the parts carry all three and neither a Julian
142
+ # day (+:jd+) nor a day of the year (+:yday+) — date_core.c's fast path
143
+ # in +d_new_by_frags+ — so this returns the same Date. Any other shape is
144
+ # left to the stdlib (+nil+).
145
+ #
146
+ # @param parts [::Hash{::Symbol=>Object}] already checked by {TemporalParser}
147
+ # @return [::Date, nil]
148
+ def date_from_parts(parts)
149
+ return nil if parts.key?(:jd) || parts.key?(:yday)
150
+
151
+ year, month, day = parts.values_at(:year, :mon, :mday)
152
+ return nil if year.nil? || month.nil? || day.nil?
153
+
154
+ ::Date.new(year, month, day)
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require_relative 'base'
5
+
6
+ module FieldStruct
7
+ module Types
8
+ # DateTime type. Returns a +DateTime+ as-is, builds one from a +Date+ or
9
+ # +Time+'s components, reads a String via +DateTime.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 DateTime < 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] field-level strftime/strptime format; +nil+
30
+ # leaves the type using ISO-8601 in/out
31
+ def self.default_format
32
+ nil
33
+ end
34
+
35
+ # Named presets for the +format:+ option.
36
+ #
37
+ # +:iso8601+ is an {Rfc3339Format} rather than a strftime String: +%z+
38
+ # renders +\+0000+, which RFC 3339 rejects, and no strftime string can
39
+ # express optional fractional seconds. The interchange and display
40
+ # presets stay strftime-based.
41
+ #
42
+ # @return [Hash{::Symbol=>Object}]
43
+ def self.presets
44
+ {
45
+ iso8601: Rfc3339Format.timestamp,
46
+ rfc2822: '%a, %d %b %Y %H:%M:%S %z',
47
+ db: '%Y-%m-%d %H:%M:%S'
48
+ }
49
+ end
50
+
51
+ # Validate a Symbol +format:+ preset (see {.presets}) and a +zone:+
52
+ # (see {ZoneOption}) at field-declaration time.
53
+ #
54
+ # @param options [::Hash{::Symbol => Object, nil}] the per-field options
55
+ # @return [::Hash{::Symbol => Object, nil}] the options, unchanged
56
+ # @raise [ArgumentError] on an unknown preset or an unreadable +zone:+
57
+ def self.resolve_options(options)
58
+ ZoneOption.validate!('DateTime', TimeFormatResolver.call(options, presets))
59
+ end
60
+
61
+ # Resolve a declared +format:+ (a Symbol preset name or a strftime
62
+ # String) into the String that {#coerce} and JSON rendering actually
63
+ # use. Declarations are stored exactly as written, so this is where a
64
+ # preset name becomes usable. See {TimeFormatResolver}.
65
+ #
66
+ # @param format [::String, ::Symbol, nil] the format as declared
67
+ # @return [::String, nil] +nil+ when the field declares no format
68
+ # @raise [ArgumentError] when a Symbol names no preset in {.presets}
69
+ def self.resolve_format(format)
70
+ # Skip building the presets Hash for the common, undeclared case.
71
+ return format if format.nil? || format.is_a?(::String)
72
+
73
+ TimeFormatResolver.resolve(format, presets)
74
+ end
75
+
76
+ # Dispatch is on explicit stdlib classes, never on a +respond_to?+ probe
77
+ # against the value: ActiveSupport defines +String#to_datetime+, so a
78
+ # probe that is false on plain Ruby is TRUE under Rails.
79
+ # +DateTime.parse+ and +DateTime.strptime+ are not redefined by
80
+ # ActiveSupport. See {Types::Time#coerce} for why cross-class conversion
81
+ # is built from components rather than +to_datetime+.
82
+ #
83
+ # @param value [::String, ::DateTime, ::Date, ::Time, nil] +nil+ → +nil+;
84
+ # a String is read through +strptime+ when a +format:+ is set and
85
+ # +DateTime.parse+ otherwise; a +DateTime+ passes through; a +Date+
86
+ # becomes midnight and a +Time+ (including ActiveSupport's
87
+ # TimeWithZone) keeps its instant and offset. Anything else is refused.
88
+ # @param format [::String, nil] strptime/strftime format; +nil+ uses
89
+ # {.default_format} (which itself defaults to +nil+ = ISO-8601 path)
90
+ # @param zone [::String, nil] +"UTC"+, +"±HH:MM"+ or an IANA name: the offset for a
91
+ # String that names none, and for a +Date+'s midnight. Input carrying
92
+ # its own offset keeps it. +nil+ means UTC. See {ZoneOption}.
93
+ # @return [::DateTime, nil]
94
+ # @raise [ArgumentError] when a String cannot be parsed, or the value is
95
+ # not a String or a temporal object
96
+ def coerce(value, format: self.class.default_format, zone: nil, **)
97
+ return nil if value.nil?
98
+
99
+ fmt = self.class.resolve_format(format)
100
+
101
+ case value
102
+ when ::String then parse_string(value, fmt, zone)
103
+ # ::DateTime before ::Date — DateTime is a subclass of Date.
104
+ when ::DateTime then value
105
+ when ::Date
106
+ return datetime_from_time(ZoneOption.midnight(value, zone)) if zone
107
+
108
+ ::DateTime.new(value.year, value.mon, value.mday)
109
+ # ActiveSupport::TimeWithZone matches here too (AS overrides Time.===)
110
+ # and answers every reader this uses.
111
+ when ::Time then datetime_from_time(value)
112
+ else
113
+ # The class, never the value — see Types::Date#coerce.
114
+ raise ArgumentError, "expected a String, Date, DateTime or Time, got #{value.class}"
115
+ end
116
+ end
117
+
118
+ # @return [Class] the top-level +::DateTime+ class
119
+ def ruby_type
120
+ ::DateTime
121
+ end
122
+
123
+ private
124
+
125
+ # @param value [::String] the raw string
126
+ # @param format [::String, nil] strptime format, when the field declares one
127
+ # @param zone [::String, nil] the field's +zone:+, if declared
128
+ # @return [::DateTime]
129
+ # @raise [ArgumentError] when the string cannot be read
130
+ def parse_string(value, format, zone)
131
+ # Validated first, then handed to the stdlib parser: the checks decide
132
+ # what is ACCEPTABLE, the stdlib still decides what the value IS, so
133
+ # the two can't drift apart. See {TemporalParser} for the three rules.
134
+ if format.is_a?(Rfc3339Format)
135
+ parts = format.parse(value)
136
+ return ::DateTime.new(parts[:year], parts[:mon], parts[:mday], parts[:hour], parts[:min],
137
+ parts[:sec] + parts[:sec_fraction], ::Kernel.Rational(parts[:offset], 86_400))
138
+ end
139
+
140
+ if format
141
+ parts = TemporalParser.strptime_parts(value, format)
142
+ return datetime_from_time(ZoneOption.time_at(value, format, zone, parts)) if zone && ZoneOption.zoneless?(parts)
143
+
144
+ return ::DateTime.strptime(value, format)
145
+ end
146
+
147
+ parts = TemporalParser.parse_parts(value)
148
+ return datetime_from_time(ZoneOption.time_at(value, nil, zone, parts)) if zone && ZoneOption.zoneless?(parts)
149
+
150
+ ::DateTime.parse(value)
151
+ end
152
+
153
+ # @param value [::Time] the value to convert
154
+ # @return [::DateTime] the same instant, at the same offset, sub-second
155
+ # precision preserved
156
+ def datetime_from_time(value)
157
+ ::DateTime.new(value.year, value.mon, value.mday, value.hour, value.min,
158
+ value.sec + ::Kernel.Rational(value.nsec, 1_000_000_000),
159
+ ::Kernel.Rational(value.utc_offset, 86_400))
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'string'
4
+
5
+ module FieldStruct
6
+ module Types
7
+ # Email type — a {Types::String} that pre-fills the field's +format:+
8
+ # option with a pragmatic email pattern. Same shape as
9
+ # {Types::UUID} / {Types::URL}: bad-format input is a validation
10
+ # error, not a coercion failure.
11
+ #
12
+ # The default pattern is deliberately not RFC 5322 strict — those
13
+ # regexes are unwieldy and reject real-world addresses. It checks
14
+ # +local@domain.tld+ shape with no internal whitespace or extra +@+
15
+ # characters; users can override via +format: /.../+ for stricter
16
+ # rules.
17
+ class Email < FieldStruct::Types::String
18
+ # Inherits +format:+ / +enum:+ from {Types::String}, advertising the
19
+ # +:format+ presets this subtype resolves (see {.presets}).
20
+ #
21
+ # @return [::Hash{::Symbol => ::Hash{::Symbol => Object}}]
22
+ def self.own_option_schema
23
+ {format: option(type: [::Regexp, ::Symbol], presets: presets.keys)}
24
+ end
25
+
26
+ # @return [Regexp] pragmatic +local@domain.tld+ shape, no internal
27
+ # whitespace or extra +@+; deliberately not RFC 5322 strict.
28
+ # Override per-field via +format:+ (Regexp or Symbol preset).
29
+ def self.default_format
30
+ /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
31
+ end
32
+
33
+ # @return [Hash{::Symbol=>Regexp}] named presets for +format:+
34
+ def self.presets
35
+ {
36
+ permissive: /\A[^@\s]+@[^@\s]+\z/,
37
+ default: default_format,
38
+ strict: /\A[\w.+-]+@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+\z/
39
+ }
40
+ end
41
+
42
+ # Resolve a Symbol +format:+ preset (see {.presets}) to its Regexp at
43
+ # field-declaration time; a user-supplied Regexp passes through.
44
+ def self.resolve_options(options)
45
+ PresetResolver.call(options, :format, presets, label: ':email format')
46
+ end
47
+ end
48
+ end
49
+ end