Fun with Rails Enums and PORO
ruby, rails, enum, poro
5 Oct 2021
I really like enums. They can be really powerful if they are used wisely. Let’s see what we can do with them in a multilingual Rails app with a little help from PORO (Plain Old Ruby Object).
In this article, I will quickly explain the basics and introduce a few tasks that can occur when you are using enums in the Rails app. For each, I will try to show the usual way (which I know about) and a more fun way (at least for me) with PORO.
Basics
I believe enums don’t need to be heavily introduced. Below is a short description from the documentation:
Declare an enum attribute where the values map to integers in the database, but can be queried by name.
And that’s it. You can also set the default value, get scopes with optional prefix or suffix, a few nice methods and default validation. A small example that will be used in the article:
enum theme_color: {
classic: 0,
dark: 1,
brown: 2
}, _suffix: true
The
_suffix
,_prefix
and_default
will be changed in not yet released (in time of writing the article) Rails 7 to a version without the_
prefix (according to the edge docs).
You will get:
Setting.classic_theme_color # scope with the suffix
Setting.theme_colors # enum hash
setting.classic_theme_color! # update
setting.classic_theme_color? # check
setting.theme_color # string instead of the integer from the database
As was already noted, Rails will store values as integers in the database, so you can easily change keys (that are mainly used in Rails) in the enum. That can be handy if you don’t exactly know the right name for them.
When should you use them?
I was tempted to write “it depends”, but my rule is simple: use enums, if you have an array of options for a single attribute, that is not editable by any user and the codebase depends on its content.
Especially the part “depends on its content” is my favourite. Imagine you have enum format
with available export formats (pdf
, html
and so on).
You can then easily create a class structure that is based on the values and you will know that adding a new format will require changing the enum and adding a new export class.
Example:
class Document
enum format: {
pdf: 0,
html: 1
}, _prefix: true
def export
"Exports::#{format.classify}Export".constantize.new(self).export
end
end
Task 1: Translations
The first and the most common one: how to translate enums?
With helper
The easiest way is to use I18n
with scope
:
I18n.t(setting.theme_color, scope: "activerecord.enums.setting")
Let’s make it more versatile and use a helper method.
# app/helpers/application_helper.rb
module ApplicationHelper
def enum_t(value, key:, count: 1)
I18n.t(value, scope: ["activerecord.enums", key], count: count)
end
end
And the final usage:
<p><%= enum_t(setting.theme_color, key: "setting.theme_color") %></p>
If we would have only this task, I would stick with this solution. There would be no need to make it a little bit more complex only for translations. But I know we will have much more to do :)
With object
Let’s start the fun with PORO and create our enum object.
# app/enums/setting/theme_color.rb
class Setting::ThemeColor
def self.t(value, count: 1)
I18n.t(value, scope: "activerecord.enums.setting.theme_color", count: count)
end
end
I chose a custom folder
enums
inside of theapp
folder, instead of having them inside eg.app/models
. It is much cleaner for me to have them in their own specific folder.
And the usage:
<p><%= Setting::ThemeColor.t(setting.theme_color) %></p>
It is very similar, but we are now directly using a simple class that makes it more readable. Another benefit is that we can use it everywhere and not only in views and it is also easily testable.
This example is not the same as above, as it is limited to the enum only and it is not versatile as the helper method. We will get there in the next task.
Task 2: List values in a form
The usual way
You can use this below, but it would be without translations and nicer output for users.
options_for_select(Setting.theme_colors)
We could prepare options data in each controller or add it as a method inside the model, but to make it more versatile, let’s use a helper method again.
I am one of those who do not like fat controllers and models. It is also the reason, why I am using PORO for enums and not models or view helpers.
# app/helpers/application_helper.rb
def enum_options(source, tkey:)
source.map do |key, _value|
[enum_t(key, key: tkey), key]
end
end
This can be used like this:
<%= form.select :theme_color, options_for_select(enum_options(Setting.theme_colors, tkey: "setting.theme_color"), setting.theme_color), {prompt: t("shared.views.select")}, required: true %>
With object
For only one enum, we could leave it. When you will have them more (and you probably will), you can refactor it (make it more versatile) and create a base class for our enums with shared code.
# app/enums/base_enum.rb
class BaseEnum
class << self
def t(value, count: 1)
I18n.t(value, scope: ["activerecord.enums", translation_key], count: count)
end
def translation_key
to_s.split("::").map(&:underscore).join(".")
end
end
end
Our enum class will be a little bit empty after it:
# app/enums/setting/theme_color.rb
class Setting::ThemeColor < BaseEnum; end
Now, we can update the base class with the code we will need to help us display our values in the form.
# app/enums/base_enum.rb
class BaseEnum
attr_reader :id, :title
def initialize(id:, title:)
@id = id
@title = title
end
class << self
def all
source.map do |key, _value|
new(id: key, title: t(key))
end
end
def source
raise NotImplementedError
end
# the rest…
end
end
You can find the final version of all used classes at the end of the article.
And finally, we can add something to the Setting::ThemeColor
class:
# app/enums/setting/theme_color.rb
class Setting::ThemeColor < BaseEnum
def self.source
Setting.theme_colors
end
end
The usage:
<%= form.collection_select :theme_color, Setting::ThemeColor.all, :id, :title, {include_blank: t("shared.views.select"), allow_blank: false}, {autofocus: false, required: true} %>
It now looks like we basically only have more code for removing one thing key: "setting.theme_color"
for translations and we have a different form method. But all changes will help us with upcoming tasks.
You probably noticed that I did not use the value (integer) from our enum. It is because Rails returns the string instead of the integer in the model attribute. Thanks to that we can display the saved value in the form easily.
Task 3: Descriptions
One day, you will get a feature request: add description text to theme color options.
The usual way
The solution is easy, we can just add a new helper method that will use a different key for translations. We will add a title key to the previous translations and add a new description key to keep the structure nice.
def enum_t(value, key:, count: 1)
I18n.t(value, scope: ["activerecord.enums", key, "title"], count: count)
end
def enum_description_t(value, key:, count: 1)
I18n.t(value, scope: ["activerecord.enums", key, "description"], count: count)
end
But we have now 3 helper methods in total. Maybe it would be good to move them into a single file to have them in at least one place without any other unrelated methods.
For me, this is not a good solution (obviously) because:
- you need to know that there are these methods (not the hardest one, but it is still better not to need to know it)
- for each new “attribute” (like the description), you will have a new method (or you would create a general where you would state the attribute in params), in each case, you will need to look to translation file to know, what attributes are available for the enum
With object
First, we need to change the translation key in BaseEnum
:
def t(value, count: 1)
I18n.t(value, scope: ["activerecord.enums", translation_key, "title"], count: count)
end
We will need to add a new method that will return us the object for needed enum. We can add it as a class method on BaseEnum
:
# app/enums/base_enum.rb
def find(key)
new(id: key, title: t(key))
end
We could make the description attribute available for all and add it into the BaseEnum
class, but we don’t need it right now…
# app/enums/setting/theme_color.rb
def description
I18n.t(id, scope: ["activerecord.enums", self.class.translation_key, "description"])
end
And the usage:
Setting::ThemeColor.find(setting.theme_color).description
We can finally fully benefit from the PORO way and have it clean and readable.
Task 4: Custom methods
Imagine, you would like to have the color in hex (eg. dark
= #000000
). With a usual way, we would create just another helper method or add a method to the model… but with enum object we can just add a new method to the class and have it all in one place.
# app/enums/setting/theme_color.rb
def hex
case id
when "classic"
"#FF0000"
when "dark"
"#000000"
when "brown"
"#D2691E"
end
end
We are now able to use it everywhere, not only in views.
Setting::ThemeColor.find("dark").hex
This was just an example. But the main point is, you now have a place where you can add these kinds of methods when you will need them.
Task 5: Limit displaying values
Another interesting task: allowing to display brown
option only for admins (for some weird reason). Again, with the usual way, we would have another helper or model method.
Luckily we have our enum class, where we can change the source method to our needs.
# app/enums/setting/theme_color.rb
def self.source
if Current.user.admin?
Setting.theme_colors
else
Setting.theme_colors.except("brown")
end
end
In this example, I am using CurrentAttributes for storing the current user.
Summary
With enum objects, you can make your enums more fun and it opens you a lot of possibilities.
- your code will be cleaner (slimmer models or helpers)
- enums will have their own place where you can extend them to your needs
- you will be able to use them everywhere
- all added logic will be easily testable
I hope you found this article interesting. If you find any inconsistencies or you know a better way, I would be glad if you let me know on Mastodon.
Final version of classes
BaseEnum
class
# app/enums/base_enum.rb
class BaseEnum
attr_reader :id, :title
def initialize(id:, title:)
@id = id
@title = title
end
class << self
def all
source.map do |key, _value|
new(id: key, title: t(key))
end
end
def find(key)
new(id: key, title: t(key))
end
def source
raise NotImplementedError
end
def t(value, count: 1)
I18n.t(value, scope: ["activerecord.enums", translation_key, "title"], count: count)
end
def translation_key
to_s.split("::").map(&:underscore).join(".")
end
end
end
Setting::ThemeColor
class
# app/enums/setting/theme_color.rb
class Setting::ThemeColor < BaseEnum
def description
I18n.t(id, scope: ["activerecord.enums", self.class.translation_key, "description"])
end
def hex
case id
when "classic"
"#FF0000"
when "dark"
"#000000"
when "brown"
"#D2691E"
end
end
def self.source
if Current.user.admin?
Setting.theme_colors
else
Setting.theme_colors.except("brown")
end
end
end
Do you like it? You can subscribe to RSS (you know how), or follow me on Mastodon.