How to associate an existing record using belongs_to with Ecto?

Hello everyone!

Something happened at work today and it kinda got me off-guard because I was expecting different behavior from Ecto. What happened is: I was trying to insert a record with a given association that already existed in the database without casting the foreign key directly.

For example:

schema "post" do
  field :title, :string
  field :body, :string
end
schema "category" do
  field :name, :string
  field :description, :string
end
schema "category_post" do
  belongs_to :post, Post
  belongs_to :category, Category
  field :remarks, :string
end

def changeset(category_post, attrs) do
  post
  |> cast(attrs, [:remarks])
  |> cast_assoc(:post)
  |> cast_assoc(:category)
  |> validate_if_posts_has_the_right_amount_of_comments() # needs the retrieved post
  |> validate_if_category_has_the_right_amount_of_followers() # needs the retrieved category
end

And then, trying to create a “category_post” with its relationship:

def create_category_post(attrs, post_id, category_id) do
  post = Posts.get_post!(post_id)
  category = Categories.get_category!(category_id)

  attrs = Map.merge(attrs, %{category: category, post: post})
   
  %CategoryPost{}
  |> CategoryPost.changeset(attrs)
  |> Repo.insert()
end

Is this even possible? I noticed that for this case Ecto always tries to insert a new “post”/ “category” if I use cast_assoc/3.

2 Likes

If the record already exists, You should use put_assoc instead.

But for this use case, I would use build_assoc.

1 Like

Hi @kokolegorille, thanks for the reply.
Could you elaborate on why would you choose one over the other?

Do You mean cast_assoc over put_assoc, or put_assoc over build_assoc?

I would use cast_assoc in case I want to use aggregate root.
I would use put_assoc, if the association already exists.

And the last case is to use build_assoc in case I want to set … a belongs to. For example if I need to set an owner, or in your case, a category.

Sorry about the confusion. I meant build_assoc over put_assoc.
If it’s not asking too much, could you exemplify?

Instead of using a normal post, You will use…

post = build_assoc(category, :posts)

and then, pass this post to the changeset, You don’t need to cast anything, category_id will be set for You.

I see. But in this specific case, I would have to have a has_many association on the other side, right?
In my scenario, I only have the belongs_to association on the record that is currently being inserted.

PS.: I’ve updated my original post examples to better represent the challenge I’m facing.

Yes, You need to have the has_many on the other side… or cast category_id on the post side, but it’s not something I like to do when I can do with build_assoc.

Maybe You can simply pass a post where You manually set category_id, like this %Post{category_id: category.id} before passing it to changeset, but I have never tried.

Hi!
You can add a field for your FK id and simply cast that in your changeset :slight_smile:

      schema "table_table" do
        belongs_to :post,
          Post,
          [foreign_key: :post_id]

        field :related_post, :integer,
          [source: :post_id]
      end

      def changeset(schema, attrs) do
        schema
        |> cast(attrs, [:related_post])
        |> cast_assoc(:post)
      end

:smiley:

trying to update my own code but I can’t so posting it again, this is clearer:

      schema "table_table" do
        belongs_to :related_post,
          Post,
          [foreign_key: :post_id, define_field: false]

        field :post_id, :integer
          # and you can add the following to opts if you want
          # the field and the column on the DB to have 
          # different names:
          # [source: :"table_column_id"]

      end

      def changeset(schema, attrs) do
        schema
        |> cast(attrs, [:post_id])
        |> cast_assoc(:related_post)
      end
1 Like

Great trick, love it