Implementing Sum Types in Ecto.

Creating a has_one_of association in Ecto with EctoMorph

Adz
8 min readJul 27, 2019
Photo by Jason Leung on Unsplash

So what do I mean by has_one_of? Well sometimes when modelling data we want to say something like “this thing can be one of these types of things”. That is to say, our thing can be one of a group of possibilities, and can only be one of those at any one time. Why is this a useful way to talk about data? Isn’t that just a has_one anyway? Let’s look at an example to see…

We want to track athletes and their results in different events. To begin with we decide to have athletes, who have_one medal. The schemas would look like this:

defmodule Athlete do
use Ecto.Schema
schema “athletes” do
field(:name, :string)
has_one(:medal, Medal)
end
end
defmodule Medal do
use Ecto.Schema
schema “medals” do
field(:type, :string)
field(:championship_points_earned, :integer)
belongs_to(:athlete, Athlete)
end
end

This is great because we can have multiple rows in the medals table, each with a different type, with values like gold, silver, or bronze. Then our athlete can has_one Medal, whose specific type is bronze, for example.

This works for a while but then the athletics foundation announces they will no longer award medals for race wins, instead they will give prize money. What can we do to track this in our program? Crucially we need to be sure that we don’t lose any existing data, so any athletes that already have medals still have medals.

We could create a new table called something like “prizes” which could have fields like “rank” and “amount”. This captures the data we need to track the athletes, but isn’t particularly appealing. To understand why let’s look at what our schemas would look like:


defmodule Athlete do
use Ecto.Schema
schema “athletes” do
field(:name, :string)
has_one(:medal, Medal)
has_one(:prize, Prize)
end
end
defmodule Medal do
use Ecto.Schema
schema “medals” do
field(:type, :string)
field(:championship_points_earned, :integer)
belongs_to(:athlete, Athlete)
end
end
defmodule Prize do
use Ecto.Schema
schema “prizes” do
field(:rank, :integer)
field(:amount, :integer)
belongs_to(:athlete, Athlete)
end
end

Whilst this works, there are a few things I don’t like. Every athlete that won a medal will now have a null Prize relation just hanging around. Similarly, every new athlete that enters a race will have a null Medal relation just hanging around forever. This might be tolerable for one field, but what if the prize for winning changes again? Instead of a Prize or Medal the top finishers from now on get AmazonVouchers. Then we’d have two legacy relations just hanging around forever.

The problem is the schema is not accurately representing our domain. An athlete cannot both win a medal AND prize money but there is nothing in our schema that prevents an athlete having both a Prize and a Medal. This isn’t just about an academic sense of purity in the domain model, these kinds of problems can dramatically increase the complexity of the application code. If we want to query an athlete’s winnings we need to know which key to look at:


def winnings(athlete = %Athlete{prize: nil}) do
athlete.medal
end
def winnings(athlete = %Athlete{medal: nil}) do
athlete.prize
end

It gets even worse if we want to start validating in the application layer the creation of Athletes so that you cannot have both a medal and a prize.

So can we do better? Can we move away from violating the tell don’t ask principle and move towards a better domain model? Yes. The relationship we want is has_one_of. We want to be able to say that an Athlete’s winnings are one of a set of possible things. Can we do that in Ecto? Yes it is possible, but it’s a bit out of left field. The key is to notice that medals and prize money are two types of a more generic thing. We could call it a Reward for now. The approach we are going to take is to add a jsonb reward column to the athletes table, and let that be a custom ecto type which decides what specific reward we have. Let’s step through the code to see what that would look like. First our migration would look like this:

defmodule PostgresTest.Repo.Migrations.AddTable do
use Ecto.Migration
def change do
create(table(:athletes)) do
add(:reward, :map)
add(:name, :text)
timestamps()
end
end
end

This tells postgres we want a jsonb column for the reward. Our Athlete schema will now look something like this:


