Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

### bug fix

* Fixed `Enumerize::ActiveModelAttributesSupport::Type` to wrap values in `#cast` so enumerized readers on plain `ActiveModel::Attributes` objects return an `Enumerize::Value` (and value predicates keep working) on Rails 8.1. Rails 8.1 reordered attribute-method module inclusion so the generated reader can outrank enumerize's module; assignment reads go through `#cast`, which previously returned the raw value. See #482.
* Fixed `Enumerize::ActiveModelAttributesSupport::Type#deserialize` to properly handle arrays for `multiple: true` attributes. Previously, deserializing an array would return `nil` instead of the enumerated values. This bug only affected ActiveModel::Attributes usage (not ActiveRecord) and was exposed when used with gems like store_model v2.0.0+ that call `deserialize` during load.

### enchancements
Expand Down
14 changes: 14 additions & 0 deletions lib/enumerize/activemodel.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ def deserialize(value)
@attr.find_value(value)
end
end

def cast(value)
case value
when nil
return super
when Array
if @attr.arguments[:multiple]
found = @attr.find_values(*value)
return found.presence || super
end
end

@attr.find_value(value) || value
end
end
end
end
113 changes: 113 additions & 0 deletions test/activemodel_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ class InterestsRequiredActiveModelUser < ActiveModelUser
validates :interests, presence: true
end

# No plain `attribute` is declared before `enumerize` here. On Rails 8.1 the
# ActiveModel-generated attribute-method module then outranks enumerize's own
# reader module in the ancestor chain, so reads resolve through the type's
# #cast. This model reproduces the raw-value regression that #cast fixes.
class CastActiveModelUser
include ActiveModel::Model
include ActiveModel::Attributes
extend Enumerize

enumerize :sex, :in => %w[male female]
enumerize :role, :in => %w[admin user], :default => 'user'
end

let(:model) { ActiveModelUser }

it 'initialize value' do
Expand Down Expand Up @@ -183,6 +196,106 @@ class InterestsRequiredActiveModelUser < ActiveModelUser
expect(deserialized.map(&:to_s)).must_equal ['music', 'programming']
end
end

describe 'Type#cast' do
it 'casts single valid value to Enumerize::Value' do
type = model.attribute_types['sex']
result = type.cast('male')
expect(result).must_be_instance_of Enumerize::Value
expect(result.to_s).must_equal 'male'
end

it 'returns nil for nil single value' do
type = model.attribute_types['sex']
result = type.cast(nil)
expect(result).must_be_nil
end

it 'returns original value for invalid single value' do
type = model.attribute_types['sex']
result = type.cast('invalid')
expect(result).must_equal 'invalid'
end

it 'casts array of valid values for multiple attribute' do
type = model.attribute_types['interests']
result = type.cast(['music', 'sports'])
expect(result).must_be_instance_of Array
expect(result.map(&:to_s)).must_equal ['music', 'sports']
end

it 'returns empty array as-is for multiple attribute' do
type = model.attribute_types['interests']
result = type.cast([])
expect(result).must_equal []
end

it 'filters out invalid values when at least one is valid' do
type = model.attribute_types['interests']
result = type.cast(['music', 'invalid', 'sports'])
expect(result.map(&:to_s)).must_equal ['music', 'sports']
end

it 'returns array as-is when all values are invalid for multiple attribute' do
type = model.attribute_types['interests']
result = type.cast(['invalid1', 'invalid2'])
expect(result).must_equal ['invalid1', 'invalid2']
end

it 'returns nil for nil value on multiple attribute' do
type = model.attribute_types['interests']
result = type.cast(nil)
expect(result).must_be_nil
end
end

describe 'Type#cast (model reader)' do
# Regression coverage for the Rails 8.1 attribute-method module ordering
# change: when no plain `attribute` precedes `enumerize`, the generated
# reader outranks enumerize's module, so reads go through #cast and must
# still return an Enumerize::Value. Without the #cast override the first
# two examples fail on Rails 8.1 (raw String/Symbol instead of a value).
it 'wraps a user-assigned value so predicate methods work' do
user = CastActiveModelUser.new(sex: 'male')
expect(user.sex).must_be_instance_of Enumerize::Value
expect(user.sex.male?).must_equal true
expect(user.sex.female?).must_equal false
end

it 'wraps a value assigned after initialization' do
user = CastActiveModelUser.new
user.sex = :female
expect(user.sex).must_be_instance_of Enumerize::Value
expect(user.sex.female?).must_equal true
end

it 'wraps the default value' do
user = CastActiveModelUser.new
expect(user.role).must_be_instance_of Enumerize::Value
expect(user.role.user?).must_equal true
end

it 'returns nil when nil is assigned' do
user = CastActiveModelUser.new(sex: 'male')
user.sex = nil
expect(user.sex).must_be_nil
end

it 'keeps an invalid value assignable so inclusion validation still rejects it' do
# The reader's return for an invalid value is ordering-dependent (nil when
# enumerize's writer wins on Rails <= 8.0, the raw value when the generated
# reader wins on Rails 8.1); the portable guarantee is that validation fails.
user = CastActiveModelUser.new(sex: 'invalid')
expect(user).wont_be :valid?
expect(user.errors[:sex]).wont_be :empty?
end

it 'leaves multiple: true attributes as an unmangled Enumerize::Set' do
user = ActiveModelUser.new(interests: [:music, :programming])
expect(user.interests).must_be_instance_of Enumerize::Set
expect(user.interests.to_a).must_equal %w[music programming]
end
end
end

else
Expand Down