Skip to content

Conversation

Yuhi-Sato
Copy link
Contributor

Motivation / Background

Fixes rails#55001

The becomes! method fails to preserve enum-based behavior when converting between STI (Single Table Inheritance) subclasses that define different enum values for the same attribute.

This is particularly problematic because enum defines predicate (#published?) and mutator (#published!) methods specific to each class. When switching classes via becomes!, the internal type mapping (EnumType) from the original class is retained, leading to incorrect validation and method behavior.

Detail

This change ensures that when performing becomes!, each attribute is rebound to the destination class’s attribute type using with_type. This guarantees that enum accessors work correctly in the destination class context.

A test case using LiveParrot and DeadParrot subclasses of Parrot (each declaring their own enum :breed) demonstrates that the fix allows safe conversion between STI subclasses while maintaining proper enum behavior.

Before this change:

    parrot = LiveParrot.create!(name: "Scipio", breed: "african")
    dead_parrot = parrot.becomes!(DeadParrot)
    dead_parrot.asian! # => raises ArgumentError

This failed due to the original enum mapping from LiveParrot being retained.

Additional information

  • The error is raised by EnumType#assert_valid_value when the new enum value is not recognized by the old enum mapping.
  • This fix ensures consistency with developer expectations when using enums in STI environments.
  • To avoid decryption errors for encrypted attributes
    (ActiveRecord::Encryption::EncryptedAttributeType), such attributes are
    explicitly skipped when rebinding types.
  • This PR is a re-submission of #55018, which was automatically closed after force-pushing to clean up the commit history. The content is unchanged; only the history was rewritten for clarity.

Checklist

Before submitting the PR make sure the following are checked:

  • This Pull Request is related to one change. Unrelated changes should be opened in separate PRs.
  • Commit message has a detailed description of what changed and why. If this PR fixes a related issue include it in the commit message. Ex: [Fix #issue-number]
  • Tests are added or updated if you fix a bug or add a feature.
  • CHANGELOG files are updated for the changed libraries if there is a behavior change or additional feature. Minor bug fixes and documentation changes should not be included.

@Yuhi-Sato Yuhi-Sato changed the title Fix: preserve enum methods when using becomes! on STI subclasses: Preserve enum methods when using becomes! on STI subclasses May 16, 2025
@Yuhi-Sato Yuhi-Sato force-pushed the fix-becomes-enum-sti branch 3 times, most recently from b2de3b6 to 522cf6a Compare May 28, 2025 03:17
- Fix rails#55001

The `becomes!` method fails to preserve enum-related behavior when
converting between STI (Single Table Inheritance) subclasses that
define different `enum` values for the same attribute.

In Active Record, `enum` dynamically defines instance methods—such as
predicate methods (`#published?`) and mutator methods (`#published!`)—
at the time the `enum` macro is invoked. These method definitions are
specific to the class where the enum is declared, and the underlying
attribute uses a class-specific `EnumType` for validation and
serialization.

However, when calling `becomes!`, the internal attribute set
(`@attributes`) is copied to the new instance **without rebinding the
types** to the destination class.

As a result:

- The enum continues to use the original class’s mapping.
- Dynamically defined methods from the destination class exist, but
  internally operate on the *wrong enum mapping*.
- This leads to unexpected behavior such as `nil` values or
  `ArgumentError` exceptions when calling enum methods.

This breaks expectations in STI scenarios, where subclasses often
define their own `enum` values on shared columns.

    ```ruby
    class Foo < ActiveRecord::Base
    end

    class Bar < Foo
      enum(:state, { draft: 'draft' })
    end

    class Baz < Foo
      enum(:state, { published: 'published' })
    end

    bar = Bar.create!(state: 'draft')
    baz = bar.becomes!(Baz)
    baz.published! # => raises ArgumentError
    ```

This fails with `ArgumentError`, because the value is not valid
under the destination class's enum mapping checked by
`EnumType#assert_valid_value`:

    ```ruby
    def assert_valid_value(value)
      return unless @_raise_on_invalid_values

      unless value.blank? || mapping.has_key?(value) || mapping.has_value?(value)
        raise ArgumentError, "'#{value}' is not a valid #{name}"
      end
    end
    ```

The problem arises because the enum type mapping from the original
class (`Bar`) is preserved in the internal attribute state even after
calling `becomes!`, making the new class's enum invalid.

When performing `becomes!`, ensure that each attribute is rebound to
the new class’s attribute type using `with_type`, so that enum accessors
function correctly in the destination class.
@Yuhi-Sato Yuhi-Sato force-pushed the fix-becomes-enum-sti branch from 522cf6a to c88d31f Compare May 31, 2025 23:27
@Yuhi-Sato Yuhi-Sato requested a review from zzak May 31, 2025 23:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Using become! on an STI model sets enum attribute to nil
2 participants