errgonomic 0.7.0 → 0.8.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.
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'option'
4
+ require_relative 'optional_dig'
5
+
6
+ module Errgonomic
7
+ # A companion to Hash whose lookups return Options, composed around a plain
8
+ # Hash rather than subclassing it. Subclassing cannot keep Option semantics:
9
+ # since Ruby 3, most Hash methods return plain Hash instances, so wrapped
10
+ # behavior silently drops off in pipelines. Composition with a small, closed
11
+ # API keeps the semantics honest; reach the plain Hash back with to_h.
12
+ #
13
+ # Lookups follow key presence, not value truthiness, so a key holding nil is
14
+ # Some(nil). This matches Option presence semantics: the discriminant tells
15
+ # you whether the key was there, and the inner value is yours to judge.
16
+ class OptionalHash
17
+ include OptionalDig
18
+
19
+ def initialize(hash = {})
20
+ unless hash.is_a?(::Hash)
21
+ raise Errgonomic::TypeMismatchError,
22
+ "OptionalHash wraps a Hash, got #{hash.class}"
23
+ end
24
+
25
+ @hash = hash
26
+ end
27
+
28
+ # Retrieve the value for a key, wrapped in an Option. A present key with
29
+ # a nil value is Some(nil); only a missing key is None.
30
+ #
31
+ # @example
32
+ # h = { color: :blue, shade: nil }.into_optional
33
+ # h[:color] # => Some(:blue)
34
+ # h[:shade] # => Some(nil)
35
+ # h[:smell] # => None()
36
+ def [](key)
37
+ return None() unless @hash.key?(key)
38
+
39
+ Some(@hash[key])
40
+ end
41
+
42
+ # Write through to the underlying hash.
43
+ #
44
+ # @example
45
+ # h = {}.into_optional
46
+ # h[:color] = :blue
47
+ # h[:color] # => Some(:blue)
48
+ def []=(key, value)
49
+ @hash[key] = value
50
+ end
51
+
52
+ # Like Hash#dig, but every step checks presence, so the result
53
+ # distinguishes an absent path (None) from a present nil (Some(nil)),
54
+ # which Hash#dig conflates. Walks nested Hashes, Arrays, and
55
+ # OptionalHashes; digging into anything else raises, pedantically, where
56
+ # Hash#dig would raise TypeError.
57
+ #
58
+ # @example
59
+ # h = { person: { name: 'Ada', middle_name: nil } }.into_optional
60
+ # h.dig(:person, :name) # => Some("Ada")
61
+ # h.dig(:person, :middle_name) # => Some(nil)
62
+ # h.dig(:person, :nickname) # => None()
63
+ # h.dig(:company, :name) # => None()
64
+ #
65
+ # @example arrays participate, with bounds checked
66
+ # h = { people: [{ name: 'Ada' }] }.into_optional
67
+ # h.dig(:people, 0, :name) # => Some("Ada")
68
+ # h.dig(:people, 1, :name) # => None()
69
+ #
70
+ # @example a nested wrapper walks the rest of the path itself
71
+ # h = { person: { name: 'Ada' }.into_optional }.into_optional
72
+ # h.dig(:person, :name) # => Some("Ada")
73
+ # h.dig(:person, :nickname) # => None()
74
+ #
75
+ # @example digging into a non-collection is an error, not a None
76
+ # h = { name: 'Ada' }.into_optional
77
+ # h.dig(:name, :length) # => raise Errgonomic::TypeMismatchError, "cannot dig into String"
78
+ def dig(key, *rest)
79
+ optional_dig(@hash, [key, *rest])
80
+ end
81
+
82
+ # @example
83
+ # h = { shade: nil }.into_optional
84
+ # h.key?(:shade) # => true
85
+ # h.key?(:color) # => false
86
+ def key?(key)
87
+ @hash.key?(key)
88
+ end
89
+
90
+ # @example
91
+ # {}.into_optional.empty? # => true
92
+ # { a: 1 }.into_optional.empty? # => false
93
+ def empty?
94
+ @hash.empty?
95
+ end
96
+
97
+ # @example
98
+ # { a: 1 }.into_optional.size # => 1
99
+ def size
100
+ @hash.size
101
+ end
102
+
103
+ # The escape hatch back to a plain Hash: a shallow copy, so hash-shaped
104
+ # code cannot mutate the wrapped state behind the Option semantics.
105
+ #
106
+ # @example
107
+ # { a: 1 }.into_optional.to_h # => { a: 1 }
108
+ def to_h
109
+ @hash.dup
110
+ end
111
+
112
+ # Equal to another OptionalHash wrapping an equal hash; never equal to a
113
+ # plain Hash, mirroring how Some(x) is never equal to x.
114
+ #
115
+ # @example
116
+ # { a: 1 }.into_optional == { a: 1 }.into_optional # => true
117
+ # { a: 1 }.into_optional == { a: 2 }.into_optional # => false
118
+ # { a: 1 }.into_optional == { a: 1 } # => false
119
+ def ==(other)
120
+ other.is_a?(OptionalHash) && inner == other.inner
121
+ end
122
+
123
+ # @example
124
+ # { a: 1 }.into_optional.eql?({ a: 1 }.into_optional) # => true
125
+ # h = { a: 1 }.into_optional
126
+ # { h => :hit }[{ a: 1 }.into_optional] # => :hit
127
+ def eql?(other)
128
+ other.is_a?(OptionalHash) && inner.eql?(other.inner)
129
+ end
130
+
131
+ def hash
132
+ [self.class, inner].hash
133
+ end
134
+
135
+ # @example
136
+ # {}.into_optional.inspect # => "OptionalHash({})"
137
+ def inspect
138
+ "OptionalHash(#{@hash.inspect})"
139
+ end
140
+
141
+ protected
142
+
143
+ def inner
144
+ @hash
145
+ end
146
+ end
147
+ end
@@ -5,6 +5,8 @@
5
5
  # gem a dependency.
