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 the app 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.