From f69ce7ce5dfa6421b53bad1cf19e75fe3c28b127 Mon Sep 17 00:00:00 2001 From: Tiago Cardoso Date: Wed, 13 May 2026 17:41:36 +0100 Subject: [PATCH 1/2] 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. --- lib/enumerize/activemodel.rb | 14 ++++++++++ test/activemodel_test.rb | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/lib/enumerize/activemodel.rb b/lib/enumerize/activemodel.rb index b57a043..7f2ad16 100644 --- a/lib/enumerize/activemodel.rb +++ b/lib/enumerize/activemodel.rb @@ -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 diff --git a/test/activemodel_test.rb b/test/activemodel_test.rb index b2c2e52..4056bec 100644 --- a/test/activemodel_test.rb +++ b/test/activemodel_test.rb @@ -183,6 +183,58 @@ 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 end else From 1ecb5f58fc2e2edaea5ca1e0c2b803a5ca8fae13 Mon Sep 17 00:00:00 2001 From: Rastam Hadi Date: Tue, 14 Jul 2026 18:35:50 +0900 Subject: [PATCH 2/2] 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 brainspec/enumerize#482 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + test/activemodel_test.rb | 61 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b588ebb..056ea43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/test/activemodel_test.rb b/test/activemodel_test.rb index 4056bec..275ad2d 100644 --- a/test/activemodel_test.rb +++ b/test/activemodel_test.rb @@ -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 @@ -235,6 +248,54 @@ class InterestsRequiredActiveModelUser < ActiveModelUser 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