Ecto: Validating belongs_to association is not nil?

Okay, I’m having a heck of a time trying to figure out how to best handle the validation of belongs_to associations in Ecto. I’m sure I’m spoiled by ActiveRecord, where I can just set the association to either a persisted or unpersisted object, and write a validation that ensures the child is “there”.

My example is a table/model, let’s call it Rating, and it belongs_to a Place (ie. field is place_id in the ratings table).

I figure there are three ways the set this association: One, we specify the place_id in the changeset directly. Two, we put_assoc an existing Place struct after the changeset options. Three, we have a Map with the Place parameters in the changeset under the :place key (and then use “cast_assoc”). So this is what I’ve got:

defmodule Rating do
  use MyApp.Web, :model
  schema "ratings" do
    field :rating, :integer
    belongs_to :place, MyApp.Place
    timestamps()
  end
  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:rating, :place_id])
    |> cast_assoc(:place)
    |> assoc_constraint(:place)
    |> validate_required([:rating])
  end

In a test, I have this:

changeset = Rating.changeset(%Rating{}, { rating: 5 })
refute changeset.valid?, "Expected error on place constraint"  # 1
assert {:error, problem } = Repo.insert(changeset)  # 2

The refute fails, because no error is generated. I found some notes that “valid?” may not actually do any of the database queries necessary to ensure the parent object actually exists, so I thought maybe that would happen during the actual insert(), so I commented that line out and asserted on the next. However, that fails, too, and I can see that I get back :ok as a status, and a record saved with place_id nil.

If I validate place_id is required, then I can’t make this work where I either put_assoc an existing record, or pass in a Map of parameters.

Is it not possible to set up a singular changeset function to validate the belongs_to reference in all three ways it could be passed in? If assoc_constraint isn’t checking for non-nil associations, what do I need to make that work?

…Paul

PS> The migration has “add :place_id, references(:places, on_delete: delete_all)” if that matters.

2 Likes

I think adding null: false might be what you’re looking for.

1 Like

I tried this just now (good idea, if I know I want that constraint in the database from the get-go), but the valid? call still doesn’t return a failure, and if I go for the insert, I don’t get an {:error, changeset} response, I get an exception for not_null_violation… I guess that’s an improvement, so I don’t get bad records, but I’d rather be able to handle the error like any other validation…

1 Like

Adding null: false added a not-null constraint to the place_id column. That won’t be validated until you hit the database, until you Repo.insert.

See Ecto.Changeset — Ecto v3.11.1.

Is assoc_constraint(:place) still there?

Yeah, I didn’t change the changeset stuff, just added the null constraint to the database.

I think you want the following given your schema:

def changeset(struct, params \\ %{}) do
  struct
  |> cast(params, [:rating, :place_id])
  |> validate_required([:rating]
  |> assoc_constraint(:place)
end

For a :belongs_to association, use assoc_constraint/3 for validation. It let’s Ecto check whether the Place to which the rating belongs exists. cast_assoc/3 would go on the Place schema to check Rating. Don’t use validate_required/3 to check association constraints (as instructed here).

I struggled with the different changeset validations in the same context. I outlined my findings here: Ecto Association vs Foreign Key Constraints

3 Likes

I have assoc_constraint there. It doesn’t seem to do anything if the changeset doesn’t reference the ID or the association at all, and if I put the “not null” constraint in the database, I get an exception rather than a changeset with errors set. (BTW, your findings were where I started :wink: )

1 Like

Ah, I see now. Two things:

First, I don’t think cast_assoc/3 is doing anything in your changeset and can be left out.

Second, I think you’re not seeing the test results you expect because of your assertions. To get a proper error message from assoc_constraint/3 you need to remove the association from the database and then try to insert your record.

For example (insert/1 and build/2 are ExMachina functions.)

test "changeset is invalid if place is missing" do
  place = insert(:place)
  rating_params = build(:rating, place: place)

  Repo.delete!(place)

  changeset = Rating.changeset(%Rating{}, rating_params)

  assert {:error, changeset} = Repo.insert(changeset)
  assert changeset.errors[:place] == {"does not exist", []}
end

Because assoc_constraint/3 checks the DB, you must try to insert to generate the changeset error message.

cast_assoc is there “in case” the association is passed as a Map in the params.

Your example is using a record with an existing ID (even though the ID is missing when you do the insert). So the place_id gets set to an integer that doesn’t exist in the database, and the insert then fails to find it – but if the place_id is nil, assoc_constraint doesn’t bother looking anything up. So if the params passed in don’t make reference to place_id, and doesn’t have the Map to use to create the dependency automagically, there is nothing that enforces that there is something being assigned to the association…

This may be an unfortunate chicken-and-egg problem, and maybe I just need two changesets, one where the Place is already created, and either passed as a place_id or added as a put_assoc, and another that relies on the params hash and cast_assoc

What if you just slip :place_id into validate_required/3? Then you can be assured that you’ll get a nice error message if either :place_id is null, or if the :place_id record has been deleted before Rating could be inserted (via assoc_constraint/3).

I can put it into validate_required, but then valid? fails if you’re casting the association as part of the params Map (because it won’t have done any database actions yet, the ID won’t get set). If I have two changeset functions, one for “casting the association” and one for “existing association”, I could validate the ID is set in the latter…

But I was kinda hoping to have a single changeset function that had all the validations there (for cases where there are a lot more fields with a lot more validations)…

Yeah hard to believe there isn’t a single-changeset solution for this scenario.

it’s pretty hacky, but perhaps this would work for you?

defmodule Rating do
  # ...

  def changeset(rating, params \\ %{}) do
    cast(rating, params, ~w(rating place_id))
    |> validate_required(~w(rating)a)
    |> cast_or_constraint_assoc(:place)
  end

  defp cast_or_constraint_assoc(changeset, name) do
    {:assoc, %{owner_key: key}} = changeset.types[name]
    if changeset.changes[key] do
      assoc_constraint(changeset, name)
    else
      cast_assoc(changeset, name, required: true)
    end
  end
end

I’d stick to exposing two different changeset functions though (and probably have a 3rd private changeset function that has common stuff)

7 Likes

Nice solution for keeping the shared validations all in one place. Was just hoping there was a better “best practice”. :slight_smile:

Don’t cast_assoc accept a required: true option?

2 Likes

It does, but then you can’t use the changeset by simply passing in the foreign key IDs (in this example, you couldn’t create a Rating.changeset(%Rating{}, %{rating: 5, place_id: 42}) (because this causes an error during the cast_assoc, since :place isn’t being used).

I understsand now, thank you. @wojtekmach sounds like a good way to go about this.

3 Likes

Even if we’re using wojtekmach’s solution aren’t we still missing a validation that some version of place as passed in? Either as place or place_id?

If we do want that validation along with the constraint do we have any options?

It does do that - if it is passed in as place_id it is validated with assoc_constraint; otherwise it is checked with cast_assoc required: true.

However the code as listed is really not complete because it does not handle updates to the record that do not include a change to the place; e.g. record is loaded from the db, place_id is set, but because place_id is not changed cast_assoc fails on it. In my project I’m using a slightly modified version that will pass the changeset if it contains the key (place_id) already:

    def cast_or_constraint_assoc(changeset, name) do
      {:assoc, %{owner_key: key}} = changeset.types[name]
      #assoc id was directly set? confirm its valid
      if changeset.changes[key] do
        assoc_constraint(changeset, name)
      else
        #assoc key is already present, and not changed? do nothing
        if Map.get(changeset.data, key) do
          changeset
        else
          #we need to insert a new assoc (or errors)
          cast_assoc(changeset, name, required: true)
        end
      end
    end
4 Likes

The second part of this comment, and the code was wrong…I don’t have that original project in front of me but I’ve since tried to use this code in another project and it does not work because the association doesn’t show up in the changes collection when it is just passed in update params as a map. I now think @wojtekmach original code is the best solution and I suspect the problem I had in the previous project was because I did not preload the association I was updating, or maybe I passed the name of database field rather than the name of the association, e.g. in this example it should be invoked in the changeset as |> cast_or_constraint_assoc(:place)

To save scrolling, this is the code I think is best:

 defp cast_or_constraint_assoc(changeset, name) do
    {:assoc, %{owner_key: key}} = changeset.types[name]
    if changeset.changes[key] do
      assoc_constraint(changeset, name)
    else
      cast_assoc(changeset, name, required: true)
    end
 end
1 Like