Rails enum under the hood

Enum is a great way to deal with statuses and other named values when it comes to models. If you are not experienced with this part of models yet, make sure you read the first article.

Have you ever wondered how the enum is working under the hood and how it’s possible that we get access to more valuable methods that we don’t define directly with the enum definition? It’s pretty simple but interesting how Rails is handling enums as it involves some metaprogramming.

This article will show you how you can build your own enum mechanism the same way as it’s done in Rails.

Enum definition and its features

Before we start, let’s take a quick look at how we can define the enum for the given column and what “magic” methods are created.

This is the typical enum definition for a column named status:

class User < ApplicationRecord
  enum status: {
    invited: 0,
    active: 1
  }
end

With this definition, we get the following methods:

  • Equality check - user.active? will return true if the status is 1
  • Scope - User.active will return only users where status is 1
  • Update - user.active! will update the user status to 1
  • List of statuses - User.statuses will return all available statuses with the values

That being said, we can start implementing our own enum. First, you will need a simple Rails model with the status column that is an integer type. In my case, it will be User.

Preparing the skeleton

I will name the module as my_own_enum and the definition is the same as for the standard enum:

class User < ApplicationRecord
  my_own_enum status: { invited: 0, active: 1 }
end

If you try to load the User class in the console, you will receive the undefined method my_own_enum error. Let’s fix it. Create app/models/my_own_enum.rb file so Rails can load it automatically and put there the following contents:

module MyOwnEnum
  def my_own_enum(mappings)

  end
end

Now, update the User model:

class User < ApplicationRecord
  extend MyOwnEnum

  my_own_enum status: { invited: 0, active: 1 }
end

The error is gone; we can start implementing the first feature.

Listing all statuses

We would like to call User.statuses and receive a hash with statuses names and values. It means that we need a class method that is a plural version of the enum name.

Thankfully Rails provides the .pluralize method for strings that will return the plural version of the word:

"status".pluralize # => “statuses”

Now, we have to define statuses method on the class level. To do this, we have to call singleton_class method that returns class and call define_method on it:

module MyOwnEnum
 def my_own_enum(mappings)
   mappings.each_pair do |name, values|
     singleton_class.define_method(name.to_s.pluralize) { values }
   end
 end
end

As you can see, it was effortless as the define_method accepts the method’s name and the method body as a block. In this case, we just want to return the enum values, so a simple block is enough.

You can now try User.statuses to see that it’s working well.

Equality check

This time it will be harder to write code as we want to provide a dynamic method on the instance, not class level:

user.active? # => true

For every value defined for enum, we will create a dynamic method with the question mark that will return true if the column’s current value matches the values for the called status.

Again, we have to call define_method but this time on the instance level. The logic is in the separated module, so it’s ready to be included in the model:

class EnumMethods < Module
  def initialize(klass)
    @klass = klass
  end

  private
  attr_reader :klass

  def define_enum_methods(name, key, value)
    define_method("#{key.to_s}?") { public_send(name) == value }
  end
end

We will need the class name later (it’s named klass to not use the Ruby keyword), and now we have one method that accepts the column name, status name, and value. This is a straightforward method that returns boolean as a result.

To include the EnumMethods module, we have to do a little trick. First, we have to include the module in the standard way, and then we have to iterate over every status and call the define_enum_methods in the context of the included module. Sounds a little bit complicated? Let’s take a look at the complete code:

module MyOwnEnum
  def my_own_enum(mappings)
    mappings.each_pair do |name, values|
      singleton_class.define_method(name.to_s.pluralize) { values }
    
      _enum_methods_module.module_eval do
        values.each_pair do |key, value|
          define_enum_methods(name, key, value)
        end
      end
    end
  end
  private

  class EnumMethods < Module
    def initialize(klass)
      @klass = klass
    end

    private
    attr_reader :klass

    def define_enum_methods(name, key, value)
      define_method("#{key.to_s}?") { public_send(name) == value }
    end
  end

  private_constant :EnumMethods

  def _enum_methods_module
    @_enum_methods_module ||= begin
      mod = EnumMethods.new(self)
      include mod
      mod
    end
  end
end

The flow is the following:

  • _enum_methods_module is called on the class level, and it includes the EnumMethods module with the define_enum_methods instance method
  • We open the module_eval block to define the new methods in the context of the included module (so they can become instance methods)
  • We iterate over every status and define the simple equality check method.

Now we can compare the statuses using dynamically created methods:

user = User.last
user.active? # => true
user.invited? # => false

Updating status

Since we have the mechanism ready for creating instance methods on the fly, we can easily add new method for updating the status:

def define_enum_methods(name, key, value)
  define_method("#{key.to_s}?") { public_send(name) == value }
  define_method("#{key.to_s}!") { update!(name => value) }
end

Simple as that. Now you can call user.active! to update the user’s status to active.

Adding scope

The last step is to add a dynamic scope so we can call User.active and receive all users with the active status. Again, we have to update define_enum_methods method but this time use the klass value and add a standard scope:

def define_enum_methods(name, key, value)
  define_method("#{key.to_s}?") { public_send(name) == value }
  define_method("#{key.to_s}!") { update!(name => value) }
  klass.scope key, -> { where(name => value) }
end

The scope definition is well known to any Rails developer, so there is nothing to explain. You can now call User.active or User.invited to filter the records using statuses.

There is even more

When it comes to the Rails source code, the enum feature is designed the same way with one exception: before defining the methods, Rails verifies if methods with the same name don’t already exist.

You can now play with the self-made enum and add negative scopes as it’s done in Rails 6 or any other feature that comes to your mind.

If you enjoyed this article, make sure you follow on Twitter and subscribe to the newsletter to receive more “under the hood” articles!