Repository navigation
Description
Summary
ActiveModel type casts don't unwrap Options, so assigning an Option through an attribute writer behaves differently per column type — including one case of silent data corruption: ActiveModel::Type::Boolean.cast(Some(false)) returns true.
Context
b.attr = a.attr (record-to-record copy, dup-then-tweak, form round-trips, update_all) is one of the most common idioms in a converted app: the reader returns an Option and the writer hands it to the type cast. Found during a full-codebase conversion audit (107 models, errgonomic 0.8.3, Rails 8.1.3.1). The gem already unwraps at the SQL quoting and predicate-builder boundaries (active_record_optional.rb:231-244, :276-317); the attribute-cast boundary is the missing third seam.
Current State
Measured per type, assigning Some(x) through a writer:
| Type | Behavior |
|---|---|
| boolean | Some(false) casts to true — silent corruption. Demonstrated end-to-end: copying a wrapped false between two records stores true. (Some is truthy and not in FALSE_VALUES.) |
| string / text / jsonb | raises SerializeError (cast calls to_s) |
| integer / float / decimal | works by accident: the numeric cast calls value.presence, which on an Option is the soft-deprecated unwrapping helper — and emits a stderr deprecation nudge on every assignment |
| date / datetime | the Option passes through uncast into @attributes; changes then holds [nil, Some(t)], leaking wrappers into dirty tracking and anything that serializes it (PaperTrail object_changes, audit rows) |
update_all(col: Some(x)) raises SerializeError for the same reason.
Desired State
Assigning an Option through any attribute writer behaves exactly like assigning its inner value (None like nil), for every type, with no deprecation output.
Acceptance Criteria
-
record.bool_col = Some(false)storesfalse;= None()storesnil - String/text/jsonb/date/datetime writers accept Options without raising and without storing a wrapper
- Numeric writers no longer route through
Option#presence(no stderr nudge) -
changes/attribute_before_type_castcontain raw values after an Option assignment -
update_allaccepts Option values - Tests cover each cast type with
Some(x),Some(falsy), andNone()
Technical Notes
- Validated prototype from the audit: prepend a module on
ActiveModel::Type::Valuethat unwraps in#castand#serialize, thensuper. Two methods, covers every type including custom ones. - Interaction warning worth a line in the docs: once casts unwrap, a truthiness bug like
self.col = col || default(wherecolreads as truthyNone) stops leaking a wrapper and instead silently persistsnil— the write succeeds. Cast unwrapping should ship alongside truthiness-linting guidance, not instead of it.
Related Files
lib/errgonomic/rails/active_record_optional.rb:231-244— existing quoting-boundary unwrap (type_cast), the pattern to extendlib/errgonomic/rails/active_record_optional.rb:276-317— existing predicate-builder unwraplib/errgonomic/option.rb:624-626— thepresence_nudgethe numeric path currently triggers
References
- ActiveRecordOptional: class_eval reader wrapping silently destroys (or is destroyed by) model-defined methods — wrap via a module #59 (module-based wrapping) — independent, but both change what a reader/writer round-trip looks like; coordinate test coverage
Activity
nz commentedon Sep 8, 2026
Yeah, good find. These are also really good for doctests for test-driven fixes and documentation of what will be a semi-arbitrary mapping of behaviors.
nz commentedon Sep 9, 2026
Fixed in #66, which merged into the stacked branch and reached main through #64 (merge commit f13b4d6). Ships in 0.9.0.