How to handle conditional foreign keys in Ecto? Constraint? Pattern Matching?

I am building a clone of Instagram using only Ecto and I’m not really sure how to approach this feature. I have a table for likes and the table is just foreign keys comment_id, post_id, and user_id. In this manner I can associate a like to there resource and the user. The resource id’s should be provided in an either/or manner. A like can’t be associated with both the post and a comment at the same time:

id | comment_id | post_id | user_id
1  | abc        | NULL    | 123
2  | NULL.      | xyz     | 123    

I initially added the below constraint on the table (id's are UUID and I can’t cast a UUID to BOOLEAN hence the crazy SQL). I then go about applying the check_constraint/3 and I realize I can only apply this to a single key at a time so is this really the right way to go about doing this. I then looked at using that magic sauce called pattern matching. This IMO will make the constraint irrelevant, but I’m left wondering how to handle the other cases of the pattern matching in which neither resource id is included, no user is included, or all three relations are not include. I have a rough draft below. I’m still unravelling my JavaScript brain and remapping to Elixir so forgive my ignorance if this is super easy to solve.

# Constraint in migration
create constraint(:likes, :likes_comment_or_post, check: "(COALESCE((comment_id)::CHAR::INTEGER, 0) > 0)::INTEGER  + (COALESCE((post_id)::CHAR::INTEGER, 0) > 0)::INTEGER = 1")
defmodule Ectogram.Like do
  use Ectogram.Schema
  import Ecto.Changeset
  alias Ectogram.{Comment,Post,User}

  schema "likes" do
    belongs_to :comment, Comment
    belongs_to :post, Post
    belongs_to :user, User

    timestamps()
  end

  def changeset(like, %{} = attrs) do
    like
    |> cast(attrs, ~w(user_id)a)
    |> validate_required(~w(user_id)a)
    |> add_error(:comment_id, "Must include comment_id or post_id.")
    |> add_error(:post_id, "Must include post_id or comment_id.")
  end

  def changeset(like, %{comment_id: nil, post_id: nil, user_id: _} = attrs) do
    like
    |> cast(attrs, ~w(user_id)a)
    |> validate_required(~w(user_id)a)
    |> add_error(:comment_id, "Must include comment_id or post_id.")
    |> add_error(:post_id, "Must include post_id or comment_id.")
  end

  def changeset(like, %{comment_id: _, post_id: nil, user_id: _} = attrs) do
    like
    |> cast(attrs, ~w(comment_id user_id)a)
    |> validate_required(~w(comment_id user_id)a)
    |> check_constraint(:comment_id, name: :likes_comment_or_post, message: "TODO")
    |> assoc_constraint(:comment)
    |> assoc_constraint(:user)
  end

  def changeset(like, %{comment_id: nil, post_id: _, user_id: _} = attrs) do
    like
    |> cast(attrs, ~w(post_id user_id)a)
    |> validate_required(~w(post_id user_id)a)
    |> check_constraint(:post_id, name: :likes_comment_or_post, message: "TODO")
    |> assoc_constraint(:post)
    |> assoc_constraint(:user)
  end
end

Squinting at the table this looks like a polymorphic association dressed up as single table inheritance with exclusion constraints.

Intuitively I’d say the number of post likes to comment likes will differ by some order of magnitude eventually, potentially making lookups very inefficient.

Why not split them into two tables and rely on separate index optimizations from the DB?

Hi Juan thanks for commenting, to be honest my SQL/DB skills are pretty junior and this was just the idea I came up with for the representation of likes.

If I am understanding correctly you are saying to split this up into separate tables like post_likes and comment_like for future performance issues. Would you be willing to expand on that so I can better understand how down the road the initial implementation could fall over?

Happy to. If your users are 100x more likely to like a post than a comment then when it tries to check the comment-like constraint it has to filter out all post-like rows.

2 Likes

Also in the future if you want to add extra fields that should only belong to a comment-like (for example specific metadata :person_shrugging:) it’d bloat the DB table size with many more unnecessary NILs, and you’d usually want to add extra check constraints for post-likes so they don’t populate the comment-like-specific columns.

The code can quickly spiral out of control with checks and balances, as you’ve learned so far… now imagine doing the above scenario in a couple of years time when you haven’t looked at this code for a while.

3 Likes

Thanks so much for the explanation! Marking this a solved by your advice to separate the table into two tables.

1 Like