12

A thread was created here, but it doesn't solve my problem.

My code is:

course.rb

class Course < ApplicationRecord
  COURSE_TYPES = %i( trial limited unlimited )
  enum course_type: COURSE_TYPES
  validates_inclusion_of :course_type, in: COURSE_TYPES
end

courses_controller.rb

class CoursesController < ApiController
  def create
    course = Course.new(course_params) # <-- Exception here
    if course.save # <-- But I expect the process can go here
      render json: course, status: :ok
    else
      render json: {error: 'Failed to create course'}, status: :unprocessable_entity
    end
  end

  private    
    def course_params
      params.require(:course).permit(:course_type)
    end
end

My test cases:

courses_controller_spec.rb

describe '#create' do
  context 'when invalid course type' do
    let(:params) { { course_type: 'english' } }
    before { post :create, params: { course: params } }

    it 'returns 422' do
      expect(response.status).to eq(422)
    end
  end
end

When running the above test case, I got an ArgumentError exception which was described at Rails issues

So I expect if I set an invalid course_type to enum, it will fail in validation phase instead of raising an exception.

Additionally, I know what really happens under the hook in rails at here and I don't want to manually rescue this kind of exception in every block of code which assigns an enum type value!

Any suggestion on this?

Community
  • 1
  • 1
Hieu Pham
  • 6,577
  • 2
  • 30
  • 50

7 Answers7

15

I've found a solution. Tested by myself in Rails 6.

# app/models/contact.rb
class Contact < ApplicationRecord
  include LiberalEnum

  enum kind: {
    phone: 'phone', skype: 'skype', whatsapp: 'whatsapp'
  }

  liberal_enum :kind

  validates :kind, presence: true, inclusion: { in: kinds.values }
end
# app/models/concerns/liberal_enum.rb
module LiberalEnum
  extend ActiveSupport::Concern

  class_methods do
    def liberal_enum(attribute)
      decorate_attribute_type(attribute, :enum) do |subtype|
        LiberalEnumType.new(attribute, public_send(attribute.to_s.pluralize), subtype)
      end
    end
  end
end
# app/types/liberal_enum_type.rb
class LiberalEnumType < ActiveRecord::Enum::EnumType
  # suppress <ArgumentError>
  # returns a value to be able to use +inclusion+ validation
  def assert_valid_value(value)
    value
  end
end

Usage:

contact = Contact.new(kind: 'foo')
contact.valid? #=> false
contact.errors.full_messages #=> ["Kind is not included in the list"]
Tyler Rick
  • 9,191
  • 6
  • 60
  • 60
Aliaksandr
  • 651
  • 6
  • 7
  • 3
    This is a really good solution. It's what I ended up doing. The only thing I changed was using `defined_enums.fetch(attribute.to_s)` instead of `public_send(attribute.to_s.pluralize)`. – Travis Dec 25 '19 at 18:35
  • This is broken in Rails 6.1 as the private method `decorate_attribute_type ` has changed. `in 'decorate_attribute_type': wrong number of arguments (given 2, expected 1) (ArgumentError)`. Needed to remove the `:enum` positional argument. – Tim Krins Jun 01 '23 at 01:30
7

UPDATED to support .valid? to have idempotent validations.

This solution isn't really elegant, but it works.

We had this problem in an API application. We do not like the idea of rescueing this error every time it is needed to be used in any controller or action. So we rescued it in the model-side as follows:

class Course < ApplicationRecord
  validate :course_type_should_be_valid

  def course_type=(value)
    super value
    @course_type_backup = nil
  rescue ArgumentError => exception
    error_message = 'is not a valid course_type'
    if exception.message.include? error_message
      @course_type_backup = value
      self[:course_type] = nil
    else
      raise
    end
  end

  private

  def course_type_should_be_valid
    if @course_type_backup
      self.course_type ||= @course_type_backup
      error_message = 'is not a valid course_type'
      errors.add(:course_type, error_message)
    end
  end
end

Arguably, the rails-team's choice of raising ArgumentError instead of validation error is correct in the sense that we have full control over what options a user can select from a radio buttons group, or can select over a select field, so if a programmer happens to add a new radio button that has a typo for its value, then it is good to raise an error as it is an application error, and not a user error.

However, for APIs, this will not work because we do not have any control anymore on what values get sent to the server.

