errgonomic 0.8.1 → 0.8.2

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: 4e2e41e94d099dc04ef7a03ce86060c8d991fb8df5ac0f0faf864e25e49082dd
4
+ data.tar.gz: 0de70e5eebd4319f18579a8bfb4c64597af07f8963d64cdb9ba357c5aec751e8
5
5
  SHA512:
6
- metadata.gz: 6423f7d1dbf8a9b0d0ee116f72f7dcd427da888f2213c5ea55ebb13fa7abcec83c63d1897f253a6f644094dabf194c9711f99565bedca9444126951ae715c39e
7
- data.tar.gz: 4d3329007ccb658b05cbb4103395133a2c355e4224f5b58850e3da399338ede381bcb0b4db3a2dcb08db54868405d8886e168b1e1b67435e484e9a94e2832f94
6
+ metadata.gz: 2a46187771e5ec92b4229dd4f89de9467358e73b2302400cf54093d12e3db5b303333aea1f3d1a21ddc88e4bfbcc5d205ca5913f84799d16ed706222376f1c9c
7
+ data.tar.gz: 0d72126a463307e8d40c1d9f4a905fbc4b0f52bb3a791fca7a523c8c124a974f935e3ab0f296ffa59722c869b85f1335bfea81b127ae34131af5a84389bdad2c
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. Two kinds of attribute stay unwrapped: those declared with `encrypts`, whose surrounding machinery reads the raw value, and those named by `errgonomic_optional_except`.
205
205
 
206
206
  ```ruby
207
207
  class Credential < ApplicationRecord
@@ -211,6 +211,44 @@ class Credential < ApplicationRecord
211
211
  encrypts :access_secret # also left unwrapped, declared either side of the include
212
212
  end
213
213
  ```
214
+
215
+ **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.
216
+
217
+ ```ruby
218
+ class Book < ApplicationRecord
219
+ include Errgonomic::Rails::ActiveRecordOptional
220
+
221
+ belongs_to :author, optional: true # Some(author) or None()
222
+ end
223
+ ```
224
+
225
+ On an application's own base class, the same include reaches every model below it, and no model mentions errgonomic again:
226
+
227
+ ```ruby
228
+ class ApplicationRecord < ActiveRecord::Base
229
+ primary_abstract_class
230
+ include Errgonomic::Rails::ActiveRecordOptional
231
+ end
232
+ ```
233
+
234
+ 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.
235
+
236
+ 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.
237
+
238
+ Two ways out, both readable in a model with no include of its own to point at:
239
+
240
+ ```ruby
241
+ class LegacyImport < ApplicationRecord
242
+ errgonomic_optional_off # this model keeps value-or-nil throughout
243
+ end
244
+
245
+ class Credential < ApplicationRecord
246
+ errgonomic_optional_except :legacy_token # this attribute does
247
+ end
248
+ ```
249
+
250
+ `Model.errgonomic_optionals` reports which readers a model wrapped, which is how to check that a conversion did what it meant to.
251
+
214
252
  - `delegate_optional :name, to: :association` (available on all models) delegates through an optional association, returning an Option instead of raising on nil.
215
253
 
