errgonomic 0.8.1 → 0.8.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c579e542517b446e381c09ef98e24237e8ba5c02ca19633d762195d9bcab6a1d
4
- data.tar.gz: aa72fb5647fbd9f335bde9b0d10e6a07b68359a7581c4f9152fbf59958d54f43
3
+ metadata.gz: 6cfa38b0fc666fade921ea48c1301cc51643175a59e82b4f2c50ac3a471e6696
4
+ data.tar.gz: 8294913794e216f90443e3396753a537d1fbbede4bf6e5eab459bdea51691e6e
5
5
  SHA512:
6
- metadata.gz: 6423f7d1dbf8a9b0d0ee116f72f7dcd427da888f2213c5ea55ebb13fa7abcec83c63d1897f253a6f644094dabf194c9711f99565bedca9444126951ae715c39e
7
- data.tar.gz: 4d3329007ccb658b05cbb4103395133a2c355e4224f5b58850e3da399338ede381bcb0b4db3a2dcb08db54868405d8886e168b1e1b67435e484e9a94e2832f94
6
+ metadata.gz: 395971235a600af1c734db4dddb9aa8363e63d76ce65175ccb40c358e67aeb48e16379b0682177c42ec4f5d90a50fa14a55f7e7b994b0cd8ffe88dda7f89e688
7
+ data.tar.gz: d02c232ea2f79a5a3a04ce3913c1e9c07909655da06b11a5654699be7e371f5a5f8345b5efb9a3be8b40e5de667280b59c49dd2ba9541555faef7ccce8b1ca19
data/.rubocop.yml CHANGED
@@ -18,6 +18,7 @@ Metrics/ClassLength:
18
18
  Exclude:
19
19
  - lib/errgonomic/option.rb
20
20
  - lib/errgonomic/result.rb
21
+ - test/**/*
21
22
 
22
23
  # core_ext vendors ActiveSupport's blank?/present? patches; the reopened core
23
24
  # classes there are explained by the file header, not per class. Test
@@ -26,3 +27,23 @@ Style/Documentation:
26
27
  Exclude:
27
28
  - lib/errgonomic/core_ext/**/*
28
29
  - test/**/*
30
+
31
+ # A test states one behavior end to end, and splitting one to satisfy a size
32
+ # metric hides the behavior it was written to name. A cop that names its own
33
+ # Exclude replaces this one, so Metrics/ClassLength repeats the path above.
34
+ Metrics:
35
+ Exclude:
36
+ - test/**/*
37
+
38
+ # The ActiveRecord hooks in the optional concern are one subject — where a
39
+ # reader may come from and what leaves it alone — and reading them together is
40
+ # the point. The generated reader is a heredoc, which the length cops count as
41
+ # if it were code.
42
+ Metrics/BlockLength:
43
+ Exclude:
44
+ - lib/errgonomic/rails/active_record_optional.rb
45
+ - test/**/*
46
+ Metrics/MethodLength:
47
+ Exclude:
48
+ - lib/errgonomic/rails/active_record_optional.rb
49
+ - test/**/*
data/README.md CHANGED
@@ -107,7 +107,7 @@ in Errgonomic::Option::None
107
107
  end
108
108
  ```
109
109
 
110
- An unhandled Option refuses to leak into your output: `to_s` and `to_json` raise `Errgonomic::SerializeError`, so you handle the inner value deliberately rather than shipping `#<Errgonomic::Option::Some...>` to a user.
110
+ An unhandled Option refuses to leak into your output: `to_s`, `to_json`, and `as_json` raise `Errgonomic::SerializeError`, so you handle the inner value deliberately rather than shipping `#<Errgonomic::Option::Some...>` to a user. The refusal covers `as_json` because Hash and Array serialization recurses through that method, and an Option nested in a payload would otherwise serialize as `{"value": ...}`.
111
111
 
112
112
  `unwrap!` and `expect!` are for tests and consoles, not application code: they raise on `None`, which is exactly the ambiguous failure the type exists to prevent. Application code should always have a combinator or pattern match that handles the `None` branch explicitly; if none fits, that is a gap worth an issue rather than a reason to unwrap.
113
113
 
@@ -148,7 +148,7 @@ in Errgonomic::Result::Err, Exception => e
148
148
  end