Jay-Ar Polidario
  • 6,463
  • 14
  • 28
  • Hey, this is a good point, but won't work, if your run :valid?, the `errors` will be cleared first, check out this http://stackoverflow.com/questions/5159612/flag-invalid-attribute-in-activerecord/5159655#5159655 – Hieu Pham May 12 '16 at 16:53
  • Yes if you run `.valid?` before you set up the value of `course_type`, there really is no error first because there's no value yet, unless you have a `:presence` validation or other validation for course_type. You'll need to have a presence validation so that there will already be errors at the start. This is in line with the other model validation which is :inclusion which is just like my answer above. Note that presence and inclusion are independent of each other – Jay-Ar Polidario May 12 '16 at 17:06
  • Man! Sure, The problem is your solution won't add the error message to `error` as you expected! – Hieu Pham May 12 '16 at 17:11
  • Hmm that's strange. This is the exact code we used in our project, so i'm positive the error gets set. Although we haven't really used `.valid?`in that context so maybe that's why. I'll check later when I get home to verify this. – Jay-Ar Polidario May 12 '16 at 17:14
  • You are right. `.valid?` clears all errors and then run validations. thanks for that. Learned something new today. I updated my answer. I tested this working. – Jay-Ar Polidario May 12 '16 at 18:53
7

Want to introduce another solution.

class Course < ApplicationRecord
  COURSE_TYPES = %i[ trial limited unlimited ]
  enum course_type: COURSE_TYPES

  validate do
    if @not_valid_course_type
      errors.add(:course_type, "Not valid course type, please select from the list: #{COURSE_TYPES}")
    end
  end

  def course_type=(value)
    if !COURSE_TYPES.include?(value.to_sym)
      @not_valid_course_type = true
    else
      super value
    end
  end
end

This will avoid ArgumentError in controllers. Works well on my Rails 6 application.

Dmitry Shveikus
  • 161
  • 2
  • 3
1

Using the above answer of the logic of Dmitry I made this dynamic solution to the ActiveRecord model

Solution 1:

