Skip to content

Commit e4a3b79

Browse files
HoneyryderChuckrastamhadiclaude
authored
cast values to Enumerize::Value, filter invalid values on multiple (#476)
* cast values to Enumerize::Value, filter invalid values on multiple this regression was caught while upgrading an application to rails 7.2, where activemodel with set the attributes API before enumerize is loaded, which makes the callback chain work differently when the model is assigned raw values which pass through `type.cast(value_before_type_cast)`, making it return the raw value, which can't respond to Enumerize::Value predication methods. * Add ActiveModel::Attributes model-reader tests for Type#cast The existing Type#cast specs assert the type in isolation. Add model-level regression coverage using a model whose enumerize attributes are declared without a preceding plain `attribute`, so on Rails 8.1 the generated reader outranks enumerize's module and reads resolve through #cast. Without the #cast override these fail with a raw String/Symbol; value predicates then raise NoMethodError. Also add a CHANGELOG entry for the #cast fix. Verified on Rails 8.1.3, 8.0.5 and 7.2.3.1 (Ruby 3.3.6). Refs #482 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Rastam Hadi <rastam.hadi@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4b1c918 commit e4a3b79

3 files changed

Lines changed: 128 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
### bug fix
44

5+
* 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.
56
* 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.
67

78
### enchancements

lib/enumerize/activemodel.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,20 @@ def deserialize(value)
5050
@attr.find_value(value)
5151
end
5252
end
53+
54+
def cast(value)
55+
case value
56+
when nil
57+
return super
58+
when Array
59+
if @attr.arguments[:multiple]
60+
found = @attr.find_values(*value)
61+
return found.presence || super
62+
end
63+
end
64+
65+
@attr.find_value(value) || value
66+
end
5367
end
5468
end
5569
end

test/activemodel_test.rb

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ class InterestsRequiredActiveModelUser < ActiveModelUser
2020
validates :interests, presence: true
2121
end
2222

23+
# No plain `attribute` is declared before `enumerize` here. On Rails 8.1 the
24+
# ActiveModel-generated attribute-method module then outranks enumerize's own
25+
# reader module in the ancestor chain, so reads resolve through the type's
26+
# #cast. This model reproduces the raw-value regression that #cast fixes.
27+
class CastActiveModelUser
28+
include ActiveModel::Model
29+
include ActiveModel::Attributes
30+
extend Enumerize
31+
32+
enumerize :sex, :in => %w[male female]
33+
enumerize :role, :in => %w[admin user], :default => 'user'
34+
end
35+
2336
let(:model) { ActiveModelUser }
2437

2538
it 'initialize value' do
@@ -183,6 +196,106 @@ class InterestsRequiredActiveModelUser < ActiveModelUser
183196
expect(deserialized.map(&:to_s)).must_equal ['music', 'programming']
184197
end
185198
end
199+
200+
describe 'Type#cast' do
201+
it 'casts single valid value to Enumerize::Value' do
202+
type = model.attribute_types['sex']
203+
result = type.cast('male')
204+
expect(result).must_be_instance_of Enumerize::Value
205+
expect(result.to_s).must_equal 'male'
206+
end
207+
208+
it 'returns nil for nil single value' do
209+
type = model.attribute_types['sex']
210+
result = type.cast(nil)
211+
expect(result).must_be_nil
212+
end
213+
214+
it 'returns original value for invalid single value' do
215+
type = model.attribute_types['sex']
216+
result = type.cast('invalid')
217+
expect(result).must_equal 'invalid'
218+
end
219+
220+
it 'casts array of valid values for multiple attribute' do
221+
type = model.attribute_types['interests']
222+
result = type.cast(['music', 'sports'])
223+
expect(result).must_be_instance_of Array
224+
expect(result.map(&:to_s)).must_equal ['music', 'sports']
225+
end
226+
227+
it 'returns empty array as-is for multiple attribute' do
228+
type = model.attribute_types['interests']
229+
result = type.cast([])
230+
expect(result).must_equal []
231+
end
232+
233+
it 'filters out invalid values when at least one is valid' do
234+
type = model.attribute_types['interests']
235+
result = type.cast(['music', 'invalid', 'sports'])
236+
expect(result.map(&:to_s)).must_equal ['music', 'sports']
237+
end
238+
239+
it 'returns array as-is when all values are invalid for multiple attribute' do
240+
type = model.attribute_types['interests']
241+
result = type.cast(['invalid1', 'invalid2'])
242+
expect(result).must_equal ['invalid1', 'invalid2']
243+
end
244+
245+
it 'returns nil for nil value on multiple attribute' do
246+
type = model.attribute_types['interests']
247+
result = type.cast(nil)
248+
expect(result).must_be_nil
249+
end
250+
end
251+
252+
describe 'Type#cast (model reader)' do
253+
# Regression coverage for the Rails 8.1 attribute-method module ordering
254+
# change: when no plain `attribute` precedes `enumerize`, the generated
255+
# reader outranks enumerize's module, so reads go through #cast and must
256+
# still return an Enumerize::Value. Without the #cast override the first
257+
# two examples fail on Rails 8.1 (raw String/Symbol instead of a value).
258+
it 'wraps a user-assigned value so predicate methods work' do
259+
user = CastActiveModelUser.new(sex: 'male')
260+
expect(user.sex).must_be_instance_of Enumerize::Value
261+
expect(user.sex.male?).must_equal true
262+
expect(user.sex.female?).must_equal false
263+
end
264+
265+
it 'wraps a value assigned after initialization' do
266+
user = CastActiveModelUser.new
267+
user.sex = :female
268+
expect(user.sex).must_be_instance_of Enumerize::Value
269+
expect(user.sex.female?).must_equal true
270+
end
271+
272+
it 'wraps the default value' do
273+
user = CastActiveModelUser.new
274+
expect(user.role).must_be_instance_of Enumerize::Value
275+
expect(user.role.user?).must_equal true
276+
end
277+
278+
it 'returns nil when nil is assigned' do
279+
user = CastActiveModelUser.new(sex: 'male')
280+
user.sex = nil
281+
expect(user.sex).must_be_nil
282+
end
283+
284+
it 'keeps an invalid value assignable so inclusion validation still rejects it' do
285+
# The reader's return for an invalid value is ordering-dependent (nil when
286+
# enumerize's writer wins on Rails <= 8.0, the raw value when the generated
287+
# reader wins on Rails 8.1); the portable guarantee is that validation fails.
288+
user = CastActiveModelUser.new(sex: 'invalid')
289+
expect(user).wont_be :valid?
290+
expect(user.errors[:sex]).wont_be :empty?
291+
end
292+
293+
it 'leaves multiple: true attributes as an unmangled Enumerize::Set' do
294+
user = ActiveModelUser.new(interests: [:music, :programming])
295+
expect(user.interests).must_be_instance_of Enumerize::Set
296+
expect(user.interests.to_a).must_equal %w[music programming]
297+
end
298+
end
186299
end
187300

188301
else

0 commit comments

Comments
 (0)