149
149
  ```
150
150
 
151
- Like Options, unwrapped Results refuse `to_s` and `to_json`. And `Object#result?` / `Object#assert_result!` help enforce at runtime that a value is a Result.
151
+ Like Options, unwrapped Results refuse `to_s`, `to_json`, and `as_json`. And `Object#result?` / `Object#assert_result!` help enforce at runtime that a value is a Result.
152
152
 
153
153
  ### Optional collections
154
154
 
@@ -201,7 +201,7 @@ end
201
201
 
202
202
  When `Rails::Railtie` is defined, Errgonomic installs a Railtie with two opt-in integrations for ActiveRecord:
203
203
 
204
- - `include Errgonomic::Rails::ActiveRecordOptional` in a model makes its nullable attributes and `optional: true` associations return `Some(value)` or `None()` instead of a value-or-nil. Every nullable column and optional association is wrapped, with no per-attribute opt-in. Two kinds of attribute stay unwrapped: those declared with `encrypts`, whose surrounding machinery reads the raw value, and those named by `errgonomic_optional_except`, which must appear before the include.
204
+ - `include Errgonomic::Rails::ActiveRecordOptional` in a model makes its nullable attributes and `optional: true` associations return `Some(value)` or `None()` instead of a value-or-nil. Every nullable column and optional association is wrapped, with no per-attribute opt-in. Three kinds of reader stay unwrapped: attributes declared with `encrypts` and singular associations with `accepts_nested_attributes_for`, both of which ActiveRecord's own machinery reads raw, and anything named by `errgonomic_optional_except`.
205
205
 
206
206
  ```ruby
207
207
  class Credential < ApplicationRecord
@@ -209,8 +209,48 @@ class Credential < ApplicationRecord
209
209
  include Errgonomic::Rails::ActiveRecordOptional
210
210
 
211
211
  encrypts :access_secret # also left unwrapped, declared either side of the include
212
+ has_one :rotation_schedule # wrapped: Some(schedule) or None()
213
+ has_one :owner, required: true # left unwrapped: absence is a validation failure
212
214
  end
213
215
  ```
216
+
217
+ **Where the include goes.** A model that includes the concern converts itself, and only itself. The include may sit at the top of the model with the other concerns, which is where Rails convention puts one. An `optional: true` association declared below it is wrapped as it is declared, rather than only the associations the class happened to declare above it.
218
+
219
+ ```ruby
220
+ class Book < ApplicationRecord
221
+ include Errgonomic::Rails::ActiveRecordOptional
222
+
223
+ belongs_to :author, optional: true # Some(author) or None()
224
+ end
225
+ ```
226
+
227
+ On an application's own base class, the same include reaches every model below it, and no model mentions errgonomic again:
228
+
229
+ ```ruby
230
+ class ApplicationRecord < ActiveRecord::Base
231
+ primary_abstract_class
232
+ include Errgonomic::Rails::ActiveRecordOptional
233
+ end
234
+ ```
235
+
236
+ Converting one model or all of them is therefore where the include goes, not a setting to choose. The association macros wrap as each model declares them, and a model's nullable columns are wrapped when ActiveRecord loads its schema, so no class body needs a database while it loads.
237
+
238
+ An application's own base class is the useful place for it. Engine and gem models such as `ActiveStorage::Blob` and `PaperTrail::Version` descend straight from `ActiveRecord::Base`, and their own code reads their attributes knowing nothing about an Option. Including it on `ActiveRecord::Base` reaches those too, which is rarely what anyone wants.
239
+
240
+ Two ways out, both readable in a model with no include of its own to point at:
241
+
242
+ ```ruby
243
+ class LegacyImport < ApplicationRecord
244
+ errgonomic_optional_off # this model keeps value-or-nil throughout
245
+ end
246
+
247
+ class Credential < ApplicationRecord
248
+ errgonomic_optional_except :legacy_token # this attribute does
249
+ end
250
+ ```
251
+
252
+ `Model.errgonomic_optionals` reports which readers a model wrapped, which is how to check that a conversion did what it meant to.
253
+
214
254
  - `delegate_optional :name, to: :association` (available on all models) delegates through an optional association, returning an Option instead of raising on nil.
215
255
 
216
256
  `Object#to_option` is also available in Rails to lift any value into an Option (`nil.to_option # => None()`).
@@ -556,6 +556,15 @@ module Errgonomic
556
556
  raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Option'
557
557
  end
558
558
 
