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,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'time'
5
+
6
+ module FieldStruct
7
+ module Types
8
+ # The +zone:+ option shared by {Time} and {DateTime}.
9
+ #
10
+ # A zone-less string (+"2026-10-22 10:00"+) names a wall-clock time, not an
11
+ # instant: by default +:time+ reads it in the *process* zone and +:datetime+
12
+ # in UTC, so the same input meant different instants on different servers.
13
+ # +zone:+ supplies the missing offset, so it means one instant everywhere.
14
+ #
15
+ # It only fills in an offset that is MISSING. A string with an offset, a
16
+ # +Time+, a +DateTime+ (and ActiveSupport's TimeWithZone) keep theirs; a
17
+ # +Date+, which has none, becomes midnight in the zone.
18
+ #
19
+ # Accepted values are +"UTC"+, fixed offsets +"±HH:MM"+ and IANA
20
+ # Area/Location names (+"America/Denver"+). A fixed offset is the same all
21
+ # year. A name is read through tzinfo's zone database, so a wall-clock time
22
+ # gets the offset in force at that date and an instant (an epoch) the
23
+ # offset in force at that instant. tzinfo is required only when a field
24
+ # declares a name.
25
+ #
26
+ # Daylight-saving edges, for a name:
27
+ #
28
+ # - A wall-clock time the zone skips (02:30 on a spring-forward day) is
29
+ # refused, as a 30 February is: it names no instant. ActiveSupport moves
30
+ # it an hour forward instead; FieldStruct does not guess.
31
+ # - A wall-clock time the zone repeats (01:30 on a fall-back day) is read
32
+ # at its daylight-time offset — the earlier instant — which is what
33
+ # ActiveSupport's TimeWithZone picks, so a Rails app sees the same answer.
34
+ # - A +Date+ whose midnight the zone skips becomes the first moment of that
35
+ # day (01:00 in Santiago on its spring-forward day), as
36
+ # +Date#in_time_zone+ does.
37
+ #
38
+ # The value returned is a plain +::Time+ (or +::DateTime+) carrying the
39
+ # offset, never a +TZInfo::TimeWithOffset+; it does not carry the zone
40
+ # itself, so arithmetic on it keeps that offset rather than following DST.
41
+ #
42
+ # Refused: abbreviations and legacy aliases with no +/+ (+"MST"+ is a fixed
43
+ # -07:00 in the database, not Mountain time with DST) and Rails' friendly
44
+ # names (+"Mountain Time (US & Canada)"+), which only exist when
45
+ # ActiveSupport is loaded and so would make a declaration valid in one
46
+ # process and invalid in another (invariant 7).
47
+ module ZoneOption
48
+ # A fixed UTC offset, colon required.
49
+ # @return [::Regexp]
50
+ OFFSET = /\A([+-])(\d{2}):(\d{2})\z/
51
+
52
+ # Refuse a +zone:+ value FieldStruct cannot read, at declaration. A
53
+ # non-String is left for the option-shape check to report.
54
+ #
55
+ # @param type_name [::String] the type's short name, for the message
56
+ # @param options [::Hash{::Symbol => Object, nil}] the per-field options
57
+ # @return [::Hash{::Symbol => Object, nil}] the options, unchanged
58
+ # @raise [ArgumentError] when +zone:+ is a String that is not +"UTC"+, a
59
+ # valid +±HH:MM+ offset or an IANA name in the zone database, or when
60
+ # no zone database can be found
61
+ def self.validate!(type_name, options)
62
+ zone = options[:zone]
63
+ return options unless zone.is_a?(::String)
64
+ return options if offset_seconds(zone)
65
+
66
+ raise ArgumentError, refusal(type_name, zone) unless zone.include?('/')
67
+
68
+ load_timezone(type_name, zone)
69
+ options
70
+ end
71
+
72
+ # @param type_name [::String] the type's short name
73
+ # @param zone [::String] the refused +zone:+
74
+ # @return [::String] the declaration error, with a hint for the
75
+ # shape of +zone+
76
+ def self.refusal(type_name, zone)
77
+ hint = if zone.match?(/[ ()&]/)
78
+ 'Rails zone names are an ActiveSupport alias, not IANA names, and exist only when ' \
79
+ 'ActiveSupport is loaded: use the name it maps to (ActiveSupport::TimeZone::MAPPING[name]).'
80
+ elsif zone.include?('/')
81
+ 'The zone database has no zone by that name.'
82
+ else
83
+ 'Use an Area/Location name: abbreviations and aliases are refused ("MST" is a fixed ' \
84
+ '-07:00 in the zone database, not Mountain time with DST), and an offset needs its colon.'
85
+ end
86
+ "zone: on #{type_name} expects an IANA name like \"America/Denver\", \"UTC\" or a UTC offset " \
87
+ "like \"-06:00\", got #{zone.inspect}. #{hint}"
88
+ end
89
+
90
+ # Look up (and cache) a zone by name, requiring tzinfo on first use.
91
+ #
92
+ # @param type_name [::String] the type's short name, for the message
93
+ # @param zone [::String] an IANA name
94
+ # @return [Object] the +TZInfo::Timezone+
95
+ # @raise [ArgumentError] when the name is unknown, or no zone data is
96
+ # installed
97
+ def self.load_timezone(type_name, zone)
98
+ require 'tzinfo'
99
+ @timezones ||= {}
100
+ @timezones[zone] ||= ::TZInfo::Timezone.get(zone)
101
+ rescue ::TZInfo::InvalidTimezoneIdentifier
102
+ raise ArgumentError, refusal(type_name, zone)
103
+ rescue ::TZInfo::DataSourceNotFound
104
+ raise ArgumentError,
105
+ "zone: on #{type_name} names #{zone.inspect}, but no time zone data was found on this system. " \
106
+ 'Add the tzinfo-data gem to your Gemfile (or install the OS zoneinfo package).'
107
+ end
108
+
109
+ # @param zone [::String] an IANA name {.validate!} accepted
110
+ # @return [Object] the cached +TZInfo::Timezone+
111
+ # @raise [ArgumentError] for a name {.validate!} would refuse, when a
112
+ # type is called directly with one
113
+ def self.timezone(zone)
114
+ cached = @timezones && @timezones[zone]
115
+ return cached if cached
116
+ raise ArgumentError, refusal('the field', zone) unless zone.include?('/')
117
+
118
+ load_timezone('the field', zone)
119
+ end
120
+
121
+ # @param zone [::String] a declared +zone:+
122
+ # @return [::Integer, nil] seconds east of UTC, or +nil+ when +zone+ is
123
+ # not a value {.validate!} accepts
124
+ def self.offset_seconds(zone)
125
+ return 0 if zone == 'UTC'
126
+
127
+ match = OFFSET.match(zone)
128
+ return nil if match.nil?
129
+
130
+ hours = Integer(match[2], 10)
131
+ minutes = Integer(match[3], 10)
132
+ return nil if hours > 23 || minutes > 59
133
+
134
+ (match[1] == '-' ? -1 : 1) * ((hours * 3600) + (minutes * 60))
135
+ end
136
+
137
+ # Whether parsed fragments leave the offset to +zone:+.
138
+ #
139
+ # @param parts [::Hash{::Symbol => Object}] fragments from +Date._parse+
140
+ # or +Date._strptime+
141
+ # @return [Boolean] +true+ when the string named no offset at all
142
+ # @raise [ArgumentError] when the string names a zone the stdlib cannot
143
+ # turn into an offset (+"XYZ"+, +"America/Denver"+): +zone:+ never
144
+ # overrides a zone the input stated
145
+ def self.zoneless?(parts)
146
+ # An epoch (%s / %Q) is an instant already; {.time_at} keeps it and
147
+ # only places it at the zone's offset, as it does for any other input.
148
+ return false unless parts[:offset].nil?
149
+ return true if parts[:zone].nil?
150
+
151
+ # The input is not echoed — see Types::Time#coerce.
152
+ raise ArgumentError, 'names a time zone with no fixed offset, which zone: does not override'
153
+ end
154
+
155
+ # Read a zone-less String as a wall-clock time in +zone+.
156
+ #
157
+ # @param value [::String] a string {.zoneless?} accepted
158
+ # @param format [::String, nil] a resolved strptime format, or +nil+ for
159
+ # the no-format path (which has already required a whole day)
160
+ # @param zone [::String] a declared +zone:+
161
+ # @param parts [::Hash{::Symbol => Object}] the fragments the string
162
+ # parsed to; an epoch (+%s+ / +%Q+) shows up as +:seconds+
163
+ # @return [::Time] at +zone+'s offset (+utc?+ for +"UTC"+)
164
+ # @raise [ArgumentError] when +zone+ is a name and the wall-clock time
165
+ # falls in a gap the zone skips
166
+ def self.time_at(value, format, zone, parts)
167
+ offset = offset_seconds(zone)
168
+ return named_time_at(value, format, timezone(zone), parts) if offset.nil?
169
+
170
+ time = if format
171
+ # Appending the offset lets strptime do everything else as usual.
172
+ # Given an offset, it also fills a date the format omits from today
173
+ # *at that offset*, where plain strptime uses the process zone's
174
+ # today, and places an epoch (%s) at that offset.
175
+ ::Time.strptime("#{value} #{offset_text(offset)}", "#{format} %z")
176
+ else
177
+ # DateTime.parse reads the wall clock with no zone involved, so a
178
+ # time the process zone skips for DST (02:30 on a spring-forward day)
179
+ # is kept rather than moved, as Time.parse would.
180
+ wall = ::DateTime.parse(value)
181
+ ::Time.new(wall.year, wall.mon, wall.mday, wall.hour, wall.min, wall.sec + wall.sec_fraction, offset)
182
+ end
183
+ zone == 'UTC' ? time.utc : time
184
+ end
185
+
186
+ # {.time_at} for an IANA name: the offset depends on the date.
187
+ #
188
+ # @param value [::String] the zone-less string
189
+ # @param format [::String, nil] a resolved strptime format, or +nil+
190
+ # @param timezone [Object] a +TZInfo::Timezone+
191
+ # @param parts [::Hash{::Symbol => Object}] the parsed fragments
192
+ # @return [::Time]
193
+ def self.named_time_at(value, format, timezone, parts)
194
+ # An epoch is an instant already: keep it, at the offset of that instant.
195
+ return in_zone(::Time.strptime(value, format), timezone) if format && parts.key?(:seconds)
196
+
197
+ wall = if format
198
+ # The offset in force NOW only decides which day "today" is when
199
+ # the format omits the date; the wall clock is resolved below.
200
+ now = offset_text(timezone.period_for(::Time.now).observed_utc_offset)
201
+ ::Time.strptime("#{value} #{now}", "#{format} %z")
202
+ else
203
+ ::DateTime.parse(value)
204
+ end
205
+ sec = wall.sec + (wall.is_a?(::DateTime) ? wall.sec_fraction : wall.subsec)
206
+ local(timezone, [wall.year, wall.mon, wall.mday, wall.hour, wall.min, sec])
207
+ end
208
+
209
+ # A wall-clock time in a named zone.
210
+ #
211
+ # @param timezone [Object] a +TZInfo::Timezone+
212
+ # @param civil [::Array<::Numeric>] year, month, day, hour, minute and
213
+ # seconds (with any fraction)
214
+ # @return [::Time] at the offset in force at that wall-clock time
215
+ # @raise [ArgumentError] when the zone skips that wall-clock time
216
+ def self.local(timezone, civil)
217
+ # tzinfo reads the components and ignores the offset of the Time it is
218
+ # given. In an overlap, dst = true and the block pick what
219
+ # ActiveSupport's TimeZone#period_for_local picks.
220
+ period = timezone.period_for_local(::Time.utc(*civil), true, &:last)
221
+ ::Time.new(*civil, period.observed_utc_offset)
222
+ rescue ::TZInfo::PeriodNotFound
223
+ # The input is not echoed — see Types::Time#coerce. The zone is the
224
+ # declaration, not the value.
225
+ raise ArgumentError,
226
+ "names a local time that does not exist in #{timezone.identifier} (skipped by a daylight-saving change)"
227
+ end
228
+
229
+ # @param time [::Time] an instant
230
+ # @param timezone [Object] a +TZInfo::Timezone+
231
+ # @return [::Time] the same instant at the zone's offset at that instant
232
+ def self.in_zone(time, timezone)
233
+ time.getlocal(timezone.period_for(time).observed_utc_offset)
234
+ end
235
+
236
+ # @param value [::Date] a plain Date
237
+ # @param zone [::String] a declared +zone:+
238
+ # @return [::Time] midnight at +zone+'s offset — for a name, the first
239
+ # moment of that day
240
+ def self.midnight(value, zone)
241
+ offset = offset_seconds(zone)
242
+ return named_midnight(value, timezone(zone)) if offset.nil?
243
+
244
+ time = ::Time.new(value.year, value.mon, value.mday, 0, 0, 0, offset)
245
+ zone == 'UTC' ? time.utc : time
246
+ end
247
+
248
+ # @param value [::Date] a plain Date
249
+ # @param timezone [Object] a +TZInfo::Timezone+
250
+ # @return [::Time] the first moment of that day in the zone
251
+ def self.named_midnight(value, timezone)
252
+ local(timezone, [value.year, value.mon, value.mday, 0, 0, 0])
253
+ rescue ArgumentError
254
+ # The zone skips midnight: the day starts where the gap ends, the
255
+ # instant midnight would be at the offset in force just before it.
256
+ before = timezone.period_for_local(::Time.utc(value.year, value.mon, value.mday) - 1, true, &:last)
257
+ in_zone(::Time.utc(value.year, value.mon, value.mday) - before.observed_utc_offset, timezone)
258
+ end
259
+
260
+ # @param seconds [::Integer] seconds east of UTC
261
+ # @return [::String] +"+HH:MM"+ / +"-HH:MM"+
262
+ def self.offset_text(seconds)
263
+ format('%s%02d:%02d', seconds.negative? ? '-' : '+', seconds.abs / 3600, (seconds.abs % 3600) / 60)
264
+ end
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FieldStruct
4
+ # The gem's semantic version.
5
+ # @return [String]
6
+ VERSION = '0.15.0'
7
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oj'
4
+ require 'pathname'
5
+ require_relative 'field_struct/version'
6
+ require_relative 'field_struct/error'
7
+ require_relative 'field_struct/model_name'
8
+ require_relative 'field_struct/types/base'
9
+ require_relative 'field_struct/types/preset_resolver'
10
+ require_relative 'field_struct/types/temporal_parser'
11
+ require_relative 'field_struct/types/rfc3339_format'
12
+ require_relative 'field_struct/types/time_format_resolver'
13
+ require_relative 'field_struct/types/zone_option'
14
+ require_relative 'field_struct/types/string'
15
+ require_relative 'field_struct/types/immutable_string'
16
+ require_relative 'field_struct/types/integer'
17
+ require_relative 'field_struct/types/float'
18
+ require_relative 'field_struct/types/big_decimal'
19
+ require_relative 'field_struct/types/boolean'
20
+ require_relative 'field_struct/types/date'
21
+ require_relative 'field_struct/types/time'
22
+ require_relative 'field_struct/types/datetime'
23
+ require_relative 'field_struct/types/value'
24
+ require_relative 'field_struct/types/array'
25
+ require_relative 'field_struct/types/nested'
26
+ require_relative 'field_struct/types/symbol'
27
+ require_relative 'field_struct/types/uuid'
28
+ require_relative 'field_struct/types/url'
29
+ require_relative 'field_struct/types/email'
30
+ require_relative 'field_struct/types/union'
31
+ require_relative 'field_struct/types/binary'
32
+ require_relative 'field_struct/registry'
33
+ require_relative 'field_struct/field'
34
+ require_relative 'field_struct/metadata'
35
+ require_relative 'field_struct/errors'
36
+ require_relative 'field_struct/base'
37
+ require_relative 'field_struct/rbs'
38
+ require_relative 'field_struct/scaffold'
39
+
40
+ # Typed PORO foundation: declare fields with enforced types, presence checks,
41
+ # and per-field validation. Subclass {FieldStruct::Base} to get a value object
42
+ # whose attributes coerce, validate on assignment, and introspect cleanly.
43
+ module FieldStruct
44
+ # What {Base#inspect} renders in place of a +sensitive: true+ field's
45
+ # value (the placeholder ActiveSupport's parameter filter uses).
46
+ #
47
+ # @return [String]
48
+ FILTERED = '[FILTERED]'
49
+
50
+ # The library's root directory — the parent of +lib/+. Computed from this
51
+ # file's location, so it resolves to the repo root in development and to the
52
+ # installed gem directory when packaged.
53
+ #
54
+ # FieldStruct.root.join('lib', 'field_struct.rb') # => #<Pathname …>
55
+ #
56
+ # @return [Pathname] absolute path to the gem root
57
+ def self.root
58
+ @root ||= Pathname.new(File.expand_path('..', __dir__))
59
+ end
60
+
61
+ # Build a new {Registry}, parented to +parent+ (defaults to
62
+ # {FieldStruct.types}), and optionally configure it with a block
63
+ # evaluated in the new registry's instance scope.
64
+ #
65
+ # module Acme
66
+ # def self.field_types
67
+ # @field_types ||= FieldStruct.new_registry do
68
+ # register :money, Acme::Types::Money
69
+ # end
70
+ # end
71
+ # end
72
+ #
73
+ # Equivalent to the longhand:
74
+ #
75
+ # FieldStruct::Registry.new(FieldStruct.types).tap do |r|
76
+ # r.register :money, Acme::Types::Money
77
+ # end
78
+ #
79
+ # Pass +parent: nil+ for an unparented registry (no fallback chain).
80
+ #
81
+ # @param parent [Registry, nil] the parent registry; defaults to
82
+ # {FieldStruct.types}. Pass +nil+ explicitly for no parent.
83
+ # @yield evaluated in the new registry's instance scope, so methods
84
+ # like +register+ / +lookup+ / +key?+ work without an explicit receiver
85
+ # @return [Registry]
86
+ def self.new_registry(parent = types, &block)
87
+ registry = Registry.new(parent)
88
+ registry.instance_eval(&block) if block
89
+ registry
90
+ end
91
+
92
+ # The base type registry, seeded with every v1 scalar type and the
93
+ # +:decimal+ alias for +:big_decimal+. Namespace registries should be
94
+ # built with this as their parent (see {Registry}).
95
+ #
96
+ # @return [Registry]
97
+ def self.types
98
+ @types ||= Registry.new.tap do |r|
99
+ r.register :string, Types::String
100
+ r.register :immutable_string, Types::ImmutableString
101
+ r.register :integer, Types::Integer
102
+ r.register :float, Types::Float
103
+ r.register :big_decimal, Types::BigDecimal
104
+ r.register :decimal, :big_decimal
105
+ r.register :boolean, Types::Boolean
106
+ r.register :date, Types::Date
107
+ r.register :time, Types::Time
108
+ r.register :datetime, Types::DateTime
109
+ r.register :value, Types::Value
110
+ r.register :array, Types::Array
111
+ r.register :symbol, Types::Symbol
112
+ r.register :uuid, Types::UUID
113
+ r.register :url, Types::URL
114
+ r.register :email, Types::Email
115
+ r.register :union, Types::Union
116
+ r.register :binary, Types::Binary
117
+ end
118
+ end
119
+
120
+ # Opt-in short alias for the +FieldStruct+ module.
121
+ #
122
+ # Defines a top-level constant (default: +FS+) that points back to
123
+ # +FieldStruct+, *and* swaps the prefix used by every +#inspect+
124
+ # method in the library so IRB output reads with the short name too:
125
+ #
126
+ # FieldStruct.use_alias!
127
+ # FS::Base # => FieldStruct::Base
128
+ # User.metadata.inspect # => "#<FS::Metadata fields=[...]>"
129
+ #
130
+ # Off by default — the user has to ask for it. Idempotent. If the
131
+ # chosen constant is already defined to something other than
132
+ # FieldStruct, raises +NameError+ rather than clobbering.
133
+ #
134
+ # @param name [Symbol] the top-level constant to define (default +:FS+)
135
+ # @return [self] for chaining
136
+ # @raise [NameError] when the constant already points at something else
137
+ def self.use_alias!(name = :FS)
138
+ sym = name.to_sym
139
+ if Object.const_defined?(sym, false)
140
+ existing = Object.const_get(sym)
141
+ unless existing.equal?(self)
142
+ raise ::NameError, "::#{sym} is already defined (#{existing.inspect}); not aliasing FieldStruct"
143
+ end
144
+ else
145
+ Object.const_set(sym, self)
146
+ end
147
+ @short_alias = sym.to_s
148
+ self
149
+ end
150
+
151
+ # The current prefix that library +#inspect+ methods should render.
152
+ # Defaults to +"FieldStruct"+; flipped to the alias string (e.g.
153
+ # +"FS"+) by {.use_alias!}.
154
+ #
155
+ # @return [String]
156
+ def self.inspect_namespace
157
+ @short_alias || 'FieldStruct'
158
+ end
159
+ end