Help to persist one model nested on another, that references a 3rd one

Hi,

I’m having some trouble to persist two models related to each other that I receive from a form as nested parameters (Phoenix). The thing is that the nested model also has a relationship to another model, that needs to be supplied after the form is submitted. I read documentation and blog posts but I think something is amiss.

Child belongs to Parent and belongs to AnotherModel. Here are the schemas:

defmodule Models.Parent do
  use Ecto.Schema
  import Ecto.Changeset

  schema "parents" do
    field(:description, :string)
    has_one(:child, Models.Child)
    timestamps()
  end

  @required_fields [:description]

  def changeset(parent, params \\ :empty) do
    parent
    |> cast(params, @required_fields)
    |> validate_required(@required_fields)
    |> validate_length(:description, min: 5)
  end
end

defmodule Models.Child do
  use Ecto.Schema
  import Ecto.Changeset

  schema "child" do
    field(:name, :string)
    field(:state, :string)
    belongs_to(:parent, Models.Parent)
    belongs_to(:another_model, Models.AnotherModel)
    timestamps()
  end

  @required_fields [:name, :state]

  def changeset(child, params \\ :empty) do
    child
    |> cast(params, @required_fields)
    |> validate_required(@required_fields)
    |> validate_inclusion(:state, ~w[new old])
  end
end

defmodule Models.AnotherModel do
  use Ecto.Schema
  import Ecto.Changeset

  schema "another_model" do
    field(:name, :string)
    has_many(:child, Models.Child)
    timestamps()
  end

  @required_fields [:name]

  def changeset(another, params \\ :empty) do
    another
    |> cast(params, @required_fields)
    |> validate_required(@required_fields)
  end
end

Upon form submission, these are the parameters received:

%{
  "description" => "mmmmm",
  "child" => %{
    "name" => "some new name"
  }
}

Trying to create a Parent changeset and inserting it doesn’t work. There are no errors in Parent, but there is an error in Child - no state set and no AnotherModel set.

Question 1: Is it reasonable to set the state and AnotherModel manually on the parameters? Since I’m not creating separate changesets for Parent and Child, I don’t see another solution. Is there a better way?

Let’s assume I manually added a state and AnotherModel, so now the list of parameters is:

%{
  "description" => "mmmmm",
  "child" => %{
    "name" => "some new name",
    "state" => "manually added",
    "another_model" => %Models.AnotherModel{...}
  }
}

Now creating a Parent changeset and inserting it works. However, Child doesn’t have AnotherModel ID set. That’s because Child cast only name and state.

I can change that so to cast another_model_id as well. We get:

defmodule Models.Child do
  ...
  @required_fields [:name, :state]
  @assoc_fields [:another_model_id]

  def changeset(child, params \\ :empty) do
    child
    |> cast(params, @required_fields ++ @assoc_fields)
    |> validate_required(@required_fields)
    |> validate_inclusion(:state, ~w[new old])
  end
end

Question 2: is there a better way? This seems to me rather ugly, and now Child has one preferred relationship with Parent (that doesn’t need cast) and one non-preferred with AnotherModel (that needs cast). I could add parent_id to @assoc_fields (@assoc_fields [:parent_id, another_model_id]), but I suspect this is leading to the wrong direction.

Question 3: since I can’t manipulate the changesets of nested models (I can only add/remove values to the params), are nested parameters the way to go? Or should I rather receive Parent and Child separately, insert Parent, set its ID to Child and set an AnotherModel ID to Child, and then insert Child? This would require Ecto.Multi and some more logic for error handling.

Sorry for the long post, thank you.

Maybe What to do when you have to add a new parameter based on the result you get from a Service? would help. tl;dr I tend to avoid it.

Trying to create a Parent changeset and inserting it doesn’t work

Have you tried cast_assoc/3?

defmodule Models.Parent do
  use Ecto.Schema
  import Ecto.Changeset

  schema "parents" do
    field(:description, :string)
    has_one(:child, Models.Child)
    timestamps()
  end

  @required_fields [:description]

  def changeset(parent, params \\ :empty) do
    parent
    |> cast(params, @required_fields)
    |> cast_assoc(:child) # <---
    |> validate_required(@required_fields)
    |> validate_length(:description, min: 5)
  end
end

It would cast data for "child" params key and try and insert it into "child" table with parent’s foreign key set.