559
+ # ActiveSupport's Hash#as_json and Array#as_json recurse through their
560
+ # members with as_json rather than to_json, so an Option nested in a
561
+ # payload reaches Object#as_json and serializes as its instance
562
+ # variables. Refuse there too, and the guard holds wherever an Option
563
+ # travels.
564
+ def as_json(*_args)
565
+ raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Option'
566
+ end
567
+
559
568
  # pp uses its own object dump unless told otherwise; keep it consistent
560
569
  # with inspect.
561
570
  def pretty_print(pp)
@@ -9,15 +9,20 @@ module Errgonomic
9
9
  extend ActiveSupport::Concern
10
10
 
11
11
  class_methods do
12
- # Names attributes that ActiveRecordOptional must leave alone. It has
13
- # to be callable before the include, which is what computes the
14
- # wrapped set, so it lives here rather than in the concern itself.
12
+ # Names attributes that ActiveRecordOptional must leave alone. It has to
13
+ # be callable before the include, which is what starts the wrapping for
14
+ # a model that converts itself, so it lives here rather than in the
15
+ # concern. Where the concern is included on a base class there is no
16
+ # before, so it also takes back a reader already wrapped.
15
17
  def errgonomic_optional_except(*names)
16
18
  @errgonomic_optional_exceptions = errgonomic_optional_exceptions + names.map(&:to_s)
19
+ errgonomic_unwrap_optionals(*names) if respond_to?(:errgonomic_unwrap_optionals)
20
+ @errgonomic_optional_exceptions
17
21
  end
18
22
 
19
23
  def errgonomic_optional_exceptions
20
- @errgonomic_optional_exceptions ||= []
24
+ @errgonomic_optional_exceptions ||=
25
+ superclass.respond_to?(:errgonomic_optional_exceptions) ? superclass.errgonomic_optional_exceptions.dup : []
21
26
  end
22
27
 
23
28
  def delegate_optional(*methods, to: nil, prefix: nil, private: nil)
@@ -19,9 +19,11 @@ module Errgonomic
19
19
  # boundary, so an Option can be passed to where/quote.
20
20
  # 4. SomeValidator provides a presence-style validation for Option
21
21
  # attributes.
22
- # 5. Attributes declared with encrypts are never wrapped: ActiveRecord
23
- # Encryption registers a length validator outside Model.validators
24
- # that reads the raw value and cannot survive an Option.
22
+ # 5. Readers that ActiveRecord's own machinery reads raw are never
23
+ # wrapped: an attribute declared with encrypts, whose length validator
24
+ # sits outside Model.validators and calls to_s on the value, and a
25
+ # singular association with nested attributes, which are assigned
26
+ # through the reader and ask the value whether it is a new record.
25
27
  #
26
28
  # errgonomic_optional_except is not on the list: it is configuration, an
27
29
  # escape hatch for whatever conflict shows up next, not a semantic
@@ -30,58 +32,167 @@ module Errgonomic
30
32
  extend ActiveSupport::Concern
31
33
 
32
34
  included do
