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 :imageabletype
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 :imageabletype, :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)
|> addimageable()
end
We create the new function:
defp addimageable(nil), do: nil
defp addimageable(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(&addimageable/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(:imageableid, 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(imageableattrs(attrs))
|> Repo.insert()
end
defp imageableattrs(%{imageable: %type{id: id}} = attrs) do
attrs
|> Map.put(:imageableid, id)
|> Map.put(:imageabletype, parsetype(type))
end
defp imageable_attrs(attrs), do: attrs
defp parsetype(type) do
type
|> Atom.tostring()
|> 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,
imageabletype: "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