errgonomic 0.8.3 → 0.9.0
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 +18 -3
- data/CHANGELOG.md +132 -2
- data/CONTRIBUTING.md +3 -2
- data/README.md +164 -18
- data/Rakefile +11 -1
- data/doctest_helper.rb +153 -0
- data/gem-groups.json +1 -1
- data/lib/errgonomic/core_ext/enumerable.rb +94 -0
- data/lib/errgonomic/option.rb +229 -50
- data/lib/errgonomic/rails/active_record_delegate_optional.rb +170 -10
- data/lib/errgonomic/rails/active_record_optional.rb +500 -60
- data/lib/errgonomic/result.rb +98 -14
- data/lib/errgonomic/version.rb +1 -1
- data/lib/errgonomic.rb +30 -0
- metadata +2 -1
data/lib/errgonomic/option.rb
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'set'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
|
|
3
6
|
module Errgonomic
|
|
4
7
|
module Option
|
|
5
8
|
# The base class for all options. Some and None are subclasses.
|
|
6
9
|
#
|
|
10
|
+
# An Option is an object, so it is always truthy. A None does not stand
|
|
11
|
+
# in for nil in a conditional, and `||` hands back the wrapper rather
|
|
12
|
+
# than the fallback. Reach for a combinator to get at the inner value.
|
|
13
|
+
#
|
|
7
14
|
class Any
|
|
8
15
|
include Comparable
|
|
9
16
|
|
|
@@ -16,6 +23,11 @@ module Errgonomic
|
|
|
16
23
|
is_none_or: :none_or?
|
|
17
24
|
}.freeze
|
|
18
25
|
|
|
26
|
+
# Names already nudged about. A soft deprecation is a message to a
|
|
27
|
+
# developer, and one per process says it; one per call turns a hot
|
|
28
|
+
# path into a stderr flood.
|
|
29
|
+
NUDGED = Set.new
|
|
30
|
+
|
|
19
31
|
# An Option deliberately forwards nothing to its inner value, so a miss
|
|
20
32
|
# here is almost always someone treating the container as its contents.
|
|
21
33
|
# Teach the route out instead of leaving a bare NoMethodError. Rust
|
|
@@ -72,7 +84,44 @@ module Errgonomic
|
|
|
72
84
|
# None() == None() # => true
|
|
73
85
|
# Some(1) == 1 # => false
|
|
74
86
|
# None() == nil # => false
|
|
87
|
+
#
|
|
88
|
+
# @example strict equality makes a cross-type comparison an error
|
|
89
|
+
# Errgonomic.with_strict_equality do
|
|
90
|
+
# begin
|
|
91
|
+
# Some(5) == 5
|
|
92
|
+
# rescue Errgonomic::TypeMismatchError => e
|
|
93
|
+
# e.class
|
|
94
|
+
# end
|
|
95
|
+
# end # => Errgonomic::TypeMismatchError
|
|
96
|
+
# Errgonomic.with_strict_equality do
|
|
97
|
+
# begin
|
|
98
|
+
# Some(5) != 5
|
|
99
|
+
# rescue Errgonomic::TypeMismatchError => e
|
|
100
|
+
# e.message.include?("!=")
|
|
101
|
+
# end
|
|
102
|
+
# end # => true
|
|
103
|
+
# Errgonomic.with_strict_equality { Some(5) == Some(5) } # => true
|
|
104
|
+
# Errgonomic.with_strict_equality { Some(5) == None() } # => false
|
|
105
|
+
#
|
|
106
|
+
# @example a Result is another container, not another Option
|
|
107
|
+
# Errgonomic.with_strict_equality do
|
|
108
|
+
# begin
|
|
109
|
+
# Some(1) == Ok(1)
|
|
110
|
+
# rescue Errgonomic::TypeMismatchError => e
|
|
111
|
+
# e.message.include?("different containers")
|
|
112
|
+
# end
|
|
113
|
+
# end # => true
|
|
114
|
+
#
|
|
115
|
+
# @example nil is another type, and absence here is the discriminant
|
|
116
|
+
# Errgonomic.with_strict_equality do
|
|
117
|
+
# begin
|
|
118
|
+
# None() == nil
|
|
119
|
+
# rescue Errgonomic::TypeMismatchError => e
|
|
120
|
+
# e.message.include?("none?")
|
|
121
|
+
# end
|
|
122
|
+
# end # => true
|
|
75
123
|
def ==(other)
|
|
124
|
+
strict_equality!(other, '==')
|
|
76
125
|
return false if self.class != other.class
|
|
77
126
|
return true if none?
|
|
78
127
|
|
|
@@ -90,7 +139,25 @@ module Errgonomic
|
|
|
90
139
|
# None().eql?(None()) # => true
|
|
91
140
|
# { Some(5) => 1 }[Some(5)] # => 1
|
|
92
141
|
# [Some(1), Some(1), None(), None()].uniq # => [Some(1), None()]
|
|
142
|
+
#
|
|
143
|
+
# @example strict equality reaches eql?, and leaves hash alone
|
|
144
|
+
# Errgonomic.with_strict_equality do
|
|
145
|
+
# begin
|
|
146
|
+
# Some(5).eql?(5)
|
|
147
|
+
# rescue Errgonomic::TypeMismatchError => e
|
|
148
|
+
# e.class
|
|
149
|
+
# end
|
|
150
|
+
# end # => Errgonomic::TypeMismatchError
|
|
151
|
+
# Errgonomic.with_strict_equality { Some(5).hash == Some(5).hash } # => true
|
|
152
|
+
# Ruby derives != from ==, so a strict-equality message would name the
|
|
153
|
+
# operator the caller did not write.
|
|
154
|
+
def !=(other)
|
|
155
|
+
strict_equality!(other, '!=')
|
|
156
|
+
super
|
|
157
|
+
end
|
|
158
|
+
|
|
93
159
|
def eql?(other)
|
|
160
|
+
strict_equality!(other, 'eql?')
|
|
94
161
|
return false if self.class != other.class
|
|
95
162
|
return true if none?
|
|
96
163
|
|
|
@@ -124,9 +191,11 @@ module Errgonomic
|
|
|
124
191
|
end
|
|
125
192
|
|
|
126
193
|
# Options order like Rust's: None sorts before any Some, and Somes
|
|
127
|
-
# order by their inner values.
|
|
128
|
-
#
|
|
129
|
-
#
|
|
194
|
+
# order by their inner values. Two Options whose inner values do not
|
|
195
|
+
# themselves compare follow Ruby's convention and answer nil. A
|
|
196
|
+
# non-Option operand raises instead: Comparable turns a nil here into
|
|
197
|
+
# an ArgumentError that names the Option as the operand at fault, where
|
|
198
|
+
# what went wrong is that a wrapper was ordered against a bare value.
|
|
130
199
|
#
|
|
131
200
|
# @example
|
|
132
201
|
# (Some(5) <=> Some(6)) # => -1
|
|
@@ -134,11 +203,20 @@ module Errgonomic
|
|
|
134
203
|
# (Some(5) <=> None()) # => 1
|
|
135
204
|
# (None() <=> None()) # => 0
|
|
136
205
|
# (Some(1) <=> Some("x")) # => nil
|
|
137
|
-
# (Some(1) <=> 1) # => nil
|
|
138
206
|
# [Some(2), None(), Some(1)].sort # => [None(), Some(1), Some(2)]
|
|
139
207
|
# [Some(2), Some(1)].min # => Some(1)
|
|
208
|
+
#
|
|
209
|
+
# @example a bare value is not ordered against an Option
|
|
210
|
+
# Some(5) <= 6 # => raise Errgonomic::TypeMismatchError, "cannot compare Some(5) with Integer; test the inner value (some_and? { |v| v <= other }) or reach for it (map, unwrap_or)"
|
|
211
|
+
# Some(5).some_and? { |v| v <= 6 } # => true
|
|
212
|
+
# Some(5).map { |v| v <= 6 } # => Some(true)
|
|
140
213
|
def <=>(other)
|
|
141
|
-
|
|
214
|
+
unless other.is_a?(Errgonomic::Option::Any)
|
|
215
|
+
raise Errgonomic::TypeMismatchError,
|
|
216
|
+
"cannot compare #{inspect} with #{other.class}; test the inner value " \
|
|
217
|
+
'(some_and? { |v| v <= other }) or reach for it (map, unwrap_or)'
|
|
218
|
+
end
|
|
219
|
+
|
|
142
220
|
return none? ? 0 : 1 if other.none?
|
|
143
221
|
return -1 if none?
|
|
144
222
|
|
|
@@ -197,13 +275,15 @@ module Errgonomic
|
|
|
197
275
|
# The presence helpers on Object keep their receiver; on an Option that
|
|
198
276
|
# would hand back the wrapper where the caller asked for a value. Here
|
|
199
277
|
# the present side unwraps instead, so a name that reads like an
|
|
200
|
-
# accessor behaves like one. The
|
|
201
|
-
# Options in favor of the combinators
|
|
202
|
-
#
|
|
203
|
-
# teaches rather than guesses at
|
|
278
|
+
# accessor behaves like one. The +_or+ spellings are soft-deprecated on
|
|
279
|
+
# Options in favor of the combinators and nudge via stderr; `presence`
|
|
280
|
+
# is the Rails idiom for unwrap_or(nil) and stays. The blank side, which
|
|
281
|
+
# has no working call sites to preserve, teaches rather than guesses at
|
|
282
|
+
# semantics.
|
|
204
283
|
|
|
205
284
|
# Returns the inner value of a Some, and raises on a None. Presence
|
|
206
|
-
# follows the discriminant, so Some(nil) yields nil.
|
|
285
|
+
# follows the discriminant, so Some(nil) yields nil. A block is called
|
|
286
|
+
# only on the None branch, as it is for expect!.
|
|
207
287
|
#
|
|
208
288
|
# @param message [String] The error message to raise on a None.
|
|
209
289
|
# @return [Object] The inner value of a Some.
|
|
@@ -212,9 +292,10 @@ module Errgonomic
|
|
|
212
292
|
# Some("secret").present_or_raise!("no secret") # => "secret"
|
|
213
293
|
# Some(nil).present_or_raise!("no secret") # => nil
|
|
214
294
|
# None().present_or_raise!("no secret") # => raise Errgonomic::NotPresentError, "no secret"
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
295
|
+
# None().present_or_raise! { "no secret for #{7}" } # => raise Errgonomic::NotPresentError, "no secret for 7"
|
|
296
|
+
def present_or_raise!(message = nil, &block)
|
|
297
|
+
presence_nudge('present_or_raise!', 'expect!')
|
|
298
|
+
raise Errgonomic::NotPresentError, block ? block.call : message if none?
|
|
218
299
|
|
|
219
300
|
value
|
|
220
301
|
end
|
|
@@ -231,6 +312,18 @@ module Errgonomic
|
|
|
231
312
|
# @example
|
|
232
313
|
# Some("secret").present_or("fallback") # => "secret"
|
|
233
314
|
# None().present_or("fallback") # => "fallback"
|
|
315
|
+
#
|
|
316
|
+
# @example the nudge fires once per process, so a hot path stays quiet
|
|
317
|
+
# Some(1).present_or(2)
|
|
318
|
+
# nudges = StringIO.new
|
|
319
|
+
# original = $stderr
|
|
320
|
+
# begin
|
|
321
|
+
# $stderr = nudges
|
|
322
|
+
# Some(1).present_or(2)
|
|
323
|
+
# ensure
|
|
324
|
+
# $stderr = original
|
|
325
|
+
# end
|
|
326
|
+
# nudges.string # => ""
|
|
234
327
|
def present_or(default)
|
|
235
328
|
presence_nudge('present_or', 'unwrap_or')
|
|
236
329
|
return default if none?
|
|
@@ -256,15 +349,30 @@ module Errgonomic
|
|
|
256
349
|
|
|
257
350
|
# Returns the inner value of a Some, and nil on a None, so the Rails
|
|
258
351
|
# +presence || default+ idiom reaches the value rather than the wrapper.
|
|
352
|
+
# Presence follows the discriminant, so a blank inner value is still a
|
|
353
|
+
# value: Some("").presence is "", where Object#presence answers nil.
|
|
259
354
|
#
|
|
260
355
|
# @return [Object, nil] The inner value of a Some, otherwise nil.
|
|
261
356
|
#
|
|
262
357
|
# @example
|
|
263
358
|
# Some("secret").presence # => "secret"
|
|
359
|
+
# Some("").presence # => ""
|
|
264
360
|
# None().presence # => nil
|
|
265
361
|
# None().presence || "fallback" # => "fallback"
|
|
362
|
+
#
|
|
363
|
+
# @example the Rails spelling of unwrap_or(nil), and no nudge with it
|
|
364
|
+
# nudges = StringIO.new
|
|
365
|
+
# original = $stderr
|
|
366
|
+
# begin
|
|
367
|
+
# $stderr = nudges
|
|
368
|
+
# captured = Some("").presence
|
|
369
|
+
# None().presence
|
|
370
|
+
# ensure
|
|
371
|
+
# $stderr = original
|
|
372
|
+
# end
|
|
373
|
+
# captured # => ""
|
|
374
|
+
# nudges.string # => ""
|
|
266
375
|
def presence
|
|
267
|
-
presence_nudge('presence', 'unwrap_or(nil)')
|
|
268
376
|
return nil if none?
|
|
269
377
|
|
|
270
378
|
value
|
|
@@ -312,6 +420,30 @@ module Errgonomic
|
|
|
312
420
|
[value]
|
|
313
421
|
end
|
|
314
422
|
|
|
423
|
+
# Yields the inner value once for a Some and not at all for a None, so
|
|
424
|
+
# an Option reads as the zero-or-one collection it is, and answers an
|
|
425
|
+
# Enumerator without a block. Option does not include Enumerable: its
|
|
426
|
+
# own filter and first answer Options, where Enumerable's answer plain
|
|
427
|
+
# values, and one name cannot mean both.
|
|
428
|
+
#
|
|
429
|
+
# @example
|
|
430
|
+
# seen = []
|
|
431
|
+
# Some(1).each { |x| seen << x } # => Some(1)
|
|
432
|
+
# seen # => [1]
|
|
433
|
+
# None().each { |x| seen << x } # => None()
|
|
434
|
+
# seen # => [1]
|
|
435
|
+
# Some(1).each.to_a # => [1]
|
|
436
|
+
# None().each.to_a # => []
|
|
437
|
+
# Some(2).each.map { |x| x * 3 } # => [6]
|
|
438
|
+
# Some(1).each.size # => 1
|
|
439
|
+
# None().each.size # => 0
|
|
440
|
+
def each(&block)
|
|
441
|
+
return to_enum(:each) { some? ? 1 : 0 } unless block
|
|
442
|
+
|
|
443
|
+
block.call(value) if some?
|
|
444
|
+
self
|
|
445
|
+
end
|
|
446
|
+
|
|
315
447
|
# returns the inner value if present, else raises an error
|
|
316
448
|
# @example
|
|
317
449
|
# Some(1).unwrap! # => 1
|
|
@@ -322,21 +454,28 @@ module Errgonomic
|
|
|
322
454
|
value
|
|
323
455
|
end
|
|
324
456
|
|
|
325
|
-
#
|
|
326
|
-
# message
|
|
457
|
+
# Returns the inner value of a Some, else raises with the given message.
|
|
458
|
+
# A block is called only on the None branch, so a message that
|
|
459
|
+
# interpolates costs nothing on the path that succeeds.
|
|
460
|
+
#
|
|
327
461
|
# @example
|
|
328
462
|
# Some(1).expect!("msg") # => 1
|
|
329
463
|
# None().expect!("here's why this failed") # => raise Errgonomic::ExpectError, "here's why this failed"
|
|
330
|
-
|
|
331
|
-
|
|
464
|
+
# Some(1).expect! { "built only where it is raised" } # => 1
|
|
465
|
+
# None().expect! { "no tier for #{7}" } # => raise Errgonomic::ExpectError, "no tier for 7"
|
|
466
|
+
def expect!(msg = nil, &block)
|
|
467
|
+
raise Errgonomic::ExpectError, block ? block.call : msg if none?
|
|
332
468
|
|
|
333
469
|
value
|
|
334
470
|
end
|
|
335
471
|
|
|
336
|
-
# returns the inner value if present, else returns the default value
|
|
472
|
+
# returns the inner value if present, else returns the default value.
|
|
473
|
+
# This is the spelling `opt || default` cannot give you: an Option is
|
|
474
|
+
# truthy, so `||` never reaches the fallback.
|
|
337
475
|
# @example
|
|
338
476
|
# Some(1).unwrap_or(2) # => 1
|
|
339
477
|
# None().unwrap_or(2) # => 2
|
|
478
|
+
# None() || 2 # => None()
|
|
340
479
|
def unwrap_or(default)
|
|
341
480
|
return default if none?
|
|
342
481
|
|
|
@@ -372,46 +511,49 @@ module Errgonomic
|
|
|
372
511
|
end
|
|
373
512
|
|
|
374
513
|
# Maps the Option to another Option by applying a function to the
|
|
375
|
-
# contained value (if Some) or returns None.
|
|
376
|
-
#
|
|
514
|
+
# contained value (if Some) or returns None. Whatever the block returns
|
|
515
|
+
# is wrapped, as in Rust: a block that returns an Option gives
|
|
516
|
+
# Some(Some(x)). and_then is the spelling for a block that returns an
|
|
517
|
+
# Option.
|
|
377
518
|
#
|
|
378
519
|
# @example
|
|
379
520
|
# Some(1).map { |x| x + 1 } # => Some(2)
|
|
380
521
|
# None().map { |x| x + 1 } # => None()
|
|
522
|
+
# Some(1).map { |x| Some(x + 1) } # => Some(Some(2))
|
|
523
|
+
# Some(1).and_then { |x| Some(x + 1) } # => Some(2)
|
|
381
524
|
def map(&block)
|
|
382
525
|
return self if none?
|
|
383
526
|
|
|
384
527
|
Some(block.call(value))
|
|
385
528
|
end
|
|
386
529
|
|
|
387
|
-
# Returns the provided default (if none), or
|
|
388
|
-
# contained value (if some).
|
|
389
|
-
#
|
|
530
|
+
# Returns the provided default (if none), or the block applied to the
|
|
531
|
+
# contained value (if some). Both come back bare, as Rust's map_or
|
|
532
|
+
# gives: this is the exit from the Option, where map stays inside it.
|
|
533
|
+
# Use +map_or_else+ when the default is expensive to build.
|
|
390
534
|
#
|
|
391
535
|
# @example
|
|
392
|
-
# None().map_or(1) { 100 } # =>
|
|
393
|
-
# Some(1).map_or(100) { |x| x + 1 } # =>
|
|
394
|
-
# Some("foo").map_or(0) { |str| str.length } # =>
|
|
536
|
+
# None().map_or(1) { 100 } # => 1
|
|
537
|
+
# Some(1).map_or(100) { |x| x + 1 } # => 2
|
|
538
|
+
# Some("foo").map_or(0) { |str| str.length } # => 3
|
|
539
|
+
# Some(2).map_or(0) { |x| x * 2 } # => 4
|
|
395
540
|
def map_or(default, &block)
|
|
396
|
-
return
|
|
541
|
+
return default if none?
|
|
397
542
|
|
|
398
|
-
|
|
543
|
+
block.call(value)
|
|
399
544
|
end
|
|
400
545
|
|
|
401
546
|
# Computes a default from the given Proc if None, or applies the block to
|
|
402
|
-
# the contained value (if Some).
|
|
547
|
+
# the contained value (if Some). Both come back bare, as map_or's do.
|
|
403
548
|
#
|
|
404
549
|
# @example
|
|
405
|
-
# None().map_or_else(-> { :foo }) { :bar } # =>
|
|
406
|
-
# Some("str").map_or_else(-> { 100 }) { |str| str.length } # =>
|
|
407
|
-
# None().map_or_else(
|
|
550
|
+
# None().map_or_else(-> { :foo }) { :bar } # => :foo
|
|
551
|
+
# Some("str").map_or_else(-> { 100 }) { |str| str.length } # => 3
|
|
552
|
+
# None().map_or_else(-> { nil }) { |str| str.length } # => nil
|
|
408
553
|
def map_or_else(proc, &block)
|
|
409
|
-
if none?
|
|
410
|
-
val = proc.call
|
|
411
|
-
return val ? Some(val) : None()
|
|
412
|
-
end
|
|
554
|
+
return proc.call if none?
|
|
413
555
|
|
|
414
|
-
|
|
556
|
+
block.call(value)
|
|
415
557
|
end
|
|
416
558
|
|
|
417
559
|
# convert the option into a result where Some is Ok and None is Err
|
|
@@ -536,13 +678,18 @@ module Errgonomic
|
|
|
536
678
|
Some(other)
|
|
537
679
|
end
|
|
538
680
|
|
|
539
|
-
#
|
|
540
|
-
#
|
|
681
|
+
# Render as inspect does. Rust gives Option a Debug and no Display, so
|
|
682
|
+
# refusing was faithful, but a to_s that raises replaces the real
|
|
683
|
+
# exception while a rescue builds its log line, and the rendered form
|
|
684
|
+
# says plainly that a wrapper arrived where a value was meant.
|
|
541
685
|
#
|
|
542
686
|
# @example
|
|
543
|
-
#
|
|
687
|
+
# Some(1).to_s # => "Some(1)"
|
|
688
|
+
# Some("x").to_s # => "Some(\"x\")"
|
|
689
|
+
# None().to_s # => "None"
|
|
690
|
+
# "value: #{Some(1)}" # => "value: Some(1)"
|
|
544
691
|
def to_s
|
|
545
|
-
|
|
692
|
+
inspect
|
|
546
693
|
end
|
|
547
694
|
|
|
548
695
|
# Refuse to serialize an unwrapped Option as JSON. Not only should we
|
|
@@ -551,9 +698,14 @@ module Errgonomic
|
|
|
551
698
|
# Object#to_json implementations.
|
|
552
699
|
#
|
|
553
700
|
# @example
|
|
554
|
-
# None().to_json # => raise Errgonomic::SerializeError,
|
|
701
|
+
# None().to_json # => raise Errgonomic::SerializeError, 'cannot serialize an unwrapped None'
|
|
702
|
+
# begin
|
|
703
|
+
# Some('a' * 100).to_json
|
|
704
|
+
# rescue Errgonomic::SerializeError => e
|
|
705
|
+
# e.message.end_with?('...')
|
|
706
|
+
# end # => true
|
|
555
707
|
def to_json(*_args)
|
|
556
|
-
raise Errgonomic::SerializeError,
|
|
708
|
+
raise Errgonomic::SerializeError, serialize_refusal
|
|
557
709
|
end
|
|
558
710
|
|
|
559
711
|
# ActiveSupport's Hash#as_json and Array#as_json recurse through their
|
|
@@ -562,7 +714,7 @@ module Errgonomic
|
|
|
562
714
|
# variables. Refuse there too, and the guard holds wherever an Option
|
|
563
715
|
# travels.
|
|
564
716
|
def as_json(*_args)
|
|
565
|
-
raise Errgonomic::SerializeError,
|
|
717
|
+
raise Errgonomic::SerializeError, serialize_refusal
|
|
566
718
|
end
|
|
567
719
|
|
|
568
720
|
# pp uses its own object dump unless told otherwise; keep it consistent
|
|
@@ -621,21 +773,48 @@ module Errgonomic
|
|
|
621
773
|
|
|
622
774
|
private
|
|
623
775
|
|
|
776
|
+
# Name the value the caller failed to handle, bounded: an inspect of a
|
|
777
|
+
# record or a long payload would bury the message carrying it.
|
|
778
|
+
def serialize_refusal
|
|
779
|
+
rendered = inspect
|
|
780
|
+
rendered = "#{rendered[0, 57]}..." if rendered.length > 60
|
|
781
|
+
"cannot serialize an unwrapped #{rendered}"
|
|
782
|
+
end
|
|
783
|
+
|
|
624
784
|
def presence_nudge(from, to)
|
|
785
|
+
return unless NUDGED.add?(from)
|
|
786
|
+
|
|
625
787
|
warn "Errgonomic: `#{from}` on an Option is soft-deprecated; prefer `#{to}`."
|
|
626
788
|
end
|
|
627
789
|
|
|
790
|
+
def strict_equality!(other, operator)
|
|
791
|
+
return unless Errgonomic.strict_equality?
|
|
792
|
+
return if other.is_a?(Errgonomic::Option::Any)
|
|
793
|
+
|
|
794
|
+
raise Errgonomic::TypeMismatchError,
|
|
795
|
+
"#{self.class} #{operator} #{other.class}, which strict equality refuses.\n" \
|
|
796
|
+
"#{strict_equality_remedy(other)}"
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
def strict_equality_remedy(other)
|
|
800
|
+
case other
|
|
801
|
+
when Errgonomic::Result::Any
|
|
802
|
+
'An Option and a Result are different containers, and neither is the other. ' \
|
|
803
|
+
'Unwrap the one you meant (opt.unwrap_or(nil) == res.unwrap_or(nil)).'
|
|
804
|
+
when nil
|
|
805
|
+
'Absence here is the discriminant: ask none?, or nil? under the Rails integration.'
|
|
806
|
+
else
|
|
807
|
+
"Compare Options (opt == Some(#{other.inspect})), test the inner value " \
|
|
808
|
+
"(opt.some_and? { |v| v == #{other.inspect} }), or unwrap_or a fallback first."
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
|
|
628
812
|
def raise_blank_side_teaching(name)
|
|
629
813
|
raise Errgonomic::UnwrappedAccessError.new(<<~MSG, name)
|
|
630
814
|
`#{name}` is not supported on an Option, whose blankness is its discriminant.
|
|
631
815
|
Test it with none?, or supply a fallback with unwrap_or / unwrap_or_else.
|
|
632
816
|
MSG
|
|
633
817
|
end
|
|
634
|
-
|
|
635
|
-
public
|
|
636
|
-
|
|
637
|
-
# Rust's mutating combinators (insert, get_or_insert, take, replace)
|
|
638
|
-
# are deliberately omitted: an Option here is a value, not a slot.
|
|
639
818
|
end
|
|
640
819
|
|
|
641
820
|
# Represent a value
|
|
@@ -4,10 +4,81 @@ module Errgonomic
|
|
|
4
4
|
module Rails
|
|
5
5
|
# Adds a `delegate_optional` class method in the spirit of Rails'
|
|
6
6
|
# `delegate`, returning an Option instead of nil or NoMethodError when
|
|
7
|
-
# the delegation target is absent.
|
|
7
|
+
# the delegation target is absent. The generated reader forwards with
|
|
8
|
+
# `...` from inside a block, which Ruby 3.4, the version CI runs, accepts.
|
|
8
9
|
module ActiveRecordDelegateOptional
|
|
9
10
|
extend ActiveSupport::Concern
|
|
10
11
|
|
|
12
|
+
# What a declaration that cannot mean what it says is told, where it is
|
|
13
|
+
# written. A writer is refused rather than delegated: an assignment
|
|
14
|
+
# through an absent target has nowhere to put the value, and dropping it
|
|
15
|
+
# silently is the failure an Option exists to prevent.
|
|
16
|
+
NO_TARGET = "Delegation needs a target. Supply a keyword argument 'to' " \
|
|
17
|
+
'(e.g. delegate_optional :hello, to: :greeter).'
|
|
18
|
+
NO_WRITERS = 'delegate_optional does not delegate a writer; an absent target would drop the value assigned'
|
|
19
|
+
NO_NAME_TO_PREFIX = "prefix: true takes the target's own name, and a module target has none; name the prefix"
|
|
20
|
+
NO_METHOD_TO_PREFIX = 'Can only automatically set the delegation prefix when delegating to a method.'
|
|
21
|
+
ALWAYS_NONE = 'delegate_optional reads an absent target as None; allow_nil: false asks for something else'
|
|
22
|
+
private_constant :NO_TARGET, :NO_WRITERS, :NO_NAME_TO_PREFIX, :NO_METHOD_TO_PREFIX, :ALWAYS_NONE
|
|
23
|
+
|
|
24
|
+
# YARD does not see through a concern's class_methods block, so the
|
|
25
|
+
# method it documents is declared rather than read.
|
|
26
|
+
#
|
|
27
|
+
# @!method delegate_optional(*methods, to: nil, prefix: nil, private: nil, allow_nil: nil)
|
|
28
|
+
# @!scope class
|
|
29
|
+
# Delegates to an optional target, answering an Option: None where the target is absent.
|
|
30
|
+
# @example prefix forms name the reader, as they do for Rails' delegate
|
|
31
|
+
# article = Article.create!(title: 'Omelas', writer: Writer.create!(name: 'Ursula', bio: 'writes'))
|
|
32
|
+
# article.writer_name # => Some('Ursula')
|
|
33
|
+
# article.author_name # => Some('Ursula')
|
|
34
|
+
# article.bio # => Some('writes')
|
|
35
|
+
# @example a delegated call forwards what it was handed
|
|
36
|
+
# article = Article.create!(title: 'Omelas', writer: Writer.create!(name: 'Ursula'))
|
|
37
|
+
# article.writer_greeting('Hello') # => Some('Hello, Ursula.')
|
|
38
|
+
# article.writer_greeting('Hi', punctuation: '!') # => Some('Hi, Ursula!')
|
|
39
|
+
# article.writer_styled_name(&:upcase) # => Some('URSULA')
|
|
40
|
+
# @example a target named for a Ruby keyword is reached through self
|
|
41
|
+
# Article.create!(title: 'Omelas').table_name # => Some('articles')
|
|
42
|
+
# @example the target is lifted, and an Option it hands back is not nested
|
|
43
|
+
# draft = Draft.create!(title: 'Omelas', writer_id: Writer.create!(name: 'Ursula').id)
|
|
44
|
+
# draft.writer_name # => Some('Ursula')
|
|
45
|
+
# draft.byline_name # => Some('Ursula')
|
|
46
|
+
# Draft.create!(title: 'Untitled').writer_name # => None()
|
|
47
|
+
# Article.create!(title: 'Untitled').writer_name # => None()
|
|
48
|
+
# @example a delegated reader points at the model that declared it
|
|
49
|
+
# Article.instance_method(:writer_name).source_location.first.end_with?('doctest_helper.rb') # => true
|
|
50
|
+
# @example an absent target is a value here, so allow_nil: true says nothing new
|
|
51
|
+
# Reprint.create!(title: 'Untitled').writer_name # => None()
|
|
52
|
+
# Reprint.create!(title: 'Untitled').respond_to?(:bio) # => false
|
|
53
|
+
# begin
|
|
54
|
+
# Class.new(Reprint) { delegate_optional :name, to: :writer, allow_nil: false }
|
|
55
|
+
# rescue ArgumentError => e
|
|
56
|
+
# e.message
|
|
57
|
+
# end # => 'delegate_optional reads an absent target as None; allow_nil: false asks for something else'
|
|
58
|
+
# @example a delegation needs a target
|
|
59
|
+
# begin
|
|
60
|
+
# Class.new(Reprint) { delegate_optional :name }
|
|
61
|
+
# rescue ArgumentError => e
|
|
62
|
+
# e.message
|
|
63
|
+
# end.start_with?("Delegation needs a target. Supply a keyword argument 'to'") # => true
|
|
64
|
+
# @example a writer is not delegated
|
|
65
|
+
# begin
|
|
66
|
+
# Class.new(Reprint) { delegate_optional :name=, to: :writer }
|
|
67
|
+
# rescue ArgumentError => e
|
|
68
|
+
# e.message
|
|
69
|
+
# end # => 'delegate_optional does not delegate a writer; an absent target would drop the value assigned'
|
|
70
|
+
# @example a module target has no name to prefix with
|
|
71
|
+
# begin
|
|
72
|
+
# Class.new(Reprint) { delegate_optional :name, to: Errgonomic, prefix: true }
|
|
73
|
+
# rescue ArgumentError => e
|
|
74
|
+
# e.message
|
|
75
|
+
# end # => "prefix: true takes the target's own name, and a module target has none; name the prefix"
|
|
76
|
+
# @example an automatic prefix needs a target it can name a method after
|
|
77
|
+
# begin
|
|
78
|
+
# Class.new(Article) { delegate_optional :name, to: :@writer, prefix: true }
|
|
79
|
+
# rescue ArgumentError => e
|
|
80
|
+
# e.message
|
|
81
|
+
# end # => 'Can only automatically set the delegation prefix when delegating to a method.'
|
|
11
82
|
class_methods do
|
|
12
83
|
# Names attributes that ActiveRecordOptional must leave alone. It has to
|
|
13
84
|
# be callable before the include, which is what starts the wrapping for
|
|
@@ -25,19 +96,108 @@ module Errgonomic
|
|
|
25
96
|
superclass.respond_to?(:errgonomic_optional_exceptions) ? superclass.errgonomic_optional_exceptions.dup : []
|
|
26
97
|
end
|
|
27
98
|
|
|
28
|
-
|
|
29
|
-
|
|
99
|
+
# How a None reaches a payload. :null writes it as null, which is
|
|
100
|
+
# what Rails does with nil and what serde does with None unless a
|
|
101
|
+
# field asks otherwise, so it is the default and needs no
|
|
102
|
+
# declaration. :omit leaves the key out instead. only: and except:
|
|
103
|
+
# scope the mode to named readers, and a reader outside the scope
|
|
104
|
+
# keeps the default. Configuration reads as well above the include
|
|
105
|
+
# as below it, so it lives here rather than in the concern.
|
|
106
|
+
def errgonomic_serialize_none(mode, only: nil, except: nil)
|
|
107
|
+
complaint = errgonomic_serialize_none_complaint(mode, only, except)
|
|
108
|
+
raise ::ArgumentError, "errgonomic_serialize_none #{complaint}" if complaint
|
|
109
|
+
|
|
110
|
+
@errgonomic_serialize_none = {
|
|
111
|
+
mode: mode,
|
|
112
|
+
only: only && Array(only).map(&:to_s),
|
|
113
|
+
except: except && Array(except).map(&:to_s)
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The nearest declaration is the whole story for a class: it replaces
|
|
118
|
+
# whatever it inherits rather than layering onto it, so a scoped one
|
|
119
|
+
# leaves every reader it does not name at the default.
|
|
120
|
+
def errgonomic_serialize_none_declaration
|
|
121
|
+
return @errgonomic_serialize_none if defined?(@errgonomic_serialize_none)
|
|
122
|
+
return nil unless superclass.respond_to?(:errgonomic_serialize_none_declaration)
|
|
123
|
+
|
|
124
|
+
superclass.errgonomic_serialize_none_declaration
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A declaration that cannot change what a payload looks like is a
|
|
128
|
+
# mistake rather than a no-op, so say what to write instead. :null is
|
|
129
|
+
# already what every unnamed reader gets, so scoping it names one set
|
|
130
|
+
# of readers for the default and leaves the rest at the default too.
|
|
131
|
+
def errgonomic_serialize_none_complaint(mode, only, except)
|
|
132
|
+
return "takes :null or :omit, not #{mode.inspect}" unless %i[null omit].include?(mode)
|
|
133
|
+
return 'takes only: or except:, not both; name the readers on one of them' if only && except
|
|
134
|
+
return unless mode == :null && (only || except)
|
|
135
|
+
|
|
136
|
+
':null is the default for every reader and takes no only: or except:; ' \
|
|
137
|
+
'declare :omit on the readers to leave out'
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def delegate_optional(*methods, to: nil, prefix: nil, private: nil, allow_nil: nil)
|
|
141
|
+
declared_at = caller_locations(1, 1).first
|
|
142
|
+
complaint = delegate_optional_complaint(methods, to, prefix, allow_nil)
|
|
143
|
+
raise ::ArgumentError, complaint if complaint
|
|
30
144
|
|
|
145
|
+
receiver = delegate_optional_receiver(to)
|
|
31
146
|
methods.each do |method_name|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
#{to}.map { |obj| obj.send(:#{method_name}) }
|
|
36
|
-
end
|
|
37
|
-
RUBY
|
|
38
|
-
send(:private, prefixed_method_name) if private
|
|
147
|
+
reader = "#{delegate_optional_prefix(to, prefix)}#{method_name}"
|
|
148
|
+
define_optional_delegation(receiver, method_name, reader, declared_at)
|
|
149
|
+
private(reader) if private
|
|
39
150
|
end
|
|
40
151
|
end
|
|
152
|
+
|
|
153
|
+
# Both ends lift exactly one layer, so a record, a nil and an Option
|
|
154
|
+
# all delegate, and an Option the call returns is not wrapped twice.
|
|
155
|
+
# The call is written out rather than sent, so the target's method is
|
|
156
|
+
# reached on the same terms a caller would reach it on, and the reader
|
|
157
|
+
# takes the declaration's file and line so a backtrace names the model.
|
|
158
|
+
def define_optional_delegation(receiver, method_name, reader, declared_at)
|
|
159
|
+
class_eval <<-RUBY, declared_at.path, declared_at.lineno # rubocop:disable Style/EvalWithLocation
|
|
160
|
+
def #{reader}(...)
|
|
161
|
+
#{receiver}.to_option.and_then { |target| target.#{method_name}(...).to_option }
|
|
162
|
+
end
|
|
163
|
+
RUBY
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# A target named for a Ruby keyword reads as the keyword in the body
|
|
167
|
+
# it is written into, so it needs an explicit receiver. Rails answers
|
|
168
|
+
# the same question for delegate, and answers it for the same names.
|
|
169
|
+
def delegate_optional_receiver(to)
|
|
170
|
+
return to.to_s unless ::ActiveSupport::Delegation::RESERVED_METHOD_NAMES.include?(to.to_s)
|
|
171
|
+
|
|
172
|
+
"self.#{to}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# true asks for the target's own name; any other prefix is the name.
|
|
176
|
+
def delegate_optional_prefix(to, prefix)
|
|
177
|
+
return '' unless prefix
|
|
178
|
+
|
|
179
|
+
"#{prefix == true ? to : prefix}_"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# A mistake is worth more where the declaration is written than as a
|
|
183
|
+
# method nothing can call. allow_nil: true is what a delegation does
|
|
184
|
+
# here anyway, so a swap from delegate carries; its opposite does not.
|
|
185
|
+
def delegate_optional_complaint(methods, to, prefix, allow_nil)
|
|
186
|
+
return NO_TARGET if to.nil?
|
|
187
|
+
return NO_WRITERS if methods.any? { |method_name| /\A\w+=\z/.match?(method_name.to_s) }
|
|
188
|
+
return ALWAYS_NONE if allow_nil == false
|
|
189
|
+
|
|
190
|
+
delegate_optional_prefix_complaint(to, prefix)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# An automatic prefix is the target's own name, so the target needs
|
|
194
|
+
# one, and one that can start a method name.
|
|
195
|
+
def delegate_optional_prefix_complaint(to, prefix)
|
|
196
|
+
return unless prefix == true
|
|
197
|
+
return NO_NAME_TO_PREFIX if to.is_a?(::Module)
|
|
198
|
+
|
|
199
|
+
NO_METHOD_TO_PREFIX if /^[^a-z_]/.match?(to.to_s)
|
|
200
|
+
end
|
|
41
201
|
end
|
|
42
202
|
end
|
|
43
203
|
end
|