Skip to content

ActiveRecordOptional: attribute type casts don't unwrap Options — Boolean.cast(Some(false)) silently stores true #60

Closed

Description

@lutzcc1

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) stores false; = None() stores nil
  • 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_cast contain raw values after an Option assignment
  • update_all accepts Option values
  • Tests cover each cast type with Some(x), Some(falsy), and None()

Technical Notes

  • Validated prototype from the audit: prepend a module on ActiveModel::Type::Value that unwraps in #cast and #serialize, then super. 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 (where col reads as truthy None) stops leaking a wrapper and instead silently persists nil — 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 extend
  • lib/errgonomic/rails/active_record_optional.rb:276-317 — existing predicate-builder unwrap
  • lib/errgonomic/option.rb:624-626 — the presence_nudge the numeric path currently triggers

References

Activity

  1. nz commented on Sep 8, 2026

    @nz
    Member

    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.

  2. nz commented on Sep 9, 2026

    @nz
    Member

    Fixed in #66, which merged into the stacked branch and reached main through #64 (merge commit f13b4d6). Ships in 0.9.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions