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