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.
- 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.
- 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.
- 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.
- 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.
- 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
-
.errgonomic_optionals ⇒ Object
The readers a model wrapped, which is how a conversion is checked.
Instance Method Summary collapse
-
#query_attribute(attr_name) ⇒ Object
(also: #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.
-
#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.
-
#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.
-
#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.
Class Method Details
.errgonomic_optionals ⇒ Object
The readers a model wrapped, which is how a conversion is checked.
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, **) super.tap { errgonomic_wrap_optional(name) if [: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, **) super.tap { errgonomic_wrap_optional(name) unless [: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, **) 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, **) 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) .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.
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.
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.
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.
111 112 113 114 115 116 117 118 |
# File 'lib/errgonomic/rails/active_record_optional.rb', line 111 def serializable_hash( = nil) hash = super Array(.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 |