Ecto: Validating belongs_to association is not nil?

For people looking for more validation, the code below works pretty well for me.
Using this code helps when you want to deal with a new Map or an existing Struct or an existing id was given for the association…

def put_or_cast_or_constraint_assoc(changeset, name) do
  {:assoc, %{owner_key: key, related: type}} = changeset.types[name] |> IO.inspect

  if changeset.changes[key] do
    assoc_constraint(changeset, name)
  else
    case val = changeset.params[name |> Atom.to_string] do
      %correct_type{} when correct_type == type ->
        put_assoc(changeset, name, val)
      %wrong_type{} ->
        add_error(changeset, name, "Wrong struct given to #{key}", given: wrong_type, wanted: type)
      %{} ->
        cast_assoc(changeset, name, required: true)
      _ ->
        add_error(changeset, name, "No valid #{name} nor #{key}")
    end
  end
end
2 Likes

Not sure why this hasn’t been mentioned before (maybe this is a new addition in Ecto), but this seems to be the answer to this issue https://hexdocs.pm/ecto/Ecto.Changeset.html#foreign_key_constraint/3

I had the same problem recently and this thread was very helpful.

It seems that foreign_key_constraint will only work if you provide the foreign key field while cast_assoc will work if you provide a map - hence @wojtekmach’s solution to validate both cases.

For future reference, a simple use-case to illustrate how it can be done individually might be…

schema "posts" do
  field :title, :string
  has_many :comments, Comment
end

schema "comments" do
  field :text, :string
  belongs_to :post, Post
end

Inserting a comment referencing an existing post:

def changeset(comment, attrs) do
  comment
  |> cast(attrs, [:text])
  |> assoc_constraint(:post) # or foreign_key_constraint(:post_id)
  |> validate_required([:text])
end

create_comment(%{text: "Nice post!", post_id: 1 })

Inserting a comment with a post all at once:

def changeset(comment, attrs) do
  comment
  |> cast(attrs, [:text])
  |> cast_assoc(:post, required: true)
  |> validate_required([:text])
end

create_comment(%{text: "Nice post!", post: %{title: "How to..." } })

1 Like

This is apples and oranges. foreign_key_constraint just provides a changeset error message when a constraint in the database is violated - which happens if you provide a foreign key value that is not defined in the referenced table. It provides nothing in terms of casting associations, or ensuring that associations are defined.

1 Like

Inserting a comment with a post all at once:

def changeset(comment, attrs) do
  comment
  |> cast(attrs, [:text])
  |> cast_assoc(:post, required: true)
  |> validate_required([:text])
end

If I may ask (being quite new to phoenix), using cast_assoc makes the test suuuper slow (about 500ms, compared to some milliseconds without inserting this extra record). We’re inserting two records here instead of one; but still; am i right to doubt such duration is normal ?

I’ve searched a lot and it still doesn’t seem like there’s a good way to handle the case where you’re creating a child record for a has_many/belongs_to by just passing in the foreign key as an :assoc_id param?

I can’t seem to find anything that actually validates if assoc_id is nil, apart from validates_required…but the docs explicitly say don’t do that. foreign_key_constraint doesn’t do anything if the key is nil/null. cast_assoc expects that you’re modifying/creating the related record, rather just referencing it.

Is the only recommended way to go via the parent?