216
254
  `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)
@@ -30,58 +30,135 @@ module Errgonomic
30
30
  extend ActiveSupport::Concern
31
31
 
32
32
  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
33
+ reflect_on_all_associations(:belongs_to)
34
+ .select { |r| r.options[:optional] }
35
+ .each { |r| errgonomic_wrap_optional(r.name) }
62
36
  end
63
37
 
64
38
  class_methods do
39
+ # What a model wrapped is the signal that a conversion did what it
40
+ # meant to, and the columns are not wrapped until the schema loads, so
41
+ # asking loads it.
65
42
  def errgonomic_optionals
66
- @errgonomic_optionals
43
+ load_schema
44
+ errgonomic_optional_names
45
+ end
46
+
47
+ # The set as it stands, for the wrapping itself: reaching for the
48
+ # schema from here would ask the schema to load while it is loading.
49
+ def errgonomic_optional_names
50
+ @errgonomic_optional_names ||= []
51
+ end
52
+
53
+ # Read when a reader is about to be wrapped rather than snapshotted at
54
+ # include time, so an exclusion works on either side of the include.
55
+ # That is what an include on a base class needs: there is no "before"
56
+ # for a model to declare anything in.
57
+ def errgonomic_optional_exclusions
58
+ inherited = if superclass.respond_to?(:errgonomic_optional_exclusions)
59
+ superclass.errgonomic_optional_exclusions
60
+ else
61
+ []
62
+ end
63
+
64
+ inherited | Array(encrypted_attributes).map(&:to_s) | Array(try(:errgonomic_optional_exceptions)).map(&:to_s)
65
+ end
66
+
67
+ # A model that keeps value-or-nil throughout, for whatever the
68
+ # application knows about it that the concern does not. Where the
69
+ # concern is included on a base class, this is how a model leaves.
70
+ def errgonomic_optional_off
71
+ @errgonomic_optional_off = true
72
+ errgonomic_unwrap_optionals(*errgonomic_optional_names.dup)
73
+ end
74
+
75
+ def errgonomic_optional_off?
76
+ return true if defined?(@errgonomic_optional_off) && @errgonomic_optional_off
77
+
78
+ superclass.respond_to?(:errgonomic_optional_off?) && superclass.errgonomic_optional_off?
79
+ end
80
+
81
+ # A reader wrapped by an ancestor is already an Option; a subclass
82
+ # that wrapped it again would nest it.
83
+ def errgonomic_optional?(name)
84
+ return true if errgonomic_optional_names.include?(name)
85
+
86
+ superclass.respond_to?(:errgonomic_optional?) && superclass.errgonomic_optional?(name)
87
+ end
88
+
89
+ # ActiveRecord defines its attribute methods the first time a model
90
+ # needs its schema, not when the class body runs. Wrapping nullable
91
+ # columns from the same seam keeps a database out of class loading.
92
+ def load_schema!
93
+ super
94
+ errgonomic_wrap_nullable_columns
95
+ end
96
+
97
+ # A subclass loads its own schema, so whichever of the two is touched
98
+ # first wraps the shared columns first, and a subclass that got there
99
+ # first would wrap its parent's readers a second time. Walk the chain
100
+ # from the top down instead, so an ancestor's readers always exist
101
+ # before a subclass considers the same name.
102
+ def errgonomic_wrap_nullable_columns
103
+ superclass.errgonomic_wrap_nullable_columns if superclass.respond_to?(:errgonomic_wrap_nullable_columns)
104
+ # An abstract class has no table, and asking one for its columns
105
+ # raises. The concern belongs on an abstract class all the same: that
106
+ # is where an application puts behaviour every model should have.
107
+ return if abstract_class? || table_name.nil?
108
+
109
+ column_names.each { |name| errgonomic_wrap_optional(name) if column_for_attribute(name).null }
110
+ end
111
+
112
+ # A concern belongs at the top of a model, above its associations, so
113
+ # an optional belongs_to is routinely declared after the include.
114
+ # Wrap it when it arrives, or the conversion is silently partial.
115
+ def belongs_to(name, scope = nil, **options)
116
+ super.tap { errgonomic_wrap_optional(name) if options[:optional] }
67
117
  end
68
118
 
69
119
  # Encryption surrounds an attribute with machinery that reads the raw
70
120
  # value, including a length validator that calls to_s on it, so a
71
121
  # 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.
122
+ # after the include is the ordinary spelling, and the exclusion is read
123
+ # from ActiveRecord's own register when a reader is about to be
124
+ # wrapped, so this only has to take back a reader already wrapped.
74
125
  def encrypts(*names, **options)
75
126
  super.tap { errgonomic_unwrap_optionals(*names) }
76
127
  end
77
128
 
78
129
  def errgonomic_unwrap_optionals(*names)
79
130
  names.map(&:to_s).each do |name|
80
- next unless @errgonomic_optionals&.delete(name)
131
+ next unless errgonomic_optional_names.delete(name)
81
132
 
82
133
  remove_method(name)
83
134
  end
84
135
  end
136
+
137
+ def errgonomic_wrap_optional(name)
138
+ name = name.to_s
139
+ return if errgonomic_optional_off?
140
+ return if errgonomic_optional_exclusions.include?(name) || errgonomic_optional?(name)
141
+
142
+ errgonomic_optional_names << name
143
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
144
+ def #{name}
145
+ reads = Thread.current[:errgonomic_optional_reads] ||= {}
146
+ key = [object_id, :#{name}]
147
+ if reads[key]
148
+ raise Errgonomic::RecursiveOptionalReadError,
149
+ "\#{self.class}##{name} re-entered itself; something beneath this reader reads it again"
150
+ end
151
+
152
+ reads[key] = true
153
+ begin
154
+ val = super
155
+ ensure
156
+ reads.delete(key)
157
+ end
158
+ val.nil? ? Errgonomic::Option::None.new : Errgonomic::Option::Some.new(val)
159
+ end
160
+ RUBY
161
+ end
85
162
  end
86
163
  end
87
164
  end
@@ -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.2'
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Zadrozny