errgonomic 0.8.0 → 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 +56 -6
- data/lib/errgonomic/option.rb +132 -0
- data/lib/errgonomic/rails/active_record_delegate_optional.rb +16 -0
- data/lib/errgonomic/rails/active_record_optional.rb +151 -18
- data/lib/errgonomic/result.rb +9 -0
- data/lib/errgonomic/version.rb +1 -1
- metadata +2 -2
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,12 +107,14 @@ 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
|
|
|
114
114
|
Presence follows the discriminant, as in Rust: `Some` is `present?` and `None` is `blank?`, regardless of the wrapped value. So `Some(false).present?` and `Some(nil).present?` are both `true`. If you care about the inner value's own presence, unwrap it first.
|
|
115
115
|
|
|
116
|
+
The presence helpers are soft-deprecated on Options in favor of the combinators. The present side unwraps, where on any other object it returns the receiver — `Some(v).present_or_raise!(msg)`, `present_or(default)`, `present_or_else { }`, and `presence` all yield `v`, and `None` raises, substitutes, or answers `nil` — and each call prints a one-line stderr nudge naming the combinator to use instead (`expect!`, `unwrap_or`, `unwrap_or_else`, `unwrap_or(nil)`). The blank side (`blank_or*`) raises `UnwrappedAccessError` outright: an Option's blankness is its discriminant, so test it with `none?`.
|
|
117
|
+
|
|
116
118
|
Equality is between Options only: `Some(5) == Some(5)`, but `Some(5) == 5` and `None() == nil` are `false`. That is quiet, never an error, matching how every Ruby object compares across types. Rust rejects `Some(5) == 5` at compile time; Ruby cannot, so guard the idiom in review and tests: compare against a wrapped value (`opt == Some(5)`) or test the inner value (`opt.some_and? { |v| v == 5 }`).
|
|
117
119
|
|
|
118
120
|
### Result
|
|
@@ -146,7 +148,7 @@ in Errgonomic::Result::Err, Exception => e
|
|
|
146
148
|
end
|
|
147
149
|
```
|
|
148
150
|
|
|
149
|
-
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.
|
|
150
152
|
|
|
151
153
|
### Optional collections
|
|
152
154
|
|
|
@@ -199,21 +201,69 @@ end
|
|
|
199
201
|
|
|
200
202
|
When `Rails::Railtie` is defined, Errgonomic installs a Railtie with two opt-in integrations for ActiveRecord:
|
|
201
203
|
|
|
202
|
-
- `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.
|
|
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
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
class Credential < ApplicationRecord
|
|
208
|
+
errgonomic_optional_except :legacy_token
|
|
209
|
+
include Errgonomic::Rails::ActiveRecordOptional
|
|
210
|
+
|
|
211
|
+
encrypts :access_secret # also left unwrapped, declared either side of the include
|
|
212
|
+
end
|
|
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
|
+
|
|
203
252
|
- `delegate_optional :name, to: :association` (available on all models) delegates through an optional association, returning an Option instead of raising on nil.
|
|
204
253
|
|
|
205
254
|
`Object#to_option` is also available in Rails to lift any value into an Option (`nil.to_option # => None()`).
|
|
206
255
|
|
|
207
256
|
#### ActiveRecord compromises
|
|
208
257
|
|
|
209
|
-
ActiveRecord assumes things about accessors that a strict Rust Option cannot satisfy, so the integration carries
|
|
258
|
+
ActiveRecord assumes things about accessors that a strict Rust Option cannot satisfy, so the integration carries five deliberate compromises. Everywhere else, treat a departure from Rust's `Option` semantics as a bug; these five are intended:
|
|
210
259
|
|
|
211
260
|
1. `None#nil?` answers `true`, so ActiveRecord internals and ordinary `.nil?` checks treat an absent value as absent. Equality does not follow suit: `None() == nil` is still `false`.
|
|
212
261
|
2. `Some` delegates `persisted?`, `marked_for_destruction?`, and `touch_later` to its record, so a `Some` can stand in for its record during persistence.
|
|
213
|
-
3. Quoting
|
|
262
|
+
3. Quoting and the predicate builder are patched so an `Option` passed into `where`/`quote` is unwrapped at the SQL boundary: `Some(v)` binds exactly as `v`, and `None()` as `nil`, so a hash condition asks for `IS NULL`. An array of Options unwraps too. An Option interpolated into raw SQL (`where("id = ?", opt)`) still raises, as it should.
|
|
214
263
|
4. `SomeValidator` provides a presence-style validation for Option attributes.
|
|
264
|
+
5. Attributes declared with `encrypts` are never wrapped: ActiveRecord Encryption's own machinery (a length validator it registers outside `Model.validators`) reads the raw value and cannot survive an Option.
|
|
215
265
|
|
|
216
|
-
The set is closed. If a future integration appears to need a
|
|
266
|
+
The set is closed. If a future integration appears to need a sixth compromise, that is a signal ActiveRecord is pushing back somewhere unmapped, and it warrants a design discussion rather than a quiet patch. `errgonomic_optional_except` is deliberately not on the list: it is configuration, an escape hatch that softens the all-or-nothing include for whatever conflict shows up next, rather than a semantic exception.
|
|
217
267
|
|
|
218
268
|
## Development
|
|
219
269
|
|
data/lib/errgonomic/option.rb
CHANGED
|
@@ -194,6 +194,114 @@ module Errgonomic
|
|
|
194
194
|
none?
|
|
195
195
|
end
|
|
196
196
|
|
|
197
|
+
# The presence helpers on Object keep their receiver; on an Option that
|
|
198
|
+
# would hand back the wrapper where the caller asked for a value. Here
|
|
199
|
+
# the present side unwraps instead, so a name that reads like an
|
|
200
|
+
# accessor behaves like one. The whole family is soft-deprecated on
|
|
201
|
+
# Options in favor of the combinators, so each call nudges via stderr,
|
|
202
|
+
# and the blank side, which has no working call sites to preserve,
|
|
203
|
+
# teaches rather than guesses at semantics.
|
|
204
|
+
|
|
205
|
+
# Returns the inner value of a Some, and raises on a None. Presence
|
|
206
|
+
# follows the discriminant, so Some(nil) yields nil.
|
|
207
|
+
#
|
|
208
|
+
# @param message [String] The error message to raise on a None.
|
|
209
|
+
# @return [Object] The inner value of a Some.
|
|
210
|
+
#
|
|
211
|
+
# @example
|
|
212
|
+
# Some("secret").present_or_raise!("no secret") # => "secret"
|
|
213
|
+
# Some(nil).present_or_raise!("no secret") # => nil
|
|
214
|
+
# None().present_or_raise!("no secret") # => raise Errgonomic::NotPresentError, "no secret"
|
|
215
|
+
def present_or_raise!(message)
|
|
216
|
+
presence_nudge('present_or_raise', 'expect!')
|
|
217
|
+
raise Errgonomic::NotPresentError, message if none?
|
|
218
|
+
|
|
219
|
+
value
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
alias present_or_raise present_or_raise!
|
|
223
|
+
|
|
224
|
+
# Returns the inner value of a Some, and the given default on a None.
|
|
225
|
+
# No pedantic type check on the default: this family is deprecated on
|
|
226
|
+
# Options, and unwrap_or, which the nudge points to, has none either.
|
|
227
|
+
#
|
|
228
|
+
# @param default [Object] The value to return on a None.
|
|
229
|
+
# @return [Object] The inner value of a Some, otherwise the default.
|
|
230
|
+
#
|
|
231
|
+
# @example
|
|
232
|
+
# Some("secret").present_or("fallback") # => "secret"
|
|
233
|
+
# None().present_or("fallback") # => "fallback"
|
|
234
|
+
def present_or(default)
|
|
235
|
+
presence_nudge('present_or', 'unwrap_or')
|
|
236
|
+
return default if none?
|
|
237
|
+
|
|
238
|
+
value
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Returns the inner value of a Some, and the result of the block on a
|
|
242
|
+
# None.
|
|
243
|
+
#
|
|
244
|
+
# @param block [Proc] The block to call on a None.
|
|
245
|
+
# @return [Object] The inner value of a Some, otherwise the block's value.
|
|
246
|
+
#
|
|
247
|
+
# @example
|
|
248
|
+
# Some("secret").present_or_else { "fallback" } # => "secret"
|
|
249
|
+
# None().present_or_else { "fallback" } # => "fallback"
|
|
250
|
+
def present_or_else(&block)
|
|
251
|
+
presence_nudge('present_or_else', 'unwrap_or_else')
|
|
252
|
+
return block.call if none?
|
|
253
|
+
|
|
254
|
+
value
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Returns the inner value of a Some, and nil on a None, so the Rails
|
|
258
|
+
# +presence || default+ idiom reaches the value rather than the wrapper.
|
|
259
|
+
#
|
|
260
|
+
# @return [Object, nil] The inner value of a Some, otherwise nil.
|
|
261
|
+
#
|
|
262
|
+
# @example
|
|
263
|
+
# Some("secret").presence # => "secret"
|
|
264
|
+
# None().presence # => nil
|
|
265
|
+
# None().presence || "fallback" # => "fallback"
|
|
266
|
+
def presence
|
|
267
|
+
presence_nudge('presence', 'unwrap_or(nil)')
|
|
268
|
+
return nil if none?
|
|
269
|
+
|
|
270
|
+
value
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# @example the blank side of the presence family teaches the combinators
|
|
274
|
+
# begin
|
|
275
|
+
# None().blank_or("x")
|
|
276
|
+
# rescue NoMethodError => e
|
|
277
|
+
# e.class
|
|
278
|
+
# end # => Errgonomic::UnwrappedAccessError
|
|
279
|
+
def blank_or(_default)
|
|
280
|
+
raise_blank_side_teaching(:blank_or)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# @example
|
|
284
|
+
# begin
|
|
285
|
+
# Some(1).blank_or_else { :x }
|
|
286
|
+
# rescue NoMethodError => e
|
|
287
|
+
# e.class
|
|
288
|
+
# end # => Errgonomic::UnwrappedAccessError
|
|
289
|
+
def blank_or_else(&_block)
|
|
290
|
+
raise_blank_side_teaching(:blank_or_else)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# @example
|
|
294
|
+
# begin
|
|
295
|
+
# None().blank_or_raise!("msg")
|
|
296
|
+
# rescue NoMethodError => e
|
|
297
|
+
# e.class
|
|
298
|
+
# end # => Errgonomic::UnwrappedAccessError
|
|
299
|
+
def blank_or_raise!(_message)
|
|
300
|
+
raise_blank_side_teaching(:blank_or_raise!)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
alias blank_or_raise blank_or_raise!
|
|
304
|
+
|
|
197
305
|
# return an Array with the contained value, if any
|
|
198
306
|
# @example
|
|
199
307
|
# Some(1).to_a # => [1]
|
|
@@ -448,6 +556,15 @@ module Errgonomic
|
|
|
448
556
|
raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Option'
|
|
449
557
|
end
|
|
450
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
|
+
|
|
451
568
|
# pp uses its own object dump unless told otherwise; keep it consistent
|
|
452
569
|
# with inspect.
|
|
453
570
|
def pretty_print(pp)
|
|
@@ -502,6 +619,21 @@ module Errgonomic
|
|
|
502
619
|
None()
|
|
503
620
|
end
|
|
504
621
|
|
|
622
|
+
private
|
|
623
|
+
|
|
624
|
+
def presence_nudge(from, to)
|
|
625
|
+
warn "Errgonomic: `#{from}` on an Option is soft-deprecated; prefer `#{to}`."
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def raise_blank_side_teaching(name)
|
|
629
|
+
raise Errgonomic::UnwrappedAccessError.new(<<~MSG, name)
|
|
630
|
+
`#{name}` is not supported on an Option, whose blankness is its discriminant.
|
|
631
|
+
Test it with none?, or supply a fallback with unwrap_or / unwrap_or_else.
|
|
632
|
+
MSG
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
public
|
|
636
|
+
|
|
505
637
|
# Rust's mutating combinators (insert, get_or_insert, take, replace)
|
|
506
638
|
# are deliberately omitted: an Option here is a value, not a slot.
|
|
507
639
|
end
|
|
@@ -9,6 +9,22 @@ module Errgonomic
|
|
|
9
9
|
extend ActiveSupport::Concern
|
|
10
10
|
|
|
11
11
|
class_methods do
|
|
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.
|
|
17
|
+
def errgonomic_optional_except(*names)
|
|
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
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def errgonomic_optional_exceptions
|
|
24
|
+
@errgonomic_optional_exceptions ||=
|
|
25
|
+
superclass.respond_to?(:errgonomic_optional_exceptions) ? superclass.errgonomic_optional_exceptions.dup : []
|
|
26
|
+
end
|
|
27
|
+
|
|
12
28
|
def delegate_optional(*methods, to: nil, prefix: nil, private: nil)
|
|
13
29
|
return if to.nil?
|
|
14
30
|
|
|
@@ -4,9 +4,9 @@ module Errgonomic
|
|
|
4
4
|
module Rails
|
|
5
5
|
# Concern to make ActiveRecord optional attributes and associations return an Option.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
7
|
+
# Five pragmatic compromises below satisfy ActiveRecord's assumptions
|
|
8
8
|
# about how accessors behave. They are deliberate exceptions to "Option
|
|
9
|
-
# behaves like Rust's Option", and the set is closed: a
|
|
9
|
+
# behaves like Rust's Option", and the set is closed: a sixth would be a
|
|
10
10
|
# signal that ActiveRecord is pushing back somewhere unmapped, deserving
|
|
11
11
|
# a design discussion rather than a quiet patch.
|
|
12
12
|
#
|
|
@@ -15,22 +15,131 @@ module Errgonomic
|
|
|
15
15
|
# None() == nil stays false.
|
|
16
16
|
# 2. Some delegates persisted?, marked_for_destruction?, and touch_later
|
|
17
17
|
# to its record, so a Some can stand in for it during persistence.
|
|
18
|
-
# 3.
|
|
19
|
-
# Option can be passed to where/quote.
|
|
18
|
+
# 3. Quoting and predicate-building prepends unwrap Options at the SQL
|
|
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.
|
|
25
|
+
#
|
|
26
|
+
# errgonomic_optional_except is not on the list: it is configuration, an
|
|
27
|
+
# escape hatch for whatever conflict shows up next, not a semantic
|
|
28
|
+
# exception.
|
|
22
29
|
module ActiveRecordOptional
|
|
23
30
|
extend ActiveSupport::Concern
|
|
24
31
|
|
|
25
32
|
included do
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
reflect_on_all_associations(:belongs_to)
|
|
34
|
+
.select { |r| r.options[:optional] }
|
|
35
|
+
.each { |r| errgonomic_wrap_optional(r.name) }
|
|
36
|
+
end
|
|
37
|
+
|
|
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.
|
|
42
|
+
def 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] }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Encryption surrounds an attribute with machinery that reads the raw
|
|
120
|
+
# value, including a length validator that calls to_s on it, so a
|
|
121
|
+
# wrapped encrypted attribute cannot be saved. Declaring encrypts
|
|
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.
|
|
125
|
+
def encrypts(*names, **options)
|
|
126
|
+
super.tap { errgonomic_unwrap_optionals(*names) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def errgonomic_unwrap_optionals(*names)
|
|
130
|
+
names.map(&:to_s).each do |name|
|
|
131
|
+
next unless errgonomic_optional_names.delete(name)
|
|
132
|
+
|
|
133
|
+
remove_method(name)
|
|
134
|
+
end
|
|
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
|
|
34
143
|
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
|
35
144
|
def #{name}
|
|
36
145
|
reads = Thread.current[:errgonomic_optional_reads] ||= {}
|
|
@@ -51,12 +160,6 @@ module Errgonomic
|
|
|
51
160
|
RUBY
|
|
52
161
|
end
|
|
53
162
|
end
|
|
54
|
-
|
|
55
|
-
class_methods do
|
|
56
|
-
def errgonomic_optionals
|
|
57
|
-
@errgonomic_optionals
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
163
|
end
|
|
61
164
|
end
|
|
62
165
|
end
|
|
@@ -136,3 +239,33 @@ module Errgonomic
|
|
|
136
239
|
end
|
|
137
240
|
|
|
138
241
|
ActiveRecord::ConnectionAdapters::Quoting.prepend(Errgonomic::Rails::ActiveRecordQuoting)
|
|
242
|
+
|
|
243
|
+
module Errgonomic
|
|
244
|
+
module Rails
|
|
245
|
+
# A hash condition never reaches the quoting layer as its raw value: the
|
|
246
|
+
# predicate builder hands it to a bind attribute, which serializes it
|
|
247
|
+
# through the column type and casts an unrecognized object to nil. Unwrap
|
|
248
|
+
# one step earlier, where every hash condition passes, so a Some binds as
|
|
249
|
+
# its inner value and a None as nil, which Arel renders as IS NULL.
|
|
250
|
+
module ActiveRecordPredicateBuilder
|
|
251
|
+
def build(attribute, value, *args)
|
|
252
|
+
super(attribute, Errgonomic::Rails.unwrap_options(value), *args)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Unwrap Options in a query condition, reaching one level into an array
|
|
257
|
+
# so a list of Options binds like a list of values.
|
|
258
|
+
def self.unwrap_options(value)
|
|
259
|
+
case value
|
|
260
|
+
when Errgonomic::Option::Any
|
|
261
|
+
value.unwrap_or(nil)
|
|
262
|
+
when Array
|
|
263
|
+
value.any? { |v| v.is_a?(Errgonomic::Option::Any) } ? value.map { |v| unwrap_options(v) } : value
|
|
264
|
+
else
|
|
265
|
+
value
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
ActiveRecord::PredicateBuilder.prepend(Errgonomic::Rails::ActiveRecordPredicateBuilder)
|
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
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: errgonomic
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.8.
|
|
4
|
+
version: 0.8.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nick Zadrozny
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 1980-01-
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: concurrent-ruby
|