6
6
  require_relative './core_ext/blank' unless Object.methods.include?(:blank?)
7
7
 
8
+ # Presence-based fallbacks for every object: keep the receiver when it is
9
+ # present, otherwise substitute, compute, or raise.
8
10
  class Object
9
11
  # Returns the receiver if it is present, otherwise raises a NotPresentError.
10
12
  # This method is useful to enforce strong expectations, where it is preferable
@@ -18,7 +20,7 @@ class Object
18
20
  self
19
21
  end
20
22
 
21
- alias_method :present_or_raise, :present_or_raise!
23
+ alias present_or_raise present_or_raise!
22
24
 
23
25
  # Returns the receiver if it is present, otherwise returns the given value. If
24
26
  # constructing the default value is expensive, consider using
@@ -61,7 +63,7 @@ class Object
61
63
  self
62
64
  end
63
65
 
64
- alias_method :blank_or_raise, :blank_or_raise!
66
+ alias blank_or_raise blank_or_raise!
65
67
 
66
68
  # Returns the receiver if it is blank, otherwise returns the given value.
67
69
  #
@@ -1,5 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Errgonomic
2
4
  module Rails
5
+ # Adds a `delegate_optional` class method in the spirit of Rails'
6
+ # `delegate`, returning an Option instead of nil or NoMethodError when
7
+ # the delegation target is absent.
3
8
  module ActiveRecordDelegateOptional
4
9
  extend ActiveSupport::Concern
5
10
 
@@ -14,6 +19,7 @@ module Errgonomic
14
19
  #{to}.map { |obj| obj.send(:#{method_name}) }
15
20
  end
16
21
  RUBY
22
+ send(:private, prefixed_method_name) if private
17
23
  end
18
24
  end
19
25
  end
@@ -4,6 +4,21 @@ module Errgonomic
4
4
  module Rails
5
5
  # Concern to make ActiveRecord optional attributes and associations return an Option.
6
6
  #
