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.
@@ -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
- # Five 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 sixth would be a
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
- # 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. Quoting and predicate-building prepends unwrap Options at the SQL
19
- # boundary, so an Option can be passed to where/quote.
20
- # 4. SomeValidator provides a presence-style validation for Option
21
- # attributes.
22
- # 5. Readers that ActiveRecord's own machinery reads raw are never
23
- # wrapped: an attribute declared with encrypts, whose length validator
24
- # sits outside Model.validators and calls to_s on the value, and a
25
- # singular association with nested attributes, which are assigned
26
- # through the reader and ask the value whether it is a new record.
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 is not on the list: it is configuration, an
29
- # escape hatch for whatever conflict shows up next, not a semantic
30
- # exception.
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
- class_eval <<-RUBY, __FILE__, __LINE__ + 1
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
- val.nil? ? Errgonomic::Option::None.new : Errgonomic::Option::Some.new(val)
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 Option attribute is Some, analogous to a presence
202
- # validation on a plain attribute.
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
- # Delegate ActiveRecord lifecycle checks to the wrapped record, so a Some
212
- # can stand in for its record during persistence.
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
- # Unwrap Options in a query condition, reaching one level into an array
303
- # so a list of Options binds like a list of values.
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