Quantcast
Channel: Viget Articles
Viewing all articles
Browse latest Browse all 1272

How to Implement an Enumerated Type in Rails

$
0
0

Have you ever wanted to use an enumerated type in your Rails app? After years of feature requests, Rails 4.1 finally added them: a simple implementation that maps strings to integers. But what if you need something different?

On a recent project, I implemented a survey, where animals are matched by answering a series of multiple-choice questions. The models looked like this:

class Animal < ActiveRecord::Base
  has_many :answer_keys
end

class AnswerKey < ActiveRecord::Base
  belongs_to :animal

  validates :color, :hair_length, presence: true
end

An animal has many answer keys, where an answer key is a set of survey answers that matches that animal. color and hair_length each represent a multiple-choice answer and are natural candidates for an enum.

The simplest possible implementation might look like this:

validates :color,       inclusion: { in: %w(black brown gray orange yellow white) }
validates :hair_length, inclusion: { in: %w(less_than_1_inch 1_to_3_inches longer_than_3_inches) }

However, there were additional requirements for each of these enums:

  • Convert the value to a human readable name, for display in the admin interface
  • Export all of the values and their human names to JSON, for consumption and display by a mobile app

Currently, the enum values are strings; what I really need is an object that looks like a string but has some custom behavior. A subclass of String should do nicely:

module Survey
  class Enum < String
    # Locale scope to use for translations
    class_attribute :i18n_scope

    # Array of all valid values
    class_attribute :valid_values

    def self.values
      @values ||= Array(valid_values).map { |val| new(val) }
    end

    def initialize(s)
      unless s.in?(Array(valid_values))
        raise ArgumentError, "#{s.inspect} is not a valid #{self.class} value"
      end

      super
    end

    def human_name
      if i18n_scope.blank?
        raise NotImplementedError, 'Your subclass must define :i18n_scope'
      end

      I18n.t!(value, scope: i18n_scope)
    end

    def value
      to_s
    end

    def as_json(opts = nil)
      {
        'value'      => value,
        'human_name' => human_name
      }
    end
  end
end

This base class handles everything we need: validating the values, converting to human readable names, and exporting to JSON. All we have to do is subclass it and set the two class attributes:

module Survey
  class Color < Enum
    self.i18n_scope = 'survey.colors'

    self.valid_values = %w(
      black
      brown
      gray
      orange
      yellow
      white
    )
  end

  class HairLength < Enum
    self.i18n_scope = 'survey.hair_lengths'

    self.valid_values = %w(
      less_than_1_inch
      1_to_3_inches
      longer_than_3_inches
    )
  end
end

Finally, we need to add our human readable translations to the locale file:

en:
  survey:
    colors:
      black: Black
      brown: Brown
      gray: Gray
      orange: Orange
      yellow: Yellow/Blonde
      white: White
    hair_lengths:
      less_than_1_inch: Less than 1 inch
      1_to_3_inches: 1 to 3 inches
      longer_than_3_inches: Longer than 3 inches

We now have an enumerated type in pure Ruby. The values look like strings while also having the custom behavior we need.

Survey::Color.values

# => ["black", "brown", "gray", "orange", "yellow", "white"]

Survey::Color.values.first.human_name

# => "Black"

Survey::Color.values.as_json

# => [{"value"=>"black", "human_name"=>"Black"}, {"value"=>"brown", "human_name"=>"Brown"}, ...]

The last step is to hook our new enumerated types into our AnswerKey model for great justice. We want color and hair_length to be automatically converted to instances of our new enum classes. Fortunately, my good friend Zachary has already solved that problem. We just have to update our Enum class with the right methods:

def self.load(value)
  if value.present?
    new(value)
  else
    # Don't try to convert nil or empty strings
    value
  end
end

def self.dump(obj)
  obj.to_s
end

And set up our model:

class AnswerKey < ActiveRecord::Base
  belongs_to :animal

  serialize :color,       Survey::Color
  serialize :hair_length, Survey::HairLength

  validates :color,       inclusion: { in: Survey::Color.values }
  validates :hair_length, inclusion: { in: Survey::HairLength.values }
end

BONUS TIP — We probably need to add these enums to a form in the admin interface, right? If you're using Formtastic, it automatically looks at our #human_name method and does the right thing:

f.input :color, as: :select, collection: Survey::Color.values

Shazam.


 

Hey friend, have you implemented enums in one of your Rails apps? How did you do that? Let me know in the comments below. Have a nice day.


Viewing all articles
Browse latest Browse all 1272

Trending Articles