errgonomic 0.8.0 → 0.8.1
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/README.md +16 -4
- data/lib/errgonomic/option.rb +123 -0
- data/lib/errgonomic/rails/active_record_delegate_optional.rb +11 -0
- data/lib/errgonomic/rails/active_record_optional.rb +60 -4
- 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: c579e542517b446e381c09ef98e24237e8ba5c02ca19633d762195d9bcab6a1d
|
|
4
|
+
data.tar.gz: aa72fb5647fbd9f335bde9b0d10e6a07b68359a7581c4f9152fbf59958d54f43
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6423f7d1dbf8a9b0d0ee116f72f7dcd427da888f2213c5ea55ebb13fa7abcec83c63d1897f253a6f644094dabf194c9711f99565bedca9444126951ae715c39e
|
|
7
|
+
data.tar.gz: 4d3329007ccb658b05cbb4103395133a2c355e4224f5b58850e3da399338ede381bcb0b4db3a2dcb08db54868405d8886e168b1e1b67435e484e9a94e2832f94
|
data/README.md
CHANGED
|
@@ -113,6 +113,8 @@ An unhandled Option refuses to leak into your output: `to_s` and `to_json` raise
|
|
|
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
|
|
@@ -199,21 +201,31 @@ 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`, which must appear before the include.
|
|
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
|
+
```
|
|
203
214
|
- `delegate_optional :name, to: :association` (available on all models) delegates through an optional association, returning an Option instead of raising on nil.
|
|
204
215
|
|
|
205
216
|
`Object#to_option` is also available in Rails to lift any value into an Option (`nil.to_option # => None()`).
|
|
206
217
|
|
|
207
218
|
#### ActiveRecord compromises
|
|
208
219
|
|
|
209
|
-
ActiveRecord assumes things about accessors that a strict Rust Option cannot satisfy, so the integration carries
|
|
220
|
+
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
221
|
|
|
211
222
|
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
223
|
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
|
|
224
|
+
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
225
|
4. `SomeValidator` provides a presence-style validation for Option attributes.
|
|
226
|
+
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
227
|
|
|
216
|
-
The set is closed. If a future integration appears to need a
|
|
228
|
+
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
229
|
|
|
218
230
|
## Development
|
|
219
231
|
|
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]
|
|
@@ -502,6 +610,21 @@ module Errgonomic
|
|
|
502
610
|
None()
|
|
503
611
|
end
|
|
504
612
|
|
|
613
|
+
private
|
|
614
|
+
|
|
615
|
+
def presence_nudge(from, to)
|
|
616
|
+
warn "Errgonomic: `#{from}` on an Option is soft-deprecated; prefer `#{to}`."
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def raise_blank_side_teaching(name)
|
|
620
|
+
raise Errgonomic::UnwrappedAccessError.new(<<~MSG, name)
|
|
621
|
+
`#{name}` is not supported on an Option, whose blankness is its discriminant.
|
|
622
|
+
Test it with none?, or supply a fallback with unwrap_or / unwrap_or_else.
|
|
623
|
+
MSG
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
public
|
|
627
|
+
|
|
505
628
|
# Rust's mutating combinators (insert, get_or_insert, take, replace)
|
|
506
629
|
# are deliberately omitted: an Option here is a value, not a slot.
|
|
507
630
|
end
|
|
@@ -9,6 +9,17 @@ 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.
|
|
15
|
+
def errgonomic_optional_except(*names)
|
|
16
|
+
@errgonomic_optional_exceptions = errgonomic_optional_exceptions + names.map(&:to_s)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def errgonomic_optional_exceptions
|
|
20
|
+
@errgonomic_optional_exceptions ||= []
|
|
21
|
+
end
|
|
22
|
+
|
|
12
23
|
def delegate_optional(*methods, to: nil, prefix: nil, private: nil)
|
|
13
24
|
return if to.nil?
|
|
14
25
|
|
|
@@ -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,10 +15,17 @@ 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
|
|
|
@@ -27,8 +34,10 @@ module Errgonomic
|
|
|
27
34
|
optional_associations = reflect_on_all_associations(:belongs_to)
|
|
28
35
|
.select { |r| r.options[:optional] }
|
|
29
36
|
.map(&:name)
|
|
37
|
+
excluded = Array(encrypted_attributes).map(&:to_s) + Array(try(:errgonomic_optional_exceptions))
|
|
30
38
|
optional_attributes = column_names
|
|
31
39
|
.select { |n| column_for_attribute(n).null }
|
|
40
|
+
.reject { |n| excluded.include?(n) }
|
|
32
41
|
@errgonomic_optionals = (optional_attributes + optional_associations)
|
|
33
42
|
@errgonomic_optionals.each do |name|
|
|
34
43
|
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
|
@@ -56,6 +65,23 @@ module Errgonomic
|
|
|
56
65
|
def errgonomic_optionals
|
|
57
66
|
@errgonomic_optionals
|
|
58
67
|
end
|
|
68
|
+
|
|
69
|
+
# Encryption surrounds an attribute with machinery that reads the raw
|
|
70
|
+
# value, including a length validator that calls to_s on it, so a
|
|
71
|
+
# 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.
|
|
74
|
+
def encrypts(*names, **options)
|
|
75
|
+
super.tap { errgonomic_unwrap_optionals(*names) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def errgonomic_unwrap_optionals(*names)
|
|
79
|
+
names.map(&:to_s).each do |name|
|
|
80
|
+
next unless @errgonomic_optionals&.delete(name)
|
|
81
|
+
|
|
82
|
+
remove_method(name)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
59
85
|
end
|
|
60
86
|
end
|
|
61
87
|
end
|
|
@@ -136,3 +162,33 @@ module Errgonomic
|
|
|
136
162
|
end
|
|
137
163
|
|
|
138
164
|
ActiveRecord::ConnectionAdapters::Quoting.prepend(Errgonomic::Rails::ActiveRecordQuoting)
|
|
165
|
+
|
|
166
|
+
module Errgonomic
|
|
167
|
+
module Rails
|
|
168
|
+
# A hash condition never reaches the quoting layer as its raw value: the
|
|
169
|
+
# predicate builder hands it to a bind attribute, which serializes it
|
|
170
|
+
# through the column type and casts an unrecognized object to nil. Unwrap
|
|
171
|
+
# one step earlier, where every hash condition passes, so a Some binds as
|
|
172
|
+
# its inner value and a None as nil, which Arel renders as IS NULL.
|
|
173
|
+
module ActiveRecordPredicateBuilder
|
|
174
|
+
def build(attribute, value, *args)
|
|
175
|
+
super(attribute, Errgonomic::Rails.unwrap_options(value), *args)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Unwrap Options in a query condition, reaching one level into an array
|
|
180
|
+
# so a list of Options binds like a list of values.
|
|
181
|
+
def self.unwrap_options(value)
|
|
182
|
+
case value
|
|
183
|
+
when Errgonomic::Option::Any
|
|
184
|
+
value.unwrap_or(nil)
|
|
185
|
+
when Array
|
|
186
|
+
value.any? { |v| v.is_a?(Errgonomic::Option::Any) } ? value.map { |v| unwrap_options(v) } : value
|
|
187
|
+
else
|
|
188
|
+
value
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
ActiveRecord::PredicateBuilder.prepend(Errgonomic::Rails::ActiveRecordPredicateBuilder)
|
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.1
|
|
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
|