errgonomic 0.8.2 → 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,44 +4,168 @@ 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. Attributes declared with encrypts are never wrapped: ActiveRecord
23
- # Encryption registers a length validator outside Model.validators
24
- # that reads the raw value and cannot survive an Option.
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.
25
50
  #
26
- # errgonomic_optional_except is not on the list: it is configuration, an
27
- # escape hatch for whatever conflict shows up next, not a semantic
28
- # 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.
29
55
  module ActiveRecordOptional
30
56
  extend ActiveSupport::Concern
31
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
+
32
69
  included do
70
+ errgonomic_optional_readers
33
71
  reflect_on_all_associations(:belongs_to)
34
72
  .select { |r| r.options[:optional] }
35
73
  .each { |r| errgonomic_wrap_optional(r.name) }
74
+ reflect_on_all_associations(:has_one)
75
+ .reject { |r| r.options[:required] }
76
+ .each { |r| errgonomic_wrap_optional(r.name) }
77
+ end
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)
36
88
  end
37
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 # => []
38
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
+
39
152
  # What a model wrapped is the signal that a conversion did what it
40
153
  # meant to, and the columns are not wrapped until the schema loads, so
41
- # asking loads it.
154
+ # asking loads it. A subclass responds to every reader an ancestor
155
+ # wrapped, so the report names those too.
42
156
  def errgonomic_optionals
43
157
  load_schema
44
- 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
45
169
  end
46
170
 
47
171
  # The set as it stands, for the wrapping itself: reaching for the
@@ -61,7 +185,49 @@ module Errgonomic
61
185
  []
62
186
  end
63
187
 
64
- inherited | Array(encrypted_attributes).map(&:to_s) | Array(try(:errgonomic_optional_exceptions)).map(&:to_s)
188
+ inherited |
189
+ Array(try(:errgonomic_optional_exceptions)).map(&:to_s) |
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
65
231
  end
66
232
 
67
233
  # A model that keeps value-or-nil throughout, for whatever the
@@ -116,21 +282,47 @@ module Errgonomic
116
282
  super.tap { errgonomic_wrap_optional(name) if options[:optional] }
117
283
  end
118
284
 
119
- # Encryption surrounds an attribute with machinery that reads the raw
120
- # value, including a length validator that calls to_s on it, so a
121
- # wrapped encrypted attribute cannot be saved. Declaring encrypts
122
- # after the include is the ordinary spelling, and the exclusion is read
123
- # from ActiveRecord's own register when a reader is about to be
124
- # wrapped, so this only has to take back a reader already wrapped.
125
- def encrypts(*names, **options)
285
+ # A has_one is absent whenever no row points back at the record, so
286
+ # its reader carries the same absence a nullable column does.
287
+ # required: true is the exception: it asserts the record is there, and
288
+ # absence is a validation failure rather than a value to handle.
289
+ def has_one(name, scope = nil, **options)
290
+ super.tap { errgonomic_wrap_optional(name) unless options[:required] }
291
+ end
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
+
303
+ # Nested attributes are assigned through the public reader, and
304
+ # ActiveRecord asks whatever it finds there whether it is a new
305
+ # record. An absent association has to arrive as nil for that, so a
306
+ # singular association with nested attributes keeps its plain reader.
307
+ def accepts_nested_attributes_for(*names, **options)
126
308
  super.tap { errgonomic_unwrap_optionals(*names) }
127
309
  end
128
310
 
311
+ # ActiveRecord keeps its own register of these, so the exclusion can be
312
+ # read from there rather than recorded as it goes past.
313
+ def errgonomic_nested_attribute_associations
314
+ return [] unless respond_to?(:nested_attributes_options)
315
+
316
+ nested_attributes_options.keys.map(&:to_s).select do |name|
317
+ %i[has_one belongs_to].include?(reflect_on_association(name)&.macro)
318
+ end
319
+ end
320
+
129
321
  def errgonomic_unwrap_optionals(*names)
130
322
  names.map(&:to_s).each do |name|
