Polymorphic Associations in Elixir


If you're coming from Ruby on Rails, you're probably familiar with the polymorphic associations between resources and you probably miss them in Elixir. We recently had to migrate a Rails functionality to Elixir without making any changes to our database, so in this article we'll cover how you can achieve that. If you've already gone through the Ecto documentation you've probably found out that copying this functionality into Еlixir is not recommended and is not the Еlixir way, yet no one actually describes how you could go about it if you want to replicate it. The purpose of this guide is to clear that up.

Rails Associations

Say we have a User model, an Image model, and a Product model.
An Image can belong to a User or to a Product. 

In Rails the go-to approach would be to define a polymorphic association like this:

class Image < ApplicationRecord
  belongs_to :imageable, polymorphic: true
end
 
class User < ApplicationRecord
  has_many :images, as: :imageable
end
 
class Product < ApplicationRecord
  has_many :images, as: :imageable
end

And our images table would have the following fields:

t.integer  :imageableid
t.string :imageable
type

or
t.references :imageable, polymorphic: true

Now when we are creating images, we can access the User or the Product record by image.imageable.

How can we achieve the same result in Elixir

First, we create migrations for each table: users, products, and images. For simplicity purposes, we'll create all resources under the same context which we'll call Shop.

1. Schema files and associations

The images schema should look like this:

schema "images" do
field :url, :string
field :imageableid, :integer
field :imageable
type, :string
field :imageable, :map, virtual: true
timestamps()
end

def changeset(image, attrs) do
image
|> cast(attrs, [:url, :imageabletype, :imageableid])
|> validaterequired([:url, :imageabletype, :imageable_id])
end

Here we have added a virtual :imageable field which later on we will populate with either a user or a product record.

The users schema should currently look like this:


schema "users" do
field :email, :string
field :name, :string
timestamps()
end

def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email])
|> validate_required([:name, :email])
end

We are going to add a has_many association to our schema.

schema "users" do
field :email, :string
field :name, :string
hasmany :images, Image, foreignkey: :imageableid, where: [imageabletype: "user"]
timestamps()
end

Don't forget to alias the Image module.
This association will allow us to access all images the user has simply by calling Repo.preload(:images). It will basically run a query to find all images with imageable_id equal to the user's ID and with imageable_type "user"

Lastly, we'll add the same has_many association to our product schema, but this time imageable_type's value will be "product". This is how the schema file should look like:

schema "products" do
field :name, :string
field :price, :float
hasmany :images, Image, foreignkey: :imageableid, where: [imageabletype: "product"]
timestamps()
end

def changeset(product, attrs) do
product
|> cast(attrs, [:name, :price])
|> validate_required([:name, :price])
end

2. Preloading resources in queries

Now let's move on to our context file and rework the get_user! and get_product! functions to load the images as well.
This is how currently out get_user! function looks like:


def get_user!(id), do: Repo.get!(User, id)

We are going to change it to this:

def get_user!(id) do
User
|> Repo.get!(id)
|> Repo.preload(:images)
end

We'll apply the same to the get_product! function.

In case you want to write it on a single line:

def get_product!(id), do: Repo.get!(Product, id) |> Repo.preload(:images)

What we want next is when we call the get_image/1 function to have the imageable field populated with either a user or a product record. This is how we are going to achieve this:

We are going to query the data base for an image and pass the result to the add_imageable/1 function that we'll create in a bit.

def getimage(id) do
Image
|> Repo.get(id)
|> add
imageable()
end

We create the new function:


defp addimageable(nil), do: nil
defp add
imageable(image), do: Map.put(image, :imageable, get_imageable(image))

Our function has two clauses. In the first clause we pattern match on a nil value in case the image we are querying for does not exist in our database. The second clause will match on anything, but in our case this will always be an Image struct, and then it will populate the empty :imageable field of our Image struct with whatever is returned from the get_imageable/1 function.
This is what our get_imageable function looks like:

def get_imageable(%{imageable_type: "user", imageable_id: user_id}), do: Repo.get(User, user_id)
  def get_imageable(%{imageable_type: "product", imageable_id: product_id}), do: Repo.get(Product, product_id)
  def get_imageable(_image), do: nil

Our get_imageable function expects a map containing imageable_type and imageable_id fields and uses them to query the database for the corresponding imageable_type. For the cases where our map does not contain the aforementioned fields our function will return nil.

The whole code related to getting an image should look like this:

def get_image(id) do
  Image
  |> Repo.get(id)
  |> add_imageable()
end

defp add_imageable(nil), do: nil
defp add_imageable(image), do: Map.put(image, :imageable, get_imageable(image))

def get_imageable(%{imageable_type: "user", imageable_id: user_id}), do: Repo.get(User, user_id)
def get_imageable(%{imageable_type: "product", imageable_id: product_id}), do: Repo.get(Product, product_id)
def get_imageable(_image), do: nil

The same logic can be applied for listing images, so let's modify our list_images/0 function a little in order for every image in the list to have its imageable field populated.

def listimages do
Image
|> Repo.all()
|> Enum.map(&add
imageable/1)
end

The above code can be considered slow, since for each image record we will query the database for its imageable. It's out of this article's scope to optimise it, but for those of you that are curious you can find an optimised snippet at the end of the article.

3. Populating imageable field on resource creation

