Exclusive Belongs To Association in Phoenix using Ecto
Recently, I was tasked with implementing an exclusive "belongs-to" relationship in a Phoenix web application. I came across an intriguing blog post on the subject, but it used ActiveRecord, so I had to adapt it to work with Ecto. To help others who may face a similar challenge, I have documented my process and the solution I arrived at.
The original article from hashrocket exclusive-belongs-to-aka-exclusive-arc-
Table of contents
Diagram
Migration
defmodule ExclusiveArc.Repo.Migrations.CreateAcls do use Ecto.Migration def change do create table(:acls) do add :document_id, references(:documents, on_delete: :delete_all) add :image_id, references(:images, on_delete: :delete_all) add :file_id, references(:files, on_delete: :delete_all) add :report_id, references(:reports, on_delete: :delete_all) # acl is a simple text here is not the point of the til add :level, :text, null: false timestamps() end # ensure the it's exclusive check_exclusive = """ ( (document_id IS NOT NULL)::integer + (image_id IS NOT NULL)::integer + (file_id IS NOT NULL)::integer + (report_id IS NOT NULL)::integer ) = 1 """ create constraint(:acls, :check_exclusive, check: check_exclusive) create index(:acls, [:document_id], unique: true, where: "document_id IS NOT NULL") create index(:acls, [:image_id], unique: true, where: "image_id IS NOT NULL") create index(:acls, [:file_id], unique: true, where: "file_id IS NOT NULL") create index(:acls, [:report_id], unique: true, where: "report_id IS NOT NULL") end end
Schema
defmodule ExclusiveArc.ACL do use Ecto.Schema alias Ecto.Changeset alias ExclusiveArc.Medias.{Document, Image, File, Report} schema "acls" do belongs_to :document, Document belongs_to :image, Image belongs_to :file, File belongs_to :report, Report field :level, :string timestamps() end @doc false def changeset(acl, attrs, media) do acl |> Changeset.cast(attrs, [:level]) |> Changeset.validate_required([:level]) |> assoc_media(media) # Last check if only one media was associated it's impossible to trigger # with this changeset but it will help in case of changes later in the project |> Changeset.check_constraint(:check_exclusive, name: :check_exclusive) end # Using pattern matching to associate the media and check the foreign key and unique constraint defp assoc_media(changeset, %Document{} = document) do changeset |> Changeset.put_assoc(:document, document) |> Changeset.foreign_key_constraint(:document_id) |> Changeset.unique_constraint(:document_id) end defp assoc_media(changeset, %Image{} = image) do changeset |> Changeset.put_assoc(:image, image) |> Changeset.foreign_key_constraint(:image_id) |> Changeset.unique_constraint(:image_id) end defp assoc_media(changeset, %File{} = file) do changeset |> Changeset.put_assoc(:file, file) |> Changeset.foreign_key_constraint(:file_id) |> Changeset.unique_constraint(:file_id) end defp assoc_media(changeset, %Report{} = report) do changeset |> Changeset.put_assoc(:report, report) |> Changeset.foreign_key_constraint(:report_id) |> Changeset.unique_constraint(:report_id) end end