Fun with Rails Enums and PORO
Tags: rails, ruby, enum, poro
Published: 2021-10-05 19:00:39


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](https://api.rubyonrails.org/v6.1.4/classes/ActiveRecord/Enum.html):

> 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:

```ruby
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](https://edgeapi.rubyonrails.org/classes/ActiveRecord/Enum.html)).

You will get:

```ruby
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:

```ruby
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`:

```ruby
I18n.t(setting.theme_color, scope: "activerecord.enums.setting")
```

Let’s make it more versatile and use a helper method.

```ruby
# 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:

```erb
<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.

```ruby
# 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:

```erb
<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.

```ruby
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.

```ruby
# 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:

```erb
<%= 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.

```ruby
# 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:

```ruby
# 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.

```ruby
# 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](#final-version-of-classes) of the article.

And finally, we can add something to the `Setting::ThemeColor` class:

```ruby
# app/enums/setting/theme_color.rb

class Setting::ThemeColor < BaseEnum
  def self.source
    Setting.theme_colors
  end
end
```

The usage:

```erb
<%= 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.

```ruby
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`:

```ruby
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`:

```ruby
# 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…

```ruby
# app/enums/setting/theme_color.rb

def description
  I18n.t(id, scope: ["activerecord.enums", self.class.translation_key, "description"])
end
```

And the usage:

```ruby
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.

```ruby
# 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.

```ruby
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.

```ruby
# 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](https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html) 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 [Twitter](https://twitter.com/citronak).

## Final version of classes

### `BaseEnum` class

```ruby
# 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

```ruby
# 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
```