linusdm

linusdm

Feedback on designing a polymorphic association in Ecto

I’d like to get some feedback on a design decision I made last week, that doesn’t feel great. It involves relationships between some Ecto schemas. These are the relevant parts the schemas:

defmodule Uitlening do
  use Ecto.Schema
  schema "uitleningen" do
    belongs_to :lezer, Lezer
    belongs_to :organisatie, Organisatie
  end

  def new(%Lezer{} = lezer), do: %__MODULE__{lezer: lezer}
  def new(%Organisatie{} = organisatie), do: %__MODULE__{organisatie: organisatie}

  def voor_lezer?(%__MODULE__{lezer_id: lezer_id}), do: lezer_id != nil
  def voor_organisatie?(%__MODULE__{} = uitlening), do: not voor_lezer?(uitlening)

  # get the "abstract" type for usage by the protocol (see later)
  def get_uitlener(%__MODULE__{lezer: %Lezer{} = lezer}), do: lezer
  def get_uitlener(%__MODULE__{organisatie: %Organisatie{} = organisatie}), do: organisatie
end

defmodule Lezer do
  use Ecto.Schema
  schema "lezers" do
    field :naam, :string
    field :voornaam, :string
    # some other fields here, but no reference to Uitlening required (yet)
  end
end

defmodule Organisatie do
  use Ecto.Schema
  schema "organisaties" do
    field :naam, :string
    # some other fields here, but no reference to Uitlening required (yet)
  end
end

The migration file looks something like this:

defmodule MyApp.Repo.Migrations.Uitleningen do
  use Ecto.Migration

  def change do
    create table(:uitleningen) do
      add :lezer_id, references(:lezers), null: true
      add :organisatie_id, references(:organisaties), null: true

      timestamps()
    end

    # check one of two references is not null (but not both "not null" or null at the same time)
    create constraint(:uitleningen, "check_lezer_org_exclusive_not_null",
             check:
               "(organisatie_id IS NOT NULL AND lezer_id IS NULL) OR (organisatie_id IS NULL AND lezer_id IS NOT NULL)"
           )
  end
end

So, a lot of code to express this simple idea: the central entity “Uitlening” can have exactly one reference to either “Lezer” or “Organisatie”. It’s kind of a polymorphic relation (although not the same kind of polymorphism as expressed in the ecto documentation, I think). The check constraint in the database gives me confidence that I don’t mess up with the rule that only one reference should be made. In addition, the constructor functions on the Uitlening module make it easier (but not impossible) to create a valid struct.

To actually work with the reference, I’ve defined a protocol that captures an interface that both referenced entities can adhere to, like this:

defprotocol Uitlener do
  @spec name(t) :: String.t()
  def name(value)

  @spec is_important?(t) :: boolean()
  def is_important?(value)
end

# the implementations, embedded in the original modules
defmodule Lezer do
  # ...
  
  def is_important?(lezer) do
    # some implementation details here
  end

  defimpl Uitlener do
    def naam(lezer), do: "#{lezer.voornaam} #{String.upcase(lezer.naam)}"
    def is_important?(lezer), do: @for.is_important?(lezer)
  end
end

defmodule Organisatie do
  # ...
  defimpl Uitlener do
    def naam(organisatie), do: organisatie.naam
    def is_important?(_organisatie), do: false
  end
end

The protocol is used mostly in heex templates, or place where I have to get a representation of the abstract type. The problem with this setup is that in order to get to the abstract struct I have to to this involved succession of calls, every time again:

<%= Uitlening.get_uitlener(uitlening) |> Uitlener.naam() %>

It would be a lot nicer if I could reference the Uitlener as a field, like this:

<%=  Uitlener.naam(uitlening.uitlener) %>

but I don’t think Ecto can “coerce” one of two references into a single field (and I don’t want to resort to doing that manually every time a struct comes back from a repository). Or are there options to solve this?

Also, the use of @for in the protocol implementation feels a bit odd. It’s only by accident that I found out that this is a shortcut notation to referencing the full module name (I had never seen it before).

More in general I’m curious if this is a common requirement, and whether I’ve chosen a good path to solve this requirement? If there are better choices, I’d love to learn about them. It seems like a lot of code to solve this problem, which also makes me doubt a bit.