Question 2: is there a better way?

I’d definitely try to avoid casting foreign keys from outside data (params). See casting foreign keys in ecto changesets · Issue #29 · nccgroup/sobelow · GitHub. tl;dr it might make your app vulnerable.

I think all your problems should be handled by cast_assoc/3


Off-topic

defmodule Models.Child do
  ...
  @required_fields [:name, :state]
  @assoc_fields [:another_model_id]

  def changeset(child, params \\ :empty) do
    child
    |> cast(params, @required_fields ++ @assoc_fields)
    |> validate_required(@required_fields)
    |> validate_inclusion(:state, ~w[new old])
  end
end

You can concat the list of castable fields at compile time and avoid that work during run time

defmodule Models.Child do
  ...
  @required_fields [:name, :state]
  @assoc_fields [:another_model_id]
  @castable_fields @required_fields ++ @assoc_fields

  def changeset(child, params \\ :empty) do
    child
    |> cast(params, @castable_fields)
    |> validate_required(@required_fields)
    |> validate_inclusion(:state, ~w[new old])
  end
end

I don’t think :empty in params is being used anymore, it’s can replaced with an empty map.

def changeset(child, params \\ %{}) do

Hi, it seems I did a mistake on the Parent transcript, sorry about that. I should have a cast_assoc there, so the Parent changeset should be exactly as you described:

def changeset(parent, params \\ :empty) do
    parent
    |> cast(params, @required_fields)
    |> cast_assoc(:child, required: true)
    |> validate_required(@required_fields)
    |> validate_length(:description, min: 5)
  end

But the problem was correctly described, and Child will not validate because state and AnotherModel are not set.

Honestly I couldn’t figure out a way to make this work with cast_assoc/3. If this is evident to you, I’d appreciate more pointers.

About avoiding manipulating params, I agree that the best is to generate a changeset with the params and then add/remove anything from the changeset, leaving the params untouched. However, in the case of nested params, where we don’t directly interact with the nested changeset, this doesn’t seem to be possible. Or is it?

Parsing the relationship IDs is indeed insecure, and I’d gladly do these changes via a changeset. However, the wall I’m hitting is the same as above, I only manipulate the changeset of Parent, I don’t get to manipulate the changeset of Child (where the relationships are). Hence my question 3 wondering if nested parameters are a good idea at all :slight_smile:

About off topics, concatenate the lists at compile time, good idea. And I should have seen :empty in some old blog post, will change that, thank you for the tips.

I wouldn’t modify changesets after they come out of changeset/2 function either. At least I haven’t had a need for that so far.

Maybe you can set a default state on your child schema

  schema "child" do
    field(:name, :string)
    field(:state, :string, default: "new")
    belongs_to(:parent, Models.Parent)
    belongs_to(:another_model, Models.AnotherModel)
    timestamps()
  end

Then inserting a parent with

%{
  "description" => "mmmmm",
  "child" => %{
    "name" => "some new name"
  }
}

should work.

I only manipulate the changeset of Parent, I don’t get to manipulate the changeset of Child (where the relationships are)

You can manipulate changesets of Child via their own changeset/2 functions. Which is

  def changeset(child, params \\ :empty) do
    child
    |> cast(params, @required_fields)
    |> validate_required(@required_fields)
    |> validate_inclusion(:state, ~w[new old])
  end

currently.


About nested params

One approach I’ve seen some people take recently is to “flatten” the incoming params by using an embedded schema, validate these “flat” params in the changeset for the embedded schema, and then populate the database via several multis.

In you case the params would become

%{
  "parent_description" => "mmm",
  "child_name" => "some new name"
}

The embedded schema would be

defmodule SomeSchema do
  use Ecto.Schema
  import Ecto.Changeset

  embedded_schema do
    field :parent_description, :string
    field :child_name, :string,
    # other fields
  end

  @required [:parent_description, :child_name]
  @optional []
  @castable @required ++ @optional

  @spec changeset(%__MODULE__{}, %{optional(String.t | atom) => term})
  def changeset(some_schema, attrs) do
    some_schema
    |> cast(attrs, @castable)
    |> validate_required(@required)
    # etc
  end
end

Then you can Ecto.Changeset.apply_changes(some_schema_changeset) if some_schema_changeset.valid? and use that in Ecto.Multi.

1 Like