Booleans are one of the oldest constructs in programming. They’re simple.
Binary. And we use booleans everywhere. One of my earliest software projects had
a database table called “users” with several columns used to specify role like
is_admin
, is_moderator
, and is_seller
.
%User{is_admin: false, is_moderator: false, is_seller: false}
This gets even worse when booleans are used to represent some sort of state. For
example a payment service that stores its state in booleans could potentially
have columns like is_authorized
, is_captured
, is_failed
, and
is_refunded
. That’s just complicated. And that’s just with a single piece of
state. Many places support partial refunds, which means now you’ve got another
boolean is_partial_refund
.
%Payment{
is_authorized: false,
is_captured: false,
is_failed: false,
is_refunded: false,
is_partial_refund: false
}
But there’s another issue: now you can have impossible states. For instance if
somehow a record has is_partial_refund
set to true
but is_refunded
set to
false
what does that even mean? Or if a record has is_failed
set to true
but also is_captured
set to true
. Make impossible states impossible!
Enums
Enums allow us to do just that. They enable a variable to contain a value from a set of predefined constants. In a statically-typed language, enum values are checked at compile time to eradicate bad values. Elixir is dynamically-typed, so we don’t get that safety guarantee, but that doesn’t demean enums' usefulness.
The user example above is made simpler with the use of an enum:
%User{role: :admin}
The role field would contain one of the following atoms: :admin
, :moderator
,
:seller
, :buyer
. That simplifies our user struct immensely, but the
difference is even bigger with the payment example:
%Payment{status: :pending}
Instead of setting different combinations of five boolean flags, we can change
the status to one of :authorized
, :captured
, :failed
, :refunded
, or
:partially_refunded
. And there is no way for the payment to get into one of
the impossible states mentioned above. But how does this work with a database?
Rolling your own enums with Strings
The simplest way to store “enums” in a database with Elixir and Ecto has been to
roll your own enums. By creating a new varchar column on your table, you can
then write the appropriate string to it when creating or updating a record.
Ecto.Changeset.validate_inclusion/4
will allow you to check that the string
being provided contains one of the supported values so that you’re not inputting
bad data.
But that safety only exists at the application layer. It ignores the potential that someone accessing the database directly could input a bad value by bypassing the application. If a bad string gets in and the application doesn’t handle it, that bad string will crash the process. But you shouldn’t have to waste time validating values coming from your data layer. Your application deserves better.
EctoEnum
Postgres and MySQL have the ability to create enum types that can be used for columns and will validate them at the data layer rather than the application layer. This means that your data store would be protected from bad values. For a long time, the EctoEnum library has been the best way to set up custom enum types for Postgres:
# lib/my_app/accounts/user_role.ex
defmodule MyApp.Accounts.UserRole do
use EctoEnum, type: :user_role, enums: [:admin, :moderator, :seller, :buyer]
end
# lib/my_app/accounts/user.ex
defmodule MyApp.Accounts.User do
use Ecto.Schema
alias MyApp.Accounts.UserRole
schema "users" do
field :role, UserRole
end
end
# priv/repo/migrations/20210102193646_add_role_to_users.exs
defmodule MyApp.Repo.Migrations.AddRoleToUsers do
use Ecto.Migration
alias MyApp.Accounts.UserRole
def change do
UserRole.create_type()
alter table(:users) do
add :role, :user_role
end
end
end
It requires creating a new module for the enum type, but it has some nice conveniences for using the type in your schema and for creating the type in your migrations in the first place. But using one more dependency also means one more thing in the application that has to be maintained.
Ecto 3.5
Ecto 3.5 was released in October 2020, and it included the new
Ecto.Enum
module. The module’s
documentation expects the Enum type to be a string when stored in the database,
but we can also make it work with a Postgres enum. This means you can now use
enums without needing another library. Ecto SQL
still doesn’t have convenience
functions for creating enums in migrations (yet) so you’ll have to drop down to
raw SQL and do that manually:
defmodule MyApp.Repo.Migrations.AddRoleToUsers do
use Ecto.Migration
def change do
create_query = "CREATE TYPE user_role AS ENUM ('admin', 'moderator', 'seller', 'buyer')"
drop_query = "DROP TYPE user_role"
execute(create_query, drop_query)
alter table(:users) do
add :role, :user_role
end
end
end
Then you can add an enum to your schema without having to create a new module and Ecto type though:
defmodule MyApp.Accounts.User do
use Ecto.Schema
schema "users" do
field :role, Ecto.Enum, values: [:admin, :moderator, :seller, :buyer]
end
end
Just like the EctoEnum library, Ecto handles all of the casting for changesets, and the role is available as an atom rather than a string. This provides some safety because you will be able to more easily differentiate between the unsafe string values provided by users and the safer atoms that have been validated by the application.
What do you think?
Do you like the new Ecto.Enum API, or do you wish the API was different? Are you
going to stick to the EctoEnum
library or to just using Strings? Let me know
on Twitter!
And if you liked this post, hopefully you’ll like the others I write. Use the form below to subscribe to my email newsletter and get a notification whenever I write a new one!
Happy New Year!