defmodule Athlete do
use Ecto.Schema
schema “athletes” do
field(:name, :string)
field(:reward, CustomTypes.Reward)
end
end

And we turn our Medal and Prize schemas have into embedded schemas:

defmodule Medal do
use Ecto.Schema
embedded_schema do
field(:colour, :string)
end
end
defmodule Prize do
use Ecto.Schema
embedded_schema do
field(:rank, :string)
field(:amount, :integer)
end
end

Our reward column will be a glob of json, so our Ecto type will do two things. It will take one of the reward schemas — i.e. Medal or Prize — and turn it into json so that we can put it in the jsonb column, and it will take any json that is in the reward column and serialize it into one of the relevant structs. To do that requires a custom Ecto type which we have called CustomTypes.Reward

defmodule CustomTypes.Reward do
use Ecto.Type
def type, do: :map
def cast(reward = %{"colour" => _}) do
EctoMorph.cast_to_struct(reward, Medal)
end
def cast(reward = %{colour: _}) do
EctoMorph.cast_to_struct(reward, Medal)
end
def cast(reward = %{"amount" => _}) do
EctoMorph.cast_to_struct(reward, Prize)
end
def cast(reward = %{amount: _}) do
EctoMorph.cast_to_struct(reward, Prize)
end

def dump(reward = %{"colour" => _}) do
EctoMorph.cast_to_struct(reward, Medal)
end
def dump(reward = %{colour: _}) do
EctoMorph.cast_to_struct(reward, Medal)
end
def dump(reward = %{"amount" => _}) do
EctoMorph.cast_to_struct(reward, Prize)
end
def dump(reward = %{amount: _}) do
EctoMorph.cast_to_struct(reward, Prize)
end

def load(reward = %{"colour" => _}) do
EctoMorph.cast_to_struct(reward, Medal)
end
def load(reward = %{"amount" => _}) do
EctoMorph.cast_to_struct(reward, Prize)
end
end

There is a lot to take in here so let’s step through it.

The Custom Type Behaviour

Custom Ecto types need to implement the custom type behaviour. This just defines a bunch of functions that we need to implement in our type. Let’s look at each function in turn.

type/0 — This defines which Ecto native type it will become. Our custom type has to be turned into something the database can handle, like a string or an integer. In our case because our column is a jsonb column, our type is a :map

cast/1 — This is called both when we use Ecto.Changeset.cast/4 and if we construct a query and pass in a value (that value will be casted according to this function). So if we do this:

Ecto.Changeset.cast(
%Athlete{},
%{reward: %{"amount" => "100"}},
[:reward]
)

The cast/1 function gets called, is passed this: %{“amount” => “100”} and our cast function determines what happens next. In our case, we convert “100” into 100,“amount” into :amount, create a Prize struct, and that is the change that the changeset will apply. Similarly if we do this:

Repo.get_by(Athlete, reward: %PrizeMoney{amount: "100"})

Our cast function will kick in and turn “100” into 100, returning a row if one exists with that reward. In essence cast lets us define mappings from one type to another.

We have been extra careful in our custom type to handle both string and atom keys, and we just piggyback on Ecto’s built in casting by using EctoMorph.cast_to_struct. That will handle filtering out any extra attributes as well as some sensible castings like numeric string -> integer.

We also correctly distinguish between creating a Medal and a Prize by matching on the specific keys that only a Medal or a Prize will have. This means we can do this:

Ecto.Changeset.cast(
%Athlete{},
%{reward: %{"amount" => "100"}},
[:reward]
)

and get an Athlete changeset with a Medal reward, or we can do this:

Ecto.Changeset.cast(
%Athlete{},
%{reward: %{"colour" => "Gold"}},
[:reward]
)

and get an Athlete changeset with a Prize reward!

