Use Ecto to simplify complex table association scenarios and improve general reliability and security when working with databases.
When working with Elixir, Ecto is the go-to ORM library and toolkit for data-mapping and validation. Among Ecto's many features and wide-range of use-cases, there's one with very little visibility and that's association defaults between different schemas.
In this article, we'll cover some of its common use-cases and some lesser known features that can simplify complex table association scenarios, and empower you to build better multi-tenant applications, while also improving general reliability and security when working with databases.
For simple CRUD applications, you might have a couple of tables in your database mapping to different resources and one-to-many and many-to-many relationships between them. For example, take a music encyclopedia app with the following schemas:
To keep things simple for now, imagine an artist can have many albums and an album can have many tracks. Their Ecto schemas might look like this:
defmodule MusicApp.Track do
use Ecto.Schema
schema "tracks" do
field :title, :string
field :type, Ecto.Enum, values: [:vocal, :instrumental]
belongs_to :artist, Artist
belongs_to :album, Album
end
end
defmodule MusicApp.Album do
use Ecto.Schema
schema "albums" do
field :title, :string
belongs_to :artist, Artist
has_many :tracks, Track
end
end
When defining the has_many
association with :tracks
in Album
, Ecto also lets us specify the association defaults which will be applied when building a new track (using cast/put assoc/embed changeset methods):
has_many :tracks, Track, defaults: [type: :vocal]
But since you could just as easily define this directly in the Track
schema, this doesn't offer enough value other than the time at which this value is set. If the associations were split in two based on their attributes, then it might make more sense:
has_many :vocals, Track, where: [type: :vocal], defaults: [type: :vocal]
has_many :instrumentals, Track, where: [type: :instrumental], defaults: [type: :instrumental]
Similarly, Single Table Inheritance with multiple parent tables associating with the same table is a valid use-case but these are rare scenarios and mostly limited by the value defined at compile-time.
Normally you would extend the changeset method of the schema to handle these situations but the drawback there is that you can't look at the parent record's attributes to set values on the child association.
Fortunately, Ecto supports passing an MFA tuple (Module, Function, Arguments - used in the form of {Module, :function, [arg1, arg2]}
) to :defaults
. This allows using custom logic to set defaults of an association being built at run-time, depending on the parent and child association attributes.
An example of this is automatically setting the :artist
of a track from the artist of the album, ensuring consistency and reliability of the data. The updated schema would look like this:
defmodule MusicApp.Album do
use Ecto.Schema
schema "albums" do
field :title, :string
belongs_to :artist, Artist
has_many :tracks, Track, defaults: {__MODULE__, :set_artist, []}
end
def set_artist(%Album{} = album, %Track{} = track) do
%{track | artist_id: album.artist_id}
end
end
This will make sure that anytime a new track association is added to an album record, it automatically sets the track's artist to the album's if another artist is not specified.
Using this you can also offload other association-related runtime logic directly to Ecto that will be executed whenever a child association is added, instead of doing it manually.
At Slab, this allows us to ensure all comments belong to the correct post, even though the parent is a comment thread (where a Post can have many threads).
Starting in v3.3.2
, Ecto added support for a new option :join_defaults
for many-to-many associations which behaves similar to the :defaults
option but instead of setting attributes on the child association, it sets them on the intermediate association for the join table.
While in the majority of cases a join table will only have two foreign keys (IDs of the records from the two tables with the association), it's not unusual to have other columns or foreign keys to track more information. Out of the box, Ecto handles the former case very gracefully by using the :join_through
option in a many_to_many
relation in an Ecto schema, but it can get messy for the latter. Here's where :join_defaults
comes in.
Building on the previous Music App example again, we introduce a RecordLabel
schema that acts as an umbrella for all child data by having every other schema reference it directly. This is a common scenario in applications where one schema (like a Team or an Organization) "owns" all other data, including associations and joins for security, privacy or performance reasons (e.g. when readying your database to support sharding aka vertical partitioning).
We also assume that the same tracks can appear on multiple albums, and like to ensure that the label_id
is consistently and automatically set on the join tables every-time they are added. This is how you would define the schema:
defmodule MusicApp.Album do
use Ecto.Schema
@set_label {__MODULE__, :set_label, []}
schema "albums" do
field :title, :string
belongs_to :label, RecordLabel
belongs_to :artist, Artist
many_to_many :tracks, Track,
join_through: AlbumTrack,
defaults: @set_label,
join_defaults: @set_label
end
def set_label(parent, child) do
%{child | label_id: parent.label_id}
end
end
defmodule MusicApp.Track do
use Ecto.Schema
schema "tracks" do
field :title, :string
field :type, TrackType
belongs_to :label, RecordLabel
belongs_to :artist, Artist
many_to_many :albums, Album, ...
end
end
defmodule MusicApp.AlbumTrack do
use Ecto.Schema
schema "album_tracks" do
belongs_to :label, RecordLabel
belongs_to :album, Album
belongs_to :track, Track
end
end
We extensively use this feature at Slab, where all tables in our database, including pivot tables, have an org_id
foreign key that references the team the data belongs to. This feature makes it possible for us to enforce that the correct org_id
value is set for all resources at Slab, enabling us to securely and reliably support multi-tenancy in our product.
As Ecto has matured in the past few years, it has evolved into more than just a simple database library. The Ecto team has worked hard to develop it into a general purpose toolkit to map, query and interact with external data in Elixir - which may or may not be sourced from a database - and as part of that have implemented features that allow us to implement everything from the simplest schema designs to more advanced architectures.
We saw how the defaults
and join_defaults
options in Ecto can help tackle complex database schema designs and simplify setting attributes that are otherwise not possible or easy to deal with when using other approaches. And although they have very specific use-cases, employing both features at Slab has already benefited us significantly; enabling us to keep all data for every team secured together, all while keeping the codebase simple.
We'd love to hear your comments, feedback and if there's anything else about Ecto or Elixir you would like to learn more about. Tweet to us at @slab with your thoughts (even if it's just your favorite Ecto feature)!