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
|
@@ -4,34 +4,70 @@ module Errgonomic
|
|
|
4
4
|
module Rails
|
|
5
5
|
# Concern to make ActiveRecord optional attributes and associations return an Option.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# The reader is the boundary and the storage stays nullable: the
|
|
8
|
+
# attribute, dirty tracking and the raw readers all see nil, where Rails
|
|
9
|
+
# already draws the line for a reader override. Rust would expect the
|
|
10
|
+
# Option all the way down; ActiveRecord reads the attribute in too many
|
|
11
|
+
# places for that.
|
|
12
|
+
#
|
|
13
|
+
# Five compromises below are where the Rust idiom gives way to
|
|
14
|
+
# ActiveRecord machinery, each forced by something ActiveRecord does with
|
|
15
|
+
# an accessor rather than chosen. The set is closed: a sixth would be a
|
|
10
16
|
# signal that ActiveRecord is pushing back somewhere unmapped, deserving
|
|
11
17
|
# a design discussion rather than a quiet patch.
|
|
12
18
|
#
|
|
13
19
|
# 1. None#nil? answers true, so AR internals and ordinary nil checks
|
|
14
20
|
# treat an absent value as absent. Equality does not follow suit:
|
|
15
|
-
# None() == nil stays false.
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
21
|
+
# None() == nil stays false. Nor does Array#compact, the common
|
|
22
|
+
# collection idiom for dropping absent members: it tests for the
|
|
23
|
+
# nil object, so it keeps a None where reject(&:none?) drops it.
|
|
24
|
+
# 2. Some delegates persisted? and touch_later to its record, so a Some
|
|
25
|
+
# can stand in for it where ActiveRecord reads an association back
|
|
26
|
+
# through its public reader.
|
|
27
|
+
# 3. Boundaries into ActiveRecord unwrap Options where a value enters,
|
|
28
|
+
# above the column type in every case: quoting and predicate building
|
|
29
|
+
# at the SQL boundary, attribute and singular association writers on
|
|
30
|
+
# assignment, the ids and conditions find, find_by and a bulk write
|
|
31
|
+
# are given, and an attribute default where it is declared.
|
|
32
|
+
# 4. SomeValidator asks whether a value is there at all, where presence
|
|
33
|
+
# asks whether it amounts to anything: Some("") passes some: true and
|
|
34
|
+
# fails presence. It lifts what it is handed, so it asks the same
|
|
35
|
+
# question of any model, converted or not.
|
|
36
|
+
# 5. Where the framework's own machinery reads a value raw, it gets one.
|
|
37
|
+
# Validation unwraps at read_attribute_for_validation, the seam every
|
|
38
|
+
# EachValidator fetches an attribute through; serialization at
|
|
39
|
+
# read_attribute_for_serialization, the seam every attribute in a
|
|
40
|
+
# payload is fetched through; and a form helper at ActionView's tag
|
|
41
|
+
# value, the seam every field reads its record through. So a standard
|
|
42
|
+
# validator weighs the value, a payload carries it and a form renders
|
|
43
|
+
# it, rather than the wrapper. A singular association with nested
|
|
44
|
+
# attributes goes further and keeps its plain reader: nested attributes
|
|
45
|
+
# are assigned through the reader, and ActiveRecord asks whatever it
|
|
46
|
+
# finds there whether it is a new record. So does a reader a framework
|
|
47
|
+
# macro declares and then reads for itself: the associations behind
|
|
48
|
+
# has_rich_text and has_one_attached, and the digest column
|
|
49
|
+
# has_secure_password hands to BCrypt.
|
|
27
50
|
#
|
|
28
|
-
# errgonomic_optional_except
|
|
29
|
-
# escape hatch for whatever conflict
|
|
30
|
-
#
|
|
51
|
+
# errgonomic_optional_except and errgonomic_serialize_none are not on the
|
|
52
|
+
# list: they are configuration, an escape hatch for whatever conflict
|
|
53
|
+
# shows up next and a choice of how an absent value is written, not
|
|
54
|
+
# semantic exceptions.
|
|
31
55
|
module ActiveRecordOptional
|
|
32
56
|
extend ActiveSupport::Concern
|
|
33
57
|
|
|
58
|
+
# The singular associations ActionText and ActiveStorage declare for a
|
|
59
|
+
# model and then read through code of their own. Recognized by the name
|
|
60
|
+
# a reflection was given rather than by the class, so nothing has to be
|
|
61
|
+
# loaded for a model to be asked.
|
|
62
|
+
FRAMEWORK_ASSOCIATION_CLASSES = %w[
|
|
63
|
+
ActionText::RichText
|
|
64
|
+
ActionText::EncryptedRichText
|
|
65
|
+
ActiveStorage::Attachment
|
|
66
|
+
ActiveStorage::Blob
|
|
67
|
+
].freeze
|
|
68
|
+
|
|
34
69
|
included do
|
|
70
|
+
errgonomic_optional_readers
|
|
35
71
|
reflect_on_all_associations(:belongs_to)
|
|
36
72
|
.select { |r| r.options[:optional] }
|
|
37
73
|
.each { |r| errgonomic_wrap_optional(r.name) }
|
|
@@ -40,13 +76,96 @@ module Errgonomic
|
|
|
40
76
|
.each { |r| errgonomic_wrap_optional(r.name) }
|
|
41
77
|
end
|
|
42
78
|
|
|
79
|
+
# Every EachValidator fetches the attribute through here, so unwrapping
|
|
80
|
+
# once at this seam is what lets the standard validators weigh the value
|
|
81
|
+
# rather than the wrapper around it.
|
|
82
|
+
#
|
|
83
|
+
# @example presence weighs the value; some: asks only whether it is there
|
|
84
|
+
# Memo.new(title: Some(''), body: Some('')).tap(&:valid?).errors[:title] # => ["can't be blank"]
|
|
85
|
+
# Memo.new(title: Some(''), body: Some('')).tap(&:valid?).errors[:body] # => []
|
|
86
|
+
def read_attribute_for_validation(key)
|
|
87
|
+
Errgonomic::Rails.unwrap_option(super)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Every attribute in a serialized payload is fetched through here, so a
|
|
91
|
+
# converted model's as_json, to_json and serializable_hash say what the
|
|
92
|
+
# unconverted one says. Rails writes an absent value as null, and so
|
|
93
|
+
# does serde unless a field asks otherwise, so a None does too.
|
|
94
|
+
#
|
|
95
|
+
# @example
|
|
96
|
+
# note = Note.create!(title: Some('The Dark Forest'))
|
|
97
|
+
# Note.find(note.id).as_json['title'] # => 'The Dark Forest'
|
|
98
|
+
# Note.find(note.id).as_json.fetch('body') # => nil
|
|
99
|
+
def read_attribute_for_serialization(key)
|
|
100
|
+
Errgonomic::Rails.unwrap_option(super)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A method named in methods: is read off the record rather than through
|
|
104
|
+
# the attribute seam, so a wrapped reader named there arrives wrapped.
|
|
105
|
+
#
|
|
106
|
+
# @example
|
|
107
|
+
# Note.new(title: Some('Wanderer')).serializable_hash(only: [], methods: :title) # => { 'title' => 'Wanderer' }
|
|
108
|
+
def serializable_hash(options = nil)
|
|
109
|
+
hash = super
|
|
110
|
+
Array(options.to_h[:methods]).each do |name|
|
|
111
|
+
key = name.to_s
|
|
112
|
+
hash[key] = Errgonomic::Rails.unwrap_option(hash[key]) if hash.key?(key)
|
|
113
|
+
end
|
|
114
|
+
errgonomic_omit_absent_keys(hash)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# YARD does not see through a concern's class_methods block, so the
|
|
118
|
+
# method it documents is declared rather than read.
|
|
119
|
+
#
|
|
120
|
+
# @!method errgonomic_optionals
|
|
121
|
+
# @!scope class
|
|
122
|
+
# The readers a model wrapped, which is how a conversion is checked.
|
|
123
|
+
# @example a reader the framework reads for itself is left alone
|
|
124
|
+
# Dispatch.errgonomic_optionals.include?('rich_text_body') # => false
|
|
125
|
+
# Dispatch.errgonomic_optionals.include?('title') # => true
|
|
126
|
+
# @example a subclass reports the readers it inherited
|
|
127
|
+
# Briefing.errgonomic_optionals # => ['title', 'summary']
|
|
128
|
+
# Briefing.errgonomic_optional_names # => []
|
|
43
129
|
class_methods do
|
|
130
|
+
# Wrapped readers live in a module of their own, the way ActiveRecord
|
|
131
|
+
# keeps its attribute methods, so a model's own def of the same name
|
|
132
|
+
# coexists with the wrapper instead of one silently replacing the
|
|
133
|
+
# other. Included rather than prepended: the model's def wins, and
|
|
134
|
+
# its super reads the Option.
|
|
135
|
+
def errgonomic_optional_readers
|
|
136
|
+
return @errgonomic_optional_readers if defined?(@errgonomic_optional_readers)
|
|
137
|
+
|
|
138
|
+
@errgonomic_optional_readers = const_set(:ErrgonomicOptionalReaders, Module.new)
|
|
139
|
+
private_constant :ErrgonomicOptionalReaders
|
|
140
|
+
include @errgonomic_optional_readers
|
|
141
|
+
@errgonomic_optional_readers
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Every class gets its module before its body runs, so where a reader
|
|
145
|
+
# sits in the ancestor chain never depends on when the schema loads
|
|
146
|
+
# or where the include was written.
|
|
147
|
+
def inherited(subclass)
|
|
148
|
+
super
|
|
149
|
+
subclass.errgonomic_optional_readers
|
|
150
|
+
end
|
|
151
|
+
|
|
44
152
|
# What a model wrapped is the signal that a conversion did what it
|
|
45
153
|
# meant to, and the columns are not wrapped until the schema loads, so
|
|
46
|
-
# asking loads it.
|
|
154
|
+
# asking loads it. A subclass responds to every reader an ancestor
|
|
155
|
+
# wrapped, so the report names those too.
|
|
47
156
|
def errgonomic_optionals
|
|
48
157
|
load_schema
|
|
49
|
-
errgonomic_optional_names
|
|
158
|
+
errgonomic_inherited_optional_names | errgonomic_optional_names
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Wrapping walks the chain from the top down, so loading this class's
|
|
162
|
+
# schema has already wrapped an ancestor's columns and reading the
|
|
163
|
+
# names is enough. An abstract ancestor is never asked for a table it
|
|
164
|
+
# has not got.
|
|
165
|
+
def errgonomic_inherited_optional_names
|
|
166
|
+
return [] unless superclass.respond_to?(:errgonomic_optional_names)
|
|
167
|
+
|
|
168
|
+
superclass.errgonomic_inherited_optional_names | superclass.errgonomic_optional_names
|
|
50
169
|
end
|
|
51
170
|
|
|
52
171
|
# The set as it stands, for the wrapping itself: reaching for the
|
|
@@ -67,9 +186,48 @@ module Errgonomic
|
|
|
67
186
|
end
|
|
68
187
|
|
|
69
188
|
inherited |
|
|
70
|
-
Array(encrypted_attributes).map(&:to_s) |
|
|
71
189
|
Array(try(:errgonomic_optional_exceptions)).map(&:to_s) |
|
|
72
|
-
errgonomic_nested_attribute_associations
|
|
190
|
+
errgonomic_nested_attribute_associations |
|
|
191
|
+
errgonomic_framework_readers
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Readers the framework reads for itself, whatever the model asked
|
|
195
|
+
# for. ActionText and ActiveStorage reach their records through the
|
|
196
|
+
# associations their macros declare, and has_secure_password hands
|
|
197
|
+
# the digest column to BCrypt, none of them through anything that has
|
|
198
|
+
# heard of an Option: a wrapper there breaks assignment, attachment
|
|
199
|
+
# and authentication alike.
|
|
200
|
+
def errgonomic_framework_readers
|
|
201
|
+
errgonomic_framework_associations + errgonomic_secure_password_digests
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def errgonomic_framework_associations
|
|
205
|
+
reflect_on_all_associations(:has_one)
|
|
206
|
+
.select { |r| FRAMEWORK_ASSOCIATION_CLASSES.include?(r.class_name) }
|
|
207
|
+
.map { |r| r.name.to_s }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# has_secure_password includes a module of its own per attribute, and
|
|
211
|
+
# the authenticate_ reader in it names the attribute whose digest is
|
|
212
|
+
# read. Asking the macro what it declared costs no schema, which a
|
|
213
|
+
# column scan would load while a class body is still running.
|
|
214
|
+
def errgonomic_secure_password_digests
|
|
215
|
+
return [] unless defined?(ActiveModel::SecurePassword::InstanceMethodsOnActivation)
|
|
216
|
+
|
|
217
|
+
ancestors.grep(ActiveModel::SecurePassword::InstanceMethodsOnActivation)
|
|
218
|
+
.flat_map { |mod| mod.instance_methods(false).grep(/\Aauthenticate_/) }
|
|
219
|
+
.map { |name| "#{name.to_s.delete_prefix('authenticate_')}_digest" }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# A wrapped reader whose absent value the declaration in force asks
|
|
223
|
+
# to be left out of a payload rather than written as null.
|
|
224
|
+
def errgonomic_serialize_none_omit?(name)
|
|
225
|
+
declaration = errgonomic_serialize_none_declaration
|
|
226
|
+
return false unless declaration && declaration[:mode] == :omit
|
|
227
|
+
return declaration[:only].include?(name) if declaration[:only]
|
|
228
|
+
return declaration[:except].exclude?(name) if declaration[:except]
|
|
229
|
+
|
|
230
|
+
true
|
|
73
231
|
end
|
|
74
232
|
|
|
75
233
|
# A model that keeps value-or-nil throughout, for whatever the
|
|
@@ -132,6 +290,16 @@ module Errgonomic
|
|
|
132
290
|
super.tap { errgonomic_wrap_optional(name) unless options[:required] }
|
|
133
291
|
end
|
|
134
292
|
|
|
293
|
+
# A digest column is ordinarily wrapped after this declaration, and
|
|
294
|
+
# the exclusion is enough there. A model whose schema has already
|
|
295
|
+
# loaded has to be handed its reader back. has_rich_text and
|
|
296
|
+
# has_one_attached need no such override: they declare their
|
|
297
|
+
# associations through has_one, which reads the exclusion after the
|
|
298
|
+
# reflection exists.
|
|
299
|
+
def has_secure_password(attribute = :password, **options)
|
|
300
|
+
super.tap { errgonomic_unwrap_optionals("#{attribute}_digest") }
|
|
301
|
+
end
|
|
302
|
+
|
|
135
303
|
# Nested attributes are assigned through the public reader, and
|
|
136
304
|
# ActiveRecord asks whatever it finds there whether it is a new
|
|
137
305
|
# record. An absent association has to arrive as nil for that, so a
|
|
@@ -150,21 +318,11 @@ module Errgonomic
|
|
|
150
318
|
end
|
|
151
319
|
end
|
|
152
320
|
|
|
153
|
-
# Encryption surrounds an attribute with machinery that reads the raw
|
|
154
|
-
# value, including a length validator that calls to_s on it, so a
|
|
155
|
-
# wrapped encrypted attribute cannot be saved. Declaring encrypts
|
|
156
|
-
# after the include is the ordinary spelling, and the exclusion is read
|
|
157
|
-
# from ActiveRecord's own register when a reader is about to be
|
|
158
|
-
# wrapped, so this only has to take back a reader already wrapped.
|
|
159
|
-
def encrypts(*names, **options)
|
|
160
|
-
super.tap { errgonomic_unwrap_optionals(*names) }
|
|
161
|
-
end
|
|
162
|
-
|
|
163
321
|
def errgonomic_unwrap_optionals(*names)
|
|
164
322
|
names.map(&:to_s).each do |name|
|
|
165
323
|
next unless errgonomic_optional_names.delete(name)
|
|
166
324
|
|
|
167
|
-
remove_method(name)
|
|
325
|
+
errgonomic_optional_readers.remove_method(name)
|
|
168
326
|
end
|
|
169
327
|
end
|
|
170
328
|
|
|
@@ -174,7 +332,7 @@ module Errgonomic
|
|
|
174
332
|
return if errgonomic_optional_exclusions.include?(name) || errgonomic_optional?(name)
|
|
175
333
|
|
|
176
334
|
errgonomic_optional_names << name
|
|
177
|
-
|
|
335
|
+
errgonomic_optional_readers.module_eval <<-RUBY, __FILE__, __LINE__ + 1
|
|
178
336
|
def #{name}
|
|
179
337
|
reads = Thread.current[:errgonomic_optional_reads] ||= {}
|
|
180
338
|
key = [object_id, :#{name}]
|
|
@@ -189,29 +347,56 @@ module Errgonomic
|
|
|
189
347
|
ensure
|
|
190
348
|
reads.delete(key)
|
|
191
349
|
end
|
|
192
|
-
|
|
350
|
+
# One layer, always: an attribute or association is never an
|
|
351
|
+
# optional of an optional, so an Option from beneath passes through.
|
|
352
|
+
val.to_option
|
|
193
353
|
end
|
|
194
354
|
RUBY
|
|
195
355
|
end
|
|
196
356
|
end
|
|
357
|
+
|
|
358
|
+
private
|
|
359
|
+
|
|
360
|
+
# ActiveModel reads an included association off the record, so what it
|
|
361
|
+
# yields is an Option. Take the record out of it, and leave an absent
|
|
362
|
+
# one out of the payload, where a nil association is already left out.
|
|
363
|
+
def serializable_add_includes(options = {})
|
|
364
|
+
super do |association, records, opts|
|
|
365
|
+
records = Errgonomic::Rails.unwrap_option(records)
|
|
366
|
+
yield association, records, opts unless records.nil?
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Deleting from the payload rather than from the attribute list is what
|
|
371
|
+
# keeps the caller's own only: and except: in force. A wrapped reader
|
|
372
|
+
# never holds Some(nil), so a nil here is the None it was declared for.
|
|
373
|
+
def errgonomic_omit_absent_keys(hash)
|
|
374
|
+
klass = self.class
|
|
375
|
+
return hash unless klass.errgonomic_serialize_none_declaration&.fetch(:mode) == :omit
|
|
376
|
+
|
|
377
|
+
hash.delete_if do |key, value|
|
|
378
|
+
value.nil? && klass.errgonomic_optional?(key) && klass.errgonomic_serialize_none_omit?(key)
|
|
379
|
+
end
|
|
380
|
+
end
|
|
197
381
|
end
|
|
198
382
|
end
|
|
199
383
|
end
|
|
200
384
|
|
|
201
|
-
# Validates that an
|
|
202
|
-
#
|
|
385
|
+
# Validates that an attribute is there at all, where presence asks whether it
|
|
386
|
+
# amounts to anything: an empty string is a value, nil and None are not.
|
|
387
|
+
# Lifting the value means the same question can be asked of a model the
|
|
388
|
+
# concern never converted.
|
|
203
389
|
class SomeValidator < ActiveModel::EachValidator
|
|
204
390
|
def validate_each(record, attribute, value)
|
|
205
|
-
record.errors.add(attribute, 'is invalid') unless value.some?
|
|
391
|
+
record.errors.add(attribute, 'is invalid') unless value.to_option.some?
|
|
206
392
|
end
|
|
207
393
|
end
|
|
208
394
|
|
|
209
395
|
module Errgonomic
|
|
210
396
|
module Option
|
|
211
|
-
#
|
|
212
|
-
#
|
|
397
|
+
# A belongs_to declared touch: true reads the associated record back
|
|
398
|
+
# through the public reader after a save, then asks it to touch itself.
|
|
213
399
|
class Some
|
|
214
|
-
delegate :marked_for_destruction?, to: :value
|
|
215
400
|
delegate :persisted?, to: :value
|
|
216
401
|
delegate :touch_later, to: :value
|
|
217
402
|
end
|
|
@@ -226,23 +411,6 @@ module Errgonomic
|
|
|
226
411
|
end
|
|
227
412
|
end
|
|
228
413
|
|
|
229
|
-
# Teach ActiveRecord type casting to unwrap Options: a Some casts as its
|
|
230
|
-
# inner value, a None casts as nil.
|
|
231
|
-
module ActiveRecordOptionShim
|
|
232
|
-
def type_cast(value)
|
|
233
|
-
case value
|
|
234
|
-
when Errgonomic::Option::Some
|
|
235
|
-
super(value.unwrap!)
|
|
236
|
-
when Errgonomic::Option::None
|
|
237
|
-
super(nil)
|
|
238
|
-
else
|
|
239
|
-
super
|
|
240
|
-
end
|
|
241
|
-
end
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
ActiveRecord::ConnectionAdapters::Quoting.prepend(ActiveRecordOptionShim)
|
|
245
|
-
|
|
246
414
|
# Lift nil into None.
|
|
247
415
|
class NilClass
|
|
248
416
|
def to_option
|
|
@@ -265,6 +433,41 @@ module Errgonomic
|
|
|
265
433
|
def to_option
|
|
266
434
|
self
|
|
267
435
|
end
|
|
436
|
+
|
|
437
|
+
# ActiveSupport's Object#try asks respond_to?, which an Option answers
|
|
438
|
+
# false for anything it does not define, so try on a wrapper would be a
|
|
439
|
+
# quiet nil for every method. Send it to the value instead: a Some
|
|
440
|
+
# tries what it holds, a None is absent and answers nil, and a method
|
|
441
|
+
# the value does not have is nil as it is for any other receiver.
|
|
442
|
+
#
|
|
443
|
+
# @example
|
|
444
|
+
# Some("bob").try(:upcase) # => "BOB"
|
|
445
|
+
# Some("bob").try(:no_such_method) # => nil
|
|
446
|
+
# None().try(:upcase) # => nil
|
|
447
|
+
# Some(2).try { |pages| pages * 3 } # => 6
|
|
448
|
+
# None().try { |pages| pages * 3 } # => nil
|
|
449
|
+
def try(...)
|
|
450
|
+
return nil if none?
|
|
451
|
+
|
|
452
|
+
value.try(...)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Rails' strict variant: absence is still nil, a method the value does
|
|
456
|
+
# not have raises.
|
|
457
|
+
#
|
|
458
|
+
# @example
|
|
459
|
+
# Some("bob").try!(:upcase) # => "BOB"
|
|
460
|
+
# None().try!(:upcase) # => nil
|
|
461
|
+
# begin
|
|
462
|
+
# Some("bob").try!(:no_such_method)
|
|
463
|
+
# rescue NoMethodError => e
|
|
464
|
+
# e.class
|
|
465
|
+
# end # => NoMethodError
|
|
466
|
+
def try!(...)
|
|
467
|
+
return nil if none?
|
|
468
|
+
|
|
469
|
+
value.try!(...)
|
|
470
|
+
end
|
|
268
471
|
end
|
|
269
472
|
end
|
|
270
473
|
end
|
|
@@ -274,6 +477,9 @@ module Errgonomic
|
|
|
274
477
|
# Teach ActiveRecord SQL quoting to unwrap Options, quoting a None as
|
|
275
478
|
# SQL NULL.
|
|
276
479
|
module ActiveRecordQuoting
|
|
480
|
+
# @example
|
|
481
|
+
# ActiveRecord::Base.connection.quote(Some(1)) # => "1"
|
|
482
|
+
# ActiveRecord::Base.connection.quote(None()) # => "NULL"
|
|
277
483
|
def quote(value)
|
|
278
484
|
return super(value) unless value.is_a?(Errgonomic::Option::Any)
|
|
279
485
|
|
|
@@ -299,8 +505,15 @@ module Errgonomic
|
|
|
299
505
|
end
|
|
300
506
|
end
|
|
301
507
|
|
|
302
|
-
#
|
|
303
|
-
#
|
|
508
|
+
# Take the value inside an Option at a boundary into ActiveRecord, and a
|
|
509
|
+
# None as nil, reaching one level into an array so a list of Options
|
|
510
|
+
# passes as a list of values.
|
|
511
|
+
#
|
|
512
|
+
# @example
|
|
513
|
+
# Errgonomic::Rails.unwrap_options(Some(1)) # => 1
|
|
514
|
+
# Errgonomic::Rails.unwrap_options(None()) # => nil
|
|
515
|
+
# Errgonomic::Rails.unwrap_options([Some(1), None()]) # => [1, nil]
|
|
516
|
+
# Errgonomic::Rails.unwrap_options(1) # => 1
|
|
304
517
|
def self.unwrap_options(value)
|
|
305
518
|
case value
|
|
306
519
|
when Errgonomic::Option::Any
|
|
@@ -311,7 +524,234 @@ module Errgonomic
|
|
|
311
524
|
value
|
|
312
525
|
end
|
|
313
526
|
end
|
|
527
|
+
|
|
528
|
+
# Take the value inside an Option, and a None as nil, where the boundary
|
|
529
|
+
# takes one value: an attribute is a single typed field, so a collection
|
|
530
|
+
# that happens to hold an Option is that collection.
|
|
531
|
+
#
|
|
532
|
+
# @example
|
|
533
|
+
# Errgonomic::Rails.unwrap_option(Some(1)) # => 1
|
|
534
|
+
# Errgonomic::Rails.unwrap_option(None()) # => nil
|
|
535
|
+
# Errgonomic::Rails.unwrap_option([Some(1)]) # => [Some(1)]
|
|
536
|
+
def self.unwrap_option(value)
|
|
537
|
+
value.is_a?(Errgonomic::Option::Any) ? value.unwrap_or(nil) : value
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Unwrap each value of a hash one layer, where the boundary takes a row
|
|
541
|
+
# or a set of conditions rather than a single value. A nested structure
|
|
542
|
+
# is the caller's own, and is left as it is. A hash holding no Option is
|
|
543
|
+
# handed back rather than copied: every write passes here, and most carry
|
|
544
|
+
# none.
|
|
545
|
+
#
|
|
546
|
+
# @example
|
|
547
|
+
# Errgonomic::Rails.unwrap_option_values(title: Some('x'), body: None()) # => { title: 'x', body: nil }
|
|
548
|
+
# plain = { title: 'x' }
|
|
549
|
+
# Errgonomic::Rails.unwrap_option_values(plain).equal?(plain) # => true
|
|
550
|
+
def self.unwrap_option_values(hash)
|
|
551
|
+
return hash unless hash.each_value.any?(Errgonomic::Option::Any)
|
|
552
|
+
|
|
553
|
+
hash.transform_values { |value| unwrap_option(value) }
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# Unwrap each value of each row, where the boundary takes a list of rows.
|
|
557
|
+
# A list holding no Option is handed back rather than copied.
|
|
558
|
+
#
|
|
559
|
+
# @example
|
|
560
|
+
# Errgonomic::Rails.unwrap_option_rows([{ title: Some('x') }]) # => [{ title: 'x' }]
|
|
561
|
+
# plain = [{ title: 'x' }]
|
|
562
|
+
# Errgonomic::Rails.unwrap_option_rows(plain).equal?(plain) # => true
|
|
563
|
+
def self.unwrap_option_rows(rows)
|
|
564
|
+
return rows unless rows.any? { |row| row.is_a?(Hash) && row.each_value.any?(Errgonomic::Option::Any) }
|
|
565
|
+
|
|
566
|
+
rows.map { |row| row.is_a?(Hash) ? unwrap_option_values(row) : row }
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# A declared default that is a Proc is not a value yet: ActiveModel calls
|
|
570
|
+
# it with no arguments each time a record is built. Wrap it rather than
|
|
571
|
+
# unwrap it, so what it returns meets the type where a literal default
|
|
572
|
+
# already does.
|
|
573
|
+
#
|
|
574
|
+
# @example
|
|
575
|
+
# Errgonomic::Rails.unwrap_option_default(Some(1)) # => 1
|
|
576
|
+
# Errgonomic::Rails.unwrap_option_default(-> { Some(1) }).call # => 1
|
|
577
|
+
def self.unwrap_option_default(default)
|
|
578
|
+
return unwrap_option(default) unless default.is_a?(Proc)
|
|
579
|
+
|
|
580
|
+
-> { unwrap_option(default.call) }
|
|
581
|
+
end
|
|
314
582
|
end
|
|
315
583
|
end
|
|
316
584
|
|
|
317
585
|
ActiveRecord::PredicateBuilder.prepend(Errgonomic::Rails::ActiveRecordPredicateBuilder)
|
|
586
|
+
|
|
587
|
+
module Errgonomic
|
|
588
|
+
module Rails
|
|
589
|
+
# A singular association writer is a setter, not a typed field, so it
|
|
590
|
+
# takes what a wrapped reader hands back: Some(record) assigns the record,
|
|
591
|
+
# None() clears the association. A Some of the wrong class still fails the
|
|
592
|
+
# association's own type check, naming the class inside it.
|
|
593
|
+
module ActiveRecordSingularAssociationWriter
|
|
594
|
+
def writer(value)
|
|
595
|
+
super(Errgonomic::Rails.unwrap_options(value))
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
ActiveRecord::Associations::SingularAssociation.prepend(Errgonomic::Rails::ActiveRecordSingularAssociationWriter)
|
|
602
|
+
|
|
603
|
+
module Errgonomic
|
|
604
|
+
module Rails
|
|
605
|
+
# An attribute writer hands its value to a type cast that has never heard
|
|
606
|
+
# of an Option, and each type fails its own way: a Some is truthy and not
|
|
607
|
+
# one of ActiveModel's FALSE_VALUES, so a wrapped false cast to true.
|
|
608
|
+
# Unwrap before the attribute is built rather than inside the cast, so the
|
|
609
|
+
# value assigned, dirty tracking and the before-type-cast reader all agree
|
|
610
|
+
# on what was assigned. Every writer passes here, as do new,
|
|
611
|
+
# assign_attributes and update.
|
|
612
|
+
module ActiveModelAttributeWrite
|
|
613
|
+
# @example
|
|
614
|
+
# Note.new(pinned: Some(false)).pinned # => Some(false)
|
|
615
|
+
# Note.new(pinned: None()).pinned # => None()
|
|
616
|
+
# Note.new(title: Some('The Dark Forest')).title # => Some('The Dark Forest')
|
|
617
|
+
# Note.new(rank: Some(3)).rank # => Some(3)
|
|
618
|
+
# Note.new(due_on: Some(Date.new(2026, 7, 31))).due_on # => Some(Date.new(2026, 7, 31))
|
|
619
|
+
#
|
|
620
|
+
# @example A wrapper never reaches the attribute behind the reader
|
|
621
|
+
# Note.new(pinned: Some(false)).attributes['pinned'] # => false
|
|
622
|
+
# Note.new(pinned: Some(false)).read_attribute_before_type_cast('pinned') # => false
|
|
623
|
+
def write_from_user(name, value)
|
|
624
|
+
super(name, Errgonomic::Rails.unwrap_option(value))
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
ActiveModel::AttributeSet.prepend(Errgonomic::Rails::ActiveModelAttributeWrite)
|
|
631
|
+
|
|
632
|
+
module Errgonomic
|
|
633
|
+
module Rails
|
|
634
|
+
# find and find_by choose their path before any bind exists: an id or a
|
|
635
|
+
# condition the statement cache cannot express is sent to the relation
|
|
636
|
+
# instead. A None has to arrive as nil for that choice, so an absent
|
|
637
|
+
# value asks for IS NULL rather than an equality that can never match.
|
|
638
|
+
module ActiveRecordFind
|
|
639
|
+
# A raw SQL condition is left alone, so an Option interpolated into one
|
|
640
|
+
# still raises rather than binding quietly.
|
|
641
|
+
#
|
|
642
|
+
# @example
|
|
643
|
+
# note = Note.create!(body: 'Ball Lightning')
|
|
644
|
+
# Note.find_by(id: note.id, title: None()) == note # => true
|
|
645
|
+
# Note.find(Some(note.id)) == note # => true
|
|
646
|
+
def find_by(*args)
|
|
647
|
+
super(*args.map { |arg| arg.is_a?(Hash) ? Errgonomic::Rails.unwrap_option_values(arg) : arg })
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# A list of ids is a list of values, so it unwraps one level in: find
|
|
651
|
+
# casts each id it was handed after the query has run, and a wrapper
|
|
652
|
+
# reaching a string primary key's type raises there.
|
|
653
|
+
#
|
|
654
|
+
# @example
|
|
655
|
+
# first = Note.create!(title: 'Supernova Era')
|
|
656
|
+
# second = Note.create!(title: 'Ball Lightning')
|
|
657
|
+
# Note.find([Some(second.id), Some(first.id)]) == [second, first] # => true
|
|
658
|
+
def find(*ids, &block)
|
|
659
|
+
super(*ids.map { |id| Errgonomic::Rails.unwrap_options(id) }, &block)
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
# A relation and an association reach find without passing the class
|
|
664
|
+
# method, so the same list has to be unwrapped there as well.
|
|
665
|
+
module ActiveRecordRelationFind
|
|
666
|
+
# @example
|
|
667
|
+
# note = Note.create!(title: 'Death\'s End')
|
|
668
|
+
# Note.where.not(title: nil).find([Some(note.id)]) == [note] # => true
|
|
669
|
+
def find(*ids, &block)
|
|
670
|
+
super(*ids.map { |id| Errgonomic::Rails.unwrap_options(id) }, &block)
|
|
671
|
+
end
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
ActiveRecord::Core::ClassMethods.prepend(Errgonomic::Rails::ActiveRecordFind)
|
|
677
|
+
ActiveRecord::Relation.prepend(Errgonomic::Rails::ActiveRecordRelationFind)
|
|
678
|
+
|
|
679
|
+
module Errgonomic
|
|
680
|
+
module Rails
|
|
681
|
+
# A bulk write never passes an attribute writer: it casts and serializes
|
|
682
|
+
# each value it was handed straight into the statement. Unwrapping the
|
|
683
|
+
# row on the way in is what lets a Some cross that boundary whatever the
|
|
684
|
+
# column type is. insert, insert! and upsert route through their plural
|
|
685
|
+
# forms, so they are covered here too. A nested structure inside a value
|
|
686
|
+
# is the caller's own and is left as it is.
|
|
687
|
+
module ActiveRecordBulkWrite
|
|
688
|
+
# @example
|
|
689
|
+
# Note.insert_all([{ title: Some('Wanderer'), rank: None() }])
|
|
690
|
+
# Note.where(title: 'Wanderer').update_all(rank: Some(3))
|
|
691
|
+
# Note.find_by(title: 'Wanderer').rank # => Some(3)
|
|
692
|
+
def update_all(updates)
|
|
693
|
+
super(updates.is_a?(Hash) ? Errgonomic::Rails.unwrap_option_values(updates) : updates)
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
def insert_all(attributes, **kwargs)
|
|
697
|
+
super(Errgonomic::Rails.unwrap_option_rows(attributes), **kwargs)
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def insert_all!(attributes, **kwargs)
|
|
701
|
+
super(Errgonomic::Rails.unwrap_option_rows(attributes), **kwargs)
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def upsert_all(attributes, **kwargs)
|
|
705
|
+
super(Errgonomic::Rails.unwrap_option_rows(attributes), **kwargs)
|
|
706
|
+
end
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
ActiveRecord::Relation.prepend(Errgonomic::Rails::ActiveRecordBulkWrite)
|
|
712
|
+
|
|
713
|
+
module Errgonomic
|
|
714
|
+
module Rails
|
|
715
|
+
# A declared default reaches the record's attribute without passing a
|
|
716
|
+
# writer: it is held as given and cast the first time the attribute is
|
|
717
|
+
# read. Unwrapping where it is declared is the only point above the type,
|
|
718
|
+
# and it keeps the stored default a plain value, as an assigned one is.
|
|
719
|
+
module ActiveModelAttributeDefault
|
|
720
|
+
# @example
|
|
721
|
+
# DefaultedNote.new.rank # => 0
|
|
722
|
+
# DefaultedNote.new.title # => nil
|
|
723
|
+
# ProcDefaultedNote.new.title # => 'Wanderer'
|
|
724
|
+
def attribute(name, type = nil, **options)
|
|
725
|
+
options[:default] = Errgonomic::Rails.unwrap_option_default(options[:default]) if options.key?(:default)
|
|
726
|
+
super(name, type, **options)
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
ActiveModel::AttributeRegistration::ClassMethods.prepend(Errgonomic::Rails::ActiveModelAttributeDefault)
|
|
733
|
+
|
|
734
|
+
module Errgonomic
|
|
735
|
+
module Rails
|
|
736
|
+
# A form helper reads its value off the record through the public reader
|
|
737
|
+
# whenever the value did not come from user input, which is every record
|
|
738
|
+
# an edit form loads from the database. Each tag then weighs what it finds
|
|
739
|
+
# its own way: a check box asks it for to_i, a datetime field for
|
|
740
|
+
# strftime, and a text field renders it into the markup. Unwrapping at the
|
|
741
|
+
# one seam they all read through is what lets a converted model render the
|
|
742
|
+
# form an unconverted one renders.
|
|
743
|
+
module ActionViewTagValue
|
|
744
|
+
private
|
|
745
|
+
|
|
746
|
+
def value
|
|
747
|
+
Errgonomic::Rails.unwrap_option(super)
|
|
748
|
+
end
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# ActionView may be loaded before this file, after it, or not at all, and the
|
|
754
|
+
# load hook answers for all three.
|
|
755
|
+
ActiveSupport.on_load(:action_view) do
|
|
756
|
+
ActionView::Helpers::Tags::Base.prepend(Errgonomic::Rails::ActionViewTagValue)
|
|
757
|
+
end
|