Module: Errgonomic::Rails::ActiveRecordOptional

Extended by:
ActiveSupport::Concern
Defined in:
lib/errgonomic/rails/active_record_optional.rb

Overview

Concern to make ActiveRecord optional attributes and associations return an Option.

The reader is the boundary and the storage stays nullable: the attribute, dirty tracking and the raw readers all see nil, where Rails already draws the line for a reader override. Rust would expect the Option all the way down; ActiveRecord reads the attribute in too many places for that.

Five compromises below are where the Rust idiom gives way to ActiveRecord machinery, each forced by something ActiveRecord does with an accessor rather than chosen. The set is closed: a sixth would be a signal that ActiveRecord is pushing back somewhere unmapped, deserving a design discussion rather than a quiet patch.

  1. None#nil? answers true, so AR internals and ordinary nil checks treat an absent value as absent. Equality does not follow suit: None() == nil raises, as any cross-type comparison does. Nor does Array#compact, the common collection idiom for dropping absent members: it tests for the nil object, so it keeps a None where reject(&:none?) drops it.
  2. Some delegates persisted? and touch_later to its record, so a Some can stand in for it where ActiveRecord reads an association back through its public reader.
  3. Boundaries into ActiveRecord unwrap Options where a value enters, above the column type in every case: quoting and predicate building at the SQL boundary, attribute and singular association writers on assignment, the ids and conditions find, find_by and a bulk write are given, and an attribute default where it is declared.
  4. SomeValidator asks whether a value is there at all, where presence asks whether it amounts to anything: Some("") passes some: true and fails presence. It lifts what it is handed, so it asks the same question of any model, converted or not.
  5. Where the framework's own machinery reads a value raw, it gets one. Validation unwraps at read_attribute_for_validation, the seam every EachValidator fetches an attribute through; serialization at read_attribute_for_serialization, the seam every attribute in a payload is fetched through; a form helper at ActionView's tag value, the seam every field reads its record through; and a query method at query_attribute, the seam every generated attr? is cast through. So a standard validator weighs the value, a payload carries it, a form renders it and a query method answers for it, rather than for the wrapper. A singular association with nested attributes goes further and keeps its plain reader: nested attributes are assigned through the reader, and ActiveRecord asks whatever it finds there whether it is a new record. So does a reader a framework macro declares and then reads for itself: the associations behind has_rich_text and has_one_attached, and the digest column has_secure_password hands to BCrypt.

errgonomic_optional_except and errgonomic_serialize_none are not on the list: they are configuration, an escape hatch for whatever conflict shows up next and a choice of how an absent value is written, not semantic exceptions.

Constant Summary collapse

FRAMEWORK_ASSOCIATION_CLASSES =

The singular associations ActionText and ActiveStorage declare for a model and then read through code of their own. Recognized by the name a reflection was given rather than by the class, so nothing has to be loaded for a model to be asked.

%w[
  ActionText::RichText
  ActionText::EncryptedRichText
  ActiveStorage::Attachment
  ActiveStorage::Blob
].freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.errgonomic_optionalsObject

The readers a model wrapped, which is how a conversion is checked.

Examples:

a reader the framework reads for itself is left alone

Dispatch.errgonomic_optionals.include?('rich_text_body') # => false
Dispatch.errgonomic_optionals.include?('title') # => true

a subclass reports the readers it inherited

Briefing.errgonomic_optionals # => ['title', 'summary']
Briefing.errgonomic_optional_names # => []


155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
# File 'lib/errgonomic/rails/active_record_optional.rb', line 155