dump/1 — Dump is in charge of turning our elixir struct into something that the database can store. Here we are helped a little by the fact that ecto and postgrex support jason by default. In essence, postgrex will try and serialize whatever we return from our dump function into json for us then give it to the db. That means we can take the exact same approach as for cast and use cast_to_struct to take whatever we are given and coerce it into the struct we want. Then postgrex tries to turn that struct into json for the db, and we make sure it can by implementing the encode protocol like so:

defmodule Medal do
use Ecto.Schema

@derive {Jason.Encoder, only: [:colour]}

embedded_schema do
field(:colour, :string)
end
end
defmodule Prize do
use Ecto.Schema
@derive {Jason.Encoder, only: [:rank, :amount]}
embedded_schema do
field(:rank, :string)
field(:amount, :integer)
end
end

Easy!

load/1 — Load gets whatever is in the db column and gives us the chance to turn it into our own struct. In our case it will be given json (meaning string keys) and we can use the same pattern match to determine whether to make a Prize or a Medal.

In summary the 3 functions cast, load and dump:

cast/1 — define how to turn other data into our custom type

load/1 — define how to turn what’s in the database into the correct Elixir struct

dump/1 — define how to turn the Elixir struct into something the database can persist.

So what would querying for a reward look like now?


athlete = Repo.get(Athlete, 10)
# There’s no need to preload the relation, or do a join as its all
# in one column athlete.reward

Easy! And how about adding a new type of reward. Well first we would define the schema, let’s pretend athletes now win discount vouchers for sporting goods. We first define the reward:

defmodule DiscountVouchers do
use Ecto.Schema
@derive {Jason.Encoder, only: [:percentage_discount]}
embedded_schema do
field(:percentage_discount, :decimal)
end
end

Then add the new cases in the custom Ecto type’s cast load and dump functions:

...def cast(reward = %{"percentage_discount" => _}) do
EctoMorph.cast_to_struct(reward, DiscountVouchers)
end
def cast(reward = %{percentage_discount: _}) do
EctoMorph.cast_to_struct(reward, DiscountVouchers)
end
...def load(reward = %{"percentage_discount" => _}) do
EctoMorph.cast_to_struct(reward, DiscountVouchers)
end
def load(reward = %{percentage_discount: _}) do
EctoMorph.cast_to_struct(reward, DiscountVouchers)
end
...def dump(reward = %{"percentage_discount" => _}) do
EctoMorph.cast_to_struct(reward, DiscountVouchers)
end
def dump(reward = %{percentage_discount: _}) do
EctoMorph.cast_to_struct(reward, DiscountVouchers)
end
...

And that’s it. What’s really nice about this is if we want to do something with each reward, like say we want to calculate each reward’s $ value, we can use a protocol and define the specific conversion for each type cleanly.


defprotocol DollarValue do
def for(reward)
end
defimpl DollarValue, for: Medal do
def for(medal = %{colour: “GOLD”}) do
1_000_000
end
def for(medal = %{colour: “SILVER”}) do
100_000
end
def for(medal = %{colour: “BRONZE”}) do
10_000
end
end
defimpl DollarValue, for: Prize do
def for(%{amount: amount}), do: amount
end

Then use it like this:


DollarValue.for(athlete.reward)

Pure unadulterated polymorphism.

Validations

The last thing we may want to do is validate the schemas in some way when we create the different rewards. To do that we can add validations to the custom ecto type like this:

def cast(reward = %{"percentage_discount" => _}) do
EctoMorph.generate_changeset(reward, DiscountVouchers)
|> DiscountVouchers.validate()
|> EctoMorph.into_struct()
end

That works great when the validation passes, but if it fails and you want the error to appear on the parent’s changeset you need to make a slight change:

def cast(reward = %{"percentage_discount" => _}) do
EctoMorph.generate_changeset(reward, DiscountVouchers)
|> DiscountVouchers.validate()
|> EctoMorph.into_struct()
|> case do
{:ok, struct} -> {:ok, struct}
# returning changeset.errors ensures the error
# goes on the parent changeset.
{:error, changeset} -> {:error, changeset.errors}
end
end

--

--