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
enddefmodule 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
enddefmodule Medal do
use Ecto.Schema
schema “medals” do
field(:type, :string)
field(:championship_points_earned, :integer)
belongs_to(:athlete, Athlete)
end
enddefmodule 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
enddef 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 Athlete
s 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
enddefmodule 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
enddefmodule 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)
enddef cast(reward = %{percentage_discount: _}) do
EctoMorph.cast_to_struct(reward, DiscountVouchers)
end...def load(reward = %{"percentage_discount" => _}) do
EctoMorph.cast_to_struct(reward, DiscountVouchers)
enddef load(reward = %{percentage_discount: _}) do
EctoMorph.cast_to_struct(reward, DiscountVouchers)
end...def dump(reward = %{"percentage_discount" => _}) do
EctoMorph.cast_to_struct(reward, DiscountVouchers)
enddef 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)
enddefimpl 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
enddefimpl 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
If you liked this, check out the EctoMorph library on github and my other articles on Ecto here