Propose a way to add validation on the enum attribute#41730
Propose a way to add validation on the enum attribute#41730kamipo wants to merge 1 commit intorails:mainfrom
Conversation
Last year, I heard about the inconveniences of ActiveRecord Enum and got some feedback (one of those is rails#40456). Some people have reported that in practice they are using enums for user input and it is inconvenient to not be able to handle invalid input with validation like other attributes. Actually, a former colleague was overriding the attribute writer for validation, and they had wished this inconvenience to be resolved in the Rails. So I propose a way to add validation on the enum attribute. This allows people to no longer need to override the attribute writer. Before: ```ruby class Book < ActiveRecord::Base enum :status, [:proposed, :written] validates_inclusion_of :status, in: statuses.keys def status=(value) super rescue ArgumentError @attributes.write_cast_value("status", value) end end ``` After: ```ruby class Book < ActiveRecord::Base enum :status, [:proposed, :written], validate: true end ``` Resolves rails#13971.
| delegate :type, to: :subtype | ||
|
|
||
| def initialize(name, mapping, subtype) | ||
| def initialize(name, mapping, subtype, validate: true) |
There was a problem hiding this comment.
would having this enabled by default be a breaking change?
There was a problem hiding this comment.
Yes, changing the default will break the existing test here:
rails/activerecord/test/cases/enum_test.rb
Lines 295 to 300 in 97832cb
There was a problem hiding this comment.
@MatheusRich i found it a bit confusing too, but validate here is the opposite to the validate prop you'd provide in your model. So validate will be true here if the validate prop is false (or not present) on the model's call to enum.
There was a problem hiding this comment.
| def initialize(name, mapping, subtype, validate: true) | |
| def initialize(name, mapping, subtype, raise_on_invalid_values: true) |
|
This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. |
|
@kamipo ping to check whether you're still interested in this 🙇 Do you have any ideas on @ghiculescu's comment? https://github.com/rails/rails/pull/41730/files#r603533074 |
|
I and the people around me are still hoping that this problem will be solved in the Rails side without overriding the attribute writer, but this is a new feature so I'm waiting for feedback from the core team. I meant that user provided |
| attribute(name, **options) do |subtype| | ||
| subtype = subtype.subtype if EnumType === subtype | ||
| EnumType.new(name, enum_values, subtype) | ||
| EnumType.new(name, enum_values, subtype, validate: !validate) |
There was a problem hiding this comment.
| EnumType.new(name, enum_values, subtype, validate: !validate) | |
| EnumType.new(name, enum_values, subtype, raise_on_invalid_values: !validate) |
| @name = name | ||
| @mapping = mapping | ||
| @subtype = subtype | ||
| @validate = validate |
There was a problem hiding this comment.
| @validate = validate | |
| @ raise_on_invalid_values = raise_on_invalid_values |
|
|
||
| def assert_valid_value(value) | ||
| unless value.blank? || mapping.has_key?(value) || mapping.has_value?(value) | ||
| if @validate && !(value.blank? || mapping.has_key?(value) || mapping.has_value?(value)) |
There was a problem hiding this comment.
| if @validate && !(value.blank? || mapping.has_key?(value) || mapping.has_value?(value)) | |
| if @raise_on_invalid_values && !(value.blank? || mapping.has_key?(value) || mapping.has_value?(value)) |
| detect_negative_enum_conditions!(value_method_names) if scopes | ||
|
|
||
| if validate | ||
| validate = {} unless Hash === validate |
There was a problem hiding this comment.
is it worth adding a test for the case where validate is a Hash? currently there's only a test for when it's true.
|
@kamipo definitely find @ghiculescu suggestions an improvement. Would love to see them applied and this merged 🙏 |
|
This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. |
|
Having user friendly feedback on our model backed forms is a must. Not being able to use I'm happy to help move this forward. What needs to be done to get this merged? |
|
This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. |
|
What is missing to get this merged? Indeed throwing an exception is not the expected behavior. |
|
Maybe instead of class Book < ActiveRecord::Base
enum :status, [:proposed, :written], strict: false
validates :status, inclusion: { in: statuses.keys }
end |
|
I can propose this concern # models/concern/custom_enum_validation.rb
module CustomEnumValidation
extend ActiveSupport::Concern
included do
self.defined_enums.keys.each do |key|
validates_inclusion_of key, in: send(key.pluralize).keys
define_method "#{key}=" do |value|
@attributes.write_cast_value(key, value)
end
end
end
end# models/user.rb
class User < ApplicationRecord
enum status: %i[approved rejected]
enum some_other_status: %i[foo bar]
# Must be included after all `enum` declarations
include CustomEnumValidation
endWith shared example (using Faker + Should-matchers): shared_examples_for 'custom_enum_validation' do
let(:factory_name) { model_class.to_s.gsub('::', '').underscore.to_sym }
let(:model_class) { described_class } # the class that includes the concern
context 'with custom enum validation' do
let(:attributes) { attributes_for(factory_name) }
let(:model_instance) { described_class.new(attributes) }
described_class.defined_enums.each do |key, values|
it { is_expected.to validate_inclusion_of(key).in_array(values.keys)}
end
end
end |
|
Given this feature is still being fleshed out, in a different issue someone dropped in this as a workaround: #13971 (comment) It replaces the current behavior of the def assert_valid_value(value)
unless value.blank? || mapping.has_key?(value) || mapping.has_value?(value)
raise ArgumentError, "'#{value}' is not a valid #{name}"
end
endWith: def assert_valid_value(value)
unless value.blank? || mapping.has_key?(value) || mapping.has_value?(value)
nil
end
endAnd now you can get validation errors with: class Post < ApplicationRecord
enum :status, { unpublished: 0, published: 10 }
validates :status, inclusion: { in: statuses.keys, message: :invalid }
endAre there any gotchas or things to think about where dropping this into a new project would be an issue until AR is officially patched with whatever solution ends up being ideal? One thing I noticed is if you use the above patch and don't put in the validator, then the following things occur:
I guess the takeaway here is do you want guaranteed database integrity or nice user facing messages? Seems like a case where having an official patch to give us the best of both worlds will be much appreciated! |
|
We merged #49100 for this change so that we can close this PR. |
Since #41730 is the original work for the enum attribute validation.

Last year, I heard about the inconveniences of ActiveRecord Enum and got
some feedback (one of those is #40456).
Some people have reported that in practice they are using enums for user
input and it is inconvenient to not be able to handle invalid input with
validation like other attributes.
Actually, a former colleague was overriding the attribute writer for
validation, and they had wished this inconvenience to be resolved in the
Rails.
So I propose a way to add validation on the enum attribute. This allows
people to no longer need to override the attribute writer.
Before:
After:
Resolves #13971.