Inserting a one to many parent and compulsory child record using changeset

Say i have a User who has many types of Address and we want to have at least one Address to consider the User as valid.

For User changeset

def changeset(user, attrs) do
    user
    |> cast(attrs, [:name])
    |> cast_assoc(:addresses, required: true)
    |> validate_required([:name])
  end

I believe i have to use an Ecto.Multi to first insert the user and then insert_all addresses after. However the changeset correctly disallows this because we don’t have at least one address yet. A chicken and egg problem it seems. I would hate to loosen the validation required: true as it would allow “illegal” users to be created.

How can one tackle such a scenario?

I would expect this to work as-is - pass the output of User.changeset above to Repo.insert and Ecto should handle inserting the child Address records as part of the same transaction.

For example

%{
  name: "Alice",
  addresses: [
    %{
      street: ""
    }
  ]
} |> User.changeset(%User{})

One will get errors: [user_id: {"can't be blank", [validation: :required]}], for addresses changeset

cast_assoc/3 will take care of adding the user_id for you if you’re creating the address as a child of a new user.

  • If the parameter does not contain an ID, the parameter data will be passed to MyApp.Address.changeset/2 with a new struct and become an insert operation

Your address changeset function is requiring the user_id, which would make sense if you were adding the address separately after creating the user, but in this case it’s causing the error at the changeset stage because the id will only be added when you run the Repo operation.

If you want to keep the behaviour of requiring a user_id for direct operations on address, create a separate changeset function for child operations and call it explicitly from the user changeset.

# in address.ex
def changeset(address, attrs) do
  cast(attrs, [:user_id, :street])
  |> other_checks()
end

def child_changeset(address, attrs) do
  cast(attrs, [:street]) 
  |> other_checks()
end

def other_checks(changeset) do
  # general validation 
end

# in user. ex
def changeset(user, attrs) do
  user
  |> cast(attrs, [:name])
  |> cast_assoc(:addresses, required: true, with: &MyApp.Address.child_changeset/2)
  |> validate_required([:name])
end

One of the problems with this is when you want to make user changes unrelated to addresses then you’d still have to preload them. Obviously the solution is to create other, specific user changesets for the different situations and use them accordingly. For example rename this one changeset_with_addresses and use it to enforce this business logic only for creating new users or bulk-updating addresses for them.

1 Like

Thank you for taking time to help me on this in detail. I think I got it :smile:

1 Like