Field Guide to the Rails ActiveRecord "Enum"
The Rails enum
allows you to use easy to remember or mnemonic names instead of integer values for a table attribute.
Historical Note: The term enum
comes from the C family of languages that
performs a similar technique. It is not related to the Ruby Enumerable mixin.
Assume we have an Account model with an integer status
attribute from the table.
Each value corresponds to a given state of the record.
Declaring an enum
on status allows the code to use
a name of the state instead of the actual integer value.
The simple use case involves a sequential set of integers starting from zero.
class Account
# ...
enum status: [:signup, :active, :closed]
# ...
end
This maps values from the table to the model:
0 => "signup"
1 => "active"
2 => "closed"
Now your model works with the mnemonic name instead of the integer value. Rails handles the translation for you when you select or update your record. Also, it will protect you from assigning incorrect values to the attribute. To test the values, methods for each value are created on your model.
account.status #=> "active"
account.signup? #=> false
account.active? #=> true
account.closed? #=> false
We can see what the mapping is with this class method:
Account.statuses #=> {"signup"=>0, "active"=>1, "closed"=>2}
Account.statuses[:active] #=> 1
Account.statuses["active"] #=> 1 (Hash with indifferent access)
Account.statuses.key(1) #=> "active"
Let’s dive into the specifics.
Getting and Setting Values
NOTE: For brevity, I will concentrate on the “active” name and it’s uses. The “signup” and “closed” names work the same way.
From the account instance, we can get the value of the status as a string. ActiveRecord automatically converts the number into the string name for us.
account.status #=> "active"
Account.statuses[account.status] #=> 1
We can set the value using a string, symbol or integer, or nil. Attempting to set an invalid value will raise and error. This will not trigger a save to the database.
account.status = "active" # Change the value by name as string
account.status = :active # Also takes the symbol
account.status = 1 # and also by the integer value
account.status = nil # We can also set it to nothing
account.status = 99 #=> ArgumentError: '99' is not a valid status
account.attributes = {status: :active} # set from a hash
Conditions
To check the value of the status, use a <name>?
method such as active?
.
While you can check against the string value, it may stop working if the enum is updated.
Using the method will raise an exception if the definition changes, which will be easier to find and fix.
account.active? #=> true, same as account.status == "active"
account.status == "active" # true, but use active? instead
account.status.nil? # Checking for nil
account.status == :active # false --> This does not work!
account.status == 1 # false --> This does not work!
Since each mnemonic name creates instance methods, you can have naming collisions between two enum declarations.
Also, the names shouldn’t match any ActiveRecord
or other defined instance method.
Rails will throw an error if you have naming collisions.
In this case, try another word (maybe a synonym) or use the _prefix
or _suffix
options to prefix/suffix the method names.
It takes either true
(using the attribute name) or any other string.
enum status: [:signup, :active, :closed], _suffix: true
enum billing_status: [:none, :active, :expired], _prefix: :billing
You only may need to adjust one of the declarations, not both. This creates the following family of methods for the “active” name:
account.active_status? # <name>_<attribute>? (suffix format)
account.billing_active? # <prefix>_<name>? (prefix format)
Type Cast Helpers
The “attribute_before_type_cast” method and “read_value_before_type_cast” methods return the original value of the attribute. However, they don’t work after the value is changed, instead returning the string or symbol you used to set the value. I prefer to use the “Account.statuses” hash instead.
account.status_before_type_cast #=> 1
account.read_value_before_type_cast("status") #=> 1
account.status = "closed" #=> "closed"
account.status_before_type_cast #=> "closed" (no longer 1)
account.status_was #=> "active"
Account.statuses[account.status] #=> 2
Updating
Update the record with the <name>!
method such as (active!).
The update!
method can also take a hash with the status value as a string, symbol, integer, or nil.
Again, it will raise an error if you pass in invalid value.
account.active! # Updates with validations like below
account.update!(status: "active")
account.update!(status: :active)
account.update!(status: 1)
account.update!(status: nil)
Personal side note: for important attributes like status (or any record state), I like to carry a timestamp (status_at in this case) when that state was last changed. You can manage this in a lifecycle block:
before_save do
self.status_at = Time.now if status_changed? || new_record?
end
Scopes
Enum also creates scope query methods for each named value, such as:
Account.active #=> Account.where(status: 1)
Account.not_active #=> Account.where("status != ?", 1)
You can disable scope creation with the _scopes
option:
enum status: [:signup, :active, :closed], _scopes: false
The mapping also works when you specify the status name in the hash format of where
clauses.
It does not work with string expressions, nor does it raise an error if you use an invalid value.
Invalid values may result in type mismatches in the generated SQL and throw a database exception.
Account.where(status: "active")
Account.where(status: :active)
Account.where(status: [:signup, "active"]) # Use the "status in set" with values
Account.where("status = ?", Account.statuses[:active]) # When you interpolate the value
The following do not work. It passes your bad value into the generated SQL which will fail. It could just not match anything, but it could raise a SQL error.
Account.where(status: :badvalue) # SQL is: "where status = 'badvalue'" which will fail
Account.where("status = ?", "active") # Fails similarly
Default Value
Specify a default value with the _default
option:
enum status: [:signup, :active, :closed], _default: "signup"
Using Non-Sequential Numbers
By specifying a hash of name/value pairs, we can override the starting point (zero) and match existing table data.
enum status: {signup: 10, active: 20, closed: 30}
We left “space” between these values so whenever new states or sub-states are introduced, they could be inserted between these values. Imagine if we need a new “suspended” state and it fits between the active and closed state in the account life-cycle. This is easy to insert:
enum status: {signup: 10, active: 20, suspended: 25, closed: 30}
Also, this technique allows us to use “ranges” in well-sorted numerations.
Non-Numeric Values
Even if our status column was a text column, we could still use the enum
to control it.
Remember, the format is {internal_name: "database value"}
,
enum status: {signup: "new", active: "active", closed: "closed"}
Here, we demonstrate mapping the “new” value in the table to the “signup” name in our application, which helps avoid the conflict with the “new” method in the class as well as gives it a better name. The active and closed values are unchanged, but we now have conditional methods and scopes for this column.
account.signup?
account.active!
Account.closed
account.status = "inactive" # Raises and error on an invalid value
This technique allows us to use PostgreSQL Enumerated Types with ActiveRecord as well.
Multiple names for a value
While you likely wouldn’t intend to do so, you could give the same value to multiple names:
enum status: {signup: 0, active: 1, closed: 2, delinquent: 2}
Consider this an alias or use to document a legacy name used in other applications. Both names can be used interchangeably, but Rails will only return one of them, and you can’t be sure which one. It may change if the enum pairs are reordered. If you do this, never check or pass the string value around, but any other use will still work.
account.status #=> "closed" or "delinquent"?
account.closed? # Good
Account.active # Good
account.status == "closed" # Bad. Use account.closed?
variable = account.status # Bad. The returned name string could change
other_account.status = account.status # Good
Comparisons
NOTE: The comparisons sections show more advanced techniques that you probably won’t need and shouldn’t implement unless you do.
Enum only supports equality comparisons such as:
account.active?
account.status == other_account.status
When your mapping is ordered in a way that supports less-than and greater-than values, we can create comparisons to support these tests and ranges.
Never compare with another value string. It compares as the string values instead of the underlying value.
account.status > "signup" # Don't do this. Same as testing "active" > "signup"
Instead we need to reach for the values hash.
Account.statuses[account.status] > Account.statuses["signup"]
For ruby, that’s not very readable. Nor does it convey the intent of the condition. Instead, create boolean helper methods.
def open?
Account.statuses[status] < Account.statuses["closed"]
end
account.open?
Normally, this is all you need in your application. I have been working with a legacy database that uses enums extenstively, so I am exploring how far down the enum rabbithole we can go.
Comparisons II: Spaceships and sorting
We can also use the “spaceship” <=>
operator to compare attribute values.
The operator was taken from Perl. Perl compares numbers wth <=>
but uses the “cmp” operator to compare strings.
I use “cmp” term outside of Perl, though you don’t have to.
def status_cmp(other)
Account.statuses[account.status] <=> Account.statuses[other]
end
do_something if status_cmp("active") < 0 # Instead of below
do_something if Account.statuses[account.status] < Account.statuses[:active]
The spaceship operator returns -1 if left is less-than than right, 0 on equal,
or 1 if left is greater-than the right. It us useful when passing a block to Array#sort
Suppose you have an array of accounts you need to sort by the status value instead of string.
Account.all.sort { |a, b| a.status_cmp(b.status) }
Comparisons III: Enhance Your Enum
We can go even further. Leverage ruby’s metaprogramming to create an enhanced enum declaration. To try this out, add this inside of app/models/application_record.rb:
# An enum variation for ordered values. Defines <attr>_between?(first, last)
# and <attr>_cmp(other) helpers
def self.ordered_enum(definitions)
enum(definitions)
definitions.each do |name, _|
define_method("#{name}_between?") do |first, last|
values = self.class.send(name.to_s.pluralize)
values[first] <= values[self[name]] && values[self[name]] <= values[last]
end
define_method("#{name}_cmp") do |other|
values = self.class.send(name.to_s.pluralize)
values[self[name]] <=> values[other]
end
end
end
Then in your model, use “ordered_enum” instead of “enum” and you get the “_between?” and “_cmp” methods as well.
ordered_enum status: {signup: 0, active: 1, closed: 2}
account.status_between?(:signup, :active) #=> true or false
account.status_cmp(:active) #=> -1 or 0 or 1
Reference
The syntax of the enum declaration is formally:
enum attribute: values, attribute: values, ...
_default: "name",
_scopes: boolean,
_prefix: true|"name",
_suffix: true|"name"
Where values
is an array of names, or hash of names and values.
A name can be a symbol or string.
While enum can take multiple attributes in a single declaration, it is better to use
only one per call to apply the options correctly.
- ActiveRecord::Enum on Ruby on Rails API.
- This was written for Ruby on Rails 6.1