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/doctest_helper.rb
CHANGED
|
@@ -1,3 +1,156 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'active_record'
|
|
4
|
+
require 'logger'
|
|
5
|
+
|
|
3
6
|
require_relative 'lib/errgonomic'
|
|
7
|
+
require_relative 'lib/errgonomic/rails'
|
|
8
|
+
|
|
9
|
+
# The Rails integration patches ActiveRecord as it loads, so examples under
|
|
10
|
+
# lib/errgonomic/rails need a live connection and a model to run against.
|
|
11
|
+
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
|
|
12
|
+
ActiveRecord::Base.logger = Logger.new(File::NULL)
|
|
13
|
+
|
|
14
|
+
# One nullable column per type the cast boundary has to map.
|
|
15
|
+
ActiveRecord::Schema.verbose = false
|
|
16
|
+
ActiveRecord::Schema.define do
|
|
17
|
+
create_table 'writers', force: :cascade do |t|
|
|
18
|
+
t.string :name
|
|
19
|
+
t.text :bio
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
create_table 'articles', force: :cascade do |t|
|
|
23
|
+
t.string :title
|
|
24
|
+
t.references :writer
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
create_table 'reports', force: :cascade do |t|
|
|
28
|
+
t.string :type, null: false
|
|
29
|
+
t.string :title
|
|
30
|
+
t.text :summary
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
create_table 'notes', force: :cascade do |t|
|
|
34
|
+
t.boolean :pinned
|
|
35
|
+
t.string :title
|
|
36
|
+
t.text :body
|
|
37
|
+
t.json :meta
|
|
38
|
+
t.integer :rank
|
|
39
|
+
t.float :score
|
|
40
|
+
t.decimal :price
|
|
41
|
+
t.date :due_on
|
|
42
|
+
t.datetime :read_at
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
Errgonomic::Rails.setup_before
|
|
47
|
+
|
|
48
|
+
class Note < ActiveRecord::Base
|
|
49
|
+
include Errgonomic::Rails::ActiveRecordOptional
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# ActionText and ActiveStorage declare singular associations of their own and
|
|
53
|
+
# read them raw. A reflection names its class as a string, so a model can be
|
|
54
|
+
# asked which readers it wrapped without either engine loaded.
|
|
55
|
+
class Dispatch < ActiveRecord::Base
|
|
56
|
+
self.table_name = 'notes'
|
|
57
|
+
include Errgonomic::Rails::ActiveRecordOptional
|
|
58
|
+
has_one :rich_text_body, class_name: 'ActionText::RichText', as: :record
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A subclass wraps nothing of its own: an inherited reader is already an
|
|
62
|
+
# Option, and wrapping it again would nest it.
|
|
63
|
+
class Report < ActiveRecord::Base
|
|
64
|
+
include Errgonomic::Rails::ActiveRecordOptional
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class Briefing < Report; end
|
|
68
|
+
|
|
69
|
+
# An unconverted delegation target: its readers hand back plain values, and
|
|
70
|
+
# two of its methods take an argument, a keyword and a block.
|
|
71
|
+
class Writer < ActiveRecord::Base
|
|
72
|
+
def greeting(salutation, punctuation: '.')
|
|
73
|
+
"#{salutation}, #{name}#{punctuation}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def styled_name
|
|
77
|
+
yield(name)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A converted model reads its association as an Option.
|
|
82
|
+
class Article < ActiveRecord::Base
|
|
83
|
+
include Errgonomic::Rails::ActiveRecordOptional
|
|
84
|
+
belongs_to :writer, optional: true
|
|
85
|
+
delegate_optional :name, to: :writer, prefix: true
|
|
86
|
+
delegate_optional :name, to: :writer, prefix: :author
|
|
87
|
+
delegate_optional :bio, to: :writer
|
|
88
|
+
delegate_optional :greeting, :styled_name, to: :writer, prefix: true
|
|
89
|
+
delegate_optional :table_name, to: :class
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The same records read through a converted model, so the target's own
|
|
93
|
+
# reader is already an Option.
|
|
94
|
+
class Byline < ActiveRecord::Base
|
|
95
|
+
self.table_name = 'writers'
|
|
96
|
+
include Errgonomic::Rails::ActiveRecordOptional
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Unconverted, so the association reader hands back a plain record or nil.
|
|
100
|
+
class Draft < ActiveRecord::Base
|
|
101
|
+
self.table_name = 'articles'
|
|
102
|
+
belongs_to :writer, optional: true
|
|
103
|
+
belongs_to :byline, class_name: 'Byline', foreign_key: :writer_id, optional: true
|
|
104
|
+
delegate_optional :name, to: :writer, prefix: true
|
|
105
|
+
delegate_optional :name, to: :byline, prefix: true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A mechanical swap from Rails' delegate carries allow_nil: true along, and
|
|
109
|
+
# a delegation declared private stays off the public surface.
|
|
110
|
+
class Reprint < ActiveRecord::Base
|
|
111
|
+
self.table_name = 'articles'
|
|
112
|
+
belongs_to :writer, optional: true
|
|
113
|
+
delegate_optional :name, to: :writer, prefix: true, allow_nil: true
|
|
114
|
+
delegate_optional :bio, to: :writer, private: true
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Two validators that answer differently for the same wrapped value.
|
|
118
|
+
class Memo < ActiveRecord::Base
|
|
119
|
+
self.table_name = 'notes'
|
|
120
|
+
include Errgonomic::Rails::ActiveRecordOptional
|
|
121
|
+
validates :title, presence: true
|
|
122
|
+
validates :body, some: true
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# yard-doctest sends any expectation that answers nil? to assert_nil, and
|
|
126
|
+
# under the Rails integration None() answers it. Compare an expected Option by
|
|
127
|
+
# value, so `# => None()` keeps meaning what it says.
|
|
128
|
+
module DoctestOptionEquality
|
|
129
|
+
def assert_example(example, expected, actual, bind)
|
|
130
|
+
# An expectation is a literal in the example's own binding, so leaving
|
|
131
|
+
# the other branches to super costs nothing but evaluating it twice.
|
|
132
|
+
return super unless evaluate_with_assertion(expected, bind).is_a?(Errgonomic::Option::Any)
|
|
133
|
+
|
|
134
|
+
assert_equal(evaluate_with_assertion(expected, bind), evaluate_with_assertion(actual, bind))
|
|
135
|
+
rescue Minitest::Assertion => e
|
|
136
|
+
add_filepath_to_backtrace(e, example.filepath)
|
|
137
|
+
raise e
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
YARD::Doctest::Example.prepend(DoctestOptionEquality)
|
|
142
|
+
|
|
143
|
+
# A declared default is cast on its way into a new record rather than assigned
|
|
144
|
+
# through a writer.
|
|
145
|
+
class DefaultedNote < ActiveRecord::Base
|
|
146
|
+
self.table_name = 'notes'
|
|
147
|
+
attribute :rank, :integer, default: Some(0)
|
|
148
|
+
attribute :title, :string, default: None()
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A Proc default is called when the record is built, so what it returns meets
|
|
152
|
+
# the column type exactly where a literal default does.
|
|
153
|
+
class ProcDefaultedNote < ActiveRecord::Base
|
|
154
|
+
self.table_name = 'notes'
|
|
155
|
+
attribute :title, :string, default: -> { Some('Wanderer') }
|
|
156
|
+
end
|
data/gem-groups.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"errgonomic":["default"],"yard":["development"],"yard-doctest":["development"],"activerecord":["development"],"minitest":["development"],"rails":["development"],"rake":["development"],"rspec":["development"],"rubocop":["development"],"rubocop-yard":["development"],"solargraph":["development"],"sqlite3":["development"],"concurrent-ruby":["default","development"],"drb":["development"],"prism":["development"],"activemodel":["development"],"activesupport":["development"],"base64":["development"],"bigdecimal":["development"],"connection_pool":["development"],"i18n":["development"],"json":["development"],"logger":["development"],"securerandom":["development"],"timeout":["development"],"tzinfo":["development"],"uri":["development"],"action_text-trix":["development"],"actioncable":["development"],"actionmailbox":["development"],"actionmailer":["development"],"actionpack":["development"],"actiontext":["development"],"actionview":["development"],"activejob":["development"],"activestorage":["development"],"builder":["development"],"bundler":["development"],"crass":["development"],"date":["development"],"erb":["development"],"erubi":["development"],"globalid":["development"],"io-console":["development"],"irb":["development"],"loofah":["development"],"mail":["development"],"marcel":["development"],"mini_mime":["development"],"net-imap":["development"],"net-pop":["development"],"net-protocol":["development"],"net-smtp":["development"],"nio4r":["development"],"nokogiri":["development"],"pp":["development"],"prettyprint":["development"],"psych":["development"],"racc":["development"],"rack":["development"],"rack-session":["development"],"rack-test":["development"],"rackup":["development"],"rails-dom-testing":["development"],"rails-html-sanitizer":["development"],"railties":["development"],"rdoc":["development"],"reline":["development"],"stringio":["development"],"thor":["development"],"tsort":["development"],"useragent":["development"],"websocket-driver":["development"],"websocket-extensions":["development"],"zeitwerk":["development"],"diff-lcs":["development"],"rspec-core":["development"],"rspec-expectations":["development"],"rspec-mocks":["development"],"rspec-support":["development"],"ast":["development"],"language_server-protocol":["development"],"lint_roller":["development"],"parallel":["development"],"parser":["development"],"rainbow":["development"],"regexp_parser":["development"],"rubocop-ast":["development"],"ruby-progressbar":["development"],"unicode-display_width":["development"],"unicode-emoji":["development"],"backport":["development"],"benchmark":["development"],"commander":["development"],"highline":["development"],"jaro_winkler":["development"],"kramdown":["development"],"kramdown-parser-gfm":["development"],"observer":["development"],"open3":["development"],"ostruct":["development"],"parlour":["development"],"rbs":["development"],"reverse_markdown":["development"],"rexml":["development"],"sorbet-runtime":["development"],"sord":["development"],"tilt":["development"],"yard-activesupport-concern":["development"],"yard-solargraph":["development"]}
|
|
1
|
+
{"errgonomic":["default"],"yard":["development"],"yard-doctest":["development"],"activerecord":["development"],"bcrypt":["development"],"minitest":["development"],"rails":["development"],"rake":["development"],"rspec":["development"],"rubocop":["development"],"rubocop-yard":["development"],"solargraph":["development"],"sqlite3":["development"],"concurrent-ruby":["default","development"],"drb":["development"],"prism":["development"],"activemodel":["development"],"activesupport":["development"],"base64":["development"],"bigdecimal":["development"],"connection_pool":["development"],"i18n":["development"],"json":["development"],"logger":["development"],"securerandom":["development"],"timeout":["development"],"tzinfo":["development"],"uri":["development"],"action_text-trix":["development"],"actioncable":["development"],"actionmailbox":["development"],"actionmailer":["development"],"actionpack":["development"],"actiontext":["development"],"actionview":["development"],"activejob":["development"],"activestorage":["development"],"builder":["development"],"bundler":["development"],"crass":["development"],"date":["development"],"erb":["development"],"erubi":["development"],"globalid":["development"],"io-console":["development"],"irb":["development"],"loofah":["development"],"mail":["development"],"marcel":["development"],"mini_mime":["development"],"net-imap":["development"],"net-pop":["development"],"net-protocol":["development"],"net-smtp":["development"],"nio4r":["development"],"nokogiri":["development"],"pp":["development"],"prettyprint":["development"],"psych":["development"],"racc":["development"],"rack":["development"],"rack-session":["development"],"rack-test":["development"],"rackup":["development"],"rails-dom-testing":["development"],"rails-html-sanitizer":["development"],"railties":["development"],"rdoc":["development"],"reline":["development"],"stringio":["development"],"thor":["development"],"tsort":["development"],"useragent":["development"],"websocket-driver":["development"],"websocket-extensions":["development"],"zeitwerk":["development"],"diff-lcs":["development"],"rspec-core":["development"],"rspec-expectations":["development"],"rspec-mocks":["development"],"rspec-support":["development"],"ast":["development"],"language_server-protocol":["development"],"lint_roller":["development"],"parallel":["development"],"parser":["development"],"rainbow":["development"],"regexp_parser":["development"],"rubocop-ast":["development"],"ruby-progressbar":["development"],"unicode-display_width":["development"],"unicode-emoji":["development"],"backport":["development"],"benchmark":["development"],"commander":["development"],"highline":["development"],"jaro_winkler":["development"],"kramdown":["development"],"kramdown-parser-gfm":["development"],"observer":["development"],"open3":["development"],"ostruct":["development"],"parlour":["development"],"rbs":["development"],"reverse_markdown":["development"],"rexml":["development"],"sorbet-runtime":["development"],"sord":["development"],"tilt":["development"],"yard-activesupport-concern":["development"],"yard-solargraph":["development"]}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../option'
|
|
4
|
+
require_relative '../result'
|
|
5
|
+
|
|
6
|
+
# The all-or-nothing collection, which Rust spells as a collect into
|
|
7
|
+
# Option<Vec<T>> or Result<Vec<T>, E>. On Enumerable rather than Array so it
|
|
8
|
+
# composes with map, and so anything that yields wrappers can be sequenced.
|
|
9
|
+
module Enumerable
|
|
10
|
+
# Collect an Enumerable of Options into an Option of an Array, stopping at
|
|
11
|
+
# the first None. A member that is not an Option raises unconditionally:
|
|
12
|
+
# with_ambiguous_downstream_errors relaxes what a block returned, not what
|
|
13
|
+
# a caller passed in.
|
|
14
|
+
#
|
|
15
|
+
# @return [Errgonomic::Option::Any]
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# [Some(1), Some(2)].sequence_options # => Some([1, 2])
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# [Some(1), None(), Some(3)].sequence_options # => None()
|
|
22
|
+
#
|
|
23
|
+
# @example
|
|
24
|
+
# [].sequence_options # => Some([])
|
|
25
|
+
#
|
|
26
|
+
# @example a lazy enumerable is read only as far as the first None
|
|
27
|
+
# seen = []
|
|
28
|
+
# lazy = [Some(1), None(), Some(3)].lazy.map { |o| seen << o; o }
|
|
29
|
+
# lazy.sequence_options # => None()
|
|
30
|
+
# seen.size # => 2
|
|
31
|
+
#
|
|
32
|
+
# @example
|
|
33
|
+
# [Some(1), 2].sequence_options # => raise Errgonomic::TypeMismatchError, "cannot sequence_options Integer; it is not an Option"
|
|
34
|
+
#
|
|
35
|
+
# @example a Hash yields pairs, which are Arrays
|
|
36
|
+
# { a: Some(1) }.sequence_options # => raise Errgonomic::TypeMismatchError, "cannot sequence_options Array; it is not an Option"
|
|
37
|
+
#
|
|
38
|
+
# @example sequence a Hash by its values
|
|
39
|
+
# { a: Some(1), b: Some(2) }.values.sequence_options # => Some([1, 2])
|
|
40
|
+
def sequence_options
|
|
41
|
+
values = []
|
|
42
|
+
each do |member|
|
|
43
|
+
unless member.is_a?(Errgonomic::Option::Any)
|
|
44
|
+
raise Errgonomic::TypeMismatchError,
|
|
45
|
+
"cannot sequence_options #{member.class}; it is not an Option"
|
|
46
|
+
end
|
|
47
|
+
return None() if member.none?
|
|
48
|
+
|
|
49
|
+
values << member.value
|
|
50
|
+
end
|
|
51
|
+
Some(values)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Collect an Enumerable of Results into a Result of an Array, stopping at
|
|
55
|
+
# the first Err, which is returned as it stands so it keeps its error.
|
|
56
|
+
# A member that is not a Result raises on the same terms as
|
|
57
|
+
# sequence_options.
|
|
58
|
+
#
|
|
59
|
+
# @return [Errgonomic::Result::Any]
|
|
60
|
+
#
|
|
61
|
+
# @example
|
|
62
|
+
# [Ok(1), Ok(2)].sequence_results # => Ok([1, 2])
|
|
63
|
+
#
|
|
64
|
+
# @example
|
|
65
|
+
# [Ok(1), Err(:nope), Ok(3)].sequence_results # => Err(:nope)
|
|
66
|
+
#
|
|
67
|
+
# @example
|
|
68
|
+
# [].sequence_results # => Ok([])
|
|
69
|
+
#
|
|
70
|
+
# @example a lazy enumerable is read only as far as the first Err
|
|
71
|
+
# seen = []
|
|
72
|
+
# lazy = [Ok(1), Err(:nope), Ok(3)].lazy.map { |r| seen << r; r }
|
|
73
|
+
# lazy.sequence_results # => Err(:nope)
|
|
74
|
+
# seen.size # => 2
|
|
75
|
+
#
|
|
76
|
+
# @example
|
|
77
|
+
# [Ok(1), Some(2)].sequence_results # => raise Errgonomic::TypeMismatchError, "cannot sequence_results Errgonomic::Option::Some; it is not a Result"
|
|
78
|
+
#
|
|
79
|
+
# @example sequence a Hash by its values
|
|
80
|
+
# { a: Ok(1), b: Ok(2) }.values.sequence_results # => Ok([1, 2])
|
|
81
|
+
def sequence_results
|
|
82
|
+
values = []
|
|
83
|
+
each do |member|
|
|
84
|
+
unless member.is_a?(Errgonomic::Result::Any)
|
|
85
|
+
raise Errgonomic::TypeMismatchError,
|
|
86
|
+
"cannot sequence_results #{member.class}; it is not a Result"
|
|
87
|
+
end
|
|
88
|
+
return member if member.err?
|
|
89
|
+
|
|
90
|
+
values << member.value
|
|
91
|
+
end
|
|
92
|
+
Ok(values)
|
|
93
|
+
end
|
|
94
|
+
end
|