How to Implement an Enumerated Type in Rails
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.