I recently migrated a Rails JSONAPI server to Phoenix, and in the process I learned a lot about using Ecto with a database that conforms to Rails naming conventions. It turned out that I was able to share an existing Rails database between the Rails and Phoenix implementations of the server during development, which allowed me to use a phased approach in transitioning to the new Phoenix version.
In his ElixirConf 2016 keynote, Chris McCord announced that Phoenix models will likely be replaced with service modules in Phoenix 1.3. Because of this I’ll stick to describing the parts of Ecto used in Phoenix models while skipping the concept of models in general.
The Ecto library has four core modules that you will need to understand. These are Ecto.Schema, Ecto.Changeset, Ecto.Query, and Ecto.Repo. To get our feet wet and provide a basis for future articles, we’ll focus on schemas here.
The responsibility of Ecto.Schema is to define a struct to hold a table’s record data, and to provide type information to facilitate casting columns and referencing associations. This definition is done using Ecto’s field and association macros. In ActiveRecord terms: Ecto schema fields are like explicit versions of the Ruby magic that turns table columns into model attributes, while Ecto schema associations are very similar to AR associations.
The Ecto documentation is extremely clear and well written, but it took some digging to figure out how Ecto conventions can be over-ridden to work with Rails databases. I wrote this article to share what I’ve learned.
We’ll be using these versions of Rails and Phoenix/Ecto.
Rails 5.0
Phoenix 1.2.1
Ecto 2.1.0-rc.2
NOTE: Ecto stable is currently 2.0.5, but it has a bug in certain
has_many through
scenarios. This is fixed in the 2.1.0 release
candidates. We’ll see how to update this dependency when we set up our
new Phoenix application.
Generating the Rails database
We’ll start by generating Rails migrations and models to build an example database. The models have a few basic attributes, but we won’t be using them yet. The models do, however, include examples of all the basic associations supported by ActiveRecord. Here are the relations we will be modeling in terms of Rails associations.
User has_and_belongs_to_many Projects
Project has_and_belongs_to_many Users
Project has_many Lists
List belongs_to Project
List has_many Items
Item belongs_to List
List has_one Status
Status belong_to List
We will also look at writing these through
associations.
Project has_many Items through Lists
User has_many Lists through Projects
User has_many Items through Lists
(a through association based on another through association)
Item has_one Status through List
Assuming we have a new Rails app backed by a PostgreSQL database, we can generate the models and migrations to express these fields and associations.
$ bin/rails g model User email:uniq encrypted_password
$ bin/rails g model Project name slug:uniq
$ bin/rails g migration CreateJoinTableProjectsUsers project user
$ bin/rails g model List name slug project:references
$ bin/rails g model Item description list:references
$ bin/rails g model Status description list:references
Now we need to make a few final adjustments. First, edit the
XXX_create_join_table_projects_users.rb
migration and uncomment both
indexes. Next, change the XXX_create_lists.rb
migration to force the
List slug
to be unique with respect to project_id
. We can do this
by adding a compound unique index.
add_index :lists, [:project_id, :slug], unique: true
With all this set up we are ready to migrate the database.
$ bin/rake db:migrate
I’ll finish by adding the remaining associations to the new models.
You can take a look at the generated migrations, the models including
all associations, and the schema.rb
file in the sample project at:
https://github.com/HaughtCodeworks/playground-rails/tree/ecto-schemas
Before we start looking at the Ecto schemas we’ll use to access this new database, let’s create some sample data in the Rails console. We just need enough data to test our association definitions later.
user = User.create!(email: "[email protected]")
project = Project.create!(name: "Project", slug: "project", users: [user])
list = List.create!(name: "List", slug: "list", project: project)
Item.create!(description: "Item", list: list)
Status.create!(description: "Status", list: list)
Connecting Ecto to the Rails database
We’ll start by creating a new app named PhoenixApp
:
$ mix phoenix.new phoenix_app --no-brunch
$ cd PhoenixApp
As mentioned in the introduction, Ecto 2.0.5 has a bug that has been
fixed in the 2.1.0 release candidates. To use the latest rc version of
Ecto you’ll need to change your project dependencies and then run mix
deps.get
to fetch the new versions.
These lines:
defp deps do
[. . .
{:phoenix_ecto, "~> 3.0"},
{:postgrex, ">= 0.0.0"},
. . .
]
end
Become:
defp deps do
[. . .
{:ecto, "~> 2.1.0-rc.0"},
{:phoenix_ecto, "~> 3.1.0-rc.0"},
{:postgrex, "~> 0.12.0"},
. . .
]
end
Now update config/dev.exs
to connect to the Rails development
database. Typically this just requires changing the username
,
password
, and database
settings.
# Configure your database
config :phoenix_app, PhoenixApp.Repo,
adapter: Ecto.Adapters.Postgres,
username: "username",
password: "password",
database: "rails_app_development",
hostname: "localhost",
pool_size: 10
Finally we can test the connection by starting an iex
session. To
exit the session type Ctrl-C Ctrl-C
.
$ iex -S mix
If iex
starts without errors, your connection is configured
correctly.
A bad username
or password
configuration will cause iex
to emit
errors looking like this:
[error] Postgrex.Protocol (#PID<0.NNN.0>) failed to connect: ** (Postgrex.Error) FATAL (invalid_password): password authentication failed for user "xxx"
A bad database
name will cause iex
to emit errors looking like
this:
[error] Postgrex.Protocol (#PID<0.NNN.0>) failed to connect: ** (Postgrex.Error) FATAL (invalid_catalog_name): database "xxx" does not exist
Generating the Ecto Schemas
Just like in Rails, we’ll use generators to build our Ecto schemas and
add the remaining associations by hand. As we generate Phoenix models
and Ecto migrations, the mix
command output will ask you to run mix
ecto.migrate
. Since our development database already has a complete
database structure built, this will fail with duplicate table
errors. For now we’ll just skip running Ecto migrations. For similar
reasons we will skip generating a migration for the projects_users
join table.
If you want to refer to the final version of the Ecto schemas as we go
along, check out the source in web/models
at:
https://github.com/HaughtCodeworks/playground-phoenix/tree/ecto-schemas
$ mix phoenix.gen.model User users email:unique encrypted_password
$ mix phoenix.gen.model Project projects name slug:unique
$ mix phoenix.gen.model List lists name slug project_id:references:projects
$ mix phoenix.gen.model Item items description list_id:references:lists
$ mix phoenix.gen.model Status statuses description list_id:references:lists
Let’s test out our new models, start up iex
and see if we can load
all Users. We won’t get too advanced with our usage of Ecto.Repo
,
for now it’s enough to understand that Repo.all
returns all records,
and Repo.one
returns a single record. All of our Phoenix modules
live under the PhoenixApp
namespace, so our Ecto User is
PhoenixApp.User
and our application Repo is PhoenixApp.Repo
. This
command fetches all of our application’s Users from our application’s
Repo:
$ iex -S mix
iex(1)> PhoenixApp.Repo.all(PhoenixApp.User)
SELECT u0."id", u0."email", u0."encrypted_password", u0."inserted_at", u0."updated_at" FROM "users" AS u0 []
** (Postgrex.Error) ERROR (undefined_column): column u0.inserted_at does not exist
(ecto) lib/ecto/adapters/sql.ex:415: Ecto.Adapters.SQL.execute_and_cache/7
(ecto) lib/ecto/repo/queryable.ex:121: Ecto.Repo.Queryable.execute/5
(ecto) lib/ecto/repo/queryable.ex:35: Ecto.Repo.Queryable.all/4
We’ve hit our first naming convention issue. Ecto uses an
inserted_at
column for tracking when a record is first inserted into
the database, Rails uses created_at
. We’ll get that fixed in the
next section.
Over-riding the inserted_at
column name
Ecto supports over-riding the default timestamps
column names both
per model schema and globally across all model schemas.
In an Ecto schema definition, timestamps
accepts options specifying
alternative column names for inserted_at
and updated_at
. Let’s try
configuring this in User.
Change the timestamps
definition in web/models/user.ex
from:
schema "users" do
field :login, :string
timestamps()
end
To:
schema "users" do
field :login, :string
timestamps inserted_at: :created_at
end
We can see if it worked in iex
. The result is a list of
PhoenixApp.User
structs. For the rest of the article I’ll be hiding
the contents of these structs for clarity, but it’s worth looking them
over if you run these commands yourself in a real iex
session.
$ iex -S mix
iex(1)> PhoenixApp.Repo.all(PhoenixApp.User)
SELECT u0."id", u0."email", u0."encrypted_password", u0."created_at", u0."updated_at" FROM "users" AS u0 []
[%PhoenixApp.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
created_at: ~N[2022-10-24 20:42:17.914653], email: "[email protected]",
encrypted_password: nil, id: 1, updated_at: ~N[2022-10-24 20:42:17.914653]}]
Obviously this configuration will be required by all of our models, so
let’s undo the User timestamps
change and set the configuration
globally instead. The documentation for these global Ecto schema
configurations can be found at
https://hexdocs.pm/ecto/Ecto.Schema.html#module-schema-attributes.
In web/web.ex
add @timestamp_opts
to the model function after use Ecto.Schema
:
def model do
quote do
use Ecto.Schema
@timestamps_opts inserted_at: :created_at
import Ecto
import Ecto.Changeset
import Ecto.Query
end
end
We can check our work:
$ iex -S mix
iex(1)> PhoenixApp.Repo.all(PhoenixApp.User)
SELECT u0."id", u0."email", u0."encrypted_password", u0."created_at", u0."updated_at" FROM "users" AS u0 []
[%PhoenixApp.User{ . . . }]
Defining our Ecto schemas
Now we can start defining the fields and associations for our Ecto
schemas. We’ll begin by covering the field declarations, then we can
add the associations. We’ll cover all of the basic association types
and then see how we can re-use those in through
associations.
Fields
The generators have done most of the field mapping work for us. Each field declaration consists of a field name, a type, and options. Types can be either primitive or custom, so you are free to add types if your application needs them. Interestingly, both list (array) and map are included in the primitive types.
The field options you’ll most commonly use are default
and
virtual
. The default
option is used to provide a static default
value for the field when it has not been set. The virtual
option
flags a field as non-persisting, a common use would be to temporarily
hold a plaintext password before encrypting it for writing to the
database. You can refer to the
Ecto.Schema field documentation for
all of the types and options.
Basic associations
We’ll start by looking at defining and accessing the basic
associations belongs_to
, has_many
, many_to_many
(has_and_belongs_to_many
), and has_one
.
belongs_to
Our Ecto model generator commands explicitly named the model table and
the foreign keys referring to other tables. By choosing table names
and foreign key names that match Rails conventions (which are pretty
hard to argue with), we automatically get working generated
belongs_to
associations in our Ecto schemas.
Let’s test our generated belongs_to
definition for Item. To fetch
our item
, we use Repo.one
which is piped to
Repo.preload(:list)
. Repo.preload
loads the data for the named
association and combines it with the Item returned by Repo.one
. We
can access the fields of the resulting struct using dot notation.
$ iex -S mix
iex(1)> item = PhoenixApp.Repo.one(PhoenixApp.Item) |> PhoenixApp.Repo.preload(:list)
SELECT i0."id", i0."description", i0."list_id", i0."created_at", i0."updated_at" FROM "items" AS i0 []
SELECT l0."id", l0."name", l0."slug", l0."project_id", l0."created_at", l0."updated_at", l0."id" FROM "lists" AS l0 WHERE (l0."id" = $1) [1]
%PhoenixApp.Item{ . . . }
iex(2)> item.list.name
"List"
has_many
Just like we did for the Rails models, we need to add has_many
definitions to our Ecto model schemas by hand. In Project we add a
has_many
explicitly naming the relationship and specifying the model
to use.
schema "projects" do
. . .
has_many :lists, PhoenixApp.List
. . .
end
List has_many
Items works the same:
schema "lists" do
. . .
has_many :items, PhoenixApp.Item
. . .
end
And we can test Project has_many
Lists out in iex
. After we fetch
our project
we can map over the project.lists
list and return the
name of each one.
$ iex -S mix
iex(1)> project = PhoenixApp.Repo.one(PhoenixApp.Project) |> PhoenixApp.Repo.preload(:lists)
SELECT p0."id", p0."name", p0."slug", p0."created_at", p0."updated_at" FROM "projects" AS p0 []
SELECT l0."id", l0."name", l0."slug", l0."project_id", l0."created_at", l0."updated_at", l0."project_id" FROM "lists" AS l0 WHERE (l0."project_id" = $1) ORDER BY l0."project_id" [1]
%PhoenixApp.Project{ . . . }
iex(2)> project.lists |> Enum.map(fn l -> l.name end)
["List"]
has_and_belongs_to_many
/many_to_many
The Rails has_and_belongs_to_many
association is referred to as
many_to_many
in Ecto. Ecto 2.0 supports many_to_many
using either
a simple join table (like projects_users
in our example) or joining
through another Ecto schema definition.
When adding the many_to_many
declaration we specify the association
name, the association model, and the name of the join table. Here are
the many_to_many
lines from User and Project.
schema "users" do
. . .
many_to_many :projects, PhoenixApp.Project, join_through: "projects_users"
. . .
end
schema "projects" do
. . .
many_to_many :users, PhoenixApp.User, join_through: "projects_users"
. . .
end
And in iex
User many_to_many
Projects:
$ iex -S mix
iex(1)> user = PhoenixApp.Repo.one(PhoenixApp.User) |> PhoenixApp.Repo.preload(:projects)
SELECT u0."id", u0."email", u0."encrypted_password", u0."created_at", u0."updated_at" FROM "users" AS u0 []
SELECT p0."id", p0."name", p0."slug", p0."created_at", p0."updated_at", u1."id" FROM "projects" AS p0 INNER JOIN "users" AS u1 ON u1."id" = ANY($1) INNER JOIN "projects_users" AS p2 ON p2."user_id" = u1."id" WHERE (p2."project_id" = p0."id") ORDER BY u1."id" [[1]]
%PhoenixApp.User{ . . . }
iex(2)> user.projects |> Enum.map(fn p -> p.name end)
["Project"]
has_one
As in Rails, Ecto has_one
definitions look just like has_many
. We
can add List has_one
Status like this:
schema "lists" do
. . .
has_one :status, PhoenixApp.Status
. . .
end
And it works as expected:
$ iex -S mix
iex(1)> list = PhoenixApp.Repo.one(PhoenixApp.List) |> PhoenixApp.Repo.preload(:status)
SELECT l0."id", l0."name", l0."slug", l0."project_id", l0."created_at", l0."updated_at" FROM "lists" AS l0 []
SELECT s0."id", s0."description", s0."list_id", s0."created_at", s0."updated_at", s0."list_id" FROM "statuses" AS s0 WHERE (s0."list_id" = $1) [1]
%PhoenixApp.List{ . . . }
iex(2)> list.status.description
"Status"
Through associations
Associations can be defined through
other associations in Ecto just
like in ActiveRecord. Ecto’s through
definitions use a list of
association names to resolve when fetching the target association.
has_many through
As an example, Project has_many
Items through
List and User
has_many
Lists through
Project can be expressed as:
schema "projects" do
. . .
has_many :items, through: [:lists, :items]
. . .
end
schema "users" do
. . .
has_many :lists, through: [:projects, :lists]
. . .
end
These through
definitions describe the association chain used to
fetch the final association and can be arbitrarily deep. You can see
them working in iex
.
$ iex -S mix
iex(1)> project = PhoenixApp.Repo.one(PhoenixApp.Project) |> PhoenixApp.Repo.preload(:items)
SELECT p0."id", p0."name", p0."slug", p0."created_at", p0."updated_at" FROM "projects" AS p0 []
SELECT l0."id", l0."name", l0."slug", l0."project_id", l0."created_at", l0."updated_at", l0."project_id" FROM "lists" AS l0 WHERE (l0."project_id" = $1) ORDER BY l0."project_id" [1]
SELECT i0."id", i0."description", i0."list_id", i0."created_at", i0."updated_at", i0."list_id" FROM "items" AS i0 WHERE (i0."list_id" = $1) ORDER BY i0."list_id" [1]
%PhoenixApp.Project{ . . . }
iex(2)> project.items |> Enum.map(fn i -> i.description end)
["Item"]
For our last has_many through
example, let’s try User has_many
Items through
Lists. In this case the lists
association on User is
also a has_many through
, and we can chain them together without any
problems.
schema "users" do
. . .
has_many :items, through: [:lists, :items]
. . .
end
With our updated version of Ecto we can access this chained through
association. One thing to notice is that pre-loading a through
relationship like items
automatically pre-loads any intermediate
associations like projects
and lists
.
i$ iex -S mix
ex(1)> user = PhoenixApp.Repo.one(PhoenixApp.User) |> PhoenixApp.Repo.preload(:items)
SELECT u0."id", u0."email", u0."encrypted_password", u0."created_at", u0."updated_at" FROM "users" AS u0 []
SELECT p0."id", p0."name", p0."slug", p0."created_at", p0."updated_at", u1."id" FROM "projects" AS p0 INNER JOIN "users" AS u1 ON u1."id" = ANY($1) INNER JOIN "projects_users" AS p2 ON p2."user_id" = u1."id" WHERE (p2."project_id" = p0."id") ORDER BY u1."id" [[1]]
SELECT l0."id", l0."name", l0."slug", l0."project_id", l0."created_at", l0."updated_at", l0."project_id" FROM "lists" AS l0 WHERE (l0."project_id" = $1) ORDER BY l0."project_id" [1]
SELECT i0."id", i0."description", i0."list_id", i0."created_at", i0."updated_at", i0."list_id" FROM "items" AS i0 WHERE (i0."list_id" = $1) ORDER BY i0."list_id" [1]
%PhoenixApp.User{ . . . }
iex(2)> user.items |> Enum.map(fn i -> i.description end)
["Item"]
has_one through
The other through
variation we have available is has_one
. Let’s
add a definition for Item has_one
Status through
List.
schema "items" do
. . .
has_one :status, through: [:list, :status]
. . .
end
And in iex
it just works:
$ iex -S mix
iex(1)> item = PhoenixApp.Repo.one(PhoenixApp.Item) |> PhoenixApp.Repo.preload(:status)
SELECT i0."id", i0."description", i0."list_id", i0."created_at", i0."updated_at" FROM "items" AS i0 []
SELECT l0."id", l0."name", l0."slug", l0."project_id", l0."created_at", l0."updated_at", l0."id" FROM "lists" AS l0 WHERE (l0."id" = $1) [1]
SELECT s0."id", s0."description", s0."list_id", s0."created_at", s0."updated_at", s0."list_id" FROM "statuses" AS s0 WHERE (s0."list_id" = $1) [1]
%PhoenixApp.Item{ . . . }
iex(2)> item.status.description
"Status"
Other Association Types
There are two other varieties of associations that I have skipped, polymorphic associations and self-referential associations. I have not needed to use either of these on a project yet.
The polymorphic association is not really supported by Ecto. From a database theory perspective, polymorphic associations are dangerous because they break enforcement of referential integrity. Ecto prefers that you use a distinct table and foreign key for each “type”, which allows for enforcement of referential integrity constraints.
The other problem with supporting polymorphic associations is that Ecto uses structs to hold record data, but there is no notion of struct inheritance in Elixir. Regardless, there is community interest in supporting some type of polymorphic association in Ecto but any implementation will likely be an extension and not part of Ecto core.
The other association we skipped is self-referential associations,
which are used to model tree like relationships. Setting up the
association definitions for has_many :children
and belongs_to
:parent
should be straight forward, and functional languages make it
easy to use recursion to fetch data to any depth. This may be an
interesting topic to explore in the future.
Next Steps
So far our use of Ecto has been straightforward, we were able to create Ecto schemas and define fields and associations compatible with Rails database conventions. At this point we have the read side of interacting with a Rails database covered.
Obviously reading records out of the database is only a small part of the story. If we want to replicate the behavior of ActiveRecord models, we’re still missing a lot of pieces. We need to handle record inserts and updates with validation and error reporting. We need callback behaviors that generate slugs or encrypt passwords. We’ll want to catch and handle violations of database constraints. Most of these behaviors will be implemented in the form of Ecto changesets.
To really use our new Ecto schemas in an application, we also need to understand Ecto’s query interface. We’ll want to join on our schema associations and access those joined tables in composed queries. We’ll want to write Ecto migrations that can duplicate the Rails database structure. And at some point we may want to use migrations to move some of our Rails naming conventions toward the Ecto ones.
That’s a lot to cover, and Ecto is definitely a different beast than Rails ActiveRecord. Fortunately our familiarity with ActiveRecord allows us to draw parallels and emphasize the differences between the two. Before long you’ll be just as good at thinking in Ecto as you are at working with ActiveRecord.