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 theEnumMethods
module with thedefine_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!