There are still places where I pattern match on the actual implementation structs or use the is_x?/1 functions, after getting the more abstract type first. Mostly in cases where the code paths to process both are very different (I’d have to extend the protocol to get all the required data for one implemenntation, and deny a lot of it in the other implementation). But I know that is not a flexible design, and will cause problems when I add a third reference to the mix (I’ll have to chase down all those places, and make sure to pattern match on that new struct too, and handle it).
Maybe a protocol isn’t the answer after all, to this design problem of having this abstract reference, which would also be valuable feedback :slight_smile:

Thanks for reading this far, I appreciate any feedback.

Most Liked

LostKobrakai

LostKobrakai

To me this sounds like an opportunity to split read and write model (structs). You can return data, which is not a ecto struct to all the places that read the values. No need to let all the complexity of the db level storage leak all over the codebase. Deal with it close to reading the values from the db, tranfsform into a format more useful to all readers and return that. Readers don‘t need to care.

hlx

hlx

This is off-topic but I have to ask because this tripped me up :slight_smile: : why do you mix English with Dutch/Flemish? You have new/1 (EN), voor_lezer?/1 (NL) and get_uitlener/1 (EN+NL) which cover all possibilities when it comes to mixing the two languages.

As a native Dutch speaker and someone who works in a German code base I would suggest you write everything in English or stick to either language. Even asking questions and showing you code like this would make it easier for other people to understand if it was in English.

If you have a strong reason for writing it this way, please let me know because I’m really curious about the thought behind this.

I also learned that there is something called @for so thanks for that!

Where Next?

Popular in Questions Top

freewebwithme
Using vs code and installed ElixirLS: support and debugger. And I got an error popped up on start up says Failed to run ‘elixir’ comma...
New
srinivasu
How to handle excepions in elixir? Suppose i have A, B, C ,D, E modules. and each module has get() function. A.get() method will call th...
New
dotdotdotPaul
Okay, I'm having a heck of a time trying to figure out how to best handle the validation of belongs_to associations in Ecto. I'm sure I'...
New
lessless
I believe there are people here who are dealing with CSV files import on the daily basis, and since Excel is a really popular tool there ...
New
yawaramin
In the Dialyzer docs ( http://erlang.org/doc/man/dialyzer.html#requesting-or-suppressing-warnings-in-source-files ), there is a way to tu...
New
aalberti333
As the title describes, I’m trying to run Enum.map() over a list of key/value pairs, where the value is a map. My data looks like this: ...
New
tduccuong
Hi, is there any work on GUI with Elixir, that is similar to Electron/Javascript? My idea is to bundle Phoenix and BEAM into a single se...
New
ashish173
I am using Ecto timestamps with postgres, I can see the timestamps() use the :naive_dateime but for my use case I wanted to store the ti...
New
gonzofish
I’m currently trying to understand how to join three tables using Ecto. All the examples I’ve seen use 2, so maybe I’m just missing somet...
New
siddhant3030
Hi, I have to write a raw query for one of my project. But till now I have used ecto queries and don’t have much experience writing raw ...
New

Other popular topics Top

Qqwy
Update: How to use the Blogs &amp; Podcasts section You can post links to your blog posts or podcasts either in one of the Official Blog...
3268 119930 1237
New
SoCreat
i’m a new one to elixir which editor can i use vs code? or atom? Thanks! :smiley:
New
sergio
In Ruby, I can go: User.find_by(email: "foobar@email.com").update(email: "hello@email.com") How can I do something similar in Elixir? ...
New
JorisKok
I have a server on AWS, and was running a load test using artillery. When looking at the Phoenix dashboard I see the Ports going to 100% ...
New
aalberti333
As the title describes, I’m trying to run Enum.map() over a list of key/value pairs, where the value is a map. My data looks like this: ...
New
chrismccord
This release brings a number of exciting features, including integration with the new Phoenix LiveDashboard and Phoenix LiveView. There h...
New
qwerescape
Is there a way to get the call stack or stack trace at any point in the code? Not from exceptions, but an expression that returns how the...
New
WestKeys
Currently suffering from paralysis by [HTTP client] analysis. This is rather unusual in Elixirland as there tends to be consensus on the ...
New
jay1
Why is it that the mnesia database isn’t the most preferred database for use in Elixir/Phoenix?
New
magnetic
Hey :wave:t3: Elixir community, I’ve been learning Elixir, and working on some side projects. My editor of choice is VSCode, and althoug...
New

We're in Beta

About us Mission Statement