There is one last thing that we need to do before we can move on to testing. To fully copy the Rails functionality we expect when we call Shop.create_image() and pass the imageable as a parameter to have the two resources associated with each other. Let's see how we can achieve that in Еlixir.

We'll modify our create_image/1 function and before we pass the attributes to the changeset function we'll apply some changes to them by calling imageable_attrs/1.

def create_image(attrs \\ %{}) do
  %Image{}
  |> Image.changeset(imageable_attrs(attrs))
  |> Repo.insert()
end

The imageable_attrs/1 function looks like this:

defp imageableattrs(%{imageable: %type{id: id}} = attrs) do
attrs
|> Map.put(:imageable
id, id)
|> Map.put(:imageabletype, parsetype(type))
end

defp imageableattrs(attrs), do: attrs


This function pattern matches on a map with an imageable field and uses the field's value to get the imageable_type and imageable
_id. And of course we have another clause that will just return the input in case the map is missing an imageable field.
The only thing that might look a bit odd is the way we get the imageable_type. The value of the type variable is an atom of either :Elixir.Shop.User or :Elixir.Shop.Product. Since we only care whether the imageable_type is a "user" or a "product" let's parse the atom to get only what we need.

defp parse_type(type) do
  type
  |> Atom.to_string()
  |> String.split(".")
  |> List.last()
  |> String.downcase()
end

This function should now return a "user" or a "product" string.
Let's see how the whole code regarding the creation of an image record looks like.

def createimage(attrs \ %{}) do
%Image{}
|> Image.changeset(imageable
attrs(attrs))
|> Repo.insert()
end

defp imageableattrs(%{imageable: %type{id: id}} = attrs) do
attrs
|> Map.put(:imageable
id, id)
|> Map.put(:imageabletype, parsetype(type))
end

defp imageable_attrs(attrs), do: attrs

defp parsetype(type) do
type
|> Atom.to
string()
|> String.split(".")
|> List.last()
|> String.downcase()
end

Now when we call create_image and pass the imageable as a parameter we'll have the resources associated with each other. But we also want to access the imageable by image.imageable right?

So this is how we can do that:
We go back to our Image changeset function and we'll add put_change/3 to populate our imageable field. We'll get the Imageable either from the params or by using the get_imageable/2 function we have previously defined.

def changeset(image, attrs) do
  image
  |> cast(attrs, [:url, :imageable_type, :imageable_id])
  |> put_change(:imageable, imageable(attrs))
  |> validate_required([:url, :imageable_type, :imageable_id])
end

defp imageable(%{imageable: imageable}), do: imageable
defp imageable(attrs), do: Shop.get_imageable(attrs)

That's all, we can move on to testing now.

4. Testing the functionality

Let's start our console and create an user:

iex(1)> {:ok, user} = Shop.create_user(%{name: "Username", email: "email"})
{:ok,
%Shop.User{
email: "email",
id: 2,
images: #Ecto.Association.NotLoaded,
name: "Username",
}}

Now let's create an image with the user as an imageable:


iex(2)> {:ok, image} = Shop.createimage(%{url: "url", imageable: user})

{:ok,
%Shop.Image{
id: 4,
imageable: %Shop.User{
email: "email",
id: 2,
images: #Ecto.Association.NotLoaded,
name: "Username",
},
imageable
id: 2,
imageable_type: "user",
url: "url"
}}

The imageable is populated, as well as the imageable Id and type. We can access the user by image.imageable.

iex(3)> image.imageable
%Shop.User{
meta: #Ecto.Schema.Metadata<:loaded, "users">,
email: "email",
id: 2,
images: #Ecto.Association.NotLoaded,
name: "Username",
}

We can create a product and an image for that product:

iex(4)> {:ok, product} = Shop.createproduct(%{name: "product", price: 5.0})
{:ok,
%Shop.Product{
id: 2,
images: #Ecto.Association.NotLoaded,
name: "product",
price: 5.0,
}}
iex(5)> {:ok, image} = Shop.create
image(%{url: "url", imageabletype: "product", imageableid: product.id})
{:ok,
%Shop.Image{
id: 5,
imageable: %Shop.Product{
id: 2,
images: #Ecto.Association.NotLoaded,
name: "product",
price: 5.0,
},
imageableid: 2,
imageable
type: "product",
url: "url"
}}

As this article has become a little lengthy we'll skip the tests for the remaining functions that we modified, but we encourage you to go ahead and test out the list_images, get_user!, get_product! and get_image functions.
As we can see images are created along with the corresponding association, so this concludes the Rails polymorphic associations in Еlixir, at least the Rails way, the Еlixir way is coming up.

*Optimised list_images

We have mentioned that the code for listing images can be optimised. You can use this snipped to do so. Note that the parse_type/1 function has already been defined in section 3 of this article.

def list_images do
  Enum.flat_map([User, Product], fn table ->
    Image
    |> join(:left, [i], t in ^table, on: t.id == i.imageable_id)
    |> select_merge_imageable(table)
    |> Repo.all
  end)
end

def select_merge_imageable(query, type) do
  query
  |> where([i, t], i.imageable_type == ^parse_type(type))
  |> select_merge([i, t], %{imageable: t})
end

Need help?

Book a 1h session with an expert on this very matter

€75/h

Pair programming

Pair programming is an agile software development technique in which two programmers work together at one workstation. One, the driver, writes code while the other, the observer or navigator,[1] reviews each line of code as it is typed in. The two programmers switch roles frequently.