Cristian Garcia
Cristian Garcia

Reputation: 9859

has_one without belongs_to in ecto/phoenix/elixir

Problem

I have a files table and there are many other tables that create a one-to-one association, e.g. users might have a avatar and posts might have photo.

A possible solution

A possible solution would be to create users_files and posts_files tables and use has_one :through. However, this looks excessive.

The ideal solution

The ideal solution would be to define tables like this

users
 - avatar_id

posts
 - photo_id

and have with: parameter in has_one so the schema looks like this

schema "users" do
    has_one :avatar, MyApp.FileDb, with: :avatar_id, foreign_key: :id #id is default

end

schema "posts" do
    has_one :photo, MyApp.FileDb, with: :photo_id, foreign_key: :id

end

and that way you don't need to define a belongs_to on files. Is there a similar mechanism already? What is the standard way to deal with this in Phoenix?

Upvotes: 5

Views: 4259

Answers (2)

bjunc
bjunc

Reputation: 952

I believe what's being described here is a Unidirectional One-to-One association. A files table can be used as a base table for avatars, post photos, and many others. If you make the files table the "owning" side, then you end up having an FK column for every entity that has a file association.

Unfortunately, I believe that if you put the FK on the avatar (by using belongs_to), then you can't cascade delete the file (that's reserved for the has_one side). So, if you delete the avatar, the file is now orphaned. Ideally, the avatar would not only be the owner of the association (have the FK), but also have the ability to cascade the delete.

Anyway, if you'd rather not put the FK on the files table, then you can address the file cascade by deleting the file (not the avatar). It gets the job done, but it's a little hackish feeling (mostly because you're saying an avatar "belongs" to a file, when from a domain perspective, it's really the other way around).

For instance, instead of using Repo.delete(avatar), you'd create a delete function that actually deletes the file. In that function, you can preload the file, and delete that. This will cascade the deletion of the avatar (assuming you have has_one in the file schema, which does not affect the db).

For example:

def delete(%__MODULE__{} = avatar) do
  avatar
  |> Repo.preload([:file])
  |> Map.get(:file)
  |> Repo.delete()
  |> case do
    {:ok, _file} -> {:ok, avatar}
    {:error, changeset} -> {:error, changeset}
  end
end

Worth noting, Doctrine uses terms like mappedBy and inversedBy to avoid these ORM/domain naming conflicts. Granted, it's rarely an issue in Ecto, and I think this is probably more of an exception.

Upvotes: 0

José Valim
José Valim

Reputation: 51339

You cannot get away of not having the belongs_to because that's where the foreign key is defined. You have two alternatives:

  1. Flip the relationship so both users and posts have an avatar_id and photo_id pointing to the files table

  2. Define both "users_files" and "posts_files" tables without a "files" table. "users_files" and "posts_files" will have the complete table structure which can be shared at the model level in Ecto. We actually talk about this case in Ecto docs: http://hexdocs.pm/ecto/Ecto.Schema.html#belongs_to/3 (see the polymorphic section)

Upvotes: 10

Related Questions