7
+ # Four pragmatic compromises below satisfy ActiveRecord's assumptions
8
+ # about how accessors behave. They are deliberate exceptions to "Option
9
+ # behaves like Rust's Option", and the set is closed: a fifth would be a
10
+ # signal that ActiveRecord is pushing back somewhere unmapped, deserving
11
+ # a design discussion rather than a quiet patch.
12
+ #
13
+ # 1. None#nil? answers true, so AR internals and ordinary nil checks
14
+ # treat an absent value as absent. Equality does not follow suit:
15
+ # None() == nil stays false.
16
+ # 2. Some delegates persisted?, marked_for_destruction?, and touch_later
17
+ # to its record, so a Some can stand in for it during persistence.
18
+ # 3. Two quoting prepends unwrap Options at the SQL boundary, so an
19
+ # Option can be passed to where/quote.
20
+ # 4. SomeValidator provides a presence-style validation for Option
21
+ # attributes.
7
22
  module ActiveRecordOptional
8
23
  extend ActiveSupport::Concern
9
24
 
@@ -18,8 +33,19 @@ module Errgonomic
18
33
  @errgonomic_optionals.each do |name|
19
34
  class_eval <<-RUBY, __FILE__, __LINE__ + 1
20
35
  def #{name}
21
- raise "stack too deep" if caller.length > 1024
22
- val = super
36
+ reads = Thread.current[:errgonomic_optional_reads] ||= {}
37
+ key = [object_id, :#{name}]
38
+ if reads[key]
39
+ raise Errgonomic::RecursiveOptionalReadError,
40
+ "\#{self.class}##{name} re-entered itself; something beneath this reader reads it again"
41
+ end
42
+
43
+ reads[key] = true
44
+ begin
45
+ val = super
46
+ ensure
47
+ reads.delete(key)
48
+ end
23
49
  val.nil? ? Errgonomic::Option::None.new : Errgonomic::Option::Some.new(val)
24
50
  end
25
51
  RUBY
@@ -35,7 +61,8 @@ module Errgonomic
35
61
  end
36
62
  end
37
63
 
38
- # do we need this since we alias present below?
64
+ # Validates that an Option attribute is Some, analogous to a presence
65
+ # validation on a plain attribute.
39
66
  class SomeValidator < ActiveModel::EachValidator
40
67
  def validate_each(record, attribute, value)
41
68
  record.errors.add(attribute, 'is invalid') unless value.some?
@@ -44,17 +71,16 @@ end
44
71
 
45
72
  module Errgonomic
46
73
  module Option
47
- class Any
48
- alias some? present?
49
- alias none? blank?
50
- end
51
-
74
+ # Delegate ActiveRecord lifecycle checks to the wrapped record, so a Some
75
+ # can stand in for its record during persistence.
52
76
  class Some
53
77
  delegate :marked_for_destruction?, to: :value
54
78
  delegate :persisted?, to: :value
55
79
  delegate :touch_later, to: :value
56
80
  end
57
81
 
82
+ # A None answers nil? like nil itself, so ActiveRecord internals that
83
+ # check for nil treat an absent value as absent.
58
84
  class None
59
85
  def nil?
60
86
  true
@@ -63,6 +89,8 @@ module Errgonomic
63
89
  end
64
90
  end
65
91
 
92
+ # Teach ActiveRecord type casting to unwrap Options: a Some casts as its
93
+ # inner value, a None casts as nil.
66
94
  module ActiveRecordOptionShim
67
95
  def type_cast(value)
68
96
  case value
@@ -78,12 +106,14 @@ end
78
106
 
79
107
  ActiveRecord::ConnectionAdapters::Quoting.prepend(ActiveRecordOptionShim)
80
108
 
109
+ # Lift nil into None.
81
110
  class NilClass
82
111
  def to_option
83
112
  None()
84
113
  end
85
114
  end
86
115
 
116
+ # Lift any other value into Some.
87
117
  class Object
88
118
  def to_option
89
119
  Some(self)
@@ -92,6 +122,8 @@ end
92
122
 
93
123
  module Errgonomic
94
124
  module Rails
125
+ # Teach ActiveRecord SQL quoting to unwrap Options, quoting a None as
126
+ # SQL NULL.
95
127
  module ActiveRecordQuoting
96
128
  def quote(value)
97
129
  return super(value) unless value.is_a?(Errgonomic::Option::Any)
@@ -1,4 +1,6 @@
1
- require_relative 'option'
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errgonomic'
2
4
  require_relative 'rails/active_record_optional'
3
5
  require_relative 'rails/active_record_delegate_optional'
4
6
 
@@ -6,12 +6,77 @@ module Errgonomic
6
6
  # much logic as possible here, and let Ok and Err handle their
7
7
  # initialization and self identification.
8
8
  class Any
9
+ include Comparable
10
+
9
11
  attr_reader :value
10
12
 
11
13
  def initialize(value)
12
14
  @value = value
13
15
  end
14
16
 
17
+ # Results order like Rust's: Ok sorts before any Err, and same variants
18
+ # order by their inner values. Follows Ruby's <=> convention of
19
+ # returning nil for incomparable operands, whether the other object is
20
+ # not a Result or the inner values do not themselves compare.
21
+ #
22
+ # @example
23
+ # (Ok(1) <=> Ok(2)) # => -1
24
+ # (Ok(1) <=> Err(:a)) # => -1
25
+ # (Err(:a) <=> Ok(1)) # => 1
26
+ # (Err(:a) <=> Err(:b)) # => -1
27
+ # (Ok(1) <=> 1) # => nil
28
+ # [Err(:a), Ok(2), Ok(1)].sort # => [Ok(1), Ok(2), Err(:a)]
29
+ def <=>(other)
30
+ return nil unless other.is_a?(Errgonomic::Result::Any)
31
+ return ok? ? -1 : 1 if self.class != other.class
32
+
33
+ value <=> other.value
34
+ end
35
+
36
+ # Rust spellings we accept but do not advertise: they delegate to the
37
+ # Ruby-idiomatic predicate and nudge the caller there via stderr.
38
+ RUST_SPELLINGS = {
39
+ is_ok: :ok?,
40
+ is_err: :err?,
41
+ is_ok_and: :ok_and?,
42
+ is_err_and: :err_and?
43
+ }.freeze
44
+
45
+ # A Result deliberately forwards nothing to its inner value, so a miss
46
+ # here is almost always someone treating the container as its contents.
47
+ # Teach the route out instead of leaving a bare NoMethodError. Rust
48
+ # spellings of the predicates delegate, with a nudge on stderr.
49
+ #
50
+ # @example
51
+ # begin
52
+ # Ok(5) + 1
53
+ # rescue NoMethodError => e
54
+ # e.class
55
+ # end # => Errgonomic::UnwrappedAccessError
56
+ # Ok(5).respond_to?(:+) # => false
57
+ # Ok(1).is_ok_and(&:odd?) # => true
58
+ # Err(:a).is_err # => true
59
+ # Ok(1).respond_to?(:is_ok) # => true
60
+ def method_missing(name, *args, &block)
61
+ if (canonical = RUST_SPELLINGS[name])
62
+ warn "Errgonomic: `#{name}` is the Rust spelling; prefer `#{canonical}`. Delegating."
63
+ return public_send(canonical, *args, &block)
64
+ end
65
+
66
+ raise Errgonomic::UnwrappedAccessError.new(<<~MSG, name)
67
+ undefined method `#{name}' for #{inspect}, a Result, which does not forward methods to its inner value.
68
+ Reach for a combinator instead:
69
+ map, map_err, and_then, or_else: transform the value or the error
70
+ unwrap_or, unwrap_or_else: supply a fallback
71
+ ok_and?, err_and?: test a predicate against the inner value
72
+ unwrap!, unwrap_err!, and expect! also exist, but are intended for tests rather than application code.
73
+ MSG
74
+ end
75
+
76
+ def respond_to_missing?(name, include_private = false)
77
+ RUST_SPELLINGS.key?(name) || super
78
+ end
79
+
15
80
  # Equality comparison for Result objects is based on value not reference.
16
81
  #
17
82
  # @param other [Object]
@@ -28,6 +93,27 @@ module Errgonomic
28
93
  value == other.value
29
94
  end
30
95
 
96
+ # Hash-based collections (Hash keys, Set, uniq, group_by) use eql? and
97
+ # hash, not ==. Follow the inner value's own eql? semantics, so Results
98
+ # behave as keys exactly like their inner values.
99
+ #
100
+ # @example
101
+ # Ok(5).eql?(Ok(5)) # => true
102
+ # Ok(1).eql?(Ok(1.0)) # => false
103
+ # Ok(1).eql?(Err(1)) # => false
104
+ # { Ok(5) => 1 }[Ok(5)] # => 1
105
+ # [Err(:a), Err(:a)].uniq # => [Err(:a)]
106
+ def eql?(other)
107
+ self.class == other.class && value.eql?(other.value)
108
+ end
109
+
110
+ # @example
111
+ # Ok(5).hash == Ok(5).hash # => true
112
+ # Ok(5).hash == Err(5).hash # => false
113
+ def hash
114
+ [self.class, value].hash
115
+ end
116
+
31
117
  # Indicate that this is some kind of result object. Contrast to
32
118
  # Object#result? which is false for all other types.
33
119
  #
@@ -283,6 +369,12 @@ module Errgonomic
283
369
  raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Result'
284
370
  end
285
371
 
372
+ # pp uses its own object dump unless told otherwise; keep it consistent
373
+ # with inspect.
374
+ def pretty_print(pp)
375
+ pp.text(inspect)
376
+ end
377
+
286
378
  # @example simple pattern match with variable capture of the value
287
379
  # result = Errgonomic::Result::Ok.new(1)
288
380
  # case result
@@ -305,7 +397,6 @@ module Errgonomic
305
397
  def deconstruct
306
398
  [self, value]
307
399
  end
308
-
309
400
  end
310
401
 
311
402
  # The Ok variant.
@@ -327,8 +418,18 @@ module Errgonomic
327
418
  def err?
328
419
  false
329
420
  end
421
+
422
+ # Render like Rust's Debug, delegating to the inner value's inspect.
423
+ #
424
+ # @example
425
+ # Ok(1).inspect # => "Ok(1)"
426
+ # Ok("x").inspect # => "Ok(\"x\")"
427
+ def inspect
428
+ "Ok(#{value.inspect})"
429
+ end
330
430
  end
331
431
 
432
+ # The Err variant.
332
433
  class Err < Any
333
434
  class Arbitrary; end
334
435
 
@@ -358,15 +459,25 @@ module Errgonomic
358
459
  def ok?
359
460
  false
360
461
  end
462
+
463
+ # Render like Rust's Debug; a value-less Err renders bare.
464
+ #
465
+ # @example
466
+ # Err(:nope).inspect # => "Err(:nope)"
467
+ # Err().inspect # => "Err()"
468
+ def inspect
469
+ return 'Err()' if value == Arbitrary
470
+
471
+ "Err(#{value.inspect})"
472
+ end
361
473
  end
362
474
  end
363
475
  end
364
476
 
365
- # Introduce certain helper methods into the Object class.
366
- #
367
- # @example
368
- # "foo".result? # => false
369
- # "foo".assert_result! # => raise Errgonomic::ResultRequiredError
477
+ # Introduce result-ness helpers into the Object class. No doctests here:
478
+ # several files reopen Object, and YARD keeps only one docstring for it, so
479
+ # examples on the class itself can be silently dropped. Each method carries
480
+ # its own examples instead.
370
481
  class Object
371
482
  # Convenience method to indicate whether we are working with a result.
372
483
  # TBD whether we implement some stubs for the rest of the Result API; I want
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Runtime type assertions for every object: keep the receiver when it matches
4
+ # the expected type, otherwise substitute, compute, or raise.
3
5
  class Object
4
6
  # Returns the receiver if it matches the expected type, otherwise raises a TypeMismatchError.
5
7
  # This is useful for enforcing type expectations in method arguments.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Errgonomic
4
- VERSION = '0.7.0'
4
+ VERSION = '0.8.0'
5
5
  end
data/lib/errgonomic.rb CHANGED
@@ -12,6 +12,13 @@ require_relative 'errgonomic/type'
12
12
  require_relative 'errgonomic/option'
13
13
  require_relative 'errgonomic/result'
14
14
 
15
+ # Option-returning lookups for the collections.
16
+ require_relative 'errgonomic/core_ext/hash'
17
+ require_relative 'errgonomic/core_ext/array'
18
+
19
+ # Lift booleans into Option and Result.
20
+ require_relative 'errgonomic/core_ext/bool'
21
+
15
22
  # Rails fu
16
23
  require_relative 'errgonomic/rails' if defined?(Rails::Railtie)
17
24
 
@@ -29,6 +36,8 @@ module Errgonomic
29
36
 
30
37
  class TypeMismatchError < Error; end
31
38
 
39
+ # Raised when unwrap! is called on a None or an Err. Carries the Err's
40
+ # inner value so diagnostics can show what actually went wrong.
32
41
  class UnwrapError < Error
33
42
  attr_reader :value
34
43
 
@@ -44,8 +53,18 @@ module Errgonomic
44
53
 
45
54
  class ResultRequiredError < Error; end
46
55
 
56
+ # Raised when a wrapped ActiveRecord attribute reader re-enters itself on
57
+ # the same record, catching runaway recursion at the first repeated frame
58
+ # instead of a SystemStackError thousands of frames later.
59
+ class RecursiveOptionalReadError < Error; end
60
+
47
61
  class NotComparableError < StandardError; end
48
62
 
63
+ # Raised when a method missing from Option or Result is called, with a
64
+ # message that teaches the combinators. Subclasses NoMethodError so every
65
+ # rescue path and Ruby-internal probe that expects one keeps working.
66
+ class UnwrappedAccessError < ::NoMethodError; end
67
+
49
68
  class SerializeError < TypeError; end
50
69
 
51
70
  # A little bit of control over how pedantic we are in our runtime type checks.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: errgonomic
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Zadrozny
@@ -66,16 +66,23 @@ files:
66
66
  - ".yardopts"
67
67
  - CHANGELOG.md
68
68
  - CODE_OF_CONDUCT.md
69
+ - CONTRIBUTING.md
69
70
  - LICENSE.txt
70
71
  - README.md
71
72
  - Rakefile
72
73
  - doctest_helper.rb
73
74
  - flake.lock
74
75
  - flake.nix
75
- - gemset.nix
76
+ - gem-groups.json
76
77
  - lib/errgonomic.rb
78
+ - lib/errgonomic/core_ext/array.rb
77
79
  - lib/errgonomic/core_ext/blank.rb
80
+ - lib/errgonomic/core_ext/bool.rb
81
+ - lib/errgonomic/core_ext/hash.rb
78
82
  - lib/errgonomic/option.rb
83
+ - lib/errgonomic/optional_array.rb
84
+ - lib/errgonomic/optional_dig.rb
85
+ - lib/errgonomic/optional_hash.rb
79
86
  - lib/errgonomic/presence.rb
80
87
  - lib/errgonomic/rails.rb
81
88
  - lib/errgonomic/rails/active_record_delegate_optional.rb
@@ -104,7 +111,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
104
111
  - !ruby/object:Gem::Version
105
112
  version: '0'
106
113
  requirements: []
107
- rubygems_version: 3.6.6
114
+ rubygems_version: 3.7.2
108
115
  specification_version: 4
109
116
  summary: Opinionated, ergonomic error handling for Ruby, inspired by Rails and Rust.
110
117
  test_files: []