131
323
  next unless errgonomic_optional_names.delete(name)
132
324
 
133
- remove_method(name)
325
+ errgonomic_optional_readers.remove_method(name)
134
326
  end
135
327
  end
136
328
 
@@ -140,7 +332,7 @@ module Errgonomic
140
332
  return if errgonomic_optional_exclusions.include?(name) || errgonomic_optional?(name)
141
333
 
142
334
  errgonomic_optional_names << name
143
- class_eval <<-RUBY, __FILE__, __LINE__ + 1
335
+ errgonomic_optional_readers.module_eval <<-RUBY, __FILE__, __LINE__ + 1
144
336
  def #{name}
145
337
  reads = Thread.current[:errgonomic_optional_reads] ||= {}
146
338
  key = [object_id, :#{name}]
@@ -155,29 +347,56 @@ module Errgonomic
155
347
  ensure
156
348
  reads.delete(key)
157
349
  end
158
- 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
159
353
  end
160
354
  RUBY
161
355
  end
162
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
163
381
  end
164
382
  end
165
383
  end
166
384
 
167
- # Validates that an Option attribute is Some, analogous to a presence
168
- # 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.
169
389
  class SomeValidator < ActiveModel::EachValidator
170
390
  def validate_each(record, attribute, value)
171
- record.errors.add(attribute, 'is invalid') unless value.some?
391
+ record.errors.add(attribute, 'is invalid') unless value.to_option.some?
172
392
  end
173
393
  end
174
394
 
175
395
  module Errgonomic
176
396
  module Option
177
- # Delegate ActiveRecord lifecycle checks to the wrapped record, so a Some
178
- # 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.
179
399
  class Some
180
- delegate :marked_for_destruction?, to: :value
181
400
  delegate :persisted?, to: :value
182
401
  delegate :touch_later, to: :value
183
402
  end
@@ -192,23 +411,6 @@ module Errgonomic
192
411
  end
193
412
  end
194
413
 
195
- # Teach ActiveRecord type casting to unwrap Options: a Some casts as its
196
- # inner value, a None casts as nil.
197
- module ActiveRecordOptionShim
198
- def type_cast(value)
199
- case value
200
- when Errgonomic::Option::Some
201
- super(value.unwrap!)
202
- when Errgonomic::Option::None
203
- super(nil)
204
- else
205
- super
206
- end
207
- end
208
- end
209
-
210
- ActiveRecord::ConnectionAdapters::Quoting.prepend(ActiveRecordOptionShim)
211
-
212
414
  # Lift nil into None.
213
415
  class NilClass
214
416
  def to_option
@@ -223,11 +425,61 @@ class Object
223
425
  end
224
426
  end
225
427
 
428
+ module Errgonomic
429
+ module Option
430
+ # An Option is already lifted. Lifting it again would nest it, and the
431
+ # nesting is invisible until something reaches for the inner value.
432
+ class Any
433
+ def to_option
434
+ self
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
471
+ end
472
+ end
473
+ end
474
+
226
475
  module Errgonomic
227
476
  module Rails
228
477
  # Teach ActiveRecord SQL quoting to unwrap Options, quoting a None as
229
478
  # SQL NULL.
230
479
  module ActiveRecordQuoting
480
+ # @example
481
+ # ActiveRecord::Base.connection.quote(Some(1)) # => "1"
482
+ # ActiveRecord::Base.connection.quote(None()) # => "NULL"
231
483
  def quote(value)
232
484
  return super(value) unless value.is_a?(Errgonomic::Option::Any)
233
485
 
@@ -253,8 +505,15 @@ module Errgonomic
253
505
  end
254
506
  end
255
507
 
256
- # Unwrap Options in a query condition, reaching one level into an array
257
- # 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
258
517
  def self.unwrap_options(value)
259
518
  case value
260
519
  when Errgonomic::Option::Any
@@ -265,7 +524,234 @@ module Errgonomic
265
524
  value
266
525
  end
267
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
268
582
  end
269
583
  end
270
584
 
271
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