#app/models/account.rb
class Account < ApplicationRecord
  ENUMS = %w(state kind meta_mode meta_margin_mode)

  enum state: {disable: 0, enable: 1}
  enum kind:  {slave: 0, copy: 1}
  enum meta_mode:         {demo: 0, real: 1}
  enum meta_margin_mode:  {netting: 0, hedging: 1}


  validate do
    ENUMS.each do |e|
      if instance_variable_get("@not_valid_#{e}")
        errors.add(e.to_sym, "must be #{self.class.send("#{e.pluralize}").keys.join(' or ')}")
      end
    end
  end


  after_initialize do |account|
    Account::ENUMS.each do |e| 
      account.class.define_method("#{e}=") do |value|
        if !account.class.send("#{e.pluralize}").keys.include?(value)
          instance_variable_set("@not_valid_#{e}", true)
        else
          super value
        end
      end
    end
  end
end

Updated.

Solution2: Here's another approach to dynamically replicate to other models.

# app/models/concerns/lib_enums.rb
module LibEnums
  extend ActiveSupport::Concern

  included do
        validate do
        self.class::ENUMS.each do |e|
          if instance_variable_get("@not_valid_#{e}")
            errors.add(e.to_sym, "must be #{self.class.send("#{e.pluralize}").keys.join(' or ')}")
          end
        end
      end

        self::ENUMS.each do |e| 
          self.define_method("#{e}=") do |value|
            if !self.class.send("#{e.pluralize}").keys.include?(value)
              instance_variable_set("@not_valid_#{e}", true)
            else
              super value
            end
          end
        end
    end
end
#app/models/account.rb

require 'lib_enums'
class Account < ApplicationRecord
  include LibEnums
  ENUMS = %w(state kind meta_mode meta_margin_mode)
end
Breno Perucchi
  • 873
  • 7
  • 16
  • 1
    I upvoted this (neat), but I would put in `models/concerns` and you have a typo (you need to pluralize, not `"#{e}s"`). – Dan Brown Aug 30 '22 at 15:36
0

The above answer by Aliaksandr does not work for Rails 7.0.4 as the decorate_attribute_type method was removed in Rails 7 and unified with the attribute method.

As such, the above solution will raise a NoMethodError similar to the following:

NoMethodError (undefined method `decorate_attribute_type' for <Model>:Class)

To implement that solution in Rails 7 consider using the following modified concern instead:

# app/models/concerns/liberal_enum.rb
module LiberalEnum
  extend ActiveSupport::Concern

  class_methods do
    def liberal_enum(attribute)
      attribute(attribute, :enum) do |subtype|
        LiberalEnumType.new(attribute, public_send(attribute.to_s.pluralize), subtype)
      end
    end
  end
end
fin-cos
  • 11
  • 3
  • Does the `do |subtype|` do anything when used with `attribute` in Rails 7? When I debug my app using this code the block is never called. Using `attribute(attribute, :enum)` seems to work if you are using postgres because the postgres adapter has an `:enum` type. For other databases (like sqlite3) there is no `:enum` type registered. – Jeremy Raymond Apr 05 '23 at 21:18
0

For folks using Rails 7 and Postgres the following works and is simpler than these other solutions.

Note this relies on Postgres' enum support and registered ActiveRecord Enum type from the postgresql connection adapter. Other dbs (like sqlite3) do not have an Enum type of their own. So attribute :label, :enum would be invalid.

class Address < ApplicationRecord
  # Define the enum field as per normal
  enum label: { home: 'home', work: 'work', other: 'other' }

  # Redefine the label field with Postgres' registered :enum type. This
  # gets rid of the ActiveRecord::Enum::EnumType wrapper that raises the
  # ArgumentError in from assert_valid_value method.
  attribute :label, :enum

  # Validate at the model level that the value is valid. Without this invalid
  # values could be sent to the database, however since we've defined
  # this field as an enum in Postgres (see schema.rb) Postgres will reject
  # any invalid values that slip through.
  validates :label, inclusion: { in: labels.values }
end

This is the migration where the label enum field was added to the database.

class AddLabelToAddresses < ActiveRecord::Migration[7.0]
  def change
    create_enum :address_label, %w[home work other]

    add_column :addresses, :label, :enum, enum_type: :address_label, null: false, default: 'other'
  end
end

In schema.rb you end up with something like:

ActiveRecord::Schema[7.0].define(version: 2023_04_05_132734) do
  enable_extension "plpgsql"

  # The enum definition
  create_enum "address_label", ["home", "work", "other"]

  create_table "addresses", force: :cascade do |t|
    # Other fields omitted for brevity.

    # The label field is an enum type that uses the previously defined
    # address_label enum
    t.enum "label", default: "other", null: false, enum_type: "address_label"    
  end
Jeremy Raymond
  • 5,817
  • 3
  • 31
  • 33
-1

The answer posted by fin-cos won't work for me in Rails 7.0.4.2

In my tests I get the following error:

ArgumentError: You tried to define an enum named "name_of_the_enum" on the model "Model", but this will generate a instance method "name_of_the_enum_value?", which is already defined by another enum.

The Rails docs state that attribute overrides existing definitions. But somehow that isn't true.

UPDATE (2023-02-20):

I took Dmitry Shveikus solution, which was working for me and mixed it with the concern approach. So I ended up with:

models/concerns/validates_enum.rb:

module ValidatesEnum
  extend ActiveSupport::Concern

  class_methods do
    def validates_enum(*enums)
      enums.each do |enum_attribute|
        define_method(:"#{enum_attribute}_types") do
          self.class.const_get(:"#{enum_attribute.to_s.upcase}_TYPES").keys.map(&:to_s)
        end

        define_method(:"#{enum_attribute}=") do |value|
          if !send("#{enum_attribute}_types").include?(value)
            self.instance_variable_set(:"@not_valid_#{enum_attribute}_type", true)
          else
            super value
          end
        end

        validate do
          if self.instance_variable_get(:"@not_valid_#{enum_attribute}_type")
            errors.add(enum_attribute, "Not a valid #{enum_attribute} type, please select from the list: #{send(:"#{enum_attribute}_types").join(', ')}")
          end
        end
      end
    end
  end
end

And in your model:

class Model < ApplicationRecord
  include ValidatesEnum

  THE_ENUM_TYPES = {
    something: 0,
    something_other: 1,
  }

  enum the_enum: THE_ENUM_TYPES

  validates_enum :the_enum

end

The convention is to declare the enum values in a constant with the role ENUM_NAME_TYPES (where ENUM_NAME is the name of your defined enum), which you then pass to define the enum itself. The concern will check for that and validate against it.

If you have multiple enums in your model, repeat the above steps. But you can call: validates_enum with multiple enums like so:

validates_enum :enum1, :enum2

Hope that helps!

PuLLi
  • 1
  • 1