Association Defaults in Ecto

Use Ecto to simplify complex table association scenarios and improve general reliability and security when working with databases.

by Sheharyar Naseer March 25, 2021

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.

Basic Usage with Compile-time Defaults

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:

  • Artist
  • Album
  • Track

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.

Defining Defaults at Run-time

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).

Defaults in Pivot Tables

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.

Wrapping Up

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)!

Enjoying the post? Get notified when we publish a new article.
Subscribe
Get notified when we publish a new article