class_methods do
  # Wrapped readers live in a module of their own, the way ActiveRecord
  # keeps its attribute methods, so a model's own def of the same name
  # coexists with the wrapper instead of one silently replacing the
  # other. Included rather than prepended: the model's def wins, and
  # its super reads the Option.
  def errgonomic_optional_readers
    return @errgonomic_optional_readers if defined?(@errgonomic_optional_readers)

    @errgonomic_optional_readers = const_set(:ErrgonomicOptionalReaders, Module.new)
    private_constant :ErrgonomicOptionalReaders
    include @errgonomic_optional_readers
    @errgonomic_optional_readers
  end

  # Every class gets its module before its body runs, so where a reader
  # sits in the ancestor chain never depends on when the schema loads
  # or where the include was written.
  def inherited(subclass)
    super
    subclass.errgonomic_optional_readers
  end

  # What a model wrapped is the signal that a conversion did what it
  # meant to, and the columns are not wrapped until the schema loads, so
  # asking loads it. A subclass responds to every reader an ancestor
  # wrapped, so the report names those too.
  def errgonomic_optionals
    load_schema
    errgonomic_inherited_optional_names | errgonomic_optional_names
  end

  # Wrapping walks the chain from the top down, so loading this class's
  # schema has already wrapped an ancestor's columns and reading the
  # names is enough. An abstract ancestor is never asked for a table it
  # has not got.
  def errgonomic_inherited_optional_names
    return [] unless superclass.respond_to?(:errgonomic_optional_names)

    superclass.errgonomic_inherited_optional_names | superclass.errgonomic_optional_names
  end

  # The set as it stands, for the wrapping itself: reaching for the
  # schema from here would ask the schema to load while it is loading.
  def errgonomic_optional_names
    @errgonomic_optional_names ||= []
  end

  # Read when a reader is about to be wrapped rather than snapshotted at
  # include time, so an exclusion works on either side of the include.
  # That is what an include on a base class needs: there is no "before"
  # for a model to declare anything in.
  def errgonomic_optional_exclusions
    inherited = if superclass.respond_to?(:errgonomic_optional_exclusions)
                  superclass.errgonomic_optional_exclusions
                else
                  []
                end

    inherited |
      Array(try(:errgonomic_optional_exceptions)).map(&:to_s) |
      errgonomic_nested_attribute_associations |
      errgonomic_framework_readers
  end

  # Readers the framework reads for itself, whatever the model asked
  # for. ActionText and ActiveStorage reach their records through the
  # associations their macros declare, and has_secure_password hands
  # the digest column to BCrypt, none of them through anything that has
  # heard of an Option: a wrapper there breaks assignment, attachment
  # and authentication alike.
  def errgonomic_framework_readers
    errgonomic_framework_associations + errgonomic_secure_password_digests
  end

  def errgonomic_framework_associations
    reflect_on_all_associations(:has_one)
      .select { |r| FRAMEWORK_ASSOCIATION_CLASSES.include?(r.class_name) }
      .map { |r| r.name.to_s }
  end

  # has_secure_password includes a module of its own per attribute, and
  # the authenticate_ reader in it names the attribute whose digest is
  # read. Asking the macro what it declared costs no schema, which a
  # column scan would load while a class body is still running.
  def errgonomic_secure_password_digests
    return [] unless defined?(ActiveModel::SecurePassword::InstanceMethodsOnActivation)

    ancestors.grep(ActiveModel::SecurePassword::InstanceMethodsOnActivation)
             .flat_map { |mod| mod.instance_methods(false).grep(/\Aauthenticate_/) }
             .map { |name| "#{name.to_s.delete_prefix('authenticate_')}_digest" }
  end

  # A wrapped reader whose absent value the declaration in force asks
  # to be left out of a payload rather than written as null.
  def errgonomic_serialize_none_omit?(name)
    declaration = errgonomic_serialize_none_declaration
    return false unless declaration && declaration[:mode] == :omit
    return declaration[:only].include?(name) if declaration[:only]
    return declaration[:except].exclude?(name) if declaration[:except]

    true
  end

  # A model that keeps value-or-nil throughout, for whatever the
  # application knows about it that the concern does not. Where the
  # concern is included on a base class, this is how a model leaves.
  def errgonomic_optional_off
    @errgonomic_optional_off = true
    errgonomic_unwrap_optionals(*errgonomic_optional_names.dup)
  end

  def errgonomic_optional_off?
    return true if defined?(@errgonomic_optional_off) && @errgonomic_optional_off

    superclass.respond_to?(:errgonomic_optional_off?) && superclass.errgonomic_optional_off?
  end

  # A reader wrapped by an ancestor is already an Option; a subclass
  # that wrapped it again would nest it.
  def errgonomic_optional?(name)
    return true if errgonomic_optional_names.include?(name)

    superclass.respond_to?(:errgonomic_optional?) && superclass.errgonomic_optional?(name)
  end

  # ActiveRecord defines its attribute methods the first time a model
  # needs its schema, not when the class body runs. Wrapping nullable
  # columns from the same seam keeps a database out of class loading.
  def load_schema!
    super
    errgonomic_wrap_nullable_columns
  end

  # A subclass loads its own schema, so whichever of the two is touched
  # first wraps the shared columns first, and a subclass that got there
  # first would wrap its parent's readers a second time. Walk the chain
  # from the top down instead, so an ancestor's readers always exist
  # before a subclass considers the same name.
  def errgonomic_wrap_nullable_columns
    superclass.errgonomic_wrap_nullable_columns if superclass.respond_to?(:errgonomic_wrap_nullable_columns)
    # An abstract class has no table, and asking one for its columns
    # raises. The concern belongs on an abstract class all the same: that
    # is where an application puts behaviour every model should have.
    return if abstract_class? || table_name.nil?

    column_names.each { |name| errgonomic_wrap_optional(name) if column_for_attribute(name).null }
  end

  # A concern belongs at the top of a model, above its associations, so
  # an optional belongs_to is routinely declared after the include.
  # Wrap it when it arrives, or the conversion is silently partial.
  def belongs_to(name, scope = nil, **options)
    super.tap { errgonomic_wrap_optional(name) if options[:optional] }
  end

  # A has_one is absent whenever no row points back at the record, so
  # its reader carries the same absence a nullable column does.
  # required: true is the exception: it asserts the record is there, and
  # absence is a validation failure rather than a value to handle.
  def has_one(name, scope = nil, **options)
    super.tap { errgonomic_wrap_optional(name) unless options[:required] }
  end

  # A digest column is ordinarily wrapped after this declaration, and
  # the exclusion is enough there. A model whose schema has already
  # loaded has to be handed its reader back. has_rich_text and
  # has_one_attached need no such override: they declare their
  # associations through has_one, which reads the exclusion after the
  # reflection exists.
  def has_secure_password(attribute = :password, **options)
    super.tap { errgonomic_unwrap_optionals("#{attribute}_digest") }
  end

  # Nested attributes are assigned through the public reader, and
  # ActiveRecord asks whatever it finds there whether it is a new
  # record. An absent association has to arrive as nil for that, so a
  # singular association with nested attributes keeps its plain reader.
  def accepts_nested_attributes_for(*names, **options)
    super.tap { errgonomic_unwrap_optionals(*names) }
  end

  # ActiveRecord keeps its own register of these, so the exclusion can be
  # read from there rather than recorded as it goes past.
  def errgonomic_nested_attribute_associations
    return [] unless respond_to?(:nested_attributes_options)

    nested_attributes_options.keys.map(&:to_s).select do |name|
      %i[has_one belongs_to].include?(reflect_on_association(name)&.macro)
    end
  end

  def errgonomic_unwrap_optionals(*names)
    names.map(&:to_s).each do |name|
      next unless errgonomic_optional_names.delete(name)

      errgonomic_optional_readers.remove_method(name)
    end
  end

  def errgonomic_wrap_optional(name)
    name = name.to_s
    return if errgonomic_optional_off?
    return if errgonomic_optional_exclusions.include?(name) || errgonomic_optional?(name)

    errgonomic_optional_names << name
    errgonomic_optional_readers.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def #{name}
        reads = Thread.current[:errgonomic_optional_reads] ||= {}
        key = [object_id, :#{name}]
        if reads[key]
          raise Errgonomic::RecursiveOptionalReadError,
                "\#{self.class}##{name} re-entered itself; something beneath this reader reads it again"
        end

        reads[key] = true
        begin
          val = super
        ensure
          reads.delete(key)
        end
        # One layer, always: an attribute or association is never an
        # optional of an optional, so an Option from beneath passes through.
        val.to_option
      end
    RUBY
  end
end

Instance Method Details

#query_attribute(attr_name) ⇒ Object Also known as: attribute?

A query method reads the record through the public reader and matches what it finds against true, then false and nil, before falling through to blankness. An Option is none of those, and its blankness is its discriminant rather than its value, so an explicit false and a stored zero would answer true. Unwrapping the read is what keeps the answer the one an unconverted model gives.

Examples:

Note.new(pinned: false).pinned? # => false
Note.new(rank: 0).rank? # => false
Note.new(title: '').title? # => false
Note.new(pinned: true).pinned? # => true
Note.new(rank: 3).rank? # => true


133
134
135
# File 'lib/errgonomic/rails/active_record_optional.rb', line 133

def query_attribute(attr_name)
  query_cast_attribute(attr_name, Errgonomic::Rails.unwrap_option(public_send(attr_name)))
end

#read_attribute_for_serialization(key) ⇒ Object

Every attribute in a serialized payload is fetched through here, so a converted model's as_json, to_json and serializable_hash say what the unconverted one says. Rails writes an absent value as null, and so does serde unless a field asks otherwise, so a None does too.

Examples:

note = Note.create!(title: Some('The Dark Forest'))
Note.find(note.id).as_json['title'] # => 'The Dark Forest'
Note.find(note.id).as_json.fetch('body') # => nil


102
103
104
# File 'lib/errgonomic/rails/active_record_optional.rb', line 102

def read_attribute_for_serialization(key)
  Errgonomic::Rails.unwrap_option(super)
end

#read_attribute_for_validation(key) ⇒ Object

Every EachValidator fetches the attribute through here, so unwrapping once at this seam is what lets the standard validators weigh the value rather than the wrapper around it.

Examples:

presence weighs the value; some: asks only whether it is there

Memo.new(title: Some(''), body: Some('')).tap(&:valid?).errors[:title] # => ["can't be blank"]
Memo.new(title: Some(''), body: Some('')).tap(&:valid?).errors[:body] # => []


89
90
91
# File 'lib/errgonomic/rails/active_record_optional.rb', line 89

def read_attribute_for_validation(key)
  Errgonomic::Rails.unwrap_option(super)
end

#serializable_hash(options = nil) ⇒ Object

A method named in methods: is read off the record rather than through the attribute seam, so a wrapped reader named there arrives wrapped.

Examples:

Note.new(title: Some('Wanderer')).serializable_hash(only: [], methods: :title) # => { 'title' => 'Wanderer' }


111
112
113
114
115
116
117
118
# File 'lib/errgonomic/rails/active_record_optional.rb', line 111

def serializable_hash(options = nil)
  hash = super
  Array(options.to_h[:methods]).each do |name|
    key = name.to_s
    hash[key] = Errgonomic::Rails.unwrap_option(hash[key]) if hash.key?(key)
  end
  errgonomic_omit_absent_keys(hash)
end