33
- # ::Rails.logger.debug('ActiveRecordOptional')
34
- optional_associations = reflect_on_all_associations(:belongs_to)
35
- .select { |r| r.options[:optional] }
36
- .map(&:name)
37
- excluded = Array(encrypted_attributes).map(&:to_s) + Array(try(:errgonomic_optional_exceptions))
38
- optional_attributes = column_names
39
- .select { |n| column_for_attribute(n).null }
40
- .reject { |n| excluded.include?(n) }
41
- @errgonomic_optionals = (optional_attributes + optional_associations)
42
- @errgonomic_optionals.each do |name|
43
- class_eval <<-RUBY, __FILE__, __LINE__ + 1
44
- def #{name}
45
- reads = Thread.current[:errgonomic_optional_reads] ||= {}
46
- key = [object_id, :#{name}]
47
- if reads[key]
48
- raise Errgonomic::RecursiveOptionalReadError,
49
- "\#{self.class}##{name} re-entered itself; something beneath this reader reads it again"
50
- end
51
-
52
- reads[key] = true
53
- begin
54
- val = super
55
- ensure
56
- reads.delete(key)
57
- end
58
- val.nil? ? Errgonomic::Option::None.new : Errgonomic::Option::Some.new(val)
59
- end
60
- RUBY
61
- end
35
+ reflect_on_all_associations(:belongs_to)
36
+ .select { |r| r.options[:optional] }
37
+ .each { |r| errgonomic_wrap_optional(r.name) }
38
+ reflect_on_all_associations(:has_one)
39
+ .reject { |r| r.options[:required] }
40
+ .each { |r| errgonomic_wrap_optional(r.name) }
62
41
  end
63
42
 
64
43
  class_methods do
44
+ # What a model wrapped is the signal that a conversion did what it
45
+ # meant to, and the columns are not wrapped until the schema loads, so
46
+ # asking loads it.
65
47
  def errgonomic_optionals
66
- @errgonomic_optionals
48
+ load_schema
49
+ errgonomic_optional_names
50
+ end
51
+
52
+ # The set as it stands, for the wrapping itself: reaching for the
53
+ # schema from here would ask the schema to load while it is loading.
54
+ def errgonomic_optional_names
55
+ @errgonomic_optional_names ||= []
56
+ end
57
+
58
+ # Read when a reader is about to be wrapped rather than snapshotted at
59
+ # include time, so an exclusion works on either side of the include.
60
+ # That is what an include on a base class needs: there is no "before"
61
+ # for a model to declare anything in.
62
+ def errgonomic_optional_exclusions
63
+ inherited = if superclass.respond_to?(:errgonomic_optional_exclusions)
64
+ superclass.errgonomic_optional_exclusions
65
+ else
66
+ []
67
+ end
68
+
69
+ inherited |
70
+ Array(encrypted_attributes).map(&:to_s) |
71
+ Array(try(:errgonomic_optional_exceptions)).map(&:to_s) |
72
+ errgonomic_nested_attribute_associations
73
+ end
74
+
75
+ # A model that keeps value-or-nil throughout, for whatever the
76
+ # application knows about it that the concern does not. Where the
77
+ # concern is included on a base class, this is how a model leaves.
78
+ def errgonomic_optional_off
79
+ @errgonomic_optional_off = true
80
+ errgonomic_unwrap_optionals(*errgonomic_optional_names.dup)
81
+ end
82
+
83
+ def errgonomic_optional_off?
84
+ return true if defined?(@errgonomic_optional_off) && @errgonomic_optional_off
85
+
86
+ superclass.respond_to?(:errgonomic_optional_off?) && superclass.errgonomic_optional_off?
87
+ end
88
+
89
+ # A reader wrapped by an ancestor is already an Option; a subclass
90
+ # that wrapped it again would nest it.
91
+ def errgonomic_optional?(name)
92
+ return true if errgonomic_optional_names.include?(name)
93
+
94
+ superclass.respond_to?(:errgonomic_optional?) && superclass.errgonomic_optional?(name)
95
+ end
96
+
97
+ # ActiveRecord defines its attribute methods the first time a model
98
+ # needs its schema, not when the class body runs. Wrapping nullable
99
+ # columns from the same seam keeps a database out of class loading.
100
+ def load_schema!
101
+ super
102
+ errgonomic_wrap_nullable_columns
103
+ end
104
+
105
+ # A subclass loads its own schema, so whichever of the two is touched
106
+ # first wraps the shared columns first, and a subclass that got there
107
+ # first would wrap its parent's readers a second time. Walk the chain
108
+ # from the top down instead, so an ancestor's readers always exist
109
+ # before a subclass considers the same name.
110
+ def errgonomic_wrap_nullable_columns
111
+ superclass.errgonomic_wrap_nullable_columns if superclass.respond_to?(:errgonomic_wrap_nullable_columns)
112
+ # An abstract class has no table, and asking one for its columns
113
+ # raises. The concern belongs on an abstract class all the same: that
114
+ # is where an application puts behaviour every model should have.
115
+ return if abstract_class? || table_name.nil?
116
+
117
+ column_names.each { |name| errgonomic_wrap_optional(name) if column_for_attribute(name).null }
118
+ end
119
+
120
+ # A concern belongs at the top of a model, above its associations, so
121
+ # an optional belongs_to is routinely declared after the include.
122
+ # Wrap it when it arrives, or the conversion is silently partial.
123
+ def belongs_to(name, scope = nil, **options)
124
+ super.tap { errgonomic_wrap_optional(name) if options[:optional] }
125
+ end
126
+
127
+ # A has_one is absent whenever no row points back at the record, so
128
+ # its reader carries the same absence a nullable column does.
129
+ # required: true is the exception: it asserts the record is there, and
130
+ # absence is a validation failure rather than a value to handle.
131
+ def has_one(name, scope = nil, **options)
132
+ super.tap { errgonomic_wrap_optional(name) unless options[:required] }
133
+ end
134
+
135
+ # Nested attributes are assigned through the public reader, and
136
+ # ActiveRecord asks whatever it finds there whether it is a new
137
+ # record. An absent association has to arrive as nil for that, so a
138
+ # singular association with nested attributes keeps its plain reader.
139
+ def accepts_nested_attributes_for(*names, **options)
140
+ super.tap { errgonomic_unwrap_optionals(*names) }
141
+ end
142
+
143
+ # ActiveRecord keeps its own register of these, so the exclusion can be
144
+ # read from there rather than recorded as it goes past.
145
+ def errgonomic_nested_attribute_associations
146
+ return [] unless respond_to?(:nested_attributes_options)
147
+
148
+ nested_attributes_options.keys.map(&:to_s).select do |name|
149
+ %i[has_one belongs_to].include?(reflect_on_association(name)&.macro)
150
+ end
67
151
  end
68
152
 
69
153
  # Encryption surrounds an attribute with machinery that reads the raw
70
154
  # value, including a length validator that calls to_s on it, so a
71
155
  # wrapped encrypted attribute cannot be saved. Declaring encrypts
72
- # after the include is the ordinary spelling, so catch it here too and
73
- # give the attribute its plain reader back.
156
+ # after the include is the ordinary spelling, and the exclusion is read
157
+ # from ActiveRecord's own register when a reader is about to be
158
+ # wrapped, so this only has to take back a reader already wrapped.
74
159
  def encrypts(*names, **options)
75
160
  super.tap { errgonomic_unwrap_optionals(*names) }
76
161
  end
77
162
 
78
163
  def errgonomic_unwrap_optionals(*names)
79
164
  names.map(&:to_s).each do |name|
80
- next unless @errgonomic_optionals&.delete(name)
165
+ next unless errgonomic_optional_names.delete(name)
81
166
 
82
167
  remove_method(name)
83
168
  end
84
169
  end
170
+
171
+ def errgonomic_wrap_optional(name)
172
+ name = name.to_s
173
+ return if errgonomic_optional_off?
174
+ return if errgonomic_optional_exclusions.include?(name) || errgonomic_optional?(name)
175
+
176
+ errgonomic_optional_names << name
177
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
178
+ def #{name}
179
+ reads = Thread.current[:errgonomic_optional_reads] ||= {}
180
+ key = [object_id, :#{name}]
181
+ if reads[key]
182
+ raise Errgonomic::RecursiveOptionalReadError,
183
+ "\#{self.class}##{name} re-entered itself; something beneath this reader reads it again"
184
+ end
185
+
186
+ reads[key] = true
187
+ begin
188
+ val = super
189
+ ensure
190
+ reads.delete(key)
191
+ end
192
+ val.nil? ? Errgonomic::Option::None.new : Errgonomic::Option::Some.new(val)
193
+ end
194
+ RUBY
195
+ end
85
196
  end
86
197
  end
87
198
  end
@@ -146,6 +257,18 @@ class Object
146
257
  end
147
258
  end
148
259
 
260
+ module Errgonomic
261
+ module Option
262
+ # An Option is already lifted. Lifting it again would nest it, and the
263
+ # nesting is invisible until something reaches for the inner value.
264
+ class Any
265
+ def to_option
266
+ self
267
+ end
268
+ end
269
+ end
270
+ end
271
+
149
272
  module Errgonomic
150
273
  module Rails
151
274
  # Teach ActiveRecord SQL quoting to unwrap Options, quoting a None as
@@ -369,6 +369,15 @@ module Errgonomic
369
369
  raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Result'
370
370
  end
371
371
 
372
+ # ActiveSupport's Hash#as_json and Array#as_json recurse through their
373
+ # members with as_json rather than to_json, so a Result nested in a
374
+ # payload reaches Object#as_json and serializes as its instance
375
+ # variables. Refuse there too, and the guard holds wherever a Result
376
+ # travels.
377
+ def as_json(*_args)
378
+ raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Result'
379
+ end
380
+
372
381
  # pp uses its own object dump unless told otherwise; keep it consistent
373
382
  # with inspect.
374
383
  def pretty_print(pp)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Errgonomic
4
- VERSION = '0.8.1'
4
+ VERSION = '0.8.3'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: errgonomic
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.8.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Zadrozny