Skip to content

Propose a way to add validation on the enum attribute#41730

Closed
kamipo wants to merge 1 commit intorails:mainfrom
kamipo:propose_a_way_to_add_validation_on_enum_attribute
Closed

Propose a way to add validation on the enum attribute#41730
kamipo wants to merge 1 commit intorails:mainfrom
kamipo:propose_a_way_to_add_validation_on_enum_attribute

Conversation

@kamipo
Copy link
Member

@kamipo kamipo commented Mar 22, 2021

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:

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:

class Book < ActiveRecord::Base
  enum :status, [:proposed, :written], validate: true
end

Resolves #13971.

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)
Copy link
Contributor

@MatheusRich MatheusRich Mar 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would having this enabled by default be a breaking change?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, changing the default will break the existing test here:

test "assign non existing value raises an error" do
e = assert_raises(ArgumentError) do
@book.status = :unknown
end
assert_equal "'unknown' is not a valid status", e.message
end

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

See https://github.com/rails/rails/pull/41730/files#diff-a0de22dc0114613fdc67f48c916e70b9983ef47bcc84b96232c36398af1d166fR190

image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def initialize(name, mapping, subtype, validate: true)
def initialize(name, mapping, subtype, raise_on_invalid_values: true)

@kamipo kamipo mentioned this pull request Jun 4, 2021
@rails-bot
Copy link

rails-bot bot commented Jun 27, 2021

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.
Thank you for your contributions.

@rails-bot rails-bot bot added the stale label Jun 27, 2021
@zzak
Copy link
Member

zzak commented Jun 27, 2021

@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

@rails-bot rails-bot bot removed the stale label Jun 27, 2021
@kamipo
Copy link
Member Author

kamipo commented Jun 28, 2021

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 validate option and the original assert_valid_value on the type cast are exclusive, but I admit that it is a bit confusing, so I am open to good suggestions.

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@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))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@stas
Copy link

stas commented Sep 1, 2021

@kamipo definitely find @ghiculescu suggestions an improvement. Would love to see them applied and this merged 🙏

@rails-bot
Copy link

rails-bot bot commented Nov 30, 2021

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.
Thank you for your contributions.

@rails-bot rails-bot bot added the stale label Nov 30, 2021
@kamipo kamipo removed the stale label Dec 6, 2021
@kueblc
Copy link

kueblc commented Dec 16, 2021

Having user friendly feedback on our model backed forms is a must. Not being able to use enum for this creates a lot of extra code and certainly doesn't seem like the Rails way.

I'm happy to help move this forward. What needs to be done to get this merged?

@rails-bot
Copy link

rails-bot bot commented Mar 16, 2022

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.
Thank you for your contributions.

@rails-bot rails-bot bot added the stale label Mar 16, 2022
@kamipo kamipo removed the stale label Mar 19, 2022
@ghost
Copy link

ghost commented Apr 7, 2022

What is missing to get this merged? Indeed throwing an exception is not the expected behavior.

@f-mer
Copy link
Contributor

f-mer commented Aug 24, 2022

Maybe instead of validate the option could be named strict to mirror behavior that is already present for the validate method. Just adding an option to just suppress the ArgumentError could be a way forward? That way it is possible to keep using enum without overriding methods and add validations on top.

class Book < ActiveRecord::Base
  enum :status, [:proposed, :written], strict: false

  validates :status, inclusion:  { in: statuses.keys }
end

@brcebn
Copy link

brcebn commented Dec 23, 2022

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
end

With 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

@nickjj
Copy link
Contributor

nickjj commented Dec 29, 2022

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 EnumType type which is:

      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
      end

With:

      def assert_valid_value(value)
        unless value.blank? || mapping.has_key?(value) || mapping.has_value?(value)
          nil
        end
      end

And now you can get validation errors with:

class Post < ApplicationRecord
  enum :status, { unpublished: 0, published: 10 }
  validates :status, inclusion: { in: statuses.keys, message: :invalid }
end

Are 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:

  • Trying to write status: 42 on create! results in AR successfully setting status: nil and writing the row in the database but Postgres will end up having 42 written to the status column giving your database an integrity issue based on your enum definition's values, this also bypasses a null: false at the database level since it gets written with the invalid data
  • Trying to write status: "yikes" on create! results in Postgres throwing PG::NotNullViolation: ERROR: null value in column "status" of relation "posts" violates not-null constraint with no row being created in the database

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!

@akhilgkrishnan
Copy link
Member

We merged #49100 for this change so that we can close this PR.

@ghiculescu
Copy link
Member

#49100

@ghiculescu ghiculescu closed this Sep 3, 2023
@kamipo kamipo deleted the propose_a_way_to_add_validation_on_enum_attribute branch September 4, 2023 04:35
kamipo added a commit that referenced this pull request Sep 4, 2023
Since #41730 is the original work for the enum attribute validation.
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.

ActiveRecord enum: use validation if exists instead of raising ArgumentError

10 participants