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 +4 -4
- data/.rubocop.yml +21 -0
- data/README.md +41 -3
- data/lib/errgonomic/option.rb +9 -0
- data/lib/errgonomic/rails/active_record_delegate_optional.rb +9 -4
- data/lib/errgonomic/rails/active_record_optional.rb +110 -33
- data/lib/errgonomic/result.rb +9 -0
- data/lib/errgonomic/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4e2e41e94d099dc04ef7a03ce86060c8d991fb8df5ac0f0faf864e25e49082dd
|
|
4
|
+
data.tar.gz: 0de70e5eebd4319f18579a8bfb4c64597af07f8963d64cdb9ba357c5aec751e8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 `
|
|
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 `
|
|
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
|
|
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()`).
|
data/lib/errgonomic/option.rb
CHANGED
|
@@ -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
|
-
#
|
|
14
|
-
#
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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,
|
|
73
|
-
#
|
|
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
|
|
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
|
data/lib/errgonomic/result.rb
CHANGED
|
@@ -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)
|
data/lib/errgonomic